From 994e5b2d089b9a523739e186d4a1eef9d051b937 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:02:55 +1000 Subject: [PATCH 01/25] feat!: remove EQL v2-only published packages (protect, schema, protect-dynamodb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 1 of the EQL v2 final removal (#707). Delete the closed v2-only dependency chain — @cipherstash/protect-dynamodb → @cipherstash/protect → @cipherstash/schema — and every reference to it. Nothing outside the three imported them (@cipherstash/stack depends only on the separate @cipherstash/protect-ffi). They are superseded by @cipherstash/stack: - @cipherstash/protect -> @cipherstash/stack - @cipherstash/schema -> @cipherstash/stack/schema - @cipherstash/protect-dynamodb -> @cipherstash/stack/dynamodb (encryptedDynamoDB) Existing EQL v2 ciphertext stays decryptable through @cipherstash/stack; this removes the v2 authoring/emission surface, not the read path. Reference cleanup (dangling refs that would break build/CI): - e2e/package.json @cipherstash/protect dep edge - root package.json build:js turbo filter - tests.yml protect/protect-dynamodb .env steps (would fail `touch` on gone dirs) and the bun-job test loop - rebuild-docs.yml trigger tag (@cipherstash/protect@* -> @cipherstash/stack@*) - integration-{drizzle,prisma-next,supabase}.yml packages/schema/** path filters - lint-no-hardcoded-runners allowlist entry - e2e package-managers BIN fixture (dead) + two stale source comments Changeset / RC housekeeping: - delete schema-stevec-standard-pin.md (only target was the deleted schema) - prune the three from pre.json initialVersions - add deletion-notice changeset on @cipherstash/stack + @cipherstash/nextjs Meta honesty: SECURITY.md package list, AGENTS.md Repository Layout, nextjs package description. --- .changeset/pre.json | 4 - .changeset/remove-eql-v2-packages.md | 22 + .changeset/schema-stevec-standard-pin.md | 8 - .github/workflows/integration-drizzle.yml | 2 - .github/workflows/integration-prisma-next.yml | 2 - .github/workflows/integration-supabase.yml | 2 - .github/workflows/rebuild-docs.yml | 2 +- .github/workflows/tests.yml | 36 +- AGENTS.md | 11 +- SECURITY.md | 3 - e2e/package.json | 1 - e2e/tests/package-managers.e2e.test.ts | 7 +- package.json | 2 +- packages/nextjs/package.json | 2 +- packages/protect-dynamodb/.npmignore | 5 - packages/protect-dynamodb/CHANGELOG.md | 213 -- packages/protect-dynamodb/README.md | 358 --- .../protect-dynamodb/__tests__/audit.test.ts | 316 --- .../__tests__/dynamodb.test.ts | 331 --- .../__tests__/error-codes.test.ts | 125 - .../__tests__/helpers.test.ts | 89 - packages/protect-dynamodb/package.json | 54 - packages/protect-dynamodb/src/helpers.ts | 237 -- packages/protect-dynamodb/src/index.ts | 77 - .../src/operations/base-operation.ts | 64 - .../src/operations/bulk-decrypt-models.ts | 67 - .../src/operations/bulk-encrypt-models.ts | 67 - .../src/operations/decrypt-model.ts | 66 - .../src/operations/encrypt-model.ts | 62 - .../src/operations/search-terms.ts | 77 - packages/protect-dynamodb/src/types.ts | 66 - packages/protect-dynamodb/tsconfig.json | 28 - packages/protect-dynamodb/tsup.config.ts | 8 - packages/protect/.npmignore | 5 - packages/protect/CHANGELOG.md | 439 --- packages/protect/README.md | 1102 -------- packages/protect/__tests__/audit.test.ts | 472 ---- .../protect/__tests__/basic-protect.test.ts | 44 - .../protect/__tests__/bulk-protect.test.ts | 597 ---- .../__tests__/deprecated/search-terms.test.ts | 140 - .../encrypt-query-searchable-json.test.ts | 1061 -------- .../__tests__/encrypt-query-stevec.test.ts | 392 --- .../protect/__tests__/encrypt-query.test.ts | 946 ------- .../protect/__tests__/error-codes.test.ts | 369 --- packages/protect/__tests__/fixtures/index.ts | 145 - packages/protect/__tests__/helpers.test.ts | 274 -- .../__tests__/infer-index-type.test.ts | 94 - .../protect/__tests__/json-protect.test.ts | 1217 --------- .../protect/__tests__/jsonb-helpers.test.ts | 203 -- .../protect/__tests__/k-discriminator.test.ts | 55 - packages/protect/__tests__/keysets.test.ts | 97 - .../protect/__tests__/lock-context.test.ts | 208 -- .../protect/__tests__/nested-models.test.ts | 958 ------- .../protect/__tests__/number-protect.test.ts | 823 ------ .../protect/__tests__/protect-ops.test.ts | 843 ------ .../__tests__/searchable-json-pg.test.ts | 2390 ----------------- packages/protect/__tests__/supabase.test.ts | 343 --- packages/protect/package.json | 82 - packages/protect/src/bin/runner.ts | 35 - packages/protect/src/bin/stash.ts | 502 ---- packages/protect/src/client.ts | 18 - .../protect/src/ffi/helpers/error-code.ts | 12 - .../src/ffi/helpers/infer-index-type.ts | 120 - .../protect/src/ffi/helpers/type-guards.ts | 18 - .../protect/src/ffi/helpers/validation.ts | 94 - packages/protect/src/ffi/index.ts | 428 --- packages/protect/src/ffi/model-helpers.ts | 952 ------- .../src/ffi/operations/base-operation.ts | 50 - .../src/ffi/operations/batch-encrypt-query.ts | 235 -- .../src/ffi/operations/bulk-decrypt-models.ts | 112 - .../src/ffi/operations/bulk-decrypt.ts | 178 -- .../src/ffi/operations/bulk-encrypt-models.ts | 131 - .../src/ffi/operations/bulk-encrypt.ts | 213 -- .../src/ffi/operations/decrypt-model.ts | 109 - .../protect/src/ffi/operations/decrypt.ts | 130 - .../ffi/operations/deprecated/search-terms.ts | 131 - .../src/ffi/operations/encrypt-model.ts | 128 - .../src/ffi/operations/encrypt-query.ts | 178 -- .../protect/src/ffi/operations/encrypt.ts | 158 -- packages/protect/src/helpers/index.ts | 207 -- packages/protect/src/helpers/jsonb.ts | 99 - packages/protect/src/identify/index.ts | 128 - packages/protect/src/index.ts | 180 -- packages/protect/src/stash/index.ts | 459 ---- packages/protect/src/types.ts | 241 -- packages/protect/tsconfig.json | 28 - packages/protect/tsup.config.ts | 30 - packages/schema/.npmignore | 5 - packages/schema/CHANGELOG.md | 104 - packages/schema/README.md | 307 --- packages/schema/__tests__/schema.test.ts | 146 - .../schema/__tests__/searchable-json.test.ts | 46 - packages/schema/package.json | 50 - packages/schema/src/index.ts | 378 --- packages/schema/tsconfig.json | 28 - packages/schema/tsup.config.ts | 8 - .../stack/src/encryption/helpers/index.ts | 7 +- packages/stack/src/wasm-inline.ts | 2 +- pnpm-lock.yaml | 161 -- scripts/lint-no-hardcoded-runners.mjs | 1 - 100 files changed, 35 insertions(+), 22225 deletions(-) create mode 100644 .changeset/remove-eql-v2-packages.md delete mode 100644 .changeset/schema-stevec-standard-pin.md delete mode 100644 packages/protect-dynamodb/.npmignore delete mode 100644 packages/protect-dynamodb/CHANGELOG.md delete mode 100644 packages/protect-dynamodb/README.md delete mode 100644 packages/protect-dynamodb/__tests__/audit.test.ts delete mode 100644 packages/protect-dynamodb/__tests__/dynamodb.test.ts delete mode 100644 packages/protect-dynamodb/__tests__/error-codes.test.ts delete mode 100644 packages/protect-dynamodb/__tests__/helpers.test.ts delete mode 100644 packages/protect-dynamodb/package.json delete mode 100644 packages/protect-dynamodb/src/helpers.ts delete mode 100644 packages/protect-dynamodb/src/index.ts delete mode 100644 packages/protect-dynamodb/src/operations/base-operation.ts delete mode 100644 packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts delete mode 100644 packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts delete mode 100644 packages/protect-dynamodb/src/operations/decrypt-model.ts delete mode 100644 packages/protect-dynamodb/src/operations/encrypt-model.ts delete mode 100644 packages/protect-dynamodb/src/operations/search-terms.ts delete mode 100644 packages/protect-dynamodb/src/types.ts delete mode 100644 packages/protect-dynamodb/tsconfig.json delete mode 100644 packages/protect-dynamodb/tsup.config.ts delete mode 100644 packages/protect/.npmignore delete mode 100644 packages/protect/CHANGELOG.md delete mode 100644 packages/protect/README.md delete mode 100644 packages/protect/__tests__/audit.test.ts delete mode 100644 packages/protect/__tests__/basic-protect.test.ts delete mode 100644 packages/protect/__tests__/bulk-protect.test.ts delete mode 100644 packages/protect/__tests__/deprecated/search-terms.test.ts delete mode 100644 packages/protect/__tests__/encrypt-query-searchable-json.test.ts delete mode 100644 packages/protect/__tests__/encrypt-query-stevec.test.ts delete mode 100644 packages/protect/__tests__/encrypt-query.test.ts delete mode 100644 packages/protect/__tests__/error-codes.test.ts delete mode 100644 packages/protect/__tests__/fixtures/index.ts delete mode 100644 packages/protect/__tests__/helpers.test.ts delete mode 100644 packages/protect/__tests__/infer-index-type.test.ts delete mode 100644 packages/protect/__tests__/json-protect.test.ts delete mode 100644 packages/protect/__tests__/jsonb-helpers.test.ts delete mode 100644 packages/protect/__tests__/k-discriminator.test.ts delete mode 100644 packages/protect/__tests__/keysets.test.ts delete mode 100644 packages/protect/__tests__/lock-context.test.ts delete mode 100644 packages/protect/__tests__/nested-models.test.ts delete mode 100644 packages/protect/__tests__/number-protect.test.ts delete mode 100644 packages/protect/__tests__/protect-ops.test.ts delete mode 100644 packages/protect/__tests__/searchable-json-pg.test.ts delete mode 100644 packages/protect/__tests__/supabase.test.ts delete mode 100644 packages/protect/package.json delete mode 100644 packages/protect/src/bin/runner.ts delete mode 100644 packages/protect/src/bin/stash.ts delete mode 100644 packages/protect/src/client.ts delete mode 100644 packages/protect/src/ffi/helpers/error-code.ts delete mode 100644 packages/protect/src/ffi/helpers/infer-index-type.ts delete mode 100644 packages/protect/src/ffi/helpers/type-guards.ts delete mode 100644 packages/protect/src/ffi/helpers/validation.ts delete mode 100644 packages/protect/src/ffi/index.ts delete mode 100644 packages/protect/src/ffi/model-helpers.ts delete mode 100644 packages/protect/src/ffi/operations/base-operation.ts delete mode 100644 packages/protect/src/ffi/operations/batch-encrypt-query.ts delete mode 100644 packages/protect/src/ffi/operations/bulk-decrypt-models.ts delete mode 100644 packages/protect/src/ffi/operations/bulk-decrypt.ts delete mode 100644 packages/protect/src/ffi/operations/bulk-encrypt-models.ts delete mode 100644 packages/protect/src/ffi/operations/bulk-encrypt.ts delete mode 100644 packages/protect/src/ffi/operations/decrypt-model.ts delete mode 100644 packages/protect/src/ffi/operations/decrypt.ts delete mode 100644 packages/protect/src/ffi/operations/deprecated/search-terms.ts delete mode 100644 packages/protect/src/ffi/operations/encrypt-model.ts delete mode 100644 packages/protect/src/ffi/operations/encrypt-query.ts delete mode 100644 packages/protect/src/ffi/operations/encrypt.ts delete mode 100644 packages/protect/src/helpers/index.ts delete mode 100644 packages/protect/src/helpers/jsonb.ts delete mode 100644 packages/protect/src/identify/index.ts delete mode 100644 packages/protect/src/index.ts delete mode 100644 packages/protect/src/stash/index.ts delete mode 100644 packages/protect/src/types.ts delete mode 100644 packages/protect/tsconfig.json delete mode 100644 packages/protect/tsup.config.ts delete mode 100644 packages/schema/.npmignore delete mode 100644 packages/schema/CHANGELOG.md delete mode 100644 packages/schema/README.md delete mode 100644 packages/schema/__tests__/schema.test.ts delete mode 100644 packages/schema/__tests__/searchable-json.test.ts delete mode 100644 packages/schema/package.json delete mode 100644 packages/schema/src/index.ts delete mode 100644 packages/schema/tsconfig.json delete mode 100644 packages/schema/tsup.config.ts diff --git a/.changeset/pre.json b/.changeset/pre.json index cdaa6fd7c..b874a8b82 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -11,9 +11,6 @@ "@cipherstash/migrate": "0.2.0", "@cipherstash/nextjs": "4.1.1", "@cipherstash/prisma-next": "0.3.2", - "@cipherstash/protect": "12.0.1", - "@cipherstash/protect-dynamodb": "12.0.1", - "@cipherstash/schema": "3.0.1", "@cipherstash/stack": "0.19.0", "@cipherstash/stack-drizzle": "0.0.0", "@cipherstash/stack-supabase": "0.0.0", @@ -70,7 +67,6 @@ "remove-legacy-drizzle-package", "remove-secrets-leftovers", "rename-db-install-to-eql-install", - "schema-stevec-standard-pin", "skills-eql-v3-accuracy", "skills-identity-docs-refresh", "stack-1-0-0-rc", diff --git a/.changeset/remove-eql-v2-packages.md b/.changeset/remove-eql-v2-packages.md new file mode 100644 index 000000000..3cc56bc0a --- /dev/null +++ b/.changeset/remove-eql-v2-packages.md @@ -0,0 +1,22 @@ +--- +'@cipherstash/stack': patch +'@cipherstash/nextjs': patch +--- + +Remove the EQL v2-only published packages `@cipherstash/protect`, +`@cipherstash/schema`, and `@cipherstash/protect-dynamodb` from the repository +and the release train. They formed a closed dependency chain +(`@cipherstash/protect-dynamodb` → `@cipherstash/protect` → `@cipherstash/schema`) +and are superseded by `@cipherstash/stack`: + +- `@cipherstash/protect` (core encryption) → `@cipherstash/stack`, which now + carries the encryption client directly. +- `@cipherstash/schema` (schema builders) → `@cipherstash/stack/schema`. +- `@cipherstash/protect-dynamodb` (standalone DynamoDB adapter) → + `@cipherstash/stack/dynamodb` (`encryptedDynamoDB`), the maintained + implementation. + +Already-published versions remain installable from npm; the git history +preserves the source for any emergency maintenance. Existing EQL v2 ciphertext +stays decryptable through `@cipherstash/stack` — this removes the v2 authoring +and emission surface, not the read path. diff --git a/.changeset/schema-stevec-standard-pin.md b/.changeset/schema-stevec-standard-pin.md deleted file mode 100644 index d96bf3ca6..000000000 --- a/.changeset/schema-stevec-standard-pin.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@cipherstash/schema': patch ---- - -`searchableJson()` now pins the SteVec encoding mode to `standard` explicitly. -protect-ffi 0.29 flipped the library default to `compat` (the EQL v3 -encoding); pinning keeps the v2 wire format byte-stable so existing encrypted -JSON columns stay queryable and comparable. diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index ef8a42adc..7dd8310ba 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -23,7 +23,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' # The WASM family suite (integration/wasm/**) exercises this entry: - 'packages/stack/src/wasm-inline.ts' @@ -46,7 +45,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' # The WASM family suite (integration/wasm/**) exercises this entry: - 'packages/stack/src/wasm-inline.ts' diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml index f3b128e01..acfb99b64 100644 --- a/.github/workflows/integration-prisma-next.yml +++ b/.github/workflows/integration-prisma-next.yml @@ -25,7 +25,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -45,7 +44,6 @@ on: # suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 7ab83ca46..98b281655 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -19,7 +19,6 @@ on: # trigger the live suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' @@ -38,7 +37,6 @@ on: # trigger the live suite that would catch it. - 'packages/stack/src/encryption/**' - 'packages/stack/src/schema/**' - - 'packages/schema/**' - 'packages/stack/integration/**' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' diff --git a/.github/workflows/rebuild-docs.yml b/.github/workflows/rebuild-docs.yml index 80b320950..858732977 100644 --- a/.github/workflows/rebuild-docs.yml +++ b/.github/workflows/rebuild-docs.yml @@ -3,7 +3,7 @@ name: Rebuild Docs on: push: tags: - - '@cipherstash/protect@*' + - '@cipherstash/stack@*' jobs: trigger-docs-rebuild: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5b7ad22d5..8ddc8f406 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -142,15 +142,6 @@ jobs: - name: Test — lint script self-tests run: pnpm run test:scripts - - name: Create .env file in ./packages/protect/ - run: | - touch ./packages/protect/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env - echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env - - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env @@ -160,14 +151,6 @@ jobs: echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env - - name: Create .env file in ./packages/protect-dynamodb/ - run: | - touch ./packages/protect-dynamodb/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - # Run TurboRepo tests - name: Run tests run: pnpm run test @@ -353,15 +336,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Create .env file in ./packages/protect/ - run: | - touch ./packages/protect/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect/.env - echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/protect/.env - - name: Create .env file in ./packages/stack/ run: | touch ./packages/stack/.env @@ -371,21 +345,13 @@ jobs: echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/stack/.env echo "DATABASE_URL=postgres://cipherstash:password@localhost:5432/cipherstash" >> ./packages/stack/.env - - name: Create .env file in ./packages/protect-dynamodb/ - run: | - touch ./packages/protect-dynamodb/.env - echo "CS_WORKSPACE_CRN=${{ vars.CS_WORKSPACE_CRN }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ID=${{ vars.CS_CLIENT_ID }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_KEY=${{ secrets.CS_CLIENT_KEY }}" >> ./packages/protect-dynamodb/.env - echo "CS_CLIENT_ACCESS_KEY=${{ secrets.CS_CLIENT_ACCESS_KEY }}" >> ./packages/protect-dynamodb/.env - # Build with Node (turbo/tsup need Node), then run tests with Bun - name: Build packages run: pnpm turbo build --filter './packages/*' - name: Run tests with Bun run: | - for dir in packages/schema packages/protect packages/stack packages/protect-dynamodb packages/stack-forge; do + for dir in packages/stack packages/stack-forge; do if [ -f "$dir/vitest.config.ts" ] || [ -f "$dir/package.json" ]; then echo "--- Testing $dir ---" (cd "$dir" && bunx --bun vitest run) || true diff --git a/AGENTS.md b/AGENTS.md index 69ca55084..1ae85fb3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,20 +73,13 @@ If these variables are missing, tests that require live encryption will fail or - `packages/stack`: Main package (`@cipherstash/stack`) containing the encryption client and all integrations - Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/client`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline` (the Drizzle and Supabase integrations moved to their own packages — see below) -- `packages/protect`: Core encryption library (internal, re-exported via `@cipherstash/stack`) - - `src/index.ts`: Public API (`Encryption`, exports) - - `src/ffi/index.ts`: `EncryptionClient` implementation, bridges to `@cipherstash/protect-ffi` - - `src/ffi/operations/*`: Encrypt/decrypt/model/bulk/query operations (thenable pattern with optional `.withLockContext()`) - - `__tests__/*`: End-to-end and API contract tests (Vitest) - `packages/cli`: The `stash` CLI — auth, init, encryption schema, and database setup (`stash eql install`). Has its own `AGENTS.md`. - `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`) - `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state - `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. **EQL v3 only**: per-domain constructors (`cipherstash.TextSearch()` / `text()` / `bigIntOrd()` / …) and `cipherstashFromStack` (the `./v3` and `./stack` entries). The EQL v2 surface was removed — the adapter's baseline migration installs the EQL v3 bundle only (works on Supabase as a non-superuser) - `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`. - `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`. -- `packages/schema`: Schema builder utilities and types (`encryptedTable`, `encryptedColumn`, `encryptedField`) - `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export) -- `packages/protect-dynamodb`: DynamoDB helpers (`protectDynamoDB`), built on `@cipherstash/protect`. A fork of the shipping adapter, kept for existing consumers of the standalone package; EQL v2 only. The maintained implementation is `packages/stack/src/dynamodb` (`encryptedDynamoDB`) - `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`) - `packages/bench`: Performance / index-engagement benchmarks (private, not published) - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) @@ -224,11 +217,11 @@ pnpm changeset:publish ## Adding Features Safely (LLM checklist) 1. Identify the target package(s) in `packages/*` and confirm whether changes affect public APIs or payload shapes. -2. If modifying `packages/protect` operations or `EncryptionClient`, ensure: +2. If modifying `packages/stack` encryption operations or `EncryptionClient`, ensure: - The Result contract and error type strings remain stable. - `.withLockContext()` remains available for affected operations. - ESM/CJS exports continue to work (don't break `require`). -3. If changing schema behavior (`packages/schema`), update type definitions and ensure validation still works in `EncryptionClient.init`. +3. If changing schema behavior (`packages/stack` schema builders, `@cipherstash/stack/schema`), update type definitions and ensure validation still works in `EncryptionClient.init`. 4. Add/extend tests in the same package. For features that require live credentials, guard with env checks or provide mock-friendly paths. 5. Run: - `pnpm run code:fix` diff --git a/SECURITY.md b/SECURITY.md index 85fd4e22b..47b725910 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,10 +11,7 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It | ------- | ----------- | | `@cipherstash/stack` | Main package: encryption client, schema, EQL v3 typed client | | `stash` | CipherStash CLI | -| `@cipherstash/protect` | Core encryption library (re-exported via `@cipherstash/stack`) | -| `@cipherstash/schema` | Schema builder utilities | | `@cipherstash/nextjs` | Next.js helpers | -| `@cipherstash/protect-dynamodb` | DynamoDB helpers | | `@cipherstash/migrate` | Plaintext-to-encrypted column migration tooling | | `@cipherstash/prisma-next` | Prisma Next integration (searchable field-level encryption for Postgres) | | `@cipherstash/stack-drizzle` | Drizzle ORM integration for `@cipherstash/stack` (EQL v2 + v3) | diff --git a/e2e/package.json b/e2e/package.json index 7fd7205eb..06cf30efb 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,6 @@ }, "dependencies": { "stash": "workspace:*", - "@cipherstash/protect": "workspace:*", "@cipherstash/stack": "workspace:*", "@cipherstash/wizard": "workspace:*" }, diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts index cf5e0951f..bfe42de02 100644 --- a/e2e/tests/package-managers.e2e.test.ts +++ b/e2e/tests/package-managers.e2e.test.ts @@ -25,10 +25,9 @@ const RUNNER: Record = { const BIN = { cli: resolve(REPO_ROOT, 'packages/cli/dist/bin/stash.js'), wizard: resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js'), - protect: resolve(REPO_ROOT, 'packages/protect/dist/bin/stash.js'), - // The legacy @cipherstash/drizzle `generate-eql-migration` bin is gone with - // the package (protect sunsets at 1.0; @cipherstash/stack-drizzle is the - // successor). + // The legacy @cipherstash/protect `stash` bin and the @cipherstash/drizzle + // `generate-eql-migration` bin are both gone with their packages + // (@cipherstash/stack and @cipherstash/stack-drizzle are the successors). } as const const UA: Record = { diff --git a/package.json b/package.json index 75cbff39a..c0e4cd9b2 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "license": "MIT", "scripts": { "build": "turbo build --filter './packages/*'", - "build:js": "turbo build --filter './packages/protect' --filter './packages/nextjs'", + "build:js": "turbo build --filter './packages/nextjs'", "changeset": "changeset", "changeset:version": "changeset version", "changeset:publish": "changeset publish", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index c318db4c1..2d7deebad 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,7 +1,7 @@ { "name": "@cipherstash/nextjs", "version": "4.1.1", - "description": "Nextjs package for use with @cipherstash/protect", + "description": "Nextjs package for use with @cipherstash/stack", "keywords": [ "encrypted", "typescript", diff --git a/packages/protect-dynamodb/.npmignore b/packages/protect-dynamodb/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/protect-dynamodb/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/protect-dynamodb/CHANGELOG.md b/packages/protect-dynamodb/CHANGELOG.md deleted file mode 100644 index 6be07a477..000000000 --- a/packages/protect-dynamodb/CHANGELOG.md +++ /dev/null @@ -1,213 +0,0 @@ -# @cipherstash/protect-dynamodb - -## 12.0.2-rc.0 - -### Patch Changes - -- @cipherstash/protect@12.0.2-rc.0 - -## 12.0.1 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. -- Updated dependencies [aa9c4b1] - - @cipherstash/protect@12.0.1 - -## 12.0.0 - -### Patch Changes - -- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`. - - Breaking upstream changes adopted in this release: - - - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code. - - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`. - - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK. - - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)). - - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases. - - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns. - - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields. - - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update. - -- Updated dependencies [f743fcc] - - @cipherstash/protect@12.0.0 - -## 11.0.2 - -### Patch Changes - -- Updated dependencies [a8dbb65] - - @cipherstash/protect@11.1.2 - -## 11.0.1 - -### Patch Changes - -- Updated dependencies [afe6810] - - @cipherstash/protect@11.1.1 - -## 11.0.0 - -### Patch Changes - -- Updated dependencies [068f820] - - @cipherstash/protect@11.1.0 - -## 10.0.0 - -### Patch Changes - -- Updated dependencies [b0e56b8] - - @cipherstash/protect@10.6.0 - -## 9.0.0 - -### Patch Changes - -- Updated dependencies [db72e2c] -- Updated dependencies [e769740] - - @cipherstash/protect@10.5.0 - -## 8.0.0 - -### Patch Changes - -- Updated dependencies [9ccaf68] - - @cipherstash/protect@10.4.0 - -## 7.0.0 - -### Patch Changes - -- Updated dependencies [a1fce2b] -- Updated dependencies [622b684] - - @cipherstash/protect@10.3.0 - -## 6.0.1 - -### Patch Changes - -- @cipherstash/protect@10.2.1 - -## 6.0.0 - -### Patch Changes - -- Updated dependencies [de029de] - - @cipherstash/protect@10.2.0 - -## 5.1.1 - -### Patch Changes - -- Updated dependencies [ff4421f] - - @cipherstash/protect@10.1.1 - -## 5.1.0 - -### Patch Changes - -- Updated dependencies [6b87c17] - - @cipherstash/protect@10.1.0 - -## 5.0.2 - -### Patch Changes - -- @cipherstash/protect@10.0.2 - -## 5.0.1 - -### Patch Changes - -- @cipherstash/protect@10.0.1 - -## 5.0.0 - -### Major Changes - -- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. - - - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. - - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) - - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - - Add comprehensive test suites for JSON, integer, and basic encryption - - Update encryption format to use 'k' property for searchable JSON - - Remove deprecated search terms tests for JSON fields - - Simplify schema data types to text, int, json only - - Update model helpers to handle new encryption format - - Fix type safety issues in bulk operations and model encryption - -### Patch Changes - -- Updated dependencies [788dbfc] - - @cipherstash/protect@10.0.0 - -## 4.0.0 - -### Patch Changes - -- Updated dependencies [c7ed7ab] -- Updated dependencies [211e979] - - @cipherstash/protect@9.6.0 - -## 3.0.0 - -### Minor Changes - -- 6f45b02: Fully implemented audit metadata functionality. - -### Patch Changes - -- Updated dependencies [6f45b02] - - @cipherstash/protect@9.5.0 - -## 2.0.1 - -### Patch Changes - -- @cipherstash/protect@9.4.1 - -## 2.0.0 - -### Patch Changes - -- Updated dependencies [1cc4772] - - @cipherstash/protect@9.4.0 - -## 1.0.0 - -### Minor Changes - -- 01fed9e: Added audit support for all protect and protect-dynamodb interfaces. - -### Patch Changes - -- Updated dependencies [01fed9e] - - @cipherstash/protect@9.3.0 - -## 0.3.0 - -### Minor Changes - -- 2b63ee1: Support nested protect schema in dynamodb helper functions. -- e33fbaf: Fixed bug when handling schema definitions without an equality flag. - -## 0.2.0 - -### Minor Changes - -- 5fc0150: Fix build and publish. - -## 1.0.0 - -### Minor Changes - -- c8468ee: Released initial version of the DynamoDB helper interface. - -### Patch Changes - -- Updated dependencies [c8468ee] - - @cipherstash/protect@9.1.0 diff --git a/packages/protect-dynamodb/README.md b/packages/protect-dynamodb/README.md deleted file mode 100644 index 1489f336a..000000000 --- a/packages/protect-dynamodb/README.md +++ /dev/null @@ -1,358 +0,0 @@ -# CipherStash DynamoDB Helpers - -Helpers for using [CipherStash encryption](https://github.com/cipherstash/stack) with DynamoDB. - -> [!TIP] -> For new projects we recommend [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack), which provides this functionality as `encryptedDynamoDB` from `@cipherstash/stack/dynamodb`. See the [DynamoDB docs](https://cipherstash.com/docs/stack/cipherstash/encryption/dynamodb). This package documents the legacy `@cipherstash/protect` API. - -[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) -[![NPM version](https://img.shields.io/npm/v/@cipherstash/protect-dynamodb.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/protect-dynamodb) -[![License](https://img.shields.io/npm/l/@cipherstash/protect-dynamodb.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/stack/blob/main/LICENSE.md) - -## Installation - -```bash -npm install @cipherstash/protect-dynamodb -# or -yarn add @cipherstash/protect-dynamodb -# or -pnpm add @cipherstash/protect-dynamodb -``` - -## Quick Start - -```typescript -import { protectDynamoDB } from '@cipherstash/protect-dynamodb' -import { protect, csColumn, csTable } from '@cipherstash/protect' -import { PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb' - -// Define your protected table schema -const users = csTable('users', { - email: csColumn('email').equality(), -}) - -// Initialize the Protect client -const protectClient = await protect({ - schemas: [users], -}) - -// Create the DynamoDB helper instance -const protectDynamo = protectDynamoDB({ - protectClient, -}) - -// Encrypt and store a user -const user = { - email: 'user@example.com', -} - -const encryptResult = await protectDynamo.encryptModel(user, users) -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} - -// Store in DynamoDB -await docClient.send(new PutCommand({ - TableName: 'Users', - Item: encryptResult.data, -})) - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -// Query using the search term -const [emailHmac] = searchTermsResult.data -const result = await docClient.send(new GetCommand({ - TableName: 'Users', - Key: { email__hmac: emailHmac }, -})) - -if (!result.Item) { - throw new Error('Item not found') -} - -// Decrypt the result -const decryptResult = await protectDynamo.decryptModel( - result.Item, - users, -) - -if (decryptResult.failure) { - throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) -} - -const decryptedUser = decryptResult.data -``` - -## Features - -### Encryption and Decryption - -The package provides methods to encrypt and decrypt data for DynamoDB: - -- `encryptModel`: Encrypts a single model -- `bulkEncryptModels`: Encrypts multiple models in bulk -- `decryptModel`: Decrypts a single model -- `bulkDecryptModels`: Decrypts multiple models in bulk - -All methods return a `Result` type that must be checked for failures: - -```typescript -const result = await protectDynamo.encryptModel(user, users) -if (result.failure) { - // Handle error - console.error(result.failure.message) -} else { - // Use encrypted data - const encryptedData = result.data -} -``` - -### Search Terms - -Create search terms for querying encrypted data: - -- `createSearchTerms`: Creates search terms for one or more columns - -```typescript -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### DynamoDB Integration - -The package automatically handles: -- Converting encrypted data to DynamoDB's format -- Adding HMAC attributes for searchable fields -- Preserving unencrypted fields -- Converting DynamoDB items back to encrypted format for decryption - -## Usage Patterns - -### Simple Table with Encrypted Fields - -```typescript -const users = csTable('users', { - email: csColumn('email').equality(), -}) - -// Encrypt and store -const encryptResult = await protectDynamo.encryptModel({ - pk: 'user#1', - email: 'user@example.com', -}, users) - -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} - -// Query using search terms -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} -``` - -### Encrypted Partition Key - -```typescript -// Table with encrypted partition key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'email__hmac', - KeyType: 'HASH', - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### Encrypted Sort Key - -```typescript -// Table with encrypted sort key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'pk', - AttributeType: 'S', - }, - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'pk', - KeyType: 'HASH', - }, - { - AttributeName: 'email__hmac', - KeyType: 'RANGE', - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -### Global Secondary Index with Encrypted Key - -```typescript -// Table with GSI using encrypted key -const table = { - TableName: 'Users', - AttributeDefinitions: [ - { - AttributeName: 'pk', - AttributeType: 'S', - }, - { - AttributeName: 'email__hmac', - AttributeType: 'S', - }, - ], - KeySchema: [ - { - AttributeName: 'pk', - KeyType: 'HASH', - }, - ], - GlobalSecondaryIndexes: [ - { - IndexName: 'EmailIndex', - KeySchema: [ - { - AttributeName: 'email__hmac', - KeyType: 'HASH', - }, - ], - Projection: { - ProjectionType: 'INCLUDE', - NonKeyAttributes: ['email__source'], - }, - }, - ], -} - -// Create search terms for querying -const searchTermsResult = await protectDynamo.createSearchTerms([ - { - value: 'user@example.com', - column: users.email, - table: users, - }, -]) - -if (searchTermsResult.failure) { - throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`) -} - -const [emailHmac] = searchTermsResult.data -``` - -## Error Handling - -All methods return a `Result` type from `@byteslice/result` that must be checked for failures: - -```typescript -const result = await protectDynamo.encryptModel(user, users) - -if (result.failure) { - // Handle error - console.error(result.failure.message) -} else { - // Use encrypted data - const encryptedData = result.data -} -``` - -## Type Safety - -The package is fully typed and supports TypeScript: - -```typescript -type User = { - pk: string - email: string -} - -// Type-safe encryption -const encryptResult = await protectDynamo.encryptModel(user, users) -if (encryptResult.failure) { - throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`) -} -const encryptedUser = encryptResult.data - -// Type-safe decryption -const decryptResult = await protectDynamo.decryptModel(item, users) -if (decryptResult.failure) { - throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`) -} -const decryptedUser = decryptResult.data -``` diff --git a/packages/protect-dynamodb/__tests__/audit.test.ts b/packages/protect-dynamodb/__tests__/audit.test.ts deleted file mode 100644 index 619ff8754..000000000 --- a/packages/protect-dynamodb/__tests__/audit.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue, protect } from '@cipherstash/protect' -import { beforeAll, describe, expect, it } from 'vitest' -import { protectDynamoDB } from '../src' - -const schema = csTable('dynamo_cipherstash_test', { - email: csColumn('email').equality(), - firstName: csColumn('firstName').equality(), - lastName: csColumn('lastName').equality(), - phoneNumber: csColumn('phoneNumber'), - example: { - protected: csValue('example.protected'), - deep: { - protected: csValue('example.deep.protected'), - }, - }, -}) - -describe('protect dynamodb helpers', () => { - let protectClient: Awaited> - let protectDynamo: ReturnType - - beforeAll(async () => { - protectClient = await protect({ - schemas: [schema], - }) - - protectDynamo = protectDynamoDB({ - protectClient, - }) - }) - - it('should encrypt columns', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - address: '123 Main Street', - createdAt: '2024-08-15T22:14:49.948Z', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - companyName: 'Acme Corp', - batteryBrands: ['Brand1', 'Brand2'], - metadata: { role: 'admin' }, - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema).audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - expect(encryptedData.example).toHaveProperty('protected__source') - expect(encryptedData.example.deep).toHaveProperty('protected__source') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.address).toBe('123 Main Street') - expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z') - expect(encryptedData.companyName).toBe('Acme Corp') - expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2']) - expect(encryptedData.example.notProtected).toBe('I am not protected') - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - expect(encryptedData.metadata).toEqual({ role: 'admin' }) - }) - - it('should handle null and undefined values', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: null, - firstName: undefined, - lastName: 'Smith', - phoneNumber: null, - metadata: { role: null }, - example: { - protected: null, - notProtected: 'I am not protected', - deep: { - protected: undefined, - notProtected: 'deep not protected', - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema).audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify null/undefined equality columns are handled - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.phoneNumber).toBeNull() - expect(encryptedData.email).toBeNull() - expect(encryptedData.firstName).toBeUndefined() - expect(encryptedData.metadata).toEqual({ role: null }) - expect(encryptedData.example.protected).toBeNull() - expect(encryptedData.example.deep.protected).toBeUndefined() - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - }) - - it('should handle empty strings and special characters', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: '', - firstName: 'John!@#$%^&*()', - lastName: 'Smith ', - phoneNumber: '', - metadata: { role: 'admin!@#$%^&*()' }, - } - - const result = await protectDynamo.encryptModel(testData, schema).audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' }) - }) - - it('should handle bulk encryption', async () => { - const testData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - }, - ] - - const result = await protectDynamo - .bulkEncryptModels(testData, schema) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'dynamo_bulk_encrypt_models', - }, - }) - if (result.failure) { - throw new Error(`Bulk encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify both items are encrypted - expect(encryptedData).toHaveLength(2) - - // Verify first item - expect(encryptedData[0]).toHaveProperty('email__source') - expect(encryptedData[0]).toHaveProperty('email__hmac') - expect(encryptedData[0]).toHaveProperty('firstName__source') - expect(encryptedData[0]).toHaveProperty('firstName__hmac') - expect(encryptedData[0]).toHaveProperty('lastName__source') - expect(encryptedData[0]).toHaveProperty('lastName__hmac') - expect(encryptedData[0]).toHaveProperty('phoneNumber__source') - - // Verify second item - expect(encryptedData[1]).toHaveProperty('email__source') - expect(encryptedData[1]).toHaveProperty('email__hmac') - expect(encryptedData[1]).toHaveProperty('firstName__source') - expect(encryptedData[1]).toHaveProperty('firstName__hmac') - expect(encryptedData[1]).toHaveProperty('lastName__source') - expect(encryptedData[1]).toHaveProperty('lastName__hmac') - expect(encryptedData[1]).toHaveProperty('phoneNumber__source') - }) - - it('should handle decryption of encrypted data', async () => { - const originalData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - } - - // First encrypt - const encryptResult = await protectDynamo - .encryptModel(originalData, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' }, - }) - - if (encryptResult.failure) { - throw new Error(`Encryption failed: ${encryptResult.failure.message}`) - } - - // Then decrypt - const decryptResult = await protectDynamo - .decryptModel(encryptResult.data, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_decrypt_model' }, - }) - if (decryptResult.failure) { - throw new Error(`Decryption failed: ${decryptResult.failure.message}`) - } - - const decryptedData = decryptResult.data - - // Verify all fields match original data - expect(decryptedData).toEqual(originalData) - }) - - it('should handle decryption of bulk encrypted data', async () => { - const originalData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - }, - ] - - // First encrypt - const encryptResult = await protectDynamo - .bulkEncryptModels(originalData, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_encrypt_models' }, - }) - if (encryptResult.failure) { - throw new Error( - `Bulk encryption failed: ${encryptResult.failure.message}`, - ) - } - - // Then decrypt - const decryptResult = await protectDynamo - .bulkDecryptModels(encryptResult.data, schema) - .audit({ - metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_decrypt_models' }, - }) - if (decryptResult.failure) { - throw new Error( - `Bulk decryption failed: ${decryptResult.failure.message}`, - ) - } - - const decryptedData = decryptResult.data - - // Verify all items match original data - expect(decryptedData).toEqual(originalData) - }) -}) diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts deleted file mode 100644 index 0b8ee27af..000000000 --- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue, protect } from '@cipherstash/protect' -import { beforeAll, describe, expect, it } from 'vitest' -import { protectDynamoDB } from '../src' - -const schema = csTable('dynamo_cipherstash_test', { - email: csColumn('email').equality(), - firstName: csColumn('firstName').equality(), - lastName: csColumn('lastName').equality(), - phoneNumber: csColumn('phoneNumber'), - json: csColumn('json').dataType('json'), - jsonSearchable: csColumn('jsonSearchable').dataType('json'), - //.searchableJson('users/jsonSearchable'), - example: { - protected: csValue('example.protected'), - deep: { - protected: csValue('example.deep.protected'), - protectNestedJson: csValue('example.deep.protectNestedJson').dataType( - 'json', - ), - }, - }, -}) - -describe('protect dynamodb helpers', () => { - let protectClient: Awaited> - let protectDynamo: ReturnType - - beforeAll(async () => { - protectClient = await protect({ - schemas: [schema], - }) - - protectDynamo = protectDynamoDB({ - protectClient, - }) - }) - - it('should encrypt and decrypt a model', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - address: '123 Main Street', - createdAt: '2024-08-15T22:14:49.948Z', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - json: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - jsonSearchable: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - companyName: 'Acme Corp', - batteryBrands: ['Brand1', 'Brand2'], - metadata: { role: 'admin' }, - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - protectNestedJson: { - hello: 'world', - }, - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - expect(encryptedData.example).toHaveProperty('protected__source') - expect(encryptedData.example.deep).toHaveProperty('protected__source') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.address).toBe('123 Main Street') - expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z') - expect(encryptedData.companyName).toBe('Acme Corp') - expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2']) - expect(encryptedData.example.notProtected).toBe('I am not protected') - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - expect(encryptedData.metadata).toEqual({ role: 'admin' }) - - const decryptResult = await protectDynamo.decryptModel( - encryptedData, - schema, - ) - if (decryptResult.failure) { - throw new Error(`Decryption failed: ${decryptResult.failure.message}`) - } - - expect(decryptResult.data).toEqual(testData) - }) - - it('should handle null and undefined values', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: null, - firstName: undefined, - lastName: 'Smith', - phoneNumber: null, - metadata: { role: null }, - example: { - protected: null, - notProtected: 'I am not protected', - deep: { - protected: undefined, - notProtected: 'deep not protected', - }, - }, - } - - const result = await protectDynamo.encryptModel(testData, schema) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify null/undefined equality columns are handled - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.phoneNumber).toBeNull() - expect(encryptedData.email).toBeNull() - expect(encryptedData.firstName).toBeUndefined() - expect(encryptedData.metadata).toEqual({ role: null }) - expect(encryptedData.example.protected).toBeNull() - expect(encryptedData.example.deep.protected).toBeUndefined() - expect(encryptedData.example.deep.notProtected).toBe('deep not protected') - }) - - it('should handle empty strings and special characters', async () => { - const testData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: '', - firstName: 'John!@#$%^&*()', - lastName: 'Smith ', - phoneNumber: '', - metadata: { role: 'admin!@#$%^&*()' }, - } - - const result = await protectDynamo.encryptModel(testData, schema) - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify equality columns are encrypted - expect(encryptedData).toHaveProperty('email__source') - expect(encryptedData).toHaveProperty('email__hmac') - expect(encryptedData).toHaveProperty('firstName__source') - expect(encryptedData).toHaveProperty('firstName__hmac') - expect(encryptedData).toHaveProperty('lastName__source') - expect(encryptedData).toHaveProperty('lastName__hmac') - expect(encryptedData).toHaveProperty('phoneNumber__source') - expect(encryptedData).not.toHaveProperty('phoneNumber__hmac') - - // Verify other fields remain unchanged - expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX') - expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' }) - }) - - it('should handle bulk encryption', async () => { - const testData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - }, - ] - - const result = await protectDynamo.bulkEncryptModels(testData, schema) - if (result.failure) { - throw new Error(`Bulk encryption failed: ${result.failure.message}`) - } - - const encryptedData = result.data - - // Verify both items are encrypted - expect(encryptedData).toHaveLength(2) - - // Verify first item - expect(encryptedData[0]).toHaveProperty('email__source') - expect(encryptedData[0]).toHaveProperty('email__hmac') - expect(encryptedData[0]).toHaveProperty('firstName__source') - expect(encryptedData[0]).toHaveProperty('firstName__hmac') - expect(encryptedData[0]).toHaveProperty('lastName__source') - expect(encryptedData[0]).toHaveProperty('lastName__hmac') - expect(encryptedData[0]).toHaveProperty('phoneNumber__source') - - // Verify second item - expect(encryptedData[1]).toHaveProperty('email__source') - expect(encryptedData[1]).toHaveProperty('email__hmac') - expect(encryptedData[1]).toHaveProperty('firstName__source') - expect(encryptedData[1]).toHaveProperty('firstName__hmac') - expect(encryptedData[1]).toHaveProperty('lastName__source') - expect(encryptedData[1]).toHaveProperty('lastName__hmac') - expect(encryptedData[1]).toHaveProperty('phoneNumber__source') - }) - - it('should handle decryption of encrypted data', async () => { - const originalData = { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test.user@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - } - - // First encrypt - const encryptResult = await protectDynamo.encryptModel(originalData, schema) - - if (encryptResult.failure) { - throw new Error(`Encryption failed: ${encryptResult.failure.message}`) - } - - // Then decrypt - const decryptResult = await protectDynamo.decryptModel( - encryptResult.data, - schema, - ) - if (decryptResult.failure) { - throw new Error(`Decryption failed: ${decryptResult.failure.message}`) - } - - const decryptedData = decryptResult.data - - // Verify all fields match original data - expect(decryptedData).toEqual(originalData) - }) - - it('should handle decryption of bulk encrypted data', async () => { - const originalData = [ - { - id: '01ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test1@example.com', - firstName: 'John', - lastName: 'Smith', - phoneNumber: '555-555-5555', - }, - { - id: '02ABCDEFGHIJKLMNOPQRSTUVWX', - email: 'test2@example.com', - firstName: 'Jane', - lastName: 'Doe', - phoneNumber: '555-555-5556', - example: { - protected: 'hello world', - notProtected: 'I am not protected', - deep: { - protected: 'deep protected', - notProtected: 'deep not protected', - }, - }, - }, - ] - - // First encrypt - const encryptResult = await protectDynamo.bulkEncryptModels( - originalData, - schema, - ) - if (encryptResult.failure) { - throw new Error( - `Bulk encryption failed: ${encryptResult.failure.message}`, - ) - } - - // Then decrypt - const decryptResult = await protectDynamo.bulkDecryptModels( - encryptResult.data, - schema, - ) - if (decryptResult.failure) { - throw new Error( - `Bulk decryption failed: ${decryptResult.failure.message}`, - ) - } - - const decryptedData = decryptResult.data - - // Verify all items match original data - expect(decryptedData).toEqual(originalData) - }) -}) diff --git a/packages/protect-dynamodb/__tests__/error-codes.test.ts b/packages/protect-dynamodb/__tests__/error-codes.test.ts deleted file mode 100644 index 918331c95..000000000 --- a/packages/protect-dynamodb/__tests__/error-codes.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import 'dotenv/config' -import type { ProtectClient } from '@cipherstash/protect' -import { - csColumn, - csTable, - FfiProtectError, - protect, -} from '@cipherstash/protect' -import { beforeAll, describe, expect, it } from 'vitest' -import { protectDynamoDB } from '../src' -import type { ProtectDynamoDBError } from '../src/types' - -const FFI_TEST_TIMEOUT = 30_000 - -describe('ProtectDynamoDB Error Code Preservation', () => { - let protectClient: ProtectClient - let protectDynamo: ReturnType - - const testSchema = csTable('test_table', { - email: csColumn('email').equality(), - }) - - const badSchema = csTable('test_table', { - nonexistent: csColumn('nonexistent_column'), - }) - - beforeAll(async () => { - protectClient = await protect({ schemas: [testSchema] }) - protectDynamo = protectDynamoDB({ protectClient }) - }) - - describe('handleError FFI error code extraction', () => { - it('FfiProtectError has code property accessible', () => { - const ffiError = new FfiProtectError({ - code: 'UNKNOWN_COLUMN', - message: 'Test error', - }) - expect(ffiError.code).toBe('UNKNOWN_COLUMN') - expect(ffiError instanceof FfiProtectError).toBe(true) - }) - }) - - describe('encryptModel error codes', () => { - it( - 'preserves FFI error codes', - async () => { - const model = { nonexistent: 'test value' } - - const result = await protectDynamo.encryptModel(model, badSchema) - - expect(result.failure).toBeDefined() - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'UNKNOWN_COLUMN', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('decryptModel error codes', () => { - it( - 'uses PROTECT_DYNAMODB_ERROR for IO/parsing errors without FFI codes', - async () => { - // Malformed ciphertext causes IO/parsing errors that don't have FFI error codes - const malformedItem = { - email__source: 'invalid_ciphertext_data', - } - - const result = await protectDynamo.decryptModel( - malformedItem, - testSchema, - ) - - expect(result.failure).toBeDefined() - // FFI returns undefined code for IO/parsing errors, so we fall back to generic code - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'PROTECT_DYNAMODB_ERROR', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkEncryptModels error codes', () => { - it( - 'preserves FFI error codes', - async () => { - const models = [{ nonexistent: 'value1' }, { nonexistent: 'value2' }] - - const result = await protectDynamo.bulkEncryptModels(models, badSchema) - - expect(result.failure).toBeDefined() - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'UNKNOWN_COLUMN', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkDecryptModels error codes', () => { - it( - 'uses PROTECT_DYNAMODB_ERROR for IO/parsing errors without FFI codes', - async () => { - // Malformed ciphertext causes IO/parsing errors that don't have FFI error codes - const malformedItems = [ - { email__source: 'invalid1' }, - { email__source: 'invalid2' }, - ] - - const result = await protectDynamo.bulkDecryptModels( - malformedItems, - testSchema, - ) - - expect(result.failure).toBeDefined() - // FFI returns undefined code for IO/parsing errors, so we fall back to generic code - expect((result.failure as ProtectDynamoDBError).code).toBe( - 'PROTECT_DYNAMODB_ERROR', - ) - }, - FFI_TEST_TIMEOUT, - ) - }) -}) diff --git a/packages/protect-dynamodb/__tests__/helpers.test.ts b/packages/protect-dynamodb/__tests__/helpers.test.ts deleted file mode 100644 index aeb22bdc6..000000000 --- a/packages/protect-dynamodb/__tests__/helpers.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { csColumn, csTable } from '@cipherstash/protect' -import { describe, expect, it } from 'vitest' -import { - ciphertextAttrSuffix, - searchTermAttrSuffix, - toItemWithEqlPayloads, -} from '../src/helpers' - -describe('toItemWithEqlPayloads', () => { - it('wraps searchable JSON columns as ste_vec (k: "sv") EQL payloads', () => { - // Regression test for `cast_as === 'json'` (previously 'jsonb'). The - // searchableJson() builder produces cast_as: 'json' + ste_vec index, and - // this branch must emit k: 'sv' so DynamoDB items round-trip through - // decrypt without losing the ste_vec discriminant. - const schema = csTable('users', { - preferences: csColumn('preferences').searchableJson(), - }) - - const stored = [{ s: 'selector', t: 'term' }] - const item = { - id: 'user-1', - [`preferences${ciphertextAttrSuffix}`]: stored, - } - - const result = toItemWithEqlPayloads(item, schema) - - expect(result).toEqual({ - id: 'user-1', - preferences: { - i: { c: 'preferences', t: 'users' }, - v: 2, - k: 'sv', - sv: stored, - }, - }) - }) - - it('wraps non-ste_vec columns as scalar ciphertext (k: "ct") EQL payloads', () => { - const schema = csTable('users', { - email: csColumn('email').equality(), - }) - - const ciphertext = 'mp_base85_ciphertext' - const item = { - id: 'user-1', - [`email${ciphertextAttrSuffix}`]: ciphertext, - [`email${searchTermAttrSuffix}`]: 'hmac-value', - } - - const result = toItemWithEqlPayloads(item, schema) - - // HMAC attribute is stripped; ciphertext is wrapped as k: 'ct'. - expect(result).toEqual({ - id: 'user-1', - email: { - i: { c: 'email', t: 'users' }, - v: 2, - k: 'ct', - c: ciphertext, - }, - }) - }) - - it('wraps non-searchable JSON columns as scalar ciphertext (k: "ct")', () => { - // A plain `dataType('json')` column has cast_as: 'json' but no ste_vec - // index — it must take the default `k: 'ct'` branch, not `k: 'sv'`. - const schema = csTable('users', { - metadata: csColumn('metadata').dataType('json'), - }) - - const ciphertext = 'mp_base85_ciphertext' - const item = { - id: 'user-1', - [`metadata${ciphertextAttrSuffix}`]: ciphertext, - } - - const result = toItemWithEqlPayloads(item, schema) - - expect(result).toEqual({ - id: 'user-1', - metadata: { - i: { c: 'metadata', t: 'users' }, - v: 2, - k: 'ct', - c: ciphertext, - }, - }) - }) -}) diff --git a/packages/protect-dynamodb/package.json b/packages/protect-dynamodb/package.json deleted file mode 100644 index 2e1d99d01..000000000 --- a/packages/protect-dynamodb/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "@cipherstash/protect-dynamodb", - "version": "12.0.2-rc.0", - "description": "Protect.js DynamoDB Helpers", - "keywords": [ - "dynamodb", - "cipherstash", - "protect", - "encrypt", - "decrypt", - "security" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/protect-dynamodb" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "test": "vitest run", - "release": "tsup" - }, - "devDependencies": { - "@cipherstash/protect": "workspace:*", - "dotenv": "^17.4.2", - "tsup": "catalog:repo", - "tsx": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "peerDependencies": { - "@cipherstash/protect": "workspace:*" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@byteslice/result": "^0.2.0" - } -} diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/protect-dynamodb/src/helpers.ts deleted file mode 100644 index 505e5c54b..000000000 --- a/packages/protect-dynamodb/src/helpers.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { - Encrypted, - ProtectErrorCode, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { FfiProtectError } from '@cipherstash/protect' -import type { ProtectDynamoDBError } from './types' -export const ciphertextAttrSuffix = '__source' -export const searchTermAttrSuffix = '__hmac' - -export class ProtectDynamoDBErrorImpl - extends Error - implements ProtectDynamoDBError -{ - constructor( - message: string, - public code: ProtectErrorCode | 'PROTECT_DYNAMODB_ERROR', - public details?: Record, - ) { - super(message) - this.name = 'ProtectDynamoDBError' - } -} - -export function handleError( - error: unknown, - context: string, - options?: { - logger?: { - error: (message: string, error: Error) => void - } - errorHandler?: (error: ProtectDynamoDBError) => void - }, -): ProtectDynamoDBError { - // Preserve FFI error code if available, otherwise use generic DynamoDB error code - // Check for FfiProtectError instance or plain ProtectError objects with code property - const errorObj = error as Record - const errorCode = - error instanceof FfiProtectError - ? error.code - : errorObj && - typeof errorObj === 'object' && - 'code' in errorObj && - typeof errorObj.code === 'string' - ? (errorObj.code as ProtectErrorCode) - : 'PROTECT_DYNAMODB_ERROR' - - const errorMessage = - error instanceof Error - ? error.message - : errorObj && typeof errorObj.message === 'string' - ? errorObj.message - : String(error) - - const protectError = new ProtectDynamoDBErrorImpl(errorMessage, errorCode, { - context, - }) - - if (options?.errorHandler) { - options.errorHandler(protectError) - } - - if (options?.logger) { - options.logger.error(`Error in ${context}`, protectError) - } - - return protectError -} - -export function deepClone(obj: T): T { - if (obj === null || typeof obj !== 'object') { - return obj - } - - if (Array.isArray(obj)) { - return obj.map((item) => deepClone(item)) as unknown as T - } - - return Object.entries(obj as Record).reduce( - (acc, [key, value]) => ({ - // biome-ignore lint/performance/noAccumulatingSpread: TODO later - ...acc, - [key]: deepClone(value), - }), - {} as T, - ) -} - -export function toEncryptedDynamoItem( - encrypted: Record, - encryptedAttrs: string[], -): Record { - function processValue( - attrName: string, - attrValue: unknown, - isNested: boolean, - ): Record { - if (attrValue === null || attrValue === undefined) { - return { [attrName]: attrValue } - } - - // Handle encrypted payload - if ( - encryptedAttrs.includes(attrName) || - (isNested && - typeof attrValue === 'object' && - 'c' in (attrValue as object)) - ) { - const encryptPayload = attrValue as Encrypted - if (encryptPayload?.k === 'ct' && encryptPayload.c) { - const result: Record = {} - if (encryptPayload.hm) { - result[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm - } - result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c - return result - } - - if (encryptPayload?.k === 'sv' && encryptPayload.sv) { - const result: Record = {} - result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.sv - return result - } - } - - // Handle nested objects recursively - if (typeof attrValue === 'object' && !Array.isArray(attrValue)) { - const nestedResult = Object.entries( - attrValue as Record, - ).reduce( - (acc, [key, val]) => { - const processed = processValue(key, val, true) - return Object.assign({}, acc, processed) - }, - {} as Record, - ) - return { [attrName]: nestedResult } - } - - // Handle non-encrypted values - return { [attrName]: attrValue } - } - - return Object.entries(encrypted).reduce( - (putItem, [attrName, attrValue]) => { - const processed = processValue(attrName, attrValue, false) - return Object.assign({}, putItem, processed) - }, - {} as Record, - ) -} - -export function toItemWithEqlPayloads( - decrypted: Record, - encryptSchemas: ProtectTable, -): Record { - function processValue( - attrName: string, - attrValue: unknown, - isNested: boolean, - ): Record { - if (attrValue === null || attrValue === undefined) { - return { [attrName]: attrValue } - } - - // Skip HMAC fields - if (attrName.endsWith(searchTermAttrSuffix)) { - return {} - } - - const encryptConfig = encryptSchemas.build() - const encryptedAttrs = Object.keys(encryptConfig.columns) - const columnName = attrName.slice(0, -ciphertextAttrSuffix.length) - - // Handle encrypted payload - if ( - attrName.endsWith(ciphertextAttrSuffix) && - (encryptedAttrs.includes(columnName) || isNested) - ) { - // TODO: Implement the standardized typing for Encrypted Payloads through the FFI interface - const i = { c: columnName, t: encryptConfig.tableName } - const v = 2 - - // Nested values are not searchable, so we can just return the standard EQL payload. - // Worth noting, that encryptConfig.columns[columnName] will be undefined if isNested is true. - if ( - !isNested && - encryptConfig.columns[columnName].cast_as === 'json' && - encryptConfig.columns[columnName].indexes.ste_vec - ) { - return { - [columnName]: { - i, - v, - k: 'sv', - sv: attrValue, - }, - } - } - - return { - [columnName]: { - i, - v, - k: 'ct', - c: attrValue, - }, - } - } - - // Handle nested objects recursively - if (typeof attrValue === 'object' && !Array.isArray(attrValue)) { - const nestedResult = Object.entries( - attrValue as Record, - ).reduce( - (acc, [key, val]) => { - const processed = processValue(key, val, true) - return Object.assign({}, acc, processed) - }, - {} as Record, - ) - return { [attrName]: nestedResult } - } - - // Handle non-encrypted values - return { [attrName]: attrValue } - } - - return Object.entries(decrypted).reduce( - (formattedItem, [attrName, attrValue]) => { - const processed = processValue(attrName, attrValue, false) - return Object.assign({}, formattedItem, processed) - }, - {} as Record, - ) -} diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts deleted file mode 100644 index fe18ddfa7..000000000 --- a/packages/protect-dynamodb/src/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { - Encrypted, - ProtectTable, - ProtectTableColumn, - SearchTerm, -} from '@cipherstash/protect' -import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' -import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' -import { DecryptModelOperation } from './operations/decrypt-model' -import { EncryptModelOperation } from './operations/encrypt-model' -import { SearchTermsOperation } from './operations/search-terms' -import type { ProtectDynamoDBConfig, ProtectDynamoDBInstance } from './types' - -export function protectDynamoDB( - config: ProtectDynamoDBConfig, -): ProtectDynamoDBInstance { - const { protectClient, options } = config - - return { - encryptModel>( - item: T, - protectTable: ProtectTable, - ) { - return new EncryptModelOperation( - protectClient, - item, - protectTable, - options, - ) - }, - - bulkEncryptModels>( - items: T[], - protectTable: ProtectTable, - ) { - return new BulkEncryptModelsOperation( - protectClient, - items, - protectTable, - options, - ) - }, - - decryptModel>( - item: Record, - protectTable: ProtectTable, - ) { - return new DecryptModelOperation( - protectClient, - item, - protectTable, - options, - ) - }, - - bulkDecryptModels>( - items: Record[], - protectTable: ProtectTable, - ) { - return new BulkDecryptModelsOperation( - protectClient, - items, - protectTable, - options, - ) - }, - - /** - * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. - */ - createSearchTerms(terms: SearchTerm[]) { - return new SearchTermsOperation(protectClient, terms, options) - }, - } -} - -export * from './types' diff --git a/packages/protect-dynamodb/src/operations/base-operation.ts b/packages/protect-dynamodb/src/operations/base-operation.ts deleted file mode 100644 index e9ea0bb64..000000000 --- a/packages/protect-dynamodb/src/operations/base-operation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { ProtectDynamoDBError } from '../types' - -export type AuditConfig = { - metadata?: Record -} - -export type AuditData = { - metadata?: Record -} - -export type DynamoDBOperationOptions = { - logger?: { - error: (message: string, error: Error) => void - } - errorHandler?: (error: ProtectDynamoDBError) => void -} - -export abstract class DynamoDBOperation { - protected auditMetadata?: Record - protected logger?: DynamoDBOperationOptions['logger'] - protected errorHandler?: DynamoDBOperationOptions['errorHandler'] - - constructor(options?: DynamoDBOperationOptions) { - this.logger = options?.logger - this.errorHandler = options?.errorHandler - } - - /** - * Attach audit metadata to this operation. Can be chained. - */ - audit(config: AuditConfig): this { - this.auditMetadata = config.metadata - return this - } - - /** - * Get the audit metadata for this operation. - */ - protected getAuditData(): AuditData { - return { - metadata: this.auditMetadata, - } - } - - /** - * Execute the operation and return a Result - */ - abstract execute(): Promise> - - /** - * Make the operation thenable - */ - public then, TResult2 = never>( - onfulfilled?: - | (( - value: Result, - ) => TResult1 | PromiseLike) - | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.execute().then(onfulfilled, onrejected) - } -} diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts deleted file mode 100644 index f27e6dd4a..000000000 --- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - Decrypted, - Encrypted, - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { handleError, toItemWithEqlPayloads } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class BulkDecryptModelsOperation< - T extends Record, -> extends DynamoDBOperation[]> { - private protectClient: ProtectClient - private items: Record[] - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - items: Record[], - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.items = items - this.protectTable = protectTable - } - - public async execute(): Promise< - Result[], ProtectDynamoDBError> - > { - return await withResult( - async () => { - const itemsWithEqlPayloads = this.items.map((item) => - toItemWithEqlPayloads(item, this.protectTable), - ) - - const decryptResult = await this.protectClient - .bulkDecryptModels(itemsWithEqlPayloads as T[]) - .audit(this.getAuditData()) - - if (decryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(decryptResult.failure.message) as Error & { - code?: string - } - error.code = decryptResult.failure.code - throw error - } - - return decryptResult.data - }, - (error) => - handleError(error, 'bulkDecryptModels', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts deleted file mode 100644 index 855f9d142..000000000 --- a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class BulkEncryptModelsOperation< - T extends Record, -> extends DynamoDBOperation { - private protectClient: ProtectClient - private items: T[] - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - items: T[], - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.items = items - this.protectTable = protectTable - } - - public async execute(): Promise> { - return await withResult( - async () => { - const encryptResult = await this.protectClient - .bulkEncryptModels( - this.items.map((item) => deepClone(item)), - this.protectTable, - ) - .audit(this.getAuditData()) - - if (encryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(encryptResult.failure.message) as Error & { - code?: string - } - error.code = encryptResult.failure.code - throw error - } - - const data = encryptResult.data.map((item) => deepClone(item)) - const encryptedAttrs = Object.keys(this.protectTable.build().columns) - - return data.map( - (encrypted) => toEncryptedDynamoItem(encrypted, encryptedAttrs) as T, - ) - }, - (error) => - handleError(error, 'bulkEncryptModels', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/decrypt-model.ts b/packages/protect-dynamodb/src/operations/decrypt-model.ts deleted file mode 100644 index c6980108b..000000000 --- a/packages/protect-dynamodb/src/operations/decrypt-model.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - Decrypted, - Encrypted, - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { handleError, toItemWithEqlPayloads } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class DecryptModelOperation< - T extends Record, -> extends DynamoDBOperation> { - private protectClient: ProtectClient - private item: Record - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - item: Record, - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.item = item - this.protectTable = protectTable - } - - public async execute(): Promise, ProtectDynamoDBError>> { - return await withResult( - async () => { - const withEqlPayloads = toItemWithEqlPayloads( - this.item, - this.protectTable, - ) - - const decryptResult = await this.protectClient - .decryptModel(withEqlPayloads as T) - .audit(this.getAuditData()) - - if (decryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(decryptResult.failure.message) as Error & { - code?: string - } - error.code = decryptResult.failure.code - throw error - } - - return decryptResult.data - }, - (error) => - handleError(error, 'decryptModel', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/encrypt-model.ts b/packages/protect-dynamodb/src/operations/encrypt-model.ts deleted file mode 100644 index 82ee1bd85..000000000 --- a/packages/protect-dynamodb/src/operations/encrypt-model.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - ProtectClient, - ProtectTable, - ProtectTableColumn, -} from '@cipherstash/protect' -import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -export class EncryptModelOperation< - T extends Record, -> extends DynamoDBOperation { - private protectClient: ProtectClient - private item: T - private protectTable: ProtectTable - - constructor( - protectClient: ProtectClient, - item: T, - protectTable: ProtectTable, - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.item = item - this.protectTable = protectTable - } - - public async execute(): Promise> { - return await withResult( - async () => { - const encryptResult = await this.protectClient - .encryptModel(deepClone(this.item), this.protectTable) - .audit(this.getAuditData()) - - if (encryptResult.failure) { - // Create an Error object that preserves the FFI error code - // This is necessary because withResult's ensureError wraps non-Error objects - const error = new Error(encryptResult.failure.message) as Error & { - code?: string - } - error.code = encryptResult.failure.code - throw error - } - - const data = deepClone(encryptResult.data) - const encryptedAttrs = Object.keys(this.protectTable.build().columns) - - return toEncryptedDynamoItem(data, encryptedAttrs) as T - }, - (error) => - handleError(error, 'encryptModel', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/operations/search-terms.ts b/packages/protect-dynamodb/src/operations/search-terms.ts deleted file mode 100644 index 4da416bae..000000000 --- a/packages/protect-dynamodb/src/operations/search-terms.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - isEncryptedScalarQuery, - type ProtectClient, - type SearchTerm, -} from '@cipherstash/protect' -import { handleError } from '../helpers' -import type { ProtectDynamoDBError } from '../types' -import { - DynamoDBOperation, - type DynamoDBOperationOptions, -} from './base-operation' - -/** - * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. - * - * @example - * ```typescript - * // Before (deprecated) - * const result = await protectDynamo.createSearchTerms([{ value, column, table }]) - * const hmac = result.data[0] - * - * // After (new API) - * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }]) - * const hmac = encrypted.hm - * ``` - */ -export class SearchTermsOperation extends DynamoDBOperation { - private protectClient: ProtectClient - private terms: SearchTerm[] - - constructor( - protectClient: ProtectClient, - terms: SearchTerm[], - options?: DynamoDBOperationOptions, - ) { - super(options) - this.protectClient = protectClient - this.terms = terms - } - - public async execute(): Promise> { - return await withResult( - async () => { - const searchTermsResult = await this.protectClient - .createSearchTerms(this.terms) - .audit(this.getAuditData()) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - return searchTermsResult.data.map((term) => { - if (typeof term === 'string') { - throw new Error( - 'expected encrypted search term to be an EncryptedPayload', - ) - } - - // DynamoDB lookups go through equality queries → the FFI returns an - // EncryptedScalarQuery carrying `hm`. Anything else (scalar storage, - // a `bf`/`ob` query, or a SteVec payload) is a misconfiguration. - if (!isEncryptedScalarQuery(term) || !('hm' in term)) { - throw new Error('expected encrypted search term to have an HMAC') - } - - return term.hm - }) - }, - (error) => - handleError(error, 'createSearchTerms', { - logger: this.logger, - errorHandler: this.errorHandler, - }), - ) - } -} diff --git a/packages/protect-dynamodb/src/types.ts b/packages/protect-dynamodb/src/types.ts deleted file mode 100644 index 6cd7338ba..000000000 --- a/packages/protect-dynamodb/src/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - Encrypted, - ProtectClient, - ProtectErrorCode, - ProtectTable, - ProtectTableColumn, - SearchTerm, -} from '@cipherstash/protect' -import type { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' -import type { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' -import type { DecryptModelOperation } from './operations/decrypt-model' -import type { EncryptModelOperation } from './operations/encrypt-model' -import type { SearchTermsOperation } from './operations/search-terms' - -export interface ProtectDynamoDBConfig { - protectClient: ProtectClient - options?: { - logger?: { - error: (message: string, error: Error) => void - } - errorHandler?: (error: ProtectDynamoDBError) => void - } -} - -export interface ProtectDynamoDBError extends Error { - code: ProtectErrorCode | 'PROTECT_DYNAMODB_ERROR' - details?: Record -} - -export interface ProtectDynamoDBInstance { - encryptModel>( - item: T, - protectTable: ProtectTable, - ): EncryptModelOperation - - bulkEncryptModels>( - items: T[], - protectTable: ProtectTable, - ): BulkEncryptModelsOperation - - decryptModel>( - item: Record, - protectTable: ProtectTable, - ): DecryptModelOperation - - bulkDecryptModels>( - items: Record[], - protectTable: ProtectTable, - ): BulkDecryptModelsOperation - - /** - * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups. - * - * @example - * ```typescript - * // Before (deprecated) - * const result = await protectDynamo.createSearchTerms([{ value, column, table }]) - * const hmac = result.data[0] - * - * // After (new API) - * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }]) - * const hmac = encrypted.hm - * ``` - */ - createSearchTerms(terms: SearchTerm[]): SearchTermsOperation -} diff --git a/packages/protect-dynamodb/tsconfig.json b/packages/protect-dynamodb/tsconfig.json deleted file mode 100644 index 6da81c927..000000000 --- a/packages/protect-dynamodb/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/protect-dynamodb/tsup.config.ts b/packages/protect-dynamodb/tsup.config.ts deleted file mode 100644 index 0ced0a354..000000000 --- a/packages/protect-dynamodb/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, -}) diff --git a/packages/protect/.npmignore b/packages/protect/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/protect/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/protect/CHANGELOG.md b/packages/protect/CHANGELOG.md deleted file mode 100644 index 9c79eaf46..000000000 --- a/packages/protect/CHANGELOG.md +++ /dev/null @@ -1,439 +0,0 @@ -# @cipherstash/protect - -## 12.0.2-rc.0 - -### Patch Changes - -- Updated dependencies [229ce59] - - @cipherstash/schema@3.0.2-rc.0 - -## 12.0.1 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. -- Updated dependencies [aa9c4b1] - - @cipherstash/schema@3.0.1 - -## 12.0.0 - -### Major Changes - -- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`. - - Breaking upstream changes adopted in this release: - - - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code. - - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`. - - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK. - - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)). - - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases. - - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns. - - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields. - - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update. - -### Patch Changes - -- Updated dependencies [f743fcc] - - @cipherstash/schema@3.0.0 - -## 11.1.2 - -### Patch Changes - -- a8dbb65: Render every user-facing CLI string and execute every shell-out under the detected package manager (`npx` / `bunx` / `pnpm dlx` / `yarn dlx`), completing the work started in #379. Affected surfaces: `@cipherstash/cli` top-level + `auth` + `env` help, `db install` Drizzle migration steps, `db migrate` not-implemented warning, the Supabase migration SQL header, the Supabase status fallback exec, the `@cipherstash/protect` `stash` Stricli help (set/get/list/delete), the `@cipherstash/wizard` usage line and agent command allowlist, and the `@cipherstash/drizzle` `generate-eql-migration` help + drizzle-kit invocation. A new `pnpm run lint:runners` lint runs in CI and fails on any reintroduction of a hardcoded runner literal. - -## 11.1.1 - -### Patch Changes - -- afe6810: Bump protect-ffi version - -## 11.1.0 - -### Minor Changes - -- 068f820: Release the consolidated CipherStash CLI npm package. - -## 11.0.0 - -### Major Changes - -- b0e56b8: Upgrade protect-ffi to 0.21.0 and enable array_index_mode for searchable JSON - - - Upgrade `@cipherstash/protect-ffi` to 0.21.0 across all packages - - Enable `array_index_mode: 'all'` on STE vec indexes so JSON array operations - (jsonb_array_elements, jsonb_array_length, array containment) work correctly - - Delegate credential resolution entirely to protect-ffi's `withEnvCredentials` - - Download latest EQL at build/runtime instead of bundling hardcoded SQL files - -### Patch Changes - -- Updated dependencies [b0e56b8] - - @cipherstash/schema@2.2.0 - -## 10.5.0 - -### Minor Changes - -- db72e2c: Add `encryptQuery` API for encrypting query terms with explicit query type selection. - - - New `encryptQuery()` method replaces `createSearchTerms()` with improved query type handling - - Supports `equality`, `freeTextSearch`, and `orderAndRange` query types - - Deprecates `createSearchTerms()` - use `encryptQuery()` instead - - Updates drizzle operators to use correct index selection via `queryType` parameter - -- e769740: Add encrypted JSONB query support with `searchableJson()` (recommended). - - - New `searchableJson()` schema method enables encrypted JSONB path and containment queries - - Automatic query operation inference: string values become JSONPath selector queries, objects/arrays become containment queries - - Also supports explicit `queryType: 'steVecSelector'` and `queryType: 'steVecTerm'` for advanced use cases - - JSONB path utilities (`toJsonPath`, `buildNestedObject`, `parseJsonbPath`) for building encrypted JSON column queries - -### Patch Changes - -- Updated dependencies [e769740] - - @cipherstash/schema@2.1.0 - -## 10.4.0 - -### Minor Changes - -- 9ccaf68: Allow stash cli tool to read env files from .env.\*. - -## 10.3.0 - -### Minor Changes - -- a1fce2b: Add Stash interface and CLI tool. - -### Patch Changes - -- 622b684: Update @cipherstash/protect-ffi to 0.19.0 - -## 10.2.1 - -### Patch Changes - -- Updated dependencies [532ac3a] - - @cipherstash/schema@2.0.2 - -## 10.2.0 - -### Minor Changes - -- de029de: Add client safe exports. - -## 10.1.1 - -### Patch Changes - -- ff4421f: Expanded typedoc documentation -- Updated dependencies [ff4421f] - - @cipherstash/schema@2.0.1 - -## 10.1.0 - -### Minor Changes - -- 6b87c17: Added support for multi-tenant encryption with configurable keysets. - -## 10.0.2 - -### Patch Changes - -- Updated dependencies [9005484] - - @cipherstash/schema@2.0.0 - -## 10.0.1 - -### Patch Changes - -- Updated dependencies [d8ed4d4] - - @cipherstash/schema@1.1.0 - -## 10.0.0 - -### Major Changes - -- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. - - - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. - - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) - - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - - Add comprehensive test suites for JSON, integer, and basic encryption - - Update encryption format to use 'k' property for searchable JSON - - Remove deprecated search terms tests for JSON fields - - Simplify schema data types to text, int, json only - - Update model helpers to handle new encryption format - - Fix type safety issues in bulk operations and model encryption - -### Patch Changes - -- Updated dependencies [788dbfc] - - @cipherstash/schema@1.0.0 - -## 9.6.0 - -### Minor Changes - -- c7ed7ab: Support TypeORM example with ES2022. -- 211e979: Added support for ES2022 and later. - -## 9.5.0 - -### Minor Changes - -- 6f45b02: Fully implemented audit metadata functionality. - -## 9.4.1 - -### Patch Changes - -- Updated dependencies [d0b02ea] - - @cipherstash/schema@0.1.0 - -## 9.4.0 - -### Minor Changes - -- 1cc4772: Released support for bulk encryption and decryption. - -## 9.3.0 - -### Minor Changes - -- 01fed9e: Added audit support for all protect and protect-dynamodb interfaces. - -## 9.2.0 - -### Minor Changes - -- 587f222: Added support for deeply nested protect schemas to support more complex model objects. - -## 9.1.0 - -### Minor Changes - -- c8468ee: Released initial version of the DynamoDB helper interface. - -## 9.0.0 - -### Major Changes - -- 1bc55a0: Implemented a more configurable pattern for the Protect client. - - This release introduces a new `ProtectClientConfig` type that can be used to configure the Protect client. - This is useful if you want to configure the Protect client specific to your application, and will future proof any additional configuration options that are added in the future. - - ```ts - import { protect, type ProtectClientConfig } from "@cipherstash/protect"; - - const config: ProtectClientConfig = { - schemas: [users, orders], - workspaceCrn: "your-workspace-crn", - accessKey: "your-access-key", - clientId: "your-client-id", - clientKey: "your-client-key", - }; - - const protectClient = await protect(config); - ``` - - The now deprecated method of passing your tables to the `protect` client is no longer supported. - - ```ts - import { protect, type ProtectClientConfig } from "@cipherstash/protect"; - - // old method (no longer supported) - const protectClient = await protect(users, orders); - - // required method - const config: ProtectClientConfig = { - schemas: [users, orders], - }; - - const protectClient = await protect(config); - ``` - -## 8.4.0 - -### Minor Changes - -- a471821: Fixed a bug in the model interface to correctly handle undefined and null values. - -## 8.3.0 - -### Minor Changes - -- 628acdc: Implemented createSearchTerms for a streamlined way of working with encrypted search terms. - -## 8.2.0 - -### Minor Changes - -- 0883e16: Fix cipherstash.toml and cipherstash.secret.toml file loading by bumping to @cipherstash/protect-ffi v0.14.2 - -## 8.1.0 - -### Minor Changes - -- 95c891d: Implemented CipherStash CRN in favor of workspace ID. - - - Replaces the environment variable `CS_WORKSPACE_ID` with `CS_WORKSPACE_CRN` - - Replaces `workspace_id` with `workspace_crn` in the `cipherstash.toml` file - -- 18d3653: Fixed handling composite types for EQL v2. - -## 8.0.0 - -### Major Changes - -- 8a4ea80: Implement EQL v2 data structure. - - - Support for Protect.js searchable encryption when using Supabase. - - Encrypted payloads are now composite types which support searchable encryption with EQL v2 functions. - - The `data` property is an object that matches the EQL v2 data structure. - -## 7.0.0 - -### Major Changes - -- 2cb2d84: Replaced bulk operations with model operations. - -## 6.3.0 - -### Minor Changes - -- a564f21: Bumped versions of dependencies to address CWE-346. - -## 6.2.0 - -### Minor Changes - -- fe4b443: Added symbolic link for protect readme. - -## 6.1.0 - -### Minor Changes - -- 43e1acb: \* Added support for searching encrypted data - - Added a schema strategy for defining your schema - - Required schema to initialize the protect client - -## 6.0.0 - -### Major Changes - -- f4d8334: Released protectjs-ffi with toml file configuration support. - Added a `withResult` pattern to all public facing functions for better error handling. - Updated all documentation to reflect the new configuration pattern. - -## 5.2.0 - -### Minor Changes - -- 499c246: Implemented protectjs-ffi. - -## 5.1.0 - -### Minor Changes - -- 5a34e76: Rebranded logging context and fixed tests. - -## 5.0.0 - -### Major Changes - -- 76599e5: Rebrand jseql to protect. - -## 4.0.0 - -### Major Changes - -- 5c08fe5: Enforced lock context to be called as a proto function rather than an optional argument for crypto functions. - There was a bug that caused the lock context to be interpreted as undefined when the users intention was to use it causing the encryption/decryption to fail. - This is a breaking change for users who were using the lock context as an optional argument. - To use the lock context, call the `withLockContext` method on the encrypt, decrypt, and bulk encrypt/decrypt functions, passing the lock context as a parameter rather than as an optional argument. - -## 3.9.0 - -### Minor Changes - -- e885975: Fixed improper use of throwing errors, and log with jseql logger. - -## 3.8.0 - -### Minor Changes - -- eeaec18: Implemented typing and import synatx for es6. - -## 3.7.0 - -### Minor Changes - -- 7b8ec52: Implement packageless logging framework. - -## 3.6.0 - -### Minor Changes - -- 7480cfd: Fixed node:util package bundling. - -## 3.5.0 - -### Minor Changes - -- c0123be: Replaced logtape with native node debuglog. - -## 3.4.0 - -### Minor Changes - -- 9a3132c: Implemented bulk encryption and decryptions. -- 9a3132c: Fixed the logtape peer dependency version. - -## 3.3.0 - -### Minor Changes - -- 80ee5af: Fixed bugs when implmenting the lock context with CTS v2 tokens. - -## 3.2.0 - -### Minor Changes - -- 0526f60: Use the latest jseql-ffi (0.4.0) -- fbb2bcb: Implemented CTS v2 for identity lock. - -## 3.1.0 - -### Minor Changes - -- 71ce612: Released support for LockContext initializer. -- e484718: Refactored init function to not require envrionment variables as arguments. -- e484718: Replaces jset with vitest for better typescript support. - -## 3.0.0 - -### Major Changes - -- 2eefb5f: Implemented jseql-ffi for inline crypto. - -## 2.1.0 - -### Minor Changes - -- 0536f03: Implemented new CsPlaintextV1Schema type and schema. - -## 2.0.0 - -### Major Changes - -- bea60c4: Added release management. - -## 1.0.0 - -### Major Changes - -- Released the initial version of jseql. diff --git a/packages/protect/README.md b/packages/protect/README.md deleted file mode 100644 index 06e45b53e..000000000 --- a/packages/protect/README.md +++ /dev/null @@ -1,1102 +0,0 @@ -

- CipherStash Logo -
- Protect.js -

-

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

-

-
⭐ Please star this repo if you find it useful!
-
- - - -> [!TIP] -> `@cipherstash/protect` is the core encryption library that powers [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack). For new projects we recommend `@cipherstash/stack`, which re-exports this functionality alongside integrations for Drizzle, Supabase, DynamoDB, secrets, and identity-aware encryption. See the [CipherStash docs](https://cipherstash.com/docs) for the current API. - -Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database. - -Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html). - -Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB. - -> [!IMPORTANT] -> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL. -> Read more about [searching encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption). - -Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb). - -## Quick start (60 seconds) - -Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file. - -Install the package: - -```bash -npm install @cipherstash/protect -``` - -Start encrypting data: - -```ts -import { protect } from "@cipherstash/protect"; -import { csTable, csColumn } from "@cipherstash/protect"; - -// 1) Define a schema -const users = csTable("users", { email: csColumn("email") }); - -// 2) Create a client (requires CS_* env vars) -const client = await protect({ schemas: [users] }); - -// 3) Encrypt → store JSONB payload -const encrypted = await client.encrypt("alice@example.com", { - table: users, - column: users.email, -}); - -if (encrypted.failure) { - // You decide how to handle the failure and the user experience -} - -// 4) Decrypt later -const decrypted = await client.decrypt(encrypted.data); -``` - -## Architecture (high level) - -![Protect.js Architecture Diagram](https://raw.githubusercontent.com/cipherstash/stack/45e40dc888ddc412c361139192ed24668d3db60d/docs/images/protectjs-architecture.png) - -## Table of contents - -- [Quick start (60 seconds)](#quick-start-60-seconds) -- [Architecture (high level)](#architecture-high-level) -- [Features](#features) -- [Installing Protect.js](#installing-protectjs) -- [Getting started](#getting-started) -- [Identity-aware encryption](#identity-aware-encryption) -- [Supported data types](#supported-data-types) -- [Searchable encryption](#searchable-encryption) -- [Multi-tenant encryption](#multi-tenant-encryption) -- [Logging](#logging) -- [CipherStash Client](#cipherstash-client) -- [Example applications](#example-applications) -- [Builds and bundling](#builds-and-bundling) -- [Contributing](#contributing) -- [License](#license) - -For more specific documentation, refer to the [docs](https://cipherstash.com/docs). - -## Features - -Protect.js protects data in using industry-standard AES encryption. -Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations. -This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance. - -**Features:** - -- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value. -- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered. -- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js. -- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption. -- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance. -- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL. -- **TypeScript support**: Strongly typed with TypeScript interfaces and types. - -**Use cases:** - -- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product. -- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001. -- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt. - -## Installing Protect.js - -Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice: - -```bash -npm install @cipherstash/protect -# or -yarn add @cipherstash/protect -# or -pnpm add @cipherstash/protect -``` - -> [!TIP] -> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon). - -### Opt-out of bundling - -> [!IMPORTANT] -> **You need to opt-out of bundling when using Protect.js.** - -Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid). - -When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages). - -Read more about [building and bundling with Protect.js](#builds-and-bundling). - -## Getting started - -- 🆕 **Existing app?** Skip to [the next step](#configuration). -- 🌱 **Clean slate?** Check out the [getting started tutorial](https://cipherstash.com/docs/stack/quickstart). - -### Configuration - -If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup). -Once you have an account, you will create a Workspace which is scoped to your application environment. - -Follow the onboarding steps to get your first set of credentials required to use Protect.js. -By the end of the onboarding, you will have the following environment variables: - -```bash -CS_WORKSPACE_CRN= # The workspace identifier -CS_CLIENT_ID= # The client identifier -CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS -CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API -``` - -Save these environment variables to a `.env` file in your project. - -### Basic file structure - -The following is the basic file structure of the project. -In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`. - -``` -📦 - ├ 📂 src - │ ├ 📂 protect - │ │ ├ 📜 index.ts - │ │ └ 📜 schema.ts - │ └ 📜 index.ts - ├ 📜 .env - ├ 📜 cipherstash.toml - ├ 📜 cipherstash.secret.toml - ├ 📜 package.json - └ 📜 tsconfig.json -``` - -### Define your schema - -Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt. - -Define your tables and columns by adding this to `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email"), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); -``` - -**Searchable encryption:** - -If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`: - -```ts -import { csTable, csColumn } from "@cipherstash/protect"; - -export const users = csTable("users", { - email: csColumn("email").freeTextSearch().equality().orderAndRange(), -}); - -export const orders = csTable("orders", { - address: csColumn("address"), -}); - -export const documents = csTable("documents", { - metadata: csColumn("metadata").searchableJson(), // Enables encrypted JSONB queries -}); -``` - -Read more about [defining your schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema). - -### Initialize the Protect client - -To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`: - -```ts -import { protect, type ProtectClientConfig } from "@cipherstash/protect"; -import { users, orders } from "./schema"; - -const config: ProtectClientConfig = { - schemas: [users, orders], -} - -// Pass all your tables to the protect function to initialize the client -export const protectClient = await protect(config); -``` - -The `protect` function requires at least one `csTable` be provided in the `schemas` array. - -### Encrypt data - -Protect.js provides the `encrypt` function on `protectClient` to encrypt data. -`encrypt` takes a plaintext string, and an object with the table and column as parameters. - -To start encrypting data, add the following to `src/index.ts`: - -```typescript -import { users } from "./protect/schema"; -import { protectClient } from "./protect"; - -const encryptResult = await protectClient.encrypt("secret@squirrel.example", { - column: users.email, - table: users, -}); - -if (encryptResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptResult.failure.type, - encryptResult.failure.message - ); -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key. -The `encryptResult` will return one of the following: - -```typescript -// Success -{ - data: EncryptedPayload -} - -// Failure -{ - failure: { - type: 'EncryptionError', - message: 'A message about the error' - } -} -``` - -### Decrypt data - -Protect.js provides the `decrypt` function on `protectClient` to decrypt data. -`decrypt` takes an encrypted data object as a parameter. - -To start decrypting data, add the following to `src/index.ts`: - -```typescript -import { protectClient } from "./protect"; - -// encryptResult is the EQL payload from the previous step -const decryptResult = await protectClient.decrypt(encryptResult.data); - -if (decryptResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptResult.failure.type, - decryptResult.failure.message - ); -} - -const plaintext = decryptResult.data; -console.log("plaintext:", plaintext); -``` - -The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key. -The `decryptResult` will return one of the following: - -```typescript -// Success -{ - data: 'secret@squirrel.example' -} - -// Failure -{ - failure: { - type: 'DecryptionError', - message: 'A message about the error' - } -} -``` - -### Working with models and objects - -Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects. -These methods automatically handle the encryption of fields defined in your schema. - -If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations. - -> [!TIP] -> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations. -> -> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record. - -#### Encrypting a model - -Use the `encryptModel` method to encrypt a model's fields that are defined in your schema: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Your model with plaintext values -const user = { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), -}; - -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedUser = encryptedResult.data; -console.log("encrypted user:", encryptedUser); -``` - -The `encryptModel` function will only encrypt fields that are defined in your schema. -Other fields (like `id` and `createdAt` in the example above) will remain unchanged. - -#### Type safety with models - -Protect.js provides strong TypeScript support for model operations. -You can specify your model's type to ensure end-to-end type safety: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Define your model type -type User = { - id: string; - email: string | null; - address: string | null; - createdAt: Date; - updatedAt: Date; - metadata?: { - preferences?: { - notifications: boolean; - theme: string; - }; - }; -}; - -// The encryptModel method will ensure type safety -const encryptedResult = await protectClient.encryptModel(user, users); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUser = encryptedResult.data; -// TypeScript knows that encryptedUser matches the User type structure -// but with encrypted fields for those defined in the schema - -// Decryption maintains type safety -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUser = decryptedResult.data; -// decryptedUser is fully typed as User - -// Bulk operations also support type safety -const bulkEncryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -const bulkDecryptedResult = await protectClient.bulkDecryptModels( - bulkEncryptedResult.data -); -``` - -The type system ensures that: - -- Input models match your defined type structure -- Only fields defined in your schema are encrypted -- Encrypted and decrypted results maintain the correct type structure -- Optional and nullable fields are properly handled -- Nested object structures are preserved -- Additional properties not defined in the schema remain unchanged - -This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints. - -> [!TIP] -> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations. - -Example with Drizzle infered types: - -```typescript -import { protectClient } from "./protect"; -import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core"; -import { csTable, csColumn } from "@cipherstash/protect"; - -const protectUsers = csTable("users", { - email: csColumn("email"), -}); - -const users = pgTable("users", { - id: serial("id").primaryKey(), - email: jsonb("email").notNull(), -}); - -type User = InferSelectModel; - -const user = { - id: "1", - email: "user@example.com", -}; - -// Drizzle User type works directly with model operations -const encryptedResult = await protectClient.encryptModel( - user, - protectUsers -); -``` - -#### Decrypting a model - -Use the `decryptModel` method to decrypt a model's encrypted fields: - -```typescript -import { protectClient } from "./protect"; - -const decryptedResult = await protectClient.decryptModel(encryptedUser); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedUser = decryptedResult.data; -console.log("decrypted user:", decryptedUser); -``` - -#### Bulk model operations - -For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of models with plaintext values -const userModels = [ - { - id: "1", - email: "user1@example.com", - address: "123 Main St", - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt multiple models at once -const encryptedResult = await protectClient.bulkEncryptModels( - userModels, - users -); - -if (encryptedResult.failure) { - // Handle the failure -} - -const encryptedUsers = encryptedResult.data; - -// Decrypt multiple models at once -const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers); - -if (decryptedResult.failure) { - // Handle the failure -} - -const decryptedUsers = decryptedResult.data; -``` - -The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object. -They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema. - -### Bulk operations - -Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually. - -> [!TIP] -> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS. - -#### Bulk encryption - -Use the `bulkEncrypt` method to encrypt multiple plaintext values at once: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -// Array of plaintext values with optional IDs for correlation -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -if (encryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk encrypting:", - encryptedResult.failure.type, - encryptedResult.failure.message - ); -} - -const encryptedData = encryptedResult.data; -console.log("encrypted data:", encryptedData); -``` - -The `bulkEncrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: EncryptedPayload }, - { id: "user2", data: EncryptedPayload }, - { id: "user3", data: EncryptedPayload }, -] -``` - -You can also encrypt without IDs if you don't need correlation: - -```typescript -const plaintexts = [ - { plaintext: "alice@example.com" }, - { plaintext: "bob@example.com" }, - { plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); -``` - -#### Bulk decryption - -Use the `bulkDecrypt` method to decrypt multiple encrypted values at once: - -```typescript -import { protectClient } from "./protect"; - -// encryptedData is the result from bulkEncrypt -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle the failure - console.log( - "error when bulk decrypting:", - decryptedResult.failure.type, - decryptedResult.failure.message - ); -} - -const decryptedData = decryptedResult.data; -console.log("decrypted data:", decryptedData); -``` - -The `bulkDecrypt` method returns an array of objects with the following structure: - -```typescript -[ - { id: "user1", data: "alice@example.com" }, - { id: "user2", data: "bob@example.com" }, - { id: "user3", data: "charlie@example.com" }, -] -``` - -#### Response structure - -The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes: - -- **Success**: The item has a `data` field containing the decrypted plaintext -- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed - -```typescript -// Example response structure -{ - data: [ - { id: "user1", data: "alice@example.com" }, // Success - { id: "user2", error: "Invalid ciphertext format" }, // Failure - { id: "user3", data: "charlie@example.com" }, // Success - ] -} -``` - -> [!NOTE] -> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions. - -You can handle mixed results by checking each item: - -```typescript -const decryptedResult = await protectClient.bulkDecrypt(encryptedData); - -if (decryptedResult.failure) { - // Handle overall operation failure - console.log("Bulk decryption failed:", decryptedResult.failure.message); - return; -} - -// Process individual results -decryptedResult.data.forEach((item) => { - if ('data' in item) { - // Success - item.data contains the decrypted plaintext - console.log(`Decrypted ${item.id}:`, item.data); - } else if ('error' in item) { - // Failure - item.error contains the specific error message - console.log(`Failed to decrypt ${item.id}:`, item.error); - } -}); -``` - -#### Handling null values - -Bulk operations properly handle null values in both encryption and decryption: - -```typescript -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: null }, - { id: "user3", plaintext: "charlie@example.com" }, -]; - -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Null values are preserved in the encrypted result -// encryptedResult.data[1].data will be null - -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); - -// Null values are preserved in the decrypted result -// decryptedResult.data[1].data will be null -``` - -#### Using bulk operations with lock contexts - -Bulk operations support identity-aware encryption through lock contexts: - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -const lc = new LockContext(); -const lockContext = await lc.identify(userJwt); - -if (lockContext.failure) { - // Handle the failure -} - -const plaintexts = [ - { id: "user1", plaintext: "alice@example.com" }, - { id: "user2", plaintext: "bob@example.com" }, -]; - -// Encrypt with lock context -const encryptedResult = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data); - -// Decrypt with lock context -const decryptedResult = await protectClient - .bulkDecrypt(encryptedResult.data) - .withLockContext(lockContext.data); -``` - -#### Performance considerations - -Bulk operations are optimized for performance and can handle thousands of values efficiently: - -```typescript -// Create a large array of values -const plaintexts = Array.from({ length: 1000 }, (_, i) => ({ - id: `user${i}`, - plaintext: `user${i}@example.com`, -})); - -// Single call to ZeroKMS for all 1000 values -const encryptedResult = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, -}); - -// Single call to ZeroKMS for all 1000 values -const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data); -``` - -The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities. - -### Store encrypted data in a database - -Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment. - -To store the encrypted data, specify the column type as `jsonb`. - -```sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - email jsonb NOT NULL, -); -``` - -#### Searchable encryption in PostgreSQL - -To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation). - -1. Download the EQL install script. The version is pinned to match this release of Protect.js — install exactly this version: - - ```sh - curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt.sql - ``` - - Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase. - Download the matching Supabase EQL install script: - - ```sh - curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt-supabase.sql - ``` - -2. Run this command to install the custom types and functions: - - ```sh - psql -f cipherstash-encrypt.sql - ``` - - or with Supabase: - - ```sh - psql -f cipherstash-encrypt-supabase.sql - ``` - -EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column. - -```sql -CREATE TABLE users ( - id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - email eql_v2_encrypted -); -``` - -> [!WARNING] -> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects. -> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](https://cipherstash.com/docs). - -Read more about [how to search encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) in the docs. - -## Identity-aware encryption - -> [!IMPORTANT] -> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider. -> Read more about [lock contexts with Clerk and Next.js](https://cipherstash.com/docs/stack/cipherstash/encryption/identity). - -Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption. - -This ensures that only the user who encrypted data is able to decrypt it. - -Protect.js does this through a mechanism called a _lock context_. - -### Lock context - -Lock contexts ensure that only specific users can access sensitive data. - -> [!CAUTION] -> You must use the same lock context to encrypt and decrypt data. -> If you use different lock contexts, you will be unable to decrypt the data. - -To use a lock context, initialize a `LockContext` object with the identity claims. - -```typescript -import { LockContext } from "@cipherstash/protect/identify"; - -// protectClient from the previous steps -const lc = new LockContext(); -``` - -> [!NOTE] -> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim. - -### Identifying a user for a lock context - -A lock context needs to be locked to a user. -To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session: - -```typescript -const identifyResult = await lc.identify(jwt); - -// The identify method returns the same Result pattern as the encrypt and decrypt methods. -if (identifyResult.failure) { - // Hanlde the failure -} - -const lockContext = identifyResult.data; -``` - -### Encrypting data with a lock context - -To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const encryptResult = await protectClient - .encrypt("plaintext", { - table: users, - column: users.email, - }) - .withLockContext(lockContext); - -if (encryptResult.failure) { - // Handle the failure -} - -console.log("EQL Payload containing ciphertexts:", encryptResult.data); -``` - -### Decrypting data with a lock context - -To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter: - -```typescript -import { protectClient } from "./protect"; - -const decryptResult = await protectClient - .decrypt(encryptResult.data) - .withLockContext(lockContext); - -if (decryptResult.failure) { - // Handle the failure -} - -const plaintext = decryptResult.data; -``` - -### Model encryption with lock context - -All model operations support lock contexts for identity-aware encryption: - -```typescript -import { protectClient } from "./protect"; -import { users } from "./protect/schema"; - -const myUsers = [ - { - id: "1", - email: "user@example.com", - address: "123 Main St", - createdAt: new Date("2024-01-01"), - }, - { - id: "2", - email: "user2@example.com", - address: "456 Oak Ave", - }, -]; - -// Encrypt a model with lock context -const encryptedResult = await protectClient - .encryptModel(myUsers[0], users) - .withLockContext(lockContext); - -if (encryptedResult.failure) { - // Handle the failure -} - -// Decrypt a model with lock context -const decryptedResult = await protectClient - .decryptModel(encryptedResult.data) - .withLockContext(lockContext); - -// Bulk operations also support lock contexts -const bulkEncryptedResult = await protectClient - .bulkEncryptModels(myUsers, users) - .withLockContext(lockContext); - -const bulkDecryptedResult = await protectClient - .bulkDecryptModels(bulkEncryptedResult.data) - .withLockContext(lockContext); -``` - -## Supported data types - -Protect.js currently supports encrypting and decrypting text. -Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon. - -Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/stack/issues/48). - -## Searchable encryption - -Protect.js supports searching encrypted data in PostgreSQL: - -- **Text columns**: Use `.equality()`, `.freeTextSearch()`, and `.orderAndRange()` for exact match, text search, and sorting/range queries. -- **JSONB columns**: Use `.searchableJson()` (recommended) to enable encrypted JSONPath selector and containment queries on JSON data. The query operation is automatically inferred from the plaintext type. - -Read more about [searching encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) and the [PostgreSQL implementation details](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) in the docs. - -## Multi-tenant encryption - -Protect.js supports multi-tenant encryption by using keysets. -Each keyset is cryptographically isolated from other keysets which esentially means that each tenant has their own unique keyspace. -If you are using a multi-tenant application, you can use keysets to encrypt data for each tenant creating a strong security boundary. - -In the [CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/encryption/keysets), you can create and manage keysets and then use the keyset identifier to encrypt data for each tenant when initializing the Protect.js client. - -```typescript -import { protect } from "@cipherstash/protect"; -import { users } from "./protect/schema"; - -const protectClient = await protect({ - schemas: [users], - keyset: { - // Must be a valid UUID which can be found in the CipherStash Dashboard - id: '123e4567-e89b-12d3-a456-426614174000' - }, -}) - -// or with a keyset name - -const protectClient = await protect({ - schemas: [users], - keyset: { - name: 'Company A' - }, -}) -``` - -> [!IMPORTANT] -> When creating a new keyset, make sure to grant your client access to the keyset or client initialization will fail. -> Read more about [managing keyset access](https://cipherstash.com/docs/platform/workspaces/key-sets). - -## Logging - -> [!TIP] -> `@cipherstash/protect` will NEVER log plaintext data. -> This is by design to prevent sensitive data from leaking into logs. - -`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default. -To enable the logger, configure the following environment variable: - -```bash -PROTECT_LOG_LEVEL=debug # Enable debug logging -PROTECT_LOG_LEVEL=info # Enable info logging -PROTECT_LOG_LEVEL=error # Enable error logging -``` - -## CipherStash Client - -Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package. -The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/stack-ffi). - -Read more about configuring the CipherStash Client in the [configuration docs](https://cipherstash.com/docs/stack/reference). - -## Example applications - -Looking for examples of how to use Protect.js? -Check out the [example applications](./examples): - -- [Basic example](/examples/basic) demonstrates how to perform encryption operations -- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM -- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption - -`@cipherstash/protect` can be used with most ORMs. -If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/stack/issues/new). - -## Builds and bundling - -`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package. - -Here are a few resources to help based on your tool set: - -- [Required Next.js configuration](https://cipherstash.com/docs/stack/deploy/bundling). -- [SST and AWS serverless functions](https://cipherstash.com/docs/stack/deploy/bundling). - -> [!TIP] -> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [bundling guide](https://cipherstash.com/docs/stack/deploy/bundling). - -## Contributing - -Please read the [contribution guide](CONTRIBUTE.md). - -## License - -Protect.js is [MIT licensed](./LICENSE.md). - ---- - -### Didn't find what you wanted? - -[Click here to let us know what was missing from our docs.](https://github.com/cipherstash/stack/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md) diff --git a/packages/protect/__tests__/audit.test.ts b/packages/protect/__tests__/audit.test.ts deleted file mode 100644 index 6d508f585..000000000 --- a/packages/protect/__tests__/audit.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - auditable: csColumn('auditable'), - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - address?: string | null - auditable?: string | null - createdAt?: Date - updatedAt?: Date - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption with audit', () => { - it('should encrypt and decrypt a payload with audit metadata', async () => { - const email = 'very_secret_data' - - const ciphertext = await protectClient - .encrypt(email, { - column: users.auditable, - table: users, - }) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt', - }, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data).audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt', - }, - }) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) - - it('should encrypt and decrypt a model with audit metadata', async () => { - // Create a model with decrypted values - const decryptedModel: User = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - auditable: 'sensitive_data', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - } - - // Encrypt the model with audit - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt_model', - }, - }) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.auditable).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model with audit - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt_model', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in a model with audit metadata', async () => { - // Create a model with null values - const decryptedModel: User = { - id: '1', - email: null, - address: null, - auditable: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - } - - // Encrypt the model with audit - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt_model_nulls', - }, - }) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toBeNull() - expect(encryptedModel.data.address).toBeNull() - expect(encryptedModel.data.auditable).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model with audit - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt_model_nulls', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('bulk encryption with audit', () => { - it('should bulk encrypt and decrypt models with audit metadata', async () => { - // Create models with decrypted values - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - auditable: 'sensitive_data_1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - auditable: 'sensitive_data_2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - ] - - // Encrypt the models with audit - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_models', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[0].auditable).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].auditable).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - - // Decrypt the models with audit - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_models', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should handle mixed null and non-null values in bulk operations with audit', async () => { - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - address: null, - auditable: 'sensitive_data_1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: null, - address: '123 Main St', - auditable: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - email: 'test3@example.com', - address: '456 Oak St', - auditable: 'sensitive_data_3', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, - ] - - // Encrypt the models with audit - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_mixed_nulls', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toBeNull() - expect(encryptedModels.data[0].auditable).toHaveProperty('c') - expect(encryptedModels.data[1].email).toBeNull() - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].auditable).toBeNull() - expect(encryptedModels.data[2].email).toHaveProperty('c') - expect(encryptedModels.data[2].address).toHaveProperty('c') - expect(encryptedModels.data[2].auditable).toHaveProperty('c') - - // Decrypt the models with audit - const decryptedResult = await protectClient - .bulkDecryptModels(decryptedModels) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_mixed_nulls', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should return empty array if models is empty with audit', async () => { - // Encrypt empty array of models with audit - const encryptedModels = await protectClient - .bulkEncryptModels([], users) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_empty', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - expect(encryptedModels.data).toEqual([]) - - // Decrypt empty array of models with audit - const decryptedResult = await protectClient - .bulkDecryptModels([]) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_empty', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([]) - }, 30000) -}) - -describe('audit with lock context', () => { - it('should encrypt and decrypt a model with both audit and lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel: User = { - id: '1', - email: 'test@example.com', - auditable: 'sensitive_with_context', - } - - // Encrypt the model with both audit and lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'encrypt_with_context', - }, - }) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model with both audit and lock context - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'decrypt_with_context', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should bulk encrypt and decrypt models with both audit and lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - auditable: 'bulk_sensitive_1', - }, - { - id: '2', - email: 'test2@example.com', - auditable: 'bulk_sensitive_2', - }, - ] - - // Encrypt the models with both audit and lock context - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_encrypt_with_context', - }, - }) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models with both audit and lock context - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) - .audit({ - metadata: { - sub: 'cj@cjb.io', - type: 'bulk_decrypt_with_context', - }, - }) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) diff --git a/packages/protect/__tests__/basic-protect.test.ts b/packages/protect/__tests__/basic-protect.test.ts deleted file mode 100644 index 6277a842a..000000000 --- a/packages/protect/__tests__/basic-protect.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('json'), -}) - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption', () => { - it('should encrypt and decrypt a payload', async () => { - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const a = ciphertext.data - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) -}) diff --git a/packages/protect/__tests__/bulk-protect.test.ts b/packages/protect/__tests__/bulk-protect.test.ts deleted file mode 100644 index 893bea86a..000000000 --- a/packages/protect/__tests__/bulk-protect.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { type EncryptedPayload, LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('bulk encryption and decryption', () => { - describe('bulk encrypt', () => { - it('should bulk encrypt an array of plaintexts with IDs', async () => { - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - - // Verify all encrypted values are different - expect(encryptedData.data[0].data?.c).not.toBe( - encryptedData.data[1].data?.c, - ) - expect(encryptedData.data[1].data?.c).not.toBe( - encryptedData.data[2].data?.c, - ) - expect(encryptedData.data[0].data?.c).not.toBe( - encryptedData.data[2].data?.c, - ) - }, 30000) - - it('should bulk encrypt an array of plaintexts without IDs', async () => { - const plaintexts = [ - { plaintext: 'alice@example.com' }, - { plaintext: 'bob@example.com' }, - { plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', undefined) - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', undefined) - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - expect(encryptedData.data[2]).toHaveProperty('id', undefined) - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - }, 30000) - - it('should handle null values in bulk encrypt', async () => { - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - }, 30000) - - it('should handle all null values in bulk encrypt', async () => { - const plaintexts = [ - { id: 'user1', plaintext: null }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: null }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toBeNull() - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toBeNull() - }, 30000) - - it('should handle empty array in bulk encrypt', async () => { - const plaintexts: Array<{ id?: string; plaintext: string | null }> = [] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - expect(encryptedData.data).toHaveLength(0) - }, 30000) - }) - - describe('bulk decrypt', () => { - it('should bulk decrypt an array of encrypted payloads with IDs', async () => { - // First encrypt some data - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should bulk decrypt an array of encrypted payloads without IDs', async () => { - // First encrypt some data - const plaintexts = [ - { plaintext: 'alice@example.com' }, - { plaintext: 'bob@example.com' }, - { plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', undefined) - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', undefined) - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', undefined) - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should handle null values in bulk decrypt', async () => { - // First encrypt some data with nulls - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should handle all null values in bulk decrypt', async () => { - // First encrypt some data with all nulls - const plaintexts = [ - { id: 'user1', plaintext: null }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: null }, - ] - - const encryptedData = await protectClient.bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify structure - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', null) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', null) - }, 30000) - - it('should handle empty array in bulk decrypt', async () => { - const encryptedPayloads: Array<{ id?: string; data: EncryptedPayload }> = - [] - - const decryptedData = await protectClient.bulkDecrypt(encryptedPayloads) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - expect(decryptedData.data).toHaveLength(0) - }, 30000) - }) - - describe('bulk operations with lock context', () => { - it('should bulk encrypt and decrypt with lock context', async () => { - // This test requires a valid JWT token, so we'll skip it in CI - // TODO: Add proper JWT token handling for CI - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - // Encrypt with lock context - const encryptedData = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com') - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty( - 'data', - 'charlie@example.com', - ) - }, 30000) - - it('should handle null values with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const plaintexts = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 'charlie@example.com' }, - ] - - // Encrypt with lock context - const encryptedData = await protectClient - .bulkEncrypt(plaintexts, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify null is preserved - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify null is preserved - expect(decryptedData.data[1]).toHaveProperty('data') - expect(decryptedData.data[1].data).toBeNull() - }, 30000) - - it('should decrypt mixed lock context payloads with specific lock context', async () => { - const userJwt = process.env.USER_JWT - const user2Jwt = process.env.USER_2_JWT - - if (!userJwt || !user2Jwt) { - console.log( - 'Skipping mixed lock context test - missing USER_JWT or USER_2_JWT', - ) - return - } - - const lc = new LockContext() - const lc2 = new LockContext() - const lockContext1 = await lc.identify(userJwt) - const lockContext2 = await lc2.identify(user2Jwt) - - if (lockContext1.failure) { - throw new Error(`[protect]: ${lockContext1.failure.message}`) - } - - if (lockContext2.failure) { - throw new Error(`[protect]: ${lockContext2.failure.message}`) - } - - // Encrypt first value with USER_JWT lock context - const encryptedData1 = await protectClient - .bulkEncrypt([{ id: 'user1', plaintext: 'alice@example.com' }], { - column: users.email, - table: users, - }) - .withLockContext(lockContext1.data) - - if (encryptedData1.failure) { - throw new Error(`[protect]: ${encryptedData1.failure.message}`) - } - - // Encrypt second value with USER_2_JWT lock context - const encryptedData2 = await protectClient - .bulkEncrypt([{ id: 'user2', plaintext: 'bob@example.com' }], { - column: users.email, - table: users, - }) - .withLockContext(lockContext2.data) - - if (encryptedData2.failure) { - throw new Error(`[protect]: ${encryptedData2.failure.message}`) - } - - // Combine both encrypted payloads - const combinedEncryptedData = [ - ...encryptedData1.data, - ...encryptedData2.data, - ] - - // Decrypt with USER_2_JWT lock context - const decryptedData = await protectClient - .bulkDecrypt(combinedEncryptedData) - .withLockContext(lockContext2.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify both payloads are returned - expect(decryptedData.data).toHaveLength(2) - - // First payload should fail to decrypt since it was encrypted with different lock context - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('error') - expect(decryptedData.data[0]).not.toHaveProperty('data') - - // Second payload should be decrypted since it was encrypted with the same lock context - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com') - expect(decryptedData.data[1]).not.toHaveProperty('error') - }, 30000) - }) - - describe('bulk operations round-trip', () => { - it('should maintain data integrity through encrypt/decrypt cycle', async () => { - const originalData = [ - { id: 'user1', plaintext: 'alice@example.com' }, - { id: 'user2', plaintext: 'bob@example.com' }, - { id: 'user3', plaintext: null }, - { id: 'user4', plaintext: 'dave@example.com' }, - ] - - // Encrypt - const encryptedData = await protectClient.bulkEncrypt(originalData, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Decrypt - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify round-trip integrity - expect(decryptedData.data).toHaveLength(originalData.length) - - for (let i = 0; i < originalData.length; i++) { - expect(decryptedData.data[i].id).toBe(originalData[i].id) - expect(decryptedData.data[i].data).toBe(originalData[i].plaintext) - } - }, 30000) - - it('should handle large arrays efficiently', async () => { - const originalData = Array.from({ length: 100 }, (_, i) => ({ - id: `user${i}`, - plaintext: `user${i}@example.com`, - })) - - // Encrypt - const encryptedData = await protectClient.bulkEncrypt(originalData, { - column: users.email, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Decrypt - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toBe(`user${i}@example.com`) - } - }, 30000) - }) -}) diff --git a/packages/protect/__tests__/deprecated/search-terms.test.ts b/packages/protect/__tests__/deprecated/search-terms.test.ts deleted file mode 100644 index d3c12a773..000000000 --- a/packages/protect/__tests__/deprecated/search-terms.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { describe, expect, it } from 'vitest' -import { protect, type SearchTerm } from '../../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - age: csColumn('age').dataType('number').equality(), - score: csColumn('score').dataType('number').equality(), -}) - -describe('createSearchTerms (deprecated - backward compatibility)', () => { - it('should create search terms with default return type', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 'hello', - column: users.email, - table: users, - }, - { - value: 'world', - column: users.address, - table: users, - }, - ] as SearchTerm[] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - expect(searchTermsResult.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - v: 2, - }), - ]), - ) - }, 30000) - - it('should create search terms with composite-literal return type', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 'hello', - column: users.email, - table: users, - returnType: 'composite-literal', - }, - ] as SearchTerm[] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with escaped-composite-literal return type', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 'hello', - column: users.email, - table: users, - returnType: 'escaped-composite-literal', - }, - ] as SearchTerm[] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^".*"$/) - const unescaped = JSON.parse(result) - expect(unescaped).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with composite-literal return type for numbers', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 42, - column: users.age, - table: users, - returnType: 'composite-literal' as const, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(result.slice(1, -1))).not.toThrow() - }, 30000) - - it('should create search terms with escaped-composite-literal return type for numbers', async () => { - const protectClient = await protect({ schemas: [users] }) - - const searchTerms = [ - { - value: 99, - column: users.score, - table: users, - returnType: 'escaped-composite-literal' as const, - }, - ] - - const searchTermsResult = await protectClient.createSearchTerms(searchTerms) - - if (searchTermsResult.failure) { - throw new Error(`[protect]: ${searchTermsResult.failure.message}`) - } - - const result = searchTermsResult.data[0] as string - expect(result).toMatch(/^".*"$/) - const unescaped = JSON.parse(result) - expect(unescaped).toMatch(/^\(.*\)$/) - expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow() - }, 30000) -}) diff --git a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts b/packages/protect/__tests__/encrypt-query-searchable-json.test.ts deleted file mode 100644 index faa25cb84..000000000 --- a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts +++ /dev/null @@ -1,1061 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { ProtectErrorTypes, protect } from '../src' - -type ProtectClient = Awaited> - -import { - createFailingMockLockContext, - createMockLockContext, - createMockLockContextWithNullContext, - expectFailure, - jsonbSchema, - metadata, - unwrapResult, -} from './fixtures' - -/* - * The `searchableJson` queryType provides a friendlier API by auto-inferring the - * underlying query operation from the plaintext type. It's equivalent to omitting - * queryType on ste_vec columns, but explicit for code clarity. - * - String values → ste_vec_selector (JSONPath queries) - * - Object/Array values → ste_vec_term (containment queries) - */ - -/** Assert encrypted selector output has valid shape */ -function expectSelector(data: any) { - expect(data).toHaveProperty('s') - expect(typeof data.s).toBe('string') - expect(data.s.length).toBeGreaterThan(0) -} - -/** Assert encrypted term output has valid shape */ -function expectTerm(data: any) { - expect(data).toHaveProperty('sv') - expect(Array.isArray(data.sv)).toBe(true) -} - -describe('encryptQuery with searchableJson queryType', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - // Core functionality: auto-inference from plaintext type - - it('auto-infers ste_vec_selector for string plaintext (JSONPath)', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('auto-infers ste_vec_term for object plaintext (containment)', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('auto-infers ste_vec_term for nested object', async () => { - const result = await protectClient.encryptQuery( - { user: { profile: { role: 'admin' } } }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('auto-infers ste_vec_term for array plaintext', async () => { - const result = await protectClient.encryptQuery(['admin', 'user'], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('returns null for null plaintext', async () => { - const result = await protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - // Edge cases: number/boolean require wrapping (same as steVecTerm) - - it('fails for bare number plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(42, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - expectFailure(result, /Wrap the number in a JSON object/) - }, 30000) - - it('fails for bare boolean plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(true, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - expectFailure(result, /Wrap the boolean in a JSON object/) - }, 30000) -}) - -describe('encryptQuery with searchableJson column and omitted queryType', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('auto-infers ste_vec_selector for string plaintext (JSONPath)', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('auto-infers ste_vec_term for object plaintext (containment)', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('returns null for null plaintext', async () => { - const result = await protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('fails for bare number plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(42, { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - expectFailure(result, /Wrap the number in a JSON object/) - }, 30000) - - it('fails for bare boolean plaintext (requires wrapping)', async () => { - const result = await protectClient.encryptQuery(true, { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - expectFailure(result, /Wrap the boolean in a JSON object/) - }, 30000) -}) - -describe('searchableJson validation', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('throws when used on column without ste_vec index', async () => { - const result = await protectClient.encryptQuery('$.path', { - column: metadata.raw, // raw column has no ste_vec index - table: metadata, - queryType: 'searchableJson', - }) - - expectFailure(result) - }, 30000) -}) - -describe('searchableJson batch operations', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('handles mixed plaintext types in single batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', // string → ste_vec_selector - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: { role: 'admin' }, // object → ste_vec_term - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: ['tag1', 'tag2'], // array → ste_vec_term - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectSelector(data[0]) - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectTerm(data[1]) - expect(data[2]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectTerm(data[2]) - }, 30000) - - it('handles null values in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expectSelector(data[1]) - expect(data[2]).toBeNull() - }, 30000) - - it('can be mixed with explicit steVecSelector/steVecTerm in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.path1', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', // auto-infer - }, - { - value: '$.path2', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', // explicit - }, - { - value: { key: 'value' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', // explicit - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expectSelector(data[0]) - expectSelector(data[1]) - expectTerm(data[2]) - }, 30000) - - it('can omit queryType for searchableJson in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.path1', - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - { - value: { key: 'value' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expectSelector(data[0]) - expectTerm(data[1]) - expect(data[2]).toBeNull() - }, 30000) -}) - -describe('searchableJson with returnType formatting', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('supports composite-literal returnType', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'composite-literal', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: ("json") - expect(data[0]).toMatch(/^\(".*"\)$/) - }, 30000) - - it('supports escaped-composite-literal returnType', async () => { - const result = await protectClient.encryptQuery([ - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: "(\"json\")" - outer quotes with escaped inner quotes - expect(data[0]).toMatch(/^"\(.*\)"$/) - }, 30000) - - describe('single-value returnType', () => { - it('returns composite-literal for selector', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'composite-literal', - }) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns composite-literal for term', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'composite-literal', - }, - ) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns escaped-composite-literal for selector', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'escaped-composite-literal', - }) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data as string).toMatch(/^"\(.*\)"$/) - // JSON.parse should yield the composite-literal format - const parsed = JSON.parse(data as string) - expect(parsed).toMatch(/^\(.*\)$/) - }, 30000) - - it('returns escaped-composite-literal for term', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - returnType: 'escaped-composite-literal', - }, - ) - - const data = unwrapResult(result) - expect(typeof data).toBe('string') - expect(data as string).toMatch(/^"\(.*\)"$/) - const parsed = JSON.parse(data as string) - expect(parsed).toMatch(/^\(.*\)$/) - }, 30000) - - it('returns Encrypted object when returnType is omitted', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) as any - expect(typeof data).toBe('object') - expect(data).toHaveProperty('i') - expect(data.i).toHaveProperty('t') - expect(data.i).toHaveProperty('c') - }, 30000) - }) -}) - -describe('searchableJson with LockContext', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('exposes withLockContext method', async () => { - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - expect(operation.withLockContext).toBeDefined() - expect(typeof operation.withLockContext).toBe('function') - }) - - it('executes string plaintext with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('executes object plaintext with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // LockContext should be called even if the actual encryption fails - // with a mock token (ste_vec_term operations may require real auth) - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - // Ensure the operation actually completed (has either data or failure) - expect(result.data !== undefined || result.failure !== undefined).toBe(true) - - // The result may fail due to mock token, but we verify LockContext integration worked - if (result.data) { - expect(result.data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(result.data) - } - }, 30000) - - it('executes batch with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // LockContext should be called even if the actual encryption fails - // with a mock token (ste_vec_term operations may require real auth) - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - // The result may fail due to mock token, but we verify LockContext integration worked - if (result.data) { - expect(result.data).toHaveLength(2) - } - }, 30000) - - it('handles LockContext failure gracefully', async () => { - const mockLockContext = createFailingMockLockContext( - ProtectErrorTypes.CtsTokenError, - 'Mock LockContext failure', - ) - - const operation = protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expectFailure( - result, - 'Mock LockContext failure', - ProtectErrorTypes.CtsTokenError, - ) - }, 30000) - - it('handles null value with LockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Null values should return null without calling LockContext - // since there's nothing to encrypt - expect(mockLockContext.getLockContext).not.toHaveBeenCalled() - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('handles explicit null context from getLockContext gracefully', async () => { - const mockLockContext = createMockLockContextWithNullContext() - - const operation = protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Should succeed - null context should not be passed to FFI - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expectSelector(data[0]) - }, 30000) -}) - -describe('searchableJson equivalence', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('produces identical metadata to omitting queryType for string', async () => { - const explicitResult = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const implicitResult = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - }) - - // Both should succeed and have identical metadata structure - const explicitData = unwrapResult(explicitResult) - const implicitData = unwrapResult(implicitResult) - - expect(explicitData.i).toEqual(implicitData.i) - expect(explicitData.v).toEqual(implicitData.v) - expectSelector(explicitData) - expectSelector(implicitData) - }, 30000) - - it('produces identical metadata to omitting queryType for object', async () => { - const explicitResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const implicitResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - }, - ) - - const explicitData = unwrapResult(explicitResult) - const implicitData = unwrapResult(implicitResult) - - expect(explicitData.i).toEqual(implicitData.i) - expect(explicitData.v).toEqual(implicitData.v) - expectTerm(explicitData) - expectTerm(implicitData) - }, 30000) - - it('produces identical metadata to explicit steVecSelector for string', async () => { - const searchableJsonResult = await protectClient.encryptQuery( - '$.user.email', - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const steVecSelectorResult = await protectClient.encryptQuery( - '$.user.email', - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ) - - const searchableJsonData = unwrapResult(searchableJsonResult) - const steVecSelectorData = unwrapResult(steVecSelectorResult) - - expect(searchableJsonData.i).toEqual(steVecSelectorData.i) - expect(searchableJsonData.v).toEqual(steVecSelectorData.v) - expectSelector(searchableJsonData) - expectSelector(steVecSelectorData) - }, 30000) - - it('produces identical metadata to explicit steVecTerm for object', async () => { - const searchableJsonResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const steVecTermResult = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ) - - const searchableJsonData = unwrapResult(searchableJsonResult) - const steVecTermData = unwrapResult(steVecTermResult) - - expect(searchableJsonData.i).toEqual(steVecTermData.i) - expect(searchableJsonData.v).toEqual(steVecTermData.v) - expectTerm(searchableJsonData) - expectTerm(steVecTermData) - }, 30000) -}) - -describe('searchableJson edge cases', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - // Valid edge cases that should succeed - - it('succeeds for empty object', async () => { - const result = await protectClient.encryptQuery( - {}, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for empty array', async () => { - const result = await protectClient.encryptQuery([], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for object with wrapped number', async () => { - const result = await protectClient.encryptQuery( - { value: 42 }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for object with wrapped boolean', async () => { - const result = await protectClient.encryptQuery( - { active: true }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for object with null value', async () => { - const result = await protectClient.encryptQuery( - { field: null }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - it('succeeds for deeply nested object (3+ levels)', async () => { - const result = await protectClient.encryptQuery( - { - level1: { - level2: { - level3: { - level4: { - value: 'deep', - }, - }, - }, - }, - }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectTerm(data) - }, 30000) - - // String edge cases for JSONPath selectors - - it('succeeds for JSONPath with array index notation', async () => { - const result = await protectClient.encryptQuery('$.items[0].name', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) - - it('succeeds for JSONPath with wildcard', async () => { - const result = await protectClient.encryptQuery('$.items[*].name', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - expectSelector(data) - }, 30000) -}) - -describe('searchableJson batch edge cases', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('handles single-item batch identically to scalar', async () => { - const scalarResult = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }) - - const batchResult = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const scalarData = unwrapResult(scalarResult) - const batchData = unwrapResult(batchResult) - - expect(batchData).toHaveLength(1) - expect(batchData[0].i).toEqual(scalarData.i) - expect(batchData[0].v).toEqual(scalarData.v) - expectSelector(scalarData) - expectSelector(batchData[0]) - }, 30000) - - it('handles all-null batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(data[1]).toBeNull() - expect(data[2]).toBeNull() - }, 30000) - - it('handles empty batch', async () => { - const result = await protectClient.encryptQuery([]) - - const data = unwrapResult(result) - expect(data).toHaveLength(0) - }, 30000) - - it('handles large batch (10+ items)', async () => { - const items = Array.from({ length: 12 }, (_, i) => ({ - value: i % 2 === 0 ? `$.path${i}` : { index: i }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson' as const, - })) - - const result = await protectClient.encryptQuery(items) - - const data = unwrapResult(result) - expect(data).toHaveLength(12) - data.forEach((item: any, idx: number) => { - expect(item).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - v: 2, - }) - if (idx % 2 === 0) { - expectSelector(item) - } else { - expectTerm(item) - } - }) - }, 30000) - - it('handles multiple interspersed nulls at various positions', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'searchableJson', - }, - ]) - - const data = unwrapResult(result) - expect(data).toHaveLength(5) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expectSelector(data[1]) - expect(data[2]).toBeNull() - expect(data[3]).not.toBeNull() - expectTerm(data[3]) - expect(data[4]).toBeNull() - }, 30000) -}) diff --git a/packages/protect/__tests__/encrypt-query-stevec.test.ts b/packages/protect/__tests__/encrypt-query-stevec.test.ts deleted file mode 100644 index 7c536d725..000000000 --- a/packages/protect/__tests__/encrypt-query-stevec.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -type ProtectClient = Awaited> - -import { expectFailure, jsonbSchema, metadata, unwrapResult } from './fixtures' - -describe('encryptQuery with steVecSelector', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('encrypts a JSONPath selector', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('encrypts nested path selector', async () => { - const result = await protectClient.encryptQuery('$.user.profile.settings', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('fails for non-string plaintext with steVecSelector (object)', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ) - - expectFailure(result) - }, 30000) -}) - -describe('encryptQuery with steVecTerm', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('encrypts an object for containment query', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('encrypts nested object for containment', async () => { - const result = await protectClient.encryptQuery( - { user: { profile: { role: 'admin' } } }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('encrypts array for containment query', async () => { - const result = await protectClient.encryptQuery([1, 2, 3], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('rejects string plaintext with steVecTerm', async () => { - // steVecTerm requires object or array, not string - // For path queries like '$.field', use steVecSelector instead - const result = await protectClient.encryptQuery('search text', { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }) - - expectFailure(result, /expected JSON object or array/) - }, 30000) -}) - -describe('encryptQuery STE Vec validation', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('throws when steVecSelector used on non-ste_vec column', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: metadata.raw, // raw column has no ste_vec index - table: metadata, - queryType: 'steVecSelector', - }) - - expectFailure(result) - }, 30000) - - it('throws when steVecTerm used on non-ste_vec column', async () => { - const result = await protectClient.encryptQuery( - { field: 'value' }, - { - column: metadata.raw, // raw column has no ste_vec index - table: metadata, - queryType: 'steVecTerm', - }, - ) - - expectFailure(result) - }, 30000) -}) - -describe('encryptQuery batch with STE Vec', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema, metadata] }) - }) - - it('handles mixed query types in batch (steVecSelector + steVecTerm)', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) - - it('handles multiple steVecSelector queries in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - { - value: '$.settings.theme', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) - - it('handles null values with steVecSelector in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - { - value: '$.user.email', - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecSelector', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) - - it('handles null values with steVecTerm in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - { - value: { role: 'admin' }, - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } }) - }, 30000) -}) - -describe('encryptQuery with queryType inference', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('infers steVecSelector for string plaintext without queryType', async () => { - const result = await protectClient.encryptQuery('$.user.email', { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - should infer steVecSelector from string - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('infers steVecTerm for object plaintext without queryType', async () => { - const result = await protectClient.encryptQuery( - { role: 'admin' }, - { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - should infer steVecTerm from object - }, - ) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('infers steVecTerm for array plaintext without queryType', async () => { - const result = await protectClient.encryptQuery(['admin', 'user'], { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - should infer steVecTerm from array - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) - - it('infers steVecTerm for number plaintext but FFI requires wrapping', async () => { - // Numbers infer steVecTerm but FFI requires wrapping in object/array - const result = await protectClient.encryptQuery(42, { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - infers steVecTerm, FFI rejects with helpful message - }) - - expectFailure(result, /Wrap the number in a JSON object/) - }, 30000) - - it('infers steVecTerm for boolean plaintext but FFI requires wrapping', async () => { - // Booleans infer steVecTerm but FFI requires wrapping in object/array - const result = await protectClient.encryptQuery(true, { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - infers steVecTerm, FFI rejects with helpful message - }) - - expectFailure(result, /Wrap the boolean in a JSON object/) - }, 30000) - - it('returns null for null plaintext (no inference needed)', async () => { - const result = await protectClient.encryptQuery(null, { - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType and null plaintext - should return null - }) - - // Null returns null, doesn't throw - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('uses explicit queryType over plaintext inference', async () => { - // String plaintext would normally infer steVecSelector, but explicit steVecTerm should be used - // Note: steVecTerm with string fails FFI validation, so we test the opposite direction - // Using a number (which would infer steVecTerm) with explicit steVecSelector would also fail - // So we verify with array + steVecTerm (already tested) and trust unit test coverage for precedence - const result = await protectClient.encryptQuery([42], { - column: jsonbSchema.metadata, - table: jsonbSchema, - queryType: 'steVecTerm', // Explicit - matches inference but proves explicit path works - }) - - const data = unwrapResult(result) - expect(data).toBeDefined() - expect(data).toMatchObject({ - i: { t: 'documents', c: 'metadata' }, - }) - }, 30000) -}) - -describe('encryptQuery batch with queryType inference', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ schemas: [jsonbSchema] }) - }) - - it('infers queryOp for each term independently in batch', async () => { - const results = await protectClient.encryptQuery([ - { - value: '$.user.email', // string → steVecSelector - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - }, - { - value: { role: 'admin' }, // object → steVecTerm - column: jsonbSchema.metadata, - table: jsonbSchema, - // No queryType - }, - ]) - - const data = unwrapResult(results) - expect(data).toHaveLength(2) - expect(data[0]).toBeDefined() - expect(data[1]).toBeDefined() - }, 30000) -}) diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts deleted file mode 100644 index 1f64000b2..000000000 --- a/packages/protect/__tests__/encrypt-query.test.ts +++ /dev/null @@ -1,946 +0,0 @@ -import 'dotenv/config' -import { beforeAll, describe, expect, it } from 'vitest' -import { ProtectErrorTypes, protect } from '../src' -import type { ProtectClient } from '../src/ffi' -import { - articles, - createFailingMockLockContext, - createMockLockContext, - createMockLockContextWithNullContext, - expectFailure, - metadata, - products, - unwrapResult, - users, -} from './fixtures' - -describe('encryptQuery', () => { - let protectClient: ProtectClient - - beforeAll(async () => { - protectClient = await protect({ - schemas: [users, articles, products, metadata], - }) - }) - - describe('single value encryption with explicit queryType', () => { - it('encrypts for equality query type', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('encrypts for freeTextSearch query type', async () => { - const result = await protectClient.encryptQuery('hello world', { - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'bio' }, - v: 2, - }) - expect(data).toHaveProperty('bf') - }, 30000) - - it('encrypts for orderAndRange query type', async () => { - const result = await protectClient.encryptQuery(25, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'age' }, - v: 2, - }) - expect(data).toHaveProperty('ob') - }, 30000) - }) - - describe('auto-inference when queryType omitted', () => { - it('auto-infers equality for column with .equality()', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('hm') - }, 30000) - - it('auto-infers freeTextSearch for match-only column', async () => { - const result = await protectClient.encryptQuery('search content', { - column: articles.content, - table: articles, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('bf') - }, 30000) - - it('auto-infers orderAndRange for ore-only column', async () => { - const result = await protectClient.encryptQuery(99.99, { - column: products.price, - table: products, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('ob') - }, 30000) - }) - - describe('edge cases', () => { - it('handles null values', async () => { - const result = await protectClient.encryptQuery(null, { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('rejects NaN values', async () => { - const result = await protectClient.encryptQuery(Number.NaN, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - expectFailure(result, 'NaN') - }, 30000) - - it('rejects Infinity values', async () => { - const result = await protectClient.encryptQuery( - Number.POSITIVE_INFINITY, - { - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ) - - expectFailure(result, 'Infinity') - }, 30000) - - it('rejects negative Infinity values', async () => { - const result = await protectClient.encryptQuery( - Number.NEGATIVE_INFINITY, - { - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ) - - expectFailure(result, 'Infinity') - }, 30000) - }) - - describe('validation errors', () => { - it('fails when queryType does not match column config', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'freeTextSearch', // email only has equality - }) - - expectFailure(result, 'not configured') - }, 30000) - - it('fails when column has no indexes configured', async () => { - const result = await protectClient.encryptQuery('raw data', { - column: metadata.raw, - table: metadata, - }) - - expectFailure(result, 'no indexes configured') - }, 30000) - - it('provides descriptive error for queryType mismatch', async () => { - const result = await protectClient.encryptQuery(42, { - column: users.age, - table: users, - queryType: 'equality', // age only has orderAndRange - }) - - expectFailure(result, 'unique') - expectFailure(result, 'not configured', ProtectErrorTypes.EncryptionError) - }, 30000) - }) - - describe('value/index type compatibility', () => { - it('fails when encrypting number with match index (explicit queryType)', async () => { - const result = await protectClient.encryptQuery(123, { - column: articles.content, // match-only column - table: articles, - queryType: 'freeTextSearch', - }) - - expectFailure(result, 'match') - expectFailure(result, 'numeric') - }, 30000) - - it('fails when encrypting number with auto-inferred match index', async () => { - const result = await protectClient.encryptQuery(123, { - column: articles.content, // match-only column, will infer 'match' - table: articles, - }) - - expectFailure(result, 'match') - }, 30000) - - it('fails in batch when number used with match index', async () => { - const result = await protectClient.encryptQuery([ - { value: 123, column: articles.content, table: articles }, - ]) - - expectFailure(result, 'match') - }, 30000) - - it('allows string with match index', async () => { - const result = await protectClient.encryptQuery('search text', { - column: articles.content, - table: articles, - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('bf') // bloom filter - }, 30000) - - it('allows number with ore index', async () => { - const result = await protectClient.encryptQuery(42, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('ob') // order bits - }, 30000) - }) - - describe('numeric edge cases', () => { - it('encrypts MAX_SAFE_INTEGER', async () => { - const result = await protectClient.encryptQuery(Number.MAX_SAFE_INTEGER, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'age' }, - v: 2, - }) - expect(data).toHaveProperty('ob') - }, 30000) - - it('encrypts MIN_SAFE_INTEGER', async () => { - const result = await protectClient.encryptQuery(Number.MIN_SAFE_INTEGER, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'age' }, - v: 2, - }) - expect(data).toHaveProperty('ob') - }, 30000) - - it('encrypts negative zero', async () => { - const result = await protectClient.encryptQuery(-0, { - column: users.age, - table: users, - queryType: 'orderAndRange', - }) - - const data = unwrapResult(result) - expect(data).toHaveProperty('ob') - }, 30000) - }) - - describe('string edge cases', () => { - it('encrypts empty string', async () => { - const result = await protectClient.encryptQuery('', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('encrypts unicode/emoji strings', async () => { - const result = await protectClient.encryptQuery('Hello 世界 🌍🚀', { - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'bio' }, - v: 2, - }) - expect(data).toHaveProperty('bf') - }, 30000) - - it('encrypts strings with SQL special characters', async () => { - const result = await protectClient.encryptQuery( - "'; DROP TABLE users; --", - { - column: users.email, - table: users, - queryType: 'equality', - }, - ) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - }) - - describe('encryptQuery bulk (array overload)', () => { - it('encrypts multiple terms in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'user@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: 'search term', - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) - expect(data[1]).toMatchObject({ i: { t: 'users', c: 'bio' } }) - expect(data[2]).toMatchObject({ i: { t: 'users', c: 'age' } }) - }, 30000) - - it('handles empty array', async () => { - // Empty arrays without opts are treated as empty batch for backward compatibility - const result = await protectClient.encryptQuery([]) - - const data = unwrapResult(result) - expect(data).toEqual([]) - }, 30000) - - it('handles null values in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).not.toBeNull() - expect(data[1]).toBeNull() - }, 30000) - - it('auto-infers queryType when omitted', async () => { - const result = await protectClient.encryptQuery([ - { value: 'user@example.com', column: users.email, table: users }, - { value: 42, column: users.age, table: users }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(2) - expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') - }, 30000) - - it('rejects NaN/Infinity values in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: Number.NaN, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - { - value: Number.POSITIVE_INFINITY, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - expect(result.failure).toBeDefined() - }, 30000) - - it('rejects negative Infinity in batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: Number.NEGATIVE_INFINITY, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - expectFailure(result, 'Infinity') - }, 30000) - }) - - describe('bulk index preservation', () => { - it('preserves exact positions with multiple nulls interspersed', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: 'user@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - { - value: null, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(5) - expect(data[0]).toBeNull() - expect(data[1]).not.toBeNull() - expect(data[1]).toHaveProperty('hm') - expect(data[2]).toBeNull() - expect(data[3]).toBeNull() - expect(data[4]).not.toBeNull() - expect(data[4]).toHaveProperty('ob') - }, 30000) - - it('handles single-item array', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'single@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) - expect(data[0]).toHaveProperty('hm') - }, 30000) - - it('handles all-null array', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - }, - { - value: null, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(data[1]).toBeNull() - expect(data[2]).toBeNull() - }, 30000) - }) - - describe('audit support', () => { - it('passes audit metadata for single query', async () => { - const result = await protectClient - .encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - .audit({ metadata: { userId: 'test-user' } }) - - const data = unwrapResult(result) - expect(data).toMatchObject({ i: { t: 'users', c: 'email' } }) - }, 30000) - - it('passes audit metadata for bulk query', async () => { - const result = await protectClient - .encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - .audit({ metadata: { userId: 'test-user' } }) - - const data = unwrapResult(result) - expect(data).toHaveLength(1) - }, 30000) - }) - - describe('returnType formatting', () => { - it('returns Encrypted by default (no returnType)', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data[0]).toBe('object') - }, 30000) - - it('returns composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: ("json") - expect(data[0]).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns escaped-composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(typeof data[0]).toBe('string') - // Format: "(\"json\")" - outer quotes with escaped inner quotes - expect(data[0]).toMatch(/^"\(.*\)"$/) - }, 30000) - - it('returns eql format when explicitly specified', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'eql', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(1) - expect(data[0]).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data[0]).toBe('object') - }, 30000) - - it('handles mixed returnType values in same batch', async () => { - const result = await protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, // default - { - value: 'search term', - column: users.bio, - table: users, - queryType: 'freeTextSearch', - returnType: 'composite-literal', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - - // First: default (Encrypted object) - expect(typeof data[0]).toBe('object') - expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } }) - - // Second: composite-literal (string) - expect(typeof data[1]).toBe('string') - expect(data[1]).toMatch(/^\(".*"\)$/) - - // Third: escaped-composite-literal (string) - expect(typeof data[2]).toBe('string') - expect(data[2]).toMatch(/^"\(.*\)"$/) - }, 30000) - - it('handles returnType with null values', async () => { - const result = await protectClient.encryptQuery([ - { - value: null, - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }, - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }, - { - value: null, - column: users.bio, - table: users, - queryType: 'freeTextSearch', - returnType: 'escaped-composite-literal', - }, - ]) - - const data = unwrapResult(result) - - expect(data).toHaveLength(3) - expect(data[0]).toBeNull() - expect(typeof data[1]).toBe('string') - expect(data[1]).toMatch(/^\(".*"\)$/) - expect(data[2]).toBeNull() - }, 30000) - }) - - describe('single-value returnType formatting', () => { - it('returns Encrypted by default (no returnType)', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data).toBe('object') - }, 30000) - - it('returns composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }) - - const data = unwrapResult(result) - - expect(typeof data).toBe('string') - // Format: ("json") - expect(data).toMatch(/^\(".*"\)$/) - }, 30000) - - it('returns escaped-composite-literal format when specified', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'escaped-composite-literal', - }) - - const data = unwrapResult(result) - - expect(typeof data).toBe('string') - // Format: "(\"json\")" - outer quotes with escaped inner quotes - expect(data).toMatch(/^"\(.*\)"$/) - }, 30000) - - it('returns eql format when explicitly specified', async () => { - const result = await protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'eql', - }) - - const data = unwrapResult(result) - - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(typeof data).toBe('object') - }, 30000) - - it('handles null value with composite-literal returnType', async () => { - const result = await protectClient.encryptQuery(null, { - column: users.email, - table: users, - queryType: 'equality', - returnType: 'composite-literal', - }) - - const data = unwrapResult(result) - - expect(data).toBeNull() - }, 30000) - }) - - describe('LockContext support', () => { - it('single query with LockContext calls getLockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - expect(withContext).toHaveProperty('execute') - expect(typeof withContext.execute).toBe('function') - }, 30000) - - it('bulk query with LockContext calls getLockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - expect(withContext).toHaveProperty('execute') - expect(typeof withContext.execute).toBe('function') - }, 30000) - - it('executes single query with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - const data = unwrapResult(result) - expect(data).toMatchObject({ - i: { t: 'users', c: 'email' }, - v: 2, - }) - expect(data).toHaveProperty('hm') - }, 30000) - - it('executes bulk query with LockContext mock', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - { - value: 42, - column: users.age, - table: users, - queryType: 'orderAndRange', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1) - - const data = unwrapResult(result) - expect(data).toHaveLength(2) - expect(data[0]).toHaveProperty('hm') - expect(data[1]).toHaveProperty('ob') - }, 30000) - - it('handles LockContext failure gracefully', async () => { - const mockLockContext = createFailingMockLockContext( - ProtectErrorTypes.CtsTokenError, - 'Mock LockContext failure', - ) - - const operation = protectClient.encryptQuery('test@example.com', { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - expectFailure( - result, - 'Mock LockContext failure', - ProtectErrorTypes.CtsTokenError, - ) - }, 30000) - - it('handles null value with LockContext', async () => { - const mockLockContext = createMockLockContext() - - const operation = protectClient.encryptQuery(null, { - column: users.email, - table: users, - queryType: 'equality', - }) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Null values should return null without calling LockContext - // since there's nothing to encrypt - const data = unwrapResult(result) - expect(data).toBeNull() - }, 30000) - - it('handles explicit null context from getLockContext gracefully', async () => { - // Simulate a runtime scenario where context is null (bypasses TypeScript) - const mockLockContext = createMockLockContextWithNullContext() - - const operation = protectClient.encryptQuery([ - { - value: 'test@example.com', - column: users.email, - table: users, - queryType: 'equality', - }, - ]) - - const withContext = operation.withLockContext(mockLockContext as any) - const result = await withContext.execute() - - // Should succeed - null context should not be passed to FFI - const data = unwrapResult(result) - expect(data).toHaveLength(1) - expect(data[0]).toHaveProperty('hm') - }, 30000) - }) -}) diff --git a/packages/protect/__tests__/error-codes.test.ts b/packages/protect/__tests__/error-codes.test.ts deleted file mode 100644 index d0a3d8596..000000000 --- a/packages/protect/__tests__/error-codes.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import type { ProtectClient } from '../src' -import { FfiProtectError, ProtectErrorTypes, protect } from '../src' - -/** FFI tests require longer timeout due to client initialization */ -const FFI_TEST_TIMEOUT = 30_000 - -/** - * Tests for FFI error code preservation in ProtectError. - * These tests verify that specific FFI error codes are preserved when errors occur, - * enabling programmatic error handling. - */ -describe('FFI Error Code Preservation', () => { - let protectClient: ProtectClient - - // Schema with a valid column for testing - const testSchema = csTable('test_table', { - email: csColumn('email').equality(), - bio: csColumn('bio').freeTextSearch(), - age: csColumn('age').dataType('number').orderAndRange(), - metadata: csColumn('metadata').searchableJson(), - }) - - // Schema without indexes for testing non-FFI validation - const noIndexSchema = csTable('no_index_table', { - raw: csColumn('raw'), - }) - - // Schema with non-existent column for triggering FFI UNKNOWN_COLUMN error - const badModelSchema = csTable('test_table', { - nonexistent: csColumn('nonexistent_column'), - }) - - beforeAll(async () => { - protectClient = await protect({ schemas: [testSchema, noIndexSchema] }) - }) - - describe('FfiProtectError class', () => { - it('constructs with code and message', () => { - const error = new FfiProtectError({ - code: 'UNKNOWN_COLUMN', - message: 'Test error', - }) - expect(error.code).toBe('UNKNOWN_COLUMN') - expect(error.message).toBe('Test error') - }) - }) - - describe('encryptQuery error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column', - async () => { - // Create a fake column that doesn't exist in the schema - const fakeColumn = csColumn('nonexistent_column').equality() - - const result = await protectClient.encryptQuery('test', { - column: fakeColumn, - table: testSchema, - queryType: 'equality', - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for columns without indexes (non-FFI validation)', - async () => { - // This error is caught during pre-FFI validation, not by FFI itself - const result = await protectClient.encryptQuery('test', { - column: noIndexSchema.raw, - table: noIndexSchema, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.message).toContain('no indexes configured') - // Pre-FFI validation errors don't have FFI error codes - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI validation errors', - async () => { - // NaN validation happens before FFI call - const result = await protectClient.encryptQuery(Number.NaN, { - column: testSchema.age, - table: testSchema, - queryType: 'orderAndRange', - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - // Non-FFI errors should have undefined code - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('batch encryptQuery error codes', () => { - it( - 'preserves error code in batch operations', - async () => { - const fakeColumn = csColumn('nonexistent_column').equality() - - const result = await protectClient.encryptQuery([ - { - value: 'test', - column: fakeColumn, - table: testSchema, - queryType: 'equality', - }, - ]) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI batch errors', - async () => { - const result = await protectClient.encryptQuery([ - { - value: Number.NaN, - column: testSchema.age, - table: testSchema, - queryType: 'orderAndRange', - }, - ]) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('encrypt error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column in encrypt', - async () => { - const fakeColumn = csColumn('nonexistent_column') - - const result = await protectClient.encrypt('test', { - column: fakeColumn, - table: testSchema, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI encrypt errors', - async () => { - const result = await protectClient.encrypt(Number.NaN, { - column: testSchema.age, - table: testSchema, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - // NaN validation happens before FFI call - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('decrypt error codes', () => { - it( - 'returns undefined code for malformed ciphertext (non-FFI validation)', - async () => { - // This error occurs during ciphertext parsing, not FFI decryption - const invalidCiphertext = { - i: { t: 'test_table', c: 'nonexistent' }, - v: 2, - c: 'invalid_ciphertext_data', - } - - const result = await protectClient.decrypt(invalidCiphertext) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - // Malformed ciphertext errors are caught before FFI and don't have codes - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkEncrypt error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column', - async () => { - const fakeColumn = csColumn('nonexistent_column') - - const result = await protectClient.bulkEncrypt( - [{ plaintext: 'test1' }, { plaintext: 'test2' }], - { - column: fakeColumn, - table: testSchema, - }, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - - it( - 'returns undefined code for non-FFI validation errors', - async () => { - const result = await protectClient.bulkEncrypt( - [{ plaintext: Number.NaN }], - { - column: testSchema.age, - table: testSchema, - }, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkDecrypt error codes', () => { - it( - 'returns undefined code for malformed ciphertexts (non-FFI validation)', - async () => { - // bulkDecrypt uses the "fallible" FFI API (decryptBulkFallible) which normally: - // - Succeeds at the operation level - // - Returns per-item results with either { data } or { error } - // - // However, malformed ciphertexts cause parsing errors BEFORE the fallible API, - // which throws and triggers a top-level failure (not per-item errors). - // These pre-FFI errors don't have structured FFI error codes. - const invalidCiphertexts = [ - { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' } }, - { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' } }, - ] - - const result = await protectClient.bulkDecrypt(invalidCiphertexts) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - // FFI parsing errors don't have structured error codes - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('encryptModel error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for model with non-existent column', - async () => { - const model = { nonexistent: 'test value' } - - const result = await protectClient.encryptModel(model, badModelSchema) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('decryptModel error codes', () => { - it( - 'returns undefined code for malformed model (non-FFI validation)', - async () => { - const malformedModel = { - email: { - i: { t: 'test_table', c: 'email' }, - v: 2, - c: 'invalid_ciphertext', - }, - } - - const result = await protectClient.decryptModel(malformedModel) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkEncryptModels error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for models with non-existent column', - async () => { - const models = [{ nonexistent: 'value1' }, { nonexistent: 'value2' }] - - const result = await protectClient.bulkEncryptModels( - models, - badModelSchema, - ) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('bulkDecryptModels error codes', () => { - it( - 'returns undefined code for malformed models (non-FFI validation)', - async () => { - const malformedModels = [ - { - email: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' }, - }, - { - email: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' }, - }, - ] - - const result = await protectClient.bulkDecryptModels(malformedModels) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError) - expect(result.failure?.code).toBeUndefined() - }, - FFI_TEST_TIMEOUT, - ) - }) - - describe('searchTerms (deprecated) error codes', () => { - it( - 'returns UNKNOWN_COLUMN code for non-existent column', - async () => { - const fakeColumn = csColumn('nonexistent_column').equality() - - const result = await protectClient.createSearchTerms([ - { value: 'test', column: fakeColumn, table: testSchema }, - ]) - - expect(result.failure).toBeDefined() - expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError) - expect(result.failure?.code).toBe('UNKNOWN_COLUMN') - }, - FFI_TEST_TIMEOUT, - ) - }) -}) diff --git a/packages/protect/__tests__/fixtures/index.ts b/packages/protect/__tests__/fixtures/index.ts deleted file mode 100644 index fa892b375..000000000 --- a/packages/protect/__tests__/fixtures/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { csColumn, csTable } from '@cipherstash/schema' -import { expect, vi } from 'vitest' - -// ============ Schema Fixtures ============ - -/** - * Users table with multiple index types for testing - */ -export const users = csTable('users', { - email: csColumn('email').equality(), - bio: csColumn('bio').freeTextSearch(), - age: csColumn('age').dataType('number').orderAndRange(), -}) - -/** - * Articles table with only freeTextSearch (for auto-inference test) - */ -export const articles = csTable('articles', { - content: csColumn('content').freeTextSearch(), -}) - -/** - * Products table with only orderAndRange (for auto-inference test) - */ -export const products = csTable('products', { - price: csColumn('price').dataType('number').orderAndRange(), -}) - -/** - * Metadata table with no indexes (for validation error test) - */ -export const metadata = csTable('metadata', { - raw: csColumn('raw'), -}) - -/** - * Documents table with searchable JSON column (for STE Vec queries) - */ -export const jsonbSchema = csTable('documents', { - id: csColumn('id'), - metadata: csColumn('metadata').searchableJson(), -}) - -/** - * Schema fixture with mixed column types including JSON. - */ -export const mixedSchema = csTable('records', { - id: csColumn('id'), - email: csColumn('email').equality(), - name: csColumn('name').freeTextSearch(), - metadata: csColumn('metadata').searchableJson(), -}) - -// ============ Mock Factories ============ - -/** - * Creates a mock LockContext with successful response - */ -export function createMockLockContext(overrides?: { - accessToken?: string - expiry?: number - identityClaim?: string[] -}) { - return { - getLockContext: vi.fn().mockResolvedValue({ - data: { - ctsToken: { - accessToken: overrides?.accessToken ?? 'mock-token', - expiry: overrides?.expiry ?? Date.now() + 3600000, - }, - context: { - identityClaim: overrides?.identityClaim ?? ['sub'], - }, - }, - }), - } -} - -/** - * Creates a mock LockContext with explicit null context (simulates runtime edge case) - */ -export function createMockLockContextWithNullContext() { - return { - getLockContext: vi.fn().mockResolvedValue({ - data: { - ctsToken: { - accessToken: 'mock-token', - expiry: Date.now() + 3600000, - }, - context: null, // Explicit null - simulating runtime edge case - }, - }), - } -} - -/** - * Creates a mock LockContext that returns a failure - */ -export function createFailingMockLockContext( - errorType: string, - message: string, -) { - return { - getLockContext: vi.fn().mockResolvedValue({ - failure: { type: errorType, message }, - }), - } -} - -// ============ Test Helpers ============ - -/** - * Unwraps a Result type, throwing an error if it's a failure. - * Use this to simplify test assertions when you expect success. - */ -export function unwrapResult(result: { - data?: T - failure?: { message: string } -}): T { - if (result.failure) { - throw new Error(result.failure.message) - } - return result.data as T -} - -/** - * Asserts that a result is a failure with optional message and type matching - */ -export function expectFailure( - result: { failure?: { message: string; type?: string } }, - messagePattern?: string | RegExp, - expectedType?: string, -) { - expect(result.failure).toBeDefined() - if (messagePattern) { - if (typeof messagePattern === 'string') { - expect(result.failure?.message).toContain(messagePattern) - } else { - expect(result.failure?.message).toMatch(messagePattern) - } - } - if (expectedType) { - expect(result.failure?.type).toBe(expectedType) - } -} diff --git a/packages/protect/__tests__/helpers.test.ts b/packages/protect/__tests__/helpers.test.ts deleted file mode 100644 index 566ff2554..000000000 --- a/packages/protect/__tests__/helpers.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - bulkModelsToEncryptedPgComposites, - encryptedToCompositeLiteral, - encryptedToPgComposite, - isEncryptedPayload, - isEncryptedScalarQuery, - modelToEncryptedPgComposites, -} from '../src/helpers' - -describe('helpers', () => { - describe('encryptedToPgComposite', () => { - it('should convert encrypted payload to pg composite', () => { - const encrypted = { - v: 1, - c: 'ciphertext', - i: { - c: 'iv', - t: 't', - }, - k: 'k', - ob: ['a', 'b'], - bf: [1, 2, 3], - hm: 'hm', - } - - const pgComposite = encryptedToPgComposite(encrypted) - expect(pgComposite).toEqual({ - data: encrypted, - }) - }) - }) - - describe('encryptedToCompositeLiteral', () => { - it('should convert encrypted payload to pg composite literal string', () => { - const encrypted = { - v: 1, - c: 'ciphertext', - i: { - c: 'iv', - t: 't', - }, - } - - const literal = encryptedToCompositeLiteral(encrypted) - // Should produce PostgreSQL composite literal format: ("json_string") - expect(literal).toMatch(/^\(.*\)$/) - // The inner content should be a valid JSON string (double-stringified) - const innerContent = literal.slice(1, -1) // Remove outer parentheses - expect(() => JSON.parse(innerContent)).not.toThrow() - // Parsing the inner content should give us the original JSON - const parsedJson = JSON.parse(JSON.parse(innerContent)) - expect(parsedJson).toEqual(encrypted) - }) - }) - - describe('isEncryptedPayload', () => { - it('should return true for valid encrypted payload', () => { - const encrypted = { - v: 1, - c: 'ciphertext', - i: { c: 'iv', t: 't' }, - } - expect(isEncryptedPayload(encrypted)).toBe(true) - }) - - it('should return false for null', () => { - expect(isEncryptedPayload(null)).toBe(false) - }) - - it('should return false for non-encrypted object', () => { - expect(isEncryptedPayload({ foo: 'bar' })).toBe(false) - }) - }) - - describe('isEncryptedScalarQuery', () => { - const baseIndex = { c: 'email', t: 'users' } - - it('returns true for a unique (hm) scalar query term', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - hm: 'abc123', - }), - ).toBe(true) - }) - - it('returns true for a match (bf) scalar query term', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - bf: [1, 2, 3], - }), - ).toBe(true) - }) - - it('returns true for an ore (ob) scalar query term', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - ob: ['a', 'b'], - }), - ).toBe(true) - }) - - it('returns false when the storage ciphertext (c) is present', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - c: 'ciphertext', - hm: 'abc123', - }), - ).toBe(false) - }) - - it('returns false when more than one lookup term is present', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - hm: 'abc123', - bf: [1, 2, 3], - }), - ).toBe(false) - }) - - it('returns false when no lookup term is present', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: baseIndex, - }), - ).toBe(false) - }) - - it('returns false for a ste_vec query term (k: "sv")', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'sv', - i: baseIndex, - sv: [{ s: 'selector', t: 'term' }], - }), - ).toBe(false) - }) - - it('returns false when version (v) is missing or not a number', () => { - expect( - isEncryptedScalarQuery({ - k: 'ct', - i: baseIndex, - hm: 'abc123', - }), - ).toBe(false) - expect( - isEncryptedScalarQuery({ - v: '2', - k: 'ct', - i: baseIndex, - hm: 'abc123', - }), - ).toBe(false) - }) - - it('returns false when index (i) is missing or null', () => { - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - hm: 'abc123', - }), - ).toBe(false) - expect( - isEncryptedScalarQuery({ - v: 2, - k: 'ct', - i: null, - hm: 'abc123', - }), - ).toBe(false) - }) - - it('returns false for null, primitives, and non-objects', () => { - expect(isEncryptedScalarQuery(null)).toBe(false) - expect(isEncryptedScalarQuery(undefined)).toBe(false) - expect(isEncryptedScalarQuery('string')).toBe(false) - expect(isEncryptedScalarQuery(42)).toBe(false) - }) - }) - - describe('modelToEncryptedPgComposites', () => { - it('should transform model with encrypted fields', () => { - const model = { - name: 'John', - email: { - v: 1, - c: 'encrypted_email', - i: { c: 'iv', t: 't' }, - }, - age: 30, - } - - const result = modelToEncryptedPgComposites(model) - expect(result).toEqual({ - name: 'John', - email: { - data: { - v: 1, - c: 'encrypted_email', - i: { c: 'iv', t: 't' }, - }, - }, - age: 30, - }) - }) - }) - - describe('bulkModelsToEncryptedPgComposites', () => { - it('should transform multiple models with encrypted fields', () => { - const models = [ - { - name: 'John', - email: { - v: 1, - c: 'encrypted_email1', - i: { c: 'iv', t: 't' }, - }, - }, - { - name: 'Jane', - email: { - v: 1, - c: 'encrypted_email2', - i: { c: 'iv', t: 't' }, - }, - }, - ] - - const result = bulkModelsToEncryptedPgComposites(models) - expect(result).toEqual([ - { - name: 'John', - email: { - data: { - v: 1, - c: 'encrypted_email1', - i: { c: 'iv', t: 't' }, - }, - }, - }, - { - name: 'Jane', - email: { - data: { - v: 1, - c: 'encrypted_email2', - i: { c: 'iv', t: 't' }, - }, - }, - }, - ]) - }) - }) -}) diff --git a/packages/protect/__tests__/infer-index-type.test.ts b/packages/protect/__tests__/infer-index-type.test.ts deleted file mode 100644 index ac9c9340f..000000000 --- a/packages/protect/__tests__/infer-index-type.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { csColumn, csTable } from '@cipherstash/schema' -import { describe, expect, it } from 'vitest' -import { inferQueryOpFromPlaintext } from '../src/ffi/helpers/infer-index-type' -import { inferIndexType, validateIndexType } from '../src/index' - -describe('infer-index-type helpers', () => { - const users = csTable('users', { - email: csColumn('email').equality(), - bio: csColumn('bio').freeTextSearch(), - age: csColumn('age').orderAndRange(), - name: csColumn('name').equality().freeTextSearch(), - }) - - describe('inferIndexType', () => { - it('returns unique for equality-only column', () => { - expect(inferIndexType(users.email)).toBe('unique') - }) - - it('returns match for freeTextSearch-only column', () => { - expect(inferIndexType(users.bio)).toBe('match') - }) - - it('returns ore for orderAndRange-only column', () => { - expect(inferIndexType(users.age)).toBe('ore') - }) - - it('returns unique when multiple indexes (priority: unique > match > ore)', () => { - expect(inferIndexType(users.name)).toBe('unique') - }) - - it('returns match when freeTextSearch and orderAndRange (priority: match > ore)', () => { - const schema = csTable('t', { - col: csColumn('col').freeTextSearch().orderAndRange(), - }) - expect(inferIndexType(schema.col)).toBe('match') - }) - - it('throws for column with no indexes', () => { - const noIndex = csTable('t', { col: csColumn('col') }) - expect(() => inferIndexType(noIndex.col)).toThrow('no indexes configured') - }) - - it('returns ste_vec for searchableJson-only column', () => { - const schema = csTable('t', { col: csColumn('col').searchableJson() }) - expect(inferIndexType(schema.col)).toBe('ste_vec') - }) - }) - - describe('validateIndexType', () => { - it('does not throw for valid index type', () => { - expect(() => validateIndexType(users.email, 'unique')).not.toThrow() - }) - - it('throws for unconfigured index type', () => { - expect(() => validateIndexType(users.email, 'match')).toThrow( - 'not configured', - ) - }) - - it('accepts ste_vec when configured', () => { - const schema = csTable('t', { col: csColumn('col').searchableJson() }) - expect(() => validateIndexType(schema.col, 'ste_vec')).not.toThrow() - }) - - it('rejects ste_vec when not configured', () => { - const schema = csTable('t', { col: csColumn('col').equality() }) - expect(() => validateIndexType(schema.col, 'ste_vec')).toThrow( - 'not configured', - ) - }) - }) - - describe('inferQueryOpFromPlaintext', () => { - it('returns ste_vec_selector for string plaintext', () => { - expect(inferQueryOpFromPlaintext('$.user.email')).toBe('ste_vec_selector') - }) - - it('returns ste_vec_term for object plaintext', () => { - expect(inferQueryOpFromPlaintext({ role: 'admin' })).toBe('ste_vec_term') - }) - - it('returns ste_vec_term for array plaintext', () => { - expect(inferQueryOpFromPlaintext(['admin', 'user'])).toBe('ste_vec_term') - }) - - it('returns ste_vec_term for number plaintext', () => { - expect(inferQueryOpFromPlaintext(42)).toBe('ste_vec_term') - }) - - it('returns ste_vec_term for boolean plaintext', () => { - expect(inferQueryOpFromPlaintext(true)).toBe('ste_vec_term') - }) - }) -}) diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts deleted file mode 100644 index 22fc203ec..000000000 --- a/packages/protect/__tests__/json-protect.test.ts +++ /dev/null @@ -1,1217 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - json: csColumn('json').dataType('json'), - metadata: { - profile: csValue('metadata.profile').dataType('json'), - settings: { - preferences: csValue('metadata.settings.preferences').dataType('json'), - }, - }, -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - json?: Record | null - metadata?: { - profile?: Record | null - settings?: { - preferences?: Record | null - } - } -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('JSON encryption and decryption', () => { - it('should encrypt and decrypt a simple JSON payload', async () => { - const json = { - name: 'John Doe', - age: 30, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should encrypt and decrypt a complex JSON payload', async () => { - const json = { - user: { - id: 123, - name: 'Jane Smith', - email: 'jane@example.com', - preferences: { - theme: 'dark', - notifications: true, - language: 'en-US', - }, - tags: ['premium', 'verified'], - metadata: { - created: '2023-01-01T00:00:00Z', - lastLogin: '2023-12-01T10:30:00Z', - }, - }, - settings: { - privacy: { - public: false, - shareData: true, - }, - features: { - beta: true, - experimental: false, - }, - }, - array: [1, 2, 3, { nested: 'value' }], - nullValue: null, - booleanValue: true, - numberValue: 42.5, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle null JSON payload', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) - }, 30000) - - it('should handle empty JSON object', async () => { - const json = {} - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with special characters', async () => { - const json = { - message: 'Hello "world" with \'quotes\' and \\backslashes\\', - unicode: '🚀 emoji and ñ special chars', - symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?/~`', - multiline: 'Line 1\nLine 2\tTabbed', - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) -}) - -describe('JSON model encryption and decryption', () => { - it('should encrypt and decrypt a model with JSON field', async () => { - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - json: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.address).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toHaveProperty('k', 'ct') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null JSON in model', async () => { - const decryptedModel = { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - json: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.address).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toBeNull() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined JSON in model', async () => { - const decryptedModel = { - id: '3', - email: 'test3@example.com', - address: '789 Pine St', - json: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.address).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('JSON bulk encryption and decryption', () => { - it('should bulk encrypt and decrypt JSON payloads', async () => { - const jsonPayloads = [ - { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, - { id: 'user2', plaintext: { name: 'Bob', age: 30 } }, - { id: 'user3', plaintext: { name: 'Charlie', age: 35 } }, - ] - - const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('k', 'ct') - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', { - name: 'Alice', - age: 25, - }) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', { - name: 'Bob', - age: 30, - }) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', { - name: 'Charlie', - age: 35, - }) - }, 30000) - - it('should handle mixed null and non-null JSON in bulk operations', async () => { - const jsonPayloads = [ - { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: { name: 'Charlie', age: 35 } }, - ] - - const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('k', 'ct') - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', { - name: 'Alice', - age: 25, - }) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', { - name: 'Charlie', - age: 35, - }) - }, 30000) - - it('should bulk encrypt and decrypt models with JSON fields', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - json: { - name: 'Alice', - preferences: { theme: 'dark' }, - }, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - json: { - name: 'Bob', - preferences: { theme: 'light' }, - }, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('k', 'ct') - expect(encryptedModels.data[0].address).toHaveProperty('k', 'ct') - expect(encryptedModels.data[0].json).toHaveProperty('k', 'ct') - expect(encryptedModels.data[1].email).toHaveProperty('k', 'ct') - expect(encryptedModels.data[1].address).toHaveProperty('k', 'ct') - expect(encryptedModels.data[1].json).toHaveProperty('k', 'ct') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) - -describe('JSON encryption with lock context', () => { - it('should encrypt and decrypt JSON with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const json = { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - } - - const ciphertext = await protectClient - .encrypt(json, { - column: users.json, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(json) - }, 30000) - - it('should encrypt JSON model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const decryptedModel = { - id: '1', - email: 'test@example.com', - json: { - name: 'John Doe', - preferences: { theme: 'dark' }, - }, - } - - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.json).toHaveProperty('k', 'ct') - - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should bulk encrypt JSON with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const jsonPayloads = [ - { id: 'user1', plaintext: { name: 'Alice', age: 25 } }, - { id: 'user2', plaintext: { name: 'Bob', age: 30 } }, - ] - - const encryptedData = await protectClient - .bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(2) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('k', 'ct') - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(2) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', { - name: 'Alice', - age: 25, - }) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', { - name: 'Bob', - age: 30, - }) - }, 30000) -}) - -describe('JSON nested object encryption', () => { - it('should encrypt and decrypt nested JSON objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - metadata: { - profile: { - name: 'John Doe', - age: 30, - preferences: { - theme: 'dark', - notifications: true, - }, - }, - settings: { - preferences: { - language: 'en-US', - timezone: 'UTC', - }, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.profile).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.settings?.preferences).toHaveProperty( - 'c', - ) - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in nested JSON objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '2', - email: 'test2@example.com', - metadata: { - profile: null, - settings: { - preferences: null, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.profile).toBeNull() - expect(encryptedModel.data.metadata?.settings?.preferences).toBeNull() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined values in nested JSON objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - email: 'test3@example.com', - metadata: { - profile: undefined, - settings: { - preferences: undefined, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toHaveProperty('k', 'ct') - expect(encryptedModel.data.metadata?.profile).toBeUndefined() - expect(encryptedModel.data.metadata?.settings?.preferences).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('JSON edge cases and error handling', () => { - it('should handle very large JSON objects', async () => { - const largeJson = { - data: Array.from({ length: 1000 }, (_, i) => ({ - id: i, - name: `User ${i}`, - email: `user${i}@example.com`, - metadata: { - preferences: { - theme: i % 2 === 0 ? 'dark' : 'light', - notifications: i % 3 === 0, - }, - }, - })), - metadata: { - total: 1000, - created: new Date().toISOString(), - }, - } - - const ciphertext = await protectClient.encrypt(largeJson, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: largeJson, - }) - }, 30000) - - it('should handle JSON with circular references (should fail gracefully)', async () => { - const circularObj: Record = { name: 'test' } - circularObj.self = circularObj - - try { - await protectClient.encrypt(circularObj, { - column: users.json, - table: users, - }) - // This should not reach here as JSON.stringify should fail - expect(true).toBe(false) - } catch (error) { - // Expected to fail due to circular reference - expect(error).toBeDefined() - } - }, 30000) - - it('should handle JSON with special data types', async () => { - const json = { - string: 'hello', - number: 42, - boolean: true, - null: null, - array: [1, 2, 3], - object: { nested: 'value' }, - date: new Date('2023-01-01T00:00:00Z'), - // Note: Functions and undefined are not JSON serializable - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - // Date objects get serialized to strings in JSON - const expectedJson = { - ...json, - date: '2023-01-01T00:00:00.000Z', - } - - expect(plaintext).toEqual({ - data: expectedJson, - }) - }, 30000) -}) - -describe('JSON performance tests', () => { - it('should handle large numbers of JSON objects efficiently', async () => { - const largeJsonArray = Array.from({ length: 100 }, (_, i) => ({ - id: i, - data: { - name: `User ${i}`, - preferences: { - theme: i % 2 === 0 ? 'dark' : 'light', - notifications: i % 3 === 0, - }, - metadata: { - created: new Date().toISOString(), - version: i, - }, - }, - })) - - const jsonPayloads = largeJsonArray.map((item, index) => ({ - id: `user${index}`, - plaintext: item, - })) - - const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, { - column: users.json, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(100) - - // Decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toEqual(largeJsonArray[i]) - } - }, 5000) -}) - -describe('JSON advanced scenarios', () => { - it('should handle JSON with deeply nested arrays', async () => { - const json = { - users: [ - { - id: 1, - name: 'Alice', - roles: [ - { name: 'admin', permissions: ['read', 'write', 'delete'] }, - { name: 'user', permissions: ['read'] }, - ], - }, - { - id: 2, - name: 'Bob', - roles: [{ name: 'user', permissions: ['read'] }], - }, - ], - metadata: { - total: 2, - lastUpdated: new Date().toISOString(), - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with mixed data types in arrays', async () => { - const json = { - mixedArray: ['string', 42, true, null, { nested: 'object' }, [1, 2, 3]], - metadata: { - types: ['string', 'number', 'boolean', 'null', 'object', 'array'], - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with Unicode and international characters', async () => { - const json = { - international: { - chinese: '你好世界', - japanese: 'こんにちは世界', - korean: '안녕하세요 세계', - arabic: 'مرحبا بالعالم', - russian: 'Привет мир', - emoji: '🚀 🌍 💻 🎉', - }, - metadata: { - languages: ['Chinese', 'Japanese', 'Korean', 'Arabic', 'Russian'], - encoding: 'UTF-8', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with scientific notation and large numbers', async () => { - const json = { - numbers: { - integer: 1234567890, - float: Math.PI, - scientific: 1.23e10, - negative: -9876543210, - zero: 0, - verySmall: 1.23e-10, - }, - metadata: { - precision: 'high', - format: 'scientific', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with boolean edge cases', async () => { - const json = { - booleans: { - true: true, - false: false, - stringTrue: 'true', - stringFalse: 'false', - numberOne: 1, - numberZero: 0, - emptyString: '', - nullValue: null, - }, - metadata: { - type: 'boolean_edge_cases', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) -}) - -describe('JSON error handling and edge cases', () => { - it('should handle malformed JSON gracefully', async () => { - // This test ensures the library handles JSON serialization errors - const invalidJson = { - valid: 'data', - // This will cause JSON.stringify to fail - circular: null as unknown, - } - - // Create a circular reference - invalidJson.circular = invalidJson - - try { - await protectClient.encrypt(invalidJson, { - column: users.json, - table: users, - }) - expect(true).toBe(false) // Should not reach here - } catch (error) { - expect(error).toBeDefined() - expect(error).toBeInstanceOf(Error) - } - }, 30000) - - it('should handle empty arrays and objects', async () => { - const json = { - emptyArray: [], - emptyObject: {}, - nestedEmpty: { - array: [], - object: {}, - }, - mixedEmpty: { - data: 'present', - empty: [], - null: null, - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with very long strings', async () => { - const longString = 'A'.repeat(10000) // 10KB string - const json = { - longString, - metadata: { - length: longString.length, - type: 'long_string', - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) - - it('should handle JSON with all primitive types', async () => { - const json = { - string: 'hello world', - number: 42, - float: 3.14, - boolean: true, - null: null, - array: [1, 2, 3], - object: { key: 'value' }, - nested: { - level1: { - level2: { - level3: 'deep value', - }, - }, - }, - } - - const ciphertext = await protectClient.encrypt(json, { - column: users.json, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('k', 'ct') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: json, - }) - }, 30000) -}) diff --git a/packages/protect/__tests__/jsonb-helpers.test.ts b/packages/protect/__tests__/jsonb-helpers.test.ts deleted file mode 100644 index 9f12d131f..000000000 --- a/packages/protect/__tests__/jsonb-helpers.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildNestedObject, parseJsonbPath, toJsonPath } from '../src' - -describe('toJsonPath', () => { - it('converts simple path to JSONPath format', () => { - expect(toJsonPath('name')).toBe('$.name') - }) - - it('converts nested path to JSONPath format', () => { - expect(toJsonPath('user.email')).toBe('$.user.email') - }) - - it('converts deeply nested path', () => { - expect(toJsonPath('user.profile.settings.theme')).toBe( - '$.user.profile.settings.theme', - ) - }) - - it('returns unchanged if already in JSONPath format', () => { - expect(toJsonPath('$.user.email')).toBe('$.user.email') - }) - - it('normalizes bare $ prefix', () => { - expect(toJsonPath('$user.email')).toBe('$.user.email') - }) - - it('handles path starting with dot', () => { - expect(toJsonPath('.user.email')).toBe('$.user.email') - }) - - it('handles root path', () => { - expect(toJsonPath('$')).toBe('$') - }) - - it('handles empty string', () => { - expect(toJsonPath('')).toBe('$') - }) - - it('handles array index in path', () => { - expect(toJsonPath('user.roles[0]')).toBe('$.user.roles[0]') - }) - - it('handles array index with nested property', () => { - expect(toJsonPath('items[0].name')).toBe('$.items[0].name') - }) - - it('handles already-prefixed path with array index', () => { - expect(toJsonPath('$.data[2]')).toBe('$.data[2]') - }) - - it('handles nested array indices', () => { - expect(toJsonPath('matrix[0][1]')).toBe('$.matrix[0][1]') - }) - - it('handles array index at root level', () => { - expect(toJsonPath('[0].name')).toBe('$[0].name') - }) - - it('preserves already-prefixed root array index', () => { - expect(toJsonPath('$[0]')).toBe('$[0]') - }) - - it('preserves already-prefixed root array index with property', () => { - expect(toJsonPath('$[0].name')).toBe('$[0].name') - }) - - it('handles large array index', () => { - expect(toJsonPath('items[999].value')).toBe('$.items[999].value') - }) - - it('handles deeply nested path after array index', () => { - expect(toJsonPath('data[0].user.profile.settings')).toBe( - '$.data[0].user.profile.settings', - ) - }) - - it('handles root array with nested array', () => { - expect(toJsonPath('[0].items[1].name')).toBe('$[0].items[1].name') - }) -}) - -describe('buildNestedObject', () => { - it('builds single-level object', () => { - expect(buildNestedObject('name', 'alice')).toEqual({ name: 'alice' }) - }) - - it('builds two-level nested object', () => { - expect(buildNestedObject('user.role', 'admin')).toEqual({ - user: { role: 'admin' }, - }) - }) - - it('builds deeply nested object', () => { - expect(buildNestedObject('a.b.c.d', 'value')).toEqual({ - a: { b: { c: { d: 'value' } } }, - }) - }) - - it('handles numeric values', () => { - expect(buildNestedObject('user.age', 30)).toEqual({ - user: { age: 30 }, - }) - }) - - it('handles boolean values', () => { - expect(buildNestedObject('user.active', true)).toEqual({ - user: { active: true }, - }) - }) - - it('handles null values', () => { - expect(buildNestedObject('user.data', null)).toEqual({ - user: { data: null }, - }) - }) - - it('handles object values', () => { - const value = { nested: 'object' } - expect(buildNestedObject('user.config', value)).toEqual({ - user: { config: { nested: 'object' } }, - }) - }) - - it('handles array values', () => { - expect(buildNestedObject('user.tags', ['admin', 'user'])).toEqual({ - user: { tags: ['admin', 'user'] }, - }) - }) - - it('strips JSONPath prefix from path', () => { - expect(buildNestedObject('$.user.role', 'admin')).toEqual({ - user: { role: 'admin' }, - }) - }) - - it('throws on empty path', () => { - expect(() => buildNestedObject('', 'value')).toThrow('Path cannot be empty') - }) - - it('throws on root-only path', () => { - expect(() => buildNestedObject('$', 'value')).toThrow( - 'Path must contain at least one segment', - ) - }) - - it('throws on __proto__ segment', () => { - expect(() => buildNestedObject('__proto__.polluted', 'yes')).toThrow( - 'Path contains forbidden segment: __proto__', - ) - }) - - it('throws on prototype segment', () => { - expect(() => buildNestedObject('user.prototype.hack', 'yes')).toThrow( - 'Path contains forbidden segment: prototype', - ) - }) - - it('throws on constructor segment', () => { - expect(() => buildNestedObject('constructor', 'yes')).toThrow( - 'Path contains forbidden segment: constructor', - ) - }) - - it('throws on nested forbidden segment', () => { - expect(() => buildNestedObject('a.b.__proto__', 'yes')).toThrow( - 'Path contains forbidden segment: __proto__', - ) - }) -}) - -describe('parseJsonbPath', () => { - it('parses simple path', () => { - expect(parseJsonbPath('name')).toEqual(['name']) - }) - - it('parses nested path', () => { - expect(parseJsonbPath('user.email')).toEqual(['user', 'email']) - }) - - it('parses deeply nested path', () => { - expect(parseJsonbPath('a.b.c.d')).toEqual(['a', 'b', 'c', 'd']) - }) - - it('strips JSONPath prefix', () => { - expect(parseJsonbPath('$.user.email')).toEqual(['user', 'email']) - }) - - it('strips bare $ prefix', () => { - expect(parseJsonbPath('$user.email')).toEqual(['user', 'email']) - }) - - it('handles empty string', () => { - expect(parseJsonbPath('')).toEqual([]) - }) - - it('handles root only', () => { - expect(parseJsonbPath('$')).toEqual([]) - }) - - it('filters empty segments', () => { - expect(parseJsonbPath('user..email')).toEqual(['user', 'email']) - }) -}) diff --git a/packages/protect/__tests__/k-discriminator.test.ts b/packages/protect/__tests__/k-discriminator.test.ts deleted file mode 100644 index 002ff842f..000000000 --- a/packages/protect/__tests__/k-discriminator.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email'), -}) - -describe('k-field discriminator (EQL v2.3)', () => { - let protectClient: Awaited> - - beforeAll(async () => { - protectClient = await protect({ schemas: [users] }) - }) - - it('encrypts scalar data with k: "ct" discriminator', async () => { - const testData = 'test@example.com' - - const result = await protectClient.encrypt(testData, { - column: users.email, - table: users, - }) - - if (result.failure) { - throw new Error(`Encryption failed: ${result.failure.message}`) - } - - expect(result.data).toHaveProperty('k', 'ct') - expect(result.data).toHaveProperty('c') - expect(result.data).toHaveProperty('v') - expect(result.data).toHaveProperty('i') - }, 30000) - - it('decrypts a payload round-trips back to the original plaintext', async () => { - const testData = 'roundtrip@example.com' - - const encrypted = await protectClient.encrypt(testData, { - column: users.email, - table: users, - }) - - if (encrypted.failure) { - throw new Error(`Encryption failed: ${encrypted.failure.message}`) - } - - const result = await protectClient.decrypt(encrypted.data!) - - if (result.failure) { - throw new Error(`Decryption failed: ${result.failure.message}`) - } - - expect(result.data).toBe(testData) - }, 30000) -}) diff --git a/packages/protect/__tests__/keysets.test.ts b/packages/protect/__tests__/keysets.test.ts deleted file mode 100644 index e8f4e0fed..000000000 --- a/packages/protect/__tests__/keysets.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import 'dotenv/config' -import { ensureKeyset } from '@cipherstash/protect-ffi' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' - -const users = csTable('users', { - email: csColumn('email'), -}) - -let testKeysetId: string - -beforeAll(async () => { - const keyset = await ensureKeyset({ name: 'Test' }) - testKeysetId = keyset.id -}) - -describe('encryption and decryption with keyset id', () => { - it('should encrypt and decrypt a payload', async () => { - const protectClient = await protect({ - schemas: [users], - keyset: { - id: testKeysetId, - }, - }) - - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const a = ciphertext.data - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) -}) - -describe('encryption and decryption with keyset name', () => { - it('should encrypt and decrypt a payload', async () => { - const protectClient = await protect({ - schemas: [users], - keyset: { - name: 'Test', - }, - }) - - const email = 'hello@example.com' - - const ciphertext = await protectClient.encrypt(email, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const a = ciphertext.data - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: email, - }) - }, 30000) -}) - -describe('encryption and decryption with invalid keyset id', () => { - it('should throw an error', async () => { - await expect( - protect({ - schemas: [users], - keyset: { - id: 'invalid-uuid', - }, - }), - ).rejects.toThrow( - '[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.', - ) - }) -}) diff --git a/packages/protect/__tests__/lock-context.test.ts b/packages/protect/__tests__/lock-context.test.ts deleted file mode 100644 index 4a0894b29..000000000 --- a/packages/protect/__tests__/lock-context.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { protect } from '../src' -import { LockContext } from '../src/identify' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption with lock context', () => { - it('should encrypt and decrypt a payload with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const email = 'hello@example.com' - - const ciphertext = await protectClient - .encrypt(email, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(email) - }, 30000) - - it('should encrypt and decrypt a model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model with lock context - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - }) - }, 30000) - - it('should encrypt with context and be unable to decrypt without context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - try { - await protectClient.decryptModel(encryptedModel.data) - } catch (error) { - const e = error as Error - expect(e.message.startsWith('Failed to retrieve key')).toEqual(true) - } - }, 30000) - - it('should bulk encrypt and decrypt models with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values - const decryptedModels = [ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ] - - // Encrypt the models with lock context - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models with lock context - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ]) - }, 30000) -}) diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts deleted file mode 100644 index 8f44f809b..000000000 --- a/packages/protect/__tests__/nested-models.test.ts +++ /dev/null @@ -1,958 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { describe, expect, it, vi } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - name: csColumn('name').freeTextSearch(), - example: { - field: csValue('example.field'), - nested: { - deeper: csValue('example.nested.deeper'), - }, - }, -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - notEncrypted?: string | null - example: { - field: string | undefined | null - nested?: { - deeper: string | undefined | null - plaintext?: string | undefined | null - notInSchema?: { - deeper: string | undefined | null - } - deeperNotInSchema?: string | undefined | null - extra?: { - plaintext: string | undefined | null - } - } - plaintext?: string | undefined | null - fieldNotInSchema?: string | undefined | null - notInSchema?: { - deeper: string | undefined | null - } - } -} - -describe('encrypt models with nested fields', () => { - it('should encrypt and decrypt a single value from a nested schema', async () => { - const protectClient = await protect({ schemas: [users] }) - - const encryptResponse = await protectClient.encrypt('hello world', { - column: users.example.field, - table: users, - }) - - if (encryptResponse.failure) { - throw new Error(`[protect]: ${encryptResponse.failure.message}`) - } - - // Verify encrypted field - expect(encryptResponse.data).toHaveProperty('c') - - const decryptResponse = await protectClient.decrypt(encryptResponse.data) - - if (decryptResponse.failure) { - throw new Error(`[protect]: ${decryptResponse.failure.message}`) - } - - expect(decryptResponse).toEqual({ - data: 'hello world', - }) - }) - - it('should encrypt and decrypt a model with nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - notEncrypted: 'not encrypted', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - example: { - field: 'test', - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '2', - email: null, - address: null, - example: { - field: null, - nested: { - deeper: null, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toBeNull() - expect(encryptedModel.data.address).toBeNull() - expect(encryptedModel.data.example.field).toBeNull() - expect(encryptedModel.data.example.nested?.deeper).toBeNull() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - example: { - field: undefined, - nested: { - deeper: undefined, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toBeUndefined() - expect(encryptedModel.data.example.field).toBeUndefined() - expect(encryptedModel.data.example.nested?.deeper).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle mixed null and undefined values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '4', - email: 'test@example.com', - address: undefined, - notEncrypted: 'not encrypted', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - example: { - field: null, - nested: { - deeper: undefined, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - - // Verify null/undefined fields are preserved - expect(encryptedModel.data.address).toBeUndefined() - expect(encryptedModel.data.example.field).toBeNull() - expect(encryptedModel.data.example.nested?.deeper).toBeUndefined() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('4') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle deeply nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - example: { - field: 'outer', - nested: { - deeper: 'inner value', - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('3') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle missing optional nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '5', - example: { - field: 'present', - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.example.field).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('5') - expect(encryptedModel.data.example.nested).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - describe('bulk operations with nested fields', () => { - it('should handle bulk encryption and decryption of models with nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'test1', - nested: { - deeper: 'value1', - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'test2', - nested: { - deeper: 'value2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[1].id).toBe('2') - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }, 30000) - - it('should handle bulk operations with null and undefined values in nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: null, - example: { - field: null, - nested: { - deeper: undefined, - }, - }, - }, - { - id: '2', - email: undefined, - example: { - field: undefined, - nested: { - deeper: null, - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify null/undefined fields are preserved - expect(encryptedModels.data[0].email).toBeNull() - expect(encryptedModels.data[0].example.field).toBeNull() - expect(encryptedModels.data[0].example.nested?.deeper).toBeUndefined() - expect(encryptedModels.data[1].email).toBeUndefined() - expect(encryptedModels.data[1].example.field).toBeUndefined() - expect(encryptedModels.data[1].example.nested?.deeper).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[1].id).toBe('2') - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }, 30000) - - it('should handle bulk operations with missing optional nested fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'test1', - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'test2', - nested: { - deeper: 'value2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.nested).toBeUndefined() - expect(encryptedModels.data[1].id).toBe('2') - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }, 30000) - - it('should handle empty array in bulk operations', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - expect(encryptedModels.data).toEqual([]) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual([]) - }, 30000) - }) -}) - -describe('nested fields with a plaintext field', () => { - it('should handle nested fields with a plaintext field', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - notEncrypted: 'not encrypted', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - example: { - field: 'test', - plaintext: 'plaintext', - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.example.plaintext).toBe('plaintext') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - it('should handle multiple plaintext fields at different nesting levels', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - notEncrypted: 'not encrypted', - example: { - field: 'encrypted field', - plaintext: 'top level plaintext', - nested: { - deeper: 'encrypted deeper', - plaintext: 'nested plaintext', - extra: { - plaintext: 'deeply nested plaintext', - }, - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.notEncrypted).toBe('not encrypted') - expect(encryptedModel.data.example.plaintext).toBe('top level plaintext') - expect(encryptedModel.data.example.nested?.plaintext).toBe( - 'nested plaintext', - ) - expect(encryptedModel.data.example.nested?.extra?.plaintext).toBe( - 'deeply nested plaintext', - ) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - it('should handle partial path matches in nested objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - example: { - field: 'encrypted field', - nested: { - deeper: 'encrypted deeper', - // This should not be encrypted as it's not in the schema - notInSchema: { - deeper: 'not encrypted', - }, - }, - // This should not be encrypted as it's not in the schema - notInSchema: { - deeper: 'not encrypted', - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.example.nested?.notInSchema?.deeper).toBe( - 'not encrypted', - ) - expect(encryptedModel.data.example.notInSchema?.deeper).toBe( - 'not encrypted', - ) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - it('should handle mixed encrypted and plaintext fields with similar paths', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - example: { - field: 'encrypted field', - fieldNotInSchema: 'not encrypted', - nested: { - deeper: 'encrypted deeper', - deeperNotInSchema: 'not encrypted', - }, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.example.field).toHaveProperty('c') - expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.example.fieldNotInSchema).toBe('not encrypted') - expect(encryptedModel.data.example.nested?.deeperNotInSchema).toBe( - 'not encrypted', - ) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }) - - describe('bulk operations with plaintext fields', () => { - it('should handle bulk encryption and decryption with plaintext fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - example: { - field: 'encrypted field 1', - plaintext: 'plaintext 1', - nested: { - deeper: 'encrypted deeper 1', - plaintext: 'nested plaintext 1', - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Main St', - example: { - field: 'encrypted field 2', - plaintext: 'plaintext 2', - nested: { - deeper: 'encrypted deeper 2', - plaintext: 'nested plaintext 2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.plaintext).toBe('plaintext 1') - expect(encryptedModels.data[0].example.nested?.plaintext).toBe( - 'nested plaintext 1', - ) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].example.plaintext).toBe('plaintext 2') - expect(encryptedModels.data[1].example.nested?.plaintext).toBe( - 'nested plaintext 2', - ) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }) - - it('should handle bulk operations with mixed encrypted and non-encrypted fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'encrypted field 1', - fieldNotInSchema: 'not encrypted 1', - nested: { - deeper: 'encrypted deeper 1', - deeperNotInSchema: 'not encrypted deeper 1', - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'encrypted field 2', - fieldNotInSchema: 'not encrypted 2', - nested: { - deeper: 'encrypted deeper 2', - deeperNotInSchema: 'not encrypted deeper 2', - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.fieldNotInSchema).toBe( - 'not encrypted 1', - ) - expect(encryptedModels.data[0].example.nested?.deeperNotInSchema).toBe( - 'not encrypted deeper 1', - ) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].example.fieldNotInSchema).toBe( - 'not encrypted 2', - ) - expect(encryptedModels.data[1].example.nested?.deeperNotInSchema).toBe( - 'not encrypted deeper 2', - ) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }) - - it('should handle bulk operations with deeply nested plaintext fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModels: User[] = [ - { - id: '1', - email: 'test1@example.com', - example: { - field: 'encrypted field 1', - nested: { - deeper: 'encrypted deeper 1', - extra: { - plaintext: 'deeply nested plaintext 1', - }, - }, - }, - }, - { - id: '2', - email: 'test2@example.com', - example: { - field: 'encrypted field 2', - nested: { - deeper: 'encrypted deeper 2', - extra: { - plaintext: 'deeply nested plaintext 2', - }, - }, - }, - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].example.field).toHaveProperty('c') - expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].example.field).toHaveProperty('c') - expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].example.nested?.extra?.plaintext).toBe( - 'deeply nested plaintext 1', - ) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].example.nested?.extra?.plaintext).toBe( - 'deeply nested plaintext 2', - ) - - const decryptedResults = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResults.failure) { - throw new Error(`[protect]: ${decryptedResults.failure.message}`) - } - - expect(decryptedResults.data).toEqual(decryptedModels) - }) - }) -}) diff --git a/packages/protect/__tests__/number-protect.test.ts b/packages/protect/__tests__/number-protect.test.ts deleted file mode 100644 index 754e42e6d..000000000 --- a/packages/protect/__tests__/number-protect.test.ts +++ /dev/null @@ -1,823 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable, csValue } from '@cipherstash/schema' -import { beforeAll, describe, expect, it, test } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - age: csColumn('age').dataType('number').equality().orderAndRange(), - score: csColumn('score').dataType('number').equality().orderAndRange(), - metadata: { - count: csValue('metadata.count').dataType('number'), - level: csValue('metadata.level').dataType('number'), - }, -}) - -type User = { - id: string - email?: string - createdAt?: Date - updatedAt?: Date - address?: string - age?: number - score?: number - metadata?: { - count?: number - level?: number - } -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -const cases = [ - 25, - 0, - -42, - 2147483647, - 77.9, - 0.0, - -117.123456, - 1e15, - -1e15, // Very large floats - 9007199254740991, // Max safe integer in JavaScript -] - -describe('Number encryption and decryption', () => { - test.each(cases)('should encrypt and decrypt a number: %d', async (age) => { - const ciphertext = await protectClient.encrypt(age, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: age, - }) - }, 30000) - - it('should handle null integer', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) - }, 30000) - - // Special case - it('should treat a negative zero valued float as 0.0', async () => { - const score = -0.0 - - const ciphertext = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: 0.0, - }) - }, 30000) - - // Special case - it('should error for a NaN float', async () => { - const score = Number.NaN - - const result = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot encrypt NaN value') - }, 30000) - - // Special case - it('should error for Infinity', async () => { - const score = Number.POSITIVE_INFINITY - - const result = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot encrypt Infinity value') - }, 30000) - - // Special case - it('should error for -Infinity', async () => { - const score = Number.NEGATIVE_INFINITY - - const result = await protectClient.encrypt(score, { - column: users.score, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot encrypt Infinity value') - }, 30000) -}) - -describe('Model encryption and decryption', () => { - it('should encrypt and decrypt a model with number fields', async () => { - const decryptedModel = { - id: '1', - email: 'test@example.com', - address: '123 Main St', - age: 30, - score: 95, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toHaveProperty('c') - expect(encryptedModel.data.score).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null numbers in model', async () => { - const decryptedModel: User = { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - age: undefined, - score: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toBeUndefined() - expect(encryptedModel.data.score).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined numbers in model', async () => { - const decryptedModel = { - id: '3', - email: 'test3@example.com', - address: '789 Pine St', - age: undefined, - score: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - expect(encryptedModel.data.age).toBeUndefined() - expect(encryptedModel.data.score).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('Bulk encryption and decryption', () => { - it('should bulk encrypt and decrypt number payloads', async () => { - const intPayloads = [ - { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: 30.7 }, - { id: 'user3', plaintext: -35.123 }, - ] - - const encryptedData = await protectClient.bulkEncrypt(intPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - - // EQL v2.3: scalar encryptions carry the `k: 'ct'` discriminator - expect(encryptedData.data[0].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[1].data).toHaveProperty('k', 'ct') - expect(encryptedData.data[2].data).toHaveProperty('k', 'ct') - - // Verify all encrypted values are different - const getCiphertext = (data: { c?: unknown } | null | undefined) => data?.c - - expect(getCiphertext(encryptedData.data[0].data)).not.toBe( - getCiphertext(encryptedData.data[1].data), - ) - expect(getCiphertext(encryptedData.data[1].data)).not.toBe( - getCiphertext(encryptedData.data[2].data), - ) - expect(getCiphertext(encryptedData.data[0].data)).not.toBe( - getCiphertext(encryptedData.data[2].data), - ) - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 25) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 30.7) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', -35.123) - }, 30000) - - it('should handle mixed null and non-null numbers in bulk operations', async () => { - const intPayloads = [ - { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: null }, - { id: 'user3', plaintext: 35 }, - ] - - const encryptedData = await protectClient.bulkEncrypt(intPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(3) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toBeNull() - expect(encryptedData.data[2]).toHaveProperty('id', 'user3') - expect(encryptedData.data[2]).toHaveProperty('data') - expect(encryptedData.data[2].data).toHaveProperty('c') - - // Now decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(3) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 25) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', null) - expect(decryptedData.data[2]).toHaveProperty('id', 'user3') - expect(decryptedData.data[2]).toHaveProperty('data', 35) - }, 30000) - - it('should bulk encrypt and decrypt models with number fields', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1@example.com', - address: '123 Main St', - age: 25, - score: 85, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - { - id: '2', - email: 'test2@example.com', - address: '456 Oak St', - age: 30, - score: 90, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[0].age).toHaveProperty('c') - expect(encryptedModels.data[0].score).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[1].age).toHaveProperty('c') - expect(encryptedModels.data[1].score).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) - -describe('Encryption with lock context', () => { - it('should encrypt and decrypt number with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const age = 42 - - const ciphertext = await protectClient - .encrypt(age, { - column: users.age, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(age) - }, 30000) - - it('should encrypt model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const decryptedModel = { - id: '1', - email: 'test@example.com', - age: 30, - score: 95, - } - - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.age).toHaveProperty('c') - expect(encryptedModel.data.score).toHaveProperty('c') - - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should bulk encrypt numbers with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const intPayloads = [ - { id: 'user1', plaintext: 25 }, - { id: 'user2', plaintext: 30 }, - ] - - const encryptedData = await protectClient - .bulkEncrypt(intPayloads, { - column: users.age, - table: users, - }) - .withLockContext(lockContext.data) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(2) - expect(encryptedData.data[0]).toHaveProperty('id', 'user1') - expect(encryptedData.data[0]).toHaveProperty('data') - expect(encryptedData.data[0].data).toHaveProperty('c') - expect(encryptedData.data[1]).toHaveProperty('id', 'user2') - expect(encryptedData.data[1]).toHaveProperty('data') - expect(encryptedData.data[1].data).toHaveProperty('c') - - // Decrypt with lock context - const decryptedData = await protectClient - .bulkDecrypt(encryptedData.data) - .withLockContext(lockContext.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify decrypted data - expect(decryptedData.data).toHaveLength(2) - expect(decryptedData.data[0]).toHaveProperty('id', 'user1') - expect(decryptedData.data[0]).toHaveProperty('data', 25) - expect(decryptedData.data[1]).toHaveProperty('id', 'user2') - expect(decryptedData.data[1]).toHaveProperty('data', 30) - }, 30000) -}) - -describe('Nested object encryption', () => { - it('should encrypt and decrypt nested number objects', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '1', - email: 'test@example.com', - metadata: { - count: 100, - level: 5, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toHaveProperty('c') - expect(encryptedModel.data.metadata?.level).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle null values in nested objects with number fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel: User = { - id: '2', - email: 'test2@example.com', - metadata: { - count: undefined, - level: undefined, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toBeUndefined() - expect(encryptedModel.data.metadata?.level).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) - - it('should handle undefined values in nested objects with number fields', async () => { - const protectClient = await protect({ schemas: [users] }) - - const decryptedModel = { - id: '3', - email: 'test3@example.com', - metadata: { - count: undefined, - level: undefined, - }, - } - - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.metadata?.count).toBeUndefined() - expect(encryptedModel.data.metadata?.level).toBeUndefined() - - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModel) - }, 30000) -}) - -describe('encryptQuery for numbers', () => { - it('should create encrypted query for number fields', async () => { - const result = await protectClient.encryptQuery([ - { value: 25, column: users.age, table: users, queryType: 'equality' }, - { value: 100, column: users.score, table: users, queryType: 'equality' }, - ]) - - if (result.failure) { - throw new Error(`[protect]: ${result.failure.message}`) - } - - expect(result.data).toHaveLength(2) - expect(result.data[0]).toHaveProperty('v', 2) - expect(result.data[1]).toHaveProperty('v', 2) - }, 30000) -}) - -describe('Performance tests', () => { - it('should handle large numbers of numbers efficiently', async () => { - const largeNumArray = Array.from({ length: 100 }, (_, i) => ({ - id: i, - data: { - age: i + 18, // Ages 18-117 - score: (i % 100) + 1, // Scores 1-100 - }, - })) - - const numPayloads = largeNumArray.map((item, index) => ({ - id: `user${index}`, - plaintext: item.data.age, - })) - - const encryptedData = await protectClient.bulkEncrypt(numPayloads, { - column: users.age, - table: users, - }) - - if (encryptedData.failure) { - throw new Error(`[protect]: ${encryptedData.failure.message}`) - } - - // Verify structure - expect(encryptedData.data).toHaveLength(100) - - // Decrypt the data - const decryptedData = await protectClient.bulkDecrypt(encryptedData.data) - - if (decryptedData.failure) { - throw new Error(`[protect]: ${decryptedData.failure.message}`) - } - - // Verify all data is preserved - expect(decryptedData.data).toHaveLength(100) - - for (let i = 0; i < 100; i++) { - expect(decryptedData.data[i].id).toBe(`user${i}`) - expect(decryptedData.data[i].data).toEqual(largeNumArray[i].data.age) - } - }, 60000) -}) - -describe('Advanced scenarios', () => { - it('should handle boundary values', async () => { - const boundaryValues = [ - Number.MIN_SAFE_INTEGER, - -2147483648, // Min 32-bit signed integer - -1, - 0, - 1, - 2147483647, // Max 32-bit signed integer - Number.MAX_SAFE_INTEGER, - ] - - for (const value of boundaryValues) { - const ciphertext = await protectClient.encrypt(value, { - column: users.age, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify encrypted field - expect(ciphertext.data).toHaveProperty('c') - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: value, - }) - } - }, 30000) -}) - -const invalidPlaintexts = [ - '400', - 'aaa', - '100a', - '73.51', - {}, - [], - [123], - { num: 123 }, -] - -describe('Invalid or uncoercable values', () => { - test.each(invalidPlaintexts)('should fail to encrypt', async (input) => { - const result = await protectClient.encrypt(input, { - column: users.age, - table: users, - }) - - expect(result.failure).toBeDefined() - expect(result.failure?.message).toContain('Cannot convert') - }, 30000) -}) diff --git a/packages/protect/__tests__/protect-ops.test.ts b/packages/protect/__tests__/protect-ops.test.ts deleted file mode 100644 index c7a2e2765..000000000 --- a/packages/protect/__tests__/protect-ops.test.ts +++ /dev/null @@ -1,843 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), -}) - -type User = { - id: string - email?: string | null - createdAt?: Date - updatedAt?: Date - address?: string | null - number?: number -} - -let protectClient: Awaited> - -beforeAll(async () => { - protectClient = await protect({ - schemas: [users], - }) -}) - -describe('encryption and decryption edge cases', () => { - it('should return null if plaintext is null', async () => { - const ciphertext = await protectClient.encrypt(null, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - // Verify null is preserved - expect(ciphertext.data).toBeNull() - - const plaintext = await protectClient.decrypt(ciphertext.data) - - expect(plaintext).toEqual({ - data: null, - }) - }, 30000) - - it('should encrypt and decrypt a model', async () => { - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify encrypted fields - expect(encryptedModel.data.email).toHaveProperty('c') - expect(encryptedModel.data.address).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - }) - }, 30000) - - it('should handle null values in a model', async () => { - // Create a model with null values - const decryptedModel = { - id: '1', - email: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify null fields are preserved - expect(encryptedModel.data.email).toBeNull() - expect(encryptedModel.data.address).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - }) - }, 30000) - - it('should handle undefined values in a model', async () => { - // Create a model with undefined values - const decryptedModel = { - id: '1', - email: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel( - decryptedModel, - users, - ) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Verify undefined fields are preserved - expect(encryptedModel.data.email).toBeUndefined() - expect(encryptedModel.data.address).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModel.data.id).toBe('1') - expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModel.data.number).toBe(1) - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: null, - }) - }, 30000) -}) - -describe('bulk encryption', () => { - it('should bulk encrypt and decrypt models', async () => { - // Create models with decrypted values - const decryptedModels = [ - { - id: '1', - email: 'test', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: '123 Main St', - }, - { - id: '2', - email: 'test2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - address: null, - }, - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toHaveProperty('c') - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toBeNull() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - address: '123 Main St', - }, - { - id: '2', - email: 'test2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - address: null, - }, - ]) - }, 30000) - - it('should return empty array if models is empty', async () => { - // Encrypt empty array of models - const encryptedModels = await protectClient.bulkEncryptModels( - [], - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - expect(encryptedModels.data).toEqual([]) - }, 30000) - - it('should return empty array if decrypting empty array of models', async () => { - // Decrypt empty array of models - const decryptedResult = await protectClient.bulkDecryptModels([]) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([]) - }, 30000) -}) - -describe('bulk encryption edge cases', () => { - it('should handle mixed null and non-null values in bulk operations', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1', - address: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: null, - address: '123 Main St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - email: 'test3', - address: '456 Oak St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toBeNull() - expect(encryptedModels.data[1].email).toBeNull() - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[2].email).toHaveProperty('c') - expect(encryptedModels.data[2].address).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - expect(encryptedModels.data[2].id).toBe('3') - expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].number).toBe(3) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should handle mixed undefined and non-undefined values in bulk operations', async () => { - const decryptedModels = [ - { - id: '1', - email: 'test1', - address: undefined, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, - { - id: '2', - email: null, - address: '123 Main St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - email: 'test3', - address: '456 Oak St', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toHaveProperty('c') - expect(encryptedModels.data[0].address).toBeUndefined() - expect(encryptedModels.data[1].email).toBeNull() - expect(encryptedModels.data[1].address).toHaveProperty('c') - expect(encryptedModels.data[2].email).toHaveProperty('c') - expect(encryptedModels.data[2].address).toHaveProperty('c') - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - expect(encryptedModels.data[2].id).toBe('3') - expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].number).toBe(3) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) - - it('should handle empty models in bulk operations', async () => { - const decryptedModels = [ - { - id: '1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 1, - }, // No encrypted fields - { - id: '2', - email: 'test2', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 2, - }, - { - id: '3', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: 3, - }, // No encrypted fields - ] - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - decryptedModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Verify encrypted fields for each model - expect(encryptedModels.data[0].email).toBeUndefined() - expect(encryptedModels.data[0].address).toBeUndefined() - expect(encryptedModels.data[1].email).toHaveProperty('c') - expect(encryptedModels.data[1].address).toBeUndefined() - expect(encryptedModels.data[2].email).toBeUndefined() - expect(encryptedModels.data[2].address).toBeUndefined() - - // Verify non-encrypted fields remain unchanged - expect(encryptedModels.data[0].id).toBe('1') - expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[0].number).toBe(1) - expect(encryptedModels.data[1].id).toBe('2') - expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[1].number).toBe(2) - expect(encryptedModels.data[2].id).toBe('3') - expect(encryptedModels.data[2].createdAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].updatedAt).toEqual(new Date('2021-01-01')) - expect(encryptedModels.data[2].number).toBe(3) - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(decryptedModels) - }, 30000) -}) - -describe('error handling', () => { - it('should handle invalid encrypted payloads', async () => { - const validModel = { - id: '1', - email: 'test@example.com', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - } - - // First encrypt a valid model - const encryptedModel = await protectClient.encryptModel( - validModel, - users, - ) - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Create an invalid model by removing required fields - const invalidModel = { - id: '1', - // Missing required fields - } - - try { - await protectClient.decryptModel(invalidModel as User) - throw new Error('Expected decryption to fail') - } catch (error) { - expect(error).toBeDefined() - } - }, 30000) - - it('should handle missing required fields', async () => { - const model = { - id: '1', - email: null, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: null, - number: 1, - } - - try { - await protectClient.encryptModel(model, users) - throw new Error('Expected encryption to fail') - } catch (error) { - expect(error).toBeDefined() - } - }, 30000) -}) - -describe('type safety', () => { - it('should maintain type safety with complex nested objects', async () => { - const model = { - id: '1', - email: 'test@example.com', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - address: '123 Main St', - number: 1, - metadata: { - preferences: { - notifications: true, - theme: 'dark', - }, - }, - } - - // Encrypt the model - const encryptedModel = await protectClient.encryptModel(model, users) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model - const decryptedResult = await protectClient.decryptModel( - encryptedModel.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(model) - }, 30000) -}) - -describe('performance', () => { - it('should handle large numbers of models efficiently', async () => { - const largeModels = Array(10) - .fill(null) - .map((_, i) => ({ - id: i.toString(), - email: `test${i}@example.com`, - address: `Address ${i}`, - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - number: i, - })) - - // Encrypt the models - const encryptedModels = await protectClient.bulkEncryptModels( - largeModels, - users, - ) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models - const decryptedResult = await protectClient.bulkDecryptModels( - encryptedModels.data, - ) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual(largeModels) - }, 60000) -}) - -describe('encryption and decryption with lock context', () => { - it('should encrypt and decrypt a payload with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - const email = 'hello@example.com' - - const ciphertext = await protectClient - .encrypt(email, { - column: users.email, - table: users, - }) - .withLockContext(lockContext.data) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const plaintext = await protectClient - .decrypt(ciphertext.data) - .withLockContext(lockContext.data) - - if (plaintext.failure) { - throw new Error(`[protect]: ${plaintext.failure.message}`) - } - - expect(plaintext.data).toEqual(email) - }, 30000) - - it('should encrypt and decrypt a model with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - // Decrypt the model with lock context - const decryptedResult = await protectClient - .decryptModel(encryptedModel.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual({ - id: '1', - email: 'plaintext', - }) - }, 30000) - - it('should encrypt with context and be unable to decrypt without context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create a model with decrypted values - const decryptedModel = { - id: '1', - email: 'plaintext', - } - - // Encrypt the model with lock context - const encryptedModel = await protectClient - .encryptModel(decryptedModel, users) - .withLockContext(lockContext.data) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - try { - await protectClient.decryptModel(encryptedModel.data) - } catch (error) { - const e = error as Error - expect(e.message.startsWith('Failed to retrieve key')).toEqual(true) - } - }, 30000) - - it('should bulk encrypt and decrypt models with lock context', async () => { - const userJwt = process.env.USER_JWT - - if (!userJwt) { - console.log('Skipping lock context test - no USER_JWT provided') - return - } - - const lc = new LockContext() - const lockContext = await lc.identify(userJwt) - - if (lockContext.failure) { - throw new Error(`[protect]: ${lockContext.failure.message}`) - } - - // Create models with decrypted values - const decryptedModels = [ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ] - - // Encrypt the models with lock context - const encryptedModels = await protectClient - .bulkEncryptModels(decryptedModels, users) - .withLockContext(lockContext.data) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - // Decrypt the models with lock context - const decryptedResult = await protectClient - .bulkDecryptModels(encryptedModels.data) - .withLockContext(lockContext.data) - - if (decryptedResult.failure) { - throw new Error(`[protect]: ${decryptedResult.failure.message}`) - } - - expect(decryptedResult.data).toEqual([ - { - id: '1', - email: 'test', - }, - { - id: '2', - email: 'test2', - }, - ]) - }, 30000) -}) - -describe('special characters', () => { - it('should encrypt and decrypt multiple special characters together', async () => { - const plaintext = - 'complex@string-with/slashes\\backslashes.and#symbols$%&+!@#$%^&*()_+-=[]{}|;:,.<>?/~`' - - const ciphertext = await protectClient.encrypt(plaintext, { - column: users.email, - table: users, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const decrypted = await protectClient.decrypt(ciphertext.data) - - expect(decrypted).toEqual({ - data: plaintext, - }) - }, 30000) -}) diff --git a/packages/protect/__tests__/searchable-json-pg.test.ts b/packages/protect/__tests__/searchable-json-pg.test.ts deleted file mode 100644 index 49b13af02..000000000 --- a/packages/protect/__tests__/searchable-json-pg.test.ts +++ /dev/null @@ -1,2390 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { LockContext, protect } from '../src' - -if (!process.env.DATABASE_URL) { - throw new Error('Missing env.DATABASE_URL') -} - -// Disable prepared statements — required for pooled connections (PgBouncer in transaction mode) -const sql = postgres(process.env.DATABASE_URL, { prepare: false }) - -const table = csTable('protect-ci-jsonb', { - metadata: csColumn('metadata').searchableJson(), -}) - -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const userJwt = process.env.USER_JWT - -type ProtectClient = Awaited> -let protectClient: ProtectClient - -// ─── Helpers ───────────────────────────────────────────────────────── - -async function insertRow(plaintext: any) { - const encrypted = await protectClient.encryptModel( - { metadata: plaintext }, - table, - ) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - return { id: inserted.id, encrypted } -} - -async function verifyRow(row: any, expected: any) { - expect(row).toBeDefined() - const decrypted = await protectClient.decryptModel({ metadata: row.metadata }) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data.metadata).toEqual(expected) -} - -async function encryptQueryTerm( - value: any, - queryType: 'steVecSelector' | 'steVecTerm' | 'searchableJson', - returnType: - | 'composite-literal' - | 'escaped-composite-literal' = 'composite-literal', -) { - const result = await protectClient.encryptQuery(value, { - column: table.metadata, - table: table, - queryType, - returnType, - }) - if (result.failure) throw new Error(result.failure.message) - return result.data -} - -beforeAll(async () => { - protectClient = await protect({ schemas: [table] }) - - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci-jsonb" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - metadata eql_v2_encrypted, - test_run_id TEXT - ) - ` -}, 30000) - -afterAll(async () => { - await sql`DELETE FROM "protect-ci-jsonb" WHERE test_run_id = ${TEST_RUN_ID}` - await sql.end() -}, 30000) - -describe('searchableJson postgres integration', () => { - // ─── Storage: encrypt → insert → select → decrypt ────────────────── - - describe('storage: encrypt → insert → select → decrypt', () => { - it('round-trips a flat JSON object', async () => { - const plaintext = { user: { email: 'flat-rt@test.com' }, role: 'admin' } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('round-trips nested JSON with arrays', async () => { - const plaintext = { - user: { - profile: { role: 'admin', permissions: ['read', 'write'] }, - tags: [{ name: 'vip' }, { name: 'beta' }], - }, - items: [{ id: 1, name: 'widget' }], - } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('round-trips null values', async () => { - const encrypted = await protectClient.encrypt(null, { - column: table.metadata, - table: table, - }) - - if (encrypted.failure) throw new Error(encrypted.failure.message) - expect(encrypted.data).toBeNull() - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (NULL, ${TEST_RUN_ID}) - RETURNING id - ` - - const rows = await sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE id = ${inserted.id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].metadata).toBeNull() - }, 30000) - }) - - // ─── jsonb_path_query: path-based selector queries ───────────────── - - describe('jsonb_path_query: path-based selector queries', () => { - it('finds row by simple top-level path ($.role)', async () => { - const plaintext = { role: 'path-toplevel-test', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path ($.user.email)', async () => { - const plaintext = { - user: { email: 'nested-path@test.com' }, - type: 'nested-path', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by deeply nested path ($.a.b.c)', async () => { - const plaintext = { a: { b: { c: 'deep-value' } }, marker: 'deep-path' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.a.b.c', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching path returns zero rows', async () => { - // Insert a doc that does NOT have $.nonexistent.path - const plaintext = { exists: true, marker: 'no-match-test' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - // No row should have this path - expect(rows.length).toBe(0) - }, 30000) - - it('multiple docs — only matching doc returned', async () => { - // Insert two docs: one with $.target.value, one without - const plaintextWithPath = { - target: { value: 'found-it' }, - marker: 'has-target', - } - const plaintextWithoutPath = { - other: { key: 'nope' }, - marker: 'no-target', - } - - const { id: idWith } = await insertRow(plaintextWithPath) - const { id: idWithout } = await insertRow(plaintextWithoutPath) - - const selectorTerm = await encryptQueryTerm( - '$.target.value', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - // The doc with $.target.value should be found - const matchingRow = rows.find((r) => r.id === idWith) - expect(matchingRow).toBeDefined() - - // The doc without $.target.value should NOT be found - const nonMatchingRow = rows.find((r) => r.id === idWithout) - expect(nonMatchingRow).toBeUndefined() - - // Decrypt and verify the matching row - await verifyRow(matchingRow!, plaintextWithPath) - }, 30000) - - it('finds row by simple top-level path (Simple)', async () => { - const plaintext = { role: 'path-tl-simple', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path (Simple)', async () => { - const plaintext = { - user: { email: 'nested-simple@test.com' }, - type: 'nested-path-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds with deep nested path (Simple)', async () => { - const plaintext = { - target: { nested: { value: 'deep-simple' } }, - marker: 'jpq-deep-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.target.nested.value', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching path returns zero rows (Simple)', async () => { - const plaintext = { data: true, marker: 'jpq-nomatch-simple' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.missing.path', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, '${selectorTerm}'::eql_v2_encrypted) as result - WHERE t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── Containment: @> term queries ────────────────────────────────── - - describe('containment: @> term queries', () => { - it('matches by key/value pair', async () => { - const plaintext = { role: 'admin-containment', department: 'engineering' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'admin-containment' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('matches by nested object structure', async () => { - const plaintext = { - user: { profile: { role: 'superadmin' } }, - active: true, - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { profile: { role: 'superadmin' } } }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching term returns zero rows', async () => { - const plaintext = { status: 'active', tier: 'free' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { status: 'nonexistent-value-xyz' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── Mixed and batch operations ──────────────────────────────────── - - describe('mixed and batch operations', () => { - it('batch encrypts selector + containment terms together', async () => { - const plaintext = { - user: { email: 'batch@test.com' }, - role: 'editor', - kind: 'batch-mixed', - } - const { id } = await insertRow(plaintext) - - const queryResult = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }, - { - value: { role: 'editor' }, - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ]) - - if (queryResult.failure) throw new Error(queryResult.failure.message) - const [selectorTerm, containmentTerm] = queryResult.data - - // Selector query: jsonb_path_query - const selectorRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - const selectorMatch = selectorRows.find((r) => r.id === id) - expect(selectorMatch).toBeDefined() - await verifyRow(selectorMatch!, plaintext) - - // Containment query: @> - const containmentRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - const containmentMatch = containmentRows.find((r) => r.id === id) - expect(containmentMatch).toBeDefined() - await verifyRow(containmentMatch!, plaintext) - }, 30000) - - it('inferred vs explicit queryType produce same results', async () => { - const plaintext = { category: 'equivalence-test', priority: 'high' } - const { id } = await insertRow(plaintext) - - // Selector: inferred (searchableJson) vs explicit (steVecSelector) - const inferredSelectorTerm = await encryptQueryTerm( - '$.category', - 'searchableJson', - ) - const explicitSelectorTerm = await encryptQueryTerm( - '$.category', - 'steVecSelector', - ) - - const inferredRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${inferredSelectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - const explicitRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${explicitSelectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(inferredRows.length).toBe(explicitRows.length) - expect(inferredRows.length).toBeGreaterThanOrEqual(1) - - // Both should find our inserted row - const inferredMatch = inferredRows.find((r) => r.id === id) - const explicitMatch = explicitRows.find((r) => r.id === id) - expect(inferredMatch).toBeDefined() - expect(explicitMatch).toBeDefined() - - // Decrypt and compare — both should yield identical plaintext - const inferredDecrypted = await protectClient.decryptModel({ - metadata: inferredMatch!.metadata, - }) - const explicitDecrypted = await protectClient.decryptModel({ - metadata: explicitMatch!.metadata, - }) - if (inferredDecrypted.failure) - throw new Error(inferredDecrypted.failure.message) - if (explicitDecrypted.failure) - throw new Error(explicitDecrypted.failure.message) - - expect(inferredDecrypted.data.metadata).toEqual( - explicitDecrypted.data.metadata, - ) - expect(inferredDecrypted.data.metadata).toEqual(plaintext) - - // Containment: inferred (searchableJson) vs explicit (steVecTerm) - const inferredContainmentTerm = await encryptQueryTerm( - { category: 'equivalence-test' }, - 'searchableJson', - ) - const explicitContainmentTerm = await encryptQueryTerm( - { category: 'equivalence-test' }, - 'steVecTerm', - ) - - const inferredTermRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${inferredContainmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - const explicitTermRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${explicitContainmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(inferredTermRows.length).toBe(explicitTermRows.length) - expect(inferredTermRows.length).toBeGreaterThanOrEqual(1) - - const inferredTermMatch = inferredTermRows.find((r) => r.id === id) - const explicitTermMatch = explicitTermRows.find((r) => r.id === id) - expect(inferredTermMatch).toBeDefined() - expect(explicitTermMatch).toBeDefined() - - const inferredTermDecrypted = await protectClient.decryptModel({ - metadata: inferredTermMatch!.metadata, - }) - const explicitTermDecrypted = await protectClient.decryptModel({ - metadata: explicitTermMatch!.metadata, - }) - if (inferredTermDecrypted.failure) - throw new Error(inferredTermDecrypted.failure.message) - if (explicitTermDecrypted.failure) - throw new Error(explicitTermDecrypted.failure.message) - - expect(inferredTermDecrypted.data.metadata).toEqual( - explicitTermDecrypted.data.metadata, - ) - expect(inferredTermDecrypted.data.metadata).toEqual(plaintext) - }, 30000) - }) - - // ─── Escaped-composite-literal format ───────────────────────────── - - describe('escaped-composite-literal format', () => { - it('escaped selector → unwrap → query PG', async () => { - const plaintext = { - user: { email: 'escaped-sel@test.com' }, - marker: 'escaped-selector', - } - const { id } = await insertRow(plaintext) - - // Encrypt with both formats - const compositeData = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - 'composite-literal', - ) - const escapedData = (await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - 'escaped-composite-literal', - )) as string - - // Verify escaped format and unwrap - expect(typeof escapedData).toBe('string') - expect(escapedData).toMatch(/^"\(.*\)"$/) - const unwrapped = JSON.parse(escapedData) - - expect(unwrapped).toBe(compositeData) - - // Use composite-literal form to query PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${compositeData}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('escaped containment → unwrap → query PG', async () => { - const plaintext = { - role: 'escaped-containment-test', - department: 'security', - } - const { id } = await insertRow(plaintext) - - const escapedData = (await encryptQueryTerm( - { role: 'escaped-containment-test' }, - 'steVecTerm', - 'escaped-composite-literal', - )) as string - - // Verify escaped format and unwrap - expect(typeof escapedData).toBe('string') - expect(escapedData).toMatch(/^"\(.*\)"$/) - const unwrapped = JSON.parse(escapedData) - - // Unwrapped escaped format should be a valid composite-literal - expect(typeof unwrapped).toBe('string') - expect(unwrapped).toMatch(/^\(.*\)$/) - - // Use unwrapped composite-literal form to query PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${unwrapped}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('batch escaped format', async () => { - const plaintext = { - user: { email: 'batch-escaped@test.com' }, - role: 'batch-escaped-role', - marker: 'batch-escaped', - } - const { id } = await insertRow(plaintext) - - const queryResult = await protectClient.encryptQuery([ - { - value: '$.user.email', - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'escaped-composite-literal', - }, - { - value: { role: 'batch-escaped-role' }, - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'escaped-composite-literal', - }, - ]) - if (queryResult.failure) throw new Error(queryResult.failure.message) - - expect(queryResult.data).toHaveLength(2) - for (const item of queryResult.data) { - expect(typeof item).toBe('string') - expect(item).toMatch(/^"\(.*\)"$/) - } - - // Unwrap escaped format - const selectorUnwrapped = JSON.parse(queryResult.data[0] as string) - const containmentUnwrapped = JSON.parse(queryResult.data[1] as string) - - // Selector query - const selectorRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorUnwrapped}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - const selectorMatch = selectorRows.find((r) => r.id === id) - expect(selectorMatch).toBeDefined() - await verifyRow(selectorMatch!, plaintext) - - // Containment query - const containmentRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentUnwrapped}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - const containmentMatch = containmentRows.find((r) => r.id === id) - expect(containmentMatch).toBeDefined() - await verifyRow(containmentMatch!, plaintext) - }, 30000) - }) - - // ─── LockContext integration ────────────────────────────────────── - - describe.skipIf(!userJwt)('LockContext integration', () => { - it('selector with LockContext', async () => { - const lc = new LockContext() - const lockContext = await lc.identify(userJwt!) - if (lockContext.failure) throw new Error(lockContext.failure.message) - - const plaintext = { - user: { email: 'lc-selector@test.com' }, - marker: 'lock-context-selector', - } - - const encrypted = await protectClient - .encryptModel({ metadata: plaintext }, table) - .withLockContext(lockContext.data) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - const selectorResult = await protectClient - .encryptQuery('$.user.email', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }) - .withLockContext(lockContext.data) - .execute() - if (selectorResult.failure) - throw new Error(selectorResult.failure.message) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorResult.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === inserted.id) - expect(matchingRow).toBeDefined() - - const decrypted = await protectClient - .decryptModel({ metadata: matchingRow!.metadata }) - .withLockContext(lockContext.data) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data.metadata).toEqual(plaintext) - }, 60000) - - it('containment with LockContext', async () => { - const lc = new LockContext() - const lockContext = await lc.identify(userJwt!) - if (lockContext.failure) throw new Error(lockContext.failure.message) - - const plaintext = { role: 'lc-containment-test', department: 'auth' } - - const encrypted = await protectClient - .encryptModel({ metadata: plaintext }, table) - .withLockContext(lockContext.data) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - const containmentResult = await protectClient - .encryptQuery( - { role: 'lc-containment-test' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ) - .withLockContext(lockContext.data) - .execute() - if (containmentResult.failure) - throw new Error(containmentResult.failure.message) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentResult.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === inserted.id) - expect(matchingRow).toBeDefined() - - const decrypted = await protectClient - .decryptModel({ metadata: matchingRow!.metadata }) - .withLockContext(lockContext.data) - if (decrypted.failure) throw new Error(decrypted.failure.message) - expect(decrypted.data.metadata).toEqual(plaintext) - }, 60000) - - it('batch with LockContext', async () => { - const lc = new LockContext() - const lockContext = await lc.identify(userJwt!) - if (lockContext.failure) throw new Error(lockContext.failure.message) - - const plaintext = { - user: { email: 'lc-batch@test.com' }, - role: 'lc-batch-role', - kind: 'lock-context-batch', - } - - const encrypted = await protectClient - .encryptModel({ metadata: plaintext }, table) - .withLockContext(lockContext.data) - if (encrypted.failure) throw new Error(encrypted.failure.message) - - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encrypted.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - const batchResult = await protectClient - .encryptQuery([ - { - value: '$.user.email', - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }, - { - value: { role: 'lc-batch-role' }, - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ]) - .withLockContext(lockContext.data) - .execute() - if (batchResult.failure) throw new Error(batchResult.failure.message) - - const [selectorTerm, containmentTerm] = batchResult.data - - // Selector query - const selectorRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - const selectorMatch = selectorRows.find((r) => r.id === inserted.id) - expect(selectorMatch).toBeDefined() - - const selectorDecrypted = await protectClient - .decryptModel({ metadata: selectorMatch!.metadata }) - .withLockContext(lockContext.data) - if (selectorDecrypted.failure) - throw new Error(selectorDecrypted.failure.message) - expect(selectorDecrypted.data.metadata).toEqual(plaintext) - - // Containment query - const containmentRows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentTerm}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - const containmentMatch = containmentRows.find((r) => r.id === inserted.id) - expect(containmentMatch).toBeDefined() - - const containmentDecrypted = await protectClient - .decryptModel({ metadata: containmentMatch!.metadata }) - .withLockContext(lockContext.data) - if (containmentDecrypted.failure) - throw new Error(containmentDecrypted.failure.message) - expect(containmentDecrypted.data.metadata).toEqual(plaintext) - }, 60000) - }) - - // ─── Concurrent query operations ───────────────────────────────── - - describe('concurrent query operations', () => { - it('parallel selector queries', async () => { - // Insert 3 docs with distinct structures - const docs = [ - { alpha: { key: 'concurrent-sel-1' }, marker: 'concurrent-1' }, - { beta: { key: 'concurrent-sel-2' }, marker: 'concurrent-2' }, - { gamma: { key: 'concurrent-sel-3' }, marker: 'concurrent-3' }, - ] - - const insertedIds: number[] = [] - for (const plaintext of docs) { - const { id } = await insertRow(plaintext) - insertedIds.push(id) - } - - // Parallel encrypt 3 selector queries - const [q1, q2, q3] = await Promise.all([ - protectClient.encryptQuery('$.alpha.key', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - protectClient.encryptQuery('$.beta.key', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - protectClient.encryptQuery('$.gamma.key', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - ]) - - if (q1.failure) throw new Error(q1.failure.message) - if (q2.failure) throw new Error(q2.failure.message) - if (q3.failure) throw new Error(q3.failure.message) - - // Execute each against PG - const [rows1, rows2, rows3] = await Promise.all([ - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${q1.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${q2.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${q3.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - ]) - - // Each query should find its respective doc and not others - expect(rows1.find((r) => r.id === insertedIds[0])).toBeDefined() - expect(rows1.find((r) => r.id === insertedIds[1])).toBeUndefined() - expect(rows1.find((r) => r.id === insertedIds[2])).toBeUndefined() - expect(rows2.find((r) => r.id === insertedIds[1])).toBeDefined() - expect(rows2.find((r) => r.id === insertedIds[0])).toBeUndefined() - expect(rows2.find((r) => r.id === insertedIds[2])).toBeUndefined() - expect(rows3.find((r) => r.id === insertedIds[2])).toBeDefined() - expect(rows3.find((r) => r.id === insertedIds[0])).toBeUndefined() - expect(rows3.find((r) => r.id === insertedIds[1])).toBeUndefined() - - // Decrypt and validate each matched row - const match1 = rows1.find((r) => r.id === insertedIds[0])! - const decrypted1 = await protectClient.decryptModel({ - metadata: match1.metadata, - }) - if (decrypted1.failure) throw new Error(decrypted1.failure.message) - expect(decrypted1.data.metadata).toEqual(docs[0]) - - const match2 = rows2.find((r) => r.id === insertedIds[1])! - const decrypted2 = await protectClient.decryptModel({ - metadata: match2.metadata, - }) - if (decrypted2.failure) throw new Error(decrypted2.failure.message) - expect(decrypted2.data.metadata).toEqual(docs[1]) - - const match3 = rows3.find((r) => r.id === insertedIds[2])! - const decrypted3 = await protectClient.decryptModel({ - metadata: match3.metadata, - }) - if (decrypted3.failure) throw new Error(decrypted3.failure.message) - expect(decrypted3.data.metadata).toEqual(docs[2]) - }, 60000) - - it('parallel containment queries', async () => { - const docs = [ - { role: 'concurrent-contain-1', tier: 'gold' }, - { role: 'concurrent-contain-2', tier: 'silver' }, - ] - - const insertedIds: number[] = [] - for (const plaintext of docs) { - const { id } = await insertRow(plaintext) - insertedIds.push(id) - } - - // Parallel encrypt 2 containment queries - const [c1, c2] = await Promise.all([ - protectClient.encryptQuery( - { role: 'concurrent-contain-1' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ), - protectClient.encryptQuery( - { role: 'concurrent-contain-2' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ), - ]) - - if (c1.failure) throw new Error(c1.failure.message) - if (c2.failure) throw new Error(c2.failure.message) - - const [rows1, rows2] = await Promise.all([ - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE metadata @> ${c1.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE metadata @> ${c2.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - `, - ]) - - // Each finds only its target doc - expect(rows1.find((r) => r.id === insertedIds[0])).toBeDefined() - expect(rows1.find((r) => r.id === insertedIds[1])).toBeUndefined() - expect(rows2.find((r) => r.id === insertedIds[1])).toBeDefined() - expect(rows2.find((r) => r.id === insertedIds[0])).toBeUndefined() - - // Decrypt and validate each matched row - const match1 = rows1.find((r) => r.id === insertedIds[0])! - const decrypted1 = await protectClient.decryptModel({ - metadata: match1.metadata, - }) - if (decrypted1.failure) throw new Error(decrypted1.failure.message) - expect(decrypted1.data.metadata).toEqual(docs[0]) - - const match2 = rows2.find((r) => r.id === insertedIds[1])! - const decrypted2 = await protectClient.decryptModel({ - metadata: match2.metadata, - }) - if (decrypted2.failure) throw new Error(decrypted2.failure.message) - expect(decrypted2.data.metadata).toEqual(docs[1]) - }, 60000) - - it('parallel mixed encrypt+query', async () => { - const plaintext = { - user: { email: 'concurrent-mixed@test.com' }, - role: 'concurrent-mixed-role', - kind: 'mixed-concurrent', - } - - // Parallel: encryptModel + selector encryptQuery + containment encryptQuery - const [encryptedModel, selectorResult, containmentResult] = - await Promise.all([ - protectClient.encryptModel({ metadata: plaintext }, table), - protectClient.encryptQuery('$.user.email', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - returnType: 'composite-literal', - }), - protectClient.encryptQuery( - { role: 'concurrent-mixed-role' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - returnType: 'composite-literal', - }, - ), - ]) - - if (encryptedModel.failure) - throw new Error(encryptedModel.failure.message) - if (selectorResult.failure) - throw new Error(selectorResult.failure.message) - if (containmentResult.failure) - throw new Error(containmentResult.failure.message) - - // Insert the encrypted doc - const [inserted] = await sql` - INSERT INTO "protect-ci-jsonb" (metadata, test_run_id) - VALUES (${sql.json(encryptedModel.data.metadata)}::eql_v2_encrypted, ${TEST_RUN_ID}) - RETURNING id - ` - - // Query with both terms - const [selectorRows, containmentRows] = await Promise.all([ - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorResult.data}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - `, - sql` - SELECT id, (metadata).data as metadata FROM "protect-ci-jsonb" - WHERE metadata @> ${containmentResult.data}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - `, - ]) - - // Both should find the inserted row - expect(selectorRows.find((r) => r.id === inserted.id)).toBeDefined() - expect(containmentRows.find((r) => r.id === inserted.id)).toBeDefined() - // Verify result sets are bounded (not returning all rows) - expect(selectorRows.length).toBeGreaterThanOrEqual(1) - expect(containmentRows.length).toBeGreaterThanOrEqual(1) - - // Decrypt and validate both matched rows - const selectorMatch = selectorRows.find((r) => r.id === inserted.id)! - const selectorDecrypted = await protectClient.decryptModel({ - metadata: selectorMatch.metadata, - }) - if (selectorDecrypted.failure) - throw new Error(selectorDecrypted.failure.message) - expect(selectorDecrypted.data.metadata).toEqual(plaintext) - - const containmentMatch = containmentRows.find( - (r) => r.id === inserted.id, - )! - const containmentDecrypted = await protectClient.decryptModel({ - metadata: containmentMatch.metadata, - }) - if (containmentDecrypted.failure) - throw new Error(containmentDecrypted.failure.message) - expect(containmentDecrypted.data.metadata).toEqual(plaintext) - }, 60000) - }) - - // ─── Contained-by: <@ term queries ──────────────────────────────── - - describe('contained-by: <@ term queries', () => { - it('matches by key/value pair (Extended)', async () => { - const plaintext = { role: 'contained-by-kv', department: 'eng' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'contained-by-kv' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('matches by nested object (Extended)', async () => { - const plaintext = { - user: { profile: { role: 'contained-by-nested' } }, - active: true, - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { profile: { role: 'contained-by-nested' } } }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching value returns zero rows (Extended)', async () => { - const plaintext = { status: 'active-cb', tier: 'free' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { status: 'nonexistent-cb-xyz' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('matches by key/value pair (Simple)', async () => { - const plaintext = { role: 'contained-by-kv-simple', department: 'ops' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'contained-by-kv-simple' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('matches by nested object (Simple)', async () => { - const plaintext = { - user: { profile: { role: 'contained-by-nested-simple' } }, - active: true, - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { profile: { role: 'contained-by-nested-simple' } } }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching value returns zero rows (Simple)', async () => { - const plaintext = { status: 'active-cb-simple', tier: 'premium' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { status: 'nonexistent-cb-simple-xyz' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── jsonb_path_query_first: scalar path queries ────────────────── - - describe('jsonb_path_query_first: scalar path queries', () => { - it('finds row by string field (Extended)', async () => { - const plaintext = { role: 'qf-string', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path (Extended)', async () => { - const plaintext = { - user: { email: 'qf-nested@test.com' }, - type: 'qf-nested', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns no rows for unknown path (Extended)', async () => { - const plaintext = { exists: true, marker: 'qf-nomatch' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('finds row by string field (Simple)', async () => { - const plaintext = { role: 'qf-string-simple', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('finds row by nested path (Simple)', async () => { - const plaintext = { - user: { email: 'qf-nested-simple@test.com' }, - type: 'qf-nested-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns no rows for unknown path (Simple)', async () => { - const plaintext = { exists: true, marker: 'qf-nomatch-simple' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_query_first(t.metadata, '${selectorTerm}'::eql_v2_encrypted) IS NOT NULL - AND t.test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - // ─── jsonb_path_exists: boolean path queries ────────────────────── - - describe('jsonb_path_exists: boolean path queries', () => { - it('returns true for existing field (Extended)', async () => { - const plaintext = { role: 'pe-exists', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns true for nested path (Extended)', async () => { - const plaintext = { - user: { email: 'pe-nested@test.com' }, - type: 'pe-nested', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns false for unknown path (Extended)', async () => { - const plaintext = { exists: true, marker: 'pe-nomatch' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql` - SELECT id, eql_v2.jsonb_path_exists(t.metadata, ${selectorTerm}::eql_v2_encrypted) as path_exists - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].path_exists).toBe(false) - }, 30000) - - it('returns true for existing field (Simple)', async () => { - const plaintext = { role: 'pe-exists-simple', extra: 'data' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) - AND test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns true for nested path (Simple)', async () => { - const plaintext = { - user: { email: 'pe-nested-simple@test.com' }, - type: 'pe-nested-simple', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) - AND test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('returns false for unknown path (Simple)', async () => { - const plaintext = { exists: true, marker: 'pe-nomatch-simple' } - await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent.path', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE eql_v2.jsonb_path_exists(t.metadata, '${selectorTerm}'::eql_v2_encrypted) - AND test_run_id = '${TEST_RUN_ID}'`, - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - describe('jsonb_array_elements + jsonb_array_length: array queries', () => { - it('returns null length for missing path (Extended)', async () => { - const plaintext = { exists: true, marker: 'al-nomatch' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent', - 'steVecSelector', - ) - - const rows = await sql` - SELECT t.id, - eql_v2.jsonb_array_length( - eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted) - ) as arr_len - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].arr_len).toBeNull() - - const dataRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - // [@] notation (proxy convention) produces the selector hash matching is_array=true STE vec entries. - // EQL v2.3: walk the raw sv array on the jsonb payload so each element is a plain jsonb object - // (not the wrapped eql_v2_encrypted composite — its `.data` notation only works inside the function). - it('[@] selector matches is_array=true entries in STE vec', async () => { - const plaintext = { colors: ['a', 'b'], marker: 'diag-sv' } - const { id } = await insertRow(plaintext) - - const entries = await sql` - SELECT - elem->>'s' as selector, - (elem->>'a')::boolean as is_array - FROM "protect-ci-jsonb" t, - LATERAL jsonb_array_elements((t.metadata).data -> 'sv') AS elem - WHERE t.id = ${id} - ` - - const arrayEntries = entries.filter((e: any) => e.is_array === true) - expect(arrayEntries.length).toBeGreaterThan(0) - - const selectorAt = await encryptQueryTerm('$.colors[@]', 'steVecSelector') - const hashAt = await sql` - SELECT (${selectorAt}::eql_v2_encrypted).data->>'s' as s - ` - - expect(hashAt[0].s).toBe(arrayEntries[0].selector) - }, 30000) - - // EQL v2.3: `jsonb_path_query_first` returns at most one sv entry (LIMIT 1) — counting / expanding - // uses `jsonb_path_query`, which aggregates matching array entries into a single row whose `data` - // carries `sv: [...]` + `a: 1`. `jsonb_array_length` / `jsonb_array_elements` walk that inner sv. - it('returns correct length for known array (Extended)', async () => { - const plaintext = { colors: ['a', 'b', 'c', 'd'], marker: 'al-known' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.colors[@]', - 'steVecSelector', - ) - - const rows = await sql` - SELECT eql_v2.jsonb_array_length(elem) AS arr_len - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) AS elem - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].arr_len).toBe(4) - - const dataRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - it('returns correct length for known array (Simple)', async () => { - const plaintext = { colors: ['x', 'y', 'z'], marker: 'al-known-s' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.colors[@]', - 'steVecSelector', - ) - - const rows = await sql.unsafe( - `SELECT eql_v2.jsonb_array_length(elem) AS arr_len - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, $1::eql_v2_encrypted) AS elem - WHERE t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(1) - expect(rows[0].arr_len).toBe(3) - - const dataRows = await sql.unsafe( - `SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = $1`, - [id], - ) - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - it('expands array via jsonb_array_elements (Extended)', async () => { - const plaintext = { tags: ['ae-a', 'ae-b', 'ae-c'], marker: 'ae-expand' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.tags[@]', 'steVecSelector') - - const rows = await sql` - SELECT eql_v2.jsonb_array_elements(elem) AS item - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) AS elem - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(3) - - const dataRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - - it('expands array via jsonb_array_elements (Simple)', async () => { - const plaintext = { - tags: ['ae-s-a', 'ae-s-b', 'ae-s-c'], - marker: 'ae-expand-s', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.tags[@]', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT eql_v2.jsonb_array_elements(elem) AS item - FROM "protect-ci-jsonb" t, - LATERAL eql_v2.jsonb_path_query(t.metadata, $1::eql_v2_encrypted) AS elem - WHERE t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(3) - - const dataRows = await sql.unsafe( - `SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = $1`, - [id], - ) - expect(dataRows).toHaveLength(1) - await verifyRow(dataRows[0], plaintext) - }, 30000) - }) - - describe('containment: @> with array values', () => { - it('matches array subset (Extended)', async () => { - const plaintext = { - tags: ['ac-alpha', 'ac-beta', 'ac-gamma'], - marker: 'ac-subset', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-alpha'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array value returns no rows (Extended)', async () => { - const plaintext = { tags: ['ac-exist'], marker: 'ac-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-nonexistent'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('matches array subset (Simple)', async () => { - const plaintext = { - tags: ['ac-simple-x', 'ac-simple-y'], - marker: 'ac-simple', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-simple-x'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array value returns no rows (Simple)', async () => { - const plaintext = { tags: ['ac-s-exist'], marker: 'ac-s-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['ac-s-absent'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - - it('matches nested array subset (Extended)', async () => { - const plaintext = { - user: { roles: ['ac-nested-admin', 'ac-nested-editor'] }, - marker: 'ac-nested', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { user: { roles: ['ac-nested-admin'] } }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> ${containmentTerm}::eql_v2_encrypted - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - }) - - describe('contained-by: <@ with array values', () => { - it('matches array superset (Extended)', async () => { - const plaintext = { - tags: ['cb-one', 'cb-two', 'cb-three'], - marker: 'cb-superset', - } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-one'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array returns no rows (Extended)', async () => { - const plaintext = { tags: ['cb-exist'], marker: 'cb-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-absent'] }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('matches array superset (Simple)', async () => { - const plaintext = { tags: ['cb-s-one', 'cb-s-two'], marker: 'cb-s-super' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-s-one'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('non-matching array returns no rows (Simple)', async () => { - const plaintext = { tags: ['cb-s-exist'], marker: 'cb-s-nomatch' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { tags: ['cb-s-absent'] }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - }) - - describe('storage: array round-trips (gaps only)', () => { - it('round-trips object with empty string array', async () => { - const plaintext = { tags: [], marker: 'rt-empty-string-arr' } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('round-trips nested empty object array', async () => { - const plaintext = { data: { items: [] }, marker: 'rt-empty-obj-arr' } - const { id } = await insertRow(plaintext) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - }) - - // ─── Containment: operand and protocol matrix ────────────────────── - - describe('containment: operand and protocol matrix', () => { - it('@> matches key/value (Simple)', async () => { - const plaintext = { role: 'cm-admin-s', dept: 'cm-eng-s' } - const { id } = await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'cm-admin-s' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('@> non-matching returns no rows (Simple)', async () => { - const plaintext = { role: 'cm-exist-s' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'cm-nope-s' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.metadata @> $1::eql_v2_encrypted - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBe(0) - }, 30000) - - it('term <@ column matches subset (Extended)', async () => { - const plaintext = { role: 'cm-sub', marker: 'cm-sub-marker' } - const { id } = await insertRow(plaintext) - - // Query term is a SUBSET of the stored data - const containmentTerm = await encryptQueryTerm( - { role: 'cm-sub' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('term <@ column non-matching (Extended)', async () => { - const plaintext = { role: 'cm-sub-x' } - await insertRow(plaintext) - - const containmentTerm = await encryptQueryTerm( - { role: 'cm-sub-miss' }, - 'steVecTerm', - ) - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE ${containmentTerm}::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBe(0) - }, 30000) - - it('term <@ column matches subset (Simple)', async () => { - const plaintext = { role: 'cm-sub-s', marker: 'cm-sub-s-marker' } - const { id } = await insertRow(plaintext) - - // Query term is a SUBSET of the stored data - const containmentTerm = await encryptQueryTerm( - { role: 'cm-sub-s' }, - 'steVecTerm', - ) - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE $1::eql_v2_encrypted <@ t.metadata - AND t.test_run_id = $2`, - [containmentTerm, TEST_RUN_ID], - ) - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r: any) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - }) - - // ─── Field access: -> operator ───────────────────────────────────── - - describe('field access: -> operator', () => { - it('extracts field by encrypted selector (Extended)', async () => { - const plaintext = { - role: 'fa-enc', - dept: 'fa-dept', - marker: 'fa-enc-sel', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).not.toBeNull() - - const fullRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - await verifyRow(fullRows[0], plaintext) - }, 30000) - - it('extracts field by encrypted selector (Simple)', async () => { - const plaintext = { - role: 'fa-enc-s', - dept: 'fa-dept-s', - marker: 'fa-enc-sel-s', - } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT t.metadata -> $1::eql_v2_encrypted as extracted - FROM "protect-ci-jsonb" t - WHERE t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).not.toBeNull() - - const fullRows = await sql` - SELECT (metadata).data as metadata FROM "protect-ci-jsonb" t WHERE t.id = ${id} - ` - await verifyRow(fullRows[0], plaintext) - }, 30000) - - it('returns null for non-existent field (Extended)', async () => { - const plaintext = { role: 'fa-null', marker: 'fa-null-marker' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm( - '$.nonexistent', - 'steVecSelector', - ) - - const rows = await sql` - SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).toBeNull() - }, 30000) - - it('extracted field can be round-tripped (Extended)', async () => { - const plaintext = { - role: 'fa-roundtrip', - dept: 'fa-rt-dept', - marker: 'fa-rt-marker', - } - const { id } = await insertRow(plaintext) - - // Extract the role field via -> operator - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT t.metadata -> ${selectorTerm}::eql_v2_encrypted as extracted, - (t.metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE t.id = ${id} - ` - - expect(rows).toHaveLength(1) - expect(rows[0].extracted).not.toBeNull() - - // Decrypt the full document and verify the extracted field matches - await verifyRow(rows[0], plaintext) - }, 30000) - }) - - // ─── WHERE comparison: = equality ────────────────────────────────── - - // EQL v2.3: `=` on `eql_v2_encrypted` reduces to `hmac_256(a) = hmac_256(b)` and silently - // returns 0 rows when either side lacks `hm` (previously raised). For sv-element equality on - // oc-bearing selectors (strings / numbers), cast the extracted entry to `eql_v2.ste_vec_entry` - // — that operator is XOR-aware over `hm`/`oc` via `eq_term`. - describe('WHERE comparison: = equality (sv-element via ste_vec_entry)', () => { - it('jsonb_path_query_first = self-comparison (Extended)', async () => { - const plaintext = { role: 'eq-jpqf', marker: 'eq-jpqf-marker' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE (eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(t.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND t.id = ${id} - ` - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('jsonb_path_query_first = self-comparison (Simple)', async () => { - const plaintext = { role: 'eq-jpqf-s', marker: 'eq-jpqf-s-marker' } - const { id } = await insertRow(plaintext) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql.unsafe( - `SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t - WHERE (eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(t.metadata, $1::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND t.id = $2`, - [selectorTerm, id], - ) - - expect(rows).toHaveLength(1) - await verifyRow(rows[0], plaintext) - }, 30000) - - it('equality across two documents with same plaintext matches both', async () => { - const doc1 = { role: 'eq-cross-same', dept: 'eq-cross-d1' } - const doc2 = { role: 'eq-cross-same', dept: 'eq-cross-d2' } - - const { id: id1 } = await insertRow(doc1) - const { id: id2 } = await insertRow(doc2) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT a.id as id_a, b.id as id_b - FROM "protect-ci-jsonb" a, "protect-ci-jsonb" b - WHERE (eql_v2.jsonb_path_query_first(a.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(b.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND a.id = ${id1} - AND b.id = ${id2} - ` - - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ id_a: id1, id_b: id2 }) - }, 30000) - - it('equality across two documents with different plaintext returns empty', async () => { - const doc1 = { role: 'eq-cross-mismatch-1', marker: 'eq-mm-1' } - const doc2 = { role: 'eq-cross-mismatch-2', marker: 'eq-mm-2' } - - const { id: id1 } = await insertRow(doc1) - const { id: id2 } = await insertRow(doc2) - - const selectorTerm = await encryptQueryTerm('$.role', 'steVecSelector') - - const rows = await sql` - SELECT a.id as id_a, b.id as id_b - FROM "protect-ci-jsonb" a, "protect-ci-jsonb" b - WHERE (eql_v2.jsonb_path_query_first(a.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - = (eql_v2.jsonb_path_query_first(b.metadata, ${selectorTerm}::eql_v2_encrypted)).data::eql_v2.ste_vec_entry - AND a.id = ${id1} - AND b.id = ${id2} - ` - - expect(rows).toHaveLength(0) - }, 30000) - }) - - // ─── eql (default) return type ────────────────────────────────────── - - describe('eql (default) return type', () => { - it('selector query using raw eql return type', async () => { - const plaintext = { - user: { email: 'eql-raw-sel@test.com' }, - marker: 'eql-raw-sel', - } - const { id } = await insertRow(plaintext) - - // Omit returnType — single-value encryptQuery returns raw Encrypted object - const queryResult = await protectClient.encryptQuery('$.user.email', { - column: table.metadata, - table: table, - queryType: 'steVecSelector', - }) - if (queryResult.failure) throw new Error(queryResult.failure.message) - const rawResult = queryResult.data - - // Must use sql.json() to pass raw Encrypted object to PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${sql.json(rawResult)}::eql_v2_encrypted) as result - WHERE t.test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - - it('containment query using raw eql return type', async () => { - const plaintext = { role: 'eql-raw-contain', marker: 'eql-raw-ct' } - const { id } = await insertRow(plaintext) - - // Omit returnType — single-value encryptQuery returns raw Encrypted object - const queryResult = await protectClient.encryptQuery( - { role: 'eql-raw-contain' }, - { - column: table.metadata, - table: table, - queryType: 'steVecTerm', - }, - ) - if (queryResult.failure) throw new Error(queryResult.failure.message) - const rawResult = queryResult.data - - // Must use sql.json() to pass raw Encrypted object to PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" - WHERE metadata @> ${sql.json(rawResult)}::eql_v2_encrypted - AND test_run_id = ${TEST_RUN_ID} - ` - - expect(rows.length).toBeGreaterThanOrEqual(1) - const matchingRow = rows.find((r) => r.id === id) - expect(matchingRow).toBeDefined() - await verifyRow(matchingRow!, plaintext) - }, 30000) - }) - - // ─── Concurrent encrypt + decrypt stress ──────────────────────────── - - describe('concurrent encrypt + decrypt stress', () => { - it('concurrent encrypt + decrypt stress (10 parallel)', async () => { - const docs = Array.from({ length: 10 }, (_, i) => ({ - user: { email: `stress-${i}@test.com` }, - role: `stress-role-${i}`, - index: i, - marker: `stress-${i}`, - })) - - // Insert all 10 docs - const insertedIds: number[] = [] - for (const plaintext of docs) { - const { id } = await insertRow(plaintext) - insertedIds.push(id) - } - - // 10 parallel encrypt-query-decrypt pipelines - const results = await Promise.all( - docs.map(async (plaintext, i) => { - // Encrypt a selector query - const selectorTerm = await encryptQueryTerm( - '$.user.email', - 'steVecSelector', - ) - - // Query PG - const rows = await sql` - SELECT id, (metadata).data as metadata - FROM "protect-ci-jsonb" t, - eql_v2.jsonb_path_query(t.metadata, ${selectorTerm}::eql_v2_encrypted) as result - WHERE t.id = ${insertedIds[i]} - ` - - expect(rows).toHaveLength(1) - - // Decrypt - const decrypted = await protectClient.decryptModel({ - metadata: rows[0].metadata, - }) - if (decrypted.failure) throw new Error(decrypted.failure.message) - - return decrypted.data.metadata - }), - ) - - // Assert all 10 return correct plaintext - expect(results).toHaveLength(10) - results.forEach((result, i) => { - expect(result).toEqual(docs[i]) - }) - }, 120000) - }) -}) diff --git a/packages/protect/__tests__/supabase.test.ts b/packages/protect/__tests__/supabase.test.ts deleted file mode 100644 index 8face3bd5..000000000 --- a/packages/protect/__tests__/supabase.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import 'dotenv/config' -import { csColumn, csTable } from '@cipherstash/schema' -import { createClient } from '@supabase/supabase-js' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - bulkModelsToEncryptedPgComposites, - type Encrypted, - encryptedToPgComposite, - isEncryptedPayload, - modelToEncryptedPgComposites, - protect, -} from '../src' - -// supabase.test.ts needs a live Supabase project, so the suite is skipped -// when the Supabase environment is not configured (e.g. in CI, pending a -// containerised Supabase setup). It runs locally when SUPABASE_URL, -// SUPABASE_ANON_KEY, and DATABASE_URL are all set. -const SUPABASE_ENABLED = Boolean( - process.env.SUPABASE_URL && - process.env.SUPABASE_ANON_KEY && - process.env.DATABASE_URL, -) - -const supabase = SUPABASE_ENABLED - ? createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) - : (undefined as unknown as ReturnType) - -const table = csTable('protect-ci', { - encrypted: csColumn('encrypted').freeTextSearch().equality(), - age: csColumn('age').dataType('number').equality(), - score: csColumn('score').dataType('number').equality(), -}) - -// Hard code this as the CI database doesn't support order by on encrypted columns -const SKIP_ORDER_BY_TEST = true - -// Unique identifier for this test run to isolate data from concurrent test runs -// This is stored in a dedicated test_run_id column to avoid polluting test data -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - -// Track all inserted IDs for cleanup -const insertedIds: number[] = [] - -beforeAll(async () => { - // Idempotent fixture setup. The `protect-ci` table is shared across the - // drizzle + protect/supabase + stack/supabase integration suites; each - // suite's beforeAll runs the same CREATE TABLE so a fresh database is - // ready without manual DBA work. The schema is the union of every - // column those suites read or write. - const sql = postgres(process.env.DATABASE_URL as string, { prepare: false }) - try { - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - email eql_v2_encrypted, - age eql_v2_encrypted, - score eql_v2_encrypted, - profile eql_v2_encrypted, - encrypted eql_v2_encrypted, - "otherField" TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - test_run_id TEXT - ) - ` - // Backfill any column added after the table was first created on a - // long-lived CI database. CREATE TABLE IF NOT EXISTS is a no-op on - // those, so new columns need an explicit ADD COLUMN IF NOT EXISTS. - await sql` - ALTER TABLE "protect-ci" ADD COLUMN IF NOT EXISTS "otherField" TEXT - ` - // Tell PostgREST to refresh its schema cache so the supabase-js client - // can see a freshly created table without waiting for the polling - // interval. No-op on plain Postgres (no listener bound). - await sql`NOTIFY pgrst, 'reload schema'` - } finally { - await sql.end() - } - - // Clean up any data from this specific test run (safe for concurrent runs) - const { error } = await supabase - .from('protect-ci') - .delete() - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - console.warn(`[protect]: Failed to clean up test data: ${error.message}`) - } -}, 30000) - -afterAll(async () => { - // Clean up all data from this test run - if (insertedIds.length > 0) { - const { error } = await supabase - .from('protect-ci') - .delete() - .in('id', insertedIds) - if (error) { - console.error(`[protect]: Failed to clean up test data: ${error.message}`) - } - } -}) - -describe.skipIf(!SUPABASE_ENABLED)('supabase', () => { - it('should insert and select encrypted data', async () => { - const protectClient = await protect({ schemas: [table] }) - - const e = 'hello world' - - const ciphertext = await protectClient.encrypt(e, { - column: table.encrypted, - table: table, - }) - - if (ciphertext.failure) { - throw new Error(`[protect]: ${ciphertext.failure.message}`) - } - - const { data: insertedData, error: insertError } = await supabase - .from('protect-ci') - .insert({ - encrypted: encryptedToPgComposite(ciphertext.data), - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData[0].id) - - const { data, error } = await supabase - .from('protect-ci') - .select('id, encrypted::jsonb') - .eq('id', insertedData[0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - const dataToDecrypt = data[0].encrypted as Encrypted - const plaintext = await protectClient.decrypt(dataToDecrypt) - - expect(plaintext).toEqual({ - data: e, - }) - }, 30000) - - it('should insert and select encrypted model data', async () => { - const protectClient = await protect({ schemas: [table] }) - - const model = { - encrypted: 'hello world', - otherField: 'not encrypted', - } - - const encryptedModel = await protectClient.encryptModel(model, table) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - const { data: insertedData, error: insertError } = await supabase - .from('protect-ci') - .insert([ - { - ...modelToEncryptedPgComposites(encryptedModel.data), - test_run_id: TEST_RUN_ID, - }, - ]) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData[0].id) - - const { data, error } = await supabase - .from('protect-ci') - .select('id, encrypted::jsonb, otherField') - .eq('id', insertedData[0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - if (!isEncryptedPayload(data[0].encrypted)) { - throw new Error('Expected encrypted payload') - } - - const decryptedModel = await protectClient.decryptModel(data[0]) - - if (decryptedModel.failure) { - throw new Error(`[protect]: ${decryptedModel.failure.message}`) - } - - expect({ - encrypted: decryptedModel.data.encrypted, - otherField: data[0].otherField, - }).toEqual(model) - }, 30000) - - it('should insert and select bulk encrypted model data', async () => { - const protectClient = await protect({ schemas: [table] }) - - const models = [ - { - encrypted: 'hello world 1', - otherField: 'not encrypted 1', - }, - { - encrypted: 'hello world 2', - otherField: 'not encrypted 2', - }, - ] - - const encryptedModels = await protectClient.bulkEncryptModels(models, table) - - if (encryptedModels.failure) { - throw new Error(`[protect]: ${encryptedModels.failure.message}`) - } - - const dataToInsert = bulkModelsToEncryptedPgComposites( - encryptedModels.data, - ).map((row) => ({ - ...row, - test_run_id: TEST_RUN_ID, - })) - - const { data: insertedData, error: insertError } = await supabase - .from('protect-ci') - .insert(dataToInsert) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData.map((d: { id: number }) => d.id)) - - const { data, error } = await supabase - .from('protect-ci') - .select('id, encrypted::jsonb, otherField') - .in( - 'id', - insertedData.map((d: { id: number }) => d.id), - ) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - const decryptedModels = await protectClient.bulkDecryptModels(data) - - if (decryptedModels.failure) { - throw new Error(`[protect]: ${decryptedModels.failure.message}`) - } - - expect( - decryptedModels.data.map((d) => { - return { - encrypted: d.encrypted, - otherField: d.otherField, - } - }), - ).toEqual(models) - }, 30000) - - it('should insert and query encrypted number data with equality', async () => { - const protectClient = await protect({ schemas: [table] }) - - const testAge = 25 - const model = { - age: testAge, - otherField: 'not encrypted', - } - - const encryptedModel = await protectClient.encryptModel(model, table) - - if (encryptedModel.failure) { - throw new Error(`[protect]: ${encryptedModel.failure.message}`) - } - - const insertResult = await supabase - .from('protect-ci') - .insert([ - { - ...modelToEncryptedPgComposites(encryptedModel.data), - test_run_id: TEST_RUN_ID, - }, - ]) - .select('id') - - if (insertResult.error) { - throw new Error(`[protect]: ${insertResult.error.message}`) - } - - const insertedRecordId = insertResult.data[0].id - insertedIds.push(insertedRecordId) - - // Create encrypted query for equality search with composite-literal returnType - const encryptedResult = await protectClient.encryptQuery([ - { - value: testAge, - column: table.age, - table: table, - queryType: 'equality', - returnType: 'composite-literal', - }, - ]) - - if (encryptedResult.failure) { - throw new Error(`[protect]: ${encryptedResult.failure.message}`) - } - - const [searchTerm] = encryptedResult.data - - // Query filtering by both encrypted age AND our specific test run's ID - // This ensures we don't pick up stale data from other test runs - const { data, error } = await supabase - .from('protect-ci') - .select('id, age::jsonb, otherField') - .eq('age', searchTerm) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - // Verify we found our specific row with encrypted age match - expect(data).toHaveLength(1) - - const decryptedModel = await protectClient.decryptModel(data[0]) - - if (decryptedModel.failure) { - throw new Error(`[protect]: ${decryptedModel.failure.message}`) - } - - expect(decryptedModel.data.age).toBe(testAge) - }, 30000) -}) diff --git a/packages/protect/package.json b/packages/protect/package.json deleted file mode 100644 index 2864adee6..000000000 --- a/packages/protect/package.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "name": "@cipherstash/protect", - "version": "12.0.2-rc.0", - "description": "CipherStash Protect for JavaScript", - "keywords": [ - "encrypted", - "query", - "language", - "typescript", - "ts", - "protect" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/protect" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "bin": { - "stash": "./dist/bin/stash.js" - }, - "main": "./dist/index.cjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - }, - "./client": { - "types": "./dist/client.d.ts", - "import": "./dist/client.js", - "require": "./dist/client.cjs" - }, - "./identify": { - "types": "./dist/identify/index.d.ts", - "import": "./dist/identify/index.js", - "require": "./dist/identify/index.cjs" - }, - "./stash": { - "types": "./dist/stash/index.d.ts", - "import": "./dist/stash/index.js", - "require": "./dist/stash/index.cjs" - } - }, - "scripts": { - "build": "tsup", - "postbuild": "chmod +x ./dist/bin/stash.js", - "dev": "tsup --watch", - "test": "vitest run", - "release": "tsup" - }, - "devDependencies": { - "@supabase/supabase-js": "^2.110.2", - "execa": "^9.5.2", - "json-schema-to-typescript": "^15.0.2", - "postgres": "^3.4.7", - "tsup": "catalog:repo", - "tsx": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@byteslice/result": "^0.2.0", - "@cipherstash/protect-ffi": "0.23.0", - "@cipherstash/schema": "workspace:*", - "@stricli/core": "^1.2.9", - "dotenv": "17.4.2", - "zod": "^3.25.76" - }, - "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "4.62.2" - } -} diff --git a/packages/protect/src/bin/runner.ts b/packages/protect/src/bin/runner.ts deleted file mode 100644 index c313f5c5e..000000000 --- a/packages/protect/src/bin/runner.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' - -type Pm = 'npm' | 'pnpm' | 'yarn' | 'bun' - -function fromUserAgent(): Pm | undefined { - const ua = process.env.npm_config_user_agent ?? '' - if (ua.startsWith('bun/')) return 'bun' - if (ua.startsWith('pnpm/')) return 'pnpm' - if (ua.startsWith('yarn/')) return 'yarn' - return undefined -} - -function fromLockfile(cwd: string): Pm | undefined { - if ( - existsSync(resolve(cwd, 'bun.lockb')) || - existsSync(resolve(cwd, 'bun.lock')) - ) - return 'bun' - if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(resolve(cwd, 'yarn.lock'))) return 'yarn' - if (existsSync(resolve(cwd, 'package-lock.json'))) return 'npm' - return undefined -} - -export function detectRunner(): string { - const pm = fromUserAgent() ?? fromLockfile(process.cwd()) ?? 'npm' - return pm === 'bun' - ? 'bunx' - : pm === 'pnpm' - ? 'pnpm dlx' - : pm === 'yarn' - ? 'yarn dlx' - : 'npx' -} diff --git a/packages/protect/src/bin/stash.ts b/packages/protect/src/bin/stash.ts deleted file mode 100644 index 7506cbae6..000000000 --- a/packages/protect/src/bin/stash.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { config } from 'dotenv' - -config() - -import readline from 'node:readline' -import { - buildApplication, - buildCommand, - buildRouteMap, - run, -} from '@stricli/core' -import { Stash } from '../stash/index.js' -import { detectRunner } from './runner.js' - -// ANSI color codes for beautiful terminal output -const colors = { - reset: '\x1b[0m', - bold: '\x1b[1m', - dim: '\x1b[2m', - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - cyan: '\x1b[36m', - magenta: '\x1b[35m', -} - -const style = { - success: (text: string) => - `${colors.green}${colors.bold}✓${colors.reset} ${colors.green}${text}${colors.reset}`, - error: (text: string) => - `${colors.red}${colors.bold}✗${colors.reset} ${colors.red}${text}${colors.reset}`, - info: (text: string) => - `${colors.blue}${colors.bold}ℹ${colors.reset} ${colors.blue}${text}${colors.reset}`, - warning: (text: string) => - `${colors.yellow}${colors.bold}⚠${colors.reset} ${colors.yellow}${text}${colors.reset}`, - title: (text: string) => `${colors.bold}${colors.cyan}${text}${colors.reset}`, - label: (text: string) => `${colors.dim}${text}${colors.reset}`, - value: (text: string) => `${colors.bold}${text}${colors.reset}`, - bullet: () => `${colors.green}•${colors.reset}`, -} - -// Detect the package manager and build the CLI reference -const runner = detectRunner() -const cliRef = `${runner} stash` - -/** - * Get configuration from environment variables - */ -function getConfig(environment: string): Stash['config'] { - const workspaceCRN = process.env.CS_WORKSPACE_CRN - const clientId = process.env.CS_CLIENT_ID - const clientKey = process.env.CS_CLIENT_KEY - const apiKey = process.env.CS_CLIENT_ACCESS_KEY - const accessKey = process.env.CS_ACCESS_KEY - - const missing: string[] = [] - if (!workspaceCRN) missing.push('CS_WORKSPACE_CRN') - if (!clientId) missing.push('CS_CLIENT_ID') - if (!clientKey) missing.push('CS_CLIENT_KEY') - if (!apiKey) missing.push('CS_CLIENT_ACCESS_KEY') - - if (missing.length > 0) { - console.error( - style.error( - `Missing required environment variables: ${missing.join(', ')}`, - ), - ) - console.error( - `\n${style.info('Please set the following environment variables:')}`, - ) - for (const varName of missing) { - console.error(` ${style.bullet()} ${varName}`) - } - process.exit(1) - } - - if (!workspaceCRN || !clientId || !clientKey || !apiKey) { - // This should never happen due to the check above, but TypeScript needs it - throw new Error('Missing required configuration') - } - - return { - workspaceCRN, - clientId, - clientKey, - apiKey, - accessKey, - environment, - } -} - -/** - * Create a Stash instance with proper error handling - */ -function createStash(environment: string): Stash { - const config = getConfig(environment) - return new Stash(config) -} - -/** - * Prompt user for confirmation - */ -function askConfirmation(prompt: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise((resolve) => { - rl.question(prompt, (answer) => { - rl.close() - const normalized = answer.trim().toLowerCase() - resolve(normalized === 'y' || normalized === 'yes') - }) - }) -} - -/** - * Set command - Store an encrypted secret - */ -const setCommand = buildCommand({ - func: async (flags: { name: string; value: string; environment: string }) => { - const { name, value, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Encrypting and storing secret "${name}" in environment "${environment}"...`)}`, - ) - - const result = await stash.set(name, value) - if (result.failure) { - console.error( - style.error(`Failed to set secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log( - style.success( - `Secret "${name}" stored successfully in environment "${environment}"`, - ), - ) - }, - parameters: { - flags: { - name: { - kind: 'parsed', - parse: String, - brief: 'Name of the secret to store', - }, - value: { - kind: 'parsed', - parse: String, - brief: 'Plaintext value to encrypt and store', - }, - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - }, - aliases: { - n: 'name', - V: 'value', - e: 'environment', - }, - }, - docs: { - brief: 'Store an encrypted secret in CipherStash', - fullDescription: ` -Store a secret value that will be encrypted locally before being sent to the CipherStash API. -The secret is encrypted end-to-end, ensuring your plaintext never leaves your machine unencrypted. - -Examples: - ${cliRef} secrets set --name DATABASE_URL --value "postgres://..." --environment production - ${cliRef} secrets set -n DATABASE_URL -V "postgres://..." -e production - ${cliRef} secrets set --name API_KEY --value "sk-123..." --environment staging - `.trim(), - }, -}) - -/** - * Get command - Retrieve and decrypt a secret - */ -const getCommand = buildCommand({ - func: async (flags: { name: string; environment: string }) => { - const { name, environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Retrieving secret "${name}" from environment "${environment}"...`)}`, - ) - - const result = await stash.get(name) - if (result.failure) { - console.error( - style.error(`Failed to get secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log(`\n${style.title('Secret Value:')}`) - console.log(style.value(result.data)) - }, - parameters: { - flags: { - name: { - kind: 'parsed', - parse: String, - brief: 'Name of the secret to retrieve', - }, - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - }, - aliases: { - n: 'name', - e: 'environment', - }, - }, - docs: { - brief: 'Retrieve and decrypt a secret from CipherStash', - fullDescription: ` -Retrieve a secret from CipherStash and decrypt it locally. The secret value is decrypted -on your machine, ensuring end-to-end security. - -Examples: - ${cliRef} secrets get --name DATABASE_URL --environment production - ${cliRef} secrets get -n DATABASE_URL -e production - ${cliRef} secrets get --name API_KEY --environment staging - `.trim(), - }, -}) - -/** - * List command - List all secrets in an environment - */ -const listCommand = buildCommand({ - func: async (flags: { environment: string }) => { - const { environment } = flags - const stash = createStash(environment) - - console.log( - `${style.info(`Listing secrets in environment "${environment}"...`)}`, - ) - - const result = await stash.list() - if (result.failure) { - console.error( - style.error(`Failed to list secrets: ${result.failure.message}`), - ) - process.exit(1) - } - - if (result.data.length === 0) { - console.log( - `\n${style.warning(`No secrets found in environment "${environment}"`)}`, - ) - return - } - - console.log(`\n${style.title(`Secrets in environment "${environment}":`)}`) - console.log('') - - for (const secret of result.data) { - const name = style.value(secret.name) - const metadata: string[] = [] - if (secret.createdAt) { - metadata.push( - `${style.label('created:')} ${new Date(secret.createdAt).toLocaleString()}`, - ) - } - if (secret.updatedAt) { - metadata.push( - `${style.label('updated:')} ${new Date(secret.updatedAt).toLocaleString()}`, - ) - } - - const metaStr = - metadata.length > 0 - ? ` ${colors.dim}(${metadata.join(', ')})${colors.reset}` - : '' - console.log(` ${style.bullet()} ${name}${metaStr}`) - } - - console.log('') - console.log( - style.label( - `Total: ${result.data.length} secret${result.data.length === 1 ? '' : 's'}`, - ), - ) - }, - parameters: { - flags: { - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - }, - aliases: { - e: 'environment', - }, - }, - docs: { - brief: 'List all secrets in an environment', - fullDescription: ` -List all secrets stored in the specified environment. Only secret names and metadata -are returned; values remain encrypted and are not displayed. - -Examples: - ${cliRef} secrets list --environment production - ${cliRef} secrets list -e production - ${cliRef} secrets list --environment staging - `.trim(), - }, -}) - -/** - * Delete command - Delete a secret from the vault - */ -const deleteCommand = buildCommand({ - func: async (flags: { name: string; environment: string; yes?: boolean }) => { - const { name, environment, yes } = flags - const stash = createStash(environment) - - // Ask for confirmation unless --yes flag is set - if (!yes) { - const confirmation = await askConfirmation( - `${style.warning(`Are you sure you want to delete secret "${name}" from environment "${environment}"? This action cannot be undone. (yes/no): `)}`, - ) - - if (!confirmation) { - console.log(style.info('Deletion cancelled.')) - return - } - } - - console.log( - `${style.info(`Deleting secret "${name}" from environment "${environment}"...`)}`, - ) - - const result = await stash.delete(name) - if (result.failure) { - console.error( - style.error(`Failed to delete secret: ${result.failure.message}`), - ) - process.exit(1) - } - - console.log( - style.success( - `Secret "${name}" deleted successfully from environment "${environment}"`, - ), - ) - }, - parameters: { - flags: { - name: { - kind: 'parsed', - parse: String, - brief: 'Name of the secret to delete', - }, - environment: { - kind: 'parsed', - parse: String, - brief: 'Environment name (e.g., production, staging, development)', - }, - yes: { - kind: 'boolean', - optional: true, - brief: 'Skip confirmation prompt', - }, - }, - aliases: { - n: 'name', - e: 'environment', - y: 'yes', - }, - }, - docs: { - brief: 'Delete a secret from CipherStash', - fullDescription: ` -Permanently delete a secret from the specified environment. This action cannot be undone. -By default, you will be prompted for confirmation before deletion. Use --yes to skip the confirmation. - -Examples: - ${cliRef} secrets delete --name DATABASE_URL --environment production - ${cliRef} secrets delete -n DATABASE_URL -e production --yes - ${cliRef} secrets delete --name API_KEY --environment staging -y - `.trim(), - }, -}) - -/** - * Secrets route map - Groups all secret management commands - */ -const secretsRouteMap = buildRouteMap({ - routes: { - set: setCommand, - get: getCommand, - list: listCommand, - delete: deleteCommand, - }, - docs: { - brief: 'Manage encrypted secrets in CipherStash', - fullDescription: ` -The secrets command group provides operations for managing encrypted secrets stored in CipherStash. -All secrets are encrypted locally before being sent to the API, ensuring end-to-end encryption. - -Available Commands: - set Store an encrypted secret - get Retrieve and decrypt a secret - list List all secrets in an environment - delete Delete a secret from the vault - -Environment Variables: - CS_WORKSPACE_CRN CipherStash workspace CRN (required) - CS_CLIENT_ID CipherStash client ID (required) - CS_CLIENT_KEY CipherStash client key (required) - CS_CLIENT_ACCESS_KEY CipherStash client access key (required) - -Examples: - ${cliRef} secrets set --name DATABASE_URL --value "postgres://..." --environment production - ${cliRef} secrets set -n DATABASE_URL -V "postgres://..." -e production - ${cliRef} secrets get --name DATABASE_URL --environment production - ${cliRef} secrets get -n DATABASE_URL -e production - ${cliRef} secrets list --environment production - ${cliRef} secrets list -e production - ${cliRef} secrets delete --name DATABASE_URL --environment production - ${cliRef} secrets delete -n DATABASE_URL -e production --yes - ${cliRef} secrets delete -n DATABASE_URL -e production -y - `.trim(), - }, -}) - -/** - * Root command - Entry point for the CLI - */ -const rootRouteMap = buildRouteMap({ - routes: { - secrets: secretsRouteMap, - }, - docs: { - brief: 'CipherStash Protect - Encrypted secrets management', - fullDescription: ` -CipherStash Protect CLI - -Manage encrypted secrets with end-to-end encryption. Secrets are encrypted locally -before being sent to the CipherStash API, ensuring your plaintext never leaves -your machine unencrypted. - -Quick Start: - 1. Set required environment variables (CS_WORKSPACE_CRN, CS_CLIENT_ID, etc.) - 2. Use '${cliRef} secrets set' to store your first secret - 3. Use '${cliRef} secrets get' to retrieve secrets when needed - -Commands: - secrets Manage encrypted secrets - -Run '${cliRef} --help' for more information about a command. - `.trim(), - }, -}) - -/** - * Build the CLI application - */ -const app = buildApplication(rootRouteMap, { - name: 'stash', - versionInfo: { currentVersion: '10.2.1' }, - scanner: { caseStyle: 'allow-kebab-for-camel' }, -}) - -/** - * Main entry point - */ -async function main(): Promise { - try { - await run(app, process.argv.slice(2), { - process, - async forCommand() { - return { - process, - } - }, - }) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - console.error(style.error(`Unexpected error: ${message}`)) - process.exit(1) - } -} - -void main().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - console.error(style.error(`Fatal error: ${message}`)) - process.exit(1) -}) diff --git a/packages/protect/src/client.ts b/packages/protect/src/client.ts deleted file mode 100644 index 9d165ecc1..000000000 --- a/packages/protect/src/client.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Client-safe exports for @cipherstash/protect - * - * This entry point exports types and utilities that can be used in client-side code - * without requiring the @cipherstash/protect-ffi native module. - * - * Use this import path: `@cipherstash/protect/client` - */ - -export type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -// Schema types and utilities - client-safe -export { csColumn, csTable, csValue } from '@cipherstash/schema' -export type { ProtectClient } from './ffi' diff --git a/packages/protect/src/ffi/helpers/error-code.ts b/packages/protect/src/ffi/helpers/error-code.ts deleted file mode 100644 index 0552e84a1..000000000 --- a/packages/protect/src/ffi/helpers/error-code.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { - ProtectError as FfiProtectError, - type ProtectErrorCode, -} from '@cipherstash/protect-ffi' - -/** - * Extracts FFI error code from an error if it's an FFI error, otherwise returns undefined. - * Used to preserve specific error codes in ProtectError responses. - */ -export function getErrorCode(error: unknown): ProtectErrorCode | undefined { - return error instanceof FfiProtectError ? error.code : undefined -} diff --git a/packages/protect/src/ffi/helpers/infer-index-type.ts b/packages/protect/src/ffi/helpers/infer-index-type.ts deleted file mode 100644 index 3700ff166..000000000 --- a/packages/protect/src/ffi/helpers/infer-index-type.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { JsPlaintext, QueryOpName } from '@cipherstash/protect-ffi' -import type { ProtectColumn } from '@cipherstash/schema' -import type { FfiIndexTypeName, QueryTypeName } from '../../types' -import { queryTypeToFfi, queryTypeToQueryOp } from '../../types' - -/** - * Infer the primary index type from a column's configured indexes. - * Priority: unique > match > ore > ste_vec (for scalar queries) - */ -export function inferIndexType(column: ProtectColumn): FfiIndexTypeName { - const config = column.build() - const indexes = config.indexes - - if (!indexes || Object.keys(indexes).length === 0) { - throw new Error(`Column "${column.getName()}" has no indexes configured`) - } - - // Priority order for inference - if (indexes.unique) return 'unique' - if (indexes.match) return 'match' - if (indexes.ore) return 'ore' - if (indexes.ste_vec) return 'ste_vec' - - throw new Error( - `Column "${column.getName()}" has no suitable index for queries`, - ) -} - -/** - * Infer the FFI query operation from plaintext type for STE Vec queries. - * - String → ste_vec_selector (JSONPath queries like '$.user.email') - * - Object/Array/Number/Boolean → ste_vec_term (containment queries) - */ -export function inferQueryOpFromPlaintext(plaintext: JsPlaintext): QueryOpName { - if (typeof plaintext === 'string') { - return 'ste_vec_selector' - } - // Objects, arrays, numbers, booleans are all valid JSONB containment values - if ( - typeof plaintext === 'object' || - typeof plaintext === 'number' || - typeof plaintext === 'boolean' || - typeof plaintext === 'bigint' - ) { - return 'ste_vec_term' - } - // This should never happen with valid JsPlaintext, but keep for safety - return 'ste_vec_term' -} - -/** - * Validate that the specified index type is configured on the column - */ -export function validateIndexType( - column: ProtectColumn, - indexType: FfiIndexTypeName, -): void { - const config = column.build() - const indexes = config.indexes ?? {} - - const indexMap: Record = { - unique: !!indexes.unique, - match: !!indexes.match, - ore: !!indexes.ore, - ste_vec: !!indexes.ste_vec, - } - - if (!indexMap[indexType]) { - throw new Error( - `Index type "${indexType}" is not configured on column "${column.getName()}"`, - ) - } -} - -/** - * Resolve the index type and query operation for a query. - * Validates the index type is configured on the column when queryType is explicit. - * For ste_vec columns without explicit queryType, infers queryOp from plaintext shape. - * - * @param column - The column to resolve the index type for - * @param queryType - Optional explicit query type (if provided, validates against column config) - * @param plaintext - Optional plaintext value for queryOp inference on ste_vec columns - * @returns The FFI index type name and optional query operation name - * @throws Error if ste_vec is inferred but queryOp cannot be determined - */ -export function resolveIndexType( - column: ProtectColumn, - queryType?: QueryTypeName, - plaintext?: JsPlaintext | null, -): { indexType: FfiIndexTypeName; queryOp?: QueryOpName } { - const indexType = queryType - ? queryTypeToFfi[queryType] - : inferIndexType(column) - - if (queryType) { - validateIndexType(column, indexType) - - // For searchableJson, infer queryOp from plaintext type (not from mapping) - if (queryType === 'searchableJson') { - if (plaintext === undefined || plaintext === null) { - return { indexType } - } - return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) } - } - - return { indexType, queryOp: queryTypeToQueryOp[queryType] } - } - - // ste_vec inferred without explicit queryType → must infer from plaintext - if (indexType === 'ste_vec') { - if (plaintext === undefined || plaintext === null) { - // Null plaintext handled by caller (returns null early) - no inference needed - return { indexType } - } - return { indexType, queryOp: inferQueryOpFromPlaintext(plaintext) } - } - - // Non-ste_vec → no queryOp needed - return { indexType } -} diff --git a/packages/protect/src/ffi/helpers/type-guards.ts b/packages/protect/src/ffi/helpers/type-guards.ts deleted file mode 100644 index 86fb2fece..000000000 --- a/packages/protect/src/ffi/helpers/type-guards.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ScalarQueryTerm } from '../../types' - -/** - * Type guard to check if a value is an array of ScalarQueryTerm objects. - * Used to discriminate between single value and bulk encryption in encryptQuery overloads. - */ -export function isScalarQueryTermArray( - value: unknown, -): value is readonly ScalarQueryTerm[] { - return ( - Array.isArray(value) && - value.length > 0 && - typeof value[0] === 'object' && - value[0] !== null && - 'column' in value[0] && - 'table' in value[0] - ) -} diff --git a/packages/protect/src/ffi/helpers/validation.ts b/packages/protect/src/ffi/helpers/validation.ts deleted file mode 100644 index d544bcf9c..000000000 --- a/packages/protect/src/ffi/helpers/validation.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Result } from '@byteslice/result' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { FfiIndexTypeName } from '../../types' - -/** - * Validates that a value is not NaN or Infinity. - * Returns a failure Result if validation fails, undefined otherwise. - * Use this in async flows that return Result types. - * - * Uses `never` as the success type so the result can be assigned to any Result. - * - * @internal - */ -export function validateNumericValue( - value: unknown, -): Result | undefined { - if (typeof value === 'number' && Number.isNaN(value)) { - return { - failure: { - type: ProtectErrorTypes.EncryptionError, - message: '[protect]: Cannot encrypt NaN value', - }, - } - } - if (typeof value === 'number' && !Number.isFinite(value)) { - return { - failure: { - type: ProtectErrorTypes.EncryptionError, - message: '[protect]: Cannot encrypt Infinity value', - }, - } - } - return undefined -} - -/** - * Validates that a value is not NaN or Infinity. - * Throws an error if validation fails. - * Use this in sync flows where exceptions are caught. - * - * @internal - */ -export function assertValidNumericValue(value: unknown): void { - if (typeof value === 'number' && Number.isNaN(value)) { - throw new Error('[protect]: Cannot encrypt NaN value') - } - if (typeof value === 'number' && !Number.isFinite(value)) { - throw new Error('[protect]: Cannot encrypt Infinity value') - } -} - -/** - * Validates that the value type is compatible with the index type. - * Match index (freeTextSearch) only supports string values. - * Returns a failure Result if validation fails, undefined otherwise. - * Use this in async flows that return Result types. - * - * @internal - */ -export function validateValueIndexCompatibility( - value: unknown, - indexType: FfiIndexTypeName, - columnName: string, -): Result | undefined { - if (typeof value === 'number' && indexType === 'match') { - return { - failure: { - type: ProtectErrorTypes.EncryptionError, - message: `[protect]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.`, - }, - } - } - return undefined -} - -/** - * Validates that the value type is compatible with the index type. - * Match index (freeTextSearch) only supports string values. - * Throws an error if validation fails. - * Use this in sync flows where exceptions are caught. - * - * @internal - */ -export function assertValueIndexCompatibility( - value: unknown, - indexType: FfiIndexTypeName, - columnName: string, -): void { - if (typeof value === 'number' && indexType === 'match') { - throw new Error( - `[protect]: Cannot use 'match' index with numeric value on column "${columnName}". The 'freeTextSearch' index only supports string values. Configure the column with 'orderAndRange()' or 'equality()' for numeric queries.`, - ) - } -} diff --git a/packages/protect/src/ffi/index.ts b/packages/protect/src/ffi/index.ts deleted file mode 100644 index 9c0505bc3..000000000 --- a/packages/protect/src/ffi/index.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { type JsPlaintext, newClient } from '@cipherstash/protect-ffi' -import { - type EncryptConfig, - encryptConfigSchema, - type ProtectTable, - type ProtectTableColumn, -} from '@cipherstash/schema' -import { logger } from '../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '..' -import { toFfiKeysetIdentifier } from '../helpers' -import type { - BulkDecryptPayload, - BulkEncryptPayload, - Client, - Decrypted, - Encrypted, - EncryptOptions, - EncryptQueryOptions, - KeysetIdentifier, - ScalarQueryTerm, - SearchTerm, -} from '../types' -import { isScalarQueryTermArray } from './helpers/type-guards' -import { BatchEncryptQueryOperation } from './operations/batch-encrypt-query' -import { BulkDecryptOperation } from './operations/bulk-decrypt' -import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models' -import { BulkEncryptOperation } from './operations/bulk-encrypt' -import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models' -import { DecryptOperation } from './operations/decrypt' -import { DecryptModelOperation } from './operations/decrypt-model' -import { SearchTermsOperation } from './operations/deprecated/search-terms' -import { EncryptOperation } from './operations/encrypt' -import { EncryptModelOperation } from './operations/encrypt-model' -import { EncryptQueryOperation } from './operations/encrypt-query' - -export const noClientError = () => - new Error( - 'The EQL client has not been initialized. Please call init() before using the client.', - ) - -/** The ProtectClient is the main entry point for interacting with the CipherStash Protect.js library. - * It provides methods for encrypting and decrypting individual values, as well as models (objects) and bulk operations. - * - * The client must be initialized using the {@link protect} function before it can be used. - */ -export class ProtectClient { - private client: Client - private encryptConfig: EncryptConfig | undefined - - /** - * Initializes the ProtectClient with the provided configuration. - * @internal - * @param config - The configuration object for initializing the client. - * @returns A promise that resolves to a {@link Result} containing the initialized ProtectClient or a {@link ProtectError}. - **/ - async init(config: { - encryptConfig: EncryptConfig - workspaceCrn?: string - accessKey?: string - clientId?: string - clientKey?: string - keyset?: KeysetIdentifier - }): Promise> { - return await withResult( - async () => { - const validated: EncryptConfig = encryptConfigSchema.parse( - config.encryptConfig, - ) - - logger.debug( - 'Initializing the Protect.js client with the following encrypt config:', - { - encryptConfig: validated, - }, - ) - - // newClient handles env var fallback internally via withEnvCredentials, - // so we pass config values through without manual fallback here. - this.client = await newClient({ - encryptConfig: validated, - clientOpts: { - workspaceCrn: config.workspaceCrn, - accessKey: config.accessKey, - clientId: config.clientId, - clientKey: config.clientKey, - keyset: toFfiKeysetIdentifier(config.keyset), - }, - }) - - this.encryptConfig = validated - - logger.info('Successfully initialized the Protect.js client.') - return this - }, - (error: unknown) => ({ - type: ProtectErrorTypes.ClientInitError, - message: (error as Error).message, - }), - ) - } - - /** - * Encrypt a value - returns a promise which resolves to an encrypted value. - * - * @param plaintext - The plaintext value to be encrypted. Can be null. - * @param opts - Options specifying the column and table for encryption. - * @returns An EncryptOperation that can be awaited or chained with additional methods. - * - * @example - * The following example demonstrates how to encrypt a value using the Protect client. - * It includes defining an encryption schema with {@link csTable} and {@link csColumn}, - * initializing the client with {@link protect}, and performing the encryption. - * - * `encrypt` returns an {@link EncryptOperation} which can be awaited to get a {@link Result} - * which can either be the encrypted value or a {@link ProtectError}. - * - * ```typescript - * // Define encryption schema - * import { csTable, csColumn } from "@cipherstash/protect" - * const userSchema = csTable("users", { - * email: csColumn("email"), - * }); - * - * // Initialize Protect client - * const protectClient = await protect({ schemas: [userSchema] }) - * - * // Encrypt a value - * const encryptedResult = await protectClient.encrypt( - * "person@example.com", - * { column: userSchema.email, table: userSchema } - * ) - * - * // Handle encryption result - * if (encryptedResult.failure) { - * throw new Error(`Encryption failed: ${encryptedResult.failure.message}`); - * } - * - * console.log("Encrypted data:", encryptedResult.data); - * ``` - * - * @example - * When encrypting data, a {@link LockContext} can be provided to tie the encryption to a specific user or session. - * This ensures that the same lock context is required for decryption. - * - * The following example demonstrates how to create a lock context using a user's JWT token - * and use it during encryption. - * - * ```typescript - * // Define encryption schema and initialize client as above - * - * // Create a lock for the user's `sub` claim from their JWT - * const lc = new LockContext(); - * const lockContext = await lc.identify(userJwt); - * - * if (lockContext.failure) { - * // Handle the failure - * } - * - * // Encrypt a value with the lock context - * // Decryption will then require the same lock context - * const encryptedResult = await protectClient.encrypt( - * "person@example.com", - * { column: userSchema.email, table: userSchema } - * ) - * .withLockContext(lockContext) - * ``` - * - * @see {@link Result} - * @see {@link csTable} - * @see {@link LockContext} - * @see {@link EncryptOperation} - */ - encrypt( - plaintext: JsPlaintext | null, - opts: EncryptOptions, - ): EncryptOperation { - return new EncryptOperation(this.client, plaintext, opts) - } - - /** - * Encrypt a query value - returns a promise which resolves to an encrypted query value. - * - * @param plaintext - The plaintext value to be encrypted for querying. Can be null. - * @param opts - Options specifying the column, table, and optional queryType for encryption. - * @returns An EncryptQueryOperation that can be awaited or chained with additional methods. - * - * @example - * The following example demonstrates how to encrypt a query value using the Protect client. - * - * ```typescript - * // Define encryption schema - * import { csTable, csColumn } from "@cipherstash/protect" - * const userSchema = csTable("users", { - * email: csColumn("email").equality(), - * }); - * - * // Initialize Protect client - * const protectClient = await protect({ schemas: [userSchema] }) - * - * // Encrypt a query value - * const encryptedResult = await protectClient.encryptQuery( - * "person@example.com", - * { column: userSchema.email, table: userSchema, queryType: 'equality' } - * ) - * - * // Handle encryption result - * if (encryptedResult.failure) { - * throw new Error(`Encryption failed: ${encryptedResult.failure.message}`); - * } - * - * console.log("Encrypted query:", encryptedResult.data); - * ``` - * - * @example - * The queryType can be auto-inferred from the column's configured indexes: - * - * ```typescript - * // When queryType is omitted, it will be inferred from the column's indexes - * const encryptedResult = await protectClient.encryptQuery( - * "person@example.com", - * { column: userSchema.email, table: userSchema } - * ) - * ``` - * - * @see {@link EncryptQueryOperation} - * - * **JSONB columns (searchableJson):** - * When `queryType` is omitted on a `searchableJson()` column, the query operation is inferred: - * - String plaintext → `steVecSelector` (JSONPath queries like `'$.user.email'`) - * - Object/Array plaintext → `steVecTerm` (containment queries like `{ role: 'admin' }`) - */ - encryptQuery( - plaintext: JsPlaintext | null, - opts: EncryptQueryOptions, - ): EncryptQueryOperation - - /** - * Encrypt multiple values for use in queries (batch operation). - * @param terms - Array of query terms to encrypt - */ - encryptQuery(terms: readonly ScalarQueryTerm[]): BatchEncryptQueryOperation - - encryptQuery( - plaintextOrTerms: JsPlaintext | null | readonly ScalarQueryTerm[], - opts?: EncryptQueryOptions, - ): EncryptQueryOperation | BatchEncryptQueryOperation { - // Discriminate between ScalarQueryTerm[] and JsPlaintext (which can also be an array) - // using a type guard function - if (isScalarQueryTermArray(plaintextOrTerms)) { - return new BatchEncryptQueryOperation(this.client, plaintextOrTerms) - } - - // Handle empty arrays: if opts provided, treat as single value; otherwise batch mode - // This maintains backward compatibility for encryptQuery([]) while allowing - // encryptQuery([], opts) to encrypt an empty array as a single value - if ( - Array.isArray(plaintextOrTerms) && - plaintextOrTerms.length === 0 && - !opts - ) { - return new BatchEncryptQueryOperation( - this.client, - [] as readonly ScalarQueryTerm[], - ) - } - - return new EncryptQueryOperation( - this.client, - plaintextOrTerms as JsPlaintext | null, - opts!, - ) - } - - /** - * Decryption - returns a promise which resolves to a decrypted value. - * - * @param encryptedData - The encrypted data to be decrypted. - * @returns A DecryptOperation that can be awaited or chained with additional methods. - * - * @example - * The following example demonstrates how to decrypt a value that was previously encrypted using {@link encrypt} client. - * It includes encrypting a value first, then decrypting it, and handling the result. - * - * ```typescript - * const encryptedData = await eqlClient.encrypt( - * "person@example.com", - * { column: "email", table: "users" } - * ) - * const decryptResult = await eqlClient.decrypt(encryptedData) - * if (decryptResult.failure) { - * throw new Error(`Decryption failed: ${decryptResult.failure.message}`); - * } - * console.log("Decrypted data:", decryptResult.data); - * ``` - * - * @example - * Provide a lock context when decrypting: - * ```typescript - * await eqlClient.decrypt(encryptedData) - * .withLockContext(lockContext) - * ``` - * - * @see {@link LockContext} - * @see {@link DecryptOperation} - */ - decrypt(encryptedData: Encrypted): DecryptOperation { - return new DecryptOperation(this.client, encryptedData) - } - - /** - * Encrypt a model based on its encryptConfig. - * - * @example - * ```typescript - * type User = { - * id: string; - * email: string; // encrypted - * } - * - * // Define the schema for the users table - * const usersSchema = csTable('users', { - * email: csColumn('email').freeTextSearch().equality().orderAndRange(), - * }) - * - * // Initialize the Protect client - * const protectClient = await protect({ schemas: [usersSchema] }) - * - * // Encrypt a user model - * const encryptedModel = await protectClient.encryptModel( - * { id: 'user_123', email: 'person@example.com' }, - * usersSchema, - * ) - * ``` - */ - encryptModel>( - input: Decrypted, - table: ProtectTable, - ): EncryptModelOperation { - return new EncryptModelOperation(this.client, input, table) - } - - /** - * Decrypt a model with encrypted values - * Usage: - * await eqlClient.decryptModel(encryptedModel) - * await eqlClient.decryptModel(encryptedModel).withLockContext(lockContext) - */ - decryptModel>( - input: T, - ): DecryptModelOperation { - return new DecryptModelOperation(this.client, input) - } - - /** - * Bulk encrypt models with decrypted values - * Usage: - * await eqlClient.bulkEncryptModels(decryptedModels, table) - * await eqlClient.bulkEncryptModels(decryptedModels, table).withLockContext(lockContext) - */ - bulkEncryptModels>( - input: Array>, - table: ProtectTable, - ): BulkEncryptModelsOperation { - return new BulkEncryptModelsOperation(this.client, input, table) - } - - /** - * Bulk decrypt models with encrypted values - * Usage: - * await eqlClient.bulkDecryptModels(encryptedModels) - * await eqlClient.bulkDecryptModels(encryptedModels).withLockContext(lockContext) - */ - bulkDecryptModels>( - input: Array, - ): BulkDecryptModelsOperation { - return new BulkDecryptModelsOperation(this.client, input) - } - - /** - * Bulk encryption - returns a thenable object. - * Usage: - * await eqlClient.bulkEncrypt(plaintexts, { column, table }) - * await eqlClient.bulkEncrypt(plaintexts, { column, table }).withLockContext(lockContext) - */ - bulkEncrypt( - plaintexts: BulkEncryptPayload, - opts: EncryptOptions, - ): BulkEncryptOperation { - return new BulkEncryptOperation(this.client, plaintexts, opts) - } - - /** - * Bulk decryption - returns a thenable object. - * Usage: - * await eqlClient.bulkDecrypt(encryptedPayloads) - * await eqlClient.bulkDecrypt(encryptedPayloads).withLockContext(lockContext) - */ - bulkDecrypt(encryptedPayloads: BulkDecryptPayload): BulkDecryptOperation { - return new BulkDecryptOperation(this.client, encryptedPayloads) - } - - /** - * Create search terms to use in a query searching encrypted data - * - * @deprecated Use `encryptQuery(terms)` instead. - * - * Migration example: - * ```typescript - * // Before (deprecated) - * const result = await client.createSearchTerms([ - * { value: 'test', column: users.email, table: users } - * ]) - * - * // After - * const result = await client.encryptQuery([ - * { value: 'test', column: users.email, table: users, queryType: 'equality' } - * ]) - * ``` - * - * Usage: - * await eqlClient.createSearchTerms(searchTerms) - * await eqlClient.createSearchTerms(searchTerms).withLockContext(lockContext) - */ - createSearchTerms(terms: SearchTerm[]): SearchTermsOperation { - return new SearchTermsOperation(this.client, terms) - } -} diff --git a/packages/protect/src/ffi/model-helpers.ts b/packages/protect/src/ffi/model-helpers.ts deleted file mode 100644 index ff6823426..000000000 --- a/packages/protect/src/ffi/model-helpers.ts +++ /dev/null @@ -1,952 +0,0 @@ -import { - type Encrypted as CipherStashEncrypted, - type DecryptBulkOptions, - decryptBulk, - encryptBulk, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { isEncryptedPayload } from '../helpers' -import type { GetLockContextResponse } from '../identify' -import type { Client, Decrypted, Encrypted } from '../types' -import type { AuditData } from './operations/base-operation' - -/** - * Helper function to extract encrypted fields from a model - */ -export function extractEncryptedFields>( - model: T, -): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(model)) { - if (isEncryptedPayload(value)) { - result[key] = value - } - } - - return result -} - -/** - * Helper function to extract non-encrypted fields from a model - */ -export function extractOtherFields>( - model: T, -): Record { - const result: Record = {} - - for (const [key, value] of Object.entries(model)) { - if (!isEncryptedPayload(value)) { - result[key] = value - } - } - - return result -} - -/** - * Helper function to merge encrypted and non-encrypted fields into a model - */ -export function mergeFields( - otherFields: Record, - encryptedFields: Record, -): T { - return { ...otherFields, ...encryptedFields } as T -} - -/** - * Base interface for bulk operation payloads - */ -interface BulkOperationPayload { - id: string - [key: string]: unknown -} - -/** - * Interface for bulk operation key mapping - */ -interface BulkOperationKeyMap { - modelIndex: number - fieldKey: string -} - -/** - * Helper function to handle single model bulk operations with mapping - */ -async function handleSingleModelBulkOperation< - T extends BulkOperationPayload, - R, ->( - items: T[], - operation: (items: T[]) => Promise, - keyMap: Record, -): Promise> { - if (items.length === 0) { - return {} - } - - const results = await operation(items) - const mappedResults: Record = {} - - results.forEach((result, index) => { - const originalKey = keyMap[index.toString()] - mappedResults[originalKey] = result - }) - - return mappedResults -} - -/** - * Helper function to handle multiple model bulk operations with mapping - */ -async function handleMultiModelBulkOperation( - items: T[], - operation: (items: T[]) => Promise, - keyMap: Record, -): Promise> { - if (items.length === 0) { - return {} - } - - const results = await operation(items) - const mappedResults: Record = {} - - results.forEach((result, index) => { - const key = index.toString() - const { modelIndex, fieldKey } = keyMap[key] - mappedResults[`${modelIndex}-${fieldKey}`] = result - }) - - return mappedResults -} - -/** - * Helper function to prepare fields for decryption - */ -function prepareFieldsForDecryption>( - model: T, -): { - otherFields: Record - operationFields: Record - keyMap: Record - nullFields: Record -} { - const otherFields = { ...model } as Record - const operationFields: Record = {} - const nullFields: Record = {} - const keyMap: Record = {} - let index = 0 - - const processNestedFields = (obj: Record, prefix = '') => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - nullFields[fullKey] = value - continue - } - - if (typeof value === 'object' && !isEncryptedPayload(value)) { - // Recursively process nested objects - processNestedFields(value as Record, fullKey) - } else if (isEncryptedPayload(value)) { - // This is an encrypted field - const id = index.toString() - keyMap[id] = fullKey - operationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = otherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - - processNestedFields(model) - return { otherFields, operationFields, keyMap, nullFields } -} - -/** - * Helper function to prepare fields for encryption - */ -function prepareFieldsForEncryption>( - model: T, - table: ProtectTable, -): { - otherFields: Record - operationFields: Record - keyMap: Record - nullFields: Record -} { - const otherFields = { ...model } as Record - const operationFields: Record = {} - const nullFields: Record = {} - const keyMap: Record = {} - let index = 0 - - const processNestedFields = ( - obj: Record, - prefix = '', - columnPaths: string[] = [], - ) => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - nullFields[fullKey] = value - continue - } - - if ( - typeof value === 'object' && - !isEncryptedPayload(value) && - !columnPaths.includes(fullKey) - ) { - // Only process nested objects if they're in the schema - if (columnPaths.some((path) => path.startsWith(fullKey))) { - processNestedFields( - value as Record, - fullKey, - columnPaths, - ) - } - } else if (columnPaths.includes(fullKey)) { - // Only process fields that are explicitly defined in the schema - const id = index.toString() - keyMap[id] = fullKey - operationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = otherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) - processNestedFields(model, '', columnPaths) - - return { otherFields, operationFields, keyMap, nullFields } -} - -/** - * Helper function to convert a model with encrypted fields to a decrypted model - */ -export async function decryptModelFields>( - model: T, - client: Client, - auditData?: AuditData, -): Promise> { - if (!client) { - throw new Error('Client not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForDecryption(model) - - const bulkDecryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - ciphertext: value as CipherStashEncrypted, - }), - ) - - const decryptedFields = await handleSingleModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the decrypted fields - for (const [key, value] of Object.entries(decryptedFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as Decrypted -} - -/** - * Helper function to convert a decrypted model to a model with encrypted fields - */ -export async function encryptModelFields>( - model: Decrypted, - table: ProtectTable, - client: Client, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForEncryption(model, table) - - const bulkEncryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - plaintext: value as string, - table: table.tableName, - column: key, - }), - ) - - const encryptedData = await handleSingleModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the encrypted fields - for (const [key, value] of Object.entries(encryptedData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as T -} - -/** - * Helper function to convert a model with encrypted fields to a decrypted model with lock context - */ -export async function decryptModelFieldsWithLockContext< - T extends Record, ->( - model: T, - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise> { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForDecryption(model) - - const bulkDecryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - ciphertext: value as CipherStashEncrypted, - lockContext: lockContext.context, - }), - ) - - const decryptedFields = await handleSingleModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the decrypted fields - for (const [key, value] of Object.entries(decryptedFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as Decrypted -} - -/** - * Helper function to convert a decrypted model to a model with encrypted fields with lock context - */ -export async function encryptModelFieldsWithLockContext< - T extends Record, ->( - model: Decrypted, - table: ProtectTable, - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareFieldsForEncryption(model, table) - - const bulkEncryptPayload = Object.entries(operationFields).map( - ([key, value]) => ({ - id: key, - plaintext: value as string, - table: table.tableName, - column: key, - lockContext: lockContext.context, - }), - ) - - const encryptedData = await handleSingleModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - // Reconstruct the object with proper nesting - const result: Record = { ...otherFields } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the encrypted fields - for (const [key, value] of Object.entries(encryptedData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as T -} - -/** - * Helper function to prepare multiple models for bulk operation - */ -function prepareBulkModelsForOperation>( - models: T[], - table?: ProtectTable, -): { - otherFields: Record[] - operationFields: Record[] - keyMap: Record - nullFields: Record[] -} { - const otherFields: Record[] = [] - const operationFields: Record[] = [] - const nullFields: Record[] = [] - const keyMap: Record = {} - let index = 0 - - for (let modelIndex = 0; modelIndex < models.length; modelIndex++) { - const model = models[modelIndex] - const modelOtherFields = { ...model } as Record - const modelOperationFields: Record = {} - const modelNullFields: Record = {} - - const processNestedFields = ( - obj: Record, - prefix = '', - columnPaths: string[] = [], - ) => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - modelNullFields[fullKey] = value - continue - } - - if ( - typeof value === 'object' && - !isEncryptedPayload(value) && - !columnPaths.includes(fullKey) - ) { - // Only process nested objects if they're in the schema - if (columnPaths.some((path) => path.startsWith(fullKey))) { - processNestedFields( - value as Record, - fullKey, - columnPaths, - ) - } - } else if (columnPaths.includes(fullKey)) { - // Only process fields that are explicitly defined in the schema - const id = index.toString() - keyMap[id] = { modelIndex, fieldKey: fullKey } - modelOperationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = modelOtherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - - if (table) { - // Get all column paths from the table schema - const columnPaths = Object.keys(table.build().columns) - processNestedFields(model, '', columnPaths) - } else { - // For decryption, process all encrypted fields - const processEncryptedFields = ( - obj: Record, - prefix = '', - columnPaths: string[] = [], - ) => { - for (const [key, value] of Object.entries(obj)) { - const fullKey = prefix ? `${prefix}.${key}` : key - - if (value === null || value === undefined) { - modelNullFields[fullKey] = value - continue - } - - if ( - typeof value === 'object' && - !isEncryptedPayload(value) && - !columnPaths.includes(fullKey) - ) { - // Recursively process nested objects - processEncryptedFields( - value as Record, - fullKey, - columnPaths, - ) - } else if (isEncryptedPayload(value)) { - // This is an encrypted field - const id = index.toString() - keyMap[id] = { modelIndex, fieldKey: fullKey } - modelOperationFields[fullKey] = value - index++ - - // Remove from otherFields - const parts = fullKey.split('.') - let current = modelOtherFields - for (let i = 0; i < parts.length - 1; i++) { - current = current[parts[i]] as Record - } - delete current[parts[parts.length - 1]] - } - } - } - processEncryptedFields(model) - } - - otherFields.push(modelOtherFields) - operationFields.push(modelOperationFields) - nullFields.push(modelNullFields) - } - - return { otherFields, operationFields, keyMap, nullFields } -} - -/** - * Helper function to convert multiple decrypted models to models with encrypted fields - */ -export async function bulkEncryptModels>( - models: Decrypted[], - table: ProtectTable, - client: Client, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - if (!models || models.length === 0) { - return [] - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models, table) - - const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - plaintext: value as string, - table: table.tableName, - column: key, - })), - ) - - const encryptedData = await handleMultiModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - return models.map((_, modelIndex) => { - const result: Record = { ...otherFields[modelIndex] } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields[modelIndex])) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the encrypted fields - const modelData = Object.fromEntries( - Object.entries(encryptedData) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) - - for (const [key, value] of Object.entries(modelData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as T - }) -} - -/** - * Helper function to convert multiple models with encrypted fields to decrypted models - */ -export async function bulkDecryptModels>( - models: T[], - client: Client, - auditData?: AuditData, -): Promise[]> { - if (!client) { - throw new Error('Client not initialized') - } - - if (!models || models.length === 0) { - return [] - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models) - - const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - ciphertext: value as CipherStashEncrypted, - })), - ) - - const decryptedFields = await handleMultiModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Helper function to set a nested value - const setNestedValue = ( - obj: Record, - path: string[], - value: unknown, - ) => { - let current = obj - for (let i = 0; i < path.length - 1; i++) { - const part = path[i] - if (!(part in current)) { - current[part] = {} - } - current = current[part] as Record - } - current[path[path.length - 1]] = value - } - - return models.map((_, modelIndex) => { - const result: Record = { ...otherFields[modelIndex] } - - // First, reconstruct the null/undefined fields - for (const [key, value] of Object.entries(nullFields[modelIndex])) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - // Then, reconstruct the decrypted fields - const modelData = Object.fromEntries( - Object.entries(decryptedFields) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ) - - for (const [key, value] of Object.entries(modelData)) { - const parts = key.split('.') - setNestedValue(result, parts, value) - } - - return result as Decrypted - }) -} - -/** - * Helper function to convert multiple models with encrypted fields to decrypted models with lock context - */ -export async function bulkDecryptModelsWithLockContext< - T extends Record, ->( - models: T[], - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise[]> { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models) - - const bulkDecryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - ciphertext: value as CipherStashEncrypted, - lockContext: lockContext.context, - })), - ) - - const decryptedFields = await handleMultiModelBulkOperation( - bulkDecryptPayload, - (items) => - decryptBulk(client, { - ciphertexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Reconstruct models - return models.map((_, modelIndex) => ({ - ...otherFields[modelIndex], - ...nullFields[modelIndex], - ...Object.fromEntries( - Object.entries(decryptedFields) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ), - })) as Decrypted[] -} - -/** - * Helper function to convert multiple decrypted models to models with encrypted fields with lock context - */ -export async function bulkEncryptModelsWithLockContext< - T extends Record, ->( - models: Decrypted[], - table: ProtectTable, - client: Client, - lockContext: GetLockContextResponse, - auditData?: AuditData, -): Promise { - if (!client) { - throw new Error('Client not initialized') - } - - if (!lockContext) { - throw new Error('Lock context is not initialized') - } - - const { otherFields, operationFields, keyMap, nullFields } = - prepareBulkModelsForOperation(models, table) - - const bulkEncryptPayload = operationFields.flatMap((fields, modelIndex) => - Object.entries(fields).map(([key, value]) => ({ - id: `${modelIndex}-${key}`, - plaintext: value as string, - table: table.tableName, - column: key, - lockContext: lockContext.context, - })), - ) - - const encryptedData = await handleMultiModelBulkOperation( - bulkEncryptPayload, - (items) => - encryptBulk(client, { - plaintexts: items, - serviceToken: lockContext.ctsToken, - unverifiedContext: auditData?.metadata, - }), - keyMap, - ) - - // Reconstruct models - return models.map((_, modelIndex) => ({ - ...otherFields[modelIndex], - ...nullFields[modelIndex], - ...Object.fromEntries( - Object.entries(encryptedData) - .filter(([key]) => { - const [idx] = key.split('-') - return Number.parseInt(idx) === modelIndex - }) - .map(([key, value]) => { - const [_, fieldKey] = key.split('-') - return [fieldKey, value] - }), - ), - })) as T[] -} diff --git a/packages/protect/src/ffi/operations/base-operation.ts b/packages/protect/src/ffi/operations/base-operation.ts deleted file mode 100644 index 8e4dd36b8..000000000 --- a/packages/protect/src/ffi/operations/base-operation.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { ProtectError } from '../..' - -export type AuditConfig = { - metadata?: Record -} - -export type AuditData = { - metadata?: Record -} - -export abstract class ProtectOperation { - protected auditMetadata?: Record - - /** - * Attach audit metadata to this operation. Can be chained. - * @param config Configuration for ZeroKMS audit logging - * @param config.metadata Arbitrary JSON object for appending metadata to the audit log - */ - audit(config: AuditConfig): this { - this.auditMetadata = config.metadata - return this - } - - /** - * Get the audit data for this operation. - */ - public getAuditData(): AuditData { - return { - metadata: this.auditMetadata, - } - } - - /** - * Execute the operation and return a Result - */ - abstract execute(): Promise> - - /** - * Make the operation thenable - */ - public then, TResult2 = never>( - onfulfilled?: - | ((value: Result) => TResult1 | PromiseLike) - | null, - onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.execute().then(onfulfilled, onrejected) - } -} diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts deleted file mode 100644 index a8ebca064..000000000 --- a/packages/protect/src/ffi/operations/batch-encrypt-query.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { - Encrypted as CipherStashEncrypted, - EncryptedQuery as CipherStashEncryptedQuery, -} from '@cipherstash/protect-ffi' -import { - encryptQueryBulk as ffiEncryptQueryBulk, - type JsPlaintext, - type QueryPayload, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import { formatEncryptedResult } from '../../helpers' -import type { Context, LockContext } from '../../identify' -import type { Client, EncryptedQueryResult, ScalarQueryTerm } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { resolveIndexType } from '../helpers/infer-index-type' -import { - assertValidNumericValue, - assertValueIndexCompatibility, -} from '../helpers/validation' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -/** - * Separates null/undefined values from non-null terms in the input array. - * Returns a set of indices where values are null/undefined and an array of non-null terms with their original indices. - */ -function filterNullTerms(terms: readonly ScalarQueryTerm[]): { - nullIndices: Set - nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] -} { - const nullIndices = new Set() - const nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[] = [] - - terms.forEach((term, index) => { - if (term.value === null || term.value === undefined) { - nullIndices.add(index) - } else { - nonNullTerms.push({ term, originalIndex: index }) - } - }) - - return { nullIndices, nonNullTerms } -} - -/** - * Validates and transforms a single term into a QueryPayload. - * Throws an error if the value is NaN or Infinity. - * Optionally includes lockContext if provided. - */ -function buildQueryPayload( - term: ScalarQueryTerm, - lockContext?: Context, -): QueryPayload { - assertValidNumericValue(term.value) - - const { indexType, queryOp } = resolveIndexType( - term.column, - term.queryType, - term.value, - ) - - // Validate value/index compatibility - assertValueIndexCompatibility(term.value, indexType, term.column.getName()) - - const payload: QueryPayload = { - plaintext: term.value as JsPlaintext, - column: term.column.getName(), - table: term.table.tableName, - indexType, - queryOp, - } - - if (lockContext != null) { - payload.lockContext = lockContext - } - - return payload -} - -/** - * Reconstructs the results array with nulls in their original positions. - * Non-null encrypted values are placed at their original indices. - * Applies formatting based on term.returnType. - */ -function assembleResults( - totalLength: number, - encryptedValues: (CipherStashEncrypted | CipherStashEncryptedQuery)[], - nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[], -): EncryptedQueryResult[] { - const results: EncryptedQueryResult[] = new Array(totalLength).fill(null) - - // Fill in encrypted values at their original positions, applying formatting - nonNullTerms.forEach(({ term, originalIndex }, i) => { - const encrypted = encryptedValues[i] - - results[originalIndex] = formatEncryptedResult(encrypted, term.returnType) - }) - - return results -} - -/** - * @internal Use {@link ProtectClient.encryptQuery} with array input instead. - */ -export class BatchEncryptQueryOperation extends ProtectOperation< - EncryptedQueryResult[] -> { - constructor( - private client: Client, - private terms: readonly ScalarQueryTerm[], - ) { - super() - } - - public withLockContext( - lockContext: LockContext, - ): BatchEncryptQueryOperationWithLockContext { - return new BatchEncryptQueryOperationWithLockContext( - this.client, - this.terms, - lockContext, - this.auditMetadata, - ) - } - - public async execute(): Promise< - Result - > { - logger.debug('Encrypting query terms', { count: this.terms.length }) - - if (this.terms.length === 0) { - return { data: [] } - } - - const { nullIndices, nonNullTerms } = filterNullTerms(this.terms) - - if (nonNullTerms.length === 0) { - return { data: this.terms.map(() => null) } - } - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = nonNullTerms.map(({ term }) => - buildQueryPayload(term), - ) - - const encrypted = await ffiEncryptQueryBulk(this.client, { - queries, - unverifiedContext: metadata, - }) - - return assembleResults(this.terms.length, encrypted, nonNullTerms) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} - -/** - * @internal Use {@link ProtectClient.encryptQuery} with array input and `.withLockContext()` instead. - */ -export class BatchEncryptQueryOperationWithLockContext extends ProtectOperation< - EncryptedQueryResult[] -> { - constructor( - private client: Client, - private terms: readonly ScalarQueryTerm[], - private lockContext: LockContext, - auditMetadata?: Record, - ) { - super() - this.auditMetadata = auditMetadata - } - - public async execute(): Promise< - Result - > { - logger.debug('Encrypting query terms with lock context', { - count: this.terms.length, - }) - - if (this.terms.length === 0) { - return { data: [] } - } - - // Check for all-null terms BEFORE fetching lockContext to avoid unnecessary network call - const { nullIndices, nonNullTerms } = filterNullTerms(this.terms) - - if (nonNullTerms.length === 0) { - return { data: this.terms.map(() => null) } - } - - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - return { failure: lockContextResult.failure } - } - - const { ctsToken, context } = lockContextResult.data - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = nonNullTerms.map(({ term }) => - buildQueryPayload(term, context), - ) - - const encrypted = await ffiEncryptQueryBulk(this.client, { - queries, - serviceToken: ctsToken, - unverifiedContext: metadata, - }) - - return assembleResults(this.terms.length, encrypted, nonNullTerms) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts b/packages/protect/src/ffi/operations/bulk-decrypt-models.ts deleted file mode 100644 index dda93bff7..000000000 --- a/packages/protect/src/ffi/operations/bulk-decrypt-models.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - bulkDecryptModels, - bulkDecryptModelsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class BulkDecryptModelsOperation< - T extends Record, -> extends ProtectOperation[]> { - private client: Client - private models: T[] - - constructor(client: Client, models: T[]) { - super() - this.client = client - this.models = models - } - - public withLockContext( - lockContext: LockContext, - ): BulkDecryptModelsOperationWithLockContext { - return new BulkDecryptModelsOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise[], ProtectError>> { - logger.debug('Bulk decrypting models WITHOUT a lock context') - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await bulkDecryptModels(this.models, this.client, auditData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - models: T[] - } { - return { - client: this.client, - models: this.models, - } - } -} - -export class BulkDecryptModelsOperationWithLockContext< - T extends Record, -> extends ProtectOperation[]> { - private operation: BulkDecryptModelsOperation - private lockContext: LockContext - - constructor( - operation: BulkDecryptModelsOperation, - lockContext: LockContext, - ) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise[], ProtectError>> { - return await withResult( - async () => { - const { client, models } = this.operation.getOperation() - - logger.debug('Bulk decrypting models WITH a lock context') - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await bulkDecryptModelsWithLockContext( - models, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-decrypt.ts b/packages/protect/src/ffi/operations/bulk-decrypt.ts deleted file mode 100644 index e34c85dcf..000000000 --- a/packages/protect/src/ffi/operations/bulk-decrypt.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - type Encrypted as CipherStashEncrypted, - type DecryptResult, - decryptBulkFallible, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { Context, LockContext } from '../../identify' -import type { BulkDecryptedData, BulkDecryptPayload, Client } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -// Helper functions for better composability -const createDecryptPayloads = ( - encryptedPayloads: BulkDecryptPayload, - lockContext?: Context, -) => { - return encryptedPayloads - .map((item, index) => ({ ...item, originalIndex: index })) - .filter(({ data }) => data !== null) - .map(({ id, data, originalIndex }) => ({ - id, - ciphertext: data as CipherStashEncrypted, - originalIndex, - ...(lockContext && { lockContext }), - })) -} - -const createNullResult = ( - encryptedPayloads: BulkDecryptPayload, -): BulkDecryptedData => { - return encryptedPayloads.map(({ id }) => ({ - id, - data: null, - })) -} - -const mapDecryptedDataToResult = ( - encryptedPayloads: BulkDecryptPayload, - decryptedData: DecryptResult[], -): BulkDecryptedData => { - const result: BulkDecryptedData = new Array(encryptedPayloads.length) - let decryptedIndex = 0 - - for (let i = 0; i < encryptedPayloads.length; i++) { - if (encryptedPayloads[i].data === null) { - result[i] = { id: encryptedPayloads[i].id, data: null } - } else { - const decryptResult = decryptedData[decryptedIndex] - if ('error' in decryptResult) { - result[i] = { - id: encryptedPayloads[i].id, - error: decryptResult.error, - } - } else { - result[i] = { - id: encryptedPayloads[i].id, - data: decryptResult.data, - } - } - decryptedIndex++ - } - } - - return result -} - -export class BulkDecryptOperation extends ProtectOperation { - private client: Client - private encryptedPayloads: BulkDecryptPayload - - constructor(client: Client, encryptedPayloads: BulkDecryptPayload) { - super() - this.client = client - this.encryptedPayloads = encryptedPayloads - } - - public withLockContext( - lockContext: LockContext, - ): BulkDecryptOperationWithLockContext { - return new BulkDecryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Bulk decrypting data WITHOUT a lock context') - return await withResult( - async () => { - if (!this.client) throw noClientError() - if (!this.encryptedPayloads || this.encryptedPayloads.length === 0) - return [] - - const nonNullPayloads = createDecryptPayloads(this.encryptedPayloads) - - if (nonNullPayloads.length === 0) { - return createNullResult(this.encryptedPayloads) - } - - const { metadata } = this.getAuditData() - - const decryptedData = await decryptBulkFallible(this.client, { - ciphertexts: nonNullPayloads, - unverifiedContext: metadata, - }) - - return mapDecryptedDataToResult(this.encryptedPayloads, decryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - encryptedPayloads: BulkDecryptPayload - } { - return { - client: this.client, - encryptedPayloads: this.encryptedPayloads, - } - } -} - -export class BulkDecryptOperationWithLockContext extends ProtectOperation { - private operation: BulkDecryptOperation - private lockContext: LockContext - - constructor(operation: BulkDecryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, encryptedPayloads } = this.operation.getOperation() - logger.debug('Bulk decrypting data WITH a lock context') - - if (!client) throw noClientError() - if (!encryptedPayloads || encryptedPayloads.length === 0) return [] - - const context = await this.lockContext.getLockContext() - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const nonNullPayloads = createDecryptPayloads( - encryptedPayloads, - context.data.context, - ) - - if (nonNullPayloads.length === 0) { - return createNullResult(encryptedPayloads) - } - - const { metadata } = this.getAuditData() - - const decryptedData = await decryptBulkFallible(client, { - ciphertexts: nonNullPayloads, - serviceToken: context.data.ctsToken, - unverifiedContext: metadata, - }) - - return mapDecryptedDataToResult(encryptedPayloads, decryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts b/packages/protect/src/ffi/operations/bulk-encrypt-models.ts deleted file mode 100644 index 2bb69446b..000000000 --- a/packages/protect/src/ffi/operations/bulk-encrypt-models.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - bulkEncryptModels, - bulkEncryptModelsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class BulkEncryptModelsOperation< - T extends Record, -> extends ProtectOperation { - private client: Client - private models: Decrypted[] - private table: ProtectTable - - constructor( - client: Client, - models: Decrypted[], - table: ProtectTable, - ) { - super() - this.client = client - this.models = models - this.table = table - } - - public withLockContext( - lockContext: LockContext, - ): BulkEncryptModelsOperationWithLockContext { - return new BulkEncryptModelsOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Bulk encrypting models WITHOUT a lock context', { - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await bulkEncryptModels( - this.models, - this.table, - this.client, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - models: Decrypted[] - table: ProtectTable - } { - return { - client: this.client, - models: this.models, - table: this.table, - } - } -} - -export class BulkEncryptModelsOperationWithLockContext< - T extends Record, -> extends ProtectOperation { - private operation: BulkEncryptModelsOperation - private lockContext: LockContext - - constructor( - operation: BulkEncryptModelsOperation, - lockContext: LockContext, - ) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, models, table } = this.operation.getOperation() - - logger.debug('Bulk encrypting models WITH a lock context', { - table: table.tableName, - }) - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await bulkEncryptModelsWithLockContext( - models, - table, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/bulk-encrypt.ts b/packages/protect/src/ffi/operations/bulk-encrypt.ts deleted file mode 100644 index 8b9dd78db..000000000 --- a/packages/protect/src/ffi/operations/bulk-encrypt.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { encryptBulk, type JsPlaintext } from '@cipherstash/protect-ffi' -import type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { Context, LockContext } from '../../identify' -import type { - BulkEncryptedData, - BulkEncryptPayload, - Client, - Encrypted, - EncryptOptions, -} from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -// Helper functions for better composability -const createEncryptPayloads = ( - plaintexts: BulkEncryptPayload, - column: ProtectColumn | ProtectValue, - table: ProtectTable, - lockContext?: Context, -) => { - return plaintexts - .map((item, index) => ({ ...item, originalIndex: index })) - .filter(({ plaintext }) => plaintext !== null) - .map(({ id, plaintext, originalIndex }) => ({ - id, - plaintext: plaintext as JsPlaintext, - column: column.getName(), - table: table.tableName, - originalIndex, - ...(lockContext && { lockContext }), - })) -} - -const createNullResult = ( - plaintexts: BulkEncryptPayload, -): BulkEncryptedData => { - return plaintexts.map(({ id }) => ({ id, data: null })) -} - -const mapEncryptedDataToResult = ( - plaintexts: BulkEncryptPayload, - encryptedData: Encrypted[], -): BulkEncryptedData => { - const result: BulkEncryptedData = new Array(plaintexts.length) - let encryptedIndex = 0 - - for (let i = 0; i < plaintexts.length; i++) { - if (plaintexts[i].plaintext === null) { - result[i] = { id: plaintexts[i].id, data: null } - } else { - result[i] = { - id: plaintexts[i].id, - data: encryptedData[encryptedIndex], - } - encryptedIndex++ - } - } - - return result -} - -export class BulkEncryptOperation extends ProtectOperation { - private client: Client - private plaintexts: BulkEncryptPayload - private column: ProtectColumn | ProtectValue - private table: ProtectTable - - constructor( - client: Client, - plaintexts: BulkEncryptPayload, - opts: EncryptOptions, - ) { - super() - this.client = client - this.plaintexts = plaintexts - this.column = opts.column - this.table = opts.table - } - - public withLockContext( - lockContext: LockContext, - ): BulkEncryptOperationWithLockContext { - return new BulkEncryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Bulk encrypting data WITHOUT a lock context', { - column: this.column.getName(), - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - if (!this.plaintexts || this.plaintexts.length === 0) { - return [] - } - - const nonNullPayloads = createEncryptPayloads( - this.plaintexts, - this.column, - this.table, - ) - - if (nonNullPayloads.length === 0) { - return createNullResult(this.plaintexts) - } - - const { metadata } = this.getAuditData() - - const encryptedData = await encryptBulk(this.client, { - plaintexts: nonNullPayloads, - unverifiedContext: metadata, - }) - - return mapEncryptedDataToResult(this.plaintexts, encryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - plaintexts: BulkEncryptPayload - column: ProtectColumn | ProtectValue - table: ProtectTable - } { - return { - client: this.client, - plaintexts: this.plaintexts, - column: this.column, - table: this.table, - } - } -} - -export class BulkEncryptOperationWithLockContext extends ProtectOperation { - private operation: BulkEncryptOperation - private lockContext: LockContext - - constructor(operation: BulkEncryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, plaintexts, column, table } = - this.operation.getOperation() - - logger.debug('Bulk encrypting data WITH a lock context', { - column: column.getName(), - table: table.tableName, - }) - - if (!client) { - throw noClientError() - } - if (!plaintexts || plaintexts.length === 0) { - return [] - } - - const context = await this.lockContext.getLockContext() - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const nonNullPayloads = createEncryptPayloads( - plaintexts, - column, - table, - context.data.context, - ) - - if (nonNullPayloads.length === 0) { - return createNullResult(plaintexts) - } - - const { metadata } = this.getAuditData() - - const encryptedData = await encryptBulk(client, { - plaintexts: nonNullPayloads, - serviceToken: context.data.ctsToken, - unverifiedContext: metadata, - }) - - return mapEncryptedDataToResult(plaintexts, encryptedData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/decrypt-model.ts b/packages/protect/src/ffi/operations/decrypt-model.ts deleted file mode 100644 index 3bdf3c34b..000000000 --- a/packages/protect/src/ffi/operations/decrypt-model.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - decryptModelFields, - decryptModelFieldsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class DecryptModelOperation< - T extends Record, -> extends ProtectOperation> { - private client: Client - private model: T - - constructor(client: Client, model: T) { - super() - this.client = client - this.model = model - } - - public withLockContext( - lockContext: LockContext, - ): DecryptModelOperationWithLockContext { - return new DecryptModelOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise, ProtectError>> { - logger.debug('Decrypting model WITHOUT a lock context') - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await decryptModelFields(this.model, this.client, auditData) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - model: T - } { - return { - client: this.client, - model: this.model, - } - } -} - -export class DecryptModelOperationWithLockContext< - T extends Record, -> extends ProtectOperation> { - private operation: DecryptModelOperation - private lockContext: LockContext - - constructor(operation: DecryptModelOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise, ProtectError>> { - return await withResult( - async () => { - const { client, model } = this.operation.getOperation() - - logger.debug('Decrypting model WITH a lock context') - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await decryptModelFieldsWithLockContext( - model, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/decrypt.ts b/packages/protect/src/ffi/operations/decrypt.ts deleted file mode 100644 index 98591f8ed..000000000 --- a/packages/protect/src/ffi/operations/decrypt.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - decrypt as ffiDecrypt, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Encrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -/** - * Decrypts an encrypted payload using the provided client. - * This is the type returned by the {@link ProtectClient.decrypt | decrypt} method of the {@link ProtectClient}. - */ -export class DecryptOperation extends ProtectOperation { - private client: Client - private encryptedData: Encrypted - - constructor(client: Client, encryptedData: Encrypted) { - super() - this.client = client - this.encryptedData = encryptedData - } - - public withLockContext( - lockContext: LockContext, - ): DecryptOperationWithLockContext { - return new DecryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - if (this.encryptedData === null) { - return null - } - - const { metadata } = this.getAuditData() - - logger.debug('Decrypting data WITHOUT a lock context', { - metadata, - }) - - return await ffiDecrypt(this.client, { - ciphertext: this.encryptedData, - unverifiedContext: metadata, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - encryptedData: Encrypted - auditData?: Record - } { - return { - client: this.client, - encryptedData: this.encryptedData, - auditData: this.getAuditData(), - } - } -} - -export class DecryptOperationWithLockContext extends ProtectOperation { - private operation: DecryptOperation - private lockContext: LockContext - - constructor(operation: DecryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - const auditData = operation.getAuditData() - if (auditData) { - this.audit(auditData) - } - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, encryptedData } = this.operation.getOperation() - - if (!client) { - throw noClientError() - } - - if (encryptedData === null) { - return null - } - - const { metadata } = this.getAuditData() - - logger.debug('Decrypting data WITH a lock context', { - metadata, - }) - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - return await ffiDecrypt(client, { - ciphertext: encryptedData, - unverifiedContext: metadata, - lockContext: context.data.context, - serviceToken: context.data.ctsToken, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.DecryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/deprecated/search-terms.ts b/packages/protect/src/ffi/operations/deprecated/search-terms.ts deleted file mode 100644 index f0b2c2603..000000000 --- a/packages/protect/src/ffi/operations/deprecated/search-terms.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { encryptQueryBulk, type QueryPayload } from '@cipherstash/protect-ffi' -import { logger } from '../../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../../..' -import type { LockContext } from '../../../identify' -import type { Client, EncryptedSearchTerm, SearchTerm } from '../../../types' -import { noClientError } from '../..' -import { getErrorCode } from '../../helpers/error-code' -import { inferIndexType } from '../../helpers/infer-index-type' -import { ProtectOperation } from '../base-operation' - -/** - * @deprecated Use `BatchEncryptQueryOperation` instead. - * This class is maintained for backward compatibility only. - */ -export class SearchTermsOperation extends ProtectOperation< - EncryptedSearchTerm[] -> { - constructor( - private client: Client, - private terms: SearchTerm[], - ) { - super() - } - - public withLockContext( - lockContext: LockContext, - ): SearchTermsOperationWithLockContext { - return new SearchTermsOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Creating search terms (deprecated API)', { - count: this.terms.length, - }) - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = this.terms.map((term) => ({ - plaintext: term.value, - column: term.column.getName(), - table: term.table.tableName, - indexType: inferIndexType(term.column), - })) - - const encryptedTerms = await encryptQueryBulk(this.client, { - queries, - unverifiedContext: metadata, - }) - - return this.terms.map((term, index) => { - if (term.returnType === 'composite-literal') { - return `(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})` - } - if (term.returnType === 'escaped-composite-literal') { - return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})`)}` - } - return encryptedTerms[index] - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} - -export class SearchTermsOperationWithLockContext extends ProtectOperation< - EncryptedSearchTerm[] -> { - constructor( - private operation: SearchTermsOperation, - private lockContext: LockContext, - ) { - super() - this.auditMetadata = (operation as any).auditMetadata - } - - public async execute(): Promise> { - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - return { failure: lockContextResult.failure } - } - - const { ctsToken, context } = lockContextResult.data - const op = this.operation as any - - return await withResult( - async () => { - if (!op.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const queries: QueryPayload[] = op.terms.map((term: SearchTerm) => ({ - plaintext: term.value, - column: term.column.getName(), - table: term.table.tableName, - indexType: inferIndexType(term.column), - lockContext: context, - })) - - const encryptedTerms = await encryptQueryBulk(op.client, { - queries, - serviceToken: ctsToken, - unverifiedContext: metadata, - }) - - return op.terms.map((term: SearchTerm, index: number) => { - if (term.returnType === 'composite-literal') { - return `(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})` - } - if (term.returnType === 'escaped-composite-literal') { - return `${JSON.stringify(`(${JSON.stringify(JSON.stringify(encryptedTerms[index]))})`)}` - } - return encryptedTerms[index] - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/encrypt-model.ts b/packages/protect/src/ffi/operations/encrypt-model.ts deleted file mode 100644 index 54f1bb59e..000000000 --- a/packages/protect/src/ffi/operations/encrypt-model.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Decrypted } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { - encryptModelFields, - encryptModelFieldsWithLockContext, -} from '../model-helpers' -import { ProtectOperation } from './base-operation' - -export class EncryptModelOperation< - T extends Record, -> extends ProtectOperation { - private client: Client - private model: Decrypted - private table: ProtectTable - - constructor( - client: Client, - model: Decrypted, - table: ProtectTable, - ) { - super() - this.client = client - this.model = model - this.table = table - } - - public withLockContext( - lockContext: LockContext, - ): EncryptModelOperationWithLockContext { - return new EncryptModelOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Encrypting model WITHOUT a lock context', { - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - const auditData = this.getAuditData() - - return await encryptModelFields( - this.model, - this.table, - this.client, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - model: Decrypted - table: ProtectTable - } { - return { - client: this.client, - model: this.model, - table: this.table, - } - } -} - -export class EncryptModelOperationWithLockContext< - T extends Record, -> extends ProtectOperation { - private operation: EncryptModelOperation - private lockContext: LockContext - - constructor(operation: EncryptModelOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, model, table } = this.operation.getOperation() - - logger.debug('Encrypting model WITH a lock context', { - table: table.tableName, - }) - - if (!client) { - throw noClientError() - } - - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - const auditData = this.getAuditData() - - return await encryptModelFieldsWithLockContext( - model, - table, - client, - context.data, - auditData, - ) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts deleted file mode 100644 index bb633ccea..000000000 --- a/packages/protect/src/ffi/operations/encrypt-query.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - encryptQuery as ffiEncryptQuery, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import { formatEncryptedResult } from '../../helpers' -import type { LockContext } from '../../identify' -import type { - Client, - EncryptedQueryResult, - EncryptQueryOptions, -} from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { resolveIndexType } from '../helpers/infer-index-type' -import { - assertValueIndexCompatibility, - validateNumericValue, -} from '../helpers/validation' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -/** - * @internal Use {@link ProtectClient.encryptQuery} instead. - */ -export class EncryptQueryOperation extends ProtectOperation { - constructor( - private client: Client, - private plaintext: JsPlaintext | null, - private opts: EncryptQueryOptions, - ) { - super() - } - - public withLockContext( - lockContext: LockContext, - ): EncryptQueryOperationWithLockContext { - return new EncryptQueryOperationWithLockContext( - this.client, - this.plaintext, - this.opts, - lockContext, - this.auditMetadata, - ) - } - - public async execute(): Promise> { - logger.debug('Encrypting query', { - column: this.opts.column.getName(), - table: this.opts.table.tableName, - queryType: this.opts.queryType, - }) - - if (this.plaintext === null || this.plaintext === undefined) { - return { data: null } - } - - const validationError = validateNumericValue(this.plaintext) - if (validationError?.failure) { - return { failure: validationError.failure } - } - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const { indexType, queryOp } = resolveIndexType( - this.opts.column, - this.opts.queryType, - this.plaintext, - ) - - // Validate value/index compatibility - assertValueIndexCompatibility( - this.plaintext, - indexType, - this.opts.column.getName(), - ) - - const encrypted = await ffiEncryptQuery(this.client, { - plaintext: this.plaintext as JsPlaintext, - column: this.opts.column.getName(), - table: this.opts.table.tableName, - indexType, - queryOp, - unverifiedContext: metadata, - }) - - return formatEncryptedResult(encrypted, this.opts.returnType) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation() { - return { client: this.client, plaintext: this.plaintext, ...this.opts } - } -} - -/** - * @internal Use {@link ProtectClient.encryptQuery} with `.withLockContext()` instead. - */ -export class EncryptQueryOperationWithLockContext extends ProtectOperation { - constructor( - private client: Client, - private plaintext: JsPlaintext | null, - private opts: EncryptQueryOptions, - private lockContext: LockContext, - auditMetadata?: Record, - ) { - super() - this.auditMetadata = auditMetadata - } - - public async execute(): Promise> { - if (this.plaintext === null || this.plaintext === undefined) { - return { data: null } - } - - const validationError = validateNumericValue(this.plaintext) - if (validationError?.failure) { - return { failure: validationError.failure } - } - - const lockContextResult = await this.lockContext.getLockContext() - if (lockContextResult.failure) { - return { failure: lockContextResult.failure } - } - - const { ctsToken, context } = lockContextResult.data - - return await withResult( - async () => { - if (!this.client) throw noClientError() - - const { metadata } = this.getAuditData() - - const { indexType, queryOp } = resolveIndexType( - this.opts.column, - this.opts.queryType, - this.plaintext, - ) - - // Validate value/index compatibility - assertValueIndexCompatibility( - this.plaintext, - indexType, - this.opts.column.getName(), - ) - - const encrypted = await ffiEncryptQuery(this.client, { - plaintext: this.plaintext as JsPlaintext, - column: this.opts.column.getName(), - table: this.opts.table.tableName, - indexType, - queryOp, - lockContext: context, - serviceToken: ctsToken, - unverifiedContext: metadata, - }) - - return formatEncryptedResult(encrypted, this.opts.returnType) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/ffi/operations/encrypt.ts b/packages/protect/src/ffi/operations/encrypt.ts deleted file mode 100644 index d91afa757..000000000 --- a/packages/protect/src/ffi/operations/encrypt.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { - encrypt as ffiEncrypt, - type JsPlaintext, -} from '@cipherstash/protect-ffi' -import type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -import { logger } from '../../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '../..' -import type { LockContext } from '../../identify' -import type { Client, Encrypted, EncryptOptions } from '../../types' -import { getErrorCode } from '../helpers/error-code' -import { noClientError } from '../index' -import { ProtectOperation } from './base-operation' - -export class EncryptOperation extends ProtectOperation { - private client: Client - private plaintext: JsPlaintext | null - private column: ProtectColumn | ProtectValue - private table: ProtectTable - - constructor( - client: Client, - plaintext: JsPlaintext | null, - opts: EncryptOptions, - ) { - super() - this.client = client - this.plaintext = plaintext - this.column = opts.column - this.table = opts.table - } - - public withLockContext( - lockContext: LockContext, - ): EncryptOperationWithLockContext { - return new EncryptOperationWithLockContext(this, lockContext) - } - - public async execute(): Promise> { - logger.debug('Encrypting data WITHOUT a lock context', { - column: this.column.getName(), - table: this.table.tableName, - }) - - return await withResult( - async () => { - if (!this.client) { - throw noClientError() - } - - if (this.plaintext === null) { - return null - } - - if ( - typeof this.plaintext === 'number' && - Number.isNaN(this.plaintext) - ) { - throw new Error('[protect]: Cannot encrypt NaN value') - } - - if ( - typeof this.plaintext === 'number' && - !Number.isFinite(this.plaintext) - ) { - throw new Error('[protect]: Cannot encrypt Infinity value') - } - - const { metadata } = this.getAuditData() - - return await ffiEncrypt(this.client, { - plaintext: this.plaintext, - column: this.column.getName(), - table: this.table.tableName, - unverifiedContext: metadata, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } - - public getOperation(): { - client: Client - plaintext: JsPlaintext | null - column: ProtectColumn | ProtectValue - table: ProtectTable - } { - return { - client: this.client, - plaintext: this.plaintext, - column: this.column, - table: this.table, - } - } -} - -export class EncryptOperationWithLockContext extends ProtectOperation { - private operation: EncryptOperation - private lockContext: LockContext - - constructor(operation: EncryptOperation, lockContext: LockContext) { - super() - this.operation = operation - this.lockContext = lockContext - } - - public async execute(): Promise> { - return await withResult( - async () => { - const { client, plaintext, column, table } = - this.operation.getOperation() - - logger.debug('Encrypting data WITH a lock context', { - column: column, - table: table, - }) - - if (!client) { - throw noClientError() - } - - if (plaintext === null) { - return null - } - - const { metadata } = this.getAuditData() - const context = await this.lockContext.getLockContext() - - if (context.failure) { - throw new Error(`[protect]: ${context.failure.message}`) - } - - return await ffiEncrypt(client, { - plaintext, - column: column.getName(), - table: table.tableName, - lockContext: context.data.context, - serviceToken: context.data.ctsToken, - unverifiedContext: metadata, - }) - }, - (error: unknown) => ({ - type: ProtectErrorTypes.EncryptionError, - message: (error as Error).message, - code: getErrorCode(error), - }), - ) - } -} diff --git a/packages/protect/src/helpers/index.ts b/packages/protect/src/helpers/index.ts deleted file mode 100644 index aae2909b5..000000000 --- a/packages/protect/src/helpers/index.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { - Encrypted as CipherStashEncrypted, - EncryptedQuery as CipherStashEncryptedQuery, - EncryptedScalarQuery, - KeysetIdentifier as KeysetIdentifierFfi, -} from '@cipherstash/protect-ffi' -import type { - Encrypted, - EncryptedQueryResult, - KeysetIdentifier, -} from '../types' - -/** - * The shape `encryptQuery` / `encryptQueryBulk` can return: a full storage - * payload (`Encrypted`, returned for `ste_vec_term` containment queries) or a - * query-only payload with no ciphertext (`EncryptedQuery`, returned for - * scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` path queries). - * - * TODO: duplicated in `@cipherstash/stack` — see - * `packages/stack/src/encryption/helpers/index.ts`. Both copies should be - * removed once `@cipherstash/protect-ffi` exports a named alias for the - * `encryptQuery` return type (https://github.com/cipherstash/stack/pull/473). - */ -type EncryptedQueryTerm = CipherStashEncrypted | CipherStashEncryptedQuery - -export type EncryptedPgComposite = { - data: Encrypted -} - -/** - * Helper function to transform an encrypted payload into a PostgreSQL composite type. - * Use this when inserting data via Supabase or similar clients. - */ -export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite { - return { - data: obj, - } -} - -/** - * Helper function to transform an encrypted payload into a PostgreSQL composite literal string. - * Use this when querying with `.eq()` or similar equality operations in Supabase. - * - * @deprecated Use `encryptQuery()` with `returnType: 'composite-literal'` instead. - * @example - * ```typescript - * // Before (deprecated): - * const [encrypted] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality' } - * ]) - * const literal = encryptedToCompositeLiteral(encrypted) - * await supabase.from('table').select().eq('column', literal) - * - * // After (recommended): - * const [searchTerm] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality', returnType: 'composite-literal' } - * ]) - * await supabase.from('table').select().eq('column', searchTerm) - * ``` - */ -export function encryptedToCompositeLiteral(obj: EncryptedQueryTerm): string { - if (obj === null) { - throw new Error('encryptedToCompositeLiteral: obj cannot be null') - } - return `(${JSON.stringify(JSON.stringify(obj))})` -} - -/** - * Helper function to transform an encrypted payload into an escaped PostgreSQL composite literal string. - * Use this when you need the composite literal format to be escaped as a string value. - * - * @deprecated Use `encryptQuery()` with `returnType: 'escaped-composite-literal'` instead. - * See also: `encryptedToCompositeLiteral` for parallel deprecation guidance. - * @example - * ```typescript - * // Before (deprecated): - * const [encrypted] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality' } - * ]) - * const escapedLiteral = encryptedToEscapedCompositeLiteral(encrypted) - * - * // After (recommended): - * const [searchTerm] = await protectClient.encryptQuery([ - * { value: searchValue, column, table, queryType: 'equality', returnType: 'escaped-composite-literal' } - * ]) - * ``` - */ -export function encryptedToEscapedCompositeLiteral( - obj: EncryptedQueryTerm, -): string { - if (obj === null) { - throw new Error('encryptedToEscapedCompositeLiteral: obj cannot be null') - } - return JSON.stringify(encryptedToCompositeLiteral(obj)) -} - -export function formatEncryptedResult( - encrypted: EncryptedQueryTerm, - returnType?: string, -): EncryptedQueryResult { - if (returnType === 'composite-literal') { - return encryptedToCompositeLiteral(encrypted) - } - if (returnType === 'escaped-composite-literal') { - return encryptedToEscapedCompositeLiteral(encrypted) - } - return encrypted -} - -/** - * Helper function to transform a model's encrypted fields into PostgreSQL composite types - */ -export function modelToEncryptedPgComposites>( - model: T, -): T { - const result: Record = {} - - for (const [key, value] of Object.entries(model)) { - if (isEncryptedPayload(value)) { - result[key] = encryptedToPgComposite(value) - } else { - result[key] = value - } - } - - return result as T -} - -/** - * Helper function to transform multiple models' encrypted fields into PostgreSQL composite types - */ -export function bulkModelsToEncryptedPgComposites< - T extends Record, ->(models: T[]): T[] { - return models.map((model) => modelToEncryptedPgComposites(model)) -} - -export function toFfiKeysetIdentifier( - keyset: KeysetIdentifier | undefined, -): KeysetIdentifierFfi | undefined { - if (!keyset) return undefined - - if ('name' in keyset) { - return { Name: keyset.name } - } - - return { Uuid: keyset.id } -} - -/** - * Helper function to check if a value is an encrypted payload - */ -export function isEncryptedPayload(value: unknown): value is Encrypted { - if (value === null) return false - - // TODO: this can definitely be improved - if (typeof value === 'object') { - const obj = value as Encrypted - return ( - obj !== null && 'v' in obj && ('c' in obj || 'sv' in obj) && 'i' in obj - ) - } - - return false -} - -/** - * Type guard narrowing a value to {@link EncryptedScalarQuery} — the scalar - * query term (`unique` / `match` / `ore` lookup) returned by `encryptQuery` / - * `encryptQueryBulk`. Unlike a storage payload it carries no ciphertext (`c`); - * it carries exactly one lookup term: `hm`, `bf`, or `ob`. - * - * Use this to discriminate a scalar query term from a storage payload - * (`EncryptedScalar`/`EncryptedSteVec`) or a `ste_vec_selector` query. - */ -export function isEncryptedScalarQuery( - value: unknown, -): value is EncryptedScalarQuery { - if (value === null || typeof value !== 'object') return false - - const obj = value as Record - - // `k: 'ct'` is the scalar discriminant; a query term never carries the - // ciphertext (`c`) that every storage payload has. - if (obj.k !== 'ct' || 'c' in obj) return false - if ( - typeof obj.v !== 'number' || - typeof obj.i !== 'object' || - obj.i === null - ) { - return false - } - - // Exactly one lookup term: `hm` (unique), `bf` (match), or `ob` (ore). - const lookupTerms = [ - typeof obj.hm === 'string', - Array.isArray(obj.bf), - Array.isArray(obj.ob), - ].filter(Boolean) - return lookupTerms.length === 1 -} - -export { - buildNestedObject, - parseJsonbPath, - toJsonPath, -} from './jsonb' diff --git a/packages/protect/src/helpers/jsonb.ts b/packages/protect/src/helpers/jsonb.ts deleted file mode 100644 index 018ea99c4..000000000 --- a/packages/protect/src/helpers/jsonb.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * JSONB path utilities for converting between path formats. - * - * These utilities support dot-notation and basic JSONPath-style array indices (e.g., "[0]"). - * Only limited validation is performed (forbidden prototype keys); callers should still - * ensure segments are valid property names. - */ - -/** - * Convert a dot-notation path to JSONPath selector format. - * - * @example - * toJsonPath("user.email") // "$.user.email" - * toJsonPath("$.user.email") // "$.user.email" (unchanged) - * toJsonPath(".user.email") // "$.user.email" - * toJsonPath("name") // "$.name" - */ -export function toJsonPath(path: string): string { - if (!path || path === '$') return '$' - if (path.startsWith('$[')) return path - if (path.startsWith('$.')) return path - if (path.startsWith('$')) return `$.${path.slice(1)}` - if (path.startsWith('.')) return `$${path}` - if (path.startsWith('[')) return `$${path}` - return `$.${path}` -} - -/** - * Parse a JSONB path string into segments. - * Handles both dot notation and JSONPath format. - * - * Returns an empty array for empty, null, or undefined input (defensive for JS consumers). - * - * @example - * parseJsonbPath("user.email") // ["user", "email"] - * parseJsonbPath("$.user.email") // ["user", "email"] - * parseJsonbPath("name") // ["name"] - * parseJsonbPath("$.name") // ["name"] - */ -export function parseJsonbPath(path: string): string[] { - if (!path || typeof path !== 'string') return [] - - // Remove leading $. or $ prefix - const normalized = path.replace(/^\$\.?/, '') - - if (!normalized) return [] - - return normalized.split('.').filter(Boolean) -} - -/** - * Build a nested object from a dot-notation path and value. - * - * @example - * buildNestedObject("user.role", "admin") - * // Returns: { user: { role: "admin" } } - * - * buildNestedObject("name", "alice") - * // Returns: { name: "alice" } - * - * buildNestedObject("a.b.c", 123) - * // Returns: { a: { b: { c: 123 } } } - */ -const FORBIDDEN_KEYS = ['__proto__', 'prototype', 'constructor'] - -function validateSegment(segment: string): void { - if (FORBIDDEN_KEYS.includes(segment)) { - throw new Error(`Path contains forbidden segment: ${segment}`) - } -} - -export function buildNestedObject( - path: string, - value: unknown, -): Record { - if (!path) { - throw new Error('Path cannot be empty') - } - - const segments = parseJsonbPath(path) - if (segments.length === 0) { - throw new Error('Path must contain at least one segment') - } - - const result: Record = Object.create(null) - let current = result - - for (let i = 0; i < segments.length - 1; i++) { - const key = segments[i] - validateSegment(key) - current[key] = Object.create(null) - current = current[key] as Record - } - - const leafKey = segments[segments.length - 1] - validateSegment(leafKey) - current[leafKey] = value - return result -} diff --git a/packages/protect/src/identify/index.ts b/packages/protect/src/identify/index.ts deleted file mode 100644 index a1ff22be4..000000000 --- a/packages/protect/src/identify/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { type Result, withResult } from '@byteslice/result' -import { loadWorkSpaceId } from '../../../utils/config' -import { logger } from '../../../utils/logger' -import { type ProtectError, ProtectErrorTypes } from '..' - -export type CtsRegions = 'ap-southeast-2' - -export type IdentifyOptions = { - fetchFromCts?: boolean -} - -export type CtsToken = { - accessToken: string - expiry: number -} - -export type Context = { - identityClaim: string[] -} - -export type LockContextOptions = { - context?: Context - ctsToken?: CtsToken -} - -export type GetLockContextResponse = { - ctsToken: CtsToken - context: Context -} - -export class LockContext { - private ctsToken: CtsToken | undefined - private workspaceId: string - private context: Context - - constructor({ - context = { identityClaim: ['sub'] }, - ctsToken, - }: LockContextOptions = {}) { - const workspaceId = loadWorkSpaceId() - - if (!workspaceId) { - throw new Error( - 'You have not defined a workspace ID in your config file, or the CS_WORKSPACE_ID environment variable.', - ) - } - - if (ctsToken) { - this.ctsToken = ctsToken - } - - this.workspaceId = workspaceId - this.context = context - logger.debug('Successfully initialized the EQL lock context.') - } - - async identify(jwtToken: string): Promise> { - const workspaceId = this.workspaceId - - const ctsEndpoint = - process.env.CS_CTS_ENDPOINT || - 'https://ap-southeast-2.aws.auth.viturhosted.net' - - const ctsFetchResult = await withResult( - () => - fetch(`${ctsEndpoint}/api/authorize`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId, - oidcToken: jwtToken, - }), - }), - (error) => ({ - type: ProtectErrorTypes.CtsTokenError, - message: error.message, - }), - ) - - if (ctsFetchResult.failure) { - return ctsFetchResult - } - - const identifiedLockContext = await withResult( - async () => { - const ctsToken = (await ctsFetchResult.data.json()) as CtsToken - - if (!ctsToken.accessToken) { - throw new Error( - 'The response from the CipherStash API did not contain an access token. Please contact support.', - ) - } - - this.ctsToken = ctsToken - return this - }, - (error) => ({ - type: ProtectErrorTypes.CtsTokenError, - message: error.message, - }), - ) - - return identifiedLockContext - } - - getLockContext(): Promise> { - return withResult( - () => { - if (!this.ctsToken?.accessToken && !this.ctsToken?.expiry) { - throw new Error( - 'The CTS token is not set. Please call identify() with a users JWT token, or pass an existing CTS token to the LockContext constructor before calling getLockContext().', - ) - } - - return { - context: this.context, - ctsToken: this.ctsToken, - } - }, - (error) => ({ - type: ProtectErrorTypes.CtsTokenError, - message: error.message, - }), - ) - } -} diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts deleted file mode 100644 index c9505f07d..000000000 --- a/packages/protect/src/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { ProtectTable, ProtectTableColumn } from '@cipherstash/schema' -import { buildEncryptConfig } from '@cipherstash/schema' -import { ProtectClient } from './ffi' -import type { KeysetIdentifier } from './types' - -// Re-export FFI error types for programmatic error handling -export { - ProtectError as FfiProtectError, - type ProtectErrorCode, -} from '@cipherstash/protect-ffi' - -export const ProtectErrorTypes = { - ClientInitError: 'ClientInitError', - EncryptionError: 'EncryptionError', - DecryptionError: 'DecryptionError', - LockContextError: 'LockContextError', - CtsTokenError: 'CtsTokenError', -} - -export interface ProtectError { - type: (typeof ProtectErrorTypes)[keyof typeof ProtectErrorTypes] - message: string - code?: import('@cipherstash/protect-ffi').ProtectErrorCode -} - -type AtLeastOneCsTable = [T, ...T[]] - -export type ProtectClientConfig = { - schemas: AtLeastOneCsTable> - - /** - * The CipherStash workspace CRN (Cloud Resource Name). - * Format: `crn:.aws:`. - * Can also be set via the `CS_WORKSPACE_CRN` environment variable. - */ - workspaceCrn?: string - - /** - * The API access key used for authenticating with the CipherStash API. - * Can also be set via the `CS_CLIENT_ACCESS_KEY` environment variable. - * Obtain this from the CipherStash dashboard after creating a workspace. - */ - accessKey?: string - - /** - * The client identifier used to authenticate with CipherStash services. - * Can also be set via the `CS_CLIENT_ID` environment variable. - * Generated during workspace onboarding in the CipherStash dashboard. - */ - clientId?: string - - /** - * The client key material used in combination with ZeroKMS for encryption operations. - * Can also be set via the `CS_CLIENT_KEY` environment variable. - * Generated during workspace onboarding in the CipherStash dashboard. - */ - clientKey?: string - - /** - * An optional keyset identifier for multi-tenant encryption. - * Each keyset provides cryptographic isolation, giving each tenant its own keyspace. - * Specify by name (`{ name: "tenant-a" }`) or UUID (`{ id: "..." }`). - * Keysets are created and managed in the CipherStash dashboard. - */ - keyset?: KeysetIdentifier -} - -function isValidUuid(uuid: string): boolean { - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - return uuidRegex.test(uuid) -} - -/* Initialize a Protect client with the provided configuration. - - @param config - The configuration object for initializing the Protect client. - - @see {@link ProtectClientConfig} for details on the configuration options. - - @returns A Promise that resolves to an instance of ProtectClient. - - @throws Will throw an error if no schemas are provided or if the keyset ID is not a valid UUID. -*/ -export const protect = async ( - config: ProtectClientConfig, -): Promise => { - const { schemas } = config - - if (!schemas.length) { - throw new Error( - '[protect]: At least one csTable must be provided to initialize the protect client', - ) - } - - if ( - config.keyset && - 'id' in config.keyset && - !isValidUuid(config.keyset.id) - ) { - throw new Error( - '[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.', - ) - } - - const clientConfig = { - workspaceCrn: config.workspaceCrn, - accessKey: config.accessKey, - clientId: config.clientId, - clientKey: config.clientKey, - keyset: config.keyset, - } - - const client = new ProtectClient() - const encryptConfig = buildEncryptConfig(...schemas) - - const result = await client.init({ - encryptConfig, - ...clientConfig, - }) - - if (result.failure) { - throw new Error(`[protect]: ${result.failure.message}`) - } - - return result.data -} - -export type { Result } from '@byteslice/result' -export type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' -export { csColumn, csTable, csValue } from '@cipherstash/schema' -export type { ProtectClient } from './ffi' -// Helpers -export { - inferIndexType, - validateIndexType, -} from './ffi/helpers/infer-index-type' -export type { ProtectOperation } from './ffi/operations/base-operation' -export { - BatchEncryptQueryOperation, - BatchEncryptQueryOperationWithLockContext, -} from './ffi/operations/batch-encrypt-query' -export type { BulkDecryptOperation } from './ffi/operations/bulk-decrypt' -export type { BulkDecryptModelsOperation } from './ffi/operations/bulk-decrypt-models' -export type { BulkEncryptOperation } from './ffi/operations/bulk-encrypt' -export type { BulkEncryptModelsOperation } from './ffi/operations/bulk-encrypt-models' -export type { DecryptOperation } from './ffi/operations/decrypt' -export type { DecryptModelOperation } from './ffi/operations/decrypt-model' -export type { EncryptOperation } from './ffi/operations/encrypt' -export type { EncryptModelOperation } from './ffi/operations/encrypt-model' -// Operations -export { - EncryptQueryOperation, - EncryptQueryOperationWithLockContext, -} from './ffi/operations/encrypt-query' -export * from './helpers' -// LockContext related type exports -export type { - Context, - CtsRegions, - CtsToken, - GetLockContextResponse, - IdentifyOptions, - LockContextOptions, -} from './identify' -// LockContext class export (value export for instantiation) -export { LockContext } from './identify' -// Types -export type { - EncryptQueryOptions, - FfiIndexTypeName, - QueryTypeName, - ScalarQueryTerm, -} from './types' -export * from './types' -export { queryTypes, queryTypeToFfi } from './types' diff --git a/packages/protect/src/stash/index.ts b/packages/protect/src/stash/index.ts deleted file mode 100644 index 73338dc83..000000000 --- a/packages/protect/src/stash/index.ts +++ /dev/null @@ -1,459 +0,0 @@ -import type { Result } from '@byteslice/result' -import { csColumn, csTable } from '@cipherstash/schema' -import { encryptedToPgComposite, type ProtectClient, protect } from '../index' -import type { Encrypted } from '../types' - -export type SecretName = string -export type SecretValue = string - -/** - * Configuration options for initializing the Stash client - */ -export interface StashConfig { - workspaceCRN: string - clientId: string - clientKey: string - environment: string - apiKey: string - accessKey?: string -} - -/** - * Secret metadata returned from the API - */ -export interface SecretMetadata { - id?: string - name: string - environment: string - createdAt?: string - updatedAt?: string -} - -/** - * API response for listing secrets - */ -export interface ListSecretsResponse { - environment: string - secrets: SecretMetadata[] -} - -/** - * API response for getting a secret - */ -export interface GetSecretResponse { - name: string - environment: string - encryptedValue: { - data: Encrypted - } - createdAt?: string - updatedAt?: string -} - -export interface DecryptedSecretResponse { - name: string - environment: string - value: string - createdAt?: string - updatedAt?: string -} - -/** - * The Stash client provides a high-level API for managing encrypted secrets - * stored in CipherStash. Secrets are encrypted locally before being sent to - * the API, ensuring end-to-end encryption. - */ -export class Stash { - private protectClient: ProtectClient | null = null - private config: StashConfig - private readonly apiBaseUrl = - process.env.STASH_API_URL || 'https://getstash.sh/api/secrets' - private readonly secretsSchema = csTable('secrets', { - value: csColumn('value'), - }) - - /** - * Extracts the workspace ID from a CRN string. - * CRN format: crn:region.aws:ID - * - * @param crn The CRN string to extract from - * @returns The workspace ID portion of the CRN - */ - private extractWorkspaceIdFromCrn(crn: string): string { - const match = crn.match(/crn:[^:]+:([^:]+)$/) - if (!match) { - throw new Error('Invalid CRN format') - } - return match[1] - } - - constructor(config: StashConfig) { - this.config = config - } - - /** - * Initialize the Stash client and underlying Protect client - */ - private async ensureInitialized(): Promise { - if (this.protectClient) { - return - } - - this.protectClient = await protect({ - schemas: [this.secretsSchema], - workspaceCrn: this.config.workspaceCRN, - clientId: this.config.clientId, - clientKey: this.config.clientKey, - accessKey: this.config.apiKey, - keyset: { - name: this.config.environment, - }, - }) - } - - /** - * Get the authorization header for API requests - */ - private getAuthHeader(): string { - return `Bearer ${this.config.apiKey}` - } - - /** - * Make an API request with error handling - */ - private async apiRequest( - method: string, - path: string, - body?: unknown, - ): Promise> { - try { - const url = `${this.apiBaseUrl}${path}` - const headers: Record = { - 'Content-Type': 'application/json', - Authorization: this.getAuthHeader(), - } - - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `API request failed with status ${response.status}` - try { - const errorJson = JSON.parse(errorText) - errorMessage = errorJson.message || errorMessage - } catch { - errorMessage = errorText || errorMessage - } - - return { - failure: { - type: 'ApiError', - message: errorMessage, - }, - } - } - - const data = await response.json() - return { data } - } catch (error) { - return { - failure: { - type: 'NetworkError', - message: - error instanceof Error - ? error.message - : 'Unknown network error occurred', - }, - } - } - } - - /** - * Store an encrypted secret in the vault. - * The value is encrypted locally before being sent to the API. - * - * @param name - The name of the secret - * @param value - The plaintext value to encrypt and store - * @returns A Result indicating success or failure - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.set('DATABASE_URL', 'postgres://user:pass@localhost:5432/mydb') - * if (result.failure) { - * console.error('Failed to set secret:', result.failure.message) - * } - * ``` - */ - async set( - name: SecretName, - value: SecretValue, - ): Promise> { - await this.ensureInitialized() - - if (!this.protectClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Protect client', - }, - } - } - - // Encrypt the value locally - const encryptResult = await this.protectClient.encrypt(value, { - column: this.secretsSchema.value, - table: this.secretsSchema, - }) - - if (encryptResult.failure) { - return { - failure: { - type: 'EncryptionError', - message: encryptResult.failure.message, - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Send encrypted value to API - return await this.apiRequest('POST', '/set', { - workspaceId, - environment: this.config.environment, - name, - encryptedValue: encryptedToPgComposite(encryptResult.data), - }) - } - - /** - * Retrieve and decrypt a secret from the vault. - * The secret is decrypted locally after retrieval. - * - * @param name - The name of the secret to retrieve - * @returns A Result containing the decrypted value or an error - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.get('DATABASE_URL') - * if (result.failure) { - * console.error('Failed to get secret:', result.failure.message) - * } else { - * console.log('Secret value:', result.data) - * } - * ``` - */ - async get( - name: SecretName, - ): Promise> { - await this.ensureInitialized() - - if (!this.protectClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Protect client', - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Fetch encrypted value from API - const apiResult = await this.apiRequest('POST', '/get', { - workspaceId, - environment: this.config.environment, - name, - }) - - if (apiResult.failure) { - return apiResult - } - - // Decrypt the value locally - const decryptResult = await this.protectClient.decrypt( - apiResult.data.encryptedValue.data, - ) - - if (decryptResult.failure) { - return { - failure: { - type: 'DecryptionError', - message: decryptResult.failure.message, - }, - } - } - - if (typeof decryptResult.data !== 'string') { - return { - failure: { - type: 'DecryptionError', - message: 'Decrypted value is not a string', - }, - } - } - - return { data: decryptResult.data } - } - - /** - * Retrieve and decrypt many secrets from the vault. - * The secrets are decrypted locally after retrieval. - * This method only triggers a single network request to the ZeroKMS. - * - * @param names - The names of the secrets to retrieve - * @returns A Result containing an object mapping secret names to their decrypted values - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.getMany(['DATABASE_URL', 'API_KEY']) - * if (result.failure) { - * console.error('Failed to get secrets:', result.failure.message) - * } else { - * const dbUrl = result.data.DATABASE_URL // Access by name - * const apiKey = result.data.API_KEY - * } - * ``` - */ - async getMany( - names: SecretName[], - ): Promise< - Result, { type: string; message: string }> - > { - await this.ensureInitialized() - - if (!this.protectClient) { - return { - failure: { - type: 'ClientError', - message: 'Failed to initialize Protect client', - }, - } - } - - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - // Fetch encrypted value from API - const apiResult = await this.apiRequest( - 'POST', - '/get-many', - { - workspaceId, - environment: this.config.environment, - names, - }, - ) - - if (apiResult.failure) { - return apiResult - } - - const dataToDecrypt = apiResult.data.map((item) => ({ - name: item.name, - value: item.encryptedValue.data, - })) - - const decryptResult = - await this.protectClient.bulkDecryptModels(dataToDecrypt) - - if (decryptResult.failure) { - return { - failure: { - type: 'DecryptionError', - message: decryptResult.failure.message, - }, - } - } - - console.log('Decrypt result:', JSON.stringify(decryptResult.data, null, 2)) - - // Transform array of decrypted secrets into an object keyed by secret name - const decryptedSecrets = - decryptResult.data as unknown as DecryptedSecretResponse[] - const secretsMap: Record = {} - - for (const secret of decryptedSecrets) { - if (secret.name && secret.value) { - secretsMap[secret.name] = secret.value - } - } - - return { data: secretsMap } - } - - /** - * List all secrets in the environment. - * Only names and metadata are returned; values remain encrypted. - * - * @returns A Result containing the list of secrets or an error - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.list() - * if (result.failure) { - * console.error('Failed to list secrets:', result.failure.message) - * } else { - * console.log('Secrets:', result.data) - * } - * ``` - */ - async list(): Promise< - Result - > { - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - const apiResult = await this.apiRequest( - 'POST', - '/list', - { - workspaceId, - environment: this.config.environment, - }, - ) - - if (apiResult.failure) { - return apiResult - } - - return { data: apiResult.data.secrets } - } - - /** - * Delete a secret from the vault. - * - * @param name - The name of the secret to delete - * @returns A Result indicating success or failure - * - * @example - * ```typescript - * const stash = new Stash({ ... }) - * const result = await stash.delete('DATABASE_URL') - * if (result.failure) { - * console.error('Failed to delete secret:', result.failure.message) - * } - * ``` - */ - async delete( - name: SecretName, - ): Promise> { - // Extract workspaceId from CRN - const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) - - return await this.apiRequest('POST', '/delete', { - workspaceId, - environment: this.config.environment, - name, - }) - } -} diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts deleted file mode 100644 index de8e061db..000000000 --- a/packages/protect/src/types.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { - Encrypted as CipherStashEncrypted, - EncryptedQuery as CipherStashEncryptedQuery, - JsPlaintext, - newClient, - QueryOpName, -} from '@cipherstash/protect-ffi' -import type { - ProtectColumn, - ProtectTable, - ProtectTableColumn, - ProtectValue, -} from '@cipherstash/schema' - -/** - * Type to represent the client object - */ -export type Client = Awaited> | undefined - -/** - * Type to represent an encrypted payload stored in the database. Always carries - * a ciphertext — scalar payloads at the root (`c`), SteVec payloads at - * `sv[0].c`. For search-term payloads returned by `encryptQuery`, see - * {@link EncryptedQuery}. - */ -export type Encrypted = CipherStashEncrypted | null - -/** - * Type to represent an encrypted query term (search needle) returned by - * `encryptQuery` / `encryptQueryBulk` for scalar (`unique` / `match` / `ore`) - * lookups and `ste_vec_selector` JSON path queries. Carries no ciphertext — it - * is matched against stored values, never decrypted. JSON containment queries - * (`ste_vec_term`) return a storage-shaped {@link Encrypted} payload instead. - */ -export type EncryptedQuery = CipherStashEncryptedQuery | null - -/** - * Represents an encrypted payload in the database - * @deprecated Use `Encrypted` instead - */ -export type EncryptedPayload = Encrypted | null - -/** - * Represents an encrypted data object in the database - * @deprecated Use `Encrypted` instead - */ -export type EncryptedData = Encrypted | null - -/** - * Represents a value that will be encrypted and used in a search - */ -export type SearchTerm = { - value: JsPlaintext - column: ProtectColumn - table: ProtectTable - returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' -} - -export type KeysetIdentifier = - | { - name: string - } - | { - id: string - } - -/** - * The return type of the search term based on the return type specified in the `SearchTerm` type. - * - `eql` → an `Encrypted` storage payload (for `ste_vec_term` containment) or an - * {@link EncryptedQuery} term (for scalar lookups and `ste_vec_selector` queries). - * - `composite-literal` → `string` where the value is a composite literal. - * - `escaped-composite-literal` → `string` where the value is an escaped composite literal. - */ -export type EncryptedSearchTerm = Encrypted | EncryptedQuery | string - -/** - * Result type for encryptQuery batch operations. - * Can be an `Encrypted` storage payload (e.g. for `ste_vec_term`), an - * {@link EncryptedQuery} term (for scalar lookups and `ste_vec_selector`), - * a `string` (for composite-literal formats), or `null` (for null inputs). - */ -export type EncryptedQueryResult = Encrypted | EncryptedQuery | string | null - -/** - * Represents a payload to be encrypted using the `encrypt` function - */ -export type EncryptPayload = JsPlaintext | null - -/** - * Represents the options for encrypting a payload using the `encrypt` function - */ -export type EncryptOptions = { - column: ProtectColumn | ProtectValue - table: ProtectTable -} - -/** - * Type to identify encrypted fields in a model - */ -export type EncryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: T[K] -} - -/** - * Type to identify non-encrypted fields in a model - */ -export type OtherFields = { - [K in keyof T as T[K] extends Encrypted ? never : K]: T[K] -} - -/** - * Type to represent decrypted fields in a model - */ -export type DecryptedFields = { - [K in keyof T as T[K] extends Encrypted ? K : never]: string -} - -/** - * Represents a model with plaintext (decrypted) values instead of the EQL/JSONB types - */ -export type Decrypted = OtherFields & DecryptedFields - -/** - * Types for bulk encryption and decryption operations. - */ -export type BulkEncryptPayload = Array<{ - id?: string - plaintext: JsPlaintext | null -}> - -export type BulkEncryptedData = Array<{ id?: string; data: Encrypted }> -export type BulkDecryptPayload = Array<{ id?: string; data: Encrypted }> -export type BulkDecryptedData = Array> - -type DecryptionSuccess = { - error?: never - data: T - id?: string -} - -type DecryptionError = { - error: T - id?: string - data?: never -} - -export type DecryptionResult = DecryptionSuccess | DecryptionError - -/** - * User-facing query type names for encrypting query values. - * - * - `'equality'`: For exact match queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/exact | Exact Queries} - * - `'freeTextSearch'`: For text search queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/match | Match Queries} - * - `'orderAndRange'`: For comparison and range queries. {@link https://cipherstash.com/docs/platform/searchable-encryption/supported-queries/range | Range Queries} - * - `'steVecSelector'`: For JSONPath selector queries (e.g., '$.user.email') - * - `'steVecTerm'`: For containment queries (e.g., { role: 'admin' }) - * - `'searchableJson'`: Auto-infers selector or term based on plaintext type (recommended) - * - String values → ste_vec_selector (JSONPath queries) - * - Object/Array/Number/Boolean → ste_vec_term (containment queries) - * - * Note: For columns with an ste_vec index, `'searchableJson'` behaves identically to omitting - * `queryType` entirely - both auto-infer the query operation from the plaintext type. Using - * `'searchableJson'` explicitly is useful for code clarity and self-documenting intent. - */ -export type QueryTypeName = - | 'orderAndRange' - | 'freeTextSearch' - | 'equality' - | 'steVecSelector' - | 'steVecTerm' - | 'searchableJson' - -/** - * Internal FFI index type names. - * @internal - */ -export type FfiIndexTypeName = 'ore' | 'match' | 'unique' | 'ste_vec' - -/** - * Query type constants for use with encryptQuery(). - */ -export const queryTypes = { - orderAndRange: 'orderAndRange', - freeTextSearch: 'freeTextSearch', - equality: 'equality', - steVecSelector: 'steVecSelector', - steVecTerm: 'steVecTerm', - searchableJson: 'searchableJson', -} as const satisfies Record - -/** - * Maps user-friendly query type names to FFI index type names. - * @internal - */ -export const queryTypeToFfi: Record = { - orderAndRange: 'ore', - freeTextSearch: 'match', - equality: 'unique', - steVecSelector: 'ste_vec', - steVecTerm: 'ste_vec', - searchableJson: 'ste_vec', -} - -/** - * Maps query type names to FFI query operation names. - * Returns undefined for query types that don't need a specific queryOp. - * @internal - */ -export const queryTypeToQueryOp: Partial> = { - steVecSelector: 'ste_vec_selector', - steVecTerm: 'ste_vec_term', -} - -/** - * Base type for query term options shared between single and bulk operations. - * @internal - */ -export type QueryTermBase = { - column: ProtectColumn - table: ProtectTable - queryType?: QueryTypeName // Optional - auto-infers if omitted - /** - * The format for the returned encrypted value: - * - `'eql'` (default) - Returns raw Encrypted object - * - `'composite-literal'` - Returns PostgreSQL composite literal format `("json")` - * - `'escaped-composite-literal'` - Returns escaped format `"(\"json\")"` - */ - returnType?: 'eql' | 'composite-literal' | 'escaped-composite-literal' -} - -/** - * Options for encrypting a single query term. - */ -export type EncryptQueryOptions = QueryTermBase - -/** - * Individual query term for bulk operations. - */ -export type ScalarQueryTerm = QueryTermBase & { - value: JsPlaintext | null -} diff --git a/packages/protect/tsconfig.json b/packages/protect/tsconfig.json deleted file mode 100644 index 639824185..000000000 --- a/packages/protect/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ES2022", "DOM"], - "target": "ES2022", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/protect/tsup.config.ts b/packages/protect/tsup.config.ts deleted file mode 100644 index 8fdee46c6..000000000 --- a/packages/protect/tsup.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig([ - { - entry: [ - 'src/index.ts', - 'src/client.ts', - 'src/identify/index.ts', - 'src/stash/index.ts', - ], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, - target: 'es2022', - tsconfig: './tsconfig.json', - }, - { - entry: ['src/bin/stash.ts'], - outDir: 'dist/bin', - format: ['esm'], - target: 'es2022', - banner: { - js: '#!/usr/bin/env node', - }, - dts: false, - sourcemap: true, - external: ['dotenv'], - noExternal: [], - }, -]) diff --git a/packages/schema/.npmignore b/packages/schema/.npmignore deleted file mode 100644 index 3490e24db..000000000 --- a/packages/schema/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.env -.turbo -node_modules -cipherstash.secret.toml -cipherstash.toml \ No newline at end of file diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md deleted file mode 100644 index 678414865..000000000 --- a/packages/schema/CHANGELOG.md +++ /dev/null @@ -1,104 +0,0 @@ -# @cipherstash/schema - -## 3.0.2-rc.0 - -### Patch Changes - -- 229ce59: `searchableJson()` now pins the SteVec encoding mode to `standard` explicitly. - protect-ffi 0.29 flipped the library default to `compat` (the EQL v3 - encoding); pinning keeps the v2 wire format byte-stable so existing encrypted - JSON columns stay queryable and comparable. - -## 3.0.1 - -### Patch Changes - -- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack. - -## 3.0.0 - -### Major Changes - -- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`. - - Breaking upstream changes adopted in this release: - - - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code. - - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`. - - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK. - - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)). - - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases. - - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns. - - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields. - - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update. - -## 2.2.0 - -### Minor Changes - -- b0e56b8: Upgrade protect-ffi to 0.21.0 and enable array_index_mode for searchable JSON - - - Upgrade `@cipherstash/protect-ffi` to 0.21.0 across all packages - - Enable `array_index_mode: 'all'` on STE vec indexes so JSON array operations - (jsonb_array_elements, jsonb_array_length, array containment) work correctly - - Delegate credential resolution entirely to protect-ffi's `withEnvCredentials` - - Download latest EQL at build/runtime instead of bundling hardcoded SQL files - -## 2.1.0 - -### Minor Changes - -- e769740: Add encrypted JSONB query support with `searchableJson()` (recommended). - - - New `searchableJson()` schema method enables encrypted JSONB path and containment queries - - Automatic query operation inference: string values become JSONPath selector queries, objects/arrays become containment queries - - Also supports explicit `queryType: 'steVecSelector'` and `queryType: 'steVecTerm'` for advanced use cases - - JSONB path utilities (`toJsonPath`, `buildNestedObject`, `parseJsonbPath`) for building encrypted JSON column queries - -## 2.0.2 - -### Patch Changes - -- 532ac3a: Corrected types documentation in README to match Typedoc. - `int` -> `number` - `text` -> `string` - -## 2.0.1 - -### Patch Changes - -- ff4421f: Expanded typedoc documentation - -## 2.0.0 - -### Major Changes - -- 9005484: Include EQL 2.1.8 in package distribution - -## 1.1.0 - -### Minor Changes - -- d8ed4d4: Exported all types for packages looking for deeper integrations with Protect.js. - -## 1.0.0 - -### Major Changes - -- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support. - - - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms. - - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists) - - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext - - Add comprehensive test suites for JSON, integer, and basic encryption - - Update encryption format to use 'k' property for searchable JSON - - Remove deprecated search terms tests for JSON fields - - Simplify schema data types to text, int, json only - - Update model helpers to handle new encryption format - - Fix type safety issues in bulk operations and model encryption - -## 0.1.0 - -### Minor Changes - -- d0b02ea: Released initial package for CipherStash Encrypt schemas. diff --git a/packages/schema/README.md b/packages/schema/README.md deleted file mode 100644 index f9cc98288..000000000 --- a/packages/schema/README.md +++ /dev/null @@ -1,307 +0,0 @@ -# @cipherstash/schema - -A TypeScript schema builder for CipherStash encryption that enables you to define encryption schemas with searchable encryption capabilities. - -## Overview - -`@cipherstash/schema` is a standalone package that provides the schema building functionality used by `@cipherstash/protect`. While not required for typical usage, this package is available if you need to build encryption configuration schemas directly or want to understand the underlying schema structure. - -> [!TIP] -> For new projects we recommend [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack), which exposes this functionality as `encryptedTable` / `encryptedColumn` from `@cipherstash/stack/schema`. The `csTable` / `csColumn` API documented below is the legacy naming used by `@cipherstash/protect`. - -## Installation - -```bash -npm install @cipherstash/schema -# or -yarn add @cipherstash/schema -# or -pnpm add @cipherstash/schema -``` - -## Quick Start - -```typescript -import { csTable, csColumn, buildEncryptConfig } from '@cipherstash/schema' - -// Define your schema -const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - name: csColumn('name').freeTextSearch(), - age: csColumn('age').orderAndRange(), -}) - -// Build the encryption configuration -const config = buildEncryptConfig(users) -console.log(config) -``` - -## Core Functions - -### `csTable(tableName, columns)` - -Creates a table definition with encrypted columns. - -```typescript -import { csTable, csColumn } from '@cipherstash/schema' - -const users = csTable('users', { - email: csColumn('email'), - name: csColumn('name'), -}) -``` - -### `csColumn(columnName)` - -Creates a column definition with configurable indexes and data types. - -```typescript -import { csColumn } from '@cipherstash/schema' - -const emailColumn = csColumn('email') - .freeTextSearch() // Enable text search - .equality() // Enable exact matching - .orderAndRange() // Enable sorting and range queries - .dataType('string') // Set data type -``` - -### `csValue(valueName)` - -Creates a value definition for nested objects (up to 3 levels deep). - -```typescript -import { csTable, csColumn, csValue } from '@cipherstash/schema' - -const users = csTable('users', { - email: csColumn('email').equality(), - profile: { - name: csValue('profile.name'), - address: { - street: csValue('profile.address.street'), - location: { - coordinates: csValue('profile.address.location.coordinates'), - }, - }, - }, -}) -``` - -### `buildEncryptConfig(...tables)` - -Builds the final encryption configuration from table definitions. - -```typescript -import { buildEncryptConfig } from '@cipherstash/schema' - -const config = buildEncryptConfig(users, orders, products) -``` - -## Index Types - -### Equality Index (`.equality()`) - -Enables exact matching queries. - -```typescript -const emailColumn = csColumn('email').equality() -// SQL equivalent: WHERE email = 'example@example.com' -``` - -### Free Text Search (`.freeTextSearch()`) - -Enables text search with configurable options. - -```typescript -const descriptionColumn = csColumn('description').freeTextSearch({ - tokenizer: { kind: 'ngram', token_length: 3 }, - token_filters: [{ kind: 'downcase' }], - k: 6, - m: 2048, - include_original: true, -}) -// SQL equivalent: WHERE description LIKE '%example%' -``` - -### Order and Range (`.orderAndRange()`) - -Enables sorting and range queries. - -```typescript -const priceColumn = csColumn('price').orderAndRange() -// SQL equivalent: ORDER BY price ASC, WHERE price > 100 -``` - -## Data Types - -Set the data type for a column using `.dataType()`: - -```typescript -const column = csColumn('field') - .dataType('string') // text (default) - .dataType('number') // Javascript number (i.e. integer or float) - .dataType('jsonb') // JSON binary -``` - -## Nested Objects - -Support for nested object encryption (up to 3 levels deep): - -```typescript -const users = csTable('users', { - email: csColumn('email').equality(), - profile: { - name: csValue('profile.name'), - address: { - street: csValue('profile.address.street'), - city: csValue('profile.address.city'), - location: { - coordinates: csValue('profile.address.location.coordinates'), - }, - }, - }, -}) -``` - -> **Note**: Nested objects are not searchable and are not recommended for SQL databases. Use separate columns for searchable fields. - -## Advanced Configuration - -### Custom Token Filters - -```typescript -const column = csColumn('field').equality([ - { kind: 'downcase' } -]) -``` - -### Custom Match Options - -```typescript -const column = csColumn('field').freeTextSearch({ - tokenizer: { kind: 'standard' }, - token_filters: [{ kind: 'downcase' }], - k: 8, - m: 4096, - include_original: false, -}) -``` - -## Type Safety - -The schema builder provides full TypeScript support: - -```typescript -import { csTable, csColumn, type ProtectTableColumn } from '@cipherstash/schema' - -const users = csTable('users', { - email: csColumn('email').equality(), - name: csColumn('name').freeTextSearch(), -} as const) - -// TypeScript will infer the correct types -type UsersTable = typeof users -``` - -## Integration with Protect.js - -While this package can be used standalone, it's typically used through `@cipherstash/protect`: - -```typescript -import { csTable, csColumn } from '@cipherstash/protect' - -const users = csTable('users', { - email: csColumn('email').equality().freeTextSearch(), -}) - -const protectClient = await protect({ - schemas: [users], -}) -``` - -## Generated Configuration - -The `buildEncryptConfig` function generates a configuration object like this: - -```typescript -{ - v: 2, - tables: { - users: { - email: { - cast_as: 'text', - indexes: { - unique: { token_filters: [] }, - match: { - tokenizer: { kind: 'ngram', token_length: 3 }, - token_filters: [{ kind: 'downcase' }], - k: 6, - m: 2048, - include_original: true, - }, - ore: {}, - }, - }, - }, - }, -} -``` - -## Use Cases - -- **Standalone schema building**: When you need to generate encryption configurations outside of Protect.js -- **Custom tooling**: Building tools that work with CipherStash encryption schemas -- **Schema validation**: Validating schema structures before using them with Protect.js -- **Documentation generation**: Creating documentation from schema definitions - -## API Reference - -### `csTable(tableName: string, columns: ProtectTableColumn)` - -Creates a table definition. - -**Parameters:** -- `tableName`: The name of the table in the database -- `columns`: Object defining the columns and their configurations - -**Returns:** `ProtectTable & T` - -### `csColumn(columnName: string)` - -Creates a column definition. - -**Parameters:** -- `columnName`: The name of the column in the database - -**Returns:** `ProtectColumn` - -**Methods:** -- `.dataType(castAs: CastAs)`: Set the data type -- `.equality(tokenFilters?: TokenFilter[])`: Enable equality index -- `.freeTextSearch(opts?: MatchIndexOpts)`: Enable text search -- `.orderAndRange()`: Enable order and range index -- `.searchableJson()`: Enable searchable JSON index - -### `csValue(valueName: string)` - -Creates a value definition for nested objects. - -**Parameters:** -- `valueName`: Dot-separated path to the value (e.g., 'profile.name') - -**Returns:** `ProtectValue` - -**Methods:** -- `.dataType(castAs: CastAs)`: Set the data type - -### `buildEncryptConfig(...tables: ProtectTable[])` - -Builds the encryption configuration. - -**Parameters:** -- `...tables`: Variable number of table definitions - -**Returns:** `EncryptConfig` - -## License - -MIT License - see [LICENSE.md](../../LICENSE.md) for details. diff --git a/packages/schema/__tests__/schema.test.ts b/packages/schema/__tests__/schema.test.ts deleted file mode 100644 index 373f63585..000000000 --- a/packages/schema/__tests__/schema.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildEncryptConfig, csColumn, csTable, csValue } from '../src' - -describe('Schema with nested columns', () => { - it('should handle nested column structures in encrypt config', () => { - const users = csTable('users', { - email: csColumn('email').freeTextSearch().equality().orderAndRange(), - address: csColumn('address').freeTextSearch(), - example: { - field: csValue('example.field'), - nested: { - deep: csValue('example.nested.deep'), - }, - }, - } as const) - - const config = buildEncryptConfig(users) - - // Verify basic structure - expect(config).toEqual({ - v: 1, - tables: { - users: expect.any(Object), - }, - }) - - // Verify all columns are present with correct names - const columns = config.tables.users - expect(Object.keys(columns)).toEqual([ - 'email', - 'address', - 'example.field', - 'example.nested.deep', - ]) - - // Verify email column configuration - expect(columns.email).toEqual({ - cast_as: 'string', - indexes: { - match: expect.any(Object), - unique: expect.any(Object), - ore: {}, - }, - }) - - // Verify address column configuration - expect(columns.address).toEqual({ - cast_as: 'string', - indexes: { - match: expect.any(Object), - }, - }) - - // Verify nested field configuration - expect(columns['example.field']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - - // Verify deeply nested field configuration - expect(columns['example.nested.deep']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - }) - - it('should handle multiple tables with nested columns', () => { - const users = csTable('users', { - email: csColumn('email').equality(), - profile: { - name: csValue('profile.name'), - }, - } as const) - - const posts = csTable('posts', { - title: csColumn('title').freeTextSearch(), - metadata: { - tags: csValue('metadata.tags'), - }, - } as const) - - const config = buildEncryptConfig(users, posts) - - // Verify both tables are present - expect(Object.keys(config.tables)).toEqual(['users', 'posts']) - - // Verify users table columns - expect(Object.keys(config.tables.users)).toEqual(['email', 'profile.name']) - expect(config.tables.users.email.indexes).toHaveProperty('unique') - - // Verify posts table columns - expect(Object.keys(config.tables.posts)).toEqual(['title', 'metadata.tags']) - expect(config.tables.posts.title.indexes).toHaveProperty('match') - }) - - it('should handle complex nested structures with multiple index types', () => { - const complex = csTable('complex', { - id: csColumn('id').equality(), - content: { - text: csValue('content.text'), - metadata: { - tags: csValue('content.metadata.tags'), - stats: { - views: csValue('content.metadata.stats.views'), - }, - }, - }, - } as const) - - const config = buildEncryptConfig(complex) - - // Verify all columns are present - expect(Object.keys(config.tables.complex)).toEqual([ - 'id', - 'content.text', - 'content.metadata.tags', - 'content.metadata.stats.views', - ]) - - // Verify complex nested column with multiple indexes - expect(config.tables.complex['content.metadata.tags']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - - // Verify deeply nested column with order and range - expect(config.tables.complex['content.metadata.stats.views']).toEqual({ - cast_as: 'string', - indexes: {}, - }) - }) - - // NOTE: Leaving this test commented out until stevec indexing for JSON is supported. - /*it('should handle ste_vec index for JSON columns', () => { - const users = csTable('users', { - json: csColumn('json').dataType('jsonb').searchableJson(), - } as const) - - const config = buildEncryptConfig(users) - - expect(config.tables.users.json.indexes).toHaveProperty('ste_vec') - expect(config.tables.users.json.indexes.ste_vec?.prefix).toEqual( - 'users/json', - ) - })*/ -}) diff --git a/packages/schema/__tests__/searchable-json.test.ts b/packages/schema/__tests__/searchable-json.test.ts deleted file mode 100644 index 8f14ec7cb..000000000 --- a/packages/schema/__tests__/searchable-json.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { buildEncryptConfig, csColumn, csTable } from '../src/index' - -describe('searchableJson()', () => { - it('sets cast_as to json and ste_vec marker on column build', () => { - const column = csColumn('metadata').searchableJson() - const config = column.build() - - expect(config.cast_as).toBe('json') - expect(config.indexes.ste_vec?.prefix).toBe('enabled') - }) - - it('is chainable', () => { - const column = csColumn('metadata') - expect(column.searchableJson()).toBe(column) - }) -}) - -describe('ProtectTable.build() with searchableJson', () => { - it('transforms prefix to table/column format', () => { - const users = csTable('users', { - metadata: csColumn('metadata').searchableJson(), - }) - const built = users.build() - - expect(built.columns.metadata.cast_as).toBe('json') - expect(built.columns.metadata.indexes.ste_vec?.prefix).toBe( - 'users/metadata', - ) - }) -}) - -describe('buildEncryptConfig with searchableJson', () => { - it('emits ste_vec index with table/column prefix', () => { - const users = csTable('users', { - metadata: csColumn('metadata').searchableJson(), - }) - - const config = buildEncryptConfig(users) - - expect(config.tables.users.metadata.cast_as).toBe('json') - expect(config.tables.users.metadata.indexes.ste_vec?.prefix).toBe( - 'users/metadata', - ) - }) -}) diff --git a/packages/schema/package.json b/packages/schema/package.json deleted file mode 100644 index f718c249f..000000000 --- a/packages/schema/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@cipherstash/schema", - "version": "3.0.2-rc.0", - "description": "CipherStash schema builder for TypeScript", - "keywords": [ - "encrypted", - "protect", - "schema", - "builder" - ], - "bugs": { - "url": "https://github.com/cipherstash/stack/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/cipherstash/stack.git", - "directory": "packages/schema" - }, - "license": "MIT", - "author": "CipherStash ", - "type": "module", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "files": [ - "dist", - "README.md" - ], - "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "test": "vitest run", - "release": "tsup" - }, - "devDependencies": { - "tsup": "catalog:repo", - "typescript": "catalog:repo", - "vitest": "catalog:repo" - }, - "dependencies": { - "zod": "^3.25.76" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts deleted file mode 100644 index 1b61526a3..000000000 --- a/packages/schema/src/index.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { z } from 'zod' - -// ------------------------ -// Zod schemas -// ------------------------ - -/** - * Allowed cast types for CipherStash schema fields. - * - * **Possible values:** - * - `"bigint"` - * - `"boolean"` - * - `"date"` - * - `"number"` - * - `"string"` - * - `"json"` - * - * @remarks - * This is a Zod enum used at runtime to validate schema definitions. - * Use {@link CastAs} when typing your own code. - */ -export const castAsEnum = z - .enum(['bigint', 'boolean', 'date', 'number', 'string', 'json']) - .default('string') - -const tokenFilterSchema = z.object({ - kind: z.literal('downcase'), -}) - -const tokenizerSchema = z - .union([ - z.object({ - kind: z.literal('standard'), - }), - z.object({ - kind: z.literal('ngram'), - token_length: z.number(), - }), - ]) - .default({ kind: 'ngram', token_length: 3 }) - .optional() - -const oreIndexOptsSchema = z.object({}) - -const uniqueIndexOptsSchema = z.object({ - token_filters: z.array(tokenFilterSchema).default([]).optional(), -}) - -const matchIndexOptsSchema = z.object({ - tokenizer: tokenizerSchema, - token_filters: z.array(tokenFilterSchema).default([]).optional(), - k: z.number().default(6).optional(), - m: z.number().default(2048).optional(), - include_original: z.boolean().default(false).optional(), -}) - -const arrayIndexModeSchema = z.union([ - z.literal('all'), - z.literal('none'), - z.object({ - item: z.boolean().optional(), - wildcard: z.boolean().optional(), - position: z.boolean().optional(), - }), -]) - -const steVecIndexOptsSchema = z.object({ - prefix: z.string(), - array_index_mode: arrayIndexModeSchema.optional(), - mode: z.enum(['compat', 'standard']).optional(), -}) - -const indexesSchema = z - .object({ - ore: oreIndexOptsSchema.optional(), - unique: uniqueIndexOptsSchema.optional(), - match: matchIndexOptsSchema.optional(), - ste_vec: steVecIndexOptsSchema.optional(), - }) - .default({}) - -const columnSchema = z - .object({ - cast_as: castAsEnum, - indexes: indexesSchema, - }) - .default({}) - -const tableSchema = z.record(columnSchema).default({}) - -const tablesSchema = z.record(tableSchema).default({}) - -// `v` is locked to `1` because protect-ffi `0.22.0+` rejects any other value at -// `newClient` with `UNSUPPORTED_CONFIG_VERSION`. Enforcing it here means a bad -// hand-rolled config fails at zod-validation time instead of crossing into the -// native module first. -export const encryptConfigSchema = z.object({ - v: z.literal(1), - tables: tablesSchema, -}) - -// ------------------------ -// Type definitions -// ------------------------ - -/** - * Type-safe alias for {@link castAsEnum} used to specify the *unencrypted* data type of a column or value. - * This is important because once encrypted, all data is stored as binary blobs. - * - * @see {@link castAsEnum} for possible values. - */ -export type CastAs = z.infer -export type TokenFilter = z.infer -export type MatchIndexOpts = z.infer -export type SteVecIndexOpts = z.infer -export type UniqueIndexOpts = z.infer -export type OreIndexOpts = z.infer -export type ColumnSchema = z.infer - -export type ProtectTableColumn = { - [key: string]: - | ProtectColumn - | { - [key: string]: - | ProtectValue - | { - [key: string]: - | ProtectValue - | { - [key: string]: ProtectValue - } - } - } -} -export type EncryptConfig = z.infer - -// ------------------------ -// Interface definitions -// ------------------------ -export class ProtectValue { - private valueName: string - private castAsValue: CastAs - - constructor(valueName: string) { - this.valueName = valueName - this.castAsValue = 'string' - } - - /** - * Set or override the cast_as value. - */ - dataType(castAs: CastAs) { - this.castAsValue = castAs - return this - } - - build() { - return { - cast_as: this.castAsValue, - indexes: {}, - } - } - - getName() { - return this.valueName - } -} - -export class ProtectColumn { - private columnName: string - private castAsValue: CastAs - private indexesValue: { - ore?: OreIndexOpts - unique?: UniqueIndexOpts - match?: Required - ste_vec?: SteVecIndexOpts - } = {} - - constructor(columnName: string) { - this.columnName = columnName - this.castAsValue = 'string' - } - - /** - * Set or override the cast_as value. - */ - dataType(castAs: CastAs) { - this.castAsValue = castAs - return this - } - - /** - * Enable ORE indexing (Order-Revealing Encryption). - */ - orderAndRange() { - this.indexesValue.ore = {} - return this - } - - /** - * Enable an Exact index. Optionally pass tokenFilters. - */ - equality(tokenFilters?: TokenFilter[]) { - this.indexesValue.unique = { - token_filters: tokenFilters ?? [], - } - return this - } - - /** - * Enable a Match index. Allows passing of custom match options. - */ - freeTextSearch(opts?: MatchIndexOpts) { - // Provide defaults - this.indexesValue.match = { - tokenizer: opts?.tokenizer ?? { kind: 'ngram', token_length: 3 }, - token_filters: opts?.token_filters ?? [ - { - kind: 'downcase', - }, - ], - k: opts?.k ?? 6, - m: opts?.m ?? 2048, - include_original: opts?.include_original ?? true, - } - return this - } - - /** - * Configure this column for searchable encrypted JSON. - * Enables path queries ($.user.email) and containment queries ({ role: 'admin' }). - * Automatically sets cast_as to 'json'. - */ - searchableJson() { - this.castAsValue = 'json' - // `mode: 'standard'` pins the per-entry ordering term to CLLW-ORE (`oc`), - // the only encoding the eql_v2 SQL compares. protect-ffi 0.29 flipped the - // library default to `compat` (CLLW-OPE, `op`) for EQL v3; without the pin - // every v2 containment query silently matches nothing — and existing v2 - // rows encrypted under `standard` are not cross-comparable with `compat` - // anyway, so this also keeps the v2 wire format byte-stable. - this.indexesValue.ste_vec = { - prefix: 'enabled', - array_index_mode: 'all', - mode: 'standard', - } - return this - } - - build() { - return { - cast_as: this.castAsValue, - indexes: this.indexesValue, - } - } - - getName() { - return this.columnName - } -} - -interface TableDefinition { - tableName: string - columns: Record -} - -export class ProtectTable { - constructor( - public readonly tableName: string, - private readonly columnBuilders: T, - ) {} - - /** - * Build a TableDefinition object: tableName + built column configs. - */ - build(): TableDefinition { - const builtColumns: Record = {} - - const processColumn = ( - builder: - | ProtectColumn - | Record< - string, - | ProtectValue - | Record< - string, - | ProtectValue - | Record> - > - >, - colName: string, - ) => { - if (builder instanceof ProtectColumn) { - const builtColumn = builder.build() - - // Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix. - if ( - builtColumn.cast_as === 'json' && - builtColumn.indexes.ste_vec?.prefix === 'enabled' - ) { - builtColumns[colName] = { - ...builtColumn, - indexes: { - ...builtColumn.indexes, - ste_vec: { - ...builtColumn.indexes.ste_vec, - prefix: `${this.tableName}/${colName}`, - }, - }, - } - } else { - builtColumns[colName] = builtColumn - } - } else { - for (const [key, value] of Object.entries(builder)) { - if (value instanceof ProtectValue) { - builtColumns[value.getName()] = value.build() - } else { - processColumn(value, key) - } - } - } - } - - for (const [colName, builder] of Object.entries(this.columnBuilders)) { - processColumn(builder, colName) - } - - return { - tableName: this.tableName, - columns: builtColumns, - } - } -} - -// ------------------------ -// User facing functions -// ------------------------ -export function csTable( - tableName: string, - columns: T, -): ProtectTable & T { - const tableBuilder = new ProtectTable(tableName, columns) as ProtectTable & - T - - for (const [colName, colBuilder] of Object.entries(columns)) { - ;(tableBuilder as ProtectTableColumn)[colName] = colBuilder - } - - return tableBuilder -} - -export function csColumn(columnName: string) { - return new ProtectColumn(columnName) -} - -export function csValue(valueName: string) { - return new ProtectValue(valueName) -} - -// ------------------------ -// Internal functions -// ------------------------ -export function buildEncryptConfig( - ...protectTables: Array> -): EncryptConfig { - const config: EncryptConfig = { - v: 1, - tables: {}, - } - - for (const tb of protectTables) { - const tableDef = tb.build() - config.tables[tableDef.tableName] = tableDef.columns - } - - return config -} diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json deleted file mode 100644 index 6da81c927..000000000 --- a/packages/schema/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - // Enable latest features - "lib": ["ESNext", "DOM"], - "target": "ESNext", - "module": "ESNext", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "esModuleInterop": true, - - // Bundler mode - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, - "skipLibCheck": true, - "noFallthroughCasesInSwitch": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } -} diff --git a/packages/schema/tsup.config.ts b/packages/schema/tsup.config.ts deleted file mode 100644 index 0ced0a354..000000000 --- a/packages/schema/tsup.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'tsup' - -export default defineConfig({ - entry: ['src/index.ts'], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, -}) diff --git a/packages/stack/src/encryption/helpers/index.ts b/packages/stack/src/encryption/helpers/index.ts index 8b431d8d2..74c59f1a2 100644 --- a/packages/stack/src/encryption/helpers/index.ts +++ b/packages/stack/src/encryption/helpers/index.ts @@ -11,10 +11,9 @@ import type { Encrypted, EncryptedQueryResult, KeysetIdentifier } from '@/types' * payload, or a v3 ciphertext-free scalar/SteVec query term (including the * bare selector hash and `eql_v3.query_json` containment needle). * - * TODO: duplicated in `@cipherstash/protect` — see - * `packages/protect/src/helpers/index.ts`. Both copies should be removed once - * `@cipherstash/protect-ffi` exports a named alias for the `encryptQuery` - * return type (https://github.com/cipherstash/stack/pull/473). + * TODO: replace this local union once `@cipherstash/protect-ffi` exports a + * named alias for the `encryptQuery` return type + * (https://github.com/cipherstash/stack/pull/473). */ type EncryptedQueryTerm = | CipherStashEncrypted diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index a3409a8fd..04face125 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -781,7 +781,7 @@ export class WasmEncryptionClient { (term) => term.value !== null && term.value !== undefined, async (live) => // The FFI's batch field is `queries` (matching the native - // ffiEncryptQueryBulk call in packages/protect). + // ffiEncryptQueryBulk call in the encryption client). (await wasmEncryptQueryBulk( // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type this.client as never, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0804b040b..33cdbce42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,9 +86,6 @@ importers: e2e: dependencies: - '@cipherstash/protect': - specifier: workspace:* - version: link:../packages/protect '@cipherstash/stack': specifier: workspace:* version: link:../packages/stack @@ -465,97 +462,6 @@ importers: specifier: catalog:repo version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - packages/protect: - dependencies: - '@byteslice/result': - specifier: ^0.2.0 - version: 0.2.0 - '@cipherstash/protect-ffi': - specifier: 0.23.0 - version: 0.23.0 - '@cipherstash/schema': - specifier: workspace:* - version: link:../schema - '@stricli/core': - specifier: ^1.2.9 - version: 1.2.9 - dotenv: - specifier: 17.4.2 - version: 17.4.2 - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - '@supabase/supabase-js': - specifier: ^2.110.2 - version: 2.110.2 - execa: - specifier: ^9.5.2 - version: 9.6.1 - json-schema-to-typescript: - specifier: ^15.0.2 - version: 15.0.4 - postgres: - specifier: ^3.4.7 - version: 3.4.9 - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - tsx: - specifier: catalog:repo - version: 4.23.0 - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.62.2 - version: 4.62.2 - - packages/protect-dynamodb: - dependencies: - '@byteslice/result': - specifier: ^0.2.0 - version: 0.2.0 - devDependencies: - '@cipherstash/protect': - specifier: workspace:* - version: link:../protect - dotenv: - specifier: ^17.4.2 - version: 17.4.2 - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - tsx: - specifier: catalog:repo - version: 4.23.0 - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - - packages/schema: - dependencies: - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - tsup: - specifier: catalog:repo - version: 8.5.1(jiti@2.7.0)(postcss@8.5.14)(tsx@4.23.0)(typescript@5.9.3)(yaml@2.9.0) - typescript: - specifier: catalog:repo - version: 5.9.3 - vitest: - specifier: catalog:repo - version: 3.2.7(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.0)(yaml@2.9.0) - packages/stack: dependencies: '@byteslice/result': @@ -1082,69 +988,36 @@ packages: '@cipherstash/eql@3.0.2': resolution: {integrity: sha512-E85o0aoOqgCW6RReLtJ0YLh/ExRlmDJo7LlJGpWPoMTVaw+CW8o11DJ4oJIF1vFtuxSVxNULuPzzBuVmpTvvcA==} - '@cipherstash/protect-ffi-darwin-arm64@0.23.0': - resolution: {integrity: sha512-DhkKC+trOfk3RLDvPXqGsrpWdVnLAMEVLUI59OuR9tdTcJeiABtbQx8VaXdbzvNxnbkoDnOqbFRE5D11Z7nerQ==} - cpu: [arm64] - os: [darwin] - '@cipherstash/protect-ffi-darwin-arm64@0.30.0': resolution: {integrity: sha512-MbUfH0em2ysQxEV8+ed6442Q/EPQbAh5p17p1p4+JLUgbQJwPvxP8gm0yjeVrYBuGnZodsMqZwgOgEGt7360Ig==} cpu: [arm64] os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.23.0': - resolution: {integrity: sha512-YuEn2RDHOaj9s8qDIX9cpQuBmsN2SZp/RjiNX72LxhV7JEDJuLSt0ySrl+k6MHoLiZotjkp7I1u6tq3vuLCC0Q==} - cpu: [x64] - os: [darwin] - '@cipherstash/protect-ffi-darwin-x64@0.30.0': resolution: {integrity: sha512-GD2RXtjLvQaxWOPq2kbZEoNKTYWpDNTPQJ/tb9djpJF4RaR15g/jYhvk7Nqe2v3o6gre5RJBUpsaTQqC+G+L9A==} cpu: [x64] os: [darwin] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': - resolution: {integrity: sha512-I1kID2JqWnJUd0VHzNQo4gxeOAhEgzeXg3Fn0iDHnGKy+HDHd7+t/qEei9YLrV0wAXDnDFRhXXWRRVs8CxDzZA==} - cpu: [arm64] - os: [linux] - '@cipherstash/protect-ffi-linux-arm64-gnu@0.30.0': resolution: {integrity: sha512-R/DWCDQDDx/hRksRueJLvqxyQCSpGOZIummKqcVvBNkIhF/6Aos1SZ+zXTHAZ4gvBlfu3ovnRfMMI6eAstZDog==} cpu: [arm64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': - resolution: {integrity: sha512-n4aCDK0os4iY1BQIHVVUBgt8WnfIb8R3gLXTRrTkMVug0dcoQ0ZZaL5ltIUgFGJG4bvfW8+7zWLRZ51CZkqKsQ==} - cpu: [x64] - os: [linux] - '@cipherstash/protect-ffi-linux-x64-gnu@0.30.0': resolution: {integrity: sha512-uLi9kiKkJ9jUki75hgvLnDsDrJOj0RtZ8G0fVaKl4nnhZjCIVqpkn0EMlf0GOq2Bj8WiGSnca7kTGrhVkvgY9w==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': - resolution: {integrity: sha512-62WG6ayFJ/1+M7W/AWGEDskLo6aHtr8PFoHoXkSTdhWf29RPb4+yU1pPNYVCitVWB1sdGs+lXSO6MFD4N6IIXw==} - cpu: [x64] - os: [linux] - '@cipherstash/protect-ffi-linux-x64-musl@0.30.0': resolution: {integrity: sha512-ZrPyxc+qi9u9LsyGYbdqoi6oMPeBBx+E+/uZuAzVtp115k8CsI8xlcrmNswQ6Z74EgvAUfLb1ykTn5/P9Ge0rw==} cpu: [x64] os: [linux] - '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': - resolution: {integrity: sha512-z+jErHcPw1RwiwhSqqx/QzKqkk06gulh6YJl4TlSBPlJPjhR30TEcxQpQ2zf7kuv86JqsBRHv8UazLNePSiEww==} - cpu: [x64] - os: [win32] - '@cipherstash/protect-ffi-win32-x64-msvc@0.30.0': resolution: {integrity: sha512-xew3mH+jf9RlfuIjXQ7KFWTs44Wjur//Ed1U48yFK3zmsyzkZGYVwfN8zieKpKgKqvj844s5x/0kvFXtrJDB0A==} cpu: [x64] os: [win32] - '@cipherstash/protect-ffi@0.23.0': - resolution: {integrity: sha512-Ca8MKLrrumC561VoPDOhuUZcF8C8YenqO1Ig9hSJSRUB+jFeIJXeyn7glExsvKYWtxOx/pRub9FV8A0RyuPHMg==} - '@cipherstash/protect-ffi@0.30.0': resolution: {integrity: sha512-Xh8X/71ZOW6B6iEKPQlG4KrDgsKYZBw29ohsFnmMKOaTWVR7EodCE6MpSRDJ4uc+zYftp3QWsvUDEFz9gDpg6w==} @@ -2087,9 +1960,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@stricli/core@1.2.9': - resolution: {integrity: sha512-vKniyS+0GtarIaUpMRMQ5+Ck9mXeaBqdN/7otznkBTWU0EXfkMJAHt2QdOCmOudR9eciQZQf09DmYm14yfPWzw==} - '@supabase/auth-js@2.110.2': resolution: {integrity: sha512-Qj7a6EDP+AMMQFWqGv+qFa8r6re//dk+qQI5bA0KK+PZmnI3JPu97TDeNt6SMiQ2FkklP79hP2yDFYSnA989OA==} engines: {node: '>=22.0.0'} @@ -4179,53 +4049,24 @@ snapshots: '@cipherstash/eql@3.0.2': {} - '@cipherstash/protect-ffi-darwin-arm64@0.23.0': - optional: true - '@cipherstash/protect-ffi-darwin-arm64@0.30.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.23.0': - optional: true - '@cipherstash/protect-ffi-darwin-x64@0.30.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': - optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.30.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': - optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.30.0': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': - optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.30.0': optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': - optional: true - '@cipherstash/protect-ffi-win32-x64-msvc@0.30.0': optional: true - '@cipherstash/protect-ffi@0.23.0': - dependencies: - '@neon-rs/load': 0.1.82 - optionalDependencies: - '@cipherstash/protect-ffi-darwin-arm64': 0.23.0 - '@cipherstash/protect-ffi-darwin-x64': 0.23.0 - '@cipherstash/protect-ffi-linux-arm64-gnu': 0.23.0 - '@cipherstash/protect-ffi-linux-x64-gnu': 0.23.0 - '@cipherstash/protect-ffi-linux-x64-musl': 0.23.0 - '@cipherstash/protect-ffi-win32-x64-msvc': 0.23.0 - '@cipherstash/protect-ffi@0.30.0': dependencies: '@neon-rs/load': 0.1.82 @@ -5062,8 +4903,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@stricli/core@1.2.9': {} - '@supabase/auth-js@2.110.2': dependencies: tslib: 2.8.1 diff --git a/scripts/lint-no-hardcoded-runners.mjs b/scripts/lint-no-hardcoded-runners.mjs index c513a28ed..e8bbc8b49 100644 --- a/scripts/lint-no-hardcoded-runners.mjs +++ b/scripts/lint-no-hardcoded-runners.mjs @@ -10,7 +10,6 @@ const ALLOWLISTED_PATHS = new Set([ 'packages/wizard/src/lib/detect.ts', // npm row of the PM table 'packages/cli/src/commands/init/utils.ts', // runnerCommand `case 'npm'` 'packages/cli/src/commands/init/lib/setup-prompt.ts', // execCommand `case 'npm':` switch - 'packages/protect/src/bin/runner.ts', // Pre-allowlisted: helper for Task 11 'packages/drizzle/src/bin/runner.ts', // Pre-allowlisted: helper for Task 13 'scripts/lint-no-hardcoded-runners.mjs', // this script's own docs ]) From 689021ea0cbdff6c0857a1257fa4c6af3a6db428 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:21:29 +1000 Subject: [PATCH 02/25] docs: add EQL v2 removal PR1 plan --- .../2026-07-22-eql-v2-removal-pr1-plan.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md diff --git a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md new file mode 100644 index 000000000..f37c3219b --- /dev/null +++ b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md @@ -0,0 +1,70 @@ +# EQL v2 removal — PR 1 step-plan (delete published v2 packages) + +Executes PR 1 of `docs/plans/2026-07-22-eql-v2-final-removal-design.md`. +Branch: `feat/remove-eql-v2-pr1-delete-packages` (worktree). Scoped against `origin/main` = `6ce53817`. + +## Goal +Delete the closed v2-only dependency chain — `@cipherstash/protect-dynamodb` → +`@cipherstash/protect` → `@cipherstash/schema` — and remove every reference so the +build, lockfile, and changeset state stay consistent. Mergeable in isolation. + +## Verified scope (live-code survey, not just design line-counts) +Closed-chain claim CONFIRMED for code imports: nothing outside the three imports them, +and `@cipherstash/stack` depends only on `@cipherstash/protect-ffi` (a different, external +package), not on any of the three. + +Corrections vs. the design's PR-1 paragraph: +- `.changeset/config.json` — the three are NOT in the `fixed` group and `ignore` is `[]`. + No config edit required (design assumed a fixed-group removal). +- `pnpm-workspace.yaml` uses globs (`packages/*`), no explicit entries to remove. +- No `tsconfig` `references` anywhere — nothing to unpick. +- Extra build blockers the design folded under "root config": `e2e/package.json` dep edge + and root `package.json` `build:js` turbo filter. +- Extra changeset-state fixes: pending `schema-stevec-standard-pin.md` targets the doomed + `@cipherstash/schema`; `pre.json` pins all three in `initialVersions`. + +## Steps + +### 1. Delete the three package directories +- `rm -rf packages/protect-dynamodb packages/protect packages/schema` + +### 2. Fix build blockers (dangling references that break compile/CI) +- `e2e/package.json` — remove the `"@cipherstash/protect": "workspace:*"` dependency line. +- root `package.json` — `build:js`: drop `--filter './packages/protect'`, keep `./packages/nextjs`. + +### 3. Clean stale (non-breaking) references +- `scripts/lint-no-hardcoded-runners.mjs` — remove the `packages/protect/src/bin/runner.ts` + allowlist entry (verify the script doesn't assert the path exists — if it does, this is + actually a blocker). +- `packages/nextjs/package.json` — description references `@cipherstash/protect`; repoint to + `@cipherstash/stack`. +- `skills/stash-drizzle/SKILL.md:38` — inspect the `@cipherstash/protect` mention; fix only if + the deletion made it wrong (a historical note about the legacy protect-based package may stay). + +### 4. Changeset / RC-mode housekeeping +- Delete `.changeset/schema-stevec-standard-pin.md` (only target is the deleted `@cipherstash/schema`; + already consumed in a prior rc per `pre.json`). +- `.changeset/pre.json` — remove the three from `initialVersions`; remove `schema-stevec-standard-pin` + from the `changesets` array (keeps it consistent with the deleted file). +- Add deletion-notice changeset `.changeset/remove-eql-v2-packages.md`: + `'@cipherstash/stack': patch` (successor surface for all three; group already major via + `stack-1-0-0-rc`), prose body naming each removed package and its migration path + (`@cipherstash/protect` → `@cipherstash/stack`; `@cipherstash/schema` → `@cipherstash/stack/schema`; + `@cipherstash/protect-dynamodb` → `@cipherstash/stack/dynamodb` `encryptedDynamoDB`). + Follows the `remove-legacy-drizzle-package.md` precedent. + +### 5. Meta-file honesty (trim what described the removed packages) +- `SECURITY.md` — drop the three rows from the package list. +- `AGENTS.md` — Repository Layout entries (protect, schema, protect-dynamodb) + prose mentions; + keep the "maintained implementation is `packages/stack/src/dynamodb`" guidance. + +### 6. Regenerate lockfile +- `pnpm install` (updates `pnpm-lock.yaml` for removed packages + e2e edge). CI is + `--frozen-lockfile`, so the committed lockfile must match. + +## Verification (green gate before commit) +- `pnpm changeset status` — no changeset references a missing package. +- `pnpm run build` — whole-repo turbo build; proves no dangling import/reference. +- `pnpm run code:check` — biome, error-free. +- `git grep -n "@cipherstash/protect\b\|@cipherstash/schema\|@cipherstash/protect-dynamodb"` — + only intentional survivors (e.g. migration-path prose, `protect-ffi` which is unrelated). From 27a0915902586d0b1d794762f4ed3c2c80629500 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:34:35 +1000 Subject: [PATCH 03/25] docs: correct plan changeset targets and stale-ref grep pattern - Note the intentional @cipherstash/nextjs patch (package.json description edit) - Use PCRE negative lookahead so the stale-reference check excludes protect-ffi --- docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md index f37c3219b..54e53c516 100644 --- a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md +++ b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md @@ -48,7 +48,9 @@ Corrections vs. the design's PR-1 paragraph: from the `changesets` array (keeps it consistent with the deleted file). - Add deletion-notice changeset `.changeset/remove-eql-v2-packages.md`: `'@cipherstash/stack': patch` (successor surface for all three; group already major via - `stack-1-0-0-rc`), prose body naming each removed package and its migration path + `stack-1-0-0-rc`) and `'@cipherstash/nextjs': patch` (its `package.json` description changes + from `@cipherstash/protect` to `@cipherstash/stack` — a published-metadata edit), prose body + naming each removed package and its migration path (`@cipherstash/protect` → `@cipherstash/stack`; `@cipherstash/schema` → `@cipherstash/stack/schema`; `@cipherstash/protect-dynamodb` → `@cipherstash/stack/dynamodb` `encryptedDynamoDB`). Follows the `remove-legacy-drizzle-package.md` precedent. @@ -66,5 +68,7 @@ Corrections vs. the design's PR-1 paragraph: - `pnpm changeset status` — no changeset references a missing package. - `pnpm run build` — whole-repo turbo build; proves no dangling import/reference. - `pnpm run code:check` — biome, error-free. -- `git grep -n "@cipherstash/protect\b\|@cipherstash/schema\|@cipherstash/protect-dynamodb"` — - only intentional survivors (e.g. migration-path prose, `protect-ffi` which is unrelated). +- `git grep -nP "@cipherstash/protect(?!-ffi)|@cipherstash/schema|@cipherstash/protect-dynamodb"` — + only intentional survivors (e.g. migration-path prose). The `(?!-ffi)` lookahead (PCRE, hence + `-P`) excludes the unrelated `@cipherstash/protect-ffi`; a plain `\b` would not, since a word + boundary sits between `protect` and `-ffi`. From a88fac420f115a1b3a46415d0c1fddb02e224be5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 17:33:04 +1000 Subject: [PATCH 04/25] refactor(migrate): drop EQL v2 from the domain-type classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `eql_v2_encrypted → 2` branch from `classifyEqlDomain`, so the migrate domain-type resolution (`detectColumnEqlVersion`, `listEncryptedColumns`, `resolveEncryptedColumn`) recognises only the self-describing `eql_v3_*` domains. v3 is the sole generation this workspace authors and backfills; a legacy v2 column's version is carried by the manifest's recorded `eqlVersion` (the CLI status renderers already fall back to it), so v2 status output is unchanged. This drops v2 *classification*, not the v2 read path — existing v2 ciphertext stays decryptable via `@cipherstash/stack`. `EqlVersion` keeps its `2` member for manifest-sourced legacy values and the exported function signatures are unchanged. Tests in `version.test.ts` are updated to assert v2 domains are no longer classified (excluded from `listEncryptedColumns`, `null` from the detectors). Decision 6 guard: `classifyEqlDomain` is a source-column classifier for backfill planning and read-only CLI status display — no decrypt/round-trip consumes its `2` result — so dropping v2 here is safe. NOTE: `packages/migrate/src/eql.ts` (the `eql_v2.*` config-lifecycle wrappers) is NOT deleted in this PR. Although it carries no decrypt path, it is hard- imported by the CLI's v2 cut-over/config commands (`encrypt cutover`, `db activate`, `db push`); deleting it would break the CLI build, which is out of this migrate-scoped PR. Its removal must be sequenced with the CLI v2-command removal. PR 2 of the EQL v2 final removal (#707). --- .../remove-eql-v2-migrate-classifier.md | 17 ++++++++++ .../migrate/src/__tests__/version.test.ts | 34 ++++++++++++++----- packages/migrate/src/version.ts | 15 +++++--- 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 .changeset/remove-eql-v2-migrate-classifier.md diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md new file mode 100644 index 000000000..45d163efa --- /dev/null +++ b/.changeset/remove-eql-v2-migrate-classifier.md @@ -0,0 +1,17 @@ +--- +'@cipherstash/migrate': patch +--- + +Drop EQL v2 from the domain-type classifier. `classifyEqlDomain` (and the +`detectColumnEqlVersion` / `listEncryptedColumns` / `resolveEncryptedColumn` +resolution built on it) no longer recognise the legacy `eql_v2_encrypted` +domain — v3 is the sole generation this workspace authors and backfills, so a +column's version is now determined solely from its self-describing `eql_v3_*` +domain type. A legacy v2 column's version is carried by the manifest's recorded +`eqlVersion` instead (the CLI's `encrypt status` / `status` renderers already +fall back to it), so status output is unchanged for v2 columns. + +This removes v2 *classification*, not the v2 read path: existing v2 ciphertext +remains decryptable through `@cipherstash/stack`. `EqlVersion` keeps its `2` +member for manifest-sourced legacy values; the exported function signatures are +unchanged. diff --git a/packages/migrate/src/__tests__/version.test.ts b/packages/migrate/src/__tests__/version.test.ts index b6cfdb9d6..96e5cfbb6 100644 --- a/packages/migrate/src/__tests__/version.test.ts +++ b/packages/migrate/src/__tests__/version.test.ts @@ -18,8 +18,13 @@ function mockClient(rows: Array>) { } describe('classifyEqlDomain', () => { - it('maps eql_v2_encrypted to 2', () => { - expect(classifyEqlDomain('eql_v2_encrypted')).toBe(2) + it('no longer classifies eql_v2_encrypted — v3 is the sole authored generation', () => { + // The v2 branch was removed: this workspace authors/backfills v3 only, so + // the domain classifier recognises `eql_v3_*` alone. A legacy v2 column's + // version now comes from the manifest's recorded `eqlVersion`, not here. + // (Existing v2 ciphertext stays decryptable — only classification of v2 as + // an authorable generation is dropped.) + expect(classifyEqlDomain('eql_v2_encrypted')).toBeNull() }) it('maps any eql_v3_* domain to 3', () => { @@ -46,10 +51,20 @@ describe('classifyEqlDomain', () => { describe('detectColumnEqlVersion', () => { it('classifies from the domain type', async () => { - const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }]) + const { client } = mockClient([{ domain_name: 'eql_v3_text_search' }]) expect( await detectColumnEqlVersion(client, 'users', 'email_encrypted'), - ).toBe(2) + ).toBe(3) + }) + + it('returns null for a legacy eql_v2_encrypted domain (v2 no longer classified)', async () => { + // A v2 column still exists physically, but the classifier no longer treats + // it as an authorable EQL generation — callers fall back to the manifest's + // recorded eqlVersion. + const { client } = mockClient([{ domain_name: 'eql_v2_encrypted' }]) + expect( + await detectColumnEqlVersion(client, 'users', 'ssn_encrypted'), + ).toBeNull() }) it('returns null for a plaintext column (base type, not a domain)', async () => { @@ -88,16 +103,17 @@ describe('detectColumnEqlVersion', () => { }) describe('listEncryptedColumns', () => { - it('returns only EQL-domain columns, classified', async () => { + it('returns only EQL v3-domain columns, classified (legacy v2 domains excluded)', async () => { const { client } = mockClient([ { column: 'id', domain_name: 'int8' }, { column: 'email', domain_name: 'text' }, { column: 'email_enc', domain_name: 'eql_v3_text_search' }, + // A legacy v2 column is no longer classified as EQL, so it drops out of + // the encrypted-column listing entirely. { column: 'ssn_encrypted', domain_name: 'eql_v2_encrypted' }, ]) expect(await listEncryptedColumns(client, 'users')).toEqual([ { column: 'email_enc', domain: 'eql_v3_text_search', version: 3 }, - { column: 'ssn_encrypted', domain: 'eql_v2_encrypted', version: 2 }, ]) }) }) @@ -153,10 +169,10 @@ describe('pickEncryptedColumn', () => { }) it('never resolves the plaintext column to itself', () => { - // Post-cutover v2: `email` itself carries the v2 domain. It is the - // ciphertext, not a counterpart of itself. + // A column that IS the plaintext argument cannot be its own encrypted + // counterpart, even when it's the table's sole EQL-domain column. expect( - pickEncryptedColumn([col('email', 'eql_v2_encrypted', 2)], 'email'), + pickEncryptedColumn([col('email', 'eql_v3_encrypted')], 'email'), ).toBeNull() }) diff --git a/packages/migrate/src/version.ts b/packages/migrate/src/version.ts index dccfe7e35..60e1d9d41 100644 --- a/packages/migrate/src/version.ts +++ b/packages/migrate/src/version.ts @@ -17,7 +17,7 @@ export type EqlVersion = 2 | 3 export interface EncryptedColumnInfo { /** The column's name, exactly as Postgres reports it. */ column: string - /** The EQL domain name, e.g. `eql_v2_encrypted` or `eql_v3_text_search`. */ + /** The EQL domain name, e.g. `eql_v3_text_search` or `eql_v3_integer_ord`. */ domain: string version: EqlVersion } @@ -30,12 +30,19 @@ export interface EncryptedColumnInfo { * rule lives, and why detection never relies on column NAMES: the * `_encrypted` naming is a convention, neither enforced nor required. * - * - `eql_v2_encrypted` → 2 * - `eql_v3_*` (e.g. `eql_v3_text_search`, `eql_v3_integer_ord`) → 3 - * - anything else → `null` (not an EQL column) + * - anything else → `null` (not an EQL v3 column) + * + * v3 is the sole generation this workspace authors and backfills, so the + * classifier only recognises `eql_v3_*` domains. A legacy `eql_v2_encrypted` + * column is therefore no longer classified here — its version is carried by + * the manifest's recorded `eqlVersion` instead (see the `?? eqlVersion` + * fallbacks in the CLI's `encrypt status` / `status` renderers). Existing v2 + * ciphertext stays decryptable; only its *classification as an authorable + * generation* is dropped. `EqlVersion` keeps the `2` member for those + * manifest-sourced legacy values. */ export function classifyEqlDomain(domain: string): EqlVersion | null { - if (domain === 'eql_v2_encrypted') return 2 // Underscore included: a bare `startsWith('eql_v3')` would also claim // hypothetical future generations like `eql_v30_*`. if (domain.startsWith('eql_v3_')) return 3 From 686004f81287c42c51f7e6fe9dfef0f1dd014db2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:28:18 +1000 Subject: [PATCH 05/25] 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 06/25] 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 2b4e2e923fbc03d2815e444cfec42ed2f7950b17 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 07/25] refactor(stack-supabase): fold v3 query builder into base Re-parameterize EncryptedQueryBuilderImpl over AnyV3Table and inline every EncryptedQueryBuilderV3Impl dialect override, then delete query-builder-v3.ts. Pure refactor: no runtime behaviour or wire encoding change. The decrypt path stays generation-agnostic (decryptModel/bulkDecryptModels), so stored EQL v2 payloads still decrypt (Decision 6). --- .../stack-supabase/src/query-builder-v3.ts | 989 ------------------ packages/stack-supabase/src/query-builder.ts | 938 ++++++++++++++--- 2 files changed, 818 insertions(+), 1109 deletions(-) delete mode 100644 packages/stack-supabase/src/query-builder-v3.ts diff --git a/packages/stack-supabase/src/query-builder-v3.ts b/packages/stack-supabase/src/query-builder-v3.ts deleted file mode 100644 index 8df37536c..000000000 --- a/packages/stack-supabase/src/query-builder-v3.ts +++ /dev/null @@ -1,989 +0,0 @@ -import { - DATE_LIKE_CASTS, - EncryptedV3Column, - logger, - matchNeedleError, - parseSelectorSegments, - reconstructSelectorDocument, - unsupportedLeafReason, -} from '@cipherstash/stack/adapter-kit' -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import { - type EncryptionError, - EncryptionErrorTypes, -} from '@cipherstash/stack/errors' -import type { - ColumnSchema, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import type { - BuildableQueryColumn, - Encrypted, - EncryptedQueryResult, - QueryTypeName, - ScalarQueryTerm, -} from '@cipherstash/stack/types' -import { addJsonbCastsV3, selectKeyToDbV3 } from './helpers' -import { - EncryptedQueryBuilderImpl, - EncryptionFailedError, -} from './query-builder' -import type { - DbName, - DbSelect, - FilterOp, - SupabaseClientLike, - SupabaseQueryBuilder, -} from './types' - -/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 - * client's decrypt-model path (see `encryption/v3.ts`). */ -const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) - -/** - * The subset of a v3 column builder the dialect relies on. Structural rather - * than the concrete class union so the runtime `instanceof EncryptedV3Column` - * gate and this type stay independent. - */ -type V3ColumnLike = { - getName(): string - getEqlType(): string - getQueryCapabilities(): { - equality: boolean - orderAndRange: boolean - freeTextSearch: boolean - /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ - searchableJson?: boolean - } - build(): ColumnSchema -} - -/** - * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a - * non-empty array. Everything else is rejected with an actionable steer: - * - * - Scalars/strings: the caller meant free-text (`matches` on a text column) or - * a selector — a raw JSON string is NOT parsed, by design (parsing would make - * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). - * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- - * serialize to scalars or `{}` — not the sub-document the caller believes. - * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), - * so an accidentally-empty needle would silently return (and decrypt) the - * whole table. The Drizzle adapter rejects the same needle for the same - * reason — the two first-party adapters must agree that this is an error. - */ -function assertJsonContainmentOperand(column: string, value: unknown): void { - const isPlainObject = - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || - Object.getPrototypeOf(value) === null) - if (!isPlainObject && !Array.isArray(value)) { - // Array.isArray is false on this branch by construction, so the label only - // distinguishes null / non-plain object / scalar. - const got = - value === null - ? 'null' - : typeof value === 'object' - ? (value as object).constructor?.name || 'a non-plain object' - : typeof value - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, - ) - } - const empty = Array.isArray(value) - ? value.length === 0 - : Object.keys(value as object).length === 0 - if (empty) { - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, - ) - } -} - -/** - * Reject a declared property name that is also a DIFFERENT physical column. - * - * `select('*')` expands the introspected DB names into property names, so a - * column renamed `created_at → createdAt` and a distinct plaintext column - * literally named `createdAt` both emit the token `createdAt`, which - * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST - * returns the encrypted column under that key and the plaintext one is never - * selected, silently yielding the wrong value for a field the row type - * guarantees. - * - * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s - * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to - * construct instead. - */ -function assertNoPropertyDbNameCollision( - tableName: string, - propToDb: Record, - allColumns: string[] | null, -): void { - if (!allColumns) return - const dbNames = new Set(allColumns) - - for (const [property, dbName] of Object.entries(propToDb)) { - if (property === dbName) continue - if (!dbNames.has(property)) continue - throw new Error( - `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, - ) - } -} - -/** - * EQL v3 dialect of {@link EncryptedQueryBuilderImpl} for native concrete-domain - * columns (`public.*` type domains, `eql_v3` operators). The query mechanism is - * v2's — direct EQL operators over PostgREST — with four narrow forks: - * - * - **Column recognition / naming** — v3 columns are `EncryptedV3Column` - * builders and may map a JS property name to a different DB column name - * (`buildColumnKeyMap`). Filters, select casts, and mutations resolve - * property → DB name; select casts alias the DB column back to the property - * (`prop:db_name::jsonb`) so result rows keep property keys. - * - **Mutation encoding** — the raw encrypted payload object is sent (the - * `public.*` domains are `DOMAIN … AS jsonb`), not v2's `{ data: … }` - * composite wrap. - * - **Query-term encoding** — scalar equality/range filters use the FULL - * storage envelope from `encrypt()`, serialized as jsonb text. - * - * NOT because narrowed terms fail the domain CHECK: the bundle defines a - * `public._query` companion for each storage domain, whose CHECK - * requires `NOT (VALUE ? 'c')` — i.e. it accepts exactly the no-ciphertext - * shape `encryptQuery` produces. Those domains are simply unreachable from - * here. PostgREST has no syntax to cast a filter VALUE, and an uncast literal - * is ambiguous between the `_query` and `jsonb` `@>`/`=` overloads (42725 — - * the bundle says so itself, see `cipherstash-encrypt-v3.sql`, the - * `_query_types.sql` note). The reachable overload is the `jsonb` one, whose - * body coerces its operand to the STORAGE domain, which does require `c`. - * (protect-ffi can mint narrowed `eql_v3.query_` operands via - * `encryptQuery`, but with no way to cast a PostgREST filter value they - * stay unreachable from this adapter.) - * - * The full envelope satisfies scalar storage-domain CHECKs by construction, - * and equality/range operators extract the term they need. - * - * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON - * operators: those now require typed query-domain operands. The factory reads - * the installed EQL version and this builder fails those operators before - * encryption, so a decryptable storage envelope never enters a GET URL. - * - **Legacy `matches`, not `like`/`ilike`/`contains`** — on pre-3.0.2 EQL, - * encrypted free-text search is - * FUZZY BLOOM TOKEN MATCHING, not containment: the bundle declares `@>` on each - * match domain (`CREATE OPERATOR @> … FUNCTION = eql_v3.matches`, the SQL - * function's name), whose body is `match_term(a) @> match_term(b)` — `smallint[]` - * containment of the two bloom filters. It is order- and multiplicity- - * insensitive and one-sided (a `true` may be a false positive). PostgREST - * reaches it as `cs`. The operator is named `matches` to signal that; `contains` - * is reserved for exact (native) containment on plaintext columns. - * - * Match is tokenized + downcased, so `%` is NOT a wildcard. `like`/`ilike` on an - * encrypted column are delegated to `matches` as an APPROXIMATE compatibility - * shim (surrounding `%` stripped, internal `%`/`_` rejected, one warning) and - * pass through as real SQL LIKE on a plaintext column. - * - * Substrings DO match: the needle blooms to its own trigrams, and containment - * holds whenever every one of them is present in the stored value's bloom — - * i.e. for any substring of at least `token_length` (3) characters. Shorter - * needles bloom to nothing (`bf @> '{}'` is true for every row) and are - * rejected up front by `matchNeedleError`, not answered. - * - * Decrypted rows additionally get `Date` reconstruction from the - * encrypt-config `cast_as`, mirroring the typed v3 client. - */ -export class EncryptedQueryBuilderV3Impl< - T extends Record = Record, -> extends EncryptedQueryBuilderImpl { - private v3Table: AnyV3Table - /** JS property name → DB column name, for every encrypted column. */ - private propToDb: Record - /** DB column name → JS property name — the inverse of {@link propToDb}, used - * to expand `select('*')` back into property names. Null prototype: a DB - * column literally named `constructor` / `toString` would otherwise resolve - * to an inherited `Object.prototype` member and be emitted as a select token. */ - private dbToProp: Record - /** Built column schemas keyed by DB column name (for `cast_as`). */ - private columnSchemas: Record - /** Column builders keyed by BOTH property name and DB name. */ - private v3Columns: Record - /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ - private queryDomainsRequired: boolean - - constructor( - tableName: string, - table: AnyV3Table, - encryptionClient: EncryptionClient, - supabaseClient: SupabaseClientLike, - allColumns: string[] | null = null, - queryDomainsRequired = false, - ) { - super( - tableName, - // The base class only ever calls BuildableTable members on the schema - // (build / encryptModel plumbing); every v2-specific behaviour is - // overridden below. - table as unknown as EncryptedTable, - encryptionClient, - supabaseClient, - allColumns, - ) - - this.v3Table = table - this.queryDomainsRequired = queryDomainsRequired - this.propToDb = table.buildColumnKeyMap() - this.columnSchemas = table.build().columns - - this.dbToProp = Object.create(null) as Record - for (const [property, dbName] of Object.entries(this.propToDb)) { - this.dbToProp[dbName] = property - } - - assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) - - // Null-prototype: keyed by DB column names, and `validateTransforms` reads - // it without an own-key guard — an inherited `constructor`/`toString` would - // otherwise resolve truthy for a plaintext column of that name. - this.v3Columns = Object.create(null) as Record - for (const [property, builder] of Object.entries(table.columnBuilders)) { - if (builder instanceof EncryptedV3Column) { - const col = builder as unknown as V3ColumnLike - this.v3Columns[property] = col - this.v3Columns[col.getName()] = col - } - } - - // The base class derives encrypted column names from build(), which v3 - // keys by DB name. Filters and select strings address columns by JS - // property name, so recognition must cover both. - this.encryptedColumnNames = Object.keys(this.v3Columns) - } - - // --------------------------------------------------------------------------- - // Dialect overrides - // --------------------------------------------------------------------------- - - protected override getColumnMap(): Record { - return this.v3Columns as unknown as Record - } - - /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards - * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ - private dbNameFor(name: string): string { - return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name - } - - protected override filterColumnName(column: string): DbName { - return this.dbNameFor(column) as DbName - } - - /** - * `ORDER BY` on an OPE-backed column is supported; on every other encrypted - * column it is rejected. - * - * A bare `ORDER BY col` IS wrong. The `*_ord` domains are - * `CREATE DOMAIN … AS jsonb`, and the bundle declares no btree operator class - * on any domain — it actively lints against one (`domain_opclass`), because an - * opclass on a domain bypasses operator resolution. So the sort resolves - * through jsonb's default `jsonb_cmp` and compares the envelope's keys in - * storage order, starting at the random ciphertext `c`. No error, and a - * stable, meaningless row order. (Measured: over 10 rows it returns - * `r00,r04,r08,r01,…` where the plaintext order is `r00..r09`.) - * - * But the correct sort key is reachable without a function call. `eql_v3.ord_term` - * returns the domain's `op` term, and OPE is order-preserving by construction: - * ordering by the term reproduces the plaintext order. PostgREST cannot emit - * `ORDER BY eql_v3.ord_term(col)`, but it CAN emit a jsonb path — - * `order=col->op.asc` — which selects exactly that term. Measured against a - * live PostgREST: `order=amount->op.asc` and `.desc` both reproduce the - * plaintext order for `integer_ord` and `text_search`, over 10 rows. - * - * So the guard is on the ordering FLAVOUR, not on encryption: - * - * - `ope` present → order by `col->op`. Every plain `_ord` domain, plus - * `text_ord` and `text_search`. - * - `ore` present → reject. The `ob` term is an array of ORE blocks whose - * comparison needs the superuser-only opclass; a jsonb-path sort over it is - * meaningless. (Such a column cannot hold data on managed Postgres at all: - * its domain CHECK raises `ore_domain_unavailable`.) - * - neither → reject. Storage-only, equality-only and match-only columns - * carry no ordering term to sort by. - * - * A column absent from {@link v3Columns} is a plaintext passthrough and orders - * normally. This runtime guard is the only protection the untyped - * (no-`schemas`) surface has. - */ - protected override validateTransforms(): void { - for (const t of this.transforms) { - if (t.kind !== 'order') continue - const column = this.v3Columns[t.column] - if (!column) continue - - const indexes = this.columnSchemas[column.getName()]?.indexes - if (indexes?.ope) continue - - const reason = indexes?.ore - ? 'its ORE ordering term (`ob`) needs the superuser-only ORE operator class, which PostgREST cannot reach through a jsonb path' - : 'it carries no ordering term to sort by' - - throw new Error( - `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — ${reason}. ` + - 'Order by a plaintext column, or use an OPE-backed ordering domain ' + - '(`*_ord`, `text_ord`, `text_search`), or use the EQL v3 Drizzle integration.', - ) - } - } - - /** - * Encrypted ordering columns sort by their `op` term, not by the envelope. - * - * `order=col->op` is the one ordering expression PostgREST can emit that - * reaches the OPE term. It must NOT leak into filters — those compare whole - * envelopes through the `eql_v3.*` operators — which is why this is its own - * seam rather than a change to `filterColumnName`. - * - * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns - * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native - * btree. PostgREST cannot call a function, so it orders the `op` term where it - * sits, inside the envelope. The two agree because the term is what - * `ord_term()` returns. - * - * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed - * value. Note this does NOT avoid the database collation: Postgres compares - * jsonb strings with `varstr_cmp` under the default collation, exactly as it - * does text. What makes the ordering collation-independent is the term itself - * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for - * `text_search`) — and every collation orders digits before letters and hex - * letters among themselves. `match-bloom`'s sibling assertion pins that shape. - * - * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE - * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column - * with no `ope` index it therefore returns a bare `dbName` here — a name that - * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but - * it never does: `validateTransforms` throws (with a domain-specific reason) - * before the query executes, so the bare name is only ever an intermediate - * value on a request that is about to be rejected. - */ - protected override orderColumnName(column: string): DbName { - const dbName = this.dbNameFor(column) - const encrypted = this.v3Columns[column] - if (!encrypted) return dbName as DbName - - return ( - this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName - ) as DbName - } - - /** - * Resolve a raw `.filter()` operator to the capability it exercises. Unlike - * v2, a supported v3 operand is a full storage envelope, so `queryType` - * never selects a narrowing — it only tells {@link encryptCollectedTerms} - * which capability to demand of the column. Getting it wrong therefore - * produces a wrong accept/reject, not a wrong ciphertext: the base class's - * `'equality'` default rejects `.filter('bio', 'cs', …)` on a - * `public.eql_v3_text_match` column, the one query that column can answer. - * - * Unknown operators throw rather than silently defaulting to equality, which - * would encrypt a term the column may not even be able to compare. - */ - protected override queryTypeForRawOp(operator: string): QueryTypeName { - switch (operator) { - case 'cs': - return 'freeTextSearch' - case 'gt': - case 'gte': - case 'lt': - case 'lte': - return 'orderAndRange' - case 'eq': - case 'neq': - case 'in': - case 'is': - return 'equality' - default: - throw new Error( - `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, - ) - } - } - - protected override buildSelectString(): DbSelect | null { - if (this.selectColumns === null) return null - return addJsonbCastsV3(this.selectColumns, this.propToDb) - } - - /** - * Expand the introspected column list (DB names) into JS property names. - * - * Load-bearing for `select('*')` on a DECLARED table that renames a column. - * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing - * that makes PostgREST return the column under its property name — when the - * token it sees is a property name. Feeding it the raw DB name instead takes - * the unaliased `dbNames.has(...)` branch, so the row comes back keyed - * `created_at` while the declared row type promises `createdAt`, silently - * yielding `undefined` for a field TypeScript guarantees. - * - * A DB column with no encrypted builder (plaintext passthrough, and every - * synthesized column, where property == DB name) maps to itself. - */ - protected override expandAllColumns(columns: string[]): string[] { - return columns.map((dbName) => - Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, - ) - } - - /** v3 domains are plain jsonb — send the raw payload, keyed by DB name. */ - protected override transformEncryptedMutationModel( - model: Record, - ): Record { - const out: Record = Object.create(null) - for (const [key, value] of Object.entries(model)) { - out[this.dbNameFor(key)] = value - } - return out - } - - protected override transformEncryptedMutationModels( - models: Record[], - ): Record[] { - return models.map((model) => this.transformEncryptedMutationModel(model)) - } - - /** - * Validate a term's query type against its column's declared capabilities. - * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On - * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption - * path can place ciphertext in a GET URL. - */ - private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { - const column = term.column as unknown as V3ColumnLike - let queryType = term.queryType ?? 'equality' - const capabilities = column.getQueryCapabilities() - - // The `cs` wire operator is capability-overloaded: bloom free-text on a - // match/search TEXT column, encrypted ste_vec containment on a `types.Json` - // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ - // raw `cs` all map there); resolve to the capability the column actually - // carries. The two are mutually exclusive by construction, so this can - // never reinterpret a real free-text column. - if ( - queryType === 'freeTextSearch' && - !capabilities.freeTextSearch && - capabilities.searchableJson - ) { - queryType = 'searchableJson' - } - - if ( - queryType !== 'equality' && - queryType !== 'orderAndRange' && - queryType !== 'freeTextSearch' && - queryType !== 'searchableJson' - ) { - throw new Error( - `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, - ) - } - - if (!capabilities[queryType]) { - throw new Error( - `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, - ) - } - - if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { - // This is the common boundary for every spelling that collects an - // encrypted match/containment term: matches(), contains(), not(), raw - // filter(), and both forms of or(). Method-level checks provide earlier - // errors for the direct helpers, but cannot cover the inherited raw - // filter paths on their own. - this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) - } - - if (queryType === 'searchableJson') { - // THE single enforced operand boundary for encrypted-JSON containment. - // Terms reach this resolver from every spelling — contains(), raw - // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() - // string/structured conditions — and only contains() has a method-level - // guard. Without this check a raw string (e.g. a free-text term ported - // from a text column, or an .or() condition value, which is always a - // string) would be storage-encrypted as a JSON SCALAR and silently match - // nothing; pre-#650 every such spelling failed loudly on capability. - assertJsonContainmentOperand(column.getName(), term.value) - } - - // Free-text (bloom) needle floor. A needle shorter than the tokenizer's - // token_length produces NO tokens, so `bf @> '{}'` holds for every row and - // the query would silently return (and the caller decrypt) the whole table - // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 - // adapter (matchNeedleError) so both first-party surfaces guard identically. - // JSON containment terms (searchableJson) are validated separately above. - if (queryType === 'freeTextSearch') { - const match = column.build().indexes?.match - const reason = match ? matchNeedleError(term.value, match) : undefined - if (reason) { - throw new Error( - `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, - ) - } - } - - return column - } - - private encryptionFailure(message: string, cause?: EncryptionError): never { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - // Most callers pass the operation's own `EncryptionError`; the contract- - // violation cases (bulk length mismatch, null envelope) have none, so - // synthesize one — a broken query encryption is still an encryption failure, - // and callers branch on `error.encryptionError` regardless. - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${message}`, - cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, - ) - } - - /** - * Encrypt every filter operand as a full storage envelope, serialized to jsonb - * text for the PostgREST filter value. - * - * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. - * `in(col, [a, b, c])` collects one term per element (the list must never be - * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips - * where one would do. `bulkEncrypt` carries a single `{table, column}` for the - * whole payload, so the grouping is mandatory, not an optimisation: one bulk - * call over a mixed-column term array would stamp one column onto every - * plaintext. Results are scattered back onto the terms' original indices, - * which is the contract `termMap` downstream relies on. - * - * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching - * contract, same length assertion, same fallback. Kept separate because that - * one encrypts a single-column operand list and returns `SQL[]`, while this - * must group a multi-column term array and preserve positions. - */ - protected override async encryptCollectedTerms( - terms: ScalarQueryTerm[], - ): Promise { - const groups = new Map< - V3ColumnLike, - { indices: number[]; values: ScalarQueryTerm['value'][] } - >() - terms.forEach((term, index) => { - const column = this.assertTermQueryable(term) - const group = groups.get(column) ?? { indices: [], values: [] } - group.indices.push(index) - group.values.push(term.value) - groups.set(column, group) - }) - - const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( - this.encryptionClient, - ) - // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, - // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter - // value to the `eql_v3.query_` twins, so v3 sends full envelopes where - // v2 sends `encryptQuery` composite literals; both are `EncryptedQueryResult`. - const results = new Array(terms.length) - - await Promise.all( - Array.from(groups, async ([column, { indices, values }]) => { - const encrypted = bulkEncrypt - ? await this.bulkEncryptGroup(bulkEncrypt, column, values) - : await this.encryptGroupPerTerm(column, values) - - encrypted.forEach((envelope, i) => { - results[indices[i]] = JSON.stringify(envelope) - }) - }), - ) - - return results - } - - /** One FFI crossing for a column's whole operand list. */ - private async bulkEncryptGroup( - bulkEncrypt: NonNullable, - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise> { - const baseOp = bulkEncrypt( - values.map((plaintext) => ({ plaintext })) as never, - { column, table: this.v3Table } as never, - ) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) - this.encryptionFailure(result.failure.message, result.failure) - - // `bulkEncrypt` is position-stable, so a length mismatch means the contract - // was violated. Truncating instead would silently widen an `in` predicate - // (or narrow a `not.in`) to whatever came back. `result.data` is now - // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. - const encrypted = result.data - if (encrypted.length !== values.length) { - this.encryptionFailure( - `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, - ) - } - return encrypted.map((term, i) => { - // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` - // envelope here would be `JSON.stringify`'d to the literal string `"null"` - // and sent as the filter operand — silently matching whatever `"null"` - // encodes to rather than failing. A query term should never encrypt to a - // null envelope, so treat it as a contract violation, not a value. - if (term.data === null) { - this.encryptionFailure( - `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, - ) - } - return term.data - }) - } - - /** Fallback for a client that predates `bulkEncrypt`. */ - private async encryptGroupPerTerm( - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise { - return Promise.all( - values.map(async (value) => { - const baseOp = this.encryptionClient.encrypt(value, { - column, - table: this.v3Table, - }) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - this.encryptionFailure(result.failure.message, result.failure) - } - return result.data - }), - ) - } - - /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ - private static readonly warnedLikeDelegation = new Set() - - /** True when `column` is one of this table's encrypted v3 columns. */ - private isEncryptedV3Column(column: string): boolean { - return Boolean(this.v3Columns[column]) - } - - /** True when `column` is an encrypted `types.Json` document column. */ - private isSearchableJsonColumn(column: string): boolean { - const builder: V3ColumnLike | undefined = this.v3Columns[column] - return Boolean(builder?.getQueryCapabilities().searchableJson) - } - - private assertPostgrestCanQueryEncryptedOperator( - method: string, - column: string, - ): void { - if (!this.queryDomainsRequired) return - throw new Error( - `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, - ) - } - - /** - * `contains` on the v3 surface is EXACT containment: native jsonb/array `@>` - * on a plaintext column, ENCRYPTED ste_vec `@>` on a `types.Json` column (the - * sub-document operand is storage-encrypted whole; every leaf must match at - * its path — #650). On an encrypted match/search TEXT column containment is - * not the operation (that is the fuzzy `matches`), so refuse loudly rather - * than silently emit a bloom match under a name that promises exactness. - */ - override contains(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - this.assertPostgrestCanQueryEncryptedOperator('contains', column) - // Same validator the term resolver enforces — failing here just surfaces - // the error at the call site instead of at execution. - assertJsonContainmentOperand(column, value) - return super.contains(column, value) - } - if (this.isEncryptedV3Column(column)) { - throw new Error( - `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, - ) - } - return super.contains(column, value) - } - - /** - * `matches` is the encrypted free-text operator: fuzzy bloom-filter token - * matching, one-sided (may false-positive), NOT containment. It requires an - * encrypted match/search column; on a plaintext column, `contains` (native - * `@>`) is what the caller means — and on an encrypted JSON column, - * `contains`/`selectorEq` are (matching a document is containment, not - * free-text). Guarded here because both spellings collect the same - * `freeTextSearch` term, which the capability resolver would otherwise - * silently accept as containment of the raw string. - */ - override matches(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, - ) - } - if (!this.isEncryptedV3Column(column)) { - throw new Error( - `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, - ) - } - this.assertPostgrestCanQueryEncryptedOperator('matches', column) - return super.matches(column, value) - } - - /** - * `not(col, 'contains', …)` on an encrypted TEXT column would negate a fuzzy - * bloom match under the `contains` name — the exact confusion #617 removes — - * because the base `not()` path rewrites the `contains` spelling to the `cs` - * wire operator. Reject it and steer to the `matches` spelling (or the raw - * `cs` operator, which is honest about the wire op). - * - * On an encrypted JSON column negated containment IS the honest exact - * operation (`not.cs` over ste_vec containment — {@link selectorNe} compiles - * to it), so it passes through. Plaintext columns keep native negated - * containment, and every other operator is delegated unchanged. - */ - override not(column: string, operator: string, value: unknown): this { - if ( - operator === 'contains' && - this.isEncryptedV3Column(column) && - !this.isSearchableJsonColumn(column) - ) { - throw new Error( - `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, - ) - } - // Mirror of the matches() guard: a `matches` spelling on a JSON column - // would otherwise resolve to containment (the two share the `cs` wire op), - // silently negating an EXACT operation under a name that promises FUZZY. - if (operator === 'matches' && this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, - ) - } - return super.not(column, operator, value) - } - - /** - * Validate + reconstruct a selector needle: `('$.user.role', 'admin')` → - * `{user: {role: 'admin'}}`. Shared by {@link selectorEq}/{@link selectorNe}; - * throws with column context for a non-JSON column, an invalid path, or a - * non-scalar leaf. - */ - private selectorNeedle( - method: string, - column: string, - path: string, - value: unknown, - ): Record { - if (!this.isSearchableJsonColumn(column)) { - throw new Error( - `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, - ) - } - // Selector comparisons compare a scalar LEAF (null included in the shared - // helper's rejection; eq/ne arm — `ordering: false`; - // PostgREST cannot express selector ordering yet, see - // cipherstash/encrypt-query-language#407). - const leafReason = unsupportedLeafReason(value, false) - if (leafReason) { - throw new Error( - `[supabase v3]: ${method}("${column}", "${path}", …): ${leafReason}`, - ) - } - // Stricter than the shared helper (whose Date/bigint arms serve the Drizzle - // surface): a stored JsonDocument leaf is a JSON scalar, so a Date/bigint - // needle could never match one — reject with the serialization steer - // instead of running a query that structurally returns nothing. - if ( - typeof value !== 'string' && - typeof value !== 'number' && - typeof value !== 'boolean' - ) { - throw new Error( - `[supabase v3]: ${method}("${column}", "${path}", …): a JSON document leaf is a JSON scalar (string/number/boolean); got ${value instanceof Date ? 'a Date — pass date.toISOString() (or the stored form)' : typeof value}.`, - ) - } - let segments: string[] - try { - segments = parseSelectorSegments(path) - } catch (err) { - throw new Error( - `[supabase v3]: ${method}("${column}", …): ${err instanceof Error ? err.message : String(err)}`, - ) - } - return reconstructSelectorDocument(segments, value) - } - - /** - * Encrypted JSONPath-selector equality: matches rows whose document carries - * exactly `value` at `path`. Equality at a path IS containment of the - * path-shaped needle (`{user: {role: 'admin'}}`), so this compiles to - * {@link contains} — the ste_vec entry at the selector matches on its - * equality/ordering term. Selector ORDERING (`gt`/`lt`/…) is not expressible - * over PostgREST until the bundle grows a needle-comparison overload - * (cipherstash/encrypt-query-language#407); the Drizzle adapter's - * `ops.selector()` supports it today. - */ - selectorEq(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) - const needle = this.selectorNeedle('selectorEq', column, path, value) - return super.contains(column, needle) - } - - /** - * Encrypted JSONPath-selector inequality: rows whose document does NOT carry - * `value` at `path` — INCLUDING rows where the path is absent AND rows whose - * document column is SQL NULL, matching the Drizzle selector's `ne` (whose - * `OR entry IS NULL` arm covers both absence cases). A bare `not.cs` would - * drop NULL documents under three-valued logic (`NOT (NULL @> x)` is NULL), - * so this compiles to a structured OR: - * `column.is.null, column.not.cs.` — the containment condition's - * operand is encrypted through the normal or-condition term path. - */ - selectorNe(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) - const needle = this.selectorNeedle('selectorNe', column, path, value) - return super.or([ - { column, op: 'is', value: null }, - { column, op: 'contains', negate: true, value: needle }, - ]) - } - - /** - * `like`/`ilike` on an ENCRYPTED column are a best-effort compatibility shim, - * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token - * matching, not SQL pattern matching, so the result is APPROXIMATE — matching - * is case-insensitive and one-sided (may false-positive), and anchoring is - * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be - * approximated by trigram matching and throws. A plaintext column keeps real - * SQL LIKE. - */ - override like(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) return super.like(column, pattern) - return this.matches(column, this.likeNeedle(column, 'like', pattern)) - } - - override ilike(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) return super.ilike(column, pattern) - return this.matches(column, this.likeNeedle(column, 'ilike', pattern)) - } - - /** - * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be - * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy - * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once - * per (op, column) that the delegation is approximate. - */ - private likeNeedle(column: string, op: string, pattern: string): string { - const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') - if (needle.includes('%') || pattern.includes('_')) { - throw new Error( - `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, - ) - } - const key = `${op}:${column}` - if (!EncryptedQueryBuilderV3Impl.warnedLikeDelegation.has(key)) { - EncryptedQueryBuilderV3Impl.warnedLikeDelegation.add(key) - logger.warn( - `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, - ) - } - return needle - } - - /** - * Encrypted `matches` goes through the bloom-filter `@>`, which the bundle - * declares on the domain as PostgREST's `cs`. The operand is the full storage - * envelope; `eql_v3.matches` (the SQL function) extracts the `bf` array from - * both sides. - * - * Emitted via `filter(col, 'cs', json)` rather than `q.contains(col, json)`: - * postgrest-js's `contains` re-serializes a non-string operand, and our - * operand is already `JSON.stringify`d. Plaintext `contains` (not encrypted) - * falls through to the base's native path. - */ - protected override applyContainsFilter( - q: SupabaseQueryBuilder, - column: DbName, - value: unknown, - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (wasEncrypted) { - this.assertPostgrestCanQueryEncryptedOperator('filter', column) - return q.filter(column, 'cs', value) - } - return super.applyContainsFilter(q, column, value, wasEncrypted) - } - - /** - * `.or()` string conditions carry raw PostgREST operators, so a free-text - * condition arrives as `cs` — not a {@link FilterOp}. Resolve it through the - * same table the raw `.filter()` path uses, so `.or('amount.cs.5')` on an - * `integer_ord` column is rejected by the capability guard rather than - * silently encrypted as an equality term. A structured `{ op: 'matches' }` - * condition maps to free-text directly. - */ - protected override queryTypeForOrOp(op: FilterOp): QueryTypeName { - if (op === 'matches') return 'freeTextSearch' - // Structured conditions may carry the `contains` METHOD spelling (the wire - // token becomes `cs` in rebuildOrString). It maps to the same capability - // gate as `cs`; on a JSON column the term resolver then re-types it to - // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive - // or-form relies on this arm. - if (op === 'contains') return 'freeTextSearch' - return this.queryTypeForRawOp(op) - } - - /** Rebuild `Date` values from the encrypt-config `cast_as` (date/timestamp), - * mirroring the typed v3 client's decrypt-model path. */ - protected override postprocessDecryptedRow( - row: Record, - ): Record { - // Every key an encrypted column can appear under: the keys this select - // actually produces (including caller-chosen aliases like `ts:createdAt`), - // plus the static property and DB names as a fallback for paths that record - // no select. Aliases win. Derived here from `this.selectColumns` (the row in - // hand) rather than cached from `buildSelectString`, so a reused builder can - // never postprocess a row with a previous operation's stale select map. - const keyToDb: Record = Object.assign( - Object.create(null), - this.selectColumns === null - ? undefined - : selectKeyToDbV3(this.selectColumns, this.propToDb), - ) - for (const [property, dbName] of Object.entries(this.propToDb)) { - keyToDb[property] ??= dbName - keyToDb[dbName] ??= dbName - } - - const out: Record = { ...row } - for (const [key, dbName] of Object.entries(keyToDb)) { - const castAs = this.columnSchemas[dbName]?.cast_as - if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue - const value = out[key] - if (value == null || value instanceof Date) continue - if (typeof value === 'string' || typeof value === 'number') { - out[key] = new Date(value) - } - } - return out - } -} diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 555df6276..f09314fd3 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -1,34 +1,39 @@ import type { JsPlaintext } from '@cipherstash/protect-ffi' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { - bulkModelsToEncryptedPgComposites, + DATE_LIKE_CASTS, + EncryptedV3Column, logger, - modelToEncryptedPgComposites, + matchNeedleError, + parseSelectorSegments, + reconstructSelectorDocument, + unsupportedLeafReason, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' -import type { EncryptionError } from '@cipherstash/stack/errors' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' import type { LockContextInput } from '@cipherstash/stack/identity' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import { EncryptedColumn } from '@cipherstash/stack/schema' +import type { ColumnSchema } from '@cipherstash/stack/schema' import type { BuildableQueryColumn, + Encrypted, EncryptedQueryResult, QueryTypeName, ScalarQueryTerm, } from '@cipherstash/stack/types' import { - addJsonbCasts, + addJsonbCastsV3, formatContainmentOperand, formatInListOperand, - getEncryptedColumnNames, isEncryptableTerm, isEncryptedColumn, mapFilterOpToQueryType, parseOrString, rebuildOrString, + selectKeyToDbV3, } from './helpers' import type { DbConflictList, @@ -57,27 +62,159 @@ import type { TransformOp, } from './types' +/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 + * client's decrypt-model path (see `encryption/v3.ts`). */ +const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) + +/** + * The subset of a v3 column builder the dialect relies on. Structural rather + * than the concrete class union so the runtime `instanceof EncryptedV3Column` + * gate and this type stay independent. + */ +type V3ColumnLike = { + getName(): string + getEqlType(): string + getQueryCapabilities(): { + equality: boolean + orderAndRange: boolean + freeTextSearch: boolean + /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ + searchableJson?: boolean + } + build(): ColumnSchema +} + +/** + * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a + * non-empty array. Everything else is rejected with an actionable steer: + * + * - Scalars/strings: the caller meant free-text (`matches` on a text column) or + * a selector — a raw JSON string is NOT parsed, by design (parsing would make + * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). + * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- + * serialize to scalars or `{}` — not the sub-document the caller believes. + * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), + * so an accidentally-empty needle would silently return (and decrypt) the + * whole table. The Drizzle adapter rejects the same needle for the same + * reason — the two first-party adapters must agree that this is an error. + */ +function assertJsonContainmentOperand(column: string, value: unknown): void { + const isPlainObject = + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + if (!isPlainObject && !Array.isArray(value)) { + // Array.isArray is false on this branch by construction, so the label only + // distinguishes null / non-plain object / scalar. + const got = + value === null + ? 'null' + : typeof value === 'object' + ? (value as object).constructor?.name || 'a non-plain object' + : typeof value + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, + ) + } + const empty = Array.isArray(value) + ? value.length === 0 + : Object.keys(value as object).length === 0 + if (empty) { + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, + ) + } +} + +/** + * Reject a declared property name that is also a DIFFERENT physical column. + * + * `select('*')` expands the introspected DB names into property names, so a + * column renamed `created_at → createdAt` and a distinct plaintext column + * literally named `createdAt` both emit the token `createdAt`, which + * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST + * returns the encrypted column under that key and the plaintext one is never + * selected, silently yielding the wrong value for a field the row type + * guarantees. + * + * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s + * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to + * construct instead. + */ +function assertNoPropertyDbNameCollision( + tableName: string, + propToDb: Record, + allColumns: string[] | null, +): void { + if (!allColumns) return + const dbNames = new Set(allColumns) + + for (const [property, dbName] of Object.entries(propToDb)) { + if (property === dbName) continue + if (!dbNames.has(property)) continue + throw new Error( + `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, + ) + } +} + /** * A deferred query builder that wraps Supabase's query builder to automatically - * handle encryption and decryption of data. + * handle encryption and decryption of data for native EQL v3 concrete-domain + * columns (`public.*` type domains, `eql_v3` operators). * * All chained operations are recorded synchronously. When the builder is awaited, * it encrypts mutation data, adds `::jsonb` casts, batch-encrypts filter values, * executes the real Supabase query, and decrypts results. + * + * v3 columns are `EncryptedV3Column` builders and may map a JS property name to a + * different DB column name (`buildColumnKeyMap`). Filters, select casts, and + * mutations resolve property → DB name; select casts alias the DB column back to + * the property (`prop:db_name::jsonb`) so result rows keep property keys. The raw + * encrypted payload object is sent on mutations (the `public.*` domains are + * `DOMAIN … AS jsonb`), and scalar equality/range filters use the FULL storage + * envelope from `encrypt()`, serialized as jsonb text. + * + * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON + * operators: those now require typed query-domain operands PostgREST cannot + * express. The factory reads the installed EQL version and this builder fails + * those operators before encryption, so a decryptable storage envelope never + * enters a GET URL. + * + * Decrypted rows additionally get `Date` reconstruction from the encrypt-config + * `cast_as`, mirroring the typed v3 client. `decryptModel`/`bulkDecryptModels` + * are generation-agnostic in `@cipherstash/stack`, so a stored EQL v2 payload + * still decrypts through this builder's read path. */ export class EncryptedQueryBuilderImpl< T extends Record = Record, > { protected tableName: string - protected schema: EncryptedTable + protected table: AnyV3Table protected encryptionClient: EncryptionClient protected supabaseClient: SupabaseClientLike protected encryptedColumnNames: string[] /** All column names for the table (encrypted + plaintext), in ordinal order, * used to expand `select('*')`. `null` when the caller supplied no column - * list (v2, or a v3 client that could not introspect). */ + * list (a v3 client that could not introspect). */ protected allColumns: string[] | null = null + /** JS property name → DB column name, for every encrypted column. */ + private propToDb: Record + /** DB column name → JS property name — the inverse of {@link propToDb}, used + * to expand `select('*')` back into property names. Null prototype: a DB + * column literally named `constructor` / `toString` would otherwise resolve + * to an inherited `Object.prototype` member and be emitted as a select token. */ + private dbToProp: Record + /** Built column schemas keyed by DB column name (for `cast_as`). */ + private columnSchemas: Record + /** Column builders keyed by BOTH property name and DB name. */ + private v3Columns: Record + /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ + private queryDomainsRequired: boolean + // Recorded operations protected mutation: MutationOp | null = null protected selectColumns: string | null = null @@ -99,17 +236,43 @@ export class EncryptedQueryBuilderImpl< constructor( tableName: string, - schema: EncryptedTable, + table: AnyV3Table, encryptionClient: EncryptionClient, supabaseClient: SupabaseClientLike, allColumns: string[] | null = null, + queryDomainsRequired = false, ) { this.tableName = tableName - this.schema = schema + this.table = table this.encryptionClient = encryptionClient this.supabaseClient = supabaseClient - this.encryptedColumnNames = getEncryptedColumnNames(schema) this.allColumns = allColumns + this.queryDomainsRequired = queryDomainsRequired + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.dbToProp = Object.create(null) as Record + for (const [property, dbName] of Object.entries(this.propToDb)) { + this.dbToProp[dbName] = property + } + + assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) + + // Null-prototype: keyed by DB column names, and `validateTransforms` reads + // it without an own-key guard — an inherited `constructor`/`toString` would + // otherwise resolve truthy for a plaintext column of that name. + this.v3Columns = Object.create(null) as Record + for (const [property, builder] of Object.entries(table.columnBuilders)) { + if (builder instanceof EncryptedV3Column) { + const col = builder as unknown as V3ColumnLike + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col + } + } + + // Filters and select strings address columns by JS property name AND by DB + // name, so recognition must cover both. + this.encryptedColumnNames = Object.keys(this.v3Columns) } // --------------------------------------------------------------------------- @@ -135,14 +298,23 @@ export class EncryptedQueryBuilderImpl< } /** - * Turn the introspected column list (DB names) into select tokens. The base - * returns them unchanged — v2 never supplies a column list, so this is dead - * for v2. The v3 dialect overrides it to emit JS property names, which is - * what makes `addJsonbCastsV3` alias a renamed column back to its property - * (`createdAt:created_at::jsonb`) rather than returning it under its DB name. + * Expand the introspected column list (DB names) into JS property names. + * + * Load-bearing for `select('*')` on a DECLARED table that renames a column. + * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing + * that makes PostgREST return the column under its property name — when the + * token it sees is a property name. Feeding it the raw DB name instead takes + * the unaliased `dbNames.has(...)` branch, so the row comes back keyed + * `created_at` while the declared row type promises `createdAt`, silently + * yielding `undefined` for a field TypeScript guarantees. + * + * A DB column with no encrypted builder (plaintext passthrough, and every + * synthesized column, where property == DB name) maps to itself. */ protected expandAllColumns(columns: string[]): string[] { - return columns + return columns.map((dbName) => + Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, + ) } insert( @@ -229,28 +401,79 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * `like`/`ilike` on an ENCRYPTED column are a best-effort compatibility shim, + * delegated to `matches`. EQL v3 free-text search is fuzzy bloom token + * matching, not SQL pattern matching, so the result is APPROXIMATE — matching + * is case-insensitive and one-sided (may false-positive), and anchoring is + * lost. Leading/trailing `%` are stripped; an internal `%` or any `_` cannot be + * approximated by trigram matching and throws. A plaintext column keeps real + * SQL LIKE. + */ like(column: string, pattern: string): this { - this.filters.push({ op: 'like', column, value: pattern }) - return this + if (!this.isEncryptedV3Column(column)) { + this.filters.push({ op: 'like', column, value: pattern }) + return this + } + return this.matches(column, this.likeNeedle(column, 'like', pattern)) } ilike(column: string, pattern: string): this { - this.filters.push({ op: 'ilike', column, value: pattern }) - return this + if (!this.isEncryptedV3Column(column)) { + this.filters.push({ op: 'ilike', column, value: pattern }) + return this + } + return this.matches(column, this.likeNeedle(column, 'ilike', pattern)) } + /** + * `contains` on the v3 surface is EXACT containment: native jsonb/array `@>` + * on a plaintext column, ENCRYPTED ste_vec `@>` on a `types.Json` column (the + * sub-document operand is storage-encrypted whole; every leaf must match at + * its path — #650). On an encrypted match/search TEXT column containment is + * not the operation (that is the fuzzy `matches`), so refuse loudly rather + * than silently emit a bloom match under a name that promises exactness. + */ contains(column: string, value: unknown): this { + if (this.isSearchableJsonColumn(column)) { + this.assertPostgrestCanQueryEncryptedOperator('contains', column) + // Same validator the term resolver enforces — failing here just surfaces + // the error at the call site instead of at execution. + assertJsonContainmentOperand(column, value) + this.filters.push({ op: 'contains', column, value }) + return this + } + if (this.isEncryptedV3Column(column)) { + throw new Error( + `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, + ) + } this.filters.push({ op: 'contains', column, value }) return this } /** - * Encrypted free-text token match (v3 encrypted columns). Emits the same - * `cs`/`@>` wire operator as `contains`, but on a match-indexed encrypted - * column it is fuzzy bloom-filter token matching, not containment — see the v3 - * builder. The v3 dialect encrypts the operand as a free-text query term. + * `matches` is the encrypted free-text operator: fuzzy bloom-filter token + * matching, one-sided (may false-positive), NOT containment. It requires an + * encrypted match/search column; on a plaintext column, `contains` (native + * `@>`) is what the caller means — and on an encrypted JSON column, + * `contains`/`selectorEq` are (matching a document is containment, not + * free-text). Guarded here because both spellings collect the same + * `freeTextSearch` term, which the capability resolver would otherwise + * silently accept as containment of the raw string. */ matches(column: string, value: unknown): this { + if (this.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, + ) + } + if (!this.isEncryptedV3Column(column)) { + throw new Error( + `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, + ) + } + this.assertPostgrestCanQueryEncryptedOperator('matches', column) this.filters.push({ op: 'matches', column, value }) return this } @@ -270,7 +493,36 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * `not(col, 'contains', …)` on an encrypted TEXT column would negate a fuzzy + * bloom match under the `contains` name — the exact confusion #617 removes — + * because the `not()` path rewrites the `contains` spelling to the `cs` wire + * operator. Reject it and steer to the `matches` spelling (or the raw `cs` + * operator, which is honest about the wire op). + * + * On an encrypted JSON column negated containment IS the honest exact + * operation (`not.cs` over ste_vec containment — {@link selectorNe} compiles + * to it), so it passes through. Plaintext columns keep native negated + * containment, and every other operator is recorded unchanged. + */ not(column: string, operator: string, value: unknown): this { + if ( + operator === 'contains' && + this.isEncryptedV3Column(column) && + !this.isSearchableJsonColumn(column) + ) { + throw new Error( + `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, + ) + } + // Mirror of the matches() guard: a `matches` spelling on a JSON column + // would otherwise resolve to containment (the two share the `cs` wire op), + // silently negating an EXACT operation under a name that promises FUZZY. + if (operator === 'matches' && this.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, + ) + } this.notFilters.push({ column, op: operator as FilterOp, value }) return this } @@ -299,6 +551,41 @@ export class EncryptedQueryBuilderImpl< return this } + /** + * Encrypted JSONPath-selector equality: matches rows whose document carries + * exactly `value` at `path`. Equality at a path IS containment of the + * path-shaped needle (`{user: {role: 'admin'}}`), so this compiles to + * {@link contains} — the ste_vec entry at the selector matches on its + * equality/ordering term. Selector ORDERING (`gt`/`lt`/…) is not expressible + * over PostgREST until the bundle grows a needle-comparison overload + * (cipherstash/encrypt-query-language#407); the Drizzle adapter's + * `ops.selector()` supports it today. + */ + selectorEq(column: string, path: string, value: unknown): this { + this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) + const needle = this.selectorNeedle('selectorEq', column, path, value) + return this.contains(column, needle) + } + + /** + * Encrypted JSONPath-selector inequality: rows whose document does NOT carry + * `value` at `path` — INCLUDING rows where the path is absent AND rows whose + * document column is SQL NULL, matching the Drizzle selector's `ne` (whose + * `OR entry IS NULL` arm covers both absence cases). A bare `not.cs` would + * drop NULL documents under three-valued logic (`NOT (NULL @> x)` is NULL), + * so this compiles to a structured OR: + * `column.is.null, column.not.cs.` — the containment condition's + * operand is encrypted through the normal or-condition term path. + */ + selectorNe(column: string, path: string, value: unknown): this { + this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) + const needle = this.selectorNeedle('selectorNe', column, path, value) + return this.or([ + { column, op: 'is', value: null }, + { column, op: 'contains', negate: true, value: needle }, + ]) + } + // --------------------------------------------------------------------------- // Transform methods (passthrough) // --------------------------------------------------------------------------- @@ -432,10 +719,10 @@ export class EncryptedQueryBuilderImpl< ) // A failure inside any of the encrypt/decrypt steps above is thrown as an - // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or, in - // the v3 dialect, a synthesized one for its contract-violation cases). - // Thread it through so callers can branch on `error.encryptionError`; a plain - // PostgREST/API error is not an `EncryptionFailedError` and leaves it unset. + // `EncryptionFailedError` wrapping the operation's `EncryptionError` (or a + // synthesized one for its contract-violation cases). Thread it through so + // callers can branch on `error.encryptionError`; a plain PostgREST/API + // error is not an `EncryptionFailedError` and leaves it unset. const error: EncryptedSupabaseError = { message, encryptionError: @@ -473,7 +760,7 @@ export class EncryptedQueryBuilderImpl< if (Array.isArray(data)) { // Bulk encrypt - const baseOp = this.encryptionClient.bulkEncryptModels(data, this.schema) + const baseOp = this.encryptionClient.bulkEncryptModels(data, this.table) const op = this.lockContext ? baseOp.withLockContext(this.lockContext) : baseOp @@ -495,7 +782,7 @@ export class EncryptedQueryBuilderImpl< } // Single model - const baseOp = this.encryptionClient.encryptModel(data, this.schema) + const baseOp = this.encryptionClient.encryptModel(data, this.table) const op = this.lockContext ? baseOp.withLockContext(this.lockContext) : baseOp @@ -517,22 +804,24 @@ export class EncryptedQueryBuilderImpl< } /** - * Encode an encrypted model for the Supabase request body. v2 wraps each - * encrypted value in the `{ data: ... }` object expected by the - * `eql_v2_encrypted` composite type. The v3 dialect overrides this — native - * `eql_v3.*` domains are plain jsonb, so the raw payload is sent instead + * Encode an encrypted model for the Supabase request body. The native + * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent * (keyed by DB column name). */ protected transformEncryptedMutationModel( model: Record, ): Record { - return modelToEncryptedPgComposites(model) + const out: Record = Object.create(null) + for (const [key, value] of Object.entries(model)) { + out[this.dbNameFor(key)] = value + } + return out } protected transformEncryptedMutationModels( models: Record[], ): Record[] { - return bulkModelsToEncryptedPgComposites(models) + return models.map((model) => this.transformEncryptedMutationModel(model)) } // --------------------------------------------------------------------------- @@ -541,7 +830,7 @@ export class EncryptedQueryBuilderImpl< protected buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null - return addJsonbCasts(this.selectColumns, this.encryptedColumnNames) + return addJsonbCastsV3(this.selectColumns, this.propToDb) } // --------------------------------------------------------------------------- @@ -566,7 +855,7 @@ export class EncryptedQueryBuilderImpl< terms.push({ value, column, - table: this.schema, + table: this.table, queryType, returnType: 'composite-literal', }) @@ -757,35 +1046,225 @@ export class EncryptedQueryBuilderImpl< } /** - * Encrypt the collected filter terms, returning one encoded value per term - * (in order). v2 batch-encrypts via `encryptQuery` with the - * `composite-literal` return type — the `("json")` string the - * `eql_v2_encrypted` composite operators compare. The v3 dialect overrides - * this to produce full-envelope jsonb operands instead. + * Encrypt every filter operand as a full storage envelope, serialized to jsonb + * text for the PostgREST filter value. + * + * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. + * `in(col, [a, b, c])` collects one term per element (the list must never be + * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips + * where one would do. `bulkEncrypt` carries a single `{table, column}` for the + * whole payload, so the grouping is mandatory, not an optimisation: one bulk + * call over a mixed-column term array would stamp one column onto every + * plaintext. Results are scattered back onto the terms' original indices, + * which is the contract `termMap` downstream relies on. + * + * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching + * contract, same length assertion, same fallback. Kept separate because that + * one encrypts a single-column operand list and returns `SQL[]`, while this + * must group a multi-column term array and preserve positions. */ protected async encryptCollectedTerms( terms: ScalarQueryTerm[], ): Promise { - // Batch encrypt all terms in one call - const baseOp = this.encryptionClient.encryptQuery(terms) + const groups = new Map< + V3ColumnLike, + { indices: number[]; values: ScalarQueryTerm['value'][] } + >() + terms.forEach((term, index) => { + const column = this.assertTermQueryable(term) + const group = groups.get(column) ?? { indices: [], values: [] } + group.indices.push(index) + group.values.push(term.value) + groups.set(column, group) + }) + + const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( + this.encryptionClient, + ) + // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, + // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter + // value to the `eql_v3.query_` twins, so v3 sends full envelopes, + // serialized to jsonb text. + const results = new Array(terms.length) + + await Promise.all( + Array.from(groups, async ([column, { indices, values }]) => { + const encrypted = bulkEncrypt + ? await this.bulkEncryptGroup(bulkEncrypt, column, values) + : await this.encryptGroupPerTerm(column, values) + + encrypted.forEach((envelope, i) => { + results[indices[i]] = JSON.stringify(envelope) + }) + }), + ) + + return results + } + + /** + * Validate a term's query type against its column's declared capabilities. + * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On + * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption + * path can place ciphertext in a GET URL. + */ + private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { + const column = term.column as unknown as V3ColumnLike + let queryType = term.queryType ?? 'equality' + const capabilities = column.getQueryCapabilities() + + // The `cs` wire operator is capability-overloaded: bloom free-text on a + // match/search TEXT column, encrypted ste_vec containment on a `types.Json` + // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ + // raw `cs` all map there); resolve to the capability the column actually + // carries. The two are mutually exclusive by construction, so this can + // never reinterpret a real free-text column. + if ( + queryType === 'freeTextSearch' && + !capabilities.freeTextSearch && + capabilities.searchableJson + ) { + queryType = 'searchableJson' + } + + if ( + queryType !== 'equality' && + queryType !== 'orderAndRange' && + queryType !== 'freeTextSearch' && + queryType !== 'searchableJson' + ) { + throw new Error( + `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, + ) + } + + if (!capabilities[queryType]) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, + ) + } + + if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { + // This is the common boundary for every spelling that collects an + // encrypted match/containment term: matches(), contains(), not(), raw + // filter(), and both forms of or(). Method-level checks provide earlier + // errors for the direct helpers, but cannot cover the raw filter paths on + // their own. + this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) + } + + if (queryType === 'searchableJson') { + // THE single enforced operand boundary for encrypted-JSON containment. + // Terms reach this resolver from every spelling — contains(), raw + // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() + // string/structured conditions — and only contains() has a method-level + // guard. Without this check a raw string (e.g. a free-text term ported + // from a text column, or an .or() condition value, which is always a + // string) would be storage-encrypted as a JSON SCALAR and silently match + // nothing; pre-#650 every such spelling failed loudly on capability. + assertJsonContainmentOperand(column.getName(), term.value) + } + + // Free-text (bloom) needle floor. A needle shorter than the tokenizer's + // token_length produces NO tokens, so `bf @> '{}'` holds for every row and + // the query would silently return (and the caller decrypt) the whole table + // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 + // adapter (matchNeedleError) so both first-party surfaces guard identically. + // JSON containment terms (searchableJson) are validated separately above. + if (queryType === 'freeTextSearch') { + const match = column.build().indexes?.match + const reason = match ? matchNeedleError(term.value, match) : undefined + if (reason) { + throw new Error( + `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, + ) + } + } + + return column + } + + private encryptionFailure(message: string, cause?: EncryptionError): never { + logger.error( + `Supabase: failed to encrypt query terms for table "${this.tableName}"`, + ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. + throw new EncryptionFailedError( + `Failed to encrypt query terms: ${message}`, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, + ) + } + + /** One FFI crossing for a column's whole operand list. */ + private async bulkEncryptGroup( + bulkEncrypt: NonNullable, + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ): Promise> { + const baseOp = bulkEncrypt( + values.map((plaintext) => ({ plaintext })) as never, + { column, table: this.table } as never, + ) const op = this.lockContext ? baseOp.withLockContext(this.lockContext) : baseOp if (this.auditConfig) op.audit(this.auditConfig) const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${result.failure.message}`, - result.failure, + if (result.failure) + this.encryptionFailure(result.failure.message, result.failure) + + // `bulkEncrypt` is position-stable, so a length mismatch means the contract + // was violated. Truncating instead would silently widen an `in` predicate + // (or narrow a `not.in`) to whatever came back. `result.data` is now + // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. + const encrypted = result.data + if (encrypted.length !== values.length) { + this.encryptionFailure( + `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, ) } + return encrypted.map((term, i) => { + // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` + // envelope here would be `JSON.stringify`'d to the literal string `"null"` + // and sent as the filter operand — silently matching whatever `"null"` + // encodes to rather than failing. A query term should never encrypt to a + // null envelope, so treat it as a contract violation, not a value. + if (term.data === null) { + this.encryptionFailure( + `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, + ) + } + return term.data + }) + } - return result.data + /** Fallback for a client that predates `bulkEncrypt`. */ + private async encryptGroupPerTerm( + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ): Promise { + return Promise.all( + values.map(async (value) => { + const baseOp = this.encryptionClient.encrypt(value, { + column, + table: this.table, + }) + const op = this.lockContext + ? baseOp.withLockContext(this.lockContext) + : baseOp + if (this.auditConfig) op.audit(this.auditConfig) + + const result = await op + if (result.failure) { + this.encryptionFailure(result.failure.message, result.failure) + } + return result.data + }), + ) } // --------------------------------------------------------------------------- @@ -803,9 +1282,9 @@ export class EncryptedQueryBuilderImpl< * the order in which capability errors surface. * * Safe to run BEFORE encryption: `getColumnMap()`/`encryptedColumnNames` are - * keyed by both property and DB name in v3 (and property == DB name in v2), - * so column lookup resolves identically either side of the translation, and - * `tableColumns[prop]` is the very same builder object as `tableColumns[db]`. + * keyed by both property and DB name, so column lookup resolves identically + * either side of the translation, and `tableColumns[prop]` is the very same + * builder object as `tableColumns[db]`. */ protected toDbSpace(): DbQuerySpace { return { @@ -856,12 +1335,43 @@ export class EncryptedQueryBuilderImpl< } /** - * The column expression `order()` sends to PostgREST. Its own seam, separate - * from {@link filterColumnName}: v3 orders an encrypted column by a jsonb path - * into its ordering term, which must not leak into filters. + * Encrypted ordering columns sort by their `op` term, not by the envelope. + * + * `order=col->op` is the one ordering expression PostgREST can emit that + * reaches the OPE term. It must NOT leak into filters — those compare whole + * envelopes through the `eql_v3.*` operators — which is why this is its own + * seam rather than a change to `filterColumnName`. + * + * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns + * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native + * btree. PostgREST cannot call a function, so it orders the `op` term where it + * sits, inside the envelope. The two agree because the term is what + * `ord_term()` returns. + * + * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed + * value. Note this does NOT avoid the database collation: Postgres compares + * jsonb strings with `varstr_cmp` under the default collation, exactly as it + * does text. What makes the ordering collation-independent is the term itself + * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for + * `text_search`) — and every collation orders digits before letters and hex + * letters among themselves. `match-bloom`'s sibling assertion pins that shape. + * + * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE + * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column + * with no `ope` index it therefore returns a bare `dbName` here — a name that + * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but + * it never does: `validateTransforms` throws (with a domain-specific reason) + * before the query executes, so the bare name is only ever an intermediate + * value on a request that is about to be rejected. */ protected orderColumnName(column: string): DbName { - return this.filterColumnName(column) + const dbName = this.dbNameFor(column) + const encrypted = this.v3Columns[column] + if (!encrypted) return dbName as DbName + + return ( + this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName + ) as DbName } private transformToDbSpace(t: TransformOp): DbTransformOp { @@ -889,8 +1399,6 @@ export class EncryptedQueryBuilderImpl< switch (m.kind) { case 'insert': case 'upsert': - // `resolveMutationOptions` returns the SAME reference when no column - // needed renaming, which v2 relies on. return { ...m, options: this.resolveMutationOptions(m.options) } case 'update': case 'delete': @@ -1189,8 +1697,8 @@ export class EncryptedQueryBuilderImpl< } else { // Every condition names a plaintext column, whose property name IS // its DB name — nothing to map. Forward the caller's ORIGINAL string - // byte-for-byte: v2 relies on this for nested `and()` and quoted - // values that `parseOrString`/`rebuildOrString` cannot round-trip. + // byte-for-byte: relied on for nested `and()` and quoted values that + // `parseOrString`/`rebuildOrString` cannot round-trip. q = q.or(of_.original as DbFilterString, { referencedTable: of_.referencedTable, }) @@ -1233,30 +1741,31 @@ export class EncryptedQueryBuilderImpl< } // --------------------------------------------------------------------------- - // Dialect seams — every default preserves the v2 behaviour byte-for-byte. - // The v3 builder (see ./query-builder-v3) overrides these for native - // `eql_v3.*` domain columns. + // Dialect seams for native `eql_v3.*` domain columns. // --------------------------------------------------------------------------- + /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards + * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ + private dbNameFor(name: string): string { + return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name + } + /** - * Map a filter's column name to the DB column name PostgREST must see. - * v2 schemas key columns by their DB name already, so this is the identity; - * the v3 dialect resolves a JS property name to its DB name. + * Map a filter's column name to the DB column name PostgREST must see — + * resolving a JS property name to its DB name. * * This is the ONLY place a {@link DbName} is minted. The * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column * name reaching PostgREST must pass through here. */ protected filterColumnName(column: string): DbName { - return column as DbName + return this.dbNameFor(column) as DbName } /** * Resolve the column names carried by a mutation's options. `onConflict` is a * comma-separated column list, so it needs the same property→DB mapping as a - * filter. Returns the original object when nothing changed, so v2 — where - * {@link filterColumnName} is the identity — passes the caller's reference on - * untouched. + * filter. Returns the original object when nothing changed. */ protected resolveMutationOptions< O extends { onConflict?: string } | undefined, @@ -1274,26 +1783,86 @@ export class EncryptedQueryBuilderImpl< } /** - * Validate the accumulated transforms before the query is built. Called from - * inside {@link execute}'s try, so a throw surfaces as a `status: 500` error - * result (or rethrows under `throwOnError`), matching the filter-path - * capability guard. v2 imposes no constraints. + * `ORDER BY` on an OPE-backed column is supported; on every other encrypted + * column it is rejected. + * + * A bare `ORDER BY col` IS wrong. The `*_ord` domains are + * `CREATE DOMAIN … AS jsonb`, and the bundle declares no btree operator class + * on any domain — it actively lints against one (`domain_opclass`), because an + * opclass on a domain bypasses operator resolution. So the sort resolves + * through jsonb's default `jsonb_cmp` and compares the envelope's keys in + * storage order, starting at the random ciphertext `c`. No error, and a + * stable, meaningless row order. + * + * But the correct sort key is reachable without a function call. `eql_v3.ord_term` + * returns the domain's `op` term, and OPE is order-preserving by construction: + * ordering by the term reproduces the plaintext order. PostgREST cannot emit + * `ORDER BY eql_v3.ord_term(col)`, but it CAN emit a jsonb path — + * `order=col->op.asc` — which selects exactly that term. + * + * So the guard is on the ordering FLAVOUR, not on encryption: + * + * - `ope` present → order by `col->op`. Every plain `_ord` domain, plus + * `text_ord` and `text_search`. + * - `ore` present → reject. The `ob` term is an array of ORE blocks whose + * comparison needs the superuser-only opclass; a jsonb-path sort over it is + * meaningless. + * - neither → reject. Storage-only, equality-only and match-only columns + * carry no ordering term to sort by. + * + * A column absent from {@link v3Columns} is a plaintext passthrough and orders + * normally. This runtime guard is the only protection the untyped + * (no-`schemas`) surface has. */ - protected validateTransforms(): void {} + protected validateTransforms(): void { + for (const t of this.transforms) { + if (t.kind !== 'order') continue + const column = this.v3Columns[t.column] + if (!column) continue + + const indexes = this.columnSchemas[column.getName()]?.indexes + if (indexes?.ope) continue + + const reason = indexes?.ore + ? 'its ORE ordering term (`ob`) needs the superuser-only ORE operator class, which PostgREST cannot reach through a jsonb path' + : 'it carries no ordering term to sort by' + + throw new Error( + `[supabase v3]: cannot order by encrypted column "${column.getName()}" (${column.getEqlType()}) — ${reason}. ` + + 'Order by a plaintext column, or use an OPE-backed ordering domain ' + + '(`*_ord`, `text_ord`, `text_search`), or use the EQL v3 Drizzle integration.', + ) + } + } /** - * The CipherStash query type to encrypt a raw `.filter(column, operator, …)` - * term under. `operator` is an arbitrary PostgREST operator string, not a - * {@link FilterOp}, so it cannot go through `mapFilterOpToQueryType`. + * Resolve a raw `.filter()` operator to the capability it exercises. A + * supported v3 operand is a full storage envelope, so `queryType` never + * selects a narrowing — it only tells {@link assertTermQueryable} which + * capability to demand of the column. * - * v2 encrypts every raw filter as an equality term. That is wrong — a raw - * `.filter('amount', 'gte', …)` wants an ORE term — but in v2 `queryType` - * selects the `encryptQuery` narrowing, so correcting it changes the - * ciphertext on the wire. Preserved verbatim here and tracked separately; - * the v3 dialect, where `queryType` is only a capability gate, overrides it. + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. */ - protected queryTypeForRawOp(_operator: string): QueryTypeName { - return 'equality' + protected queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } } /** @@ -1317,10 +1886,9 @@ export class EncryptedQueryBuilderImpl< } /** - * Apply a `like`/`ilike` filter. v2 relies on the `~~` operator defined on - * `eql_v2_encrypted`; the v3 dialect overrides this for encrypted columns - * because the `eql_v3.*` domains expose free-text match via `@>` - * (PostgREST `cs`) rather than a LIKE operator. + * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were + * rewritten to `matches` at record time, so a `like`/`ilike` pending filter + * only ever names a plaintext column, which keeps real SQL LIKE. */ protected applyPatternFilter( q: SupabaseQueryBuilder, @@ -1336,20 +1904,27 @@ export class EncryptedQueryBuilderImpl< /** * Apply a `contains` filter. On a plaintext column this is PostgREST's native - * jsonb/array containment. The v3 dialect overrides it for encrypted columns, - * where `cs` resolves to the `@>` operator the EQL bundle declares on the - * domain, backed by `eql_v3.matches` (bloom-filter containment). + * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` + * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` + * (bloom-filter containment) — and the operand is the full storage envelope, + * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than + * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). * - * A structured operand is serialized here rather than by postgrest-js, which - * joins array elements on `,` without quoting them — so `['with,comma']` would - * reach Postgres as two elements. Scalars keep the native path. + * A structured plaintext operand is serialized here rather than by + * postgrest-js, which joins array elements on `,` without quoting them — so + * `['with,comma']` would reach Postgres as two elements. Scalars keep the + * native path. */ protected applyContainsFilter( q: SupabaseQueryBuilder, column: DbName, value: unknown, - _wasEncrypted: boolean, + wasEncrypted: boolean, ): SupabaseQueryBuilder { + if (wasEncrypted) { + this.assertPostgrestCanQueryEncryptedOperator('filter', column) + return q.filter(column, 'cs', value) + } const literal = formatContainmentOperand(value) return literal !== null ? q.filter(column, 'cs', literal) @@ -1359,10 +1934,17 @@ export class EncryptedQueryBuilderImpl< /** * The CipherStash query type for an `.or()` condition's operator on an * encrypted column. String-form conditions carry raw PostgREST operators - * (`cs`), which are not {@link FilterOp}s; the v3 dialect maps those. + * (`cs`), which are not {@link FilterOp}s. */ protected queryTypeForOrOp(op: FilterOp): QueryTypeName { - return mapFilterOpToQueryType(op) + if (op === 'matches') return 'freeTextSearch' + // Structured conditions may carry the `contains` METHOD spelling (the wire + // token becomes `cs` in rebuildOrString). It maps to the same capability + // gate as `cs`; on a JSON column the term resolver then re-types it to + // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive + // or-form relies on this arm. + if (op === 'contains') return 'freeTextSearch' + return this.queryTypeForRawOp(op) } /** @@ -1375,13 +1957,41 @@ export class EncryptedQueryBuilderImpl< } /** - * Post-process a decrypted result row. The v3 dialect reconstructs `Date` - * values from the encrypt-config `cast_as`; v2 returns rows unchanged. + * Post-process a decrypted result row: rebuild `Date` values from the + * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's + * decrypt-model path. */ protected postprocessDecryptedRow( row: Record, ): Record { - return row + // Every key an encrypted column can appear under: the keys this select + // actually produces (including caller-chosen aliases like `ts:createdAt`), + // plus the static property and DB names as a fallback for paths that record + // no select. Aliases win. Derived here from `this.selectColumns` (the row in + // hand) rather than cached from `buildSelectString`, so a reused builder can + // never postprocess a row with a previous operation's stale select map. + const keyToDb: Record = Object.assign( + Object.create(null), + this.selectColumns === null + ? undefined + : selectKeyToDbV3(this.selectColumns, this.propToDb), + ) + for (const [property, dbName] of Object.entries(this.propToDb)) { + keyToDb[property] ??= dbName + keyToDb[dbName] ??= dbName + } + + const out: Record = { ...row } + for (const [key, dbName] of Object.entries(keyToDb)) { + const castAs = this.columnSchemas[dbName]?.cast_as + if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + return out } // --------------------------------------------------------------------------- @@ -1523,17 +2133,105 @@ export class EncryptedQueryBuilderImpl< // --------------------------------------------------------------------------- protected getColumnMap(): Record { - const map: Record = {} - const schema = this.schema as unknown as Record + return this.v3Columns as unknown as Record + } - for (const colName of this.encryptedColumnNames) { - const col = schema[colName] - if (col instanceof EncryptedColumn) { - map[colName] = col - } + /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ + private static readonly warnedLikeDelegation = new Set() + + /** True when `column` is one of this table's encrypted v3 columns. */ + private isEncryptedV3Column(column: string): boolean { + return Boolean(this.v3Columns[column]) + } + + /** True when `column` is an encrypted `types.Json` document column. */ + private isSearchableJsonColumn(column: string): boolean { + const builder: V3ColumnLike | undefined = this.v3Columns[column] + return Boolean(builder?.getQueryCapabilities().searchableJson) + } + + private assertPostgrestCanQueryEncryptedOperator( + method: string, + column: string, + ): void { + if (!this.queryDomainsRequired) return + throw new Error( + `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + ) + } + + /** + * Validate + reconstruct a selector needle: `('$.user.role', 'admin')` → + * `{user: {role: 'admin'}}`. Shared by {@link selectorEq}/{@link selectorNe}; + * throws with column context for a non-JSON column, an invalid path, or a + * non-scalar leaf. + */ + private selectorNeedle( + method: string, + column: string, + path: string, + value: unknown, + ): Record { + if (!this.isSearchableJsonColumn(column)) { + throw new Error( + `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, + ) } + // Selector comparisons compare a scalar LEAF (null included in the shared + // helper's rejection; eq/ne arm — `ordering: false`; + // PostgREST cannot express selector ordering yet, see + // cipherstash/encrypt-query-language#407). + const leafReason = unsupportedLeafReason(value, false) + if (leafReason) { + throw new Error( + `[supabase v3]: ${method}("${column}", "${path}", …): ${leafReason}`, + ) + } + // Stricter than the shared helper (whose Date/bigint arms serve the Drizzle + // surface): a stored JsonDocument leaf is a JSON scalar, so a Date/bigint + // needle could never match one — reject with the serialization steer + // instead of running a query that structurally returns nothing. + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'boolean' + ) { + throw new Error( + `[supabase v3]: ${method}("${column}", "${path}", …): a JSON document leaf is a JSON scalar (string/number/boolean); got ${value instanceof Date ? 'a Date — pass date.toISOString() (or the stored form)' : typeof value}.`, + ) + } + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new Error( + `[supabase v3]: ${method}("${column}", …): ${err instanceof Error ? err.message : String(err)}`, + ) + } + return reconstructSelectorDocument(segments, value) + } - return map + /** + * Reduce a SQL LIKE pattern to a fuzzy-match needle, or throw when it cannot be + * approximated. Strips surrounding `%` (prefix/suffix wildcards, which fuzzy + * matching subsumes); an internal `%` or any `_` is unapproximable. Warns once + * per (op, column) that the delegation is approximate. + */ + private likeNeedle(column: string, op: string, pattern: string): string { + const needle = pattern.replace(/^%+/, '').replace(/%+$/, '') + if (needle.includes('%') || pattern.includes('_')) { + throw new Error( + `[supabase v3]: "${op}" pattern "${pattern}" on encrypted column "${column}" has wildcards fuzzy free-text matching cannot honor (an internal "%" or any "_"). Use matches("${column}", term) with a literal search term.`, + ) + } + const key = `${op}:${column}` + if (!EncryptedQueryBuilderImpl.warnedLikeDelegation.has(key)) { + EncryptedQueryBuilderImpl.warnedLikeDelegation.add(key) + logger.warn( + `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, + ) + } + return needle } } From f5f07a85b90255ee6f23affac2b3786c6a0b2d5c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 08/25] feat(stack-supabase)!: remove EQL v2 authoring surface, de-suffix v3 API encryptedSupabase is now the introspecting EQL v3 factory (was encryptedSupabaseV3); encryptedSupabaseV3 kept as a @deprecated type-identical alias. The legacy v2 encryptedSupabase({ encryptionClient, supabaseClient }).from(table, schema) wrapper and EncryptedSupabaseConfig are removed. All *V3 type exports de-suffixed to canonical names with @deprecated *V3 aliases retained. v2 decrypt is unaffected. --- packages/stack-supabase/README.md | 19 +- packages/stack-supabase/src/index.ts | 150 ++++++---------- packages/stack-supabase/src/introspect.ts | 6 +- packages/stack-supabase/src/types.ts | 207 +++++++++++++--------- 4 files changed, 197 insertions(+), 185 deletions(-) diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index 746cfc902..70aaf1981 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -9,9 +9,9 @@ Depends on `@cipherstash/stack`; install both: npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js ``` -## EQL v3 (recommended) +## EQL v3 -`encryptedSupabaseV3` introspects the database at connect time (native +`encryptedSupabase` introspects the database at connect time (native `public.eql_v3_*` column domains) — no schema argument, `select('*')` support, equality/range filters, and encrypted `order()` on OPE columns. @@ -22,18 +22,23 @@ Use the Drizzle or Prisma Next adapter, or a carefully scoped direct SQL/RPC path. ```ts -import { encryptedSupabaseV3 } from '@cipherstash/stack-supabase' +import { encryptedSupabase } from '@cipherstash/stack-supabase' -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) await es.from('users').select('id, email').eq('email', 'a@b.com') ``` +`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of +`encryptedSupabase`, so existing imports keep working. + Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer and the factory cannot run in a Worker or the browser. -## EQL v2 (legacy) +## EQL v2 (removed) -`encryptedSupabase` wraps a supabase-js client with a v2 schema; still shipped for -existing v2 deployments. +The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient, +supabaseClient }).from(tableName, schema)` — has been removed; this package now +authors and queries EQL v3 only. Migrate existing v2 columns to an `eql_v3_*` +domain, or pin the last release that shipped the v2 wrapper. See the `stash-supabase` agent skill and https://cipherstash.com/docs for the full guide. diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index ec9847261..b295a587a 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -1,78 +1,18 @@ import { Encryption } from '@cipherstash/stack' -import type { - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' import type { UnmodelledColumn } from './introspect' import { eqlRequiresQueryDomains, introspect } from './introspect' import { EncryptedQueryBuilderImpl } from './query-builder' -import { EncryptedQueryBuilderV3Impl } from './query-builder-v3' import { mergeDeclaredTables, synthesizeTables } from './schema-builder' import type { - EncryptedQueryBuilder, - EncryptedSupabaseConfig, + EncryptedQueryBuilderUntyped, EncryptedSupabaseInstance, - EncryptedSupabaseV3Instance, - EncryptedSupabaseV3Options, + EncryptedSupabaseOptions, SupabaseClientLike, - TypedEncryptedSupabaseV3Instance, + TypedEncryptedSupabaseInstance, V3Schemas, } from './types' import { verifyDeclaredSchemas } from './verify' -/** - * Create an encrypted Supabase wrapper that transparently handles encryption - * and decryption for queries on encrypted columns. - * - * @param config - Configuration containing the encryption client and Supabase client. - * @returns An object with a `from()` method that mirrors `supabase.from()` but - * auto-encrypts mutations, adds `::jsonb` casts, encrypts filter values, and - * decrypts results. - * - * @example - * ```typescript - * import { Encryption } from '@cipherstash/stack' - * import { encryptedSupabase } from '@cipherstash/stack-supabase' - * import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' - * - * const users = encryptedTable('users', { - * name: encryptedColumn('name').freeTextSearch().equality(), - * email: encryptedColumn('email').freeTextSearch().equality(), - * }) - * - * const client = await Encryption({ schemas: [users] }) - * const eSupabase = encryptedSupabase({ encryptionClient: client, supabaseClient: supabase }) - * - * // INSERT - auto-encrypts, auto-converts to PG composite - * await eSupabase.from('users', users) - * .insert({ name: 'John', email: 'john@example.com', age: 30 }) - * - * // SELECT with filter - auto-casts ::jsonb, auto-encrypts search term, auto-decrypts - * const { data } = await eSupabase.from('users', users) - * .select('id, email, name') - * .eq('email', 'john@example.com') - * ``` - */ -export function encryptedSupabase( - config: EncryptedSupabaseConfig, -): EncryptedSupabaseInstance { - const { encryptionClient, supabaseClient } = config - - return { - from = Record>( - tableName: string, - schema: EncryptedTable, - ) { - return new EncryptedQueryBuilderImpl( - tableName, - schema, - encryptionClient, - supabaseClient, - ) - }, - } -} - /** * Throw if `tableName` carries an EQL v3 column this SDK version cannot model. * @@ -101,11 +41,17 @@ function assertTableIsModelled( } /** - * Create an encrypted Supabase wrapper for **EQL v3** schemas by introspecting - * the database at connect time. Detects EQL v3 columns by their Postgres domain - * and derives each column's encryption config from it — callers no longer pass a - * schema to `from()`. Supplying `schemas` is optional: it adds compile-time - * types and verifies the declared tables against the database at construction. + * Create an encrypted Supabase wrapper over **native EQL v3 column domains** by + * introspecting the database at connect time. Detects EQL v3 columns by their + * Postgres domain and derives each column's encryption config from it — callers + * do not pass a schema to `from()`. Supplying `schemas` is optional: it adds + * compile-time types and verifies the declared tables against the database at + * construction. + * + * Encrypted data is stored as EQL v3 payloads. The generation-agnostic decrypt + * path in `@cipherstash/stack` still reads existing EQL v2 payloads, but this + * wrapper only AUTHORS EQL v3 — the legacy v2 authoring surface (a hand-written + * client-side schema and `from(tableName, schema)`) has been removed. * * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for * introspection, so it cannot run in a Worker or the browser. @@ -125,45 +71,45 @@ function assertTableIsModelled( * * @example * ```typescript - * const supabase = await encryptedSupabaseV3(supabaseUrl, supabaseKey) + * const supabase = await encryptedSupabase(supabaseUrl, supabaseKey) * await supabase.from('users').insert({ email: 'alice@example.com' }) * const { data } = await supabase.from('users').select().eq('email', 'alice@example.com') * ``` */ -export async function encryptedSupabaseV3( +export async function encryptedSupabase( supabaseUrl: string, supabaseKey: string, - options: EncryptedSupabaseV3Options & { schemas: S }, -): Promise> -export async function encryptedSupabaseV3( + options: EncryptedSupabaseOptions & { schemas: S }, +): Promise> +export async function encryptedSupabase( supabaseUrl: string, supabaseKey: string, - options?: EncryptedSupabaseV3Options, -): Promise -export async function encryptedSupabaseV3( + options?: EncryptedSupabaseOptions, +): Promise +export async function encryptedSupabase( supabaseClient: SupabaseClientLike, - options: EncryptedSupabaseV3Options & { schemas: S }, -): Promise> -export async function encryptedSupabaseV3( + options: EncryptedSupabaseOptions & { schemas: S }, +): Promise> +export async function encryptedSupabase( supabaseClient: SupabaseClientLike, - options?: EncryptedSupabaseV3Options, -): Promise -// The implementation's option params are `EncryptedSupabaseV3Options +// The implementation's option params are `EncryptedSupabaseOptions`, NOT ``. The no-schemas overloads take -// `EncryptedSupabaseV3Options` — i.e. ``, whose `schemas` is typed +// `EncryptedSupabaseOptions` — i.e. ``, whose `schemas` is typed // `undefined` — and TS2394s against an implementation param whose `schemas` is // typed `V3Schemas`. Widening the type argument to the full constraint makes // every overload relatable to the implementation signature. -export async function encryptedSupabaseV3( +export async function encryptedSupabase( clientOrUrl: SupabaseClientLike | string, - keyOrOptions?: string | EncryptedSupabaseV3Options, - maybeOptions?: EncryptedSupabaseV3Options, + keyOrOptions?: string | EncryptedSupabaseOptions, + maybeOptions?: EncryptedSupabaseOptions, ): Promise< - EncryptedSupabaseV3Instance | TypedEncryptedSupabaseV3Instance + EncryptedSupabaseInstance | TypedEncryptedSupabaseInstance > { // 1. Resolve the Supabase client + options from the overload shape. let supabaseClient: SupabaseClientLike - let options: EncryptedSupabaseV3Options + let options: EncryptedSupabaseOptions if (typeof clientOrUrl === 'string') { const url = clientOrUrl const key = keyOrOptions as string @@ -180,10 +126,10 @@ export async function encryptedSupabaseV3( if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err throw new Error( - "[supabase v3]: encryptedSupabaseV3(url, key) needs '@supabase/supabase-js' " + + "[supabase v3]: encryptedSupabase(url, key) needs '@supabase/supabase-js' " + 'to build the client, but that optional peer dependency is not installed. ' + 'Install it (`npm install @supabase/supabase-js`), or pass an existing ' + - 'client: encryptedSupabaseV3(supabaseClient, options).', + 'client: encryptedSupabase(supabaseClient, options).', { cause: err }, ) } @@ -191,7 +137,7 @@ export async function encryptedSupabaseV3( } else { supabaseClient = clientOrUrl options = - (keyOrOptions as EncryptedSupabaseV3Options) ?? {} + (keyOrOptions as EncryptedSupabaseOptions) ?? {} } // 2. Resolve the database URL for introspection. @@ -273,34 +219,46 @@ export async function encryptedSupabaseV3( // ciphertext for one. Never make it optional. assertTableIsModelled(tableName, unmodelled) const allColumns = synth.allColumns.get(tableName) ?? null - return new EncryptedQueryBuilderV3Impl( + return new EncryptedQueryBuilderImpl( tableName, table, encryptionClient, supabaseClient, allColumns, queryDomainsRequired, - ) as unknown as EncryptedQueryBuilder> + ) as unknown as EncryptedQueryBuilderUntyped> }, } return instance as unknown as - | EncryptedSupabaseV3Instance - | TypedEncryptedSupabaseV3Instance + | EncryptedSupabaseInstance + | TypedEncryptedSupabaseInstance } +/** + * @deprecated Use {@link encryptedSupabase}. `encryptedSupabaseV3` is a + * type-identical alias kept for existing imports; the `V3` suffix is redundant + * now that EQL v3 is the only generation this wrapper authors. + */ +export const encryptedSupabaseV3 = encryptedSupabase + export type { EncryptedQueryBuilder, EncryptedQueryBuilderCore, + EncryptedQueryBuilderUntyped, + // Deprecated `*V3` aliases (Decision 5 — supabase keeps type-identical aliases). EncryptedQueryBuilderV3, EncryptedQueryBuilderV3Untyped, - EncryptedSupabaseConfig, EncryptedSupabaseError, EncryptedSupabaseInstance, + EncryptedSupabaseOptions, EncryptedSupabaseResponse, EncryptedSupabaseV3Instance, EncryptedSupabaseV3Options, + FilterableKeys, + FreeTextSearchableKeys, PendingOrCondition, SupabaseClientLike, + TypedEncryptedSupabaseInstance, TypedEncryptedSupabaseV3Instance, V3FilterableKeys, V3FreeTextSearchableKeys, diff --git a/packages/stack-supabase/src/introspect.ts b/packages/stack-supabase/src/introspect.ts index 18501468c..d1619f358 100644 --- a/packages/stack-supabase/src/introspect.ts +++ b/packages/stack-supabase/src/introspect.ts @@ -189,10 +189,10 @@ export async function loadPg( if (code !== 'MODULE_NOT_FOUND' && code !== 'ERR_MODULE_NOT_FOUND') throw err throw new Error( - '[supabase v3]: encryptedSupabaseV3 introspects the database over a direct ' + + '[supabase v3]: encryptedSupabase introspects the database over a direct ' + "Postgres connection, but the optional peer dependency 'pg' is not installed. " + - 'Install it (`npm install pg`). This also means encryptedSupabaseV3 cannot run ' + - 'in a Worker or the browser — use encryptedSupabase (EQL v2) there.', + 'Install it (`npm install pg`). This also means encryptedSupabase cannot run ' + + 'in a Worker or the browser, where a direct Postgres connection is unavailable.', { cause: err }, ) } diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index 0ea50ca9d..cc5378d56 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -19,32 +19,16 @@ import type { V3Schemas } from './schema-builder' // Config & instance // --------------------------------------------------------------------------- -export type EncryptedSupabaseConfig = { - encryptionClient: EncryptionClient - supabaseClient: SupabaseClientLike -} - -export interface EncryptedSupabaseInstance { - from = Record>( - tableName: string, - schema: EncryptedTable, - ): EncryptedQueryBuilder -} - -// --------------------------------------------------------------------------- -// EQL v3 config & instance -// --------------------------------------------------------------------------- - export type { V3Schemas } /** - * Options for {@link import('./index').encryptedSupabaseV3}. + * Options for {@link import('./index').encryptedSupabase}. * * @typeParam S - declared v3 tables. When present, `from()` is constrained to * the declared table names and returns typed builders, and the tables are * verified against the database at construction. */ -export type EncryptedSupabaseV3Options< +export type EncryptedSupabaseOptions< S extends V3Schemas | undefined = undefined, > = { /** Postgres connection string for introspection. Defaults to @@ -61,7 +45,7 @@ export type EncryptedSupabaseV3Options< * Declaring a `text_search` column does NOT change its match behaviour: a * declared and a synthesized `text_search` column build byte-identically, and * neither `types.TextSearch` nor `EncryptedTextSearchColumn` accepts match - * options. See the `contains` note on `EncryptedQueryBuilderV3Impl`. + * options. See the `contains` note on `EncryptedQueryBuilderImpl`. */ schemas?: S } @@ -82,7 +66,7 @@ type V3ColumnsOfTable
= Table extends { * the filterable keys so a filter on one is a type error, matching the runtime * guard in the v3 term encryption path. */ -export type NonQueryableV3Keys
= { +export type NonQueryableKeys
= { [K in Extract, string>]: [ QueryTypesForColumn[K]>, ] extends [never] @@ -124,7 +108,7 @@ type NonScalarQueryableV3Keys
= { * before #650's `searchableJson` arm the two sets coincided). Plaintext * (non-schema) columns pass through untouched, exactly as in v2. */ -export type V3FilterableKeys< +export type FilterableKeys< Table extends AnyV3Table, Row extends Record, > = Exclude, NonScalarQueryableV3Keys
> @@ -150,7 +134,7 @@ type NonFreeTextSearchV3Keys
= { * table's encrypted columns that lack a match index. Plaintext columns pass * through, where `contains` is PostgREST's native jsonb/array containment. */ -export type V3FreeTextSearchableKeys< +export type FreeTextSearchableKeys< Table extends AnyV3Table, Row extends Record, > = Exclude, NonFreeTextSearchV3Keys
> @@ -159,13 +143,13 @@ export type V3FreeTextSearchableKeys< * Row keys `matches()` accepts: ONLY the table's ENCRYPTED columns that carry a * `freeTextSearch` capability (`public.eql_v3_text_match` / `text_search`). * - * Unlike {@link V3FreeTextSearchableKeys} (which additionally lets plaintext keys + * Unlike {@link FreeTextSearchableKeys} (which additionally lets plaintext keys * through, because the old `contains` also served native containment), this * excludes plaintext columns entirely — `matches()` is encrypted free-text only, * so calling it on a plaintext column is a compile error, not a runtime throw. * Derived from the encrypted-column keys minus the non-free-text ones. */ -export type V3EncryptedFreeTextKeys< +export type EncryptedFreeTextKeys< Table extends AnyV3Table, Row extends Record, > = Exclude< @@ -194,9 +178,9 @@ type NonSearchableJsonV3Keys
= { * columns whose domain is `public.eql_v3_json_search` (`types.Json`). Plaintext * columns are excluded — on those, `contains()` is PostgREST-native containment * and the selector methods do not apply. Mirror of - * {@link V3EncryptedFreeTextKeys} for the `searchableJson` capability. + * {@link EncryptedFreeTextKeys} for the `searchableJson` capability. */ -export type V3SearchableJsonKeys< +export type SearchableJsonKeys< Table extends AnyV3Table, Row extends Record, > = Exclude< @@ -265,7 +249,7 @@ type PlaintextContainsValue = V extends readonly unknown[] * excluded here to match the runtime rejection in `validateTransforms`, * rather than type-checking clean and throwing at execute time. */ -export type NonOrderableV3Keys
= { +export type NonOrderableKeys
= { [K in Extract< keyof V3ColumnsOfTable
, string @@ -285,25 +269,25 @@ export type NonOrderableV3Keys
= { * `jsonb_cmp` and compares the random ciphertext first. But the builder does not * emit a bare `ORDER BY`: for an encrypted ordering column it emits the jsonb * path `col->op`, which selects the OPE term, and OPE is order-preserving. See - * `EncryptedQueryBuilderV3Impl.orderColumnName`. + * `EncryptedQueryBuilderImpl.orderColumnName`. * * ORE-backed (`*_ord_ore`) columns are excluded at compile time by - * {@link NonOrderableV3Keys} — the builder sorts through a jsonb path that + * {@link NonOrderableKeys} — the builder sorts through a jsonb path that * cannot reach their superuser-only ORE opclass, so `.order()` on one is a type * error, matching the runtime rejection in `validateTransforms` (defense in * depth for the untyped `.order(someString)` path). */ -export type V3OrderableKeys< +export type OrderableKeys< Table extends AnyV3Table, Row extends Record, -> = Exclude, NonOrderableV3Keys
> +> = Exclude, NonOrderableKeys
> /** * Row keys that are NOT encrypted v3 columns. Used where a method's operand is a * SQL value rather than a ciphertext envelope — `is(col, true)` in particular, * since an encrypted column holds jsonb and can never be `IS TRUE`. */ -export type V3PlaintextKeys< +export type PlaintextKeys< Table extends AnyV3Table, Row extends Record, > = Exclude< @@ -313,86 +297,86 @@ export type V3PlaintextKeys< /** * The v3 builder type: the shared {@link EncryptedQueryBuilderCore} surface with - * filter methods narrowed to {@link V3FilterableKeys} and `order()` to - * {@link V3OrderableKeys}. + * filter methods narrowed to {@link FilterableKeys} and `order()` to + * {@link OrderableKeys}. * * `like`/`ilike` are absent by construction. EQL v3 free-text search is fuzzy * bloom-filter token matching (`@>`), not SQL wildcard matching — `%` is * tokenized like any other character, so a `like` pattern is a category error. * The v3 dialect of Drizzle omits them for the same reason. Use `matches`. */ -export interface EncryptedQueryBuilderV3< +export interface EncryptedQueryBuilder< Table extends AnyV3Table, Row extends Record, > extends EncryptedQueryBuilderCore< Row, - V3FilterableKeys & StringKeyOf, - EncryptedQueryBuilderV3, - V3OrderableKeys & StringKeyOf, + FilterableKeys & StringKeyOf, + EncryptedQueryBuilder, + OrderableKeys & StringKeyOf, // `is(col, true)` is legal only on a PLAINTEXT column: an encrypted column // holds a jsonb envelope, never a SQL boolean. The two axes were threaded // separately "so they can diverge", and now they have — `order()` admits // encrypted ordering columns (sorted by their `op` term), `is(col, true)` // still admits none. - V3PlaintextKeys & StringKeyOf + PlaintextKeys & StringKeyOf > { /** Encrypted free-text token match on legacy EQL versions. EQL 3.0.2+ * requires a query-domain cast PostgREST cannot express, so this fails fast. */ - matches & StringKeyOf>( + matches & StringKeyOf>( column: K, value: string, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Native (exact) jsonb/array containment (`@>`). Plaintext columns only — an * encrypted column is a compile error (use {@link matches}). A scalar plaintext * column resolves its operand to `never` (`@>` is array/jsonb only). */ - contains & StringKeyOf>( + contains & StringKeyOf>( column: K, value: PlaintextContainsValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Encrypted JSON containment on legacy EQL versions. EQL 3.0.2+ requires an * `eql_v3.query_json` cast PostgREST cannot express, so this fails fast before * encrypting an operand into the request URL. */ - contains & StringKeyOf>( + contains & StringKeyOf>( column: K, value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Encrypted JSONPath equality on legacy EQL versions. EQL 3.0.2+ fails fast * because PostgREST cannot express the required query-domain cast. */ - selectorEq & StringKeyOf>( + selectorEq & StringKeyOf>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Encrypted JSONPath inequality on legacy EQL versions. EQL 3.0.2+ fails * fast because PostgREST cannot express the required query-domain cast. */ - selectorNe & StringKeyOf>( + selectorNe & StringKeyOf>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Raw legacy containment spelling. EQL 3.0.2+ rejects this before sending. */ - filter & StringKeyOf>( + filter & StringKeyOf>( column: K, operator: 'cs', value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3 - filter & StringKeyOf>( + ): EncryptedQueryBuilder + filter & StringKeyOf>( column: K, operator: string, value: Row[K], - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder /** Negated legacy containment spelling. EQL 3.0.2+ rejects this before * sending. */ - not & StringKeyOf>( + not & StringKeyOf>( column: K, operator: 'contains', value: EncryptedJsonContainsValue, - ): EncryptedQueryBuilderV3 - not & StringKeyOf>( + ): EncryptedQueryBuilder + not & StringKeyOf>( column: K, operator: string, value: Row[K], - ): EncryptedQueryBuilderV3 + ): EncryptedQueryBuilder } /** @@ -407,12 +391,12 @@ export interface EncryptedQueryBuilderV3< * union (which subsumes the encrypted column's `string`); the runtime resolves * the column and picks the encoding (and rejects the wrong-column-kind pairing). */ -export interface EncryptedQueryBuilderV3Untyped< +export interface EncryptedQueryBuilderUntyped< Row extends Record, > extends EncryptedQueryBuilderCore< Row, StringKeyOf, - EncryptedQueryBuilderV3Untyped + EncryptedQueryBuilderUntyped > { /** Fuzzy free-text token match on an encrypted match/search column. The * operand is always the string term to tokenize (never an array/object), even @@ -420,33 +404,33 @@ export interface EncryptedQueryBuilderV3Untyped< matches>( column: K, value: string, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped /** Native jsonb/array containment on plaintext columns. Encrypted JSON * containment fails fast on EQL 3.0.2+. */ contains>( column: K, value: NativeContainsValue, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped /** Legacy encrypted JSONPath equality; fails fast on EQL 3.0.2+. */ selectorEq>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped /** Legacy encrypted JSONPath inequality; fails fast on EQL 3.0.2+. */ selectorNe>( column: K, path: string, value: SelectorLeafValue, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped } /** Untyped instance (no `schemas`): rows default to `Record` * and `from` accepts any table name. */ -export interface EncryptedSupabaseV3Instance { +export interface EncryptedSupabaseInstance { from = Record>( tableName: string, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped } /** Typed instance (with `schemas: S`): a declared table name resolves to the @@ -463,13 +447,13 @@ export interface EncryptedSupabaseV3Instance { * Overload order matters: the literal-constrained signature is declared first, * so TypeScript prefers it whenever the argument is a declared key and only * falls through to `string` otherwise. */ -export interface TypedEncryptedSupabaseV3Instance { +export interface TypedEncryptedSupabaseInstance { from( table: K, - ): EncryptedQueryBuilderV3> + ): EncryptedQueryBuilder> from = Record>( table: string, - ): EncryptedQueryBuilderV3Untyped + ): EncryptedQueryBuilderUntyped } // --------------------------------------------------------------------------- @@ -801,7 +785,7 @@ export interface EncryptedQueryBuilderCore< FK extends StringKeyOf, Self, /** Keys `order()` accepts. Defaults to `FK`, so the v2 surface is unchanged; - * v3 narrows it to plaintext columns (see {@link V3OrderableKeys}). */ + * v3 narrows it to plaintext columns (see {@link OrderableKeys}). */ OK extends StringKeyOf = FK, /** Keys the BOOLEAN form of `is()` accepts. Defaults to `FK`, so the v2 * surface is unchanged; v3 narrows it to plaintext columns. Distinct from @@ -905,19 +889,84 @@ export interface EncryptedQueryBuilderCore< csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self - /** Escape hatch: re-types the rows and drops back to the v2 builder surface. */ - returns>(): EncryptedQueryBuilder + /** Escape hatch: re-types the rows and drops back to the untyped v3 builder + * surface. */ + returns>(): EncryptedQueryBuilderUntyped /** Bind identity-aware encryption. Accepts either a plain * `{ identityClaim }` (the common form) or a `LockContext` instance. */ withLockContext(lockContext: LockContextInput): Self audit(config: AuditConfig): Self } -/** The v2 builder: free-text search via SQL wildcard matching. */ -export interface EncryptedQueryBuilder< - T extends Record = Record, - FK extends StringKeyOf = StringKeyOf, -> extends EncryptedQueryBuilderCore> { - like(column: K, pattern: string): EncryptedQueryBuilder - ilike(column: K, pattern: string): EncryptedQueryBuilder -} +// --------------------------------------------------------------------------- +// Deprecated `*V3` aliases (Decision 5 — supabase keeps type-identical aliases). +// The v3 names are now the unsuffixed canonical exports; these aliases keep +// existing `*V3` imports compiling. +// --------------------------------------------------------------------------- + +/** @deprecated Use {@link EncryptedSupabaseOptions}. */ +export type EncryptedSupabaseV3Options< + S extends V3Schemas | undefined = undefined, +> = EncryptedSupabaseOptions + +/** @deprecated Use {@link NonQueryableKeys}. */ +export type NonQueryableV3Keys
= + NonQueryableKeys
+ +/** @deprecated Use {@link FilterableKeys}. */ +export type V3FilterableKeys< + Table extends AnyV3Table, + Row extends Record, +> = FilterableKeys + +/** @deprecated Use {@link FreeTextSearchableKeys}. */ +export type V3FreeTextSearchableKeys< + Table extends AnyV3Table, + Row extends Record, +> = FreeTextSearchableKeys + +/** @deprecated Use {@link EncryptedFreeTextKeys}. */ +export type V3EncryptedFreeTextKeys< + Table extends AnyV3Table, + Row extends Record, +> = EncryptedFreeTextKeys + +/** @deprecated Use {@link SearchableJsonKeys}. */ +export type V3SearchableJsonKeys< + Table extends AnyV3Table, + Row extends Record, +> = SearchableJsonKeys + +/** @deprecated Use {@link NonOrderableKeys}. */ +export type NonOrderableV3Keys
= + NonOrderableKeys
+ +/** @deprecated Use {@link OrderableKeys}. */ +export type V3OrderableKeys< + Table extends AnyV3Table, + Row extends Record, +> = OrderableKeys + +/** @deprecated Use {@link PlaintextKeys}. */ +export type V3PlaintextKeys< + Table extends AnyV3Table, + Row extends Record, +> = PlaintextKeys + +/** @deprecated Use {@link EncryptedQueryBuilder}. */ +export type EncryptedQueryBuilderV3< + Table extends AnyV3Table, + Row extends Record, +> = EncryptedQueryBuilder + +/** @deprecated Use {@link EncryptedQueryBuilderUntyped}. */ +export type EncryptedQueryBuilderV3Untyped< + Row extends Record, +> = EncryptedQueryBuilderUntyped + +/** @deprecated Use {@link EncryptedSupabaseInstance}. */ +export type EncryptedSupabaseV3Instance = EncryptedSupabaseInstance + +/** @deprecated Use {@link TypedEncryptedSupabaseInstance}. */ +export type TypedEncryptedSupabaseV3Instance = + TypedEncryptedSupabaseInstance From 3bde0c17667acbf2e2bc907d1473ea2d0e40538c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 09/25] test(stack-supabase): update suites for folded v3 builder + de-suffix Point tests at the folded EncryptedQueryBuilderImpl, drop the removed v2 authoring tests (v2 live suite, v2 wire-encoding block, v2 builder type test), convert the shared execute() error-threading tests to a v3 table, and add canonical-name type assertions. --- .../supabase-encryption-error.test.ts | 37 +- .../__tests__/supabase-v3-builder.test.ts | 292 +-------------- .../__tests__/supabase-v3-factory.test.ts | 2 +- .../__tests__/supabase-v3-json.test.ts | 2 +- .../__tests__/supabase-v3-matrix.test.ts | 2 +- .../__tests__/supabase-v3-select-star.test.ts | 2 +- .../__tests__/supabase-v3-wire.test.ts | 2 +- .../__tests__/supabase-v3.test-d.ts | 45 ++- .../stack-supabase/__tests__/supabase.test.ts | 335 ------------------ .../integration/wire.integration.test.ts | 2 +- 10 files changed, 51 insertions(+), 670 deletions(-) delete mode 100644 packages/stack-supabase/__tests__/supabase.test.ts diff --git a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts index 5154b8c1e..b9acdd008 100644 --- a/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts +++ b/packages/stack-supabase/__tests__/supabase-encryption-error.test.ts @@ -1,13 +1,8 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { EncryptionErrorTypes } from '@cipherstash/stack/errors' -import { - encryptedColumn, - encryptedTable as encryptedTableV2, -} from '@cipherstash/stack/schema' import { describe, expect, it } from 'vitest' import { EncryptedQueryBuilderImpl } from '../src/query-builder' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' import { createMockEncryptionClient, createMockSupabase, @@ -18,18 +13,14 @@ import { /** * Regression coverage for #626: the query builder's catch block used to hardcode * `encryptionError: undefined`, so the typed `EncryptedSupabaseError.encryptionError` - * field was dead. The v2 tests pin that a genuine encryption failure now threads its - * `EncryptionError` through the shared base `execute()` catch, while a plain - * (non-encryption) throw leaves it unset. The v3 tests cover the dialect's own - * `encryptionFailure` path, which synthesizes an `EncryptionError` for its two - * query-term contract-violation cases (length mismatch, null envelope) that have - * no operation failure to wrap. + * field was dead. The `execute()` tests pin that a genuine encryption failure now + * threads its `EncryptionError` through the shared `execute()` catch, while a plain + * (non-encryption) throw leaves it unset. The `encryptionFailure` tests cover the + * dialect's own `encryptionFailure` path, which synthesizes an `EncryptionError` + * for its two query-term contract-violation cases (length mismatch, null envelope) + * that have no operation failure to wrap. */ -const usersV2 = encryptedTableV2('users', { - email: encryptedColumn('email').freeTextSearch().equality(), -}) - const usersV3 = encryptedTable('users', { email: types.TextEq('email'), }) @@ -48,7 +39,7 @@ function failingOperation(failure: { type: string; message: string }) { } describe('EncryptedSupabaseError.encryptionError (#626)', () => { - it('v2: threads the EncryptionError through on an encryption failure', async () => { + it('threads the EncryptionError through on an encryption failure', async () => { const failure = { type: EncryptionErrorTypes.EncryptionError, message: 'zerokms unreachable', @@ -62,7 +53,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { const { client: supabase } = createMockSupabase() const builder = new EncryptedQueryBuilderImpl( 'users', - usersV2, + usersV3, encryptionClient as unknown as EncryptionClient, supabase, ) @@ -77,7 +68,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { ) }) - it('v2: leaves encryptionError unset on a plain (non-encryption) error', async () => { + it('leaves encryptionError unset on a plain (non-encryption) error', async () => { const encryptionClient = createMockEncryptionClient() const { client: supabase } = createMockSupabase() // Make the underlying supabase call throw a non-encryption error: an insert @@ -88,7 +79,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { const builder = new EncryptedQueryBuilderImpl( 'users', - usersV2, + usersV3, encryptionClient, supabase, ) @@ -105,8 +96,8 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { // wrap for its contract-violation cases, so it synthesizes an EncryptionError. // Drive it directly: a two-element `in` list whose bulkEncrypt returns one // term trips the length-mismatch check. (The base `execute()` threading is - // already covered by the v2 test above; overriding `encryptModel` here would - // only re-run that shared path, not this v3-specific branch.) + // already covered by the `execute()` tests above; overriding `encryptModel` + // here would only re-run that shared path, not this v3-specific branch.) const encryptionClient = createMockEncryptionClient() as unknown as { bulkEncrypt: (...args: unknown[]) => unknown } @@ -114,7 +105,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { operation([{ data: fakeEnvelope('ada', 'email') }]) const { client: supabase } = createMockSupabase() - const { error, status } = await new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderImpl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, @@ -144,7 +135,7 @@ describe('EncryptedSupabaseError.encryptionError (#626)', () => { operation([{ data: null }, { data: fakeEnvelope('grace', 'email') }]) const { client: supabase } = createMockSupabase() - const { error, status } = await new EncryptedQueryBuilderV3Impl( + const { error, status } = await new EncryptedQueryBuilderImpl( 'users', usersV3, encryptionClient as unknown as EncryptionClient, diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index b510a9baa..92e4a6649 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -1,11 +1,6 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' -import { - encryptedColumn, - encryptedTable as encryptedTableV2, -} from '@cipherstash/stack/schema' import { describe, expect, it, vi } from 'vitest' -import { encryptedSupabase } from '../src/index.js' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, @@ -29,11 +24,6 @@ const users = encryptedTable('users', { bio: types.TextMatch('bio'), }) -const usersV2 = encryptedTableV2('users', { - email: encryptedColumn('email').freeTextSearch().equality(), - age: encryptedColumn('age').dataType('number').equality().orderAndRange(), -}) - // DB column names as introspection would report them (id/note are plaintext). const USERS_ALL_COLUMNS = [ 'id', @@ -1176,286 +1166,6 @@ describe('encryptedSupabaseV3 wire encoding', () => { }) }) -// --------------------------------------------------------------------------- -// v2 regression — the dialect seams must leave the v2 wire encoding untouched -// --------------------------------------------------------------------------- - -describe('encryptedSupabase (v2) wire encoding is unchanged by the dialect seams', () => { - function v2Instance(resultData: unknown = []) { - const supabase = createMockSupabase(resultData) - const es = encryptedSupabase({ - encryptionClient: createMockEncryptionClient(), - supabaseClient: supabase.client, - }) - return { es, supabase } - } - - it('wraps encrypted mutation values in the { data } composite shape', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).insert({ email: 'a@b.com', note: 'x' }) - - const [insert] = supabase.callsFor('insert') - const body = insert.args[0] as Record - expect(body.email).toHaveProperty('data') - expect(isFakeEnvelope((body.email as Record).data)).toBe( - true, - ) - expect(body.note).toBe('x') - }) - - it('encodes filter terms as composite literals via encryptQuery', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email').eq('email', 'a@b.com') - - const [eq] = supabase.callsFor('eq') - expect(eq.args).toEqual(['email', '("a@b.com")']) - }) - - it('keeps like on encrypted columns as like', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email').like('email', 'a@b') - - expect(supabase.callsFor('like')).toHaveLength(1) - expect(supabase.callsFor('filter')).toHaveLength(0) - }) - - it('adds plain ::jsonb casts without aliasing', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, email, age') - - const [select] = supabase.callsFor('select') - expect(select.args[0]).toBe('id, email::jsonb, age::jsonb') - }) - - it('passes order() column names through unchanged', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id, age').order('age') - - const [order] = supabase.callsFor('order') - expect(order.args[0]).toBe('age') - }) - - it('passes the onConflict option through by reference', async () => { - const { es, supabase } = v2Instance() - - const options = { onConflict: 'email' } - await es.from('users', usersV2).upsert({ email: 'a@b.com' }, options) - - const [upsert] = supabase.callsFor('upsert') - expect(upsert.args[1]).toBe(options) - }) - - it('passes an all-plaintext or() string through verbatim', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').or('id.eq.1,note.eq.x') - - const [or] = supabase.callsFor('or') - expect(or.args[0]).toBe('id.eq.1,note.eq.x') - }) - - // `contains` is not a PostgREST operator in EITHER dialect — the structured - // or() path emitted `note.contains.x` on v2 too, since the base builder never - // translated the token. Fixed in `rebuildOrString`, so both dialects inherit it. - it('translates a structured or() contains to cs', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .or([{ column: 'note', op: 'contains', value: 'x' }]) - - expect(supabase.callsFor('or')[0].args[0]).toBe('note.cs.x') - }) - - // ------------------------------------------------------------------------- - // Characterization tests for the paths `toDbSpace()` will rewrite. Each pins - // the correlation between the term collector (`encryptFilterValues`) and the - // applier (`applyFilters`), which agree only by array index / column name. - // ------------------------------------------------------------------------- - - it('match() encrypts encrypted keys and passes plaintext through', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .match({ email: 'a@b.com', note: 'plain' }) - - const [match] = supabase.callsFor('match') - const query = match.args[0] as Record - expect(query.email).toBe('("a@b.com")') - expect(query.note).toBe('plain') - // Key order survives the Record -> entries -> Record round-trip - expect(Object.keys(query)).toEqual(['email', 'note']) - }) - - it('not() encrypts on encrypted columns and passes plaintext through', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').not('email', 'eq', 'a@b.com') - await es.from('users', usersV2).select('id').not('note', 'eq', 'plain') - - const [encrypted, plain] = supabase.callsFor('not') - expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) - expect(plain.args).toEqual(['note', 'eq', 'plain']) - }) - - // The v2 composite literal `("a@b.com")` is itself quote-bearing, so it needs - // the same escaped operand as v3 — postgrest-js's `in()` would emit - // `in.("("a@b.com")")` and PostgREST would reject it. - it('in() encrypts each element and leaves plaintext arrays alone', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').in('email', ['a@b.com', 'c@d']) - await es.from('users', usersV2).select('id').in('note', ['x', 'y']) - - const [encrypted] = supabase.callsFor('filter') - expect(encrypted.args).toEqual([ - 'email', - 'in', - '("(\\"a@b.com\\")","(\\"c@d\\")")', - ]) - - const [plain] = supabase.callsFor('in') - expect(plain.args[1]).toEqual(['x', 'y']) - }) - - it('is() leaves the value untouched on an encrypted column', async () => { - const { es, supabase } = v2Instance() - - await es.from('users', usersV2).select('id').is('email', null) - - const [is] = supabase.callsFor('is') - expect(is.args).toEqual(['email', null]) - }) - - it('filter() encrypts the operand on an encrypted column', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .filter('email', 'eq', 'a@b.com') - await es.from('users', usersV2).select('id').filter('note', 'eq', 'plain') - - const [encrypted, plain] = supabase.callsFor('filter') - expect(encrypted.args).toEqual(['email', 'eq', '("a@b.com")']) - expect(plain.args).toEqual(['note', 'eq', 'plain']) - }) - - // The single most important characterization test: a strict nonempty SUBSET - // of the or-string's conditions is encrypted, so the condition index `j` must - // agree between the two `parseOrString` calls that `toDbSpace()` collapses - // into one. - it('or() rebuilds a mixed encrypted/plaintext string, keeping each condition on its own column', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id') - .or('email.eq.a@b.com,note.eq.x') - - const [or] = supabase.callsFor('or') - const emitted = or.args[0] as string - const [emailCond, noteCond] = emitted.split(',') - expect(emailCond).toContain('email.eq.') - expect(emailCond).toContain('a@b.com') - expect(noteCond).toBe('note.eq.x') - }) - - it('keeps every filter array correlated in a combined query', async () => { - const { es, supabase } = v2Instance() - - await es - .from('users', usersV2) - .select('id, email, age') - .eq('email', 'a@b.com') - .not('age', 'eq', 30) - .or('email.eq.c@d,note.eq.x') - .match({ email: 'e@f.com' }) - .filter('age', 'gte', 18) - .order('age') - .limit(10) - - expect(supabase.callsFor('eq')[0].args).toEqual(['email', '("a@b.com")']) - expect(supabase.callsFor('not')[0].args).toEqual(['age', 'eq', '("30")']) - expect(supabase.callsFor('or')[0].args[0]).toContain('note.eq.x') - expect( - (supabase.callsFor('match')[0].args[0] as Record).email, - ).toBe('("e@f.com")') - expect(supabase.callsFor('filter')[0].args).toEqual([ - 'age', - 'gte', - '("18")', - ]) - expect(supabase.callsFor('order')[0].args[0]).toBe('age') - expect(supabase.callsFor('limit')[0].args[0]).toBe(10) - }) - - // Released-side regressions. `is` is a SQL predicate — PostgREST accepts only - // null/true/false — and a null operand is SQL NULL, never a value to search - // for. Only the regular `.is()` filter skipped encryption; every other - // collector encrypted whatever it was handed, emitting operands PostgREST - // rejects. - describe('is / null operands are never encrypted', () => { - it('does not encrypt not(col, is, null)', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').not('age', 'is', null) - expect(supabase.callsFor('not')[0].args).toEqual(['age', 'is', null]) - }) - - it('does not encrypt a raw filter() is operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').filter('age', 'is', null) - expect(supabase.callsFor('filter')[0].args).toEqual(['age', 'is', null]) - }) - - it('forwards an or() is condition unencrypted', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').or('age.is.null') - expect(supabase.callsFor('or')[0].args[0]).toBe('age.is.null') - }) - - it('does not encrypt a null eq() operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').eq('email', null) - expect(supabase.callsFor('eq')[0].args).toEqual(['email', null]) - }) - - it('does not encrypt a null match() value', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').match({ email: null }) - expect(supabase.callsFor('match')[0].args[0]).toEqual({ email: null }) - }) - - it('does not encrypt null elements of an in() list', async () => { - const { es, supabase } = v2Instance() - await es - .from('users', usersV2) - .select('id') - .in('email', ['a@b.com', null]) - // `null` stays a bare PostgREST `null`, never a ciphertext. - expect(supabase.callsFor('filter')[0].args).toEqual([ - 'email', - 'in', - '("(\\"a@b.com\\")",null)', - ]) - }) - - it('treats is() as a predicate even with a non-null operand', async () => { - const { es, supabase } = v2Instance() - await es.from('users', usersV2).select('id').is('email', false) - expect(supabase.callsFor('is')[0].args).toEqual(['email', false]) - }) - }) -}) - // --------------------------------------------------------------------------- // Property → DB name collision // diff --git a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts index 622af6227..b9cebc982 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-factory.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SupabaseClientLike } from '../src/index.js' import { encryptedSupabaseV3 } from '../src/index.js' import type { IntrospectionData } from '../src/introspect' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' // --- Mocks ----------------------------------------------------------------- // diff --git a/packages/stack-supabase/__tests__/supabase-v3-json.test.ts b/packages/stack-supabase/__tests__/supabase-v3-json.test.ts index f829f0024..bbf747f57 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-json.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-json.test.ts @@ -12,7 +12,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, diff --git a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts index b4306a094..6f566dbb0 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-matrix.test.ts @@ -29,7 +29,7 @@ import { V3_MATRIX, } from '@cipherstash/test-kit/catalog' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createMockEncryptionClient, createMockSupabase, diff --git a/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts b/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts index 1fd76f220..2d7a09a3b 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-select-star.test.ts @@ -1,7 +1,7 @@ import type { EncryptionClient } from '@cipherstash/stack/encryption' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' /** * Supabase double that records the select string AND simulates the part of diff --git a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts index 61953f6a7..6b33b7c66 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-wire.test.ts @@ -10,7 +10,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { createWirePostgrest } from './helpers/postgrest-wire' import { createMockEncryptionClient } from './helpers/supabase-mock' diff --git a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts index 2bd1a5724..9d0717ea5 100644 --- a/packages/stack-supabase/__tests__/supabase-v3.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-v3.test-d.ts @@ -9,6 +9,7 @@ import { } from '@cipherstash/stack/schema' import { describe, expectTypeOf, it } from 'vitest' import { + type EncryptedQueryBuilder, type EncryptedQueryBuilderV3, type EncryptedSupabaseResponse, encryptedSupabase, @@ -335,21 +336,6 @@ describe('encryptedSupabaseV3 untyped surface (no schemas)', () => { builder.contains('tags', 'vip') }) - it('keeps like/ilike on the v2 builder', () => { - const v2Users = v2EncryptedTable('users', { - email: encryptedColumn('email').freeTextSearch(), - }) - const v2 = encryptedSupabase({ - encryptionClient: {} as never, - supabaseClient, - }) - const builder = v2.from<{ email: string }>('users', v2Users) - builder.like('email', '%ada%') - builder.ilike('email', '%ada%') - // @ts-expect-error — contains is the v3 dialect's method - builder.contains('email', 'ada') - }) - it('supports a no-arg select(), like supabase-js', async () => { const supabase = await encryptedSupabaseV3(supabaseClient) supabase.from('users').select() @@ -445,3 +431,32 @@ describe('withLockContext accepts the plain { identityClaim } form (not only Loc mixedBuilder.withLockContext({ claim: ['sub'] }) }) }) + +// --------------------------------------------------------------------------- +// De-suffixed canonical names (encryptedSupabase / EncryptedQueryBuilder) +// +// `encryptedSupabaseV3` and `EncryptedQueryBuilderV3` remain as type-identical +// `@deprecated` aliases (exercised throughout the tests above); these assert the +// unsuffixed names are the same surface. +// --------------------------------------------------------------------------- + +describe('canonical (unsuffixed) exports', () => { + it('encryptedSupabase is the same factory as encryptedSupabaseV3', () => { + expectTypeOf(encryptedSupabase).toEqualTypeOf(encryptedSupabaseV3) + }) + + it('EncryptedQueryBuilder is the same type as EncryptedQueryBuilderV3', () => { + expectTypeOf>().toEqualTypeOf< + EncryptedQueryBuilderV3 + >() + }) + + it('a typed builder narrows to the documented shape', async () => { + const supabase = await encryptedSupabase(supabaseClient, { + schemas: { users }, + }) + expectTypeOf(supabase.from('users')).toEqualTypeOf< + EncryptedQueryBuilder + >() + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase.test.ts b/packages/stack-supabase/__tests__/supabase.test.ts deleted file mode 100644 index 1023bc387..000000000 --- a/packages/stack-supabase/__tests__/supabase.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import 'dotenv/config' - -import { Encryption } from '@cipherstash/stack' -import { encryptedColumn, encryptedTable } from '@cipherstash/stack/schema' -import { createClient } from '@supabase/supabase-js' -import postgres from 'postgres' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { encryptedSupabase } from '../src/index.js' - -// supabase.test.ts needs a live Supabase project, so the suite is skipped -// when the Supabase environment is not configured (e.g. in CI, pending a -// containerised Supabase setup). It runs locally when SUPABASE_URL, -// SUPABASE_ANON_KEY, and DATABASE_URL are all set. -const SUPABASE_ENABLED = Boolean( - process.env.SUPABASE_URL && - process.env.SUPABASE_ANON_KEY && - process.env.DATABASE_URL, -) - -const supabase = SUPABASE_ENABLED - ? createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) - : (undefined as unknown as ReturnType) - -const table = encryptedTable('protect-ci', { - encrypted: encryptedColumn('encrypted').freeTextSearch().equality(), - age: encryptedColumn('age').dataType('number').equality(), - // orderAndRange backs the encrypted range test below — the "range filtering - // works on Supabase" claim needs a CI-covered v2 baseline, not just the - // one-off live spike (see the v3 suite's matching range test). - score: encryptedColumn('score').dataType('number').equality().orderAndRange(), -}) - -// Row type for the protect-ci table -type ProtectCiRow = { - id: number - encrypted: string - age: number - score: number - otherField: string - test_run_id: string -} - -// Unique identifier for this test run to isolate data from concurrent test runs -// This is stored in a dedicated test_run_id column to avoid polluting test data -const TEST_RUN_ID = `test-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - -// Track all inserted IDs for cleanup -const insertedIds: number[] = [] - -beforeAll(async () => { - // Idempotent fixture setup. The `protect-ci` table is shared across the - // drizzle + protect/supabase + stack/supabase integration suites; each - // suite's beforeAll runs the same CREATE TABLE so a fresh database is - // ready without manual DBA work. The schema is the union of every - // column those suites read or write. - const sql = postgres(process.env.DATABASE_URL as string, { prepare: false }) - try { - await sql` - CREATE TABLE IF NOT EXISTS "protect-ci" ( - id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - email eql_v2_encrypted, - age eql_v2_encrypted, - score eql_v2_encrypted, - profile eql_v2_encrypted, - encrypted eql_v2_encrypted, - "otherField" TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - test_run_id TEXT - ) - ` - // Backfill any column added after the table was first created on a - // long-lived CI database. CREATE TABLE IF NOT EXISTS is a no-op on - // those, so new columns need an explicit ADD COLUMN IF NOT EXISTS. - await sql` - ALTER TABLE "protect-ci" ADD COLUMN IF NOT EXISTS "otherField" TEXT - ` - // Tell PostgREST to refresh its schema cache so the supabase-js client - // can see a freshly created table without waiting for the polling - // interval. No-op on plain Postgres (no listener bound). - await sql`NOTIFY pgrst, 'reload schema'` - } finally { - await sql.end() - } - - // Clean up any data from this specific test run (safe for concurrent runs) - const { error } = await supabase - .from('protect-ci') - .delete() - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - console.warn(`[protect]: Failed to clean up test data: ${error.message}`) - } -}, 30000) - -afterAll(async () => { - // Clean up all data from this test run - if (insertedIds.length > 0) { - const { error } = await supabase - .from('protect-ci') - .delete() - .in('id', insertedIds) - if (error) { - console.error(`[protect]: Failed to clean up test data: ${error.message}`) - } - } -}, 30000) - -describe.skipIf(!SUPABASE_ENABLED)( - 'supabase (encryptedSupabase wrapper)', - () => { - it('should insert and select encrypted data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const plaintext = 'hello world' - - // Insert — auto-encrypts the `encrypted` column, auto-converts to PG composite - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert({ - encrypted: plaintext, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Select — auto-adds ::jsonb cast to `encrypted`, auto-decrypts result - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, encrypted') - .eq('id', insertedData![0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect(data![0].encrypted).toBe(plaintext) - }, 30000) - - it('should insert and select encrypted model data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const model = { - encrypted: 'hello world', - otherField: 'not encrypted', - } - - // Insert — auto-encrypts `encrypted`, passes `otherField` through - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert({ - ...model, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Select — auto-adds ::jsonb to `encrypted`, auto-decrypts - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, encrypted, otherField') - .eq('id', insertedData![0].id) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect({ - encrypted: data![0].encrypted, - otherField: data![0].otherField, - }).toEqual(model) - }, 30000) - - it('should insert and select bulk encrypted model data', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const models = [ - { - encrypted: 'hello world 1', - otherField: 'not encrypted 1', - }, - { - encrypted: 'hello world 2', - otherField: 'not encrypted 2', - }, - ] - - // Bulk insert — auto-encrypts all models - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert(models.map((m) => ({ ...m, test_run_id: TEST_RUN_ID }))) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData!.map((d) => d.id)) - - // Select — auto-decrypts all results - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, encrypted, otherField') - .in( - 'id', - insertedData!.map((d) => d.id), - ) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect( - data!.map((d) => ({ - encrypted: d.encrypted, - otherField: d.otherField, - })), - ).toEqual(models) - }, 30000) - - it('should insert and query encrypted number data with equality', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const testAge = 25 - const model = { - age: testAge, - otherField: 'not encrypted', - } - - // Insert — auto-encrypts `age` - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert({ - ...model, - test_run_id: TEST_RUN_ID, - }) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(insertedData![0].id) - - // Query by encrypted `age` — auto-encrypts the search term - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, age, otherField') - .eq('age', testAge) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - // Verify we found our specific row with encrypted age match - expect(data).toHaveLength(1) - expect(data![0].age).toBe(testAge) - }, 30000) - - // Encrypted RANGE filtering on Supabase previously had no CI coverage — - // the custom ORE operator's resolution over PostgREST rested on a one-off - // live spike. This is the v2 baseline the v3 suite's range test mirrors. - // Range needs real ORE terms (live ZeroKMS), same gating as the suite. - // Note: range FILTERING only — ORDER BY on an encrypted column is - // unsupported on Supabase (operator families need superuser). - it('should filter encrypted number data with a range (gte/lte)', async () => { - const protectClient = await Encryption({ schemas: [table] }) - const eSupabase = encryptedSupabase({ - encryptionClient: protectClient, - supabaseClient: supabase, - }) - - const models = [ - { score: 10, otherField: 'low' }, - { score: 25, otherField: 'mid' }, - { score: 90, otherField: 'high' }, - ] - - const { data: insertedData, error: insertError } = await eSupabase - .from('protect-ci', table) - .insert(models.map((m) => ({ ...m, test_run_id: TEST_RUN_ID }))) - .select('id') - - if (insertError) { - throw new Error(`[protect]: ${insertError.message}`) - } - - insertedIds.push(...insertedData!.map((d) => d.id)) - - const { data, error } = await eSupabase - .from('protect-ci', table) - .select('id, score, otherField') - .gte('score', 20) - .lte('score', 30) - .eq('test_run_id', TEST_RUN_ID) - - if (error) { - throw new Error(`[protect]: ${error.message}`) - } - - expect(data).toHaveLength(1) - expect(data![0].score).toBe(25) - expect(data![0].otherField).toBe('mid') - }, 30000) - }, -) diff --git a/packages/stack-supabase/integration/wire.integration.test.ts b/packages/stack-supabase/integration/wire.integration.test.ts index 808f69412..cd10218a0 100644 --- a/packages/stack-supabase/integration/wire.integration.test.ts +++ b/packages/stack-supabase/integration/wire.integration.test.ts @@ -33,7 +33,7 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { databaseUrl } from '@cipherstash/test-kit' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { EncryptedQueryBuilderV3Impl } from '../src/query-builder-v3' +import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' import { makePostgrestClient, reloadSchemaCache } from './helpers/pgrest' import { narrowedQueryTerm, storageEnvelope } from './helpers/v3-envelope' From b5cdedea4eedee8e134f9652588e48f71b208780 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:52:05 +1000 Subject: [PATCH 10/25] docs(stack-supabase): update stash-supabase skill + changesets for v2 removal --- .../remove-eql-v2-supabase-authoring.md | 39 +++++ .changeset/remove-eql-v2-supabase-skill.md | 11 ++ skills/stash-supabase/SKILL.md | 142 ++++++++---------- 3 files changed, 110 insertions(+), 82 deletions(-) create mode 100644 .changeset/remove-eql-v2-supabase-authoring.md create mode 100644 .changeset/remove-eql-v2-supabase-skill.md diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md new file mode 100644 index 000000000..50b5d6335 --- /dev/null +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -0,0 +1,39 @@ +--- +'@cipherstash/stack-supabase': major +--- + +Remove the EQL v2 authoring surface and de-suffix the v3 API to the canonical +unsuffixed names (part of the EQL v2 removal, #707). + +- **`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory** + (formerly `encryptedSupabaseV3`). `encryptedSupabaseV3` remains as a + type-identical `@deprecated` alias, so existing imports keep working. +- **The legacy v2 `encryptedSupabase({ encryptionClient, supabaseClient })` + wrapper is removed** — with it the two-argument `from(tableName, schema)` form + and the hand-written client-side v2 schema. Its `EncryptedSupabaseConfig` and + the v2 `EncryptedSupabaseInstance`/`EncryptedQueryBuilder` type shapes are gone; + the unsuffixed type names now denote the v3 surface. +- **The `*V3` type exports are de-suffixed** to their canonical names — + `EncryptedSupabaseV3Options` → `EncryptedSupabaseOptions`, + `EncryptedSupabaseV3Instance` → `EncryptedSupabaseInstance`, + `TypedEncryptedSupabaseV3Instance` → `TypedEncryptedSupabaseInstance`, + `EncryptedQueryBuilderV3` → `EncryptedQueryBuilder`, + `EncryptedQueryBuilderV3Untyped` → `EncryptedQueryBuilderUntyped`, + `V3FilterableKeys` → `FilterableKeys`, `V3OrderableKeys` → `OrderableKeys`, and + the rest of the `*V3` key-helper types. Each keeps a type-identical + `@deprecated` `*V3` alias. + +**Not affected: reading existing data.** Only the v2 *authoring/emission* surface +is removed. Decryption in `@cipherstash/stack` is generation-agnostic, so rows +written as EQL v2 payloads still decrypt through the wrapper's read path. + +Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base +`EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or +wire encoding changed. + +**Migration:** rename `encryptedSupabaseV3` → `encryptedSupabase` (or keep using +the alias). If you still use the v2 `encryptedSupabase({ encryptionClient, +supabaseClient }).from(table, schema)` wrapper, migrate the table to an +`eql_v3_*` column domain and switch to the introspecting factory — +`await encryptedSupabase(supabaseUrl, supabaseKey)` — see the `stash-supabase` +skill and https://cipherstash.com/docs. diff --git a/.changeset/remove-eql-v2-supabase-skill.md b/.changeset/remove-eql-v2-supabase-skill.md new file mode 100644 index 000000000..c4c33dedd --- /dev/null +++ b/.changeset/remove-eql-v2-supabase-skill.md @@ -0,0 +1,11 @@ +--- +'stash': patch +--- + +Update the bundled `stash-supabase` agent skill for the EQL v2 removal (#707): +`encryptedSupabase` is now the connect-time-introspecting EQL v3 factory (with +`encryptedSupabaseV3` kept as a `@deprecated` alias), and the legacy v2 +`encryptedSupabase({ encryptionClient, supabaseClient })` authoring wrapper has +been removed. The skill's examples, exported-type list, and migration/cutover +guidance are corrected accordingly. Skills ship inside the `stash` tarball, so +the stale v2 guidance would otherwise land in a user's project. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index a0ebe696b..b93d2dae0 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -1,18 +1,21 @@ --- name: stash-supabase -description: Integrate CipherStash encryption with Supabase using @cipherstash/stack-supabase. Covers the encryptedSupabaseV3 wrapper over native EQL v3 column domains, transparent encryption/decryption on insert/update/select, encrypted scalar filters (eq, gt/gte/lt/lte, in, or), ordering on encrypted columns, EQL 3.0.2 PostgREST query-domain limitations, identity-aware encryption, and the complete query builder API. Use when adding encryption to a Supabase project, querying encrypted columns, or building secure Supabase applications. +description: Integrate CipherStash encryption with Supabase using @cipherstash/stack-supabase. Covers the encryptedSupabase wrapper over native EQL v3 column domains, transparent encryption/decryption on insert/update/select, encrypted scalar filters (eq, gt/gte/lt/lte, in, or), ordering on encrypted columns, EQL 3.0.2 PostgREST query-domain limitations, identity-aware encryption, and the complete query builder API. Use when adding encryption to a Supabase project, querying encrypted columns, or building secure Supabase applications. --- # CipherStash Stack - Supabase Integration Guide for integrating CipherStash field-level encryption with Supabase using -the `encryptedSupabaseV3` wrapper over native EQL v3 column domains. The +the `encryptedSupabase` wrapper over native EQL v3 column domains. The wrapper provides transparent encryption on mutations and decryption on selects, with support for equality, range, and ordering. -A legacy EQL v2 wrapper (`encryptedSupabase`) still ships for existing -deployments — see "Legacy: EQL v2" at the end. New projects should use -`encryptedSupabaseV3`. +> **Naming note.** `encryptedSupabase` is the current EQL v3 factory. +> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias, so +> existing imports keep working — prefer `encryptedSupabase` in new code. The +> old EQL v2 authoring wrapper (`encryptedSupabase({ encryptionClient, +> supabaseClient }).from(table, schema)`) has been **removed** — see +> "Legacy: EQL v2" at the end. ## When to Use This Skill @@ -102,17 +105,17 @@ SQL-standard type names (`integer`, `smallint`, `real`, `double`, `boolean`, ### 3. Initialize the wrapper ```typescript -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" // Introspects the database via options.databaseUrl or DATABASE_URL -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey) -// or wrap an existing client: await encryptedSupabaseV3(supabaseClient, options) +const es = await encryptedSupabase(supabaseUrl, supabaseKey) +// or wrap an existing client: await encryptedSupabase(supabaseClient, options) await es.from("users").insert({ email: "a@b.com", amount: 30 }) await es.from("users").select("id, email, amount").eq("email", "a@b.com") ``` -`encryptedSupabaseV3` **introspects the database at connect time**: it +`encryptedSupabase` **introspects the database at connect time**: it detects EQL v3 columns by their Postgres domain, derives each column's encryption config from the domain, and builds the encryption client internally — there is no client-side schema to hand-maintain. Introspection @@ -133,7 +136,7 @@ tables against the database at construction: ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" const users = encryptedTable("users", { email: types.TextSearch("email"), // public.eql_v3_text_search — eq + range + free-text @@ -141,7 +144,7 @@ const users = encryptedTable("users", { joined: types.TimestampOrd("joined_at") // public.eql_v3_timestamp_ord — eq + range, decrypts to Date }) -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey, { +const es = await encryptedSupabase(supabaseUrl, supabaseKey, { schemas: { users }, }) @@ -411,7 +414,7 @@ third-party OIDC JWT (Clerk, Supabase, Auth0, ...) with ```typescript import { OidcFederationStrategy } from "@cipherstash/stack" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" const strategy = OidcFederationStrategy.create( process.env.CS_WORKSPACE_CRN!, @@ -419,7 +422,7 @@ const strategy = OidcFederationStrategy.create( ) if (strategy.failure) throw new Error(strategy.failure.error.message) -const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey, { +const es = await encryptedSupabase(supabaseUrl, supabaseKey, { config: { authStrategy: strategy.data }, }) ``` @@ -470,7 +473,7 @@ const { data, error } = await es ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" -import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" +import { encryptedSupabase } from "@cipherstash/stack-supabase" // Optional declared schema — compile-time types. Introspection alone // (no `schemas`) also works. @@ -479,7 +482,7 @@ const users = encryptedTable("users", { amount: types.IntegerOrd("amount"), }) -const es = await encryptedSupabaseV3( +const es = await encryptedSupabase( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!, { schemas: { users } }, // databaseUrl defaults to DATABASE_URL @@ -549,9 +552,15 @@ at all — only `.is(column, null)`. `@cipherstash/stack-supabase` also exports the following types: -- `EncryptedSupabaseV3Options`, `EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3Schemas` -- `SupabaseClientLike` -- `EncryptedSupabaseConfig`, `EncryptedSupabaseInstance`, `EncryptedQueryBuilder`, `PendingOrCondition` (legacy EQL v2) +- `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`, `TypedEncryptedSupabaseInstance`, `EncryptedQueryBuilder`, `EncryptedQueryBuilderUntyped`, `EncryptedQueryBuilderCore`, `V3Schemas` +- `FilterableKeys`, `FreeTextSearchableKeys` +- `EncryptedSupabaseResponse`, `EncryptedSupabaseError`, `PendingOrCondition`, `SupabaseClientLike` + +Each `*V3`-suffixed name from earlier releases (`EncryptedSupabaseV3Options`, +`EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, +`EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3FilterableKeys`, +`V3FreeTextSearchableKeys`) is still exported as a `@deprecated`, type-identical +alias of its unsuffixed counterpart above. ## Migrating an Existing Column to Encrypted @@ -604,7 +613,7 @@ ALTER TABLE users Apply with `supabase db reset` locally or `supabase migration up` against the remote project. -No client-side schema change is required — `encryptedSupabaseV3` introspects +No client-side schema change is required — `encryptedSupabase` introspects the new column's domain at the next client startup. If you use declared `schemas`, add the column so it is typed: @@ -633,7 +642,7 @@ Find **every** code path that writes to `users.email` and update it to also writ ```typescript // src/db/users.ts -import { es } from './clients' // encryptedSupabaseV3 instance +import { es } from './clients' // encryptedSupabase instance export async function insertUser(email: string) { return es.from('users').insert({ @@ -682,60 +691,20 @@ If something goes wrong (e.g. you discover the dual-write code wasn't actually l **EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps its own name — point your application at `email_encrypted` through the -`encryptedSupabaseV3` wrapper, deploy, verify reads decrypt correctly, then skip +`encryptedSupabase` wrapper, deploy, verify reads decrypt correctly, then skip ahead to the drop step. Running `stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -The rest of this subsection is the **EQL v2** path (an `eql_v2_encrypted` twin -queried through the legacy `encryptedSupabase` wrapper), kept for existing v2 -deployments. - -First, if you use declared `schemas`, update them to the post-cutover shape — the encrypted column will live under the original column name: - -```typescript -// src/encryption/schema.ts (post-cutover) -export const users = encryptedTable('users', { - email: types.TextSearch('email'), -}) -``` - -(Without declared schemas, introspection picks up the renamed column at the next client startup.) - -> **Known gap (EQL v2, SDK-only users):** `stash encrypt cutover` requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. EQL v3 columns never hit this — cut-over doesn't apply to them. -> -> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): -> -> ```bash -> stash db push -> # → writes the new config as `pending`. Active config (still pointing at -> # `email_encrypted`) keeps serving while we complete the cutover. -> ``` - -Now run the cutover: - -```bash -stash encrypt cutover --table users --column email -``` - -Inside one transaction it: (1) renames `email` → `email_plaintext` and `email_encrypted` → `email`, (2) promotes the pending EQL config to `active` (and the prior active to `inactive`), (3) records a `cut_over` event in `cs_migrations`. - -App code that does `select('email')` now returns ciphertext that must be decrypted via the `encryptedSupabaseV3` wrapper. **This is the moment that breaks read paths if they aren't going through the wrapper.** - -Update read paths to use the wrapper: - -```typescript -// Before -const { data } = await supabase.from('users').select('email').eq('id', id).single() - -// After — the wrapper decrypts transparently -const { data } = await es.from('users').select('email').eq('id', id).single() -``` - -For supported scalar queries that filter on `email`, the wrapper handles the -encrypted operators internally — calls such as `.eq()` and `.gte()` keep the -same shape, but values are encrypted before reaching the database. See -`## Query Filters` above for the EQL 3.0.2 PostgREST limitations. +> **EQL v2 cutover (`stash encrypt cutover`) via this SDK is no longer +> supported.** `@cipherstash/stack-supabase` authors and queries EQL v3 only — +> the v2 `encryptedSupabase({ encryptionClient, supabaseClient })` wrapper that +> read the post-cutover `eql_v2_encrypted` column has been removed. The CLI +> lifecycle commands still auto-detect a v2 column, but the SDK read path for one +> is gone. If you have an in-flight v2 cutover, either pin the last +> `@cipherstash/stack-supabase` release that shipped the v2 wrapper to finish it, +> or (recommended) create an `eql_v3_*` twin and run the v3 rollout above. New +> encryption should always target an `eql_v3_*` domain. #### Drop: remove the plaintext column @@ -764,15 +733,24 @@ All three are read-only. ## Legacy: EQL v2 -Earlier versions of this integration stored ciphertext in `jsonb` / -composite `eql_v2_encrypted` columns (enabled via `CREATE EXTENSION eql_v2` -or the v2 EQL bundle) and queried them through the `encryptedSupabase({ -supabaseClient, encryptionClient })` factory, which takes a hand-written -client-side schema and a two-argument `from(tableName, schema)`. That surface -still ships in `@cipherstash/stack-supabase` and is unchanged — keep using it -for existing v2 deployments — but it is not the recommended path for new -projects: use `encryptedSupabaseV3`. The CLI rollout tooling (`stash encrypt -backfill` / `cutover` / `drop`) supports both generations and auto-detects which -one a column uses, so a v2 twin is no longer needed to get CLI-managed -backfill — see the EQL version note in the migration section above. For the v2 -wrapper's full API and semantics, see the docs at https://cipherstash.com/docs. +Earlier versions of this integration stored ciphertext in composite +`eql_v2_encrypted` columns (enabled via `CREATE EXTENSION eql_v2` or the v2 EQL +bundle) and both wrote and read them through the `encryptedSupabase({ +supabaseClient, encryptionClient })` factory — a hand-written client-side schema +and a two-argument `from(tableName, schema)`. + +**That v2 wrapper has been removed.** `@cipherstash/stack-supabase` now authors +and queries EQL v3 only, via the introspecting `encryptedSupabase(url, key)` / +`encryptedSupabase(client, options)` factory described above. There is no longer +a code path in this package that emits or reads `eql_v2_encrypted` columns. + +Existing v2 deployments have two options: + +- **Migrate to EQL v3** (recommended): add an `eql_v3_*` twin column and run the + rollout in "Migrating an Existing Column to Encrypted" above. +- **Stay on v2 for now:** pin the last `@cipherstash/stack-supabase` release that + shipped the v2 wrapper. The CLI rollout tooling (`stash encrypt backfill` / + `cutover` / `drop`) still auto-detects a column's EQL generation. + +For the removed v2 wrapper's historical API and semantics, see the docs at +https://cipherstash.com/docs. From d6d8978659af922ff6d21dfd2b258e926ed3e167 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 11:23:20 +1000 Subject: [PATCH 11/25] 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 6e167d3504af70787e09e799ab1d5f5514599da8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 11:26:16 +1000 Subject: [PATCH 12/25] docs(stack-supabase): clarify v2 decrypt path in changeset v2 read via this adapter is intentionally removed; v2 ciphertext still decrypts through the core @cipherstash/stack client. Mixed-generation handling is customer-side (install both), per #707 out-of-scope stance. --- .changeset/remove-eql-v2-supabase-authoring.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.changeset/remove-eql-v2-supabase-authoring.md b/.changeset/remove-eql-v2-supabase-authoring.md index 50b5d6335..ad783dc52 100644 --- a/.changeset/remove-eql-v2-supabase-authoring.md +++ b/.changeset/remove-eql-v2-supabase-authoring.md @@ -23,9 +23,15 @@ unsuffixed names (part of the EQL v2 removal, #707). the rest of the `*V3` key-helper types. Each keeps a type-identical `@deprecated` `*V3` alias. -**Not affected: reading existing data.** Only the v2 *authoring/emission* surface -is removed. Decryption in `@cipherstash/stack` is generation-agnostic, so rows -written as EQL v2 payloads still decrypt through the wrapper's read path. +**Reading existing v2 data.** Only the v2 *authoring/emission* surface is removed +— no v2 ciphertext is stranded. Decryption in `@cipherstash/stack` is +generation-agnostic, so EQL v2 payloads still decrypt through the core client +(`decrypt` / `decryptModel`). This adapter, however, is now EQL v3 only and will +not auto-read an `eql_v2_encrypted` column: to read legacy v2 data during +migration, decrypt fetched rows with `@cipherstash/stack` directly, or run a +v2-configured setup alongside the v3 one and route per column. Mixed-generation +handling is a customer-side concern (install both and handle it explicitly), not +adapter auto-detection. Internally the v3 query builder (`query-builder-v3.ts`) was folded into the base `EncryptedQueryBuilderImpl`, which is now natively EQL v3; no runtime behaviour or From 6af89559c31724568f57673d895e689682df87bb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 11:58:31 +1000 Subject: [PATCH 13/25] refactor(stack-supabase): split query-builder monolith, ratchet FTA cap 91 -> 71 The EQL v2 removal folded `query-builder-v3.ts` into `query-builder.ts` (2b4e2e92). FTA penalises size superlinearly, so two files that each passed the complexity gate became one that did not: 90.88 + 73.95 -> 105.77, against a cap of 91. That failed the "Analyze v3 complexity" check on #769. `fta-v3.yml` is explicit that the cap is "a ratchet, not an aspiration", so decompose rather than raise it. The 2331-line class becomes a pipeline: column-map.ts ColumnMap - name + capability resolution, the concern every stage needed (was six correlated fields) query-encrypt.ts filter-operand terms: collect, validate, batch-encrypt query-mutation.ts row-data encryption for insert/update/upsert query-dbspace.ts property-space -> DB-space, once query-filters.ts operand substitution onto the PostgREST query query-results.ts decryption + Date reconstruction query-builder.ts the class: recorded state, fluent surface, execute() Worst file 105.77 -> 70.12 (query-encrypt.ts); cap lowered 91 -> 71. Also removed, all no-ops from the pre-fold v2/v3 inheritance: - `notFilterOperator` - an identity function with an unused parameter - `applyPatternFilter`'s dead `_wasEncrypted` parameter - `protected` throughout, now `private`; nothing extends the class and collapsed the six-times-repeated withLockContext/audit/await dance into one `withOpContext` helper - a skipped lock context would encrypt under the wrong data key, so the three steps must stay identical. `EncryptedQueryBuilderImpl` keeps its name, export site and constructor; nothing here is part of the package's public surface (index.ts does not re-export it). 466 tests pass unchanged. One test reached the unsupported- queryType backstop by subclassing to override `queryTypeForRawOp`, which is now a module function; `assertTermQueryable` is exported instead and the test calls it directly, dropping a subclass its own comment called "breaking the internal contract". Docs: correct comments asserting adapter-side EQL v2 reads. The adapter is v3 only and does not auto-read `eql_v2_encrypted` columns - introspection matches `public.eql_v3_*` domains exclusively, so such a column never enters the encrypt config. No ciphertext is stranded: core decryption stays generation-agnostic. Also retires the two-dialect scaffolding in types.ts, whose header claimed v3 does free-text via `contains` - contradicting the typed surface forty lines above it and re-introducing the exact confusion #617 removed. `EncryptedQueryBuilderCore`'s OK/BK defaults are kept: they are live for the untyped surface, only their v2-era rationale was wrong. --- .github/workflows/fta-v3.yml | 17 +- .../__tests__/supabase-v3-builder.test.ts | 54 +- packages/stack-supabase/package.json | 2 +- packages/stack-supabase/src/column-map.ts | 218 +++ packages/stack-supabase/src/helpers.ts | 2 +- packages/stack-supabase/src/index.ts | 13 +- packages/stack-supabase/src/query-builder.ts | 1716 ++--------------- packages/stack-supabase/src/query-dbspace.ts | 141 ++ packages/stack-supabase/src/query-encrypt.ts | 659 +++++++ packages/stack-supabase/src/query-filters.ts | 388 ++++ packages/stack-supabase/src/query-mutation.ts | 79 + packages/stack-supabase/src/query-results.ts | 205 ++ packages/stack-supabase/src/types.ts | 77 +- 13 files changed, 1915 insertions(+), 1656 deletions(-) create mode 100644 packages/stack-supabase/src/column-map.ts create mode 100644 packages/stack-supabase/src/query-dbspace.ts create mode 100644 packages/stack-supabase/src/query-encrypt.ts create mode 100644 packages/stack-supabase/src/query-filters.ts create mode 100644 packages/stack-supabase/src/query-mutation.ts create mode 100644 packages/stack-supabase/src/query-results.ts diff --git a/.github/workflows/fta-v3.yml b/.github/workflows/fta-v3.yml index 86f52b389..9ce0dfe2e 100644 --- a/.github/workflows/fta-v3.yml +++ b/.github/workflows/fta-v3.yml @@ -68,10 +68,19 @@ jobs: # blocking gate. No `continue-on-error`. # One step per package so a failure names the offending package. Each caps # at its current worst file (a ratchet, not an aspiration): stack src/eql/v3 - # at 69, and the split adapter packages at their monolith maxima — drizzle - # 89 (operators.ts), supabase 91 (query-builder.ts). Lower a cap whenever a - # file is refactored below the next threshold; the v2 query-builder/operators - # monoliths are the debt to whittle down toward stack's 69. + # at 69, drizzle 89 (operators.ts, still a monolith), and supabase 71 + # (query-encrypt.ts at 70.12, after the query-builder monolith was split + # across column-map / query-encrypt / query-mutation / query-dbspace / + # query-filters / query-results). Lower a cap whenever a file is refactored + # below the next threshold; drizzle's operators.ts is the remaining debt to + # whittle down toward stack's 69. + # + # NB supabase's top three now cluster tightly (query-encrypt 70.12, + # query-builder 70.05, helpers 69.13), so 71 is a ~0.9 margin. A cap set + # flush against its max is fragile — the 91 this replaced was 0.12 above + # its max and a single refactor blew straight through it. If a formatting + # reflow trips this step without a real complexity increase, re-measure + # (`npx fta src --format table`) before assuming the code got worse. - name: Analyze stack (eql/v3) complexity run: pnpm --filter @cipherstash/stack run analyze:complexity diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index 92e4a6649..60d26c8f8 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -1,6 +1,8 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { describe, expect, it, vi } from 'vitest' +import { ColumnMap } from '../src/column-map' import { EncryptedQueryBuilderImpl as EncryptedQueryBuilderV3Impl } from '../src/query-builder' +import { assertTermQueryable } from '../src/query-encrypt' import { createMockEncryptionClient, createMockSupabase, @@ -1475,37 +1477,33 @@ describe('v3 raw filter() resolves the query type from the operator', () => { ]) }) - // `encryptCollectedTerms` rejects any queryType outside the four supported EQL + // `assertTermQueryable` rejects any queryType outside the four supported EQL // v3 kinds. No public call path can produce a fifth — `mapFilterOpToQueryType`, // `queryTypeForRawOp` and `queryTypeForOrOp` are exhaustive — so this backstop - // is unreachable without breaking the internal contract, which is exactly what - // the subclass below does. Keep the guard: it is what a future producer - // gaining a new QueryTypeName would trip over. (`searchableJson` used to be - // the exemplar here until #650 made it a real, supported kind.) - it('rejects a query type outside the supported EQL v3 kinds', async () => { - const supabase = createMockSupabase() + // is only reachable by handing the resolver a term the internal contract says + // cannot exist. Keep the guard: it is what a future producer gaining a new + // QueryTypeName would trip over. (`searchableJson` used to be the exemplar + // here until #650 made it a real, supported kind.) + it('rejects a query type outside the supported EQL v3 kinds', () => { + const term = { + value: 'a@b.com', + column: users.columnBuilders.email, + table: users, + queryType: 'steVecSelector', + returnType: 'composite-literal', + } as unknown as Parameters[0] - class BogusQueryType extends EncryptedQueryBuilderV3Impl { - protected override queryTypeForRawOp(_operator: string) { - return 'steVecSelector' as never - } - } - - const builder = new BogusQueryType( - 'users', - users, - createMockEncryptionClient(), - supabase.client, - USERS_ALL_COLUMNS, - ) - - const { error, status } = await builder - .select('id') - .filter('email', 'eq', 'a@b.com') - - expect(status).toBe(500) - expect(error?.message).toContain('query type "steVecSelector"') - expect(error?.message).toContain('not supported on EQL v3 columns') + expect(() => + assertTermQueryable(term, { + tableName: 'users', + table: users, + encryptionClient: createMockEncryptionClient(), + lockContext: null, + auditConfig: null, + columns: new ColumnMap('users', users, USERS_ALL_COLUMNS), + queryDomainsRequired: false, + }), + ).toThrow(/query type "steVecSelector".*not supported on EQL v3 columns/s) }) }) diff --git a/packages/stack-supabase/package.json b/packages/stack-supabase/package.json index 4b0a5bc46..dbd36fe63 100644 --- a/packages/stack-supabase/package.json +++ b/packages/stack-supabase/package.json @@ -51,7 +51,7 @@ "test": "vitest run", "test:types": "vitest --run --typecheck.only", "test:integration": "vitest run --config integration/vitest.config.ts", - "analyze:complexity": "fta src --score-cap 91" + "analyze:complexity": "fta src --score-cap 71" }, "dependencies": { "@cipherstash/stack": "workspace:*" diff --git a/packages/stack-supabase/src/column-map.ts b/packages/stack-supabase/src/column-map.ts new file mode 100644 index 000000000..8920d87d1 --- /dev/null +++ b/packages/stack-supabase/src/column-map.ts @@ -0,0 +1,218 @@ +import { EncryptedV3Column } from '@cipherstash/stack/adapter-kit' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import type { ColumnSchema } from '@cipherstash/stack/schema' +import type { BuildableQueryColumn } from '@cipherstash/stack/types' +import type { DbName } from './types' + +/** + * The subset of a v3 column builder the dialect relies on. Structural rather + * than the concrete class union so the runtime `instanceof EncryptedV3Column` + * gate and this type stay independent. + */ +export type V3ColumnLike = { + getName(): string + getEqlType(): string + getQueryCapabilities(): { + equality: boolean + orderAndRange: boolean + freeTextSearch: boolean + /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ + searchableJson?: boolean + } + build(): ColumnSchema +} + +/** + * Reject a declared property name that is also a DIFFERENT physical column. + * + * `select('*')` expands the introspected DB names into property names, so a + * column renamed `created_at → createdAt` and a distinct plaintext column + * literally named `createdAt` both emit the token `createdAt`, which + * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST + * returns the encrypted column under that key and the plaintext one is never + * selected, silently yielding the wrong value for a field the row type + * guarantees. + * + * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s + * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to + * construct instead. + */ +function assertNoPropertyDbNameCollision( + tableName: string, + propToDb: Record, + allColumns: string[] | null, +): void { + if (!allColumns) return + const dbNames = new Set(allColumns) + + for (const [property, dbName] of Object.entries(propToDb)) { + if (property === dbName) continue + if (!dbNames.has(property)) continue + throw new Error( + `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, + ) + } +} + +/** + * Name and capability resolution for one table's columns. + * + * Every stage of the query pipeline addresses columns by BOTH the JS property + * name a caller wrote and the DB name PostgREST must see, and each needs to ask + * whether a given name is an encrypted v3 column and what it can be queried by. + * Concentrating that here means the encrypt, DB-space, filter-apply and decrypt + * modules take one collaborator instead of six correlated fields that must be + * kept in lockstep. + */ +export class ColumnMap { + /** JS property name → DB column name, for every encrypted column. */ + readonly propToDb: Record + /** Built column schemas keyed by DB column name (for `cast_as`, `indexes`). */ + readonly columnSchemas: Record + /** Every name an encrypted column answers to — property AND DB spelling. + * Filters and select strings address columns by both, so recognition must + * cover both. */ + readonly encryptedColumnNames: string[] + + /** DB column name → JS property name — the inverse of {@link propToDb}, used + * to expand `select('*')` back into property names. Null prototype: a DB + * column literally named `constructor` / `toString` would otherwise resolve + * to an inherited `Object.prototype` member and be emitted as a select token. */ + private readonly dbToProp: Record + /** Column builders keyed by BOTH property name and DB name. */ + private readonly v3Columns: Record + + constructor( + tableName: string, + table: AnyV3Table, + allColumns: string[] | null, + ) { + this.propToDb = table.buildColumnKeyMap() + this.columnSchemas = table.build().columns + + this.dbToProp = Object.create(null) as Record + for (const [property, dbName] of Object.entries(this.propToDb)) { + this.dbToProp[dbName] = property + } + + assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) + + // Null-prototype: keyed by DB column names, and `validateTransforms` reads + // it without an own-key guard — an inherited `constructor`/`toString` would + // otherwise resolve truthy for a plaintext column of that name. + this.v3Columns = Object.create(null) as Record + for (const [property, builder] of Object.entries(table.columnBuilders)) { + if (builder instanceof EncryptedV3Column) { + const col = builder as unknown as V3ColumnLike + this.v3Columns[property] = col + this.v3Columns[col.getName()] = col + } + } + + this.encryptedColumnNames = Object.keys(this.v3Columns) + } + + /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards + * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ + dbNameFor(name: string): string { + return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name + } + + /** + * Map a filter's column name to the DB column name PostgREST must see — + * resolving a JS property name to its DB name. + * + * This is the ONLY place a {@link DbName} is minted. The + * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column + * name reaching PostgREST must pass through here. + */ + filterColumnName(column: string): DbName { + return this.dbNameFor(column) as DbName + } + + /** + * Encrypted ordering columns sort by their `op` term, not by the envelope. + * + * `order=col->op` is the one ordering expression PostgREST can emit that + * reaches the OPE term. It must NOT leak into filters — those compare whole + * envelopes through the `eql_v3.*` operators — which is why this is its own + * seam rather than a change to {@link filterColumnName}. + * + * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns + * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native + * btree. PostgREST cannot call a function, so it orders the `op` term where it + * sits, inside the envelope. The two agree because the term is what + * `ord_term()` returns. + * + * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed + * value. Note this does NOT avoid the database collation: Postgres compares + * jsonb strings with `varstr_cmp` under the default collation, exactly as it + * does text. What makes the ordering collation-independent is the term itself + * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for + * `text_search`) — and every collation orders digits before letters and hex + * letters among themselves. `match-bloom`'s sibling assertion pins that shape. + * + * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE + * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column + * with no `ope` index it therefore returns a bare `dbName` here — a name that + * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but + * it never does: `validateTransforms` throws (with a domain-specific reason) + * before the query executes, so the bare name is only ever an intermediate + * value on a request that is about to be rejected. + */ + orderColumnName(column: string): DbName { + const dbName = this.dbNameFor(column) + const encrypted = this.v3Columns[column] + if (!encrypted) return dbName as DbName + + return ( + this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName + ) as DbName + } + + /** + * Expand the introspected column list (DB names) into JS property names. + * + * Load-bearing for `select('*')` on a DECLARED table that renames a column. + * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing + * that makes PostgREST return the column under its property name — when the + * token it sees is a property name. Feeding it the raw DB name instead takes + * the unaliased `dbNames.has(...)` branch, so the row comes back keyed + * `created_at` while the declared row type promises `createdAt`, silently + * yielding `undefined` for a field TypeScript guarantees. + * + * A DB column with no encrypted builder (plaintext passthrough, and every + * synthesized column, where property == DB name) maps to itself. + */ + expandAllColumns(columns: string[]): string[] { + return columns.map((dbName) => + Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, + ) + } + + /** True when `column` is one of this table's encrypted v3 columns. */ + isEncryptedV3Column(column: string): boolean { + return Boolean(this.v3Columns[column]) + } + + /** True when `column` is an encrypted `types.Json` document column. */ + isSearchableJsonColumn(column: string): boolean { + const builder: V3ColumnLike | undefined = this.v3Columns[column] + return Boolean(builder?.getQueryCapabilities().searchableJson) + } + + /** The encrypted builder for `column`, by either spelling. */ + encryptedColumn(column: string): V3ColumnLike | undefined { + return this.v3Columns[column] + } + + /** The built schema for a DB column name — `cast_as` and `indexes`. */ + schemaFor(dbName: string): ColumnSchema | undefined { + return this.columnSchemas[dbName] + } + + /** The encrypted builders as the term collector's column lookup. */ + queryColumnMap(): Record { + return this.v3Columns as unknown as Record + } +} diff --git a/packages/stack-supabase/src/helpers.ts b/packages/stack-supabase/src/helpers.ts index 7d2ae5fc6..f8e929cee 100644 --- a/packages/stack-supabase/src/helpers.ts +++ b/packages/stack-supabase/src/helpers.ts @@ -109,7 +109,7 @@ function lookupDbName( * property name. * - A DB column name used directly is cast in place (`db_name::jsonb`). * - Tokens that already carry a cast, or contain parens/dots (functions, - * foreign tables), are left untouched — same rules as the v2 helper. + * foreign tables), are left untouched. */ export function addJsonbCastsV3( columns: string, diff --git a/packages/stack-supabase/src/index.ts b/packages/stack-supabase/src/index.ts index b295a587a..4befe9726 100644 --- a/packages/stack-supabase/src/index.ts +++ b/packages/stack-supabase/src/index.ts @@ -48,10 +48,15 @@ function assertTableIsModelled( * compile-time types and verifies the declared tables against the database at * construction. * - * Encrypted data is stored as EQL v3 payloads. The generation-agnostic decrypt - * path in `@cipherstash/stack` still reads existing EQL v2 payloads, but this - * wrapper only AUTHORS EQL v3 — the legacy v2 authoring surface (a hand-written - * client-side schema and `from(tableName, schema)`) has been removed. + * Encrypted data is stored as EQL v3 payloads. This wrapper is EQL v3 only — it + * both authors and reads v3, and the legacy v2 authoring surface (a hand-written + * client-side schema and `from(tableName, schema)`) has been removed. It does + * not auto-read an `eql_v2_encrypted` column: introspection recognises the + * `public.eql_v3_*` domains exclusively, so a v2 column never enters the + * encrypt config and is returned as an untouched passthrough. No v2 ciphertext + * is stranded — decryption in `@cipherstash/stack` is generation-agnostic, so + * legacy payloads still decrypt through the core client (`decrypt` / + * `decryptModel`). Handle mixed-generation data explicitly on the caller side. * * Requires a Postgres connection (`options.databaseUrl` or `DATABASE_URL`) for * introspection, so it cannot run in a Worker or the browser. diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index f09314fd3..2b299965e 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -1,51 +1,34 @@ -import type { JsPlaintext } from '@cipherstash/protect-ffi' import type { AuditConfig } from '@cipherstash/stack/adapter-kit' import { - DATE_LIKE_CASTS, - EncryptedV3Column, logger, - matchNeedleError, parseSelectorSegments, reconstructSelectorDocument, unsupportedLeafReason, } from '@cipherstash/stack/adapter-kit' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { AnyV3Table } from '@cipherstash/stack/eql/v3' -import { - type EncryptionError, - EncryptionErrorTypes, -} from '@cipherstash/stack/errors' import type { LockContextInput } from '@cipherstash/stack/identity' -import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { - BuildableQueryColumn, - Encrypted, - EncryptedQueryResult, - QueryTypeName, - ScalarQueryTerm, -} from '@cipherstash/stack/types' +import { ColumnMap } from './column-map' +import { addJsonbCastsV3 } from './helpers' +import { toDbSpace } from './query-dbspace' +import { + assertJsonContainmentOperand, + assertPostgrestCanQueryEncryptedOperator, + type EncryptedFilterState, + type EncryptionContext, + EncryptionFailedError, + encryptFilterValues, +} from './query-encrypt' +import { applyFilters } from './query-filters' +import { encryptMutationData } from './query-mutation' import { - addJsonbCastsV3, - formatContainmentOperand, - formatInListOperand, - isEncryptableTerm, - isEncryptedColumn, - mapFilterOpToQueryType, - parseOrString, - rebuildOrString, - selectKeyToDbV3, -} from './helpers' + type DecryptContext, + decryptResults, + type RawSupabaseResult, +} from './query-results' import type { - DbConflictList, - DbFilterString, - DbMutationOp, - DbMutationOptions, - DbName, - DbPendingOrCondition, - DbPendingOrFilter, DbQuerySpace, DbSelect, - DbTransformOp, EncryptedSupabaseError, EncryptedSupabaseResponse, FilterOp, @@ -56,109 +39,17 @@ import type { PendingOrCondition, PendingOrFilter, PendingRawFilter, + RecordedOps, ResultMode, SupabaseClientLike, SupabaseQueryBuilder, TransformOp, } from './types' -/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 - * client's decrypt-model path (see `encryption/v3.ts`). */ -const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) - -/** - * The subset of a v3 column builder the dialect relies on. Structural rather - * than the concrete class union so the runtime `instanceof EncryptedV3Column` - * gate and this type stay independent. - */ -type V3ColumnLike = { - getName(): string - getEqlType(): string - getQueryCapabilities(): { - equality: boolean - orderAndRange: boolean - freeTextSearch: boolean - /** Optional: only `public.eql_v3_json_search` (`types.Json`) carries it. */ - searchableJson?: boolean - } - build(): ColumnSchema -} - -/** - * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a - * non-empty array. Everything else is rejected with an actionable steer: - * - * - Scalars/strings: the caller meant free-text (`matches` on a text column) or - * a selector — a raw JSON string is NOT parsed, by design (parsing would make - * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). - * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- - * serialize to scalars or `{}` — not the sub-document the caller believes. - * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), - * so an accidentally-empty needle would silently return (and decrypt) the - * whole table. The Drizzle adapter rejects the same needle for the same - * reason — the two first-party adapters must agree that this is an error. - */ -function assertJsonContainmentOperand(column: string, value: unknown): void { - const isPlainObject = - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || - Object.getPrototypeOf(value) === null) - if (!isPlainObject && !Array.isArray(value)) { - // Array.isArray is false on this branch by construction, so the label only - // distinguishes null / non-plain object / scalar. - const got = - value === null - ? 'null' - : typeof value === 'object' - ? (value as object).constructor?.name || 'a non-plain object' - : typeof value - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, - ) - } - const empty = Array.isArray(value) - ? value.length === 0 - : Object.keys(value as object).length === 0 - if (empty) { - throw new Error( - `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, - ) - } -} +export { EncryptionFailedError } from './query-encrypt' -/** - * Reject a declared property name that is also a DIFFERENT physical column. - * - * `select('*')` expands the introspected DB names into property names, so a - * column renamed `created_at → createdAt` and a distinct plaintext column - * literally named `createdAt` both emit the token `createdAt`, which - * `addJsonbCastsV3` turns into `createdAt:created_at::jsonb` — twice. PostgREST - * returns the encrypted column under that key and the plaintext one is never - * selected, silently yielding the wrong value for a field the row type - * guarantees. - * - * Nothing downstream can disambiguate the two, and `EncryptedTable.build()`'s - * duplicate check only fires when two BUILDERS share a `getName()`. Refuse to - * construct instead. - */ -function assertNoPropertyDbNameCollision( - tableName: string, - propToDb: Record, - allColumns: string[] | null, -): void { - if (!allColumns) return - const dbNames = new Set(allColumns) - - for (const [property, dbName] of Object.entries(propToDb)) { - if (property === dbName) continue - if (!dbNames.has(property)) continue - throw new Error( - `[supabase v3]: property "${property}" on table "${tableName}" renames DB column "${dbName}", but "${property}" is also a distinct column in the database — the two collide in select('*'). Rename the property, or drop the declared rename.`, - ) - } -} +/** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ +const warnedLikeDelegation = new Set() /** * A deferred query builder that wraps Supabase's query builder to automatically @@ -184,55 +75,51 @@ function assertNoPropertyDbNameCollision( * enters a GET URL. * * Decrypted rows additionally get `Date` reconstruction from the encrypt-config - * `cast_as`, mirroring the typed v3 client. `decryptModel`/`bulkDecryptModels` - * are generation-agnostic in `@cipherstash/stack`, so a stored EQL v2 payload - * still decrypts through this builder's read path. + * `cast_as`, mirroring the typed v3 client. This builder authors and reads EQL + * v3 only: legacy `eql_v2_encrypted` columns are not recognised by introspection, + * so they never enter the encrypt config and are returned as untouched + * passthroughs. Decrypt v2 data with the core `@cipherstash/stack` client. + * + * The pipeline is split across sibling modules — `./column-map` (name and + * capability resolution), `./query-encrypt` (mutation data and filter terms), + * `./query-dbspace` (property → DB space), `./query-filters` (operand + * substitution), `./query-results` (decryption) — and orchestrated by + * {@link execute} below. */ export class EncryptedQueryBuilderImpl< T extends Record = Record, > { - protected tableName: string - protected table: AnyV3Table - protected encryptionClient: EncryptionClient - protected supabaseClient: SupabaseClientLike - protected encryptedColumnNames: string[] + private tableName: string + private table: AnyV3Table + private encryptionClient: EncryptionClient + private supabaseClient: SupabaseClientLike + /** Name and capability resolution for this table's columns. */ + private columns: ColumnMap /** All column names for the table (encrypted + plaintext), in ordinal order, * used to expand `select('*')`. `null` when the caller supplied no column * list (a v3 client that could not introspect). */ - protected allColumns: string[] | null = null - - /** JS property name → DB column name, for every encrypted column. */ - private propToDb: Record - /** DB column name → JS property name — the inverse of {@link propToDb}, used - * to expand `select('*')` back into property names. Null prototype: a DB - * column literally named `constructor` / `toString` would otherwise resolve - * to an inherited `Object.prototype` member and be emitted as a select token. */ - private dbToProp: Record - /** Built column schemas keyed by DB column name (for `cast_as`). */ - private columnSchemas: Record - /** Column builders keyed by BOTH property name and DB name. */ - private v3Columns: Record + private allColumns: string[] | null = null /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ private queryDomainsRequired: boolean // Recorded operations - protected mutation: MutationOp | null = null - protected selectColumns: string | null = null - protected selectOptions: + private mutation: MutationOp | null = null + private selectColumns: string | null = null + private selectOptions: | { head?: boolean; count?: 'exact' | 'planned' | 'estimated' } | undefined = undefined - protected filters: PendingFilter[] = [] - protected orFilters: PendingOrFilter[] = [] - protected matchFilters: PendingMatchFilter[] = [] - protected notFilters: PendingNotFilter[] = [] - protected rawFilters: PendingRawFilter[] = [] - protected transforms: TransformOp[] = [] - protected resultMode: ResultMode = 'array' - protected shouldThrowOnError = false + private filters: PendingFilter[] = [] + private orFilters: PendingOrFilter[] = [] + private matchFilters: PendingMatchFilter[] = [] + private notFilters: PendingNotFilter[] = [] + private rawFilters: PendingRawFilter[] = [] + private transforms: TransformOp[] = [] + private resultMode: ResultMode = 'array' + private shouldThrowOnError = false // Encryption-specific state - protected lockContext: LockContextInput | null = null - protected auditConfig: AuditConfig | null = null + private lockContext: LockContextInput | null = null + private auditConfig: AuditConfig | null = null constructor( tableName: string, @@ -248,31 +135,7 @@ export class EncryptedQueryBuilderImpl< this.supabaseClient = supabaseClient this.allColumns = allColumns this.queryDomainsRequired = queryDomainsRequired - this.propToDb = table.buildColumnKeyMap() - this.columnSchemas = table.build().columns - - this.dbToProp = Object.create(null) as Record - for (const [property, dbName] of Object.entries(this.propToDb)) { - this.dbToProp[dbName] = property - } - - assertNoPropertyDbNameCollision(tableName, this.propToDb, allColumns) - - // Null-prototype: keyed by DB column names, and `validateTransforms` reads - // it without an own-key guard — an inherited `constructor`/`toString` would - // otherwise resolve truthy for a plaintext column of that name. - this.v3Columns = Object.create(null) as Record - for (const [property, builder] of Object.entries(table.columnBuilders)) { - if (builder instanceof EncryptedV3Column) { - const col = builder as unknown as V3ColumnLike - this.v3Columns[property] = col - this.v3Columns[col.getName()] = col - } - } - - // Filters and select strings address columns by JS property name AND by DB - // name, so recognition must cover both. - this.encryptedColumnNames = Object.keys(this.v3Columns) + this.columns = new ColumnMap(tableName, table, allColumns) } // --------------------------------------------------------------------------- @@ -289,7 +152,9 @@ export class EncryptedQueryBuilderImpl< "encryptedSupabase does not support select('*'). Please list columns explicitly so that encrypted columns can be cast with ::jsonb.", ) } - this.selectColumns = this.expandAllColumns(this.allColumns).join(', ') + this.selectColumns = this.columns + .expandAllColumns(this.allColumns) + .join(', ') } else { this.selectColumns = columns } @@ -297,26 +162,6 @@ export class EncryptedQueryBuilderImpl< return this } - /** - * Expand the introspected column list (DB names) into JS property names. - * - * Load-bearing for `select('*')` on a DECLARED table that renames a column. - * `addJsonbCastsV3` only emits the `prop:db_name::jsonb` alias — the thing - * that makes PostgREST return the column under its property name — when the - * token it sees is a property name. Feeding it the raw DB name instead takes - * the unaliased `dbNames.has(...)` branch, so the row comes back keyed - * `created_at` while the declared row type promises `createdAt`, silently - * yielding `undefined` for a field TypeScript guarantees. - * - * A DB column with no encrypted builder (plaintext passthrough, and every - * synthesized column, where property == DB name) maps to itself. - */ - protected expandAllColumns(columns: string[]): string[] { - return columns.map((dbName) => - Object.hasOwn(this.dbToProp, dbName) ? this.dbToProp[dbName] : dbName, - ) - } - insert( data: Partial | Partial[], options?: { @@ -411,7 +256,7 @@ export class EncryptedQueryBuilderImpl< * SQL LIKE. */ like(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) { + if (!this.columns.isEncryptedV3Column(column)) { this.filters.push({ op: 'like', column, value: pattern }) return this } @@ -419,7 +264,7 @@ export class EncryptedQueryBuilderImpl< } ilike(column: string, pattern: string): this { - if (!this.isEncryptedV3Column(column)) { + if (!this.columns.isEncryptedV3Column(column)) { this.filters.push({ op: 'ilike', column, value: pattern }) return this } @@ -435,15 +280,15 @@ export class EncryptedQueryBuilderImpl< * than silently emit a bloom match under a name that promises exactness. */ contains(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { - this.assertPostgrestCanQueryEncryptedOperator('contains', column) + if (this.columns.isSearchableJsonColumn(column)) { + this.assertPostgrestCanQueryEncrypted('contains', column) // Same validator the term resolver enforces — failing here just surfaces // the error at the call site instead of at execution. assertJsonContainmentOperand(column, value) this.filters.push({ op: 'contains', column, value }) return this } - if (this.isEncryptedV3Column(column)) { + if (this.columns.isEncryptedV3Column(column)) { throw new Error( `[supabase v3]: contains() is native (exact) containment and does not apply to encrypted column "${column}". Use matches() for encrypted free-text search.`, ) @@ -463,17 +308,17 @@ export class EncryptedQueryBuilderImpl< * silently accept as containment of the raw string. */ matches(column: string, value: unknown): this { - if (this.isSearchableJsonColumn(column)) { + if (this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: matches() is encrypted free-text search and does not apply to encrypted JSON column "${column}". Use contains("${column}", subDocument) or selectorEq("${column}", path, value).`, ) } - if (!this.isEncryptedV3Column(column)) { + if (!this.columns.isEncryptedV3Column(column)) { throw new Error( `[supabase v3]: matches() is encrypted free-text search and requires an encrypted column; "${column}" is not one. Use contains() for native containment.`, ) } - this.assertPostgrestCanQueryEncryptedOperator('matches', column) + this.assertPostgrestCanQueryEncrypted('matches', column) this.filters.push({ op: 'matches', column, value }) return this } @@ -508,8 +353,8 @@ export class EncryptedQueryBuilderImpl< not(column: string, operator: string, value: unknown): this { if ( operator === 'contains' && - this.isEncryptedV3Column(column) && - !this.isSearchableJsonColumn(column) + this.columns.isEncryptedV3Column(column) && + !this.columns.isSearchableJsonColumn(column) ) { throw new Error( `[supabase v3]: not("${column}", 'contains', …) does not apply to encrypted column "${column}" — that is fuzzy free-text matching, not containment. Use not("${column}", 'matches', …) or the raw 'cs' operator.`, @@ -518,7 +363,7 @@ export class EncryptedQueryBuilderImpl< // Mirror of the matches() guard: a `matches` spelling on a JSON column // would otherwise resolve to containment (the two share the `cs` wire op), // silently negating an EXACT operation under a name that promises FUZZY. - if (operator === 'matches' && this.isSearchableJsonColumn(column)) { + if (operator === 'matches' && this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: not("${column}", 'matches', …) does not apply to encrypted JSON column "${column}" — matches() is free-text search. Use not("${column}", 'contains', subDocument) or selectorNe("${column}", path, value).`, ) @@ -562,7 +407,7 @@ export class EncryptedQueryBuilderImpl< * `ops.selector()` supports it today. */ selectorEq(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorEq', column) + this.assertPostgrestCanQueryEncrypted('selectorEq', column) const needle = this.selectorNeedle('selectorEq', column, path, value) return this.contains(column, needle) } @@ -578,7 +423,7 @@ export class EncryptedQueryBuilderImpl< * operand is encrypted through the normal or-condition term path. */ selectorNe(column: string, path: string, value: unknown): this { - this.assertPostgrestCanQueryEncryptedOperator('selectorNe', column) + this.assertPostgrestCanQueryEncrypted('selectorNe', column) const needle = this.selectorNeedle('selectorNe', column, path, value) return this.or([ { column, op: 'is', value: null }, @@ -686,21 +531,23 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - protected async execute(): Promise> { + private async execute(): Promise> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) + const ctx = this.encryptionContext() + // 1. Encrypt mutation data - const encryptedMutation = await this.encryptMutationData() + const encryptedMutation = await encryptMutationData(this.mutation, ctx) // 2. Build select string with ::jsonb casts const selectString = this.buildSelectString() // 3. Translate every recorded column name into DB-space, once. - const dbSpace = this.toDbSpace() + const dbSpace = toDbSpace(this.recordedOps(), this.columns) // 4. Batch-encrypt filter values - const encryptedFilters = await this.encryptFilterValues(dbSpace) + const encryptedFilters = await encryptFilterValues(dbSpace, ctx) // 5. Build and execute real Supabase query const result = await this.buildAndExecuteQuery( @@ -711,7 +558,12 @@ export class EncryptedQueryBuilderImpl< ) // 6. Decrypt results - return await this.decryptResults(result) + return await decryptResults(result, { + ...ctx, + selectColumns: this.selectColumns, + resultMode: this.resultMode, + hasMutation: this.mutation !== null, + } satisfies DecryptContext) } catch (err) { const message = err instanceof Error ? err.message : String(err) logger.error( @@ -745,676 +597,47 @@ export class EncryptedQueryBuilderImpl< } } - // --------------------------------------------------------------------------- - // Step 1: Encrypt mutation data - // --------------------------------------------------------------------------- - - protected async encryptMutationData(): Promise< - Record | Record[] | null - > { - if (!this.mutation) return null - - if (this.mutation.kind === 'delete') return null - - const data = this.mutation.data - - if (Array.isArray(data)) { - // Bulk encrypt - const baseOp = this.encryptionClient.bulkEncryptModels(data, this.table) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt models for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt models: ${result.failure.message}`, - result.failure, - ) - } - - return this.transformEncryptedMutationModels(result.data) - } - - // Single model - const baseOp = this.encryptionClient.encryptModel(data, this.table) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - logger.error( - `Supabase: failed to encrypt model for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to encrypt model: ${result.failure.message}`, - result.failure, - ) + /** The shared slice of builder state every encrypt/decrypt step needs. Built + * per `execute()`, so `lockContext`/`auditConfig` are read at execution time. */ + private encryptionContext(): EncryptionContext { + return { + tableName: this.tableName, + table: this.table, + encryptionClient: this.encryptionClient, + lockContext: this.lockContext, + auditConfig: this.auditConfig, + columns: this.columns, + queryDomainsRequired: this.queryDomainsRequired, } - - return this.transformEncryptedMutationModel(result.data) } - /** - * Encode an encrypted model for the Supabase request body. The native - * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent - * (keyed by DB column name). - */ - protected transformEncryptedMutationModel( - model: Record, - ): Record { - const out: Record = Object.create(null) - for (const [key, value] of Object.entries(model)) { - out[this.dbNameFor(key)] = value + /** The recorded query in property space, for `toDbSpace`. */ + private recordedOps(): RecordedOps { + return { + filters: this.filters, + matchFilters: this.matchFilters, + notFilters: this.notFilters, + rawFilters: this.rawFilters, + orFilters: this.orFilters, + transforms: this.transforms, + mutation: this.mutation, } - return out - } - - protected transformEncryptedMutationModels( - models: Record[], - ): Record[] { - return models.map((model) => this.transformEncryptedMutationModel(model)) } // --------------------------------------------------------------------------- // Step 2: Build select string with casts // --------------------------------------------------------------------------- - protected buildSelectString(): DbSelect | null { + private buildSelectString(): DbSelect | null { if (this.selectColumns === null) return null - return addJsonbCastsV3(this.selectColumns, this.propToDb) - } - - // --------------------------------------------------------------------------- - // Step 3: Encrypt filter values - // --------------------------------------------------------------------------- - - protected async encryptFilterValues( - dbSpace: DbQuerySpace, - ): Promise { - // Collect all terms that need encryption - const terms: ScalarQueryTerm[] = [] - const termMap: TermMapping[] = [] - - const tableColumns = this.getColumnMap() - - const pushTerm = ( - value: JsPlaintext, - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mapping: TermMapping, - ) => { - terms.push({ - value, - column, - table: this.table, - queryType, - returnType: 'composite-literal', - }) - termMap.push(mapping) - } - - /** - * Collect one term per element of an `in`-list operand. - * - * Element-wise is the only correct encoding: encrypting the array as ONE - * value collapses `(a,b)` into a single ciphertext that matches nothing. A - * null element is SQL NULL and passes through unencrypted; the applier - * restores it by index, which is why the mapping carries `inIndex`. - * - * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They - * drifted apart once already — the `not` path went unfixed while the other - * two encrypted element-wise — so they are kept in lockstep here rather than - * spelled out three times. - */ - const collectInListTerms = ( - op: FilterOp, - values: readonly unknown[], - column: ScalarQueryTerm['column'], - queryType: QueryTypeName, - mappingFor: (inIndex: number) => TermMapping, - ) => { - for (let j = 0; j < values.length; j++) { - if (!isEncryptableTerm(op, values[j])) continue - pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) - } - } - - // Regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - if (!isEncryptedColumn(f.column, this.encryptedColumnNames)) continue - - const column = tableColumns[f.column] - if (!column) continue - - if (f.op === 'in' && Array.isArray(f.value)) { - collectInListTerms( - f.op, - f.value, - column, - mapFilterOpToQueryType(f.op), - (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), - ) - } else if (!isEncryptableTerm(f.op, f.value)) { - // `is` predicate or null operand — forwarded unencrypted. - } else { - pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { - source: 'filter', - filterIndex: i, - }) - } - } - - // Match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - for (const { column: colName, value } of mf.entries) { - if (!isEncryptedColumn(colName, this.encryptedColumnNames)) continue - // `match` carries no operator; equality is implied. - if (!isEncryptableTerm('eq', value)) continue - const column = tableColumns[colName] - if (!column) continue - - pushTerm(value as JsPlaintext, column, 'equality', { - source: 'match', - matchIndex: i, - column: colName, - }) - } - } - - // Not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - if (!isEncryptedColumn(nf.column, this.encryptedColumnNames)) continue - if (!isEncryptableTerm(nf.op, nf.value)) continue - const column = tableColumns[nf.column] - if (!column) continue - - if (nf.op === 'in') { - // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, - // and encrypting it whole matches nothing. Refuse it rather than emit a - // filter that silently returns no rows. - if (!Array.isArray(nf.value)) { - throw new Error( - `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms( - nf.op, - nf.value, - column, - mapFilterOpToQueryType(nf.op), - (inIndex) => ({ source: 'not', notIndex: i, inIndex }), - ) - continue - } - - pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { - source: 'not', - notIndex: i, - }) - } - - // Or filters — conditions were parsed once, in `toDbSpace`. The string and - // structured forms differ only in their `source` tag; the encryption rules, - // including the `in`-list split below, are identical. - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - const source = of_.kind === 'string' ? 'or-string' : 'or-structured' - - for (let j = 0; j < of_.conditions.length; j++) { - const cond = of_.conditions[j] - if (!isEncryptedColumn(cond.column, this.encryptedColumnNames)) continue - const column = tableColumns[cond.column] - if (!column) continue - - // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may - // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. - const queryType = this.queryTypeForOrOp(cond.op) - const mappingFor = (inIndex?: number): TermMapping => ({ - source, - orIndex: i, - conditionIndex: j, - inIndex, - }) - - if (cond.op === 'in' && Array.isArray(cond.value)) { - collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) - continue - } - - if (!isEncryptableTerm(cond.op, cond.value)) continue - pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) - } - } - - // Raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - if (!isEncryptedColumn(rf.column, this.encryptedColumnNames)) continue - const column = tableColumns[rf.column] - if (!column) continue - - if (rf.operator === 'in') { - // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal - // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it - // whole matches nothing. Refuse it rather than emit a filter that - // silently returns no rows. - if (!Array.isArray(rf.value)) { - throw new Error( - `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + - `not a PostgREST list literal — each element must be encrypted separately`, - ) - } - collectInListTerms( - 'in', - rf.value, - column, - this.queryTypeForRawOp(rf.operator), - (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), - ) - continue - } - - if (!isEncryptableTerm(rf.operator, rf.value)) continue - - pushTerm( - rf.value as JsPlaintext, - column, - this.queryTypeForRawOp(rf.operator), - { source: 'raw', rawIndex: i }, - ) - } - - if (terms.length === 0) { - return { encryptedValues: [], termMap: [] } - } - - const encryptedValues = await this.encryptCollectedTerms(terms) - return { encryptedValues, termMap } - } - - /** - * Encrypt every filter operand as a full storage envelope, serialized to jsonb - * text for the PostgREST filter value. - * - * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. - * `in(col, [a, b, c])` collects one term per element (the list must never be - * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips - * where one would do. `bulkEncrypt` carries a single `{table, column}` for the - * whole payload, so the grouping is mandatory, not an optimisation: one bulk - * call over a mixed-column term array would stamp one column onto every - * plaintext. Results are scattered back onto the terms' original indices, - * which is the contract `termMap` downstream relies on. - * - * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching - * contract, same length assertion, same fallback. Kept separate because that - * one encrypts a single-column operand list and returns `SQL[]`, while this - * must group a multi-column term array and preserve positions. - */ - protected async encryptCollectedTerms( - terms: ScalarQueryTerm[], - ): Promise { - const groups = new Map< - V3ColumnLike, - { indices: number[]; values: ScalarQueryTerm['value'][] } - >() - terms.forEach((term, index) => { - const column = this.assertTermQueryable(term) - const group = groups.get(column) ?? { indices: [], values: [] } - group.indices.push(index) - group.values.push(term.value) - groups.set(column, group) - }) - - const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind( - this.encryptionClient, - ) - // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, - // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter - // value to the `eql_v3.query_` twins, so v3 sends full envelopes, - // serialized to jsonb text. - const results = new Array(terms.length) - - await Promise.all( - Array.from(groups, async ([column, { indices, values }]) => { - const encrypted = bulkEncrypt - ? await this.bulkEncryptGroup(bulkEncrypt, column, values) - : await this.encryptGroupPerTerm(column, values) - - encrypted.forEach((envelope, i) => { - results[indices[i]] = JSON.stringify(envelope) - }) - }), - ) - - return results - } - - /** - * Validate a term's query type against its column's declared capabilities. - * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On - * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption - * path can place ciphertext in a GET URL. - */ - private assertTermQueryable(term: ScalarQueryTerm): V3ColumnLike { - const column = term.column as unknown as V3ColumnLike - let queryType = term.queryType ?? 'equality' - const capabilities = column.getQueryCapabilities() - - // The `cs` wire operator is capability-overloaded: bloom free-text on a - // match/search TEXT column, encrypted ste_vec containment on a `types.Json` - // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ - // raw `cs` all map there); resolve to the capability the column actually - // carries. The two are mutually exclusive by construction, so this can - // never reinterpret a real free-text column. - if ( - queryType === 'freeTextSearch' && - !capabilities.freeTextSearch && - capabilities.searchableJson - ) { - queryType = 'searchableJson' - } - - if ( - queryType !== 'equality' && - queryType !== 'orderAndRange' && - queryType !== 'freeTextSearch' && - queryType !== 'searchableJson' - ) { - throw new Error( - `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, - ) - } - - if (!capabilities[queryType]) { - throw new Error( - `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, - ) - } - - if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { - // This is the common boundary for every spelling that collects an - // encrypted match/containment term: matches(), contains(), not(), raw - // filter(), and both forms of or(). Method-level checks provide earlier - // errors for the direct helpers, but cannot cover the raw filter paths on - // their own. - this.assertPostgrestCanQueryEncryptedOperator('filter', column.getName()) - } - - if (queryType === 'searchableJson') { - // THE single enforced operand boundary for encrypted-JSON containment. - // Terms reach this resolver from every spelling — contains(), raw - // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() - // string/structured conditions — and only contains() has a method-level - // guard. Without this check a raw string (e.g. a free-text term ported - // from a text column, or an .or() condition value, which is always a - // string) would be storage-encrypted as a JSON SCALAR and silently match - // nothing; pre-#650 every such spelling failed loudly on capability. - assertJsonContainmentOperand(column.getName(), term.value) - } - - // Free-text (bloom) needle floor. A needle shorter than the tokenizer's - // token_length produces NO tokens, so `bf @> '{}'` holds for every row and - // the query would silently return (and the caller decrypt) the whole table - // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 - // adapter (matchNeedleError) so both first-party surfaces guard identically. - // JSON containment terms (searchableJson) are validated separately above. - if (queryType === 'freeTextSearch') { - const match = column.build().indexes?.match - const reason = match ? matchNeedleError(term.value, match) : undefined - if (reason) { - throw new Error( - `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, - ) - } - } - - return column - } - - private encryptionFailure(message: string, cause?: EncryptionError): never { - logger.error( - `Supabase: failed to encrypt query terms for table "${this.tableName}"`, - ) - // Most callers pass the operation's own `EncryptionError`; the contract- - // violation cases (bulk length mismatch, null envelope) have none, so - // synthesize one — a broken query encryption is still an encryption failure, - // and callers branch on `error.encryptionError` regardless. - throw new EncryptionFailedError( - `Failed to encrypt query terms: ${message}`, - cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, - ) - } - - /** One FFI crossing for a column's whole operand list. */ - private async bulkEncryptGroup( - bulkEncrypt: NonNullable, - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise> { - const baseOp = bulkEncrypt( - values.map((plaintext) => ({ plaintext })) as never, - { column, table: this.table } as never, - ) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) - this.encryptionFailure(result.failure.message, result.failure) - - // `bulkEncrypt` is position-stable, so a length mismatch means the contract - // was violated. Truncating instead would silently widen an `in` predicate - // (or narrow a `not.in`) to whatever came back. `result.data` is now - // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. - const encrypted = result.data - if (encrypted.length !== values.length) { - this.encryptionFailure( - `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, - ) - } - return encrypted.map((term, i) => { - // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` - // envelope here would be `JSON.stringify`'d to the literal string `"null"` - // and sent as the filter operand — silently matching whatever `"null"` - // encodes to rather than failing. A query term should never encrypt to a - // null envelope, so treat it as a contract violation, not a value. - if (term.data === null) { - this.encryptionFailure( - `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, - ) - } - return term.data - }) - } - - /** Fallback for a client that predates `bulkEncrypt`. */ - private async encryptGroupPerTerm( - column: V3ColumnLike, - values: ScalarQueryTerm['value'][], - ): Promise { - return Promise.all( - values.map(async (value) => { - const baseOp = this.encryptionClient.encrypt(value, { - column, - table: this.table, - }) - const op = this.lockContext - ? baseOp.withLockContext(this.lockContext) - : baseOp - if (this.auditConfig) op.audit(this.auditConfig) - - const result = await op - if (result.failure) { - this.encryptionFailure(result.failure.message, result.failure) - } - return result.data - }), - ) - } - - // --------------------------------------------------------------------------- - // Phase boundary: property-space -> DB-space - // --------------------------------------------------------------------------- - - /** - * Translate every recorded column name from JS property space into DB space, - * once. Downstream (`encryptFilterValues`, `applyFilters`, - * `buildAndExecuteQuery`) consumes only the branded result, so a column can - * no longer reach PostgREST untranslated — that is a compile error. - * - * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` - * never throw, so this introduces no new early-throw point and cannot perturb - * the order in which capability errors surface. - * - * Safe to run BEFORE encryption: `getColumnMap()`/`encryptedColumnNames` are - * keyed by both property and DB name, so column lookup resolves identically - * either side of the translation, and `tableColumns[prop]` is the very same - * builder object as `tableColumns[db]`. - */ - protected toDbSpace(): DbQuerySpace { - return { - filters: this.filters.map((f) => ({ - ...f, - column: this.filterColumnName(f.column), - })), - matchFilters: this.matchFilters.map((mf) => ({ - entries: Object.entries(mf.query).map(([column, value]) => ({ - column: this.filterColumnName(column), - value, - })), - })), - notFilters: this.notFilters.map((nf) => ({ - ...nf, - column: this.filterColumnName(nf.column), - })), - rawFilters: this.rawFilters.map((rf) => ({ - ...rf, - column: this.filterColumnName(rf.column), - })), - orFilters: this.orFilters.map((of_) => this.orFilterToDbSpace(of_)), - transforms: this.transforms.map((t) => this.transformToDbSpace(t)), - mutation: this.mutation ? this.mutationToDbSpace(this.mutation) : null, - } - } - - /** Column names only. Which conditions were encrypted is never decided here: - * it stays derived at apply time from the substitution maps, so this pass - * never has to agree with the encryption predicate. The operator token is - * settled later still, in `rebuildOrString`, where `contains` becomes `cs` - * for encrypted and plaintext conditions alike. */ - private orFilterToDbSpace(of_: PendingOrFilter): DbPendingOrFilter { - const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ - ...c, - column: this.filterColumnName(c.column), - }) - - if (of_.kind === 'string') { - return { - kind: 'string', - original: of_.value, - conditions: parseOrString(of_.value).map(toDbCondition), - referencedTable: of_.referencedTable, - } - } - return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } - } - - /** - * Encrypted ordering columns sort by their `op` term, not by the envelope. - * - * `order=col->op` is the one ordering expression PostgREST can emit that - * reaches the OPE term. It must NOT leak into filters — those compare whole - * envelopes through the `eql_v3.*` operators — which is why this is its own - * seam rather than a change to `filterColumnName`. - * - * The canonical EQL form is `ORDER BY eql_v3.ord_term(col)`, which returns - * `eql_v3_internal.ope_cllw` — a domain over `bytea`, ordered by the native - * btree. PostgREST cannot call a function, so it orders the `op` term where it - * sits, inside the envelope. The two agree because the term is what - * `ord_term()` returns. - * - * `->` (jsonb) rather than `->>` (text) keeps the comparison on the typed - * value. Note this does NOT avoid the database collation: Postgres compares - * jsonb strings with `varstr_cmp` under the default collation, exactly as it - * does text. What makes the ordering collation-independent is the term itself - * — fixed-width lowercase hex (`[0-9a-f]`, 130 chars for `integer_ord`, 82 for - * `text_search`) — and every collation orders digits before letters and hex - * letters among themselves. `match-bloom`'s sibling assertion pins that shape. - * - * This runs at column-name-mapping time (`transformToDbSpace`), BEFORE - * `buildAndExecuteQuery` calls `validateTransforms`. For an encrypted column - * with no `ope` index it therefore returns a bare `dbName` here — a name that - * would sort by `jsonb_cmp` over the ciphertext if it reached PostgREST — but - * it never does: `validateTransforms` throws (with a domain-specific reason) - * before the query executes, so the bare name is only ever an intermediate - * value on a request that is about to be rejected. - */ - protected orderColumnName(column: string): DbName { - const dbName = this.dbNameFor(column) - const encrypted = this.v3Columns[column] - if (!encrypted) return dbName as DbName - - return ( - this.columnSchemas[dbName]?.indexes?.ope ? `${dbName}->op` : dbName - ) as DbName - } - - private transformToDbSpace(t: TransformOp): DbTransformOp { - switch (t.kind) { - case 'order': - return { ...t, column: this.orderColumnName(t.column) } - // `returns` is in the union but never pushed (`returns()` is a cast). - case 'limit': - case 'range': - case 'single': - case 'maybeSingle': - case 'csv': - case 'abortSignal': - case 'throwOnError': - case 'returns': - return t - default: { - const exhaustive: never = t - return exhaustive - } - } - } - - private mutationToDbSpace(m: MutationOp): DbMutationOp { - switch (m.kind) { - case 'insert': - case 'upsert': - return { ...m, options: this.resolveMutationOptions(m.options) } - case 'update': - case 'delete': - return m // options carry no column names - default: { - const exhaustive: never = m - return exhaustive - } - } + return addJsonbCastsV3(this.selectColumns, this.columns.propToDb) } // --------------------------------------------------------------------------- - // Step 4: Build and execute real Supabase query + // Step 5: Build and execute real Supabase query // --------------------------------------------------------------------------- - protected async buildAndExecuteQuery( + private async buildAndExecuteQuery( encryptedMutation: | Record | Record[] @@ -1454,7 +677,13 @@ export class EncryptedQueryBuilderImpl< } // Apply resolved filters - query = this.applyFilters(query, encryptedFilters, dbSpace) + query = applyFilters( + query, + encryptedFilters, + dbSpace, + this.columns, + this.queryDomainsRequired, + ) // Apply transforms — column names already in DB-space. for (const t of dbSpace.transforms) { @@ -1490,298 +719,6 @@ export class EncryptedQueryBuilderImpl< return result } - // --------------------------------------------------------------------------- - // Apply filters with encrypted values substituted - // --------------------------------------------------------------------------- - - protected applyFilters( - query: SupabaseQueryBuilder, - encryptedFilters: EncryptedFilterState, - dbSpace: DbQuerySpace, - ): SupabaseQueryBuilder { - let q = query - - // Build lookup maps for quick access to encrypted values - const filterValueMap = new Map() - const filterInMap = new Map() // "filterIndex:inIndex" -> value - const matchValueMap = new Map() // "matchIndex:column" -> value - const notValueMap = new Map() - const notInMap = new Map() // "notIndex:inIndex" -> value - const rawValueMap = new Map() - const rawInMap = new Map() // "rawIndex:inIndex" -> value - const orStringConditionMap = new Map() // "orIndex:condIndex" -> value - const orStructuredConditionMap = new Map() - - for (let i = 0; i < encryptedFilters.termMap.length; i++) { - const mapping = encryptedFilters.termMap[i] - const encValue = encryptedFilters.encryptedValues[i] - - switch (mapping.source) { - case 'filter': - if (mapping.inIndex !== undefined) { - filterInMap.set( - `${mapping.filterIndex}:${mapping.inIndex}`, - encValue, - ) - } else { - filterValueMap.set(mapping.filterIndex, encValue) - } - break - case 'match': - matchValueMap.set(`${mapping.matchIndex}:${mapping.column}`, encValue) - break - case 'not': - if (mapping.inIndex !== undefined) { - notInMap.set(`${mapping.notIndex}:${mapping.inIndex}`, encValue) - } else { - notValueMap.set(mapping.notIndex, encValue) - } - break - case 'raw': - if (mapping.inIndex !== undefined) { - rawInMap.set(`${mapping.rawIndex}:${mapping.inIndex}`, encValue) - } else { - rawValueMap.set(mapping.rawIndex, encValue) - } - break - // `inIndex` widens the key to address one element of an `in` list, so a - // whole-condition value and a per-element value never collide. - case 'or-string': - orStringConditionMap.set(orKey(mapping), encValue) - break - case 'or-structured': - orStructuredConditionMap.set(orKey(mapping), encValue) - break - } - } - - // Apply regular filters - for (let i = 0; i < dbSpace.filters.length; i++) { - const f = dbSpace.filters[i] - let value = f.value - - if (filterValueMap.has(i)) { - value = filterValueMap.get(i) - } else if (f.op === 'in' && Array.isArray(f.value)) { - // Reconstruct array with encrypted values substituted - value = f.value.map((v, j) => { - const key = `${i}:${j}` - return filterInMap.has(key) ? filterInMap.get(key) : v - }) - } - - const column = f.column - const wasEncrypted = filterValueMap.has(i) - - switch (f.op) { - case 'eq': - q = q.eq(column, value) - break - case 'neq': - q = q.neq(column, value) - break - case 'gt': - q = q.gt(column, value) - break - case 'gte': - q = q.gte(column, value) - break - case 'lt': - q = q.lt(column, value) - break - case 'lte': - q = q.lte(column, value) - break - case 'like': - case 'ilike': - q = this.applyPatternFilter(q, column, f.op, value, wasEncrypted) - break - // `matches` (encrypted free-text) and `contains` (plaintext / encrypted - // JSON) share the `cs`/`@>` wire operator; the operand encoding is the - // same, so both emit through the one containment applier. - case 'contains': - case 'matches': - q = this.applyContainsFilter(q, column, value, wasEncrypted) - break - case 'is': - q = q.is(column, value) - break - case 'in': - // `wasEncrypted` above is false for in-lists: their ciphertexts land - // in `filterInMap`, keyed per element. - q = this.applyInFilter( - q, - column, - value as unknown[], - Array.isArray(f.value) && - f.value.some((_, j) => filterInMap.has(`${i}:${j}`)), - ) - break - } - } - - // Apply match filters - for (let i = 0; i < dbSpace.matchFilters.length; i++) { - const mf = dbSpace.matchFilters[i] - const resolvedQuery: Record = {} - - for (const { column: colName, value: originalValue } of mf.entries) { - const key = `${i}:${colName}` - resolvedQuery[colName] = matchValueMap.has(key) - ? matchValueMap.get(key) - : originalValue - } - - q = q.match(resolvedQuery) - } - - // Apply not filters - for (let i = 0; i < dbSpace.notFilters.length; i++) { - const nf = dbSpace.notFilters[i] - - if (nf.op === 'in' && Array.isArray(nf.value)) { - const values = nf.value.map((v, j) => - notInMap.has(`${i}:${j}`) ? notInMap.get(`${i}:${j}`) : v, - ) - q = q.not(nf.column, 'in', formatInListOperand(values)) - continue - } - - const wasEncrypted = notValueMap.has(i) - const value = wasEncrypted ? notValueMap.get(i) : nf.value - - // `contains` is a supabase-js METHOD name, not a PostgREST operator, and - // `q.not()` interpolates its operand with `String(value)` — so an array - // arrives brace-less and an object as `[object Object]`. Build the - // containment literal ourselves and emit the `cs` token, exactly as the - // `.or()` path does. A scalar (including the encrypted envelope, already - // serialized) yields `null` and is forwarded untouched. - if (nf.op === 'contains' || nf.op === 'matches') { - const literal = formatContainmentOperand(value) - q = q.not(nf.column, 'cs', literal ?? value) - continue - } - - q = q.not(nf.column, this.notFilterOperator(nf.op, wasEncrypted), value) - } - - // Apply or filters - for (let i = 0; i < dbSpace.orFilters.length; i++) { - const of_ = dbSpace.orFilters[i] - - if (of_.kind === 'string') { - // Already parsed (once) and translated by `toDbSpace`. - const parsed = [...of_.conditions] - - for (let j = 0; j < parsed.length; j++) { - const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) - if (sub) { - parsed[j] = { ...parsed[j], value: sub.value } - } - } - - // Rebuild whenever a condition REFERENCES an encrypted column — not - // merely when a value was encrypted. An `is`/null operand on an - // encrypted column encrypts nothing, so keying on "was a value - // substituted" would send that condition down the verbatim path below - // and forward the caller's JS property name to a DB that only knows the - // column's real name. `toDbSpace` has already translated `parsed`. - const referencesEncrypted = parsed.some((c) => - isEncryptedColumn(c.column, this.encryptedColumnNames), - ) - - if (referencesEncrypted) { - q = q.or(rebuildOrString(parsed), { - referencedTable: of_.referencedTable, - }) - } else { - // Every condition names a plaintext column, whose property name IS - // its DB name — nothing to map. Forward the caller's ORIGINAL string - // byte-for-byte: relied on for nested `and()` and quoted values that - // `parseOrString`/`rebuildOrString` cannot round-trip. - q = q.or(of_.original as DbFilterString, { - referencedTable: of_.referencedTable, - }) - } - } else { - // Structured: convert to string - const conditions = of_.conditions.map((cond, j) => { - const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) - return sub ? { ...cond, value: sub.value } : cond - }) - - q = q.or(rebuildOrString(conditions)) - } - } - - // Apply raw filters - for (let i = 0; i < dbSpace.rawFilters.length; i++) { - const rf = dbSpace.rawFilters[i] - - // An encrypted `in` list was encrypted element-wise; reassemble it into - // the quoted PostgREST list literal, exactly as the `not` path does. A - // plaintext column keeps its operand untouched. - if ( - rf.operator === 'in' && - Array.isArray(rf.value) && - isEncryptedColumn(rf.column, this.encryptedColumnNames) - ) { - const values = rf.value.map((v, j) => - rawInMap.has(`${i}:${j}`) ? rawInMap.get(`${i}:${j}`) : v, - ) - q = q.filter(rf.column, rf.operator, formatInListOperand(values)) - continue - } - - const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value - q = q.filter(rf.column, rf.operator, value) - } - - return q - } - - // --------------------------------------------------------------------------- - // Dialect seams for native `eql_v3.*` domain columns. - // --------------------------------------------------------------------------- - - /** Resolve a JS property name to its DB column name. `Object.hasOwn` guards - * the inherited-member hazard described on {@link EncryptedTable.buildColumnKeyMap}. */ - private dbNameFor(name: string): string { - return Object.hasOwn(this.propToDb, name) ? this.propToDb[name] : name - } - - /** - * Map a filter's column name to the DB column name PostgREST must see — - * resolving a JS property name to its DB name. - * - * This is the ONLY place a {@link DbName} is minted. The - * {@link SupabaseQueryBuilder} seam accepts nothing else, so every column - * name reaching PostgREST must pass through here. - */ - protected filterColumnName(column: string): DbName { - return this.dbNameFor(column) as DbName - } - - /** - * Resolve the column names carried by a mutation's options. `onConflict` is a - * comma-separated column list, so it needs the same property→DB mapping as a - * filter. Returns the original object when nothing changed. - */ - protected resolveMutationOptions< - O extends { onConflict?: string } | undefined, - >(options: O): DbMutationOptions | undefined { - if (!options?.onConflict) return options as DbMutationOptions | undefined - const mapped = options.onConflict - .split(',') - .map((column) => this.filterColumnName(column.trim())) - .join(',') as DbConflictList - return ( - mapped === options.onConflict - ? options - : { ...options, onConflict: mapped } - ) as DbMutationOptions - } - /** * `ORDER BY` on an OPE-backed column is supported; on every other encrypted * column it is rejected. @@ -1810,17 +747,17 @@ export class EncryptedQueryBuilderImpl< * - neither → reject. Storage-only, equality-only and match-only columns * carry no ordering term to sort by. * - * A column absent from {@link v3Columns} is a plaintext passthrough and orders + * A column with no encrypted builder is a plaintext passthrough and orders * normally. This runtime guard is the only protection the untyped * (no-`schemas`) surface has. */ - protected validateTransforms(): void { + private validateTransforms(): void { for (const t of this.transforms) { if (t.kind !== 'order') continue - const column = this.v3Columns[t.column] + const column = this.columns.encryptedColumn(t.column) if (!column) continue - const indexes = this.columnSchemas[column.getName()]?.indexes + const indexes = this.columns.schemaFor(column.getName())?.indexes if (indexes?.ope) continue const reason = indexes?.ore @@ -1835,328 +772,18 @@ export class EncryptedQueryBuilderImpl< } } - /** - * Resolve a raw `.filter()` operator to the capability it exercises. A - * supported v3 operand is a full storage envelope, so `queryType` never - * selects a narrowing — it only tells {@link assertTermQueryable} which - * capability to demand of the column. - * - * Unknown operators throw rather than silently defaulting to equality, which - * would encrypt a term the column may not even be able to compare. - */ - protected queryTypeForRawOp(operator: string): QueryTypeName { - switch (operator) { - case 'cs': - return 'freeTextSearch' - case 'gt': - case 'gte': - case 'lt': - case 'lte': - return 'orderAndRange' - case 'eq': - case 'neq': - case 'in': - case 'is': - return 'equality' - default: - throw new Error( - `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, - ) - } - } - - /** - * Apply an `in` filter. - * - * A plaintext list goes to postgrest-js's `in()`, which quotes elements that - * contain `,()`. An ENCRYPTED list cannot: every element is a - * `JSON.stringify`d envelope, and `in()` wraps it in `"…"` without escaping - * the quotes inside it, so PostgREST terminates the value at the envelope's - * first `"`. Emit the operand ourselves and hand it to `filter()`, which - * forwards it verbatim. - */ - protected applyInFilter( - q: SupabaseQueryBuilder, - column: DbName, - values: unknown[], - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (!wasEncrypted) return q.in(column, values) - return q.filter(column, 'in', formatInListOperand(values)) - } - - /** - * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were - * rewritten to `matches` at record time, so a `like`/`ilike` pending filter - * only ever names a plaintext column, which keeps real SQL LIKE. - */ - protected applyPatternFilter( - q: SupabaseQueryBuilder, - column: DbName, - op: 'like' | 'ilike', - value: unknown, - _wasEncrypted: boolean, - ): SupabaseQueryBuilder { - return op === 'like' - ? q.like(column, value as string) - : q.ilike(column, value as string) - } - - /** - * Apply a `contains` filter. On a plaintext column this is PostgREST's native - * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` - * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` - * (bloom-filter containment) — and the operand is the full storage envelope, - * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than - * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). - * - * A structured plaintext operand is serialized here rather than by - * postgrest-js, which joins array elements on `,` without quoting them — so - * `['with,comma']` would reach Postgres as two elements. Scalars keep the - * native path. - */ - protected applyContainsFilter( - q: SupabaseQueryBuilder, - column: DbName, - value: unknown, - wasEncrypted: boolean, - ): SupabaseQueryBuilder { - if (wasEncrypted) { - this.assertPostgrestCanQueryEncryptedOperator('filter', column) - return q.filter(column, 'cs', value) - } - const literal = formatContainmentOperand(value) - return literal !== null - ? q.filter(column, 'cs', literal) - : q.contains(column, value) - } - - /** - * The CipherStash query type for an `.or()` condition's operator on an - * encrypted column. String-form conditions carry raw PostgREST operators - * (`cs`), which are not {@link FilterOp}s. - */ - protected queryTypeForOrOp(op: FilterOp): QueryTypeName { - if (op === 'matches') return 'freeTextSearch' - // Structured conditions may carry the `contains` METHOD spelling (the wire - // token becomes `cs` in rebuildOrString). It maps to the same capability - // gate as `cs`; on a JSON column the term resolver then re-types it to - // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive - // or-form relies on this arm. - if (op === 'contains') return 'freeTextSearch' - return this.queryTypeForRawOp(op) - } - - /** - * The PostgREST operator to use for a `.not()` filter. Every {@link FilterOp} - * except `contains` spells the same as its PostgREST operator; `contains` is - * handled before this is reached, because it also needs its operand rewritten. - */ - protected notFilterOperator(op: FilterOp, _wasEncrypted: boolean): string { - return op - } - - /** - * Post-process a decrypted result row: rebuild `Date` values from the - * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's - * decrypt-model path. - */ - protected postprocessDecryptedRow( - row: Record, - ): Record { - // Every key an encrypted column can appear under: the keys this select - // actually produces (including caller-chosen aliases like `ts:createdAt`), - // plus the static property and DB names as a fallback for paths that record - // no select. Aliases win. Derived here from `this.selectColumns` (the row in - // hand) rather than cached from `buildSelectString`, so a reused builder can - // never postprocess a row with a previous operation's stale select map. - const keyToDb: Record = Object.assign( - Object.create(null), - this.selectColumns === null - ? undefined - : selectKeyToDbV3(this.selectColumns, this.propToDb), - ) - for (const [property, dbName] of Object.entries(this.propToDb)) { - keyToDb[property] ??= dbName - keyToDb[dbName] ??= dbName - } - - const out: Record = { ...row } - for (const [key, dbName] of Object.entries(keyToDb)) { - const castAs = this.columnSchemas[dbName]?.cast_as - if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue - const value = out[key] - if (value == null || value instanceof Date) continue - if (typeof value === 'string' || typeof value === 'number') { - out[key] = new Date(value) - } - } - return out - } - - // --------------------------------------------------------------------------- - // Step 5: Decrypt results - // --------------------------------------------------------------------------- - - protected async decryptResults( - result: RawSupabaseResult, - ): Promise> { - // If there's an error from Supabase, pass it through - if (result.error) { - return { - data: null, - error: { - message: result.error.message, - details: result.error.details, - hint: result.error.hint, - code: result.error.code, - }, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // No data to decrypt - if (result.data === null || result.data === undefined) { - return { - data: null, - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Determine if we need to decrypt - const hasSelect = this.selectColumns !== null - const hasMutationWithReturning = this.mutation !== null && hasSelect - - if (!hasSelect && !hasMutationWithReturning) { - // No select means no data to decrypt (e.g., insert without .select()) - return { - data: result.data as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Decrypt based on result mode - if (this.resultMode === 'single' || this.resultMode === 'maybeSingle') { - if (result.data === null) { - return { - data: null, - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Single result — decrypt one model - const baseDecryptOp = this.encryptionClient.decryptModel( - result.data as Record, - ) - const decryptOp = this.lockContext - ? baseDecryptOp.withLockContext(this.lockContext) - : baseDecryptOp - if (this.auditConfig) decryptOp.audit(this.auditConfig) - - const decrypted = await decryptOp - if (decrypted.failure) { - logger.error( - `Supabase: failed to decrypt model for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to decrypt model: ${decrypted.failure.message}`, - decrypted.failure, - ) - } - - return { - data: this.postprocessDecryptedRow( - decrypted.data as Record, - ) as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - // Array result — bulk decrypt - const dataArray = result.data as Record[] - if (dataArray.length === 0) { - return { - data: [] as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - - const baseBulkDecryptOp = this.encryptionClient.bulkDecryptModels(dataArray) - const bulkDecryptOp = this.lockContext - ? baseBulkDecryptOp.withLockContext(this.lockContext) - : baseBulkDecryptOp - if (this.auditConfig) bulkDecryptOp.audit(this.auditConfig) - - const decrypted = await bulkDecryptOp - if (decrypted.failure) { - logger.error( - `Supabase: failed to decrypt models for table "${this.tableName}"`, - ) - - throw new EncryptionFailedError( - `Failed to decrypt models: ${decrypted.failure.message}`, - decrypted.failure, - ) - } - - return { - data: decrypted.data.map((row) => - this.postprocessDecryptedRow(row as Record), - ) as unknown as T[], - error: null, - count: result.count ?? null, - status: result.status, - statusText: result.statusText, - } - } - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- - protected getColumnMap(): Record { - return this.v3Columns as unknown as Record - } - - /** Warn once per (op, column) that a `like`/`ilike` was delegated to `matches`. */ - private static readonly warnedLikeDelegation = new Set() - - /** True when `column` is one of this table's encrypted v3 columns. */ - private isEncryptedV3Column(column: string): boolean { - return Boolean(this.v3Columns[column]) - } - - /** True when `column` is an encrypted `types.Json` document column. */ - private isSearchableJsonColumn(column: string): boolean { - const builder: V3ColumnLike | undefined = this.v3Columns[column] - return Boolean(builder?.getQueryCapabilities().searchableJson) - } - - private assertPostgrestCanQueryEncryptedOperator( + private assertPostgrestCanQueryEncrypted( method: string, column: string, ): void { - if (!this.queryDomainsRequired) return - throw new Error( - `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + assertPostgrestCanQueryEncryptedOperator( + this.queryDomainsRequired, + method, + column, ) } @@ -2172,7 +799,7 @@ export class EncryptedQueryBuilderImpl< path: string, value: unknown, ): Record { - if (!this.isSearchableJsonColumn(column)) { + if (!this.columns.isSearchableJsonColumn(column)) { throw new Error( `[supabase v3]: ${method}() requires an encrypted JSON (types.Json) column; "${column}" is not one.`, ) @@ -2225,8 +852,8 @@ export class EncryptedQueryBuilderImpl< ) } const key = `${op}:${column}` - if (!EncryptedQueryBuilderImpl.warnedLikeDelegation.has(key)) { - EncryptedQueryBuilderImpl.warnedLikeDelegation.add(key) + if (!warnedLikeDelegation.has(key)) { + warnedLikeDelegation.add(key) logger.warn( `[supabase v3]: "${op}" on encrypted column "${column}" is delegated to matches() (fuzzy bloom token search). Results are APPROXIMATE — case-insensitive, one-sided (may false-positive), and wildcards/anchoring are not honored. Call matches() directly to make this explicit.`, ) @@ -2234,98 +861,3 @@ export class EncryptedQueryBuilderImpl< return needle } } - -// --------------------------------------------------------------------------- -// Internal types -// --------------------------------------------------------------------------- - -type TermMapping = - | { source: 'filter'; filterIndex: number; inIndex?: number } - | { source: 'match'; matchIndex: number; column: string } - | { source: 'not'; notIndex: number; inIndex?: number } - | { source: 'raw'; rawIndex: number; inIndex?: number } - | { - source: 'or-string' - orIndex: number - conditionIndex: number - inIndex?: number - } - | { - source: 'or-structured' - orIndex: number - conditionIndex: number - inIndex?: number - } - -type EncryptedFilterState = { - // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns - // that type, and typing the field to match is what lets the restored envelope - // type reach the use site (`encryptedValues[i]`) instead of widening back to - // `unknown` at this boundary. - encryptedValues: EncryptedQueryResult[] - termMap: TermMapping[] -} - -/** Key an `.or()` condition, or one element of its `in` list. */ -function orKey(mapping: { - orIndex: number - conditionIndex: number - inIndex?: number -}): string { - const base = `${mapping.orIndex}:${mapping.conditionIndex}` - return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` -} - -/** - * Substitute encrypted operands back into one `.or()` condition, returning - * `undefined` when nothing was encrypted for it. - * - * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits - * the `(a,b)` list form. Substituting the array as a single value would collapse - * it to one ciphertext that matches nothing. - */ -function substituteOrValue( - map: Map, - orIndex: number, - conditionIndex: number, - cond: { op: FilterOp; value: unknown }, -): { value: unknown } | undefined { - const whole = orKey({ orIndex, conditionIndex }) - if (map.has(whole)) return { value: map.get(whole) } - - if (cond.op === 'in' && Array.isArray(cond.value)) { - let substituted = false - const value = cond.value.map((element, inIndex) => { - const key = orKey({ orIndex, conditionIndex, inIndex }) - if (!map.has(key)) return element - substituted = true - return map.get(key) - }) - if (substituted) return { value } - } - - return undefined -} - -type RawSupabaseResult = { - data: unknown - error: { - message: string - details?: string - hint?: string - code?: string - } | null - count?: number | null - status: number - statusText: string -} - -export class EncryptionFailedError extends Error { - public encryptionError: EncryptionError - - constructor(message: string, encryptionError: EncryptionError) { - super(message) - this.name = 'EncryptionFailedError' - this.encryptionError = encryptionError - } -} diff --git a/packages/stack-supabase/src/query-dbspace.ts b/packages/stack-supabase/src/query-dbspace.ts new file mode 100644 index 000000000..ae68df562 --- /dev/null +++ b/packages/stack-supabase/src/query-dbspace.ts @@ -0,0 +1,141 @@ +import type { ColumnMap } from './column-map' +import { parseOrString } from './helpers' +import type { + DbConflictList, + DbMutationOp, + DbMutationOptions, + DbPendingOrCondition, + DbPendingOrFilter, + DbQuerySpace, + DbTransformOp, + MutationOp, + PendingOrCondition, + PendingOrFilter, + RecordedOps, + TransformOp, +} from './types' + +/** + * Resolve the column names carried by a mutation's options. `onConflict` is a + * comma-separated column list, so it needs the same property→DB mapping as a + * filter. Returns the original object when nothing changed. + */ +export function resolveMutationOptions< + O extends { onConflict?: string } | undefined, +>(options: O, columns: ColumnMap): DbMutationOptions | undefined { + if (!options?.onConflict) return options as DbMutationOptions | undefined + const mapped = options.onConflict + .split(',') + .map((column) => columns.filterColumnName(column.trim())) + .join(',') as DbConflictList + return ( + mapped === options.onConflict ? options : { ...options, onConflict: mapped } + ) as DbMutationOptions +} + +/** Column names only. Which conditions were encrypted is never decided here: + * it stays derived at apply time from the substitution maps, so this pass + * never has to agree with the encryption predicate. The operator token is + * settled later still, in `rebuildOrString`, where `contains` becomes `cs` + * for encrypted and plaintext conditions alike. */ +function orFilterToDbSpace( + of_: PendingOrFilter, + columns: ColumnMap, +): DbPendingOrFilter { + const toDbCondition = (c: PendingOrCondition): DbPendingOrCondition => ({ + ...c, + column: columns.filterColumnName(c.column), + }) + + if (of_.kind === 'string') { + return { + kind: 'string', + original: of_.value, + conditions: parseOrString(of_.value).map(toDbCondition), + referencedTable: of_.referencedTable, + } + } + return { kind: 'structured', conditions: of_.conditions.map(toDbCondition) } +} + +function transformToDbSpace(t: TransformOp, columns: ColumnMap): DbTransformOp { + switch (t.kind) { + case 'order': + return { ...t, column: columns.orderColumnName(t.column) } + // `returns` is in the union but never pushed (`returns()` is a cast). + case 'limit': + case 'range': + case 'single': + case 'maybeSingle': + case 'csv': + case 'abortSignal': + case 'throwOnError': + case 'returns': + return t + default: { + const exhaustive: never = t + return exhaustive + } + } +} + +function mutationToDbSpace(m: MutationOp, columns: ColumnMap): DbMutationOp { + switch (m.kind) { + case 'insert': + case 'upsert': + return { ...m, options: resolveMutationOptions(m.options, columns) } + case 'update': + case 'delete': + return m // options carry no column names + default: { + const exhaustive: never = m + return exhaustive + } + } +} + +/** + * Translate every recorded column name from JS property space into DB space, + * once. Downstream (`encryptFilterValues`, `applyFilters`, + * `buildAndExecuteQuery`) consumes only the branded result, so a column can + * no longer reach PostgREST untranslated — that is a compile error. + * + * Total: `filterColumnName`, `parseOrString`, and `resolveMutationOptions` + * never throw, so this introduces no new early-throw point and cannot perturb + * the order in which capability errors surface. + * + * Safe to run BEFORE encryption: the column map is keyed by both property and + * DB name, so column lookup resolves identically either side of the + * translation, and `tableColumns[prop]` is the very same builder object as + * `tableColumns[db]`. + */ +export function toDbSpace( + recorded: RecordedOps, + columns: ColumnMap, +): DbQuerySpace { + return { + filters: recorded.filters.map((f) => ({ + ...f, + column: columns.filterColumnName(f.column), + })), + matchFilters: recorded.matchFilters.map((mf) => ({ + entries: Object.entries(mf.query).map(([column, value]) => ({ + column: columns.filterColumnName(column), + value, + })), + })), + notFilters: recorded.notFilters.map((nf) => ({ + ...nf, + column: columns.filterColumnName(nf.column), + })), + rawFilters: recorded.rawFilters.map((rf) => ({ + ...rf, + column: columns.filterColumnName(rf.column), + })), + orFilters: recorded.orFilters.map((of_) => orFilterToDbSpace(of_, columns)), + transforms: recorded.transforms.map((t) => transformToDbSpace(t, columns)), + mutation: recorded.mutation + ? mutationToDbSpace(recorded.mutation, columns) + : null, + } +} diff --git a/packages/stack-supabase/src/query-encrypt.ts b/packages/stack-supabase/src/query-encrypt.ts new file mode 100644 index 000000000..56ac8d653 --- /dev/null +++ b/packages/stack-supabase/src/query-encrypt.ts @@ -0,0 +1,659 @@ +import type { JsPlaintext } from '@cipherstash/protect-ffi' +import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +import { logger, matchNeedleError } from '@cipherstash/stack/adapter-kit' +import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' +import { + type EncryptionError, + EncryptionErrorTypes, +} from '@cipherstash/stack/errors' +import type { LockContextInput } from '@cipherstash/stack/identity' +import type { + Encrypted, + EncryptedQueryResult, + QueryTypeName, + ScalarQueryTerm, +} from '@cipherstash/stack/types' +import type { ColumnMap, V3ColumnLike } from './column-map' +import { + isEncryptableTerm, + isEncryptedColumn, + mapFilterOpToQueryType, +} from './helpers' +import type { DbQuerySpace, FilterOp } from './types' + +export class EncryptionFailedError extends Error { + public encryptionError: EncryptionError + + constructor(message: string, encryptionError: EncryptionError) { + super(message) + this.name = 'EncryptionFailedError' + this.encryptionError = encryptionError + } +} + +export type TermMapping = + | { source: 'filter'; filterIndex: number; inIndex?: number } + | { source: 'match'; matchIndex: number; column: string } + | { source: 'not'; notIndex: number; inIndex?: number } + | { source: 'raw'; rawIndex: number; inIndex?: number } + | { + source: 'or-string' + orIndex: number + conditionIndex: number + inIndex?: number + } + | { + source: 'or-structured' + orIndex: number + conditionIndex: number + inIndex?: number + } + +export type EncryptedFilterState = { + // `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns + // that type, and typing the field to match is what lets the restored envelope + // type reach the use site (`encryptedValues[i]`) instead of widening back to + // `unknown` at this boundary. + encryptedValues: EncryptedQueryResult[] + termMap: TermMapping[] +} + +/** + * Everything an encryption step needs from the builder. Assembled per + * `execute()`, so `lockContext`/`auditConfig` are read at execution time — not + * captured when the builder was constructed. + */ +export type EncryptionContext = { + tableName: string + table: AnyV3Table + encryptionClient: EncryptionClient + lockContext: LockContextInput | null + auditConfig: AuditConfig | null + columns: ColumnMap + /** EQL 3.0.2+ requires query-domain casts PostgREST cannot express. */ + queryDomainsRequired: boolean +} + +/** + * Apply the builder's lock context and audit config to a pending operation. + * + * Every encrypt/decrypt crossing in this adapter goes through the same three + * steps, and they must stay identical: an operation that silently skipped the + * lock context would encrypt under the wrong data key. + */ +export function withOpContext( + baseOp: PromiseLike & { + withLockContext( + lockContext: LockContextInput, + ): PromiseLike & { audit(config: AuditConfig): unknown } + audit(config: AuditConfig): unknown + }, + ctx: Pick, +): PromiseLike { + const op = ctx.lockContext ? baseOp.withLockContext(ctx.lockContext) : baseOp + if (ctx.auditConfig) op.audit(ctx.auditConfig) + return op +} + +/** + * EQL 3.0.2 removed the storage/jsonb escape hatch for free-text and JSON + * operators: those now require typed query-domain operands PostgREST cannot + * express. Fail before encryption, so a decryptable storage envelope never + * enters a GET URL. + */ +export function assertPostgrestCanQueryEncryptedOperator( + queryDomainsRequired: boolean, + method: string, + column: string, +): void { + if (!queryDomainsRequired) return + throw new Error( + `[supabase v3]: ${method}() on encrypted column "${column}" is unavailable with EQL 3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.`, + ) +} + +/** + * Validate an encrypted-JSON containment operand: a NON-EMPTY plain object or a + * non-empty array. Everything else is rejected with an actionable steer: + * + * - Scalars/strings: the caller meant free-text (`matches` on a text column) or + * a selector — a raw JSON string is NOT parsed, by design (parsing would make + * `'{"a":1}'` and `{a:1}` silently different queries on other surfaces). + * - Non-plain objects (`Date`, `Map`, `RegExp`, class instances): these JSON- + * serialize to scalars or `{}` — not the sub-document the caller believes. + * - `{}` and `[]`: jsonb containment holds for EVERY document (`doc @> '{}'`), + * so an accidentally-empty needle would silently return (and decrypt) the + * whole table. The Drizzle adapter rejects the same needle for the same + * reason — the two first-party adapters must agree that this is an error. + */ +export function assertJsonContainmentOperand( + column: string, + value: unknown, +): void { + const isPlainObject = + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + if (!isPlainObject && !Array.isArray(value)) { + // Array.isArray is false on this branch by construction, so the label only + // distinguishes null / non-plain object / scalar. + const got = + value === null + ? 'null' + : typeof value === 'object' + ? (value as object).constructor?.name || 'a non-plain object' + : typeof value + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" takes a sub-document (plain object or array) to match, got ${got}.`, + ) + } + const empty = Array.isArray(value) + ? value.length === 0 + : Object.keys(value as object).length === 0 + if (empty) { + throw new Error( + `[supabase v3]: encrypted JSON containment on column "${column}" cannot take an empty ${Array.isArray(value) ? 'array' : 'object'} needle: it matches every row. Pass a non-empty sub-document, or omit the predicate to select all rows.`, + ) + } +} + +/** + * Resolve a raw `.filter()` operator to the capability it exercises. A + * supported v3 operand is a full storage envelope, so `queryType` never + * selects a narrowing — it only tells {@link assertTermQueryable} which + * capability to demand of the column. + * + * Unknown operators throw rather than silently defaulting to equality, which + * would encrypt a term the column may not even be able to compare. + */ +export function queryTypeForRawOp(operator: string): QueryTypeName { + switch (operator) { + case 'cs': + return 'freeTextSearch' + case 'gt': + case 'gte': + case 'lt': + case 'lte': + return 'orderAndRange' + case 'eq': + case 'neq': + case 'in': + case 'is': + return 'equality' + default: + throw new Error( + `[supabase v3]: unsupported raw filter operator "${operator}" on an encrypted column`, + ) + } +} + +/** + * The CipherStash query type for an `.or()` condition's operator on an + * encrypted column. String-form conditions carry raw PostgREST operators + * (`cs`), which are not {@link FilterOp}s. + */ +export function queryTypeForOrOp(op: FilterOp): QueryTypeName { + if (op === 'matches') return 'freeTextSearch' + // Structured conditions may carry the `contains` METHOD spelling (the wire + // token becomes `cs` in rebuildOrString). It maps to the same capability + // gate as `cs`; on a JSON column the term resolver then re-types it to + // searchableJson and validates the operand. selectorNe's IS-NULL-inclusive + // or-form relies on this arm. + if (op === 'contains') return 'freeTextSearch' + return queryTypeForRawOp(op) +} + +function encryptionFailure( + tableName: string, + message: string, + cause?: EncryptionError, +): never { + logger.error( + `Supabase: failed to encrypt query terms for table "${tableName}"`, + ) + // Most callers pass the operation's own `EncryptionError`; the contract- + // violation cases (bulk length mismatch, null envelope) have none, so + // synthesize one — a broken query encryption is still an encryption failure, + // and callers branch on `error.encryptionError` regardless. + throw new EncryptionFailedError( + `Failed to encrypt query terms: ${message}`, + cause ?? { type: EncryptionErrorTypes.EncryptionError, message }, + ) +} + +// --------------------------------------------------------------------------- +// Step 3: Encrypt filter values +// --------------------------------------------------------------------------- + +export async function encryptFilterValues( + dbSpace: DbQuerySpace, + ctx: EncryptionContext, +): Promise { + // Collect all terms that need encryption + const terms: ScalarQueryTerm[] = [] + const termMap: TermMapping[] = [] + + const tableColumns = ctx.columns.queryColumnMap() + const encryptedColumnNames = ctx.columns.encryptedColumnNames + + const pushTerm = ( + value: JsPlaintext, + column: ScalarQueryTerm['column'], + queryType: QueryTypeName, + mapping: TermMapping, + ) => { + terms.push({ + value, + column, + table: ctx.table, + queryType, + returnType: 'composite-literal', + }) + termMap.push(mapping) + } + + /** + * Collect one term per element of an `in`-list operand. + * + * Element-wise is the only correct encoding: encrypting the array as ONE + * value collapses `(a,b)` into a single ciphertext that matches nothing. A + * null element is SQL NULL and passes through unencrypted; the applier + * restores it by index, which is why the mapping carries `inIndex`. + * + * Shared by the regular-`in`, `not(…,'in',…)` and or-condition paths. They + * drifted apart once already — the `not` path went unfixed while the other + * two encrypted element-wise — so they are kept in lockstep here rather than + * spelled out three times. + */ + const collectInListTerms = ( + op: FilterOp, + values: readonly unknown[], + column: ScalarQueryTerm['column'], + queryType: QueryTypeName, + mappingFor: (inIndex: number) => TermMapping, + ) => { + for (let j = 0; j < values.length; j++) { + if (!isEncryptableTerm(op, values[j])) continue + pushTerm(values[j] as JsPlaintext, column, queryType, mappingFor(j)) + } + } + + // Regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + if (!isEncryptedColumn(f.column, encryptedColumnNames)) continue + + const column = tableColumns[f.column] + if (!column) continue + + if (f.op === 'in' && Array.isArray(f.value)) { + collectInListTerms( + f.op, + f.value, + column, + mapFilterOpToQueryType(f.op), + (inIndex) => ({ source: 'filter', filterIndex: i, inIndex }), + ) + } else if (!isEncryptableTerm(f.op, f.value)) { + // `is` predicate or null operand — forwarded unencrypted. + } else { + pushTerm(f.value as JsPlaintext, column, mapFilterOpToQueryType(f.op), { + source: 'filter', + filterIndex: i, + }) + } + } + + // Match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + for (const { column: colName, value } of mf.entries) { + if (!isEncryptedColumn(colName, encryptedColumnNames)) continue + // `match` carries no operator; equality is implied. + if (!isEncryptableTerm('eq', value)) continue + const column = tableColumns[colName] + if (!column) continue + + pushTerm(value as JsPlaintext, column, 'equality', { + source: 'match', + matchIndex: i, + column: colName, + }) + } + } + + // Not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + if (!isEncryptedColumn(nf.column, encryptedColumnNames)) continue + if (!isEncryptableTerm(nf.op, nf.value)) continue + const column = tableColumns[nf.column] + if (!column) continue + + if (nf.op === 'in') { + // A PostgREST list literal (`'(a,b)'`) cannot be encrypted element-wise, + // and encrypting it whole matches nothing. Refuse it rather than emit a + // filter that silently returns no rows. + if (!Array.isArray(nf.value)) { + throw new Error( + `not("${nf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms( + nf.op, + nf.value, + column, + mapFilterOpToQueryType(nf.op), + (inIndex) => ({ source: 'not', notIndex: i, inIndex }), + ) + continue + } + + pushTerm(nf.value as JsPlaintext, column, mapFilterOpToQueryType(nf.op), { + source: 'not', + notIndex: i, + }) + } + + // Or filters — conditions were parsed once, in `toDbSpace`. The string and + // structured forms differ only in their `source` tag; the encryption rules, + // including the `in`-list split below, are identical. + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + const source = of_.kind === 'string' ? 'or-string' : 'or-structured' + + for (let j = 0; j < of_.conditions.length; j++) { + const cond = of_.conditions[j] + if (!isEncryptedColumn(cond.column, encryptedColumnNames)) continue + const column = tableColumns[cond.column] + if (!column) continue + + // `queryTypeForOrOp`, not `mapFilterOpToQueryType`: an or-condition may + // carry a raw PostgREST operator (`cs`), which is not a `FilterOp`. + const queryType = queryTypeForOrOp(cond.op) + const mappingFor = (inIndex?: number): TermMapping => ({ + source, + orIndex: i, + conditionIndex: j, + inIndex, + }) + + if (cond.op === 'in' && Array.isArray(cond.value)) { + collectInListTerms(cond.op, cond.value, column, queryType, mappingFor) + continue + } + + if (!isEncryptableTerm(cond.op, cond.value)) continue + pushTerm(cond.value as JsPlaintext, column, queryType, mappingFor()) + } + } + + // Raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + if (!isEncryptedColumn(rf.column, encryptedColumnNames)) continue + const column = tableColumns[rf.column] + if (!column) continue + + if (rf.operator === 'in') { + // Same contract as the `not(…, 'in', …)` path: a PostgREST list literal + // (`'("a","b")'`) cannot be encrypted element-wise, and encrypting it + // whole matches nothing. Refuse it rather than emit a filter that + // silently returns no rows. + if (!Array.isArray(rf.value)) { + throw new Error( + `filter("${rf.column}", "in", …) on an encrypted column requires an array of values, ` + + `not a PostgREST list literal — each element must be encrypted separately`, + ) + } + collectInListTerms( + 'in', + rf.value, + column, + queryTypeForRawOp(rf.operator), + (inIndex) => ({ source: 'raw', rawIndex: i, inIndex }), + ) + continue + } + + if (!isEncryptableTerm(rf.operator, rf.value)) continue + + pushTerm(rf.value as JsPlaintext, column, queryTypeForRawOp(rf.operator), { + source: 'raw', + rawIndex: i, + }) + } + + if (terms.length === 0) { + return { encryptedValues: [], termMap: [] } + } + + const encryptedValues = await encryptCollectedTerms(terms, ctx) + return { encryptedValues, termMap } +} + +/** + * Encrypt every filter operand as a full storage envelope, serialized to jsonb + * text for the PostgREST filter value. + * + * Terms are grouped by column and each group takes ONE `bulkEncrypt` crossing. + * `in(col, [a, b, c])` collects one term per element (the list must never be + * encrypted whole), so encrypting per term spent N ZeroKMS/FFI round-trips + * where one would do. `bulkEncrypt` carries a single `{table, column}` for the + * whole payload, so the grouping is mandatory, not an optimisation: one bulk + * call over a mixed-column term array would stamp one column onto every + * plaintext. Results are scattered back onto the terms' original indices, + * which is the contract `termMap` downstream relies on. + * + * Mirrors `eql/v3/drizzle/operators.ts` `encryptOperands` — same batching + * contract, same length assertion, same fallback. Kept separate because that + * one encrypts a single-column operand list and returns `SQL[]`, while this + * must group a multi-column term array and preserve positions. + */ +async function encryptCollectedTerms( + terms: ScalarQueryTerm[], + ctx: EncryptionContext, +): Promise { + const groups = new Map< + V3ColumnLike, + { indices: number[]; values: ScalarQueryTerm['value'][] } + >() + terms.forEach((term, index) => { + const column = assertTermQueryable(term, ctx) + const group = groups.get(column) ?? { indices: [], values: [] } + group.indices.push(index) + group.values.push(term.value) + groups.set(column, group) + }) + + const bulkEncrypt = ctx.encryptionClient.bulkEncrypt?.bind( + ctx.encryptionClient, + ) + // Each term becomes the `JSON.stringify`'d storage envelope — a `string`, + // which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter + // value to the `eql_v3.query_` twins, so v3 sends full envelopes, + // serialized to jsonb text. + const results = new Array(terms.length) + + await Promise.all( + Array.from(groups, async ([column, { indices, values }]) => { + const encrypted = bulkEncrypt + ? await bulkEncryptGroup(bulkEncrypt, column, values, ctx) + : await encryptGroupPerTerm(column, values, ctx) + + encrypted.forEach((envelope, i) => { + results[indices[i]] = JSON.stringify(envelope) + }) + }), + ) + + return results +} + +/** + * Validate a term's query type against its column's declared capabilities. + * Pure validation: `encrypt`/`bulkEncrypt` never receive the query type. On + * EQL 3.0.2+, free-text/JSON terms are rejected before this storage-encryption + * path can place ciphertext in a GET URL. + * + * Exported for direct testing: no public call path can produce an unsupported + * `queryType` (`mapFilterOpToQueryType`, {@link queryTypeForRawOp} and + * {@link queryTypeForOrOp} are exhaustive), so that backstop is only reachable + * by calling this with a hand-built term. + */ +export function assertTermQueryable( + term: ScalarQueryTerm, + ctx: EncryptionContext, +): V3ColumnLike { + const column = term.column as unknown as V3ColumnLike + let queryType = term.queryType ?? 'equality' + const capabilities = column.getQueryCapabilities() + + // The `cs` wire operator is capability-overloaded: bloom free-text on a + // match/search TEXT column, encrypted ste_vec containment on a `types.Json` + // DOCUMENT column. Both arrive here as `freeTextSearch` (contains/matches/ + // raw `cs` all map there); resolve to the capability the column actually + // carries. The two are mutually exclusive by construction, so this can + // never reinterpret a real free-text column. + if ( + queryType === 'freeTextSearch' && + !capabilities.freeTextSearch && + capabilities.searchableJson + ) { + queryType = 'searchableJson' + } + + if ( + queryType !== 'equality' && + queryType !== 'orderAndRange' && + queryType !== 'freeTextSearch' && + queryType !== 'searchableJson' + ) { + throw new Error( + `[supabase v3]: query type "${queryType}" is not supported on EQL v3 columns`, + ) + } + + if (!capabilities[queryType]) { + throw new Error( + `[supabase v3]: column "${column.getName()}" (${column.getEqlType()}) does not support ${queryType} queries — declare the column with a domain that carries that capability`, + ) + } + + if (queryType === 'freeTextSearch' || queryType === 'searchableJson') { + // This is the common boundary for every spelling that collects an + // encrypted match/containment term: matches(), contains(), not(), raw + // filter(), and both forms of or(). Method-level checks provide earlier + // errors for the direct helpers, but cannot cover the raw filter paths on + // their own. + assertPostgrestCanQueryEncryptedOperator( + ctx.queryDomainsRequired, + 'filter', + column.getName(), + ) + } + + if (queryType === 'searchableJson') { + // THE single enforced operand boundary for encrypted-JSON containment. + // Terms reach this resolver from every spelling — contains(), raw + // .filter(col,'cs',…), not(col,'contains'|'matches',…), and .or() + // string/structured conditions — and only contains() has a method-level + // guard. Without this check a raw string (e.g. a free-text term ported + // from a text column, or an .or() condition value, which is always a + // string) would be storage-encrypted as a JSON SCALAR and silently match + // nothing; pre-#650 every such spelling failed loudly on capability. + assertJsonContainmentOperand(column.getName(), term.value) + } + + // Free-text (bloom) needle floor. A needle shorter than the tokenizer's + // token_length produces NO tokens, so `bf @> '{}'` holds for every row and + // the query would silently return (and the caller decrypt) the whole table + // — a fail-open over-exposure. Reject it up front, mirroring the Drizzle v3 + // adapter (matchNeedleError) so both first-party surfaces guard identically. + // JSON containment terms (searchableJson) are validated separately above. + if (queryType === 'freeTextSearch') { + const match = column.build().indexes?.match + const reason = match ? matchNeedleError(term.value, match) : undefined + if (reason) { + throw new Error( + `[supabase v3]: cannot search column "${column.getName()}": ${reason}`, + ) + } + } + + return column +} + +/** One FFI crossing for a column's whole operand list. */ +async function bulkEncryptGroup( + bulkEncrypt: NonNullable, + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ctx: EncryptionContext, +): Promise> { + const result = await withOpContext( + bulkEncrypt( + values.map((plaintext) => ({ plaintext })) as never, + { + column, + table: ctx.table, + } as never, + ), + ctx, + ) + if (result.failure) + encryptionFailure(ctx.tableName, result.failure.message, result.failure) + + // `bulkEncrypt` is position-stable, so a length mismatch means the contract + // was violated. Truncating instead would silently widen an `in` predicate + // (or narrow a `not.in`) to whatever came back. `result.data` is now + // `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`. + const encrypted = result.data + if (encrypted.length !== values.length) { + encryptionFailure( + ctx.tableName, + `bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`, + ) + } + return encrypted.map((term, i) => { + // `BulkEncryptedData` types the element as `Encrypted | null`. A `null` + // envelope here would be `JSON.stringify`'d to the literal string `"null"` + // and sent as the filter operand — silently matching whatever `"null"` + // encodes to rather than failing. A query term should never encrypt to a + // null envelope, so treat it as a contract violation, not a value. + if (term.data === null) { + encryptionFailure( + ctx.tableName, + `bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`, + ) + } + return term.data + }) +} + +/** Fallback for a client that predates `bulkEncrypt`. */ +async function encryptGroupPerTerm( + column: V3ColumnLike, + values: ScalarQueryTerm['value'][], + ctx: EncryptionContext, +): Promise { + return Promise.all( + values.map(async (value) => { + const result = await withOpContext( + ctx.encryptionClient.encrypt(value, { + column, + table: ctx.table, + }), + ctx, + ) + if (result.failure) { + encryptionFailure(ctx.tableName, result.failure.message, result.failure) + } + return result.data + }), + ) +} diff --git a/packages/stack-supabase/src/query-filters.ts b/packages/stack-supabase/src/query-filters.ts new file mode 100644 index 000000000..fdad1d10a --- /dev/null +++ b/packages/stack-supabase/src/query-filters.ts @@ -0,0 +1,388 @@ +import type { ColumnMap } from './column-map' +import { + formatContainmentOperand, + formatInListOperand, + isEncryptedColumn, + rebuildOrString, +} from './helpers' +import { + assertPostgrestCanQueryEncryptedOperator, + type EncryptedFilterState, +} from './query-encrypt' +import type { + DbFilterString, + DbName, + DbQuerySpace, + FilterOp, + SupabaseQueryBuilder, +} from './types' + +/** Key an `.or()` condition, or one element of its `in` list. */ +function orKey(mapping: { + orIndex: number + conditionIndex: number + inIndex?: number +}): string { + const base = `${mapping.orIndex}:${mapping.conditionIndex}` + return mapping.inIndex === undefined ? base : `${base}:${mapping.inIndex}` +} + +/** + * Substitute encrypted operands back into one `.or()` condition, returning + * `undefined` when nothing was encrypted for it. + * + * An `in` list is reconstructed element-by-element so `formatOrValue` re-emits + * the `(a,b)` list form. Substituting the array as a single value would collapse + * it to one ciphertext that matches nothing. + */ +function substituteOrValue( + map: Map, + orIndex: number, + conditionIndex: number, + cond: { op: FilterOp; value: unknown }, +): { value: unknown } | undefined { + const whole = orKey({ orIndex, conditionIndex }) + if (map.has(whole)) return { value: map.get(whole) } + + if (cond.op === 'in' && Array.isArray(cond.value)) { + let substituted = false + const value = cond.value.map((element, inIndex) => { + const key = orKey({ orIndex, conditionIndex, inIndex }) + if (!map.has(key)) return element + substituted = true + return map.get(key) + }) + if (substituted) return { value } + } + + return undefined +} + +/** + * Apply an `in` filter. + * + * A plaintext list goes to postgrest-js's `in()`, which quotes elements that + * contain `,()`. An ENCRYPTED list cannot: every element is a + * `JSON.stringify`d envelope, and `in()` wraps it in `"…"` without escaping + * the quotes inside it, so PostgREST terminates the value at the envelope's + * first `"`. Emit the operand ourselves and hand it to `filter()`, which + * forwards it verbatim. + */ +function applyInFilter( + q: SupabaseQueryBuilder, + column: DbName, + values: unknown[], + wasEncrypted: boolean, +): SupabaseQueryBuilder { + if (!wasEncrypted) return q.in(column, values) + return q.filter(column, 'in', formatInListOperand(values)) +} + +/** + * Apply a `like`/`ilike` filter. On an encrypted column `like`/`ilike` were + * rewritten to `matches` at record time, so a `like`/`ilike` pending filter + * only ever names a plaintext column, which keeps real SQL LIKE. + */ +function applyPatternFilter( + q: SupabaseQueryBuilder, + column: DbName, + op: 'like' | 'ilike', + value: unknown, +): SupabaseQueryBuilder { + return op === 'like' + ? q.like(column, value as string) + : q.ilike(column, value as string) +} + +/** + * Apply a `contains` filter. On a plaintext column this is PostgREST's native + * jsonb/array containment. On an encrypted column `cs` resolves to the `@>` + * operator the EQL bundle declares on the domain, backed by `eql_v3.matches` + * (bloom-filter containment) — and the operand is the full storage envelope, + * already `JSON.stringify`d, emitted via `filter(col, 'cs', json)` rather than + * `q.contains` (postgrest-js's `contains` re-serializes a non-string operand). + * + * A structured plaintext operand is serialized here rather than by + * postgrest-js, which joins array elements on `,` without quoting them — so + * `['with,comma']` would reach Postgres as two elements. Scalars keep the + * native path. + */ +function applyContainsFilter( + q: SupabaseQueryBuilder, + column: DbName, + value: unknown, + wasEncrypted: boolean, + queryDomainsRequired: boolean, +): SupabaseQueryBuilder { + if (wasEncrypted) { + assertPostgrestCanQueryEncryptedOperator( + queryDomainsRequired, + 'filter', + column, + ) + return q.filter(column, 'cs', value) + } + const literal = formatContainmentOperand(value) + return literal !== null + ? q.filter(column, 'cs', literal) + : q.contains(column, value) +} + +/** + * Apply every recorded filter to the real Supabase query, substituting the + * encrypted operand wherever one was produced for that position. + */ +export function applyFilters( + query: SupabaseQueryBuilder, + encryptedFilters: EncryptedFilterState, + dbSpace: DbQuerySpace, + columns: ColumnMap, + queryDomainsRequired: boolean, +): SupabaseQueryBuilder { + let q = query + const encryptedColumnNames = columns.encryptedColumnNames + + // Build lookup maps for quick access to encrypted values + const filterValueMap = new Map() + const filterInMap = new Map() // "filterIndex:inIndex" -> value + const matchValueMap = new Map() // "matchIndex:column" -> value + const notValueMap = new Map() + const notInMap = new Map() // "notIndex:inIndex" -> value + const rawValueMap = new Map() + const rawInMap = new Map() // "rawIndex:inIndex" -> value + const orStringConditionMap = new Map() // "orIndex:condIndex" -> value + const orStructuredConditionMap = new Map() + + for (let i = 0; i < encryptedFilters.termMap.length; i++) { + const mapping = encryptedFilters.termMap[i] + const encValue = encryptedFilters.encryptedValues[i] + + switch (mapping.source) { + case 'filter': + if (mapping.inIndex !== undefined) { + filterInMap.set(`${mapping.filterIndex}:${mapping.inIndex}`, encValue) + } else { + filterValueMap.set(mapping.filterIndex, encValue) + } + break + case 'match': + matchValueMap.set(`${mapping.matchIndex}:${mapping.column}`, encValue) + break + case 'not': + if (mapping.inIndex !== undefined) { + notInMap.set(`${mapping.notIndex}:${mapping.inIndex}`, encValue) + } else { + notValueMap.set(mapping.notIndex, encValue) + } + break + case 'raw': + if (mapping.inIndex !== undefined) { + rawInMap.set(`${mapping.rawIndex}:${mapping.inIndex}`, encValue) + } else { + rawValueMap.set(mapping.rawIndex, encValue) + } + break + // `inIndex` widens the key to address one element of an `in` list, so a + // whole-condition value and a per-element value never collide. + case 'or-string': + orStringConditionMap.set(orKey(mapping), encValue) + break + case 'or-structured': + orStructuredConditionMap.set(orKey(mapping), encValue) + break + } + } + + // Apply regular filters + for (let i = 0; i < dbSpace.filters.length; i++) { + const f = dbSpace.filters[i] + let value = f.value + + if (filterValueMap.has(i)) { + value = filterValueMap.get(i) + } else if (f.op === 'in' && Array.isArray(f.value)) { + // Reconstruct array with encrypted values substituted + value = f.value.map((v, j) => { + const key = `${i}:${j}` + return filterInMap.has(key) ? filterInMap.get(key) : v + }) + } + + const column = f.column + const wasEncrypted = filterValueMap.has(i) + + switch (f.op) { + case 'eq': + q = q.eq(column, value) + break + case 'neq': + q = q.neq(column, value) + break + case 'gt': + q = q.gt(column, value) + break + case 'gte': + q = q.gte(column, value) + break + case 'lt': + q = q.lt(column, value) + break + case 'lte': + q = q.lte(column, value) + break + case 'like': + case 'ilike': + q = applyPatternFilter(q, column, f.op, value) + break + // `matches` (encrypted free-text) and `contains` (plaintext / encrypted + // JSON) share the `cs`/`@>` wire operator; the operand encoding is the + // same, so both emit through the one containment applier. + case 'contains': + case 'matches': + q = applyContainsFilter( + q, + column, + value, + wasEncrypted, + queryDomainsRequired, + ) + break + case 'is': + q = q.is(column, value) + break + case 'in': + // `wasEncrypted` above is false for in-lists: their ciphertexts land + // in `filterInMap`, keyed per element. + q = applyInFilter( + q, + column, + value as unknown[], + Array.isArray(f.value) && + f.value.some((_, j) => filterInMap.has(`${i}:${j}`)), + ) + break + } + } + + // Apply match filters + for (let i = 0; i < dbSpace.matchFilters.length; i++) { + const mf = dbSpace.matchFilters[i] + const resolvedQuery: Record = {} + + for (const { column: colName, value: originalValue } of mf.entries) { + const key = `${i}:${colName}` + resolvedQuery[colName] = matchValueMap.has(key) + ? matchValueMap.get(key) + : originalValue + } + + q = q.match(resolvedQuery) + } + + // Apply not filters + for (let i = 0; i < dbSpace.notFilters.length; i++) { + const nf = dbSpace.notFilters[i] + + if (nf.op === 'in' && Array.isArray(nf.value)) { + const values = nf.value.map((v, j) => + notInMap.has(`${i}:${j}`) ? notInMap.get(`${i}:${j}`) : v, + ) + q = q.not(nf.column, 'in', formatInListOperand(values)) + continue + } + + const wasEncrypted = notValueMap.has(i) + const value = wasEncrypted ? notValueMap.get(i) : nf.value + + // `contains` is a supabase-js METHOD name, not a PostgREST operator, and + // `q.not()` interpolates its operand with `String(value)` — so an array + // arrives brace-less and an object as `[object Object]`. Build the + // containment literal ourselves and emit the `cs` token, exactly as the + // `.or()` path does. A scalar (including the encrypted envelope, already + // serialized) yields `null` and is forwarded untouched. + if (nf.op === 'contains' || nf.op === 'matches') { + const literal = formatContainmentOperand(value) + q = q.not(nf.column, 'cs', literal ?? value) + continue + } + + // Every `FilterOp` except `contains` spells the same as its PostgREST + // operator, and `contains` was handled above (it also needs its operand + // rewritten), so the recorded op is the wire op. + q = q.not(nf.column, nf.op, value) + } + + // Apply or filters + for (let i = 0; i < dbSpace.orFilters.length; i++) { + const of_ = dbSpace.orFilters[i] + + if (of_.kind === 'string') { + // Already parsed (once) and translated by `toDbSpace`. + const parsed = [...of_.conditions] + + for (let j = 0; j < parsed.length; j++) { + const sub = substituteOrValue(orStringConditionMap, i, j, parsed[j]) + if (sub) { + parsed[j] = { ...parsed[j], value: sub.value } + } + } + + // Rebuild whenever a condition REFERENCES an encrypted column — not + // merely when a value was encrypted. An `is`/null operand on an + // encrypted column encrypts nothing, so keying on "was a value + // substituted" would send that condition down the verbatim path below + // and forward the caller's JS property name to a DB that only knows the + // column's real name. `toDbSpace` has already translated `parsed`. + const referencesEncrypted = parsed.some((c) => + isEncryptedColumn(c.column, encryptedColumnNames), + ) + + if (referencesEncrypted) { + q = q.or(rebuildOrString(parsed), { + referencedTable: of_.referencedTable, + }) + } else { + // Every condition names a plaintext column, whose property name IS + // its DB name — nothing to map. Forward the caller's ORIGINAL string + // byte-for-byte: relied on for nested `and()` and quoted values that + // `parseOrString`/`rebuildOrString` cannot round-trip. + q = q.or(of_.original as DbFilterString, { + referencedTable: of_.referencedTable, + }) + } + } else { + // Structured: convert to string + const conditions = of_.conditions.map((cond, j) => { + const sub = substituteOrValue(orStructuredConditionMap, i, j, cond) + return sub ? { ...cond, value: sub.value } : cond + }) + + q = q.or(rebuildOrString(conditions)) + } + } + + // Apply raw filters + for (let i = 0; i < dbSpace.rawFilters.length; i++) { + const rf = dbSpace.rawFilters[i] + + // An encrypted `in` list was encrypted element-wise; reassemble it into + // the quoted PostgREST list literal, exactly as the `not` path does. A + // plaintext column keeps its operand untouched. + if ( + rf.operator === 'in' && + Array.isArray(rf.value) && + isEncryptedColumn(rf.column, encryptedColumnNames) + ) { + const values = rf.value.map((v, j) => + rawInMap.has(`${i}:${j}`) ? rawInMap.get(`${i}:${j}`) : v, + ) + q = q.filter(rf.column, rf.operator, formatInListOperand(values)) + continue + } + + const value = rawValueMap.has(i) ? rawValueMap.get(i) : rf.value + q = q.filter(rf.column, rf.operator, value) + } + + return q +} diff --git a/packages/stack-supabase/src/query-mutation.ts b/packages/stack-supabase/src/query-mutation.ts new file mode 100644 index 000000000..154f3244a --- /dev/null +++ b/packages/stack-supabase/src/query-mutation.ts @@ -0,0 +1,79 @@ +import { logger } from '@cipherstash/stack/adapter-kit' +import type { ColumnMap } from './column-map' +import { + type EncryptionContext, + EncryptionFailedError, + withOpContext, +} from './query-encrypt' +import type { MutationOp } from './types' + +/** + * Encode an encrypted model for the Supabase request body. The native + * `eql_v3.*` domains are plain jsonb, so the raw encrypted payload is sent + * (keyed by DB column name). + */ +function transformEncryptedMutationModel( + model: Record, + columns: ColumnMap, +): Record { + const out: Record = Object.create(null) + for (const [key, value] of Object.entries(model)) { + out[columns.dbNameFor(key)] = value + } + return out +} + +/** + * Encrypt a mutation's row data — the values being STORED, as opposed to the + * filter operands being searched by (`./query-encrypt`). `delete` carries no + * data, and a builder with no recorded mutation encrypts nothing. + */ +export async function encryptMutationData( + mutation: MutationOp | null, + ctx: EncryptionContext, +): Promise | Record[] | null> { + if (!mutation) return null + if (mutation.kind === 'delete') return null + + const data = mutation.data + + if (Array.isArray(data)) { + // Bulk encrypt + const result = await withOpContext( + ctx.encryptionClient.bulkEncryptModels(data, ctx.table), + ctx, + ) + if (result.failure) { + logger.error( + `Supabase: failed to encrypt models for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt models: ${result.failure.message}`, + result.failure, + ) + } + + return result.data.map((model) => + transformEncryptedMutationModel(model, ctx.columns), + ) + } + + // Single model + const result = await withOpContext( + ctx.encryptionClient.encryptModel(data, ctx.table), + ctx, + ) + if (result.failure) { + logger.error( + `Supabase: failed to encrypt model for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to encrypt model: ${result.failure.message}`, + result.failure, + ) + } + + return transformEncryptedMutationModel(result.data, ctx.columns) +} diff --git a/packages/stack-supabase/src/query-results.ts b/packages/stack-supabase/src/query-results.ts new file mode 100644 index 000000000..8c1812bd8 --- /dev/null +++ b/packages/stack-supabase/src/query-results.ts @@ -0,0 +1,205 @@ +import { DATE_LIKE_CASTS, logger } from '@cipherstash/stack/adapter-kit' +import { selectKeyToDbV3 } from './helpers' +import { + type EncryptionContext, + EncryptionFailedError, + withOpContext, +} from './query-encrypt' +import type { EncryptedSupabaseResponse, ResultMode } from './types' + +/** cast_as kinds that reconstruct to a JS `Date` — shared with the typed v3 + * client's decrypt-model path (see `encryption/v3.ts`). */ +const DATE_LIKE_CAST_SET = new Set(DATE_LIKE_CASTS) + +export type RawSupabaseResult = { + data: unknown + error: { + message: string + details?: string + hint?: string + code?: string + } | null + count?: number | null + status: number + statusText: string +} + +/** What the decrypt step needs beyond the shared encryption context. */ +export type DecryptContext = EncryptionContext & { + selectColumns: string | null + resultMode: ResultMode + hasMutation: boolean +} + +/** + * Post-process a decrypted result row: rebuild `Date` values from the + * encrypt-config `cast_as` (date/timestamp), mirroring the typed v3 client's + * decrypt-model path. + */ +function postprocessDecryptedRow( + row: Record, + ctx: DecryptContext, +): Record { + // Every key an encrypted column can appear under: the keys this select + // actually produces (including caller-chosen aliases like `ts:createdAt`), + // plus the static property and DB names as a fallback for paths that record + // no select. Aliases win. Derived here from `ctx.selectColumns` (the row in + // hand) rather than cached from `buildSelectString`, so a reused builder can + // never postprocess a row with a previous operation's stale select map. + const propToDb = ctx.columns.propToDb + const keyToDb: Record = Object.assign( + Object.create(null), + ctx.selectColumns === null + ? undefined + : selectKeyToDbV3(ctx.selectColumns, propToDb), + ) + for (const [property, dbName] of Object.entries(propToDb)) { + keyToDb[property] ??= dbName + keyToDb[dbName] ??= dbName + } + + const out: Record = { ...row } + for (const [key, dbName] of Object.entries(keyToDb)) { + const castAs = ctx.columns.schemaFor(dbName)?.cast_as + if (!DATE_LIKE_CAST_SET.has(castAs as string)) continue + const value = out[key] + if (value == null || value instanceof Date) continue + if (typeof value === 'string' || typeof value === 'number') { + out[key] = new Date(value) + } + } + return out +} + +/** + * Decrypt a PostgREST response into the caller's row type. + * + * Reads EQL v3 only. A column carrying legacy EQL v2 ciphertext is never in + * this adapter's encrypt config — introspection recognises `public.eql_v3_*` + * domains exclusively — so it is returned as an untouched passthrough rather + * than decrypted. To read v2 data, decrypt fetched rows with the core + * `@cipherstash/stack` client, whose decrypt path is generation-agnostic. + */ +export async function decryptResults>( + result: RawSupabaseResult, + ctx: DecryptContext, +): Promise> { + // If there's an error from Supabase, pass it through + if (result.error) { + return { + data: null, + error: { + message: result.error.message, + details: result.error.details, + hint: result.error.hint, + code: result.error.code, + }, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // No data to decrypt + if (result.data === null || result.data === undefined) { + return { + data: null, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Determine if we need to decrypt + const hasSelect = ctx.selectColumns !== null + const hasMutationWithReturning = ctx.hasMutation && hasSelect + + if (!hasSelect && !hasMutationWithReturning) { + // No select means no data to decrypt (e.g., insert without .select()) + return { + data: result.data as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Decrypt based on result mode + if (ctx.resultMode === 'single' || ctx.resultMode === 'maybeSingle') { + if (result.data === null) { + return { + data: null, + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Single result — decrypt one model + const decrypted = await withOpContext( + ctx.encryptionClient.decryptModel(result.data as Record), + ctx, + ) + if (decrypted.failure) { + logger.error( + `Supabase: failed to decrypt model for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to decrypt model: ${decrypted.failure.message}`, + decrypted.failure, + ) + } + + return { + data: postprocessDecryptedRow( + decrypted.data as Record, + ctx, + ) as unknown as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + // Array result — bulk decrypt + const dataArray = result.data as Record[] + if (dataArray.length === 0) { + return { + data: [] as unknown as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } + } + + const decrypted = await withOpContext( + ctx.encryptionClient.bulkDecryptModels(dataArray), + ctx, + ) + if (decrypted.failure) { + logger.error( + `Supabase: failed to decrypt models for table "${ctx.tableName}"`, + ) + + throw new EncryptionFailedError( + `Failed to decrypt models: ${decrypted.failure.message}`, + decrypted.failure, + ) + } + + return { + data: decrypted.data.map((row) => + postprocessDecryptedRow(row as Record, ctx), + ) as unknown as T[], + error: null, + count: result.count ?? null, + status: result.status, + statusText: result.statusText, + } +} diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index cc5378d56..c74fc9e6b 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -106,7 +106,7 @@ type NonScalarQueryableV3Keys
= { * the table's encrypted columns with no scalar capability (storage-only * columns, and `types.Json` documents — see {@link NonScalarQueryableV3Keys}; * before #650's `searchableJson` arm the two sets coincided). Plaintext - * (non-schema) columns pass through untouched, exactly as in v2. + * (non-schema) columns pass through untouched. */ export type FilterableKeys< Table extends AnyV3Table, @@ -380,11 +380,12 @@ export interface EncryptedQueryBuilder< } /** - * The v3 builder for a table with no declared schema. Without capability + * The builder for a table with no declared schema. Without capability * information `contains` cannot be narrowed to match-indexed columns — the - * runtime guard in the term-encryption path is the only protection — but the - * DIALECT is still v3, so `like`/`ilike` are absent here too. Typing this as - * {@link EncryptedQueryBuilder} would hand back the v2 surface. + * runtime guard in the term-encryption path is the only protection — and + * `order()`/`is(col, true)` cannot be narrowed either, so this surface takes + * {@link EncryptedQueryBuilderCore}'s `OK`/`BK` defaults. `like`/`ilike` are + * absent here as on the typed surface. * * For the same reason nothing here can tell an encrypted match column from a * plaintext jsonb one, so `matches`/`contains` accept the full native operand @@ -631,7 +632,7 @@ export type DbMutationOptions = Record & { // --------------------------------------------------------------------------- // DB-space IR — the recorded query, with every column name translated. // -// `toDbSpace()` (see ./query-builder) maps the property-space IR above into +// `toDbSpace()` (see ./query-dbspace) maps the property-space IR above into // this one, exactly once, before any column name can reach PostgREST. The // branded `column` fields make that translation a compile-time obligation: // `applyFilters`/`buildAndExecuteQuery` consume only these types, so feeding @@ -681,6 +682,19 @@ export type DbMutationOp = | Extract | Extract +/** The whole recorded query, in PROPERTY space — the builder's chained state as + * handed to `toDbSpace()`. The mirror of {@link DbQuerySpace} on the untranslated + * side of that boundary. */ +export type RecordedOps = { + filters: PendingFilter[] + matchFilters: PendingMatchFilter[] + notFilters: PendingNotFilter[] + rawFilters: PendingRawFilter[] + orFilters: PendingOrFilter[] + transforms: TransformOp[] + mutation: MutationOp | null +} + /** The whole recorded query, in DB-space. */ export type DbQuerySpace = { filters: DbPendingFilter[] @@ -771,33 +785,43 @@ export type { type StringKeyOf = Extract /** - * Every builder method shared by the v2 and v3 dialects. `Self` is the concrete - * builder each method returns, so a dialect that omits a method (v3 omits - * `like`/`ilike`) does not have it laundered back in by a chained call whose - * return type widened to the base interface. + * Every builder method shared by the TYPED ({@link EncryptedQueryBuilder}) and + * UNTYPED ({@link EncryptedQueryBuilderUntyped}) surfaces. Both are EQL v3 — + * they differ only in how much they can narrow, not in dialect. + * + * `Self` is the concrete builder each method returns, so a surface that omits a + * method does not have it laundered back in by a chained call whose return type + * widened to the base interface. * - * Free-text search is the ONLY axis on which the two dialects differ: v2 - * matches with SQL wildcards (`like`/`ilike` → `~~`), v3 with token containment - * (`contains` → `@>`). Each adds its own method below. + * Free-text search lives on the sub-interfaces rather than here, because its + * key set differs between the two: `matches()` narrows to the encrypted + * match/search columns on the typed surface, and to every row key on the + * untyped one. Neither surface carries `like`/`ilike` — EQL v3 free-text is + * fuzzy bloom-token matching, not SQL pattern matching, so the builder rewrites + * a `like` on an encrypted column to `matches` at record time (see + * `query-builder.ts`). They survive in this file only as the internal + * {@link FilterOp} union and on the raw {@link SupabaseQueryBuilder} seam, both + * of which still serve plaintext columns. */ export interface EncryptedQueryBuilderCore< T extends Record, FK extends StringKeyOf, Self, - /** Keys `order()` accepts. Defaults to `FK`, so the v2 surface is unchanged; - * v3 narrows it to plaintext columns (see {@link OrderableKeys}). */ + /** Keys `order()` accepts. The typed surface narrows it to the orderable + * columns (see {@link OrderableKeys}); it defaults to `FK` for the untyped + * surface, which has no capability information to narrow with. */ OK extends StringKeyOf = FK, - /** Keys the BOOLEAN form of `is()` accepts. Defaults to `FK`, so the v2 - * surface is unchanged; v3 narrows it to plaintext columns. Distinct from - * `OK` on purpose: "sortable" and "IS TRUE-able" are different capability - * axes that happen to select the same keys today, and narrowing `order()` - * later must not silently narrow `is()` with it. */ + /** Keys the BOOLEAN form of `is()` accepts. The typed surface narrows it to + * plaintext columns; it defaults to `FK` for the untyped surface, as `OK` + * does. Distinct from `OK` on purpose: "sortable" and "IS TRUE-able" are + * different capability axes that happen to select the same keys today, and + * narrowing `order()` later must not silently narrow `is()` with it. */ BK extends StringKeyOf = FK, > extends PromiseLike> { /** `columns` defaults to `'*'`, matching supabase-js. A `'*'` select expands - * to the introspected column list when one is available (v3), and otherwise - * throws — v2 has no column list to cast, so `select()` and `select('*')` - * both throw there. */ + * to the introspected column list; when none is available (a client that + * could not introspect) both `select()` and `select('*')` throw, because an + * unexpanded `*` cannot cast the encrypted columns with `::jsonb`. */ select( columns?: string, options?: { head?: boolean; count?: 'exact' | 'planned' | 'estimated' }, @@ -863,9 +887,10 @@ export interface EncryptedQueryBuilderCore< options?: { referencedTable?: string; foreignTable?: string }, ): Self match(query: Partial): Self - // `OK`, not `FK`: v3 cannot order by ANY encrypted column, because PostgREST - // cannot emit `ORDER BY eql_v3.ord_term(col)` and a bare `ORDER BY` sorts the - // ciphertext envelope. `OK` defaults to `FK`, so the v2 surface is unchanged. + // `OK`, not `FK`: an encrypted column is orderable only when its domain + // carries an OPE term (PostgREST reaches it as `col->op`); a bare `ORDER BY` + // would sort the ciphertext envelope. `OK` defaults to `FK` on the untyped + // surface, where the runtime `validateTransforms` guard is the only check. order( column: K, options?: { From 36b099be723f8ba5befaee48d87d5ad6503e9bf3 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 23:32:03 +1000 Subject: [PATCH 14/25] feat(stack-drizzle)!: remove EQL v2 root and collapse ./v3 to root Delete the EQL v2 authoring surface (`src/index.ts` `encryptedType` + config extraction, `src/operators.ts`, `src/schema-extraction.ts`) and promote the EQL v3 implementation from `./v3` to the package root as a hard break with no alias. - Drop the `./v3` subpath from the `exports` map and remove `typesVersions`; the v3 impl now lives at `src/*` and is the sole `.` export. - De-suffix the public API: `createEncryptionOperatorsV3` -> `createEncryptionOperators`, `extractEncryptionSchemaV3` -> `extractEncryptionSchema`. No `*V3` aliases (they would type-check v2 call sites against v3 semantics). - Delete the two v2 operator test files; move the v3 test suite to `__tests__/*` and repoint imports. ESM+CJS both preserved. - Fix the `@cipherstash/bench` importer: rewrite the Drizzle table to v3 `types.*` domains + `EncryptionV3`, switch operator usages off the removed v2-only ops (`like`/`ilike`, jsonb-path) to `matches`/`contains`/`selector`, and update the bench fixture schema.sql to the eql_v3 domains/index terms. - Update the README and the bundled `stash-drizzle` skill to the collapsed root imports; remove v2 authoring guidance. Existing v2 ciphertext still decrypts via `@cipherstash/stack`; only the Drizzle-side v2 authoring/query-building is removed (Decision 6). BREAKING CHANGE: `@cipherstash/stack-drizzle` no longer exports an EQL v2 surface and the `./v3` subpath is removed. Import the v3 API from the package root with the de-suffixed names. --- .changeset/remove-eql-v2-drizzle-root.md | 22 + .../__benches__/drizzle/operators.bench.ts | 4 +- .../drizzle/operators.explain.test.ts | 42 +- packages/bench/package.json | 1 + packages/bench/sql/schema.sql | 28 +- packages/bench/src/drizzle/setup.ts | 48 +- packages/bench/src/harness/seed.ts | 19 +- packages/stack-drizzle/README.md | 26 +- .../__tests__/{v3 => }/bigint.test.ts | 10 +- .../__tests__/{v3 => }/codec.test.ts | 2 +- .../__tests__/{v3 => }/column.test.ts | 2 +- .../drizzle-operators-bigint.test.ts | 59 - .../__tests__/drizzle-operators-jsonb.test.ts | 211 -- .../__tests__/{v3 => }/exports.test.ts | 6 +- .../__tests__/{v3 => }/operators.test-d.ts | 16 +- .../__tests__/{v3 => }/operators.test.ts | 30 +- .../{v3 => }/schema-extraction.test.ts | 22 +- .../__tests__/{v3 => }/selector.test.ts | 8 +- .../__tests__/{v3 => }/sql-dialect.test.ts | 2 +- .../__tests__/{v3 => }/types.test-d.ts | 2 +- .../__tests__/{v3 => }/types.test.ts | 4 +- packages/stack-drizzle/integration/adapter.ts | 16 +- .../stack-drizzle/integration/json-adapter.ts | 12 +- .../lock-context.integration.test.ts | 14 +- .../null-persistence.integration.test.ts | 14 +- .../relational.integration.test.ts | 16 +- packages/stack-drizzle/package.json | 17 - packages/stack-drizzle/src/{v3 => }/codec.ts | 0 packages/stack-drizzle/src/{v3 => }/column.ts | 0 packages/stack-drizzle/src/index.ts | 232 +- packages/stack-drizzle/src/operators.ts | 2586 +++++------------ .../stack-drizzle/src/schema-extraction.ts | 140 +- .../stack-drizzle/src/{v3 => }/sql-dialect.ts | 0 packages/stack-drizzle/src/{v3 => }/types.ts | 0 packages/stack-drizzle/src/v3/index.ts | 8 - packages/stack-drizzle/src/v3/operators.ts | 959 ------ .../stack-drizzle/src/v3/schema-extraction.ts | 50 - packages/stack-drizzle/tsup.config.ts | 2 +- skills/stash-drizzle/SKILL.md | 99 +- 39 files changed, 1043 insertions(+), 3686 deletions(-) create mode 100644 .changeset/remove-eql-v2-drizzle-root.md rename packages/stack-drizzle/__tests__/{v3 => }/bigint.test.ts (94%) rename packages/stack-drizzle/__tests__/{v3 => }/codec.test.ts (98%) rename packages/stack-drizzle/__tests__/{v3 => }/column.test.ts (99%) delete mode 100644 packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts delete mode 100644 packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts rename packages/stack-drizzle/__tests__/{v3 => }/exports.test.ts (91%) rename packages/stack-drizzle/__tests__/{v3 => }/operators.test-d.ts (87%) rename packages/stack-drizzle/__tests__/{v3 => }/operators.test.ts (97%) rename packages/stack-drizzle/__tests__/{v3 => }/schema-extraction.test.ts (82%) rename packages/stack-drizzle/__tests__/{v3 => }/selector.test.ts (96%) rename packages/stack-drizzle/__tests__/{v3 => }/sql-dialect.test.ts (98%) rename packages/stack-drizzle/__tests__/{v3 => }/types.test-d.ts (97%) rename packages/stack-drizzle/__tests__/{v3 => }/types.test.ts (87%) rename packages/stack-drizzle/src/{v3 => }/codec.ts (100%) rename packages/stack-drizzle/src/{v3 => }/column.ts (100%) rename packages/stack-drizzle/src/{v3 => }/sql-dialect.ts (100%) rename packages/stack-drizzle/src/{v3 => }/types.ts (100%) delete mode 100644 packages/stack-drizzle/src/v3/index.ts delete mode 100644 packages/stack-drizzle/src/v3/operators.ts delete mode 100644 packages/stack-drizzle/src/v3/schema-extraction.ts diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md new file mode 100644 index 000000000..66e61576f --- /dev/null +++ b/.changeset/remove-eql-v2-drizzle-root.md @@ -0,0 +1,22 @@ +--- +'@cipherstash/stack-drizzle': major +'stash': patch +--- + +Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collapse the EQL v3 `./v3` subpath into the package root. + +**Breaking (`@cipherstash/stack-drizzle`):** + +- The EQL v2 root exports are gone: `encryptedType`, the v2 `extractEncryptionSchema`, the v2 `createEncryptionOperators` (including the `like` / `ilike` operators), and `EncryptionConfigError`. Authoring or querying `eql_v2_encrypted` columns through Drizzle is no longer supported. +- The `./v3` subpath is **removed** from the package `exports` map and `typesVersions`. The EQL v3 implementation is now the package root, and the `*V3` names are de-suffixed (`createEncryptionOperatorsV3` → `createEncryptionOperators`, `extractEncryptionSchemaV3` → `extractEncryptionSchema`). This is a **hard break with no alias**: post-collapse the root names would collide with the removed v2 names, and keeping an alias would silently type-check v2 call sites against v3 semantics. + +**Migration** — import the v3 surface from the package root instead of `./v3`, and drop the `V3` suffix: + +```diff +- import { types, extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from '@cipherstash/stack-drizzle/v3' ++ import { types, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' +``` + +The `types.*` column factories, `makeEqlV3Column` / `getEqlV3Column` / `isEqlV3Column`, the codec helpers (`v3ToDriver` / `v3FromDriver` / `EqlV3CodecError`), and `EncryptionOperatorError` are unchanged apart from moving to the root. + +Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed. The bundled `stash-drizzle` skill is updated to the collapsed root imports. diff --git a/packages/bench/__benches__/drizzle/operators.bench.ts b/packages/bench/__benches__/drizzle/operators.bench.ts index 4dd7c99af..53422497f 100644 --- a/packages/bench/__benches__/drizzle/operators.bench.ts +++ b/packages/bench/__benches__/drizzle/operators.bench.ts @@ -44,8 +44,8 @@ describe('drizzle', () => { await handle.db.select().from(benchTable).where(where) }) - bench('like (prefix)', async () => { - const where = (await ops.like(benchTable.encText, '%value-00000%')) as SQL + bench('matches (free-text)', async () => { + const where = (await ops.matches(benchTable.encText, 'value')) as SQL await handle.db.select().from(benchTable).where(where) }) diff --git a/packages/bench/__tests__/drizzle/operators.explain.test.ts b/packages/bench/__tests__/drizzle/operators.explain.test.ts index 9aec76d4f..d3f58857e 100644 --- a/packages/bench/__tests__/drizzle/operators.explain.test.ts +++ b/packages/bench/__tests__/drizzle/operators.explain.test.ts @@ -104,13 +104,12 @@ async function tryExplainWhere(name: string, where: SQL): Promise { // --- #421: equality + array operators ------------------------------------- // -// `bench_text_hmac_idx` (functional hash on eql_v2.hmac_256) is the expected -// fast path. Pre-fix Drizzle emits bare `=` / `<>` / `IN (...)` which falls -// back to seq scan. Post-fix it emits `eql_v2.hmac_256(col) = -// eql_v2.hmac_256(value)` and the index scan kicks in. +// `bench_text_hmac_idx` (functional hash on eql_v3.eq_term) is the expected +// fast path. The v3 operators emit `eql_v3.eq_term(col) = eql_v3.hmac_256(value)` +// so the functional hash index scan kicks in instead of a seq scan. // // `eq` and `inArray` are naturally high-selectivity (only a few rows match), -// so the planner should pick the hmac index — assertion enforces it. +// so the planner should pick the eq_term index — assertion enforces it. // // `ne` and `notInArray` are naturally low-selectivity (almost all rows match); // even with the hmac index available the planner correctly chooses a seq @@ -161,14 +160,10 @@ describe('#421: equality and array operators', () => { // We don't yet know which call-shaped forms the planner inlines. Record plan // shape; assertions land in a follow-up once #422 closes. describe('#422: call-shaped operators (recorded, not asserted)', () => { - it('records like / ilike plan shapes', async () => { + it('records matches plan shape', async () => { await tryExplainWhere( - 'like', - (await ops.like(benchTable.encText, '%value-00000%')) as SQL, - ) - await tryExplainWhere( - 'ilike', - (await ops.ilike(benchTable.encText, '%VALUE-00000%')) as SQL, + 'matches', + (await ops.matches(benchTable.encText, 'value')) as SQL, ) }) @@ -190,20 +185,15 @@ describe('#422: call-shaped operators (recorded, not asserted)', () => { ) }) - it('records jsonb operator plan shapes', async () => { - for (const [name, build] of [ - [ - 'jsonbPathQueryFirst', - () => ops.jsonbPathQueryFirst(benchTable.encJsonb, '$.idx'), - ], - ['jsonbGet', () => ops.jsonbGet(benchTable.encJsonb, '$.idx')], - [ - 'jsonbPathExists', - () => ops.jsonbPathExists(benchTable.encJsonb, '$.idx'), - ], - ] as const) { - await tryExplainWhere(name, await build()) - } + it('records encrypted-JSONB plan shapes (contains / selector)', async () => { + await tryExplainWhere( + 'contains', + (await ops.contains(benchTable.encJsonb, { idx: 42 })) as SQL, + ) + await tryExplainWhere( + 'selector.gt', + (await ops.selector(benchTable.encJsonb, '$.idx').gt(5000)) as SQL, + ) }) it('records ORDER BY plan shape (asc / desc)', async () => { diff --git a/packages/bench/package.json b/packages/bench/package.json index 1aa5b847a..8c95c944a 100644 --- a/packages/bench/package.json +++ b/packages/bench/package.json @@ -5,6 +5,7 @@ "description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).", "type": "module", "scripts": { + "build": "tsc --noEmit", "db:setup": "tsx src/cli/setup.ts", "db:reset": "tsx src/cli/reset.ts", "test:local": "vitest run", diff --git a/packages/bench/sql/schema.sql b/packages/bench/sql/schema.sql index cf861d2be..9390fa5e8 100644 --- a/packages/bench/sql/schema.sql +++ b/packages/bench/sql/schema.sql @@ -1,30 +1,32 @@ -- Bench fixture schema. -- Single bench table covering text / int / jsonb encrypted columns plus the --- three canonical EQL functional indexes: hmac_256 (hash), bloom_filter (GIN), --- ste_vec (GIN). +-- three canonical EQL v3 functional indexes over the column-side index terms: +-- eq_term (hash), match_term (GIN bloom), ste_vec (GIN). -- --- We deliberately do NOT create the `eql_v2.encrypted_operator_class` btree --- indexes that ore-benches uses. Encrypted composites for full-feature columns --- (equality + match + ORE) blow past the 2704-byte btree page-size limit, and --- those indexes don't exist on Supabase anyway — the bench's whole job is to --- validate that the functional-index path works. +-- Mirrors `src/drizzle/setup.ts`: the columns are concrete `eql_v3_*` Postgres +-- domains (see `types.TextSearch` / `types.IntegerOrd` / `types.Json`). Install +-- the domains and index-term functions first with `stash eql install --eql-version 3`. +-- +-- We deliberately do NOT create btree operator-class indexes: encrypted terms for +-- full-feature columns blow past the 2704-byte btree page-size limit, and the +-- bench's whole job is to validate that the functional-index path works. DROP TABLE IF EXISTS bench; CREATE TABLE bench ( id SERIAL PRIMARY KEY, - enc_text eql_v2_encrypted NOT NULL, - enc_int eql_v2_encrypted NOT NULL, - enc_jsonb eql_v2_encrypted NOT NULL + enc_text public.eql_v3_text_search NOT NULL, + enc_int public.eql_v3_integer_ord NOT NULL, + enc_jsonb public.eql_v3_json_search NOT NULL ); CREATE INDEX bench_text_hmac_idx - ON bench USING hash (eql_v2.hmac_256(enc_text)); + ON bench USING hash (eql_v3.eq_term(enc_text)); CREATE INDEX bench_text_bloom_idx - ON bench USING gin (eql_v2.bloom_filter(enc_text)); + ON bench USING gin (eql_v3.match_term(enc_text)); CREATE INDEX bench_jsonb_stevec_idx - ON bench USING gin (eql_v2.ste_vec(enc_jsonb)); + ON bench USING gin (eql_v3.ste_vec(enc_jsonb)); ANALYZE bench; diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 944ece227..267ffc951 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -1,9 +1,5 @@ -import { Encryption } from '@cipherstash/stack' -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { - encryptedType, - extractEncryptionSchema, -} from '@cipherstash/stack-drizzle' +import { EncryptionV3 } from '@cipherstash/stack/v3' +import { extractEncryptionSchema, types } from '@cipherstash/stack-drizzle' import { drizzle } from 'drizzle-orm/node-postgres' import { pgTable, serial } from 'drizzle-orm/pg-core' import pg from 'pg' @@ -12,34 +8,23 @@ import { getDatabaseUrl } from '../harness/db.js' /** * Drizzle schema for the bench table. Mirrors `sql/schema.sql`. * - * `id` is `serial`; the encrypted columns are `eql_v2_encrypted` composites - * driven by `@cipherstash/stack-drizzle`'s `encryptedType`. + * `id` is `serial`; the encrypted columns are concrete `eql_v3_*` Postgres + * domains emitted by `@cipherstash/stack-drizzle`'s `types.*` factories. * - * Index config flags (`equality`, `freeTextSearch`, `orderAndRange`, - * `searchableJson`) are deliberately all on — the bench needs to exercise - * every query family that lands on the table. + * The domains are chosen to exercise every query family the bench lands on the + * table: `TextSearch` (equality + free-text + order/range), `IntegerOrd` + * (equality + order/range), and `Json` (encrypted-JSONB containment + selector). */ export const benchTable = pgTable('bench', { id: serial('id').primaryKey(), - encText: encryptedType('enc_text', { - equality: true, - freeTextSearch: true, - orderAndRange: true, - }), - encInt: encryptedType('enc_int', { - dataType: 'number', - equality: true, - orderAndRange: true, - }), - encJsonb: encryptedType<{ idx: number; group: number }>('enc_jsonb', { - dataType: 'json', - searchableJson: true, - }), + encText: types.TextSearch('enc_text'), + encInt: types.IntegerOrd('enc_int'), + encJsonb: types.Json('enc_jsonb'), }) /** - * Encryption schema for the stack `Encryption()` client. Derived from the - * Drizzle table above so the two can't drift apart. + * Encryption schema for the `EncryptionV3()` client. Derived from the Drizzle + * table above so the two can't drift apart. */ export const encryptionBenchTable = extractEncryptionSchema(benchTable) @@ -49,11 +34,14 @@ export type BenchPlaintextRow = { enc_jsonb: { idx: number; group: number } } +/** The typed EQL v3 client this bench drives. */ +export type BenchEncryptionClient = Awaited> + export type BenchHandle = { pgClient: pg.Client pool: pg.Pool db: ReturnType - encryptionClient: EncryptionClient + encryptionClient: BenchEncryptionClient } /** @@ -69,7 +57,9 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await Encryption({ schemas: [encryptionBenchTable] }) + const encryptionClient = await EncryptionV3({ + schemas: [encryptionBenchTable], + }) return { pgClient, pool, db, encryptionClient } } diff --git a/packages/bench/src/harness/seed.ts b/packages/bench/src/harness/seed.ts index c25d21c7a..e9fc828b6 100644 --- a/packages/bench/src/harness/seed.ts +++ b/packages/bench/src/harness/seed.ts @@ -53,11 +53,10 @@ export async function seed( plaintexts.push(makePlaintextRow(rowsBefore + i)) } - const encResult = - await h.encryptionClient.bulkEncryptModels( - plaintexts, - encryptionBenchTable, - ) + const encResult = await h.encryptionClient.bulkEncryptModels( + plaintexts, + encryptionBenchTable, + ) if (encResult.failure) { throw new Error( `[bench:seed] bulkEncryptModels failed: ${encResult.failure.message}`, @@ -65,12 +64,12 @@ export async function seed( } // bulkEncryptModels returns rows keyed by the encryptedTable column names - // (snake_case here). Drizzle's `benchTable` uses camelCase TS field names — - // remap before insert. + // (snake_case here) with encrypted EQL v3 envelopes as values. Drizzle's + // `benchTable` uses camelCase TS field names — remap before insert. const encRows = encResult.data.map((r) => ({ - encText: r.enc_text as unknown as string, - encInt: r.enc_int as unknown as number, - encJsonb: r.enc_jsonb as unknown as { idx: number; group: number }, + encText: r.enc_text, + encInt: r.enc_int, + encJsonb: r.enc_jsonb, })) for (let i = 0; i < encRows.length; i += INSERT_BATCH) { diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md index e78a27caf..0d5b0efb4 100644 --- a/packages/stack-drizzle/README.md +++ b/packages/stack-drizzle/README.md @@ -9,7 +9,7 @@ Depends on `@cipherstash/stack`; install both: npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm ``` -## EQL v3 (`/v3` subpath) +## Usage (EQL v3) Each encrypted column is a concrete `public.eql_v3_*` Postgres domain whose query capabilities are fixed by the `types.*` factory you choose — no per-column config @@ -20,9 +20,9 @@ import { pgTable, integer } from 'drizzle-orm/pg-core' import { EncryptionV3 } from '@cipherstash/stack/v3' import { types as encryptedTypes, - extractEncryptionSchemaV3, - createEncryptionOperatorsV3, -} from '@cipherstash/stack-drizzle/v3' + extractEncryptionSchema, + createEncryptionOperators, +} from '@cipherstash/stack-drizzle' const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -30,9 +30,9 @@ const users = pgTable('users', { age: encryptedTypes.IntegerOrd('age'), // equality + order/range }) -const schema = extractEncryptionSchemaV3(users) +const schema = extractEncryptionSchema(users) const client = await EncryptionV3({ schemas: [schema] }) -const ops = createEncryptionOperatorsV3(client) +const ops = createEncryptionOperators(client) // Insert — encrypt models first const enc = await client.bulkEncryptModels( @@ -60,20 +60,6 @@ comparisons and ordering at a scalar JSONPath leaf. For example, `.orderBy(await ops.selector(users.profile, '$.age').asc())` lowers to `ORDER BY eql_v3.ord_term(...)` over the selected encrypted entry. -## EQL v2 (package root) — legacy - -The v2 integration predates the typed v3 domains and is kept for existing -projects. New projects should use v3 above. - -```ts -import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from '@cipherstash/stack-drizzle' -import { Encryption } from '@cipherstash/stack' -``` - -`encryptedType` defines an `eql_v2_encrypted` column; `createEncryptionOperators` -returns query operators (`eq`, `like`, `gt`, `inArray`, …) that transparently -encrypt search values. - ## Docs Full guide: https://cipherstash.com/docs/integrations/drizzle — see also the diff --git a/packages/stack-drizzle/__tests__/v3/bigint.test.ts b/packages/stack-drizzle/__tests__/bigint.test.ts similarity index 94% rename from packages/stack-drizzle/__tests__/v3/bigint.test.ts rename to packages/stack-drizzle/__tests__/bigint.test.ts index e31a6a4e2..7cfbf9f3d 100644 --- a/packages/stack-drizzle/__tests__/v3/bigint.test.ts +++ b/packages/stack-drizzle/__tests__/bigint.test.ts @@ -1,12 +1,12 @@ import { type SQL, sql } from 'drizzle-orm' import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it, vi } from 'vitest' -import { getEqlV3Column, isEqlV3Column } from '../../src/v3/column' +import { getEqlV3Column, isEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, -} from '../../src/v3/operators' -import { types } from '../../src/v3/types' +} from '../src/operators' +import { types } from '../src/types' // A representative query TERM — what `client.encryptQuery` returns for a bigint // operand: a ciphertext-free term, deliberately NOT a plaintext bigint. @@ -25,7 +25,7 @@ function chainable(result: unknown) { function setup() { const encryptQuery = vi.fn(() => chainable(Promise.resolve({ data: TERM }))) - const ops = createEncryptionOperatorsV3({ encryptQuery }) + const ops = createEncryptionOperators({ encryptQuery }) const dialect = new PgDialect() const render = (s: SQL) => dialect.sqlToQuery(s) return { ops, encryptQuery, render } diff --git a/packages/stack-drizzle/__tests__/v3/codec.test.ts b/packages/stack-drizzle/__tests__/codec.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/codec.test.ts rename to packages/stack-drizzle/__tests__/codec.test.ts index 3049c2c2a..6d44985a4 100644 --- a/packages/stack-drizzle/__tests__/v3/codec.test.ts +++ b/packages/stack-drizzle/__tests__/codec.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { EqlV3CodecError, v3FromDriver, v3ToDriver } from '../../src/v3/codec' +import { EqlV3CodecError, v3FromDriver, v3ToDriver } from '../src/codec' // A realistic `public.eql_v3_text_eq` envelope: schema version, table/column // identifier, mp_base85 ciphertext, HMAC term. The trivial `{v:1,c:'ct'}` diff --git a/packages/stack-drizzle/__tests__/v3/column.test.ts b/packages/stack-drizzle/__tests__/column.test.ts similarity index 99% rename from packages/stack-drizzle/__tests__/v3/column.test.ts rename to packages/stack-drizzle/__tests__/column.test.ts index 9ca47384d..dfca7f9b8 100644 --- a/packages/stack-drizzle/__tests__/v3/column.test.ts +++ b/packages/stack-drizzle/__tests__/column.test.ts @@ -12,7 +12,7 @@ import { getEqlV3Column, isEqlV3Column, makeEqlV3Column, -} from '../../src/v3/column' +} from '../src/column' describe('makeEqlV3Column', () => { it('sets dataType() to the BARE eql_v3 domain (no schema qualifier)', () => { diff --git a/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts b/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts deleted file mode 100644 index e71e47622..000000000 --- a/packages/stack-drizzle/__tests__/drizzle-operators-bigint.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { PgDialect, pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' -import { createEncryptionOperators, encryptedType } from '../src/index.js' - -// Regression coverage for the `bigint` (int8) plaintext path through the v3 -// Drizzle operators. Before the fix, `toPlaintext` fell through to -// `String(value)`, so `ops.eq(bigintCol, 1n)` was encrypted as the TEXT "1" and -// silently mismatched the column's bigint domain. The operators must forward a -// native `bigint` to `encryptQuery` (whose term type is `Plaintext`), so -// protect-ffi 0.28 encrypts it against the int8 domain and bounds-checks it. - -const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -function setup() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => - Array.isArray(termsOrValue) - ? { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - : { data: ENCRYPTED_VALUE }, - ) - const client = { encryptQuery } as unknown as EncryptionClient - const encryptionOps = createEncryptionOperators(client) - const dialect = new PgDialect() - return { encryptQuery, encryptionOps, dialect } -} - -const accounts = pgTable('accounts', { - balance: encryptedType('balance', { - dataType: 'bigint', - equality: true, - orderAndRange: true, - }), -}) - -describe('Drizzle v3 operators preserve bigint plaintext (int8 columns)', () => { - it('eq forwards a native bigint to encryptQuery, not a String()-coerced value', async () => { - const { encryptQuery, encryptionOps } = setup() - - await encryptionOps.eq(accounts.balance, 1n) - - expect(encryptQuery).toHaveBeenCalledTimes(1) - const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ value: unknown }> - expect(terms).toHaveLength(1) - expect(typeof terms[0]?.value).toBe('bigint') - expect(terms[0]?.value).toBe(1n) - // The pre-fix bug: a stringified bigint. - expect(terms[0]?.value).not.toBe('1') - }) - - it('preserves i64 boundary magnitudes losslessly (beyond Number.MAX_SAFE_INTEGER)', async () => { - const { encryptQuery, encryptionOps } = setup() - const big = 9223372036854775807n // i64::MAX - - await encryptionOps.gt(accounts.balance, big) - - const terms = encryptQuery.mock.calls[0]?.[0] as Array<{ value: unknown }> - expect(terms[0]?.value).toBe(big) - }) -}) diff --git a/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts b/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts deleted file mode 100644 index 47ab6bce0..000000000 --- a/packages/stack-drizzle/__tests__/drizzle-operators-jsonb.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' -import { PgDialect, pgTable } from 'drizzle-orm/pg-core' -import { describe, expect, it, vi } from 'vitest' -import { - createEncryptionOperators, - EncryptionOperatorError, - encryptedType, -} from '../src/index.js' - -const ENCRYPTED_VALUE = '{"v":"encrypted-value"}' - -function createMockEncryptionClient() { - const encryptQuery = vi.fn(async (termsOrValue: unknown) => { - if (Array.isArray(termsOrValue)) { - return { data: termsOrValue.map(() => ENCRYPTED_VALUE) } - } - return { data: ENCRYPTED_VALUE } - }) - - return { - client: { encryptQuery } as unknown as EncryptionClient, - encryptQuery, - } -} - -function setup() { - const { client, encryptQuery } = createMockEncryptionClient() - const encryptionOps = createEncryptionOperators(client) - const dialect = new PgDialect() - return { client, encryptQuery, encryptionOps, dialect } -} - -const docsTable = pgTable('json_docs', { - metadata: encryptedType>('metadata', { - dataType: 'json', - searchableJson: true, - }), - noJsonConfig: encryptedType('no_json_config', { - equality: true, - }), -}) - -describe('createEncryptionOperators JSONB selector typing', () => { - it('casts jsonbPathQueryFirst selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbPathQueryFirst( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_query_first\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbPathExists selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbPathExists( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch( - /eql_v2\.jsonb_path_exists\([^,]+,\s*\$\d+::eql_v2_encrypted\)/, - ) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) - - it('casts jsonbGet selector params to eql_v2_encrypted', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.jsonbGet( - docsTable.metadata, - '$.profile.email', - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toMatch(/->\s*\$\d+::eql_v2_encrypted/) - expect(query.params).toHaveLength(1) - expect(query.params[0]).toContain('encrypted-value') - expect(encryptQuery).toHaveBeenCalledTimes(1) - expect(encryptQuery.mock.calls[0]?.[0]).toMatchObject([ - { queryType: 'steVecSelector' }, - ]) - }) -}) - -describe('JSONB operator error paths', () => { - it('throws EncryptionOperatorError when column lacks searchableJson config', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - - expect(() => - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path'), - ).toThrow(/searchableJson/) - }) - - it('throws EncryptionOperatorError for jsonbPathExists without searchableJson', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbPathExists(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - }) - - it('throws EncryptionOperatorError for jsonbGet without searchableJson', () => { - const { encryptionOps } = setup() - - expect(() => - encryptionOps.jsonbGet(docsTable.noJsonConfig, '$.path'), - ).toThrow(EncryptionOperatorError) - }) - - it('error includes column name and operator context', () => { - const { encryptionOps } = setup() - - try { - encryptionOps.jsonbPathQueryFirst(docsTable.noJsonConfig, '$.path') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(EncryptionOperatorError) - const opError = error as EncryptionOperatorError - expect(opError.context?.columnName).toBe('no_json_config') - expect(opError.context?.operator).toBe('jsonbPathQueryFirst') - } - }) -}) - -describe('JSONB batched operations', () => { - it('batches jsonbPathQueryFirst and jsonbGet in encryptionOps.and()', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.and( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.email'), - encryptionOps.jsonbGet(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - expect(query.sql).toContain('->') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('batches jsonbPathExists and jsonbPathQueryFirst in encryptionOps.or()', async () => { - const { encryptQuery, encryptionOps, dialect } = setup() - - const condition = await encryptionOps.or( - encryptionOps.jsonbPathExists(docsTable.metadata, '$.profile.email'), - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.profile.name'), - ) - const query = dialect.sqlToQuery(condition) - - expect(query.sql).toContain('eql_v2.jsonb_path_exists') - expect(query.sql).toContain('eql_v2.jsonb_path_query_first') - // Verify batch encryption happened (at least one call with 2 terms) - expect( - encryptQuery.mock.calls.some( - (call: unknown[]) => Array.isArray(call[0]) && call[0].length === 2, - ), - ).toBe(true) - }) - - it('generates SQL combining conditions with AND', async () => { - const { encryptionOps, dialect } = setup() - - const condition = await encryptionOps.and( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - encryptionOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // AND combines conditions - expect(query.sql).toContain(' and ') - }) - - it('generates SQL combining conditions with OR', async () => { - const { encryptionOps, dialect } = setup() - - const condition = await encryptionOps.or( - encryptionOps.jsonbPathQueryFirst(docsTable.metadata, '$.a'), - encryptionOps.jsonbPathExists(docsTable.metadata, '$.b'), - ) - const query = dialect.sqlToQuery(condition) - - // OR combines conditions - expect(query.sql).toContain(' or ') - }) -}) diff --git a/packages/stack-drizzle/__tests__/v3/exports.test.ts b/packages/stack-drizzle/__tests__/exports.test.ts similarity index 91% rename from packages/stack-drizzle/__tests__/v3/exports.test.ts rename to packages/stack-drizzle/__tests__/exports.test.ts index 4ad6d98e3..299954fa0 100644 --- a/packages/stack-drizzle/__tests__/v3/exports.test.ts +++ b/packages/stack-drizzle/__tests__/exports.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import * as barrel from '../../src/v3/index.js' +import * as barrel from '../src/index.js' // Exhaustive, so ADDING an export fails here too. The previous version asserted // only four names, leaving the codec/column re-exports unpinned and letting a @@ -7,8 +7,8 @@ import * as barrel from '../../src/v3/index.js' const PUBLIC_SURFACE = [ 'EncryptionOperatorError', 'EqlV3CodecError', - 'createEncryptionOperatorsV3', - 'extractEncryptionSchemaV3', + 'createEncryptionOperators', + 'extractEncryptionSchema', 'getEqlV3Column', 'isEqlV3Column', 'makeEqlV3Column', diff --git a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts b/packages/stack-drizzle/__tests__/operators.test-d.ts similarity index 87% rename from packages/stack-drizzle/__tests__/v3/operators.test-d.ts rename to packages/stack-drizzle/__tests__/operators.test-d.ts index 7c04db648..b5e1be918 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/operators.test-d.ts @@ -6,12 +6,12 @@ import type { LockContext } from '@cipherstash/stack/identity' import type { EncryptedQueryResult } from '@cipherstash/stack/types' import type { EncryptionV3 } from '@cipherstash/stack/v3' import { describe, expectTypeOf, it } from 'vitest' -import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' +import { createEncryptionOperators } from '../src/index.js' /** - * Static regression guard for M1: `createEncryptionOperatorsV3` must accept the + * Static regression guard for M1: `createEncryptionOperators` must accept the * `TypedEncryptionClient` that `EncryptionV3` resolves to — the documented - * `createEncryptionOperatorsV3(await EncryptionV3({ schemas }))` usage — as well + * `createEncryptionOperators(await EncryptionV3({ schemas }))` usage — as well * as the nominal `EncryptionClient` and a hand-rolled `{ encryptQuery }` double, * none requiring a cast. Typing the parameter to `EncryptionClient` (the * original bug) makes the first call below a compile error, which this suite @@ -20,7 +20,7 @@ import { createEncryptionOperatorsV3 } from '../../src/v3/index.js' * forms; the doubles model that. Lives in a `*.test-d.ts` so it is inside the * existing typecheck scope without dragging the loose-typed runtime suites in. */ -describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { +describe('createEncryptionOperators - client parameter (M1)', () => { type V3Client = Awaited> // A query operation resolving `Result` — the surface the factory drives. @@ -31,11 +31,11 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { } it('accepts the client EncryptionV3 returns with no cast', () => { - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith({} as V3Client) + expectTypeOf(createEncryptionOperators).toBeCallableWith({} as V3Client) }) it('still accepts the nominal EncryptionClient', () => { - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith( + expectTypeOf(createEncryptionOperators).toBeCallableWith( {} as EncryptionClient, ) }) @@ -57,7 +57,7 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { // the operand-client contract, so a structural double must supply it too. encrypt: (_value: never, _opts: never): QueryOp => ({}) as never, } - expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double) + expectTypeOf(createEncryptionOperators).toBeCallableWith(double) }) it('rejects an { encryptQuery } double that resolves `unknown` (the erasure regression)', () => { @@ -76,6 +76,6 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { } // @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the // factory's `ChainableOperation` client contract. - createEncryptionOperatorsV3(erased) + createEncryptionOperators(erased) }) }) diff --git a/packages/stack-drizzle/__tests__/v3/operators.test.ts b/packages/stack-drizzle/__tests__/operators.test.ts similarity index 97% rename from packages/stack-drizzle/__tests__/v3/operators.test.ts rename to packages/stack-drizzle/__tests__/operators.test.ts index 3a5ccc120..bc2456173 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test.ts +++ b/packages/stack-drizzle/__tests__/operators.test.ts @@ -17,13 +17,13 @@ import { } from 'drizzle-orm' import { integer, PgDialect, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it, vi } from 'vitest' -import { makeEqlV3Column } from '../../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, -} from '../../src/v3/operators' -import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' -import { types } from '../../src/v3/types' +} from '../src/operators' +import { extractEncryptionSchema } from '../src/schema-extraction' +import { types } from '../src/types' // A query TERM (from `encryptQuery`), not a storage envelope: it carries index // terms but NO ciphertext `c` — that is the whole point of the encrypt → @@ -75,7 +75,7 @@ function setup(termImpl: (value?: unknown) => unknown = () => TERM) { // The factory's `client` parameter is the structural `{ encryptQuery }` // surface, so this hand-rolled double satisfies it with no cast. const client = { encryptQuery } - const ops = createEncryptionOperatorsV3(client, { lockContext, audit }) + const ops = createEncryptionOperators(client, { lockContext, audit }) const dialect = new PgDialect() const render = (s: unknown) => dialect.sqlToQuery(s as SQL) return { ops, encryptQuery, render } @@ -138,7 +138,7 @@ const users = pgTable('users', { flag: types.Boolean('flag'), }) -describe('createEncryptionOperatorsV3 - equality', () => { +describe('createEncryptionOperators - equality', () => { it.each( equalityDomains, )('%s eq emits the latest two-arg eql_v3.eq with a query-term operand', async (eqlType, spec) => { @@ -198,7 +198,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { await ops.eq(second.age, 37) expect(encryptQuery.mock.calls[1]?.[1]?.table.build()).toEqual( - extractEncryptionSchemaV3(second).build(), + extractEncryptionSchema(second).build(), ) }) @@ -228,7 +228,7 @@ describe('createEncryptionOperatorsV3 - equality', () => { }) }) -describe('createEncryptionOperatorsV3 - comparison & range', () => { +describe('createEncryptionOperators - comparison & range', () => { it.each([ ['gt', 'eql_v3.gt'], ['gte', 'eql_v3.gte'], @@ -355,7 +355,7 @@ describe('createEncryptionOperatorsV3 - comparison & range', () => { }) }) -describe('createEncryptionOperatorsV3 - free-text match', () => { +describe('createEncryptionOperators - free-text match', () => { it.each( matchDomains, )('%s matches emits latest eql_v3.matches with a query-term operand', async (eqlType, spec) => { @@ -413,7 +413,7 @@ describe('createEncryptionOperatorsV3 - free-text match', () => { }) }) -describe('createEncryptionOperatorsV3 - JSON containment', () => { +describe('createEncryptionOperators - JSON containment', () => { const JSON_TYPE = 'public.eql_v3_json_search' // json has no `eql_v3.matches` overload: containment is the `@>` operator, @@ -476,7 +476,7 @@ describe('createEncryptionOperatorsV3 - JSON containment', () => { }) }) -describe('createEncryptionOperatorsV3 - JSON selectors', () => { +describe('createEncryptionOperators - JSON selectors', () => { const JSON_TYPE = 'public.eql_v3_json_search' const column = matrixColumn(JSON_TYPE) const VALUE_SELECTOR = { sv: [{ s: 'value-selector' }] } @@ -565,7 +565,7 @@ describe('createEncryptionOperatorsV3 - JSON selectors', () => { }) }) -describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { +describe('createEncryptionOperators - domains with no scalar query', () => { it.each(nonScalarQueryDomains)('%s eq throws', async (eqlType, spec) => { const { ops } = setup() await expect( @@ -588,7 +588,7 @@ describe('createEncryptionOperatorsV3 - domains with no scalar query', () => { }) }) -describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { +describe('createEncryptionOperators - array, ordering, combinators', () => { it('inArray ORs one eq term per value; empty array throws', async () => { const { ops, render } = setup() const q = render(await ops.inArray(users.nickname, ['ada', 'grace'])) @@ -784,7 +784,7 @@ describe('createEncryptionOperatorsV3 - array, ordering, combinators', () => { }) }) -describe('createEncryptionOperatorsV3 - gating errors', () => { +describe('createEncryptionOperators - gating errors', () => { it('wraps encryption failures with operator context', async () => { const { ops, encryptQuery } = setup() encryptQuery.mockReturnValueOnce( diff --git a/packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts b/packages/stack-drizzle/__tests__/schema-extraction.test.ts similarity index 82% rename from packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts rename to packages/stack-drizzle/__tests__/schema-extraction.test.ts index e3767fcc0..4c9c32a6d 100644 --- a/packages/stack-drizzle/__tests__/v3/schema-extraction.test.ts +++ b/packages/stack-drizzle/__tests__/schema-extraction.test.ts @@ -1,10 +1,10 @@ import { encryptedTable, types as v3Types } from '@cipherstash/stack/eql/v3' import { integer, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { extractEncryptionSchemaV3 } from '../../src/v3/schema-extraction' -import { types } from '../../src/v3/types' +import { extractEncryptionSchema } from '../src/schema-extraction' +import { types } from '../src/types' -describe('extractEncryptionSchemaV3', () => { +describe('extractEncryptionSchema', () => { it('rebuilds an equivalent eql/v3 encryptedTable from every drizzle v3 factory', () => { const drizzleColumns = Object.fromEntries( Object.entries(types).map(([factoryName, factory]) => [ @@ -19,7 +19,7 @@ describe('extractEncryptionSchemaV3', () => { ]), ) - const extracted = extractEncryptionSchemaV3( + const extracted = extractEncryptionSchema( pgTable('users', drizzleColumns as never), ) const authored = encryptedTable('users', authoredColumns as never) @@ -34,7 +34,7 @@ describe('extractEncryptionSchemaV3', () => { age: types.IntegerOrd('age'), }) - const extracted = extractEncryptionSchemaV3(users) + const extracted = extractEncryptionSchema(users) const authored = encryptedTable('users', { email: v3Types.TextSearch('email'), age: v3Types.IntegerOrd('age'), @@ -51,8 +51,8 @@ describe('extractEncryptionSchemaV3', () => { email: types.IntegerOrd('email'), }) - const accountsSchema = extractEncryptionSchemaV3(accounts) - const metricsSchema = extractEncryptionSchemaV3(metrics) + const accountsSchema = extractEncryptionSchema(accounts) + const metricsSchema = extractEncryptionSchema(metrics) expect(accountsSchema.build()).toStrictEqual( encryptedTable('accounts', { @@ -72,7 +72,7 @@ describe('extractEncryptionSchemaV3', () => { emailAddress: types.TextEq('email_address'), }) - expect(extractEncryptionSchemaV3(users).build()).toStrictEqual( + expect(extractEncryptionSchema(users).build()).toStrictEqual( encryptedTable('users', { createdOn: v3Types.Date('created_on'), emailAddress: v3Types.TextEq('email_address'), @@ -89,7 +89,7 @@ describe('extractEncryptionSchemaV3', () => { }, } - expect(extractEncryptionSchemaV3(table as never).build()).toStrictEqual( + expect(extractEncryptionSchema(table as never).build()).toStrictEqual( encryptedTable('users', { createdOn: v3Types.Date('created_on'), }).build(), @@ -98,12 +98,12 @@ describe('extractEncryptionSchemaV3', () => { it('throws when the table has no encrypted v3 columns', () => { const plain = pgTable('plain', { id: integer() }) - expect(() => extractEncryptionSchemaV3(plain)).toThrow(/no encrypted v3/i) + expect(() => extractEncryptionSchema(plain)).toThrow(/no encrypted v3/i) }) it('throws the table-name error before checking for encrypted columns', () => { expect(() => - extractEncryptionSchemaV3({ + extractEncryptionSchema({ secret: { getSQLType: () => 'public.eql_v3_text_eq', name: 'secret' }, } as never), ).toThrow('Unable to read table name from Drizzle table.') diff --git a/packages/stack-drizzle/__tests__/v3/selector.test.ts b/packages/stack-drizzle/__tests__/selector.test.ts similarity index 96% rename from packages/stack-drizzle/__tests__/v3/selector.test.ts rename to packages/stack-drizzle/__tests__/selector.test.ts index e3c2296cf..cf2fec265 100644 --- a/packages/stack-drizzle/__tests__/v3/selector.test.ts +++ b/packages/stack-drizzle/__tests__/selector.test.ts @@ -1,12 +1,12 @@ import { integer, pgTable } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, parseSelectorSegments, reconstructSelectorDocument, -} from '../../src/v3/operators.js' -import { types } from '../../src/v3/types.js' +} from '../src/operators.js' +import { types } from '../src/types.js' describe('parseSelectorSegments', () => { it('parses $-rooted, bare, and whitespace-padded dot paths', () => { @@ -70,7 +70,7 @@ describe('ops.selector — up-front guards (no encryption reached)', () => { const failIfCalled = () => { throw new Error('guard should reject before encrypting') } - const ops = createEncryptionOperatorsV3({ + const ops = createEncryptionOperators({ encryptQuery: failIfCalled, encrypt: failIfCalled, } as never) diff --git a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts b/packages/stack-drizzle/__tests__/sql-dialect.test.ts similarity index 98% rename from packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts rename to packages/stack-drizzle/__tests__/sql-dialect.test.ts index 42feef81a..bd67750b3 100644 --- a/packages/stack-drizzle/__tests__/v3/sql-dialect.test.ts +++ b/packages/stack-drizzle/__tests__/sql-dialect.test.ts @@ -1,7 +1,7 @@ import { not, sql } from 'drizzle-orm' import { PgDialect } from 'drizzle-orm/pg-core' import { describe, expect, it } from 'vitest' -import { EQL_V3_FN_SCHEMA, v3Dialect } from '../../src/v3/sql-dialect' +import { EQL_V3_FN_SCHEMA, v3Dialect } from '../src/sql-dialect' const dialect = new PgDialect() const render = (s: ReturnType) => dialect.sqlToQuery(s).sql diff --git a/packages/stack-drizzle/__tests__/v3/types.test-d.ts b/packages/stack-drizzle/__tests__/types.test-d.ts similarity index 97% rename from packages/stack-drizzle/__tests__/v3/types.test-d.ts rename to packages/stack-drizzle/__tests__/types.test-d.ts index 55b8022c2..18f81ae88 100644 --- a/packages/stack-drizzle/__tests__/v3/types.test-d.ts +++ b/packages/stack-drizzle/__tests__/types.test-d.ts @@ -1,7 +1,7 @@ import type { types as v3Types } from '@cipherstash/stack/eql/v3' import type { Encrypted } from '@cipherstash/stack/types' import { describe, expectTypeOf, it } from 'vitest' -import { types } from '../../src/v3/types' +import { types } from '../src/types' describe('v3 drizzle types - type-level', () => { it('exposes exactly the same factory keys as @/eql/v3 types', () => { diff --git a/packages/stack-drizzle/__tests__/v3/types.test.ts b/packages/stack-drizzle/__tests__/types.test.ts similarity index 87% rename from packages/stack-drizzle/__tests__/v3/types.test.ts rename to packages/stack-drizzle/__tests__/types.test.ts index face747e1..b82251a9a 100644 --- a/packages/stack-drizzle/__tests__/v3/types.test.ts +++ b/packages/stack-drizzle/__tests__/types.test.ts @@ -1,7 +1,7 @@ import { types as v3Types } from '@cipherstash/stack/eql/v3' import { describe, expect, it } from 'vitest' -import { getEqlV3Column } from '../../src/v3/column' -import { types } from '../../src/v3/types' +import { getEqlV3Column } from '../src/column' +import { types } from '../src/types' describe('v3 drizzle types namespace', () => { it('exposes the same factory names as @/eql/v3 types', () => { diff --git a/packages/stack-drizzle/integration/adapter.ts b/packages/stack-drizzle/integration/adapter.ts index 524ad33d1..a2e1ea848 100644 --- a/packages/stack-drizzle/integration/adapter.ts +++ b/packages/stack-drizzle/integration/adapter.ts @@ -11,11 +11,11 @@ import { asc, type SQL } from 'drizzle-orm' import { type PgTable, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' /** * The Drizzle v3 adapter under real ZeroKMS ciphertext. @@ -58,9 +58,9 @@ export function makeDrizzleAdapter(): IntegrationAdapter { let sqlClient: postgres.Sql let db: ReturnType let client: Awaited> - let ops: ReturnType + let ops: ReturnType let table: AnyTable - let schema: ReturnType + let schema: ReturnType let tableName: string const column = (slug: string) => table[slug] @@ -140,7 +140,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { rowKey: text('row_key').primaryKey(), ...columns, }) - schema = extractEncryptionSchemaV3(table) + schema = extractEncryptionSchema(table) // The DDL comes from the column builders, not from a hand-written list, so // a domain rename cannot silently desync the table from the schema. @@ -158,7 +158,7 @@ export function makeDrizzleAdapter(): IntegrationAdapter { // The client must know this table's schema to encrypt for it. Rebuilt per // family, since each family has its own columns. client = await EncryptionV3({ schemas: [schema as never] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) }, async insertSingle(_spec: TableSpec, row: PlainRow) { diff --git a/packages/stack-drizzle/integration/json-adapter.ts b/packages/stack-drizzle/integration/json-adapter.ts index 181b98fb5..2ed37577b 100644 --- a/packages/stack-drizzle/integration/json-adapter.ts +++ b/packages/stack-drizzle/integration/json-adapter.ts @@ -11,10 +11,10 @@ import { type PgTable, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, + createEncryptionOperators, + extractEncryptionSchema, types, -} from '../src/v3/index.js' +} from '../src/index.js' // biome-ignore lint/suspicious/noExplicitAny: the table name is supplied by the shared suite at runtime type AnyTable = any @@ -26,7 +26,7 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { let tableName: string let table: AnyTable let client: Awaited> - let ops: ReturnType + let ops: ReturnType const rowsFor = async ( where: SQL | undefined, @@ -71,9 +71,9 @@ export function makeDrizzleJsonAdapter(): JsonIntegrationAdapter { rowKey: text('row_key').primaryKey(), document: types.Json('document'), }) - const schema = extractEncryptionSchemaV3(table) + const schema = extractEncryptionSchema(table) client = await EncryptionV3({ schemas: [schema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) await sqlClient.unsafe(`DROP TABLE IF EXISTS ${tableName}`) await sqlClient.unsafe(` diff --git a/packages/stack-drizzle/integration/lock-context.integration.test.ts b/packages/stack-drizzle/integration/lock-context.integration.test.ts index 293c34853..8167c0edf 100644 --- a/packages/stack-drizzle/integration/lock-context.integration.test.ts +++ b/packages/stack-drizzle/integration/lock-context.integration.test.ts @@ -41,11 +41,11 @@ import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -67,12 +67,12 @@ const secretTable = pgTable(TABLE_NAME, { secret: makeEqlV3Column(V3_MATRIX['public.eql_v3_text_eq'].builder('secret')), } as never) -const schema = extractEncryptionSchemaV3(secretTable) +const schema = extractEncryptionSchema(secretTable) type SelectRow = { rowKey: string } let client: Awaited> -let ops: ReturnType +let ops: ReturnType let db: ReturnType /** @@ -159,7 +159,7 @@ beforeAll(async () => { schemas: [schema], config: { authStrategy: federation.data }, }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) await sqlClient.unsafe(` diff --git a/packages/stack-drizzle/integration/null-persistence.integration.test.ts b/packages/stack-drizzle/integration/null-persistence.integration.test.ts index 6534c60ac..f97c1d29f 100644 --- a/packages/stack-drizzle/integration/null-persistence.integration.test.ts +++ b/packages/stack-drizzle/integration/null-persistence.integration.test.ts @@ -20,11 +20,11 @@ import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from '../src/v3/index.js' + createEncryptionOperators, + extractEncryptionSchema, +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -80,12 +80,12 @@ const TIERS = [ }, ] as const -const schema = extractEncryptionSchemaV3(nullableTable) +const schema = extractEncryptionSchema(nullableTable) type SelectRow = { rowKey: string } let client: Awaited> -let ops: ReturnType +let ops: ReturnType let db: ReturnType function unwrap(result: { data?: T; failure?: { message: string } }): T { @@ -108,7 +108,7 @@ async function selectRowKeys(condition: SQL): Promise { beforeAll(async () => { // EQL v3 is installed once per run by `global-setup.ts`. client = await EncryptionV3({ schemas: [schema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) const columnDefs = TIERS.map((t) => `"${t.db}" ${t.domain}`).join(',\n ') diff --git a/packages/stack-drizzle/integration/relational.integration.test.ts b/packages/stack-drizzle/integration/relational.integration.test.ts index ee36fcf7f..8e6eecb38 100644 --- a/packages/stack-drizzle/integration/relational.integration.test.ts +++ b/packages/stack-drizzle/integration/relational.integration.test.ts @@ -43,13 +43,13 @@ import { integer, pgTable, text } from 'drizzle-orm/pg-core' import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { makeEqlV3Column } from '../src/v3/column' +import { makeEqlV3Column } from '../src/column' import { - createEncryptionOperatorsV3, + createEncryptionOperators, EncryptionOperatorError, - extractEncryptionSchemaV3, + extractEncryptionSchema, types as v3drizzle, -} from '../src/v3/index.js' +} from '../src/index.js' const sqlClient = postgres(databaseUrl(), { prepare: false }) @@ -128,8 +128,8 @@ const BIGINT_LEDGER = -9223372036854775808n const BIGINT_B_BALANCE = -5n const BIGINT_B_LEDGER = 100n -const schema = extractEncryptionSchemaV3(matrixTable) -const bigintSchema = extractEncryptionSchemaV3(bigintTable) +const schema = extractEncryptionSchema(matrixTable) +const bigintSchema = extractEncryptionSchema(bigintTable) type PlainValue = string | number | bigint | boolean | Date type RowKey = (typeof ROWS)[number] @@ -137,7 +137,7 @@ type MatrixPlainRow = Record type SelectRow = { rowKey: string } type Db = ReturnType type Client = Awaited> -type Ops = ReturnType +type Ops = ReturnType type ComparisonOperator = 'gt' | 'gte' | 'lt' | 'lte' let client: Client @@ -213,7 +213,7 @@ function encryptedInsertRows(): MatrixPlainRow[] { beforeAll(async () => { client = await EncryptionV3({ schemas: [schema, bigintSchema] }) - ops = createEncryptionOperatorsV3(client) + ops = createEncryptionOperators(client) db = drizzle({ client: sqlClient }) const columnDefs = matrixEntries diff --git a/packages/stack-drizzle/package.json b/packages/stack-drizzle/package.json index dd004ad62..48320122c 100644 --- a/packages/stack-drizzle/package.json +++ b/packages/stack-drizzle/package.json @@ -43,25 +43,8 @@ "default": "./dist/index.cjs" } }, - "./v3": { - "import": { - "types": "./dist/v3/index.d.ts", - "default": "./dist/v3/index.js" - }, - "require": { - "types": "./dist/v3/index.d.cts", - "default": "./dist/v3/index.cjs" - } - }, "./package.json": "./package.json" }, - "typesVersions": { - "*": { - "v3": [ - "./dist/v3/index.d.ts" - ] - } - }, "scripts": { "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsup", diff --git a/packages/stack-drizzle/src/v3/codec.ts b/packages/stack-drizzle/src/codec.ts similarity index 100% rename from packages/stack-drizzle/src/v3/codec.ts rename to packages/stack-drizzle/src/codec.ts diff --git a/packages/stack-drizzle/src/v3/column.ts b/packages/stack-drizzle/src/column.ts similarity index 100% rename from packages/stack-drizzle/src/v3/column.ts rename to packages/stack-drizzle/src/column.ts diff --git a/packages/stack-drizzle/src/index.ts b/packages/stack-drizzle/src/index.ts index ff3f12bb0..a2f106095 100644 --- a/packages/stack-drizzle/src/index.ts +++ b/packages/stack-drizzle/src/index.ts @@ -1,234 +1,8 @@ -import type { - CastAs, - MatchIndexOpts, - TokenFilter, -} from '@cipherstash/stack/schema' -import { customType } from 'drizzle-orm/pg-core' - -export type { CastAs, MatchIndexOpts, TokenFilter } - -// The encrypted column type is created by the EQL install script in the -// `public` schema (see packages/cli/src/installer/index.ts). We return the -// bare identifier here — drizzle-kit's CREATE TABLE path emits it correctly -// (it only prepends a schema when `typeSchema` is set, which is only true -// for pgEnum columns). The ALTER COLUMN path is a different story: it -// unconditionally wraps the dataType() return in double-quotes and prepends -// `"{typeSchema}".`, and since custom types have no typeSchema, the output -// becomes `"undefined"."eql_v2_encrypted"`. Returning a pre-quoted -// `"public"."eql_v2_encrypted"` here does NOT fix that — drizzle-kit just -// double-escapes the quotes, producing `"undefined".""public"."eql_v2_encrypted""`. -// Instead, the CLI's `rewriteEncryptedAlterColumns` rewrites every broken -// ALTER COLUMN form into an ADD + DROP + RENAME sequence that does work. -const EQL_ENCRYPTED_DATA_TYPE = 'eql_v2_encrypted' - -/** - * Configuration for encrypted column indexes and data types - */ -export type EncryptedColumnConfig = { - /** - * Data type for the column (default: 'string') - */ - dataType?: CastAs - /** - * Enable free text search. Can be a boolean for default options, or an object for custom configuration. - */ - freeTextSearch?: boolean | MatchIndexOpts - /** - * Enable equality index. Can be a boolean for default options, or an array of token filters. - */ - equality?: boolean | TokenFilter[] - /** - * Enable order and range index for sorting and range queries. - */ - orderAndRange?: boolean - /** - * Enable searchable JSON index for JSONB path queries. - * Requires dataType: 'json'. - */ - searchableJson?: boolean -} - -/** - * Map to store configuration for encrypted columns - * Keyed by column name (the name passed to encryptedType) - */ -const columnConfigMap = new Map< - string, - EncryptedColumnConfig & { name: string } ->() - -/** - * Creates an encrypted column type for Drizzle ORM with configurable searchable encryption options. - * - * When data is encrypted, the actual stored value is an [EQL v2](/docs/reference/eql) encrypted composite type which includes any searchable encryption indexes defined for the column. - * Importantly, the original data type is not known until it is decrypted. Therefore, this function allows specifying - * the original data type via the `dataType` option in the configuration. - * This ensures that when data is decrypted, it can be correctly interpreted as the intended TypeScript type. - * - * @typeParam TData - The TypeScript type of the data stored in the column - * @param name - The column name in the database - * @param config - Optional configuration for data type and searchable encryption indexes - * @returns A Drizzle column type that can be used in pgTable definitions - * - * ## Searchable Encryption Options - * - * - `dataType`: Specifies the original data type of the column (e.g., 'string', 'number', 'json'). Default is 'string'. - * - `freeTextSearch`: Enables free text search index. Can be a boolean for default options, or an object for custom configuration. - * - `equality`: Enables equality index. Can be a boolean for default options, or an array of token filters. - * - `orderAndRange`: Enables order and range index for sorting and range queries. - * - `searchableJson`: Enables searchable JSON index for JSONB path queries on encrypted JSON columns. - * - * See {@link EncryptedColumnConfig}. - * - * @example - * Defining a drizzle table schema for postgres table with encrypted columns. - * - * ```typescript - * import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' - * import { encryptedType } from '@cipherstash/stack-drizzle' - * - * const users = pgTable('users', { - * email: encryptedType('email', { - * freeTextSearch: true, - * equality: true, - * orderAndRange: true, - * }), - * age: encryptedType('age', { - * dataType: 'number', - * equality: true, - * orderAndRange: true, - * }), - * profile: encryptedType('profile', { - * dataType: 'json', - * }), - * }) - * ``` - */ -export const encryptedType = ( - name: string, - config?: EncryptedColumnConfig, -) => { - // Create the Drizzle custom type - const customColumnType = customType<{ data: TData; driverData: string }>({ - dataType() { - return EQL_ENCRYPTED_DATA_TYPE - }, - toDriver(value: TData): string { - const jsonStr = JSON.stringify(value) - const escaped = jsonStr.replace(/"/g, '""') - return `("${escaped}")` - }, - fromDriver(value: string): TData { - const parseComposite = (str: string) => { - if (!str || str === '') return null - - const trimmed = str.trim() - - if (trimmed.startsWith('(') && trimmed.endsWith(')')) { - let inner = trimmed.slice(1, -1) - inner = inner.replace(/""/g, '"') - - if (inner.startsWith('"') && inner.endsWith('"')) { - const stripped = inner.slice(1, -1) - return JSON.parse(stripped) - } - - if (inner.startsWith('{') || inner.startsWith('[')) { - return JSON.parse(inner) - } - - return inner - } - - return JSON.parse(str) - } - - return parseComposite(value) as TData - }, - }) - - // Create the column instance - const column = customColumnType(name) - - // Store configuration keyed by column name - // This allows us to look it up during schema extraction - const fullConfig: EncryptedColumnConfig & { name: string } = { - name, - ...config, - } - - // Store in Map keyed by column name (will be looked up during extraction) - columnConfigMap.set(name, fullConfig) - - // Also store on property for immediate access (before pgTable processes it) - // We need to use any here because Drizzle columns don't have a type for custom properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle columns don't expose custom property types - ;(column as any)._encryptionConfig = fullConfig - - return column -} - -/** - * Get configuration for an encrypted column by checking if it's an encrypted type - * and looking up the config by column name - * @internal - */ -export function getEncryptedColumnConfig( - columnName: string, - column: unknown, -): (EncryptedColumnConfig & { name: string }) | undefined { - // Check if this is an encrypted column - if (column && typeof column === 'object') { - // We need to use any here to access Drizzle column properties - // biome-ignore lint/suspicious/noExplicitAny: Drizzle column types don't expose all properties - const columnAny = column as any - - // Check if it's an encrypted column by checking sqlName or dataType. - // We accept both the bare `eql_v2_encrypted` form (current) and the - // fully-qualified `"public"."eql_v2_encrypted"` form that @cipherstash/stack - // 0.15.0 briefly emitted, for back-compat with tables built against that - // release. - const isEncryptedTypeString = (value: unknown): boolean => - value === EQL_ENCRYPTED_DATA_TYPE || - value === '"public"."eql_v2_encrypted"' - - const isEncrypted = - isEncryptedTypeString(columnAny.sqlName) || - isEncryptedTypeString(columnAny.dataType) || - (columnAny.dataType && - typeof columnAny.dataType === 'function' && - isEncryptedTypeString(columnAny.dataType())) - - if (isEncrypted) { - // Try to get config from property (if still there) - if (columnAny._encryptionConfig) { - return columnAny._encryptionConfig - } - - // Look up config by column name (the name passed to encryptedType) - // The column.name should match what was passed to encryptedType - const lookupName = columnAny.name || columnName - return columnConfigMap.get(lookupName) - } - } - return undefined -} - -/** - * Create Drizzle query operators (`eq`, `lt`, `gt`, etc.) that work with - * encrypted columns. The returned operators encrypt query values before - * passing them to Drizzle, enabling searchable encryption in standard - * Drizzle queries. - */ +export { EqlV3CodecError, v3FromDriver, v3ToDriver } from './codec.js' +export { getEqlV3Column, isEqlV3Column, makeEqlV3Column } from './column.js' export { createEncryptionOperators, - EncryptionConfigError, EncryptionOperatorError, } from './operators.js' -/** - * Extract a CipherStash encryption schema from a Drizzle table definition. - * - * Inspects columns created with {@link encryptedType} and builds the equivalent - * `encryptedTable` / `encryptedColumn` schema automatically. - */ export { extractEncryptionSchema } from './schema-extraction.js' +export { types } from './types.js' diff --git a/packages/stack-drizzle/src/operators.ts b/packages/stack-drizzle/src/operators.ts index 674bb3ba3..4f6df813d 100644 --- a/packages/stack-drizzle/src/operators.ts +++ b/packages/stack-drizzle/src/operators.ts @@ -1,90 +1,96 @@ -import type { EncryptionClient } from '@cipherstash/stack/encryption' +import type { Result } from '@byteslice/result' +import type { AuditConfig } from '@cipherstash/stack/adapter-kit' +import { + jsonPathOf, + matchNeedleError, + parseSelectorSegments, + reconstructSelectorDocument, + stripDomainSchema, + unsupportedLeafReason, +} from '@cipherstash/stack/adapter-kit' +import type { + AnyEncryptedV3Column, + AnyV3Table, +} from '@cipherstash/stack/eql/v3' +import type { EncryptionError } from '@cipherstash/stack/errors' +import type { LockContext } from '@cipherstash/stack/identity' +import type { ColumnSchema } from '@cipherstash/stack/schema' import type { - EncryptedColumn, - EncryptedTable, - EncryptedTableColumn, -} from '@cipherstash/stack/schema' -import { type QueryTypeName, queryTypes } from '@cipherstash/stack/types' + EncryptedQueryResult, + QueryTypeName, +} from '@cipherstash/stack/types' import { and, - arrayContained, - arrayContains, - arrayOverlaps, asc, - between, - bindIfParam, + Column, desc, - eq, exists, - gt, - gte, - ilike, - inArray, + is, isNotNull, isNull, - like, - lt, - lte, - ne, not, - notBetween, notExists, - notIlike, - notInArray, or, type SQL, type SQLWrapper, sql, } from 'drizzle-orm' import type { PgTable } from 'drizzle-orm/pg-core' -import type { EncryptedColumnConfig } from './index.js' -import { getEncryptedColumnConfig } from './index.js' -import { extractEncryptionSchema } from './schema-extraction.js' - -// ============================================================================ -// Type Definitions and Type Guards -// ============================================================================ - -/** - * Branded type for Drizzle table with encrypted columns - */ -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -type EncryptedDrizzleTable = PgTable & { - readonly __isEncryptedTable?: true -} +import { getEqlV3Column } from './column.js' +import { + extractEncryptionSchema, + getDrizzleTableName, +} from './schema-extraction.js' +import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' /** - * Type guard to check if a value is a Drizzle SQLWrapper + * The client capability this factory consumes: `encryptQuery`, in both its + * single (`value, opts`) and batch (`terms[]`) forms. Declared structurally — + * with maximally-permissive operands — so it is satisfied by the nominal + * `EncryptionClient`, by the `TypedEncryptionClient` that `EncryptionV3` returns + * (whatever its schema tuple), AND by a hand-rolled test double, none needing a + * cast. Typing the parameter to the nominal `TypedEncryptionClient` would + * reject a client built for a narrower schema tuple (it accepts fewer tables than + * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. + * + * Every operand is a QUERY TERM, not a storage envelope: `encryptQuery` mints a + * ciphertext-free term (no `c`) carrying all of the column's configured index + * terms, which the operator layer casts to the column's `eql_v3.query_` + * type. This reaches the bundle's `(domain, query_)` overloads and keeps + * WHERE-clause payloads free of the ciphertext a query never needs (#622). + * + * `never` operands: the real client's `encryptQuery` is generic (`queryType` is + * constrained to the column's own query types), which a concrete signature here + * cannot match. `never` params keep the structural surface satisfiable by that + * generic method AND by a test double; the call sites cast their real operands. */ -function isSQLWrapper(value: unknown): value is SQLWrapper { - return ( - typeof value === 'object' && - value !== null && - 'sql' in value && - typeof (value as { sql: unknown }).sql !== 'undefined' - ) +type OperandEncryptionClient = { + encryptQuery( + value: never, + opts: never, + ): ChainableOperation + encryptQuery(terms: never): ChainableOperation } -/** - * Type guard to check if a value is a Drizzle table - */ -function isPgTable(value: unknown): value is EncryptedDrizzleTable { - return ( - typeof value === 'object' && - value !== null && - Symbol.for('drizzle:Name') in value - ) -} +// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the +// Supabase adapter, #650); re-exported so existing imports keep working. +export { parseSelectorSegments, reconstructSelectorDocument } /** - * Custom error types for better debugging + * A dedicated error for v3 operator gating and operand-encryption failures, + * carrying the offending column/table/operator for diagnostics. + * + * INTENTIONAL FORK: this mirrors the v2 adapter's `EncryptionOperatorError` + * rather than sharing it. Unifying the two would couple `./drizzle` and + * `./eql/v3/drizzle` — two independently-versioned public entry points — so the + * duplication is deliberate, not an oversight. */ export class EncryptionOperatorError extends Error { constructor( message: string, public readonly context?: { - tableName?: string columnName?: string + tableName?: string operator?: string }, ) { @@ -93,1853 +99,861 @@ export class EncryptionOperatorError extends Error { } } -export class EncryptionConfigError extends EncryptionOperatorError { - constructor(message: string, context?: EncryptionOperatorError['context']) { - super(message, context) - this.name = 'EncryptionConfigError' - } -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Helper to extract table name from a Drizzle table - */ -function getDrizzleTableName(drizzleTable: unknown): string | undefined { - if (!isPgTable(drizzleTable)) { - return undefined - } - // Access Symbol property using Record type to avoid indexing errors - const tableWithSymbol = drizzleTable as unknown as Record< - symbol, - string | undefined - > - return tableWithSymbol[Symbol.for('drizzle:Name')] -} - -/** - * Helper to get the drizzle table from a drizzle column - */ -function getDrizzleTableFromColumn(drizzleColumn: SQLWrapper): unknown { - const column = drizzleColumn as unknown as Record - return column.table as unknown -} - -/** - * Helper to extract encrypted table from a drizzle column by deriving it from the column's parent table - */ -function getEncryptedTableFromColumn( - drizzleColumn: SQLWrapper, - tableCache: Map>, -): EncryptedTable | undefined { - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - if (!drizzleTable) { - return undefined - } - - const tableName = getDrizzleTableName(drizzleTable) - if (!tableName) { - return undefined - } - - // Check cache first - let encryptedTable = tableCache.get(tableName) - if (encryptedTable) { - return encryptedTable - } - - // Extract encryption schema from drizzle table and cache it - try { - // biome-ignore lint/suspicious/noExplicitAny: PgTable type doesn't expose all needed properties - encryptedTable = extractEncryptionSchema(drizzleTable as PgTable) - tableCache.set(tableName, encryptedTable) - return encryptedTable - } catch { - // Table doesn't have encrypted columns or extraction failed - return undefined - } -} - -/** - * Helper to get the encrypted column definition for a Drizzle column from the encrypted table - */ -function getEncryptedColumn( - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable, -): EncryptedColumn | undefined { - const column = drizzleColumn as unknown as Record - const columnName = column.name as string | undefined - if (!columnName) { - return undefined - } - - const tableRecord = encryptedTable as unknown as Record - return tableRecord[columnName] as EncryptedColumn | undefined +interface ColumnContext { + builder: AnyEncryptedV3Column + table: AnyV3Table + indexes: ColumnSchema['indexes'] + columnName: string + tableName: string + /** The `eql_v3.query_` type an operand for this column casts to, so + * `encryptQuery`'s ciphertext-free term reaches the narrowed-query overloads. + * `null` for storage-only columns (no query domain); those never encrypt an + * operand — every operator gates on a query capability first. JSON columns + * override this at the call site (`query_json`, an irregular name). */ + queryCast: string | null } /** - * Column metadata extracted from a Drizzle column + * The `eql_v3.query_` cast for a column's storage domain — e.g. + * `public.eql_v3_text_search` → `eql_v3.query_text_search`. Uniform across the + * queryable column domains (`_eq`, `_ord`, `_ord_ore`, `_match`, `_search`); the + * two irregular cases are handled elsewhere: storage-only domains + * (`eql_v3_boolean`, the bare base types) have no query domain and return `null` + * (they are never queried), and `eql_v3_json_search` maps to `query_json`, cast + * explicitly on the JSON path. */ -interface ColumnInfo { - readonly encryptedColumn: EncryptedColumn | undefined - readonly config: (EncryptedColumnConfig & { name: string }) | undefined - readonly encryptedTable: EncryptedTable | undefined - readonly columnName: string - readonly tableName: string | undefined +function queryCastForDomain(eqlType: string): string | null { + const bare = stripDomainSchema(eqlType) // public.eql_v3_text_search → eql_v3_text_search + const prefix = 'eql_v3_' + if (!bare.startsWith(prefix)) return null + const suffix = bare.slice(prefix.length) + // No index suffix (bare storage-only domain like `boolean`, `text`) → no query + // domain exists. These are gated out before any operand is encrypted. The + // suffixes match the column factories in `@/eql/v3/columns` exactly: ope + // ordering is the `_ord` domain (not `_ord_ope`) and text search is `_search` + // (there is no `_search_ore` column), so those two never occur here. + if (!/_(eq|ord|ord_ore|match|search)$/.test(suffix)) { + return null + } + return `eql_v3.query_${suffix}` } -/** - * Helper to get the encrypted column and column config for a Drizzle column - * If encryptedTable is not provided, it will be derived from the column - */ -function getColumnInfo( - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, -): ColumnInfo { - const column = drizzleColumn as unknown as Record - const columnName = (column.name as string | undefined) || 'unknown' - - // If encryptedTable not provided, try to derive it from the column - let resolvedTable = encryptedTable - if (!resolvedTable) { - resolvedTable = getEncryptedTableFromColumn(drizzleColumn, tableCache) - } - - const drizzleTable = getDrizzleTableFromColumn(drizzleColumn) - const tableName = getDrizzleTableName(drizzleTable) - - if (!resolvedTable) { - // Column is not from an encrypted table - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - return { - encryptedColumn: undefined, - config, - encryptedTable: undefined, - columnName, - tableName, - } - } - - const encryptedColumn = getEncryptedColumn(drizzleColumn, resolvedTable) - const config = getEncryptedColumnConfig(columnName, drizzleColumn) - - return { - encryptedColumn, - config, - encryptedTable: resolvedTable, - columnName, - tableName, - } +export type EncryptionOperatorCallOpts = { + lockContext?: LockContext + audit?: AuditConfig } /** - * Helper to convert a value to plaintext format + * An SDK encryption operation after its lock context has been applied: still + * auditable and awaitable, but not re-lockable. `withLockContext` returns this, + * not the full {@link ChainableOperation}, mirroring the real + * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot + * lock-context twice). Modelling that is what lets the real client type satisfy + * the structural surface with no cast. */ -function toPlaintext(value: unknown): string | number | bigint { - if (typeof value === 'boolean') { - return value ? 1 : 0 - } - if ( - typeof value === 'string' || - typeof value === 'number' || - // `bigint` (int8 columns) must pass through as a native bigint, NOT via the - // `String(value)` fallthrough below — a stringified bigint would be - // encrypted as text and silently mismatch the column's bigint domain. The - // downstream `encryptQuery` term type is `Plaintext`, which carries bigint, - // and protect-ffi 0.28 i64-bounds-checks it at the boundary. - typeof value === 'bigint' - ) { - return value - } - if (value instanceof Date) { - return value.toISOString() - } - return String(value) +type AuditableOperation = { + audit(config: AuditConfig): AuditableOperation + then: PromiseLike>['then'] } /** - * Value to encrypt with its associated column + * The subset of an SDK encryption operation this factory drives: the fluent + * `withLockContext`/`audit` chain, and a `then` that resolves the operation's + * `Result`. Generic over the resolved payload `T` so the single `encryptQuery` + * carries an `EncryptedQueryResult` term and the batch form an + * `EncryptedQueryResult[]`, rather than the `unknown` this erased to before. + * + * Structural, not the concrete `EncryptQueryOperation` class, because the client + * is passed in and the factory must accept any implementation with this surface. */ -interface ValueToEncrypt { - readonly value: string | number | bigint - readonly column: SQLWrapper - readonly columnInfo: ColumnInfo - readonly queryType?: QueryTypeName - readonly originalIndex: number +type ChainableOperation = { + withLockContext(lockContext: LockContext): AuditableOperation + audit(config: AuditConfig): AuditableOperation + then: PromiseLike>['then'] } /** - * Helper to encrypt multiple values for use in a query - * Returns an array of encrypted search terms or original values if not encrypted + * Build v3-aware query operators (`eq`, `gte`, `matches`, `contains`, `asc`, …) bound to an + * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its + * plaintext operand into an EQL v3 query term before handing it to Drizzle, so + * callers pass plaintext and the emitted SQL compares encrypted values. Every + * operator also gates on the target column's capabilities and throws + * {@link EncryptionOperatorError} when the column can't answer the operator + * (e.g. ordering a non-`ore` column). + * + * @param client - anything that can `encryptQuery` — the nominal + * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no + * cast needed). + * @param defaults - lock context / audit applied to every operand encryption + * unless a per-call override is supplied. + * + * @example + * ```typescript + * const ops = createEncryptionOperators(await EncryptionV3({ schemas: [users] })) + * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) + * ``` */ -async function encryptValues( - encryptionClient: EncryptionClient, - values: Array<{ - value: unknown - column: SQLWrapper - queryType?: QueryTypeName - }>, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - if (values.length === 0) { - return [] - } - - // Single pass: collect values to encrypt with their metadata - const valuesToEncrypt: ValueToEncrypt[] = [] - const results: unknown[] = new Array(values.length) - - for (let i = 0; i < values.length; i++) { - const { value, column, queryType } = values[i] - const columnInfo = getColumnInfo(column, encryptedTable, tableCache) - - if ( - !columnInfo.encryptedColumn || - !columnInfo.config || - !columnInfo.encryptedTable - ) { - // Column is not encrypted, return value as-is - results[i] = value - continue - } - - const plaintextValue = toPlaintext(value) - valuesToEncrypt.push({ - value: plaintextValue, - column, - columnInfo, - queryType, - originalIndex: i, - }) - } - - if (valuesToEncrypt.length === 0) { - return results - } - - // Group values by column to batch encrypt with same column/table - const columnGroups = new Map< - string, - { - column: EncryptedColumn - table: EncryptedTable - columnName: string - values: Array<{ - value: string | number | bigint - index: number - queryType?: QueryTypeName - }> - resultIndices: number[] - } - >() - - let valueIndex = 0 - for (const { - value, - columnInfo, - queryType, - originalIndex, - } of valuesToEncrypt) { - // Safe access with validation - we know these exist from earlier checks - if ( - !columnInfo.config || - !columnInfo.encryptedColumn || - !columnInfo.encryptedTable - ) { - continue - } - - const columnName = columnInfo.config.name - const groupKey = `${columnInfo.tableName ?? 'unknown'}/${columnName}` - let group = columnGroups.get(groupKey) - if (!group) { - group = { - column: columnInfo.encryptedColumn, - table: columnInfo.encryptedTable, - columnName, - values: [], - resultIndices: [], - } - columnGroups.set(groupKey, group) - } - group.values.push({ value, index: valueIndex++, queryType }) - group.resultIndices.push(originalIndex) - } - - // Encrypt all values for each column in batches - for (const [, group] of columnGroups) { - const { columnName } = group - try { - const terms = group.values.map((v) => ({ - value: v.value, - column: group.column, - table: group.table, - queryType: v.queryType, - })) - - const encryptedTerms = await encryptionClient.encryptQuery(terms) - - if (encryptedTerms.failure) { - throw new EncryptionOperatorError( - `Failed to encrypt query terms for column "${columnName}": ${encryptedTerms.failure.message}`, - { columnName }, - ) - } - - // Map results back to original indices - for (let i = 0; i < group.values.length; i++) { - const resultIndex = group.resultIndices[i] ?? -1 - if (resultIndex >= 0 && resultIndex < results.length) { - results[resultIndex] = encryptedTerms.data[i] - } - } - } catch (error) { - if (error instanceof EncryptionOperatorError) { - throw error - } - const errorMessage = - error instanceof Error ? error.message : String(error) +export function createEncryptionOperators( + client: OperandEncryptionClient, + defaults: EncryptionOperatorCallOpts = {}, +) { + const tableCache = new WeakMap() + // Per-column context memo. `resolveContext` is value-independent, so caching + // by column identity makes `inArray`/`notInArray` build the context (and its + // deep-cloned match block) once for the whole list instead of once per value. + const contextCache = new WeakMap() + + function drizzleTableOf(column: SQLWrapper): PgTable | undefined { + return is(column, Column) + ? (column.table as PgTable | undefined) + : undefined + } + + function resolveContext(column: SQLWrapper, operator: string): ColumnContext { + const cached = contextCache.get(column) + if (cached) return cached + + const columnName = is(column, Column) ? column.name : 'unknown' + const builder = getEqlV3Column(columnName, column) + if (!builder) { throw new EncryptionOperatorError( - `Unexpected error encrypting values for column "${columnName}": ${errorMessage}`, - { columnName }, + `Operator "${operator}" requires an encrypted v3 column, but "${columnName}" is not one.`, + { columnName, operator }, ) } - } - - return results -} -/** - * Helper to encrypt a single value for use in a query - * Returns the encrypted search term or the original value if not encrypted - */ -async function encryptValue( - encryptionClient: EncryptionClient, - value: unknown, - drizzleColumn: SQLWrapper, - encryptedTable: EncryptedTable | undefined, - tableCache: Map>, - queryType?: QueryTypeName, -): Promise { - const results = await encryptValues( - encryptionClient, - [{ value, column: drizzleColumn, queryType }], - encryptedTable, - tableCache, - ) - return results[0] -} - -// ============================================================================ -// Lazy Operator Pattern -// ============================================================================ - -/** - * Simplified lazy operator that defers encryption until awaited or batched - */ -interface LazyOperator { - readonly __isLazyOperator: true - readonly operator: string - readonly queryType?: QueryTypeName - readonly left: SQLWrapper - readonly right: unknown - readonly min?: unknown - readonly max?: unknown - readonly needsEncryption: boolean - readonly columnInfo: ColumnInfo - execute( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ): SQL -} - -/** - * Type guard for lazy operators - */ -function isLazyOperator(value: unknown): value is LazyOperator { - return ( - typeof value === 'object' && - value !== null && - '__isLazyOperator' in value && - (value as LazyOperator).__isLazyOperator === true - ) -} - -/** - * Creates a lazy operator that defers execution - */ -function createLazyOperator( - operator: string, - left: SQLWrapper, - right: unknown, - execute: ( - encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => SQL, - needsEncryption: boolean, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, - min?: unknown, - max?: unknown, - queryType?: QueryTypeName, -): LazyOperator & Promise { - let resolvedSQL: SQL | undefined - let encryptionPromise: Promise | undefined - - const lazyOp: LazyOperator = { - __isLazyOperator: true, - operator, - queryType, - left, - right, - min, - max, - needsEncryption, - columnInfo, - execute, - } - - // Create a promise that will be resolved when encryption completes - const promise = new Promise((resolve, reject) => { - // Auto-execute when awaited directly - queueMicrotask(async () => { - if (resolvedSQL !== undefined) { - resolve(resolvedSQL) - return - } - - try { - if (!encryptionPromise) { - encryptionPromise = executeLazyOperatorDirect( - lazyOp, - encryptionClient, - defaultTable, - tableCache, - ) - } - const sql = await encryptionPromise - resolvedSQL = sql - resolve(sql) - } catch (error) { - reject(error) - } - }) - }) - - // Attach lazy operator properties to the promise - return Object.assign(promise, lazyOp) -} + const drizzleTable = drizzleTableOf(column) + const tableName = getDrizzleTableName(drizzleTable) ?? 'unknown' -/** - * Executes a lazy operator with pre-encrypted values (used in batched mode) - */ -async function executeLazyOperator( - lazyOp: LazyOperator, - encryptedValues?: { value: unknown; encrypted: unknown }[], -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - use provided encrypted values - let encryptedMin: unknown - let encryptedMax: unknown - - if (encryptedValues && encryptedValues.length >= 2) { - encryptedMin = encryptedValues[0]?.encrypted - encryptedMax = encryptedValues[1]?.encrypted - } else { - throw new EncryptionOperatorError( - 'Between operator requires both min and max encrypted values', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) + let table = drizzleTable ? tableCache.get(drizzleTable) : undefined + if (!table && drizzleTable) { + table = extractEncryptionSchema(drizzleTable) + tableCache.set(drizzleTable, table) } - - if (encryptedMin === undefined || encryptedMax === undefined) { + if (!table) { throw new EncryptionOperatorError( - 'Between operator requires both min and max values to be encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, + `Unable to resolve the encrypted table for column "${columnName}".`, + { columnName, operator }, ) } - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - let encrypted: unknown - - if (encryptedValues && encryptedValues.length > 0) { - encrypted = encryptedValues[0]?.encrypted - } else { - throw new EncryptionOperatorError( - 'Operator requires encrypted value but none provided', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - if (encrypted === undefined) { - throw new EncryptionOperatorError( - 'Encryption failed or value was not encrypted', - { - columnName: lazyOp.columnInfo.columnName, - tableName: lazyOp.columnInfo.tableName, - operator: lazyOp.operator, - }, - ) - } - - return lazyOp.execute(encrypted) -} - -/** - * Executes a lazy operator directly by encrypting values on demand - * Used when operator is awaited directly (not batched) - */ -async function executeLazyOperatorDirect( - lazyOp: LazyOperator, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - if (!lazyOp.needsEncryption) { - return lazyOp.execute(lazyOp.right) - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - // Between operator - encrypt min and max - const [encryptedMin, encryptedMax] = await encryptValues( - encryptionClient, - [ - { value: lazyOp.min, column: lazyOp.left, queryType: lazyOp.queryType }, - { value: lazyOp.max, column: lazyOp.left, queryType: lazyOp.queryType }, - ], - defaultTable, - tableCache, - ) - return lazyOp.execute(undefined, encryptedMin, encryptedMax) - } - - // Single value operator - const encrypted = await encryptValue( - encryptionClient, - lazyOp.right, - lazyOp.left, - defaultTable, - tableCache, - lazyOp.queryType, - ) - - return lazyOp.execute(encrypted) -} - -// ============================================================================ -// Operator Factory Functions -// ============================================================================ - -/** - * Creates a comparison operator (eq, ne, gt, gte, lt, lte) - */ -function createComparisonOperator( - operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - // Operators requiring orderAndRange index - const requiresOrderAndRange = ['gt', 'gte', 'lt', 'lte'].includes(operator) - - if (requiresOrderAndRange) { - if (!config?.orderAndRange) { - // Return regular Drizzle operator for non-encrypted columns - switch (operator) { - case 'gt': - return gt(left, right) - case 'gte': - return gte(left, right) - case 'lt': - return lt(left, right) - case 'lte': - return lte(left, right) - } - } - - // This will be replaced with encrypted value in executeLazyOperator - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return sql`eql_v2.${sql.raw(operator)}(${left}, ${bindIfParam(encrypted, left)})` - } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.orderAndRange, - ) as Promise - } - - // Equality operators (eq, ne) - const requiresEquality = ['eq', 'ne'].includes(operator) - - if (requiresEquality && config?.equality) { - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { - throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - return operator === 'eq' ? eq(left, encrypted) : ne(left, encrypted) + const context: ColumnContext = { + builder, + table, + indexes: builder.build().indexes, + columnName, + tableName, + queryCast: queryCastForDomain(builder.getEqlType()), } - - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.equality, - ) as Promise + contextCache.set(column, context) + return context } - // Fallback to regular Drizzle operators - return operator === 'eq' ? eq(left, right) : ne(left, right) -} - -/** - * Creates a range operator (between, notBetween) - */ -function createRangeOperator( - operator: 'between' | 'notBetween', - left: SQLWrapper, - min: unknown, - max: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.orderAndRange) { - return operator === 'between' - ? between(left, min, max) - : notBetween(left, min, max) - } - - const executeFn = ( - _encrypted: unknown, - encryptedMin?: unknown, - encryptedMax?: unknown, - ) => { - if (encryptedMin === undefined || encryptedMax === undefined) { + /** + * Gate an operator on the column's indexes. `indexes` is a disjunction — any + * one of them grants the capability — so equality (`unique` OR `ore`) and the + * single-index gates share one rule and one diagnostic shape. + */ + function requireIndex( + ctx: ColumnContext, + indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], + operator: string, + capability: string, + ): void { + if (!indexes.some((index) => ctx.indexes[index])) { throw new EncryptionOperatorError( - `${operator} operator requires both min and max values`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, + `Operator "${operator}" requires ${capability} on column "${ctx.columnName}" (domain ${ctx.builder.getEqlType()} does not support it).`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - - const rangeCondition = sql`eql_v2.gte(${left}, ${bindIfParam(encryptedMin, left)}) AND eql_v2.lte(${left}, ${bindIfParam(encryptedMax, left)})` - - return operator === 'between' - ? rangeCondition - : sql`NOT (${rangeCondition})` } - return createLazyOperator( - operator, - left, - undefined, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - min, - max, - queryTypes.orderAndRange, - ) as Promise -} - -/** - * Creates a text search operator (like, ilike, notIlike) - */ -function createTextSearchOperator( - operator: 'like' | 'ilike' | 'notIlike', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise | SQL { - const { config } = columnInfo - - if (!config?.freeTextSearch) { - // Cast to satisfy TypeScript - const rightValue = right as string | SQLWrapper - switch (operator) { - case 'like': - return like(left as Parameters[0], rightValue) - case 'ilike': - return ilike(left as Parameters[0], rightValue) - case 'notIlike': - return notIlike(left as Parameters[0], rightValue) - } - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { + // Ordering flavour is pinned by the column's domain (eql-3.0.0): `_ord` + // domains carry `ope` (`op` CLLW-OPE term), `_ord_ore` domains carry `ore` + // (`ob` block-ORE term). Either satisfies the order/range operators, and an + // order-capable column answers equality via its ordering term too. + const EQUALITY_INDEXES = ['unique', 'ore', 'ope'] as const + const ORDERING_INDEXES = ['ore', 'ope'] as const + // Two DISTINCT operators, split by semantics (#617): + // - `matches` is bloom free-text (`match`, a `text_search`/`text_match` + // column): a one-sided, order- and multiplicity-insensitive token match that + // may false-positive. It emits `eql_v3.matches(col, operand)` (the SQL + // function keeps its bundle name) but is NOT containment. + // - `contains` is encrypted-JSONB containment (an `eql_v3_json_search` column, + // `ste_vec` index): exact jsonb `@>`, no false positives — genuine + // containment, so it keeps the `contains` name. + const MATCH_INDEXES = ['match'] as const + const JSON_CONTAINMENT_INDEXES = ['ste_vec'] as const + + function applyOperationOptions( + op: ChainableOperation, + opts?: EncryptionOperatorCallOpts, + ): AuditableOperation { + const lockContext = opts?.lockContext ?? defaults.lockContext + const audit = opts?.audit ?? defaults.audit + const withLock = lockContext ? op.withLockContext(lockContext) : op + if (audit) withLock.audit(audit) + return withLock + } + + function requireNonNullOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + ): void { + if (value == null) { throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, + `Operator "${operator}" cannot encrypt a null operand for column "${ctx.columnName}". Use isNull() or isNotNull() for NULL checks.`, { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, + columnName: ctx.columnName, + tableName: ctx.tableName, operator, }, ) } - - const sqlFn = sql`eql_v2.${sql.raw(operator === 'notIlike' ? 'ilike' : operator)}(${left}, ${bindIfParam(encrypted, left)})` - return operator === 'notIlike' ? sql`NOT (${sqlFn})` : sqlFn } - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, // min - undefined, // max - queryTypes.freeTextSearch, - ) as Promise -} - -/** - * Creates a JSONB operator that encrypts a JSON path selector and wraps it - * in the appropriate `eql_v2` function call. - * - * Supports `jsonbPathQueryFirst`, `jsonbGet`, and `jsonbPathExists`. - * The column must have `searchableJson` enabled in its {@link EncryptedColumnConfig}. - */ -function createJsonbOperator( - operator: 'jsonbPathQueryFirst' | 'jsonbGet' | 'jsonbPathExists', - left: SQLWrapper, - right: unknown, - columnInfo: ColumnInfo, - encryptionClient: EncryptionClient, - defaultTable: EncryptedTable | undefined, - tableCache: Map>, -): Promise { - const { config } = columnInfo - const encryptedSelector = (value: unknown) => - sql`${bindIfParam(value, left)}::eql_v2_encrypted` - - if (!config?.searchableJson) { - throw new EncryptionOperatorError( - `The ${operator} operator requires searchableJson to be enabled on the column configuration.`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, - ) - } - - const executeFn = (encrypted: unknown) => { - if (encrypted === undefined) { + /** + * Reject a free-text needle the column's match index cannot answer. A needle + * shorter than the tokenizer's `token_length` yields an empty bloom filter, + * and `stored_bf @> '{}'` holds for every row — so without this the query + * silently returns the whole table. + */ + function requireAnswerableNeedle( + ctx: ColumnContext, + value: unknown, + operator: string, + ): void { + const match = ctx.indexes.match + if (!match) return + const reason = matchNeedleError(value, match) + if (reason) { throw new EncryptionOperatorError( - `Encryption failed for ${operator} operator`, - { - columnName: columnInfo.columnName, - tableName: columnInfo.tableName, - operator, - }, + `Operator "${operator}" cannot search column "${ctx.columnName}": ${reason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } - switch (operator) { - case 'jsonbPathQueryFirst': - return sql`eql_v2.jsonb_path_query_first(${left}, ${encryptedSelector(encrypted)})` - case 'jsonbGet': - return sql`${left} -> ${encryptedSelector(encrypted)}` - case 'jsonbPathExists': - return sql`eql_v2.jsonb_path_exists(${left}, ${encryptedSelector(encrypted)})` - } } - return createLazyOperator( - operator, - left, - right, - executeFn, - true, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - undefined, - undefined, - queryTypes.steVecSelector, - ) as Promise -} - -// ============================================================================ -// Public API: createEncryptionOperators -// ============================================================================ - -/** - * Creates a set of encryption-aware operators that automatically encrypt values - * for encrypted columns before using them with Drizzle operators. - * - * For equality and text search operators (eq, ne, like, ilike, inArray, etc.): - * Values are encrypted and then passed to regular Drizzle operators, which use - * PostgreSQL's built-in operators for eql_v2_encrypted types. - * - * For order and range operators (gt, gte, lt, lte, between, notBetween): - * Values are encrypted and then use eql_v2.* functions (eql_v2.gt(), eql_v2.gte(), etc.) - * which are required for ORE (Order-Revealing Encryption) comparisons. - * - * @param encryptionClient - The EncryptionClient instance - * @returns An object with all Drizzle operators wrapped for encrypted columns - * - * @example - * ```ts - * // Initialize operators - * const ops = createEncryptionOperators(encryptionClient) - * - * // Equality search - automatically encrypts and uses PostgreSQL operators - * const results = await db - * .select() - * .from(usersTable) - * .where(await ops.eq(usersTable.email, 'user@example.com')) - * - * // Range query - automatically encrypts and uses eql_v2.gte() - * const olderUsers = await db - * .select() - * .from(usersTable) - * .where(await ops.gte(usersTable.age, 25)) - * ``` - */ -export function createEncryptionOperators(encryptionClient: EncryptionClient): { - // Comparison operators - /** - * Equality operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users with a specific email address. - * ```ts - * const condition = await ops.eq(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - eq: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Not equal operator - encrypts value for encrypted columns. - * Requires either `equality` or `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users whose email address is not a specific value. - * ```ts - * const condition = await ops.ne(usersTable.email, 'user@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ne: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than a specific age. - * ```ts - * const condition = await ops.gt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Greater than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users older than or equal to a specific age. - * ```ts - * const condition = await ops.gte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - gte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than a specific age. - * ```ts - * const condition = await ops.lt(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lt: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Less than or equal operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users younger than or equal to a specific age. - * ```ts - * const condition = await ops.lte(usersTable.age, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - lte: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * Between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users within a specific age range. - * ```ts - * const condition = await ops.between(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - between: (left: SQLWrapper, min: unknown, max: unknown) => Promise | SQL - - /** - * Not between operator for encrypted columns with ORE index. - * Requires `orderAndRange` to be set on {@link EncryptedColumnConfig}. - * - * @example - * Select users outside a specific age range. - * ```ts - * const condition = await ops.notBetween(usersTable.age, 20, 30) - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - notBetween: ( - left: SQLWrapper, - min: unknown, - max: unknown, - ) => Promise | SQL - - /** - * Like operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern. - * ```ts - * const condition = await ops.like(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - like: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * ILike operator for encrypted columns with free text search. - * Requires `freeTextSearch` to be set on {@link EncryptedColumnConfig}. - * - * > [!IMPORTANT] - * > Case sensitivity on encrypted columns depends on the {@link EncryptedColumnConfig}. - * > Ensure that the column is configured for case-insensitive search if needed. - * - * @example - * Select users with email addresses matching a pattern (case-insensitive). - * ```ts - * const condition = await ops.ilike(usersTable.email, '%@example.com') - * const results = await db.select().from(usersTable).where(condition) - * ``` - */ - ilike: (left: SQLWrapper, right: unknown) => Promise | SQL - notIlike: (left: SQLWrapper, right: unknown) => Promise | SQL - - /** - * JSONB path query first operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_query_first()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathQueryFirst: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB get operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and uses the `->` operator, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbGet: (left: SQLWrapper, right: unknown) => Promise - - /** - * JSONB path exists operator for encrypted columns with searchable JSON. - * Requires `searchableJson` to be set on {@link EncryptedColumnConfig}. - * - * Encrypts the JSON path selector and calls `eql_v2.jsonb_path_exists()`, - * casting the parameter to `eql_v2_encrypted`. - * - * @throws {EncryptionOperatorError} If the column does not have `searchableJson` enabled. - */ - jsonbPathExists: (left: SQLWrapper, right: unknown) => Promise - // Array operators - inArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - notInArray: (left: SQLWrapper, right: unknown[] | SQLWrapper) => Promise - // Sorting operators - asc: (column: SQLWrapper) => SQL - desc: (column: SQLWrapper) => SQL - and: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - or: ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ) => Promise - // Operators that don't need encryption (pass through to Drizzle) - exists: typeof exists - notExists: typeof notExists - isNull: typeof isNull - isNotNull: typeof isNotNull - not: typeof not - // Array operators that work with arrays directly (not encrypted values) - arrayContains: typeof arrayContains - arrayContained: typeof arrayContained - arrayOverlaps: typeof arrayOverlaps -} { - // Create a cache for encrypted tables keyed by table name - const tableCache = new Map>() - const defaultTable: EncryptedTable | undefined = - undefined - - /** - * Equality operator - encrypts value and uses regular Drizzle operator - */ - const encryptedEq = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'eq', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + function operandFailure( + ctx: ColumnContext, + operator: string, + reason: string, + ): EncryptionOperatorError { + return new EncryptionOperatorError( + `Failed to encrypt query operand for "${ctx.columnName}": ${reason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, ) } /** - * Not equal operator - encrypts value and uses regular Drizzle operator + * Render a query term as a cast operand: `''::eql_v3.query_`. + * The cast is what reaches the bundle's `(domain, query_)` overloads — + * a bare `::jsonb` would hit the storage-domain overload, whose CHECK demands + * the ciphertext `c` a query term deliberately omits. `queryCast` is derived + * from the column's own domain (see `queryCastForDomain`), so `sql.raw` is safe. */ - const encryptedNe = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'ne', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + function castOperand( + ctx: ColumnContext, + operator: string, + term: EncryptedQueryResult, + ): SQL { + if (ctx.queryCast === null) { + throw operandFailure( + ctx, + operator, + `column domain "${ctx.builder.getEqlType()}" has no query operand type.`, + ) + } + return sql`${JSON.stringify(term)}::${sql.raw(ctx.queryCast)}` } - /** - * Greater than operator - uses eql_v2.gt() for encrypted columns with ORE index - */ - const encryptedGt = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'gt', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + async function encryptOperand( + ctx: ColumnContext, + value: unknown, + operator: string, + queryType: QueryTypeName, + opts?: EncryptionOperatorCallOpts, + ): Promise { + requireNonNullOperand(ctx, value, operator) - /** - * Greater than or equal operator - uses eql_v2.gte() for encrypted columns with ORE index - */ - const encryptedGte = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'gte', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + const result = await applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType, + } as never, + ), + opts, ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + return castOperand(ctx, operator, result.data) } /** - * Less than operator - uses eql_v2.lt() for encrypted columns with ORE index + * Encrypt a whole operand list in ONE `encryptQuery` batch crossing (rather + * than one per value). The batch is position-stable, so the returned terms + * align index-for-index with `values`; a response of a different length means + * the contract was violated and is rejected rather than silently truncating + * the predicate (which would widen an `inArray` or narrow a `notInArray`). + * Null operands are rejected up front, so no term is filtered out of the batch. */ - const encryptedLt = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'lt', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + async function encryptOperands( + ctx: ColumnContext, + values: unknown[], + operator: string, + queryType: QueryTypeName, + opts?: EncryptionOperatorCallOpts, + ): Promise { + for (const value of values) requireNonNullOperand(ctx, value, operator) - /** - * Less than or equal operator - uses eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedLte = ( - left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createComparisonOperator( - 'lte', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + const terms = values.map((value) => ({ + value, + column: ctx.builder, + table: ctx.table, + queryType, + })) + const result = await applyOperationOptions( + client.encryptQuery(terms as never), + opts, ) - } + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } - /** - * Between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createRangeOperator( - 'between', - left, - min, - max, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + const encrypted = result.data as EncryptedQueryResult[] + if (encrypted.length !== values.length) { + throw operandFailure( + ctx, + operator, + `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, + ) + } + return encrypted.map((term) => castOperand(ctx, operator, term)) } - /** - * Not between operator - uses eql_v2.gte() and eql_v2.lte() for encrypted columns with ORE index - */ - const encryptedNotBetween = ( - left: SQLWrapper, - min: unknown, - max: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createRangeOperator( - 'notBetween', - left, - min, - max, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } + const colSql = (column: SQLWrapper): SQL => sql`${column}` - /** - * Like operator - encrypts value and uses eql_v2.like() for encrypted columns with match index - */ - const encryptedLike = ( + async function equality( + op: EqualityOp, left: SQLWrapper, right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'like', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, op) + requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') + const enc = await encryptOperand(ctx, right, op, 'equality', opts) + return v3Dialect.equality(op, colSql(left), enc) } - /** - * Case-insensitive like operator - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const encryptedIlike = ( + async function comparison( + op: ComparisonOp, left: SQLWrapper, right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'ilike', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, op) + requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') + const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) + return v3Dialect.comparison(op, colSql(left), enc) } - /** - * Not like operator (case insensitive) - encrypts value and uses eql_v2.ilike() for encrypted columns with match index - */ - const encryptedNotIlike = ( + async function range( left: SQLWrapper, - right: unknown, - ): Promise | SQL => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createTextSearchOperator( - 'notIlike', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, - ) - } - - /** - * JSONB path query first operator - encrypts the selector and calls - * `eql_v2.jsonb_path_query_first()` for encrypted columns with searchable JSON. - */ - const encryptedJsonbPathQueryFirst = ( + min: unknown, + max: unknown, + negate: boolean, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') + // Independent operands — encrypt concurrently rather than paying two + // sequential round-trips to the crypto backend. + const [encMin, encMax] = await Promise.all([ + encryptOperand(ctx, min, operator, 'orderAndRange', opts), + encryptOperand(ctx, max, operator, 'orderAndRange', opts), + ]) + // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole + // conjunction without a wrapper here. + const condition = v3Dialect.range(colSql(left), encMin, encMax) + return negate ? sql`NOT ${condition}` : condition + } + + /** + * Fuzzy free-text token match on a `text_search`/`text_match` column. NOT + * containment: it tests whether the needle's downcased 3-gram set is a subset + * of the haystack's, via a bloom filter — order- and multiplicity-insensitive + * and one-sided (a `true` may be a false positive, a `false` never is). Emits + * `eql_v3.matches(col, operand)` (the SQL function's bundle name). + */ + async function matches( left: SQLWrapper, right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbPathQueryFirst', - left, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') + // The answerable-needle rule applies (a sub-`token_length` needle blooms to + // nothing and would match every row); the `query_` cast reaches the + // match overload. + requireAnswerableNeedle(ctx, right, operator) + const enc = await encryptOperand( + ctx, right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + operator, + 'freeTextSearch', + opts, ) + return v3Dialect.contains(colSql(left), enc) } /** - * JSONB get operator - encrypts the selector and uses the `->` operator - * for encrypted columns with searchable JSON. + * Exact encrypted-JSONB containment on an `eql_v3_json_search` (`ste_vec`) column: + * genuine jsonb `@>`, no false positives — hence it keeps the `contains` name. + * `eql_v3_json_search` has no `eql_v3.matches` overload; containment is the `@>` + * operator, whose `(eql_v3_json_search, eql_v3.query_json)` form takes a NARROWED + * query term (searchableJson → no ciphertext), cast to `eql_v3.query_json`. */ - const encryptedJsonbGet = ( + async function containsJsonOp( left: SQLWrapper, right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbGet', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + requireIndex(ctx, JSON_CONTAINMENT_INDEXES, operator, 'JSON containment') + const needle = await encryptJsonContainmentTerm(ctx, right, operator, opts) + return v3Dialect.containsJson(colSql(left), needle) + } + + /** + * Build a `query_json` containment needle for a `json` column — the JSON query + * term carries no ciphertext and satisfies the `eql_v3.query_json` CHECK the + * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's + * domain is `eql_v3_json_search` but its query operand type is the irregular + * `eql_v3.query_json`. + */ + async function encryptJsonContainmentTerm( + ctx: ColumnContext, + value: unknown, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + requireNonNullOperand(ctx, value, operator) + // Reject the empty-object needle. `doc @> '{}'` holds for EVERY document + // (jsonb `{} ⊆ anything`), so `contains(col, {})` would silently return the + // whole table — the same whole-table footgun the bloom path guards against + // with `requireAnswerableNeedle`. An accidental empty filter is a bug, not a + // match-all request; callers wanting every row should omit the predicate. + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 0 + ) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot take an empty object needle on column "${ctx.columnName}": it matches every row. Pass a non-empty sub-object, or omit the predicate to select all rows.`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) + } + const result = await applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'searchableJson', + } as never, + ), + opts, ) - } - - /** - * JSONB path exists operator - encrypts the selector and calls - * `eql_v2.jsonb_path_exists()` for encrypted columns with searchable JSON. - */ - const encryptedJsonbPathExists = ( - left: SQLWrapper, - right: unknown, - ): Promise => { - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - return createJsonbOperator( - 'jsonbPathExists', - left, - right, - columnInfo, - encryptionClient, - defaultTable, - tableCache, + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + return sql`${JSON.stringify(result.data)}::eql_v3.query_json` + } + + /** + * JSONPath selector-with-constraint on an `eql_v3_json_search` (`ste_vec`) + * column. Equality uses a value-selector containment needle, allowing the + * functional GIN index to answer the query. Ordering extracts the path entry + * with a selector hash and compares it to a ciphertext-free scalar ordering + * term. No storage ciphertext is placed in the WHERE clause. + */ + async function selectorCompare( + col: SQLWrapper, + path: string, + op: EqualityOp | ComparisonOp, + value: unknown, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', ) - } - - /** - * In array operator - encrypts all values in the array - */ - const encryptedInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return inArray(left, right as unknown as Parameters[1]) - } + requireNonNullOperand(ctx, value, operator) - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return inArray(left, right as unknown[]) + // A selector compares a scalar leaf — reject non-scalars / non-orderable + // types up front with a clear error, not a deferred DB failure. + const ordering = op !== 'eq' && op !== 'ne' + const leafReason = unsupportedLeafReason(value, ordering) + if (leafReason) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - encryptionClient, - right.map((value) => ({ - value, - column: left, - queryType: queryTypes.equality, - })), - defaultTable, - tableCache, - ) - - // Use regular eq for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => eq(left, encrypted)) - - if (conditions.length === 0) { - return sql`false` + // Surface path-validation failures as EncryptionOperatorError with context. + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - const combined = or(...conditions) - return combined ?? sql`false` - } + const canonicalPath = jsonPathOf(segments) - /** - * Not in array operator - */ - const encryptedNotInArray = async ( - left: SQLWrapper, - right: unknown[] | SQLWrapper, - ): Promise => { - // If right is a SQLWrapper (subquery), pass through to Drizzle - if (isSQLWrapper(right)) { - return notInArray( - left, - right as unknown as Parameters[1], + if (op === 'eq' || op === 'ne') { + const result = await applyOperationOptions( + client.encryptQuery( + { path: canonicalPath, value } as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecValueSelector', + } as never, + ), + opts, + ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) + } + const contains = v3Dialect.containsJson( + colSql(col), + sql`${JSON.stringify(result.data)}::eql_v3.query_json`, + ) + return op === 'eq' + ? contains + : sql`(NOT ${contains} OR ${colSql(col)} IS NULL)` + } + + // Selector hashing and scalar-term encryption are independent, so order + // them concurrently. `steVecTerm` accepts only the JSON-orderable scalar + // families (string and number), enforced above. + const [selResult, termResult] = await Promise.all([ + applyOperationOptions( + client.encryptQuery( + canonicalPath as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecSelector', + } as never, + ), + opts, + ), + applyOperationOptions( + client.encryptQuery( + value as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecTerm', + } as never, + ), + opts, + ), + ]) + if (selResult.failure) { + throw operandFailure(ctx, operator, selResult.failure.message) + } + if (termResult.failure) { + throw operandFailure(ctx, operator, termResult.failure.message) + } + + // A v3 selector term is the bare HMAC hash string; guard the shape so a + // wrapped envelope can't silently bind as a JSON blob and match no rows. + const selValue = selResult.data + if (typeof selValue !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof selValue}.`, ) } - const columnInfo = getColumnInfo(left, defaultTable, tableCache) - - if (!columnInfo.config?.equality || !Array.isArray(right)) { - return notInArray(left, right as unknown[]) - } + const selSql = sql`${selValue}::text` + const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) + const termCast = + typeof value === 'string' + ? 'eql_v3.query_text_ord' + : 'eql_v3.query_double_ord' + const rightTerm = sql`${JSON.stringify(termResult.data)}::${sql.raw(termCast)}` + return v3Dialect.comparison(op, leftEntry, rightTerm) + } - // Encrypt all values in the array in a single batch - const encryptedValues = await encryptValues( - encryptionClient, - right.map((value) => ({ - value, - column: left, - queryType: queryTypes.equality, - })), - defaultTable, - tableCache, + /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar + * operators. Async: each encrypts its operand. */ + function selectorOps(col: SQLWrapper, path: string) { + const at = + (op: EqualityOp | ComparisonOp) => + (value: unknown, opts?: EncryptionOperatorCallOpts) => + selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) + return { + /** `col->'path' = value` (encrypted equality at the selector). A row whose + * document lacks `path` is excluded (it is not equal to `value`). */ + eq: at('eq'), + /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` + * ("not equal to value" covers "has no value"). */ + ne: at('ne'), + /** `col->'path' > value` (encrypted ordering at the selector). */ + gt: at('gt'), + /** `col->'path' >= value`. */ + gte: at('gte'), + /** `col->'path' < value`. */ + lt: at('lt'), + /** `col->'path' <= value`. */ + lte: at('lte'), + /** Order rows ascending by the encrypted scalar at `path`. Missing paths + * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ + asc: (opts?: EncryptionOperatorCallOpts) => + selectorOrder(col, path, 'asc', opts), + /** Order rows descending by the encrypted scalar at `path`. Missing paths + * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ + desc: (opts?: EncryptionOperatorCallOpts) => + selectorOrder(col, path, 'desc', opts), + } + } + + /** Build `ORDER BY eql_v3.ord_term(col -> selector::text)`. + * The selector is encrypted, but the extracted SteVec entry already carries + * its OPE ordering term, so no plaintext comparison operand is needed. */ + async function selectorOrder( + col: SQLWrapper, + path: string, + direction: 'asc' | 'desc', + opts?: EncryptionOperatorCallOpts, + ): Promise { + const operator = `selector(${path}).${direction}` + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', ) - // Use regular ne for each encrypted value - PostgreSQL operators handle it - const conditions = encryptedValues - .filter((encrypted) => encrypted !== undefined) - .map((encrypted) => ne(left, encrypted)) - - if (conditions.length === 0) { - return sql`true` + let canonicalPath: string + try { + canonicalPath = jsonPathOf(parseSelectorSegments(path)) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - const combined = and(...conditions) - return combined ?? sql`true` - } - - /** - * Ascending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const encryptedAsc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo(column, defaultTable, tableCache) - - if (columnInfo.config?.orderAndRange) { - return asc(sql`eql_v2.order_by(${column})`) + const result = await applyOperationOptions( + client.encryptQuery( + canonicalPath as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'steVecSelector', + } as never, + ), + opts, + ) + if (result.failure) { + throw operandFailure(ctx, operator, result.failure.message) } - - return asc(column) - } - - /** - * Descending order helper - uses eql_v2.order_by() for encrypted columns with ORE index - */ - const encryptedDesc = (column: SQLWrapper): SQL => { - const columnInfo = getColumnInfo(column, defaultTable, tableCache) - - if (columnInfo.config?.orderAndRange) { - return desc(sql`eql_v2.order_by(${column})`) + if (typeof result.data !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof result.data}.`, + ) } - return desc(column) + const entry = v3Dialect.selectorEntry( + colSql(col), + sql`${result.data}::text`, + ) + const term = v3Dialect.orderBy(entry, 'ope') + return direction === 'asc' ? asc(term) : desc(term) } - /** - * Batched AND operator - collects lazy operators, batches encryption, and combines conditions - */ - const encryptedAnd = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - // Single pass: separate lazy operators from regular conditions - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - // Check if promise is also a lazy operator - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - // If there are no lazy operators, just use Drizzle's and() - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return and(...allConditions) ?? sql`true` - } - - // Single pass: collect all values to encrypt with metadata - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } + async function inArrayOp( + left: SQLWrapper, + values: unknown[], + negate: boolean, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(left, operator) + if (values.length === 0) { + throw new EncryptionOperatorError( + `Operator "${operator}" requires a non-empty list of values for column "${ctx.columnName}".`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) } - - // Batch encrypt all values - const encryptedResults = await encryptValues( - encryptionClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultTable, - tableCache, + // Gate and resolve the context once for the whole list, then encrypt it in + // a single `encryptQuery` batch crossing. + requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') + const op: EqualityOp = negate ? 'ne' : 'eq' + const encrypted = await encryptOperands( + ctx, + values, + operator, + 'equality', + opts, ) - - // Group encrypted values by lazy operator index - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - // Execute all lazy operators with their encrypted values - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - // Await any regular promises - const regularPromisesResults = await Promise.all(regularPromises) - - // Combine all conditions - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return and(...allConditions) ?? sql`true` + const conditions = encrypted.map((enc) => + v3Dialect.equality(op, colSql(left), enc), + ) + // The empty-list guard above leaves `conditions` non-empty, so `and`/`or` + // never return undefined here. + return (negate ? and(...conditions) : or(...conditions)) as SQL } - /** - * Batched OR operator - collects lazy operators, batches encryption, and combines conditions - */ - const encryptedOr = async ( - ...conditions: (SQL | SQLWrapper | Promise | undefined)[] - ): Promise => { - const lazyOperators: LazyOperator[] = [] - const regularConditions: (SQL | SQLWrapper | undefined)[] = [] - const regularPromises: Promise[] = [] - - for (const condition of conditions) { - if (condition === undefined) { - continue - } - - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else if (condition instanceof Promise) { - if (isLazyOperator(condition)) { - lazyOperators.push(condition) - } else { - regularPromises.push(condition) - } - } else { - regularConditions.push(condition) - } - } - - if (lazyOperators.length === 0) { - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...(await Promise.all(regularPromises)), - ] - return or(...allConditions) ?? sql`false` - } - - const valuesToEncrypt: Array<{ - value: unknown - column: SQLWrapper - columnInfo: ColumnInfo - queryType?: QueryTypeName - lazyOpIndex: number - isMin?: boolean - isMax?: boolean - }> = [] - - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - if (!lazyOp.needsEncryption) { - continue - } - - if (lazyOp.min !== undefined && lazyOp.max !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.min, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMin: true, - }) - valuesToEncrypt.push({ - value: lazyOp.max, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - isMax: true, - }) - } else if (lazyOp.right !== undefined) { - valuesToEncrypt.push({ - value: lazyOp.right, - column: lazyOp.left, - columnInfo: lazyOp.columnInfo, - queryType: lazyOp.queryType, - lazyOpIndex: i, - }) - } - } + function orderTerm(column: SQLWrapper, operator: string): SQL { + const ctx = resolveContext(column, operator) + requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') + return v3Dialect.orderBy(colSql(column), ctx.indexes.ore ? 'ore' : 'ope') + } - const encryptedResults = await encryptValues( - encryptionClient, - valuesToEncrypt.map((v) => ({ - value: v.value, - column: v.column, - queryType: v.queryType, - })), - defaultTable, - tableCache, + async function combine( + joiner: typeof and, + empty: SQL, + conditions: (SQL | SQLWrapper | Promise | undefined)[], + ): Promise { + const present = conditions.filter( + (c): c is SQL | SQLWrapper | Promise => c !== undefined, ) - - const encryptedByLazyOp = new Map< - number, - { value?: unknown; min?: unknown; max?: unknown } - >() - - for (let i = 0; i < valuesToEncrypt.length; i++) { - const { lazyOpIndex, isMin, isMax } = valuesToEncrypt[i] - const encrypted = encryptedResults[i] - - let group = encryptedByLazyOp.get(lazyOpIndex) - if (!group) { - group = {} - encryptedByLazyOp.set(lazyOpIndex, group) - } - - if (isMin) { - group.min = encrypted - } else if (isMax) { - group.max = encrypted - } else { - group.value = encrypted - } - } - - const sqlConditions: SQL[] = [] - for (let i = 0; i < lazyOperators.length; i++) { - const lazyOp = lazyOperators[i] - const encrypted = encryptedByLazyOp.get(i) - - let sqlCondition: SQL - if (lazyOp.needsEncryption && encrypted) { - const encryptedValues: Array<{ value: unknown; encrypted: unknown }> = - [] - if (encrypted.value !== undefined) { - encryptedValues.push({ - value: lazyOp.right, - encrypted: encrypted.value, - }) - } - if (encrypted.min !== undefined) { - encryptedValues.push({ value: lazyOp.min, encrypted: encrypted.min }) - } - if (encrypted.max !== undefined) { - encryptedValues.push({ value: lazyOp.max, encrypted: encrypted.max }) - } - sqlCondition = await executeLazyOperator(lazyOp, encryptedValues) - } else { - sqlCondition = lazyOp.execute(lazyOp.right) - } - - sqlConditions.push(sqlCondition) - } - - const regularPromisesResults = await Promise.all(regularPromises) - - const allConditions: (SQL | SQLWrapper | undefined)[] = [ - ...regularConditions, - ...sqlConditions, - ...regularPromisesResults, - ] - - return or(...allConditions) ?? sql`false` + const resolved = await Promise.all(present) + return joiner(...resolved) ?? empty } return { - // Comparison operators - eq: encryptedEq, - ne: encryptedNe, - gt: encryptedGt, - gte: encryptedGte, - lt: encryptedLt, - lte: encryptedLte, - - // Range operators - between: encryptedBetween, - notBetween: encryptedNotBetween, - - // Text search operators - like: encryptedLike, - ilike: encryptedIlike, - notIlike: encryptedNotIlike, - - // Searchable JSON operators - jsonbPathQueryFirst: encryptedJsonbPathQueryFirst, - jsonbGet: encryptedJsonbGet, - jsonbPathExists: encryptedJsonbPathExists, - - // Array operators - inArray: encryptedInArray, - notInArray: encryptedNotInArray, - - // Sorting operators - asc: encryptedAsc, - desc: encryptedDesc, - - // AND operator - batches encryption operations - and: encryptedAnd, - - // OR operator - batches encryption operations - or: encryptedOr, - - // Operators that don't need encryption (pass through to Drizzle) - exists, - notExists, + /** Equality: `column = value`. Encrypts `r` and emits `eql_v3.eq`. + * Requires a `unique` or `ore` index on the column. */ + eq: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + equality('eq', l, r, opts), + /** Inequality: `column <> value`. Encrypts `r` and emits `eql_v3.neq`. + * Requires a `unique` or `ore` index on the column. */ + ne: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + equality('ne', l, r, opts), + /** Greater-than: `column > value`. Encrypts `r` and emits `eql_v3.gt`. + * Requires an `ore` (order/range) index. */ + gt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('gt', l, r, opts), + /** Greater-than-or-equal: `column >= value`. Encrypts `r` and emits + * `eql_v3.gte`. Requires an `ore` (order/range) index. */ + gte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('gte', l, r, opts), + /** Less-than: `column < value`. Encrypts `r` and emits `eql_v3.lt`. + * Requires an `ore` (order/range) index. */ + lt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('lt', l, r, opts), + /** Less-than-or-equal: `column <= value`. Encrypts `r` and emits + * `eql_v3.lte`. Requires an `ore` (order/range) index. */ + lte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + comparison('lte', l, r, opts), + /** Inclusive range `min <= column <= max`. Encrypts both bounds + * concurrently. Requires an `ore` (order/range) index. */ + between: ( + l: SQLWrapper, + min: unknown, + max: unknown, + opts?: EncryptionOperatorCallOpts, + ) => range(l, min, max, false, 'between', opts), + /** Negated inclusive range `NOT (min <= column <= max)`. Encrypts both + * bounds concurrently. Requires an `ore` (order/range) index. */ + notBetween: ( + l: SQLWrapper, + min: unknown, + max: unknown, + opts?: EncryptionOperatorCallOpts, + ) => range(l, min, max, true, 'notBetween', opts), + /** Fuzzy free-text token match — the needle's 3-gram set is (bloom-)tested + * as a subset of the column's. NOT containment: order/multiplicity- + * insensitive and one-sided (a `true` may be a false positive). Encrypts + * `r`. Requires a `match` (free-text search) index. */ + matches: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + matches(l, r, 'matches', opts), + /** Exact encrypted-JSONB containment (`@>`): matches rows whose document + * contains the given sub-object. No false positives. Encrypts `r`. Requires + * a `ste_vec` index (a `types.Json` column). */ + contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => + containsJsonOp(l, r, 'contains', opts), + /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. + * Returns comparison and ordering methods bound to `col->'path'` — e.g. + * `await ops.selector(users.doc, '$.age').gt(21)` emits + * `col->'' > `, while `.asc()` emits + * `ORDER BY eql_v3.ord_term(col->'')`. Its unique power over `contains` + * is ordering at a path (`gt`/`gte`/`lt`/`lte` and `asc`/`desc`); `eq`/`ne` + * are also provided. Dot-notation object paths only in v1. */ + selector: (l: SQLWrapper, path: string) => selectorOps(l, path), + /** Membership: ORs one encrypted `eq` term per value. The whole list is + * encrypted in one `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ + inArray: ( + l: SQLWrapper, + values: unknown[], + opts?: EncryptionOperatorCallOpts, + ) => inArrayOp(l, values, false, 'inArray', opts), + /** Non-membership: ANDs one encrypted `ne` term per value. The whole list + * is encrypted in one `encryptQuery` batch crossing. Rejects an empty list; + * requires a `unique` or `ore` index. */ + notInArray: ( + l: SQLWrapper, + values: unknown[], + opts?: EncryptionOperatorCallOpts, + ) => inArrayOp(l, values, true, 'notInArray', opts), + /** Ascending order by the encrypted order term (`eql_v3.ord_term` / + * `eql_v3.ord_term_ore`, by the column's ordering flavour). + * Synchronous (no operand to encrypt). Requires an ordering index. */ + asc: (c: SQLWrapper) => asc(orderTerm(c, 'asc')), + /** Descending order by the encrypted order term (`eql_v3.ord_term` / + * `eql_v3.ord_term_ore`, by the column's ordering flavour). + * Synchronous (no operand to encrypt). Requires an ordering index. */ + desc: (c: SQLWrapper) => desc(orderTerm(c, 'desc')), + /** Conjunction of the given conditions, awaiting any async operands and + * dropping `undefined`. Empty input resolves to `true`. */ + and: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => + combine(and, sql`true`, conds), + /** Disjunction of the given conditions, awaiting any async operands and + * dropping `undefined`. Empty input resolves to `false`. */ + or: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => + combine(or, sql`false`, conds), + /** Drizzle's `isNull`, re-exported unchanged — `column IS NULL` needs no + * encryption and works on any (nullable) encrypted column. */ isNull, + /** Drizzle's `isNotNull`, re-exported unchanged — `column IS NOT NULL` + * needs no encryption. */ isNotNull, + /** Drizzle's `not`, re-exported unchanged — negates an already-built + * (encrypted) predicate. Safe over any operator here, including `between`, + * whose fragment is self-parenthesising. */ not, - // Array operators that work with arrays directly (not encrypted values) - arrayContains, - arrayContained, - arrayOverlaps, + /** Drizzle's `exists`, re-exported unchanged — for correlated subqueries. */ + exists, + /** Drizzle's `notExists`, re-exported unchanged — for correlated + * subqueries. */ + notExists, } } diff --git a/packages/stack-drizzle/src/schema-extraction.ts b/packages/stack-drizzle/src/schema-extraction.ts index 90bb3ec1e..7e0b4226f 100644 --- a/packages/stack-drizzle/src/schema-extraction.ts +++ b/packages/stack-drizzle/src/schema-extraction.ts @@ -1,130 +1,50 @@ import { - type EncryptedColumn, - type EncryptedTable, - encryptedColumn, + type AnyEncryptedV3Column, + type AnyV3Table, encryptedTable, -} from '@cipherstash/stack/schema' -import type { PgCustomColumn, PgTable } from 'drizzle-orm/pg-core' -import { getEncryptedColumnConfig } from './index.js' +} from '@cipherstash/stack/eql/v3' +import type { PgTable } from 'drizzle-orm/pg-core' +import { getEqlV3Column } from './column.js' + +/** Drizzle stashes the SQL table name on this well-known symbol key. */ +const DRIZZLE_NAME = Symbol.for('drizzle:Name') /** - * Extracts the encrypted column keys from a Drizzle table type. - * Columns created with `encryptedType` are `PgCustomColumn` instances; - * this picks only those keys and maps them to `EncryptedColumn`. + * Read the SQL table name Drizzle stashes on a `pgTable`. Returns `undefined` + * for a non-object or a table not built with `pgTable()`. Shared by + * {@link extractEncryptionSchema} and the operator factory so the + * symbol-key introspection lives in exactly one place. */ -// biome-ignore lint/suspicious/noExplicitAny: PgCustomColumn requires a wide generic -type DrizzleEncryptedSchema = { - [K in keyof T as T[K] extends PgCustomColumn - ? K - : never]: EncryptedColumn +export function getDrizzleTableName(table: unknown): string | undefined { + if (!table || typeof table !== 'object') return undefined + const name = (table as Record)[DRIZZLE_NAME] + return typeof name === 'string' ? name : undefined } -/** - * Extracts an encryption schema from a Drizzle table definition. - * This function identifies columns created with `encryptedType` and - * builds a corresponding `EncryptedTable` with `encryptedColumn` definitions. - * - * @param table - The Drizzle table definition - * @returns A EncryptedTable that can be used with encryption client initialization - * - * @example - * ```ts - * const drizzleUsersTable = pgTable('users', { - * email: encryptedType('email', { freeTextSearch: true, equality: true }), - * age: encryptedType('age', { dataType: 'number', orderAndRange: true }), - * }) - * - * const encryptionSchema = extractEncryptionSchema(drizzleUsersTable) - * const client = await createEncryptionClient({ schemas: [encryptionSchema.build()] }) - * ``` - */ -// We use any for the PgTable generic because we need to access Drizzle's internal properties -// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties -export function extractEncryptionSchema>( - table: T, -): EncryptedTable> & DrizzleEncryptedSchema { - // Drizzle tables store the name in a Symbol property - // biome-ignore lint/suspicious/noExplicitAny: Drizzle tables don't expose Symbol properties in types - const tableName = (table as any)[Symbol.for('drizzle:Name')] as - | string - | undefined +export function extractEncryptionSchema(table: PgTable): AnyV3Table { + const tableName = getDrizzleTableName(table) if (!tableName) { throw new Error( - 'Unable to extract table name from Drizzle table. Ensure you are using a table created with pgTable().', + 'Unable to read table name from Drizzle table. Use a table created with pgTable().', ) } - const columns: Record = {} - - // Iterate through table columns - for (const [columnName, column] of Object.entries(table)) { - // Skip if it's not a column (could be methods or other properties) - if (typeof column !== 'object' || column === null) { - continue - } - - // Check if this column has encrypted configuration - const config = getEncryptedColumnConfig(columnName, column) - - if (config) { - // Extract the actual column name from the column object (not the schema key) - // Drizzle columns have a 'name' property that contains the actual database column name - const actualColumnName = column.name || config.name - - // This is an encrypted column - build encryptedColumn using the actual column name - const csCol = encryptedColumn(actualColumnName) - - // Apply data type - if (config.dataType && config.dataType !== 'string') { - csCol.dataType(config.dataType) - } - - // Apply indexes based on configuration - if (config.orderAndRange) { - csCol.orderAndRange() - } - - if (config.equality) { - if (Array.isArray(config.equality)) { - // Custom token filters - csCol.equality(config.equality) - } else { - // Default equality (boolean true) - csCol.equality() - } - } - - if (config.freeTextSearch) { - if (typeof config.freeTextSearch === 'object') { - // Custom match options - csCol.freeTextSearch(config.freeTextSearch) - } else { - // Default freeTextSearch (boolean true) - csCol.freeTextSearch() - } - } - - if (config.searchableJson) { - if (config.dataType !== 'json') { - throw new Error( - `Column "${columnName}" has searchableJson enabled but dataType is "${config.dataType ?? 'string'}". searchableJson requires dataType: 'json'.`, - ) - } - csCol.searchableJson() - } - - columns[actualColumnName] = csCol - } + const columns: Record = {} + for (const [property, column] of Object.entries(table)) { + if (typeof column !== 'object' || column === null) continue + const columnName = + 'name' in column && typeof column.name === 'string' + ? column.name + : property + const builder = getEqlV3Column(columnName, column) + if (builder) columns[property] = builder } if (Object.keys(columns).length === 0) { throw new Error( - `No encrypted columns found in table "${tableName}". Use encryptedType() to define encrypted columns.`, + `No encrypted v3 columns found in table "${tableName}". Declare columns with the v3 drizzle \`types\` namespace.`, ) } - return encryptedTable(tableName, columns) as EncryptedTable< - DrizzleEncryptedSchema - > & - DrizzleEncryptedSchema + return encryptedTable(tableName, columns) } diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/sql-dialect.ts similarity index 100% rename from packages/stack-drizzle/src/v3/sql-dialect.ts rename to packages/stack-drizzle/src/sql-dialect.ts diff --git a/packages/stack-drizzle/src/v3/types.ts b/packages/stack-drizzle/src/types.ts similarity index 100% rename from packages/stack-drizzle/src/v3/types.ts rename to packages/stack-drizzle/src/types.ts diff --git a/packages/stack-drizzle/src/v3/index.ts b/packages/stack-drizzle/src/v3/index.ts deleted file mode 100644 index 7c0426d4c..000000000 --- a/packages/stack-drizzle/src/v3/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { EqlV3CodecError, v3FromDriver, v3ToDriver } from './codec.js' -export { getEqlV3Column, isEqlV3Column, makeEqlV3Column } from './column.js' -export { - createEncryptionOperatorsV3, - EncryptionOperatorError, -} from './operators.js' -export { extractEncryptionSchemaV3 } from './schema-extraction.js' -export { types } from './types.js' diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts deleted file mode 100644 index f2e113855..000000000 --- a/packages/stack-drizzle/src/v3/operators.ts +++ /dev/null @@ -1,959 +0,0 @@ -import type { Result } from '@byteslice/result' -import type { AuditConfig } from '@cipherstash/stack/adapter-kit' -import { - jsonPathOf, - matchNeedleError, - parseSelectorSegments, - reconstructSelectorDocument, - stripDomainSchema, - unsupportedLeafReason, -} from '@cipherstash/stack/adapter-kit' -import type { - AnyEncryptedV3Column, - AnyV3Table, -} from '@cipherstash/stack/eql/v3' -import type { EncryptionError } from '@cipherstash/stack/errors' -import type { LockContext } from '@cipherstash/stack/identity' -import type { ColumnSchema } from '@cipherstash/stack/schema' -import type { - EncryptedQueryResult, - QueryTypeName, -} from '@cipherstash/stack/types' -import { - and, - asc, - Column, - desc, - exists, - is, - isNotNull, - isNull, - not, - notExists, - or, - type SQL, - type SQLWrapper, - sql, -} from 'drizzle-orm' -import type { PgTable } from 'drizzle-orm/pg-core' -import { getEqlV3Column } from './column.js' -import { - extractEncryptionSchemaV3, - getDrizzleTableName, -} from './schema-extraction.js' -import { type ComparisonOp, type EqualityOp, v3Dialect } from './sql-dialect.js' - -/** - * The client capability this factory consumes: `encryptQuery`, in both its - * single (`value, opts`) and batch (`terms[]`) forms. Declared structurally — - * with maximally-permissive operands — so it is satisfied by the nominal - * `EncryptionClient`, by the `TypedEncryptionClient` that `EncryptionV3` returns - * (whatever its schema tuple), AND by a hand-rolled test double, none needing a - * cast. Typing the parameter to the nominal `TypedEncryptionClient` would - * reject a client built for a narrower schema tuple (it accepts fewer tables than - * `readonly AnyV3Table[]`); the structural surface sidesteps that variance. - * - * Every operand is a QUERY TERM, not a storage envelope: `encryptQuery` mints a - * ciphertext-free term (no `c`) carrying all of the column's configured index - * terms, which the operator layer casts to the column's `eql_v3.query_` - * type. This reaches the bundle's `(domain, query_)` overloads and keeps - * WHERE-clause payloads free of the ciphertext a query never needs (#622). - * - * `never` operands: the real client's `encryptQuery` is generic (`queryType` is - * constrained to the column's own query types), which a concrete signature here - * cannot match. `never` params keep the structural surface satisfiable by that - * generic method AND by a test double; the call sites cast their real operands. - */ -type OperandEncryptionClient = { - encryptQuery( - value: never, - opts: never, - ): ChainableOperation - encryptQuery(terms: never): ChainableOperation -} - -// Path helpers now live in @cipherstash/stack/adapter-kit (shared with the -// Supabase adapter, #650); re-exported so existing imports keep working. -export { parseSelectorSegments, reconstructSelectorDocument } - -/** - * A dedicated error for v3 operator gating and operand-encryption failures, - * carrying the offending column/table/operator for diagnostics. - * - * INTENTIONAL FORK: this mirrors the v2 adapter's `EncryptionOperatorError` - * rather than sharing it. Unifying the two would couple `./drizzle` and - * `./eql/v3/drizzle` — two independently-versioned public entry points — so the - * duplication is deliberate, not an oversight. - */ -export class EncryptionOperatorError extends Error { - constructor( - message: string, - public readonly context?: { - columnName?: string - tableName?: string - operator?: string - }, - ) { - super(message) - this.name = 'EncryptionOperatorError' - } -} - -interface ColumnContext { - builder: AnyEncryptedV3Column - table: AnyV3Table - indexes: ColumnSchema['indexes'] - columnName: string - tableName: string - /** The `eql_v3.query_` type an operand for this column casts to, so - * `encryptQuery`'s ciphertext-free term reaches the narrowed-query overloads. - * `null` for storage-only columns (no query domain); those never encrypt an - * operand — every operator gates on a query capability first. JSON columns - * override this at the call site (`query_json`, an irregular name). */ - queryCast: string | null -} - -/** - * The `eql_v3.query_` cast for a column's storage domain — e.g. - * `public.eql_v3_text_search` → `eql_v3.query_text_search`. Uniform across the - * queryable column domains (`_eq`, `_ord`, `_ord_ore`, `_match`, `_search`); the - * two irregular cases are handled elsewhere: storage-only domains - * (`eql_v3_boolean`, the bare base types) have no query domain and return `null` - * (they are never queried), and `eql_v3_json_search` maps to `query_json`, cast - * explicitly on the JSON path. - */ -function queryCastForDomain(eqlType: string): string | null { - const bare = stripDomainSchema(eqlType) // public.eql_v3_text_search → eql_v3_text_search - const prefix = 'eql_v3_' - if (!bare.startsWith(prefix)) return null - const suffix = bare.slice(prefix.length) - // No index suffix (bare storage-only domain like `boolean`, `text`) → no query - // domain exists. These are gated out before any operand is encrypted. The - // suffixes match the column factories in `@/eql/v3/columns` exactly: ope - // ordering is the `_ord` domain (not `_ord_ope`) and text search is `_search` - // (there is no `_search_ore` column), so those two never occur here. - if (!/_(eq|ord|ord_ore|match|search)$/.test(suffix)) { - return null - } - return `eql_v3.query_${suffix}` -} - -export type EncryptionOperatorCallOpts = { - lockContext?: LockContext - audit?: AuditConfig -} - -/** - * An SDK encryption operation after its lock context has been applied: still - * auditable and awaitable, but not re-lockable. `withLockContext` returns this, - * not the full {@link ChainableOperation}, mirroring the real - * `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot - * lock-context twice). Modelling that is what lets the real client type satisfy - * the structural surface with no cast. - */ -type AuditableOperation = { - audit(config: AuditConfig): AuditableOperation - then: PromiseLike>['then'] -} - -/** - * The subset of an SDK encryption operation this factory drives: the fluent - * `withLockContext`/`audit` chain, and a `then` that resolves the operation's - * `Result`. Generic over the resolved payload `T` so the single `encryptQuery` - * carries an `EncryptedQueryResult` term and the batch form an - * `EncryptedQueryResult[]`, rather than the `unknown` this erased to before. - * - * Structural, not the concrete `EncryptQueryOperation` class, because the client - * is passed in and the factory must accept any implementation with this surface. - */ -type ChainableOperation = { - withLockContext(lockContext: LockContext): AuditableOperation - audit(config: AuditConfig): AuditableOperation - then: PromiseLike>['then'] -} - -/** - * Build v3-aware query operators (`eq`, `gte`, `matches`, `contains`, `asc`, …) bound to an - * encryption `client`. Each comparison/containment operator AUTO-ENCRYPTS its - * plaintext operand into an EQL v3 query term before handing it to Drizzle, so - * callers pass plaintext and the emitted SQL compares encrypted values. Every - * operator also gates on the target column's capabilities and throws - * {@link EncryptionOperatorError} when the column can't answer the operator - * (e.g. ordering a non-`ore` column). - * - * @param client - anything that can `encryptQuery` — the nominal - * `EncryptionClient` or the `TypedEncryptionClient` from `EncryptionV3` (no - * cast needed). - * @param defaults - lock context / audit applied to every operand encryption - * unless a per-call override is supplied. - * - * @example - * ```typescript - * const ops = createEncryptionOperatorsV3(await EncryptionV3({ schemas: [users] })) - * await db.select().from(users).where(await ops.eq(users.email, 'a@b.com')) - * ``` - */ -export function createEncryptionOperatorsV3( - client: OperandEncryptionClient, - defaults: EncryptionOperatorCallOpts = {}, -) { - const tableCache = new WeakMap() - // Per-column context memo. `resolveContext` is value-independent, so caching - // by column identity makes `inArray`/`notInArray` build the context (and its - // deep-cloned match block) once for the whole list instead of once per value. - const contextCache = new WeakMap() - - function drizzleTableOf(column: SQLWrapper): PgTable | undefined { - return is(column, Column) - ? (column.table as PgTable | undefined) - : undefined - } - - function resolveContext(column: SQLWrapper, operator: string): ColumnContext { - const cached = contextCache.get(column) - if (cached) return cached - - const columnName = is(column, Column) ? column.name : 'unknown' - const builder = getEqlV3Column(columnName, column) - if (!builder) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires an encrypted v3 column, but "${columnName}" is not one.`, - { columnName, operator }, - ) - } - - const drizzleTable = drizzleTableOf(column) - const tableName = getDrizzleTableName(drizzleTable) ?? 'unknown' - - let table = drizzleTable ? tableCache.get(drizzleTable) : undefined - if (!table && drizzleTable) { - table = extractEncryptionSchemaV3(drizzleTable) - tableCache.set(drizzleTable, table) - } - if (!table) { - throw new EncryptionOperatorError( - `Unable to resolve the encrypted table for column "${columnName}".`, - { columnName, operator }, - ) - } - - const context: ColumnContext = { - builder, - table, - indexes: builder.build().indexes, - columnName, - tableName, - queryCast: queryCastForDomain(builder.getEqlType()), - } - contextCache.set(column, context) - return context - } - - /** - * Gate an operator on the column's indexes. `indexes` is a disjunction — any - * one of them grants the capability — so equality (`unique` OR `ore`) and the - * single-index gates share one rule and one diagnostic shape. - */ - function requireIndex( - ctx: ColumnContext, - indexes: readonly ('unique' | 'ore' | 'ope' | 'match' | 'ste_vec')[], - operator: string, - capability: string, - ): void { - if (!indexes.some((index) => ctx.indexes[index])) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires ${capability} on column "${ctx.columnName}" (domain ${ctx.builder.getEqlType()} does not support it).`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - } - - // Ordering flavour is pinned by the column's domain (eql-3.0.0): `_ord` - // domains carry `ope` (`op` CLLW-OPE term), `_ord_ore` domains carry `ore` - // (`ob` block-ORE term). Either satisfies the order/range operators, and an - // order-capable column answers equality via its ordering term too. - const EQUALITY_INDEXES = ['unique', 'ore', 'ope'] as const - const ORDERING_INDEXES = ['ore', 'ope'] as const - // Two DISTINCT operators, split by semantics (#617): - // - `matches` is bloom free-text (`match`, a `text_search`/`text_match` - // column): a one-sided, order- and multiplicity-insensitive token match that - // may false-positive. It emits `eql_v3.matches(col, operand)` (the SQL - // function keeps its bundle name) but is NOT containment. - // - `contains` is encrypted-JSONB containment (an `eql_v3_json_search` column, - // `ste_vec` index): exact jsonb `@>`, no false positives — genuine - // containment, so it keeps the `contains` name. - const MATCH_INDEXES = ['match'] as const - const JSON_CONTAINMENT_INDEXES = ['ste_vec'] as const - - function applyOperationOptions( - op: ChainableOperation, - opts?: EncryptionOperatorCallOpts, - ): AuditableOperation { - const lockContext = opts?.lockContext ?? defaults.lockContext - const audit = opts?.audit ?? defaults.audit - const withLock = lockContext ? op.withLockContext(lockContext) : op - if (audit) withLock.audit(audit) - return withLock - } - - function requireNonNullOperand( - ctx: ColumnContext, - value: unknown, - operator: string, - ): void { - if (value == null) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot encrypt a null operand for column "${ctx.columnName}". Use isNull() or isNotNull() for NULL checks.`, - { - columnName: ctx.columnName, - tableName: ctx.tableName, - operator, - }, - ) - } - } - - /** - * Reject a free-text needle the column's match index cannot answer. A needle - * shorter than the tokenizer's `token_length` yields an empty bloom filter, - * and `stored_bf @> '{}'` holds for every row — so without this the query - * silently returns the whole table. - */ - function requireAnswerableNeedle( - ctx: ColumnContext, - value: unknown, - operator: string, - ): void { - const match = ctx.indexes.match - if (!match) return - const reason = matchNeedleError(value, match) - if (reason) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot search column "${ctx.columnName}": ${reason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - } - - function operandFailure( - ctx: ColumnContext, - operator: string, - reason: string, - ): EncryptionOperatorError { - return new EncryptionOperatorError( - `Failed to encrypt query operand for "${ctx.columnName}": ${reason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - /** - * Render a query term as a cast operand: `''::eql_v3.query_`. - * The cast is what reaches the bundle's `(domain, query_)` overloads — - * a bare `::jsonb` would hit the storage-domain overload, whose CHECK demands - * the ciphertext `c` a query term deliberately omits. `queryCast` is derived - * from the column's own domain (see `queryCastForDomain`), so `sql.raw` is safe. - */ - function castOperand( - ctx: ColumnContext, - operator: string, - term: EncryptedQueryResult, - ): SQL { - if (ctx.queryCast === null) { - throw operandFailure( - ctx, - operator, - `column domain "${ctx.builder.getEqlType()}" has no query operand type.`, - ) - } - return sql`${JSON.stringify(term)}::${sql.raw(ctx.queryCast)}` - } - - async function encryptOperand( - ctx: ColumnContext, - value: unknown, - operator: string, - queryType: QueryTypeName, - opts?: EncryptionOperatorCallOpts, - ): Promise { - requireNonNullOperand(ctx, value, operator) - - const result = await applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType, - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - return castOperand(ctx, operator, result.data) - } - - /** - * Encrypt a whole operand list in ONE `encryptQuery` batch crossing (rather - * than one per value). The batch is position-stable, so the returned terms - * align index-for-index with `values`; a response of a different length means - * the contract was violated and is rejected rather than silently truncating - * the predicate (which would widen an `inArray` or narrow a `notInArray`). - * Null operands are rejected up front, so no term is filtered out of the batch. - */ - async function encryptOperands( - ctx: ColumnContext, - values: unknown[], - operator: string, - queryType: QueryTypeName, - opts?: EncryptionOperatorCallOpts, - ): Promise { - for (const value of values) requireNonNullOperand(ctx, value, operator) - - const terms = values.map((value) => ({ - value, - column: ctx.builder, - table: ctx.table, - queryType, - })) - const result = await applyOperationOptions( - client.encryptQuery(terms as never), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - - const encrypted = result.data as EncryptedQueryResult[] - if (encrypted.length !== values.length) { - throw operandFailure( - ctx, - operator, - `batch query encryption returned ${encrypted.length} terms for ${values.length} values.`, - ) - } - return encrypted.map((term) => castOperand(ctx, operator, term)) - } - - const colSql = (column: SQLWrapper): SQL => sql`${column}` - - async function equality( - op: EqualityOp, - left: SQLWrapper, - right: unknown, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, op) - requireIndex(ctx, EQUALITY_INDEXES, op, 'equality') - const enc = await encryptOperand(ctx, right, op, 'equality', opts) - return v3Dialect.equality(op, colSql(left), enc) - } - - async function comparison( - op: ComparisonOp, - left: SQLWrapper, - right: unknown, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, op) - requireIndex(ctx, ORDERING_INDEXES, op, 'order/range') - const enc = await encryptOperand(ctx, right, op, 'orderAndRange', opts) - return v3Dialect.comparison(op, colSql(left), enc) - } - - async function range( - left: SQLWrapper, - min: unknown, - max: unknown, - negate: boolean, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') - // Independent operands — encrypt concurrently rather than paying two - // sequential round-trips to the crypto backend. - const [encMin, encMax] = await Promise.all([ - encryptOperand(ctx, min, operator, 'orderAndRange', opts), - encryptOperand(ctx, max, operator, 'orderAndRange', opts), - ]) - // `v3Dialect.range` is already parenthesised, so `NOT` binds to the whole - // conjunction without a wrapper here. - const condition = v3Dialect.range(colSql(left), encMin, encMax) - return negate ? sql`NOT ${condition}` : condition - } - - /** - * Fuzzy free-text token match on a `text_search`/`text_match` column. NOT - * containment: it tests whether the needle's downcased 3-gram set is a subset - * of the haystack's, via a bloom filter — order- and multiplicity-insensitive - * and one-sided (a `true` may be a false positive, a `false` never is). Emits - * `eql_v3.matches(col, operand)` (the SQL function's bundle name). - */ - async function matches( - left: SQLWrapper, - right: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, MATCH_INDEXES, operator, 'free-text search') - // The answerable-needle rule applies (a sub-`token_length` needle blooms to - // nothing and would match every row); the `query_` cast reaches the - // match overload. - requireAnswerableNeedle(ctx, right, operator) - const enc = await encryptOperand( - ctx, - right, - operator, - 'freeTextSearch', - opts, - ) - return v3Dialect.contains(colSql(left), enc) - } - - /** - * Exact encrypted-JSONB containment on an `eql_v3_json_search` (`ste_vec`) column: - * genuine jsonb `@>`, no false positives — hence it keeps the `contains` name. - * `eql_v3_json_search` has no `eql_v3.matches` overload; containment is the `@>` - * operator, whose `(eql_v3_json_search, eql_v3.query_json)` form takes a NARROWED - * query term (searchableJson → no ciphertext), cast to `eql_v3.query_json`. - */ - async function containsJsonOp( - left: SQLWrapper, - right: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - requireIndex(ctx, JSON_CONTAINMENT_INDEXES, operator, 'JSON containment') - const needle = await encryptJsonContainmentTerm(ctx, right, operator, opts) - return v3Dialect.containsJson(colSql(left), needle) - } - - /** - * Build a `query_json` containment needle for a `json` column — the JSON query - * term carries no ciphertext and satisfies the `eql_v3.query_json` CHECK the - * `@>` overload needs. Cast here (not by `queryCastForDomain`): a json column's - * domain is `eql_v3_json_search` but its query operand type is the irregular - * `eql_v3.query_json`. - */ - async function encryptJsonContainmentTerm( - ctx: ColumnContext, - value: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - requireNonNullOperand(ctx, value, operator) - // Reject the empty-object needle. `doc @> '{}'` holds for EVERY document - // (jsonb `{} ⊆ anything`), so `contains(col, {})` would silently return the - // whole table — the same whole-table footgun the bloom path guards against - // with `requireAnswerableNeedle`. An accidental empty filter is a bug, not a - // match-all request; callers wanting every row should omit the predicate. - if ( - value !== null && - typeof value === 'object' && - !Array.isArray(value) && - Object.keys(value).length === 0 - ) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot take an empty object needle on column "${ctx.columnName}": it matches every row. Pass a non-empty sub-object, or omit the predicate to select all rows.`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - const result = await applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'searchableJson', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - return sql`${JSON.stringify(result.data)}::eql_v3.query_json` - } - - /** - * JSONPath selector-with-constraint on an `eql_v3_json_search` (`ste_vec`) - * column. Equality uses a value-selector containment needle, allowing the - * functional GIN index to answer the query. Ordering extracts the path entry - * with a selector hash and compares it to a ciphertext-free scalar ordering - * term. No storage ciphertext is placed in the WHERE clause. - */ - async function selectorCompare( - col: SQLWrapper, - path: string, - op: EqualityOp | ComparisonOp, - value: unknown, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(col, operator) - requireIndex( - ctx, - JSON_CONTAINMENT_INDEXES, - operator, - 'JSON selector (searchableJson)', - ) - requireNonNullOperand(ctx, value, operator) - - // A selector compares a scalar leaf — reject non-scalars / non-orderable - // types up front with a clear error, not a deferred DB failure. - const ordering = op !== 'eq' && op !== 'ne' - const leafReason = unsupportedLeafReason(value, ordering) - if (leafReason) { - throw new EncryptionOperatorError( - `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - // Surface path-validation failures as EncryptionOperatorError with context. - let segments: string[] - try { - segments = parseSelectorSegments(path) - } catch (err) { - throw new EncryptionOperatorError( - `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - const canonicalPath = jsonPathOf(segments) - - if (op === 'eq' || op === 'ne') { - const result = await applyOperationOptions( - client.encryptQuery( - { path: canonicalPath, value } as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecValueSelector', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - const contains = v3Dialect.containsJson( - colSql(col), - sql`${JSON.stringify(result.data)}::eql_v3.query_json`, - ) - return op === 'eq' - ? contains - : sql`(NOT ${contains} OR ${colSql(col)} IS NULL)` - } - - // Selector hashing and scalar-term encryption are independent, so order - // them concurrently. `steVecTerm` accepts only the JSON-orderable scalar - // families (string and number), enforced above. - const [selResult, termResult] = await Promise.all([ - applyOperationOptions( - client.encryptQuery( - canonicalPath as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecSelector', - } as never, - ), - opts, - ), - applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecTerm', - } as never, - ), - opts, - ), - ]) - if (selResult.failure) { - throw operandFailure(ctx, operator, selResult.failure.message) - } - if (termResult.failure) { - throw operandFailure(ctx, operator, termResult.failure.message) - } - - // A v3 selector term is the bare HMAC hash string; guard the shape so a - // wrapped envelope can't silently bind as a JSON blob and match no rows. - const selValue = selResult.data - if (typeof selValue !== 'string') { - throw operandFailure( - ctx, - operator, - `expected a bare selector hash, got ${typeof selValue}.`, - ) - } - - const selSql = sql`${selValue}::text` - const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) - const termCast = - typeof value === 'string' - ? 'eql_v3.query_text_ord' - : 'eql_v3.query_double_ord' - const rightTerm = sql`${JSON.stringify(termResult.data)}::${sql.raw(termCast)}` - return v3Dialect.comparison(op, leftEntry, rightTerm) - } - - /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar - * operators. Async: each encrypts its operand. */ - function selectorOps(col: SQLWrapper, path: string) { - const at = - (op: EqualityOp | ComparisonOp) => - (value: unknown, opts?: EncryptionOperatorCallOpts) => - selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) - return { - /** `col->'path' = value` (encrypted equality at the selector). A row whose - * document lacks `path` is excluded (it is not equal to `value`). */ - eq: at('eq'), - /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` - * ("not equal to value" covers "has no value"). */ - ne: at('ne'), - /** `col->'path' > value` (encrypted ordering at the selector). */ - gt: at('gt'), - /** `col->'path' >= value`. */ - gte: at('gte'), - /** `col->'path' < value`. */ - lt: at('lt'), - /** `col->'path' <= value`. */ - lte: at('lte'), - /** Order rows ascending by the encrypted scalar at `path`. Missing paths - * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ - asc: (opts?: EncryptionOperatorCallOpts) => - selectorOrder(col, path, 'asc', opts), - /** Order rows descending by the encrypted scalar at `path`. Missing paths - * produce SQL NULL and follow PostgreSQL's normal NULL ordering. */ - desc: (opts?: EncryptionOperatorCallOpts) => - selectorOrder(col, path, 'desc', opts), - } - } - - /** Build `ORDER BY eql_v3.ord_term(col -> selector::text)`. - * The selector is encrypted, but the extracted SteVec entry already carries - * its OPE ordering term, so no plaintext comparison operand is needed. */ - async function selectorOrder( - col: SQLWrapper, - path: string, - direction: 'asc' | 'desc', - opts?: EncryptionOperatorCallOpts, - ): Promise { - const operator = `selector(${path}).${direction}` - const ctx = resolveContext(col, operator) - requireIndex( - ctx, - JSON_CONTAINMENT_INDEXES, - operator, - 'JSON selector (searchableJson)', - ) - - let canonicalPath: string - try { - canonicalPath = jsonPathOf(parseSelectorSegments(path)) - } catch (err) { - throw new EncryptionOperatorError( - `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - - const result = await applyOperationOptions( - client.encryptQuery( - canonicalPath as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'steVecSelector', - } as never, - ), - opts, - ) - if (result.failure) { - throw operandFailure(ctx, operator, result.failure.message) - } - if (typeof result.data !== 'string') { - throw operandFailure( - ctx, - operator, - `expected a bare selector hash, got ${typeof result.data}.`, - ) - } - - const entry = v3Dialect.selectorEntry( - colSql(col), - sql`${result.data}::text`, - ) - const term = v3Dialect.orderBy(entry, 'ope') - return direction === 'asc' ? asc(term) : desc(term) - } - - async function inArrayOp( - left: SQLWrapper, - values: unknown[], - negate: boolean, - operator: string, - opts?: EncryptionOperatorCallOpts, - ): Promise { - const ctx = resolveContext(left, operator) - if (values.length === 0) { - throw new EncryptionOperatorError( - `Operator "${operator}" requires a non-empty list of values for column "${ctx.columnName}".`, - { columnName: ctx.columnName, tableName: ctx.tableName, operator }, - ) - } - // Gate and resolve the context once for the whole list, then encrypt it in - // a single `encryptQuery` batch crossing. - requireIndex(ctx, EQUALITY_INDEXES, operator, 'equality') - const op: EqualityOp = negate ? 'ne' : 'eq' - const encrypted = await encryptOperands( - ctx, - values, - operator, - 'equality', - opts, - ) - const conditions = encrypted.map((enc) => - v3Dialect.equality(op, colSql(left), enc), - ) - // The empty-list guard above leaves `conditions` non-empty, so `and`/`or` - // never return undefined here. - return (negate ? and(...conditions) : or(...conditions)) as SQL - } - - function orderTerm(column: SQLWrapper, operator: string): SQL { - const ctx = resolveContext(column, operator) - requireIndex(ctx, ORDERING_INDEXES, operator, 'order/range') - return v3Dialect.orderBy(colSql(column), ctx.indexes.ore ? 'ore' : 'ope') - } - - async function combine( - joiner: typeof and, - empty: SQL, - conditions: (SQL | SQLWrapper | Promise | undefined)[], - ): Promise { - const present = conditions.filter( - (c): c is SQL | SQLWrapper | Promise => c !== undefined, - ) - const resolved = await Promise.all(present) - return joiner(...resolved) ?? empty - } - - return { - /** Equality: `column = value`. Encrypts `r` and emits `eql_v3.eq`. - * Requires a `unique` or `ore` index on the column. */ - eq: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - equality('eq', l, r, opts), - /** Inequality: `column <> value`. Encrypts `r` and emits `eql_v3.neq`. - * Requires a `unique` or `ore` index on the column. */ - ne: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - equality('ne', l, r, opts), - /** Greater-than: `column > value`. Encrypts `r` and emits `eql_v3.gt`. - * Requires an `ore` (order/range) index. */ - gt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('gt', l, r, opts), - /** Greater-than-or-equal: `column >= value`. Encrypts `r` and emits - * `eql_v3.gte`. Requires an `ore` (order/range) index. */ - gte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('gte', l, r, opts), - /** Less-than: `column < value`. Encrypts `r` and emits `eql_v3.lt`. - * Requires an `ore` (order/range) index. */ - lt: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('lt', l, r, opts), - /** Less-than-or-equal: `column <= value`. Encrypts `r` and emits - * `eql_v3.lte`. Requires an `ore` (order/range) index. */ - lte: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - comparison('lte', l, r, opts), - /** Inclusive range `min <= column <= max`. Encrypts both bounds - * concurrently. Requires an `ore` (order/range) index. */ - between: ( - l: SQLWrapper, - min: unknown, - max: unknown, - opts?: EncryptionOperatorCallOpts, - ) => range(l, min, max, false, 'between', opts), - /** Negated inclusive range `NOT (min <= column <= max)`. Encrypts both - * bounds concurrently. Requires an `ore` (order/range) index. */ - notBetween: ( - l: SQLWrapper, - min: unknown, - max: unknown, - opts?: EncryptionOperatorCallOpts, - ) => range(l, min, max, true, 'notBetween', opts), - /** Fuzzy free-text token match — the needle's 3-gram set is (bloom-)tested - * as a subset of the column's. NOT containment: order/multiplicity- - * insensitive and one-sided (a `true` may be a false positive). Encrypts - * `r`. Requires a `match` (free-text search) index. */ - matches: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - matches(l, r, 'matches', opts), - /** Exact encrypted-JSONB containment (`@>`): matches rows whose document - * contains the given sub-object. No false positives. Encrypts `r`. Requires - * a `ste_vec` index (a `types.Json` column). */ - contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => - containsJsonOp(l, r, 'contains', opts), - /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. - * Returns comparison and ordering methods bound to `col->'path'` — e.g. - * `await ops.selector(users.doc, '$.age').gt(21)` emits - * `col->'' > `, while `.asc()` emits - * `ORDER BY eql_v3.ord_term(col->'')`. Its unique power over `contains` - * is ordering at a path (`gt`/`gte`/`lt`/`lte` and `asc`/`desc`); `eq`/`ne` - * are also provided. Dot-notation object paths only in v1. */ - selector: (l: SQLWrapper, path: string) => selectorOps(l, path), - /** Membership: ORs one encrypted `eq` term per value. The whole list is - * encrypted in one `encryptQuery` batch crossing. Rejects an empty list; - * requires a `unique` or `ore` index. */ - inArray: ( - l: SQLWrapper, - values: unknown[], - opts?: EncryptionOperatorCallOpts, - ) => inArrayOp(l, values, false, 'inArray', opts), - /** Non-membership: ANDs one encrypted `ne` term per value. The whole list - * is encrypted in one `encryptQuery` batch crossing. Rejects an empty list; - * requires a `unique` or `ore` index. */ - notInArray: ( - l: SQLWrapper, - values: unknown[], - opts?: EncryptionOperatorCallOpts, - ) => inArrayOp(l, values, true, 'notInArray', opts), - /** Ascending order by the encrypted order term (`eql_v3.ord_term` / - * `eql_v3.ord_term_ore`, by the column's ordering flavour). - * Synchronous (no operand to encrypt). Requires an ordering index. */ - asc: (c: SQLWrapper) => asc(orderTerm(c, 'asc')), - /** Descending order by the encrypted order term (`eql_v3.ord_term` / - * `eql_v3.ord_term_ore`, by the column's ordering flavour). - * Synchronous (no operand to encrypt). Requires an ordering index. */ - desc: (c: SQLWrapper) => desc(orderTerm(c, 'desc')), - /** Conjunction of the given conditions, awaiting any async operands and - * dropping `undefined`. Empty input resolves to `true`. */ - and: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => - combine(and, sql`true`, conds), - /** Disjunction of the given conditions, awaiting any async operands and - * dropping `undefined`. Empty input resolves to `false`. */ - or: (...conds: (SQL | SQLWrapper | Promise | undefined)[]) => - combine(or, sql`false`, conds), - /** Drizzle's `isNull`, re-exported unchanged — `column IS NULL` needs no - * encryption and works on any (nullable) encrypted column. */ - isNull, - /** Drizzle's `isNotNull`, re-exported unchanged — `column IS NOT NULL` - * needs no encryption. */ - isNotNull, - /** Drizzle's `not`, re-exported unchanged — negates an already-built - * (encrypted) predicate. Safe over any operator here, including `between`, - * whose fragment is self-parenthesising. */ - not, - /** Drizzle's `exists`, re-exported unchanged — for correlated subqueries. */ - exists, - /** Drizzle's `notExists`, re-exported unchanged — for correlated - * subqueries. */ - notExists, - } -} diff --git a/packages/stack-drizzle/src/v3/schema-extraction.ts b/packages/stack-drizzle/src/v3/schema-extraction.ts deleted file mode 100644 index 68f8796bb..000000000 --- a/packages/stack-drizzle/src/v3/schema-extraction.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - type AnyEncryptedV3Column, - type AnyV3Table, - encryptedTable, -} from '@cipherstash/stack/eql/v3' -import type { PgTable } from 'drizzle-orm/pg-core' -import { getEqlV3Column } from './column.js' - -/** Drizzle stashes the SQL table name on this well-known symbol key. */ -const DRIZZLE_NAME = Symbol.for('drizzle:Name') - -/** - * Read the SQL table name Drizzle stashes on a `pgTable`. Returns `undefined` - * for a non-object or a table not built with `pgTable()`. Shared by - * {@link extractEncryptionSchemaV3} and the operator factory so the - * symbol-key introspection lives in exactly one place. - */ -export function getDrizzleTableName(table: unknown): string | undefined { - if (!table || typeof table !== 'object') return undefined - const name = (table as Record)[DRIZZLE_NAME] - return typeof name === 'string' ? name : undefined -} - -export function extractEncryptionSchemaV3(table: PgTable): AnyV3Table { - const tableName = getDrizzleTableName(table) - if (!tableName) { - throw new Error( - 'Unable to read table name from Drizzle table. Use a table created with pgTable().', - ) - } - - const columns: Record = {} - for (const [property, column] of Object.entries(table)) { - if (typeof column !== 'object' || column === null) continue - const columnName = - 'name' in column && typeof column.name === 'string' - ? column.name - : property - const builder = getEqlV3Column(columnName, column) - if (builder) columns[property] = builder - } - - if (Object.keys(columns).length === 0) { - throw new Error( - `No encrypted v3 columns found in table "${tableName}". Declare columns with the v3 drizzle \`types\` namespace.`, - ) - } - - return encryptedTable(tableName, columns) -} diff --git a/packages/stack-drizzle/tsup.config.ts b/packages/stack-drizzle/tsup.config.ts index 19ce5967e..dcffd3486 100644 --- a/packages/stack-drizzle/tsup.config.ts +++ b/packages/stack-drizzle/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/v3/index.ts'], + entry: ['src/index.ts'], outDir: 'dist', format: ['cjs', 'esm'], sourcemap: true, diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 565f5d6e9..19ae76087 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -1,11 +1,11 @@ --- name: stash-drizzle -description: Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle/v3 (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the EncryptionV3 typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. +description: Integrate CipherStash encryption with Drizzle ORM using @cipherstash/stack-drizzle (EQL v3). Covers the types.* encrypted column factories (concrete Postgres domains), auto-encrypting query operators (eq, ne, gt/gte/lt/lte, between, inArray, matches, contains, JSON selector, asc/desc), schema extraction, the EncryptionV3 typed client, database setup with stash eql install, and migrating existing plaintext columns to encrypted. Use when adding encryption to a Drizzle ORM project, defining encrypted Drizzle schemas, or querying encrypted columns with Drizzle. --- # CipherStash Stack - Drizzle ORM Integration -Guide for integrating CipherStash field-level encryption with Drizzle ORM using `@cipherstash/stack-drizzle/v3` (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query. +Guide for integrating CipherStash field-level encryption with Drizzle ORM using `@cipherstash/stack-drizzle` (EQL v3). Provides Drizzle-native encrypted column factories and query operators that transparently encrypt search values — Drizzle never sees plaintext in a query. In EQL v3 every encrypted column is a **concrete Postgres domain** (`public.eql_v3_text_search`, `public.eql_v3_integer_ord`, ...) whose query capabilities are fixed by the type you pick — there is no capability config object. See the `stash-encryption` skill's "Schema Definition" section (the `types` catalog) for the full catalog and capability suffixes (`Eq`, `Ord`/`OrdOre`, `Match`, `Search`, `Json`). @@ -33,8 +33,8 @@ npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm The Drizzle integration ships as its own first-party package, `@cipherstash/stack-drizzle`, which depends on `@cipherstash/stack`. Install both. -The v3 surface documented here lives on the `@cipherstash/stack-drizzle/v3` subpath. -It is distinct from the older, separate `@cipherstash/drizzle` package (which is +The v3 surface documented here is exported from the `@cipherstash/stack-drizzle` +package root. It is distinct from the older, separate `@cipherstash/drizzle` package (which is `@cipherstash/protect`-based, with different symbol names) — that package is **deprecated and no longer published**; do not install it. This package replaces it. @@ -81,11 +81,11 @@ You don't usually hand-write this: the `types.*` factories below emit the domain ## Schema Definition -Use the `types` namespace from `@cipherstash/stack-drizzle/v3` to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type: +Use the `types` namespace from `@cipherstash/stack-drizzle` to define encrypted columns. Each factory maps 1:1 to a Postgres domain, and the column's query capabilities are fixed by the type: ```typescript import { pgTable, integer, timestamp, varchar } from "drizzle-orm/pg-core" -import { types } from "@cipherstash/stack-drizzle/v3" +import { types } from "@cipherstash/stack-drizzle" const usersTable = pgTable("users", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), @@ -115,18 +115,18 @@ Capability suffixes at a glance (full catalog: `stash-encryption` skill, "Schema Value families: `Integer`/`Smallint`/`Numeric`/`Real`/`Double` (`number`), `Bigint` (`bigint`), `Date`/`Timestamp` (`Date`), `Text` (`string`), `Boolean` (`boolean`, storage only), `Json` (a JSON document — object or array, not a top-level scalar). -`makeEqlV3Column(builder)` wraps a column builder from `@cipherstash/stack/eql/v3` (e.g. `makeEqlV3Column(v3types.TextEq("email"))`) — `types.TextEq("email")` from the Drizzle subpath is shorthand for the same thing. +`makeEqlV3Column(builder)` wraps a column builder from `@cipherstash/stack/eql/v3` (e.g. `makeEqlV3Column(v3types.TextEq("email"))`) — `types.TextEq("email")` from the Drizzle package is shorthand for the same thing. ## Initialization ### 1. Extract Schema from Drizzle Table ```typescript -import { extractEncryptionSchemaV3, createEncryptionOperatorsV3 } from "@cipherstash/stack-drizzle/v3" +import { extractEncryptionSchema, createEncryptionOperators } from "@cipherstash/stack-drizzle" import { EncryptionV3 } from "@cipherstash/stack/v3" // Convert the Drizzle table definition to a CipherStash v3 schema -const usersSchema = extractEncryptionSchemaV3(usersTable) +const usersSchema = extractEncryptionSchema(usersTable) ``` ### 2. Initialize the Encryption Client @@ -142,10 +142,10 @@ const encryptionClient = await EncryptionV3({ ### 3. Create Query Operators ```typescript -const ops = createEncryptionOperatorsV3(encryptionClient) +const ops = createEncryptionOperators(encryptionClient) ``` -`createEncryptionOperatorsV3(client, { lockContext, audit })` optionally sets defaults applied to every operand encryption; the async encrypting operators (`eq`, `ne`, `inArray`, `notInArray`, `gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `matches`, `contains`, and all methods returned by `selector(...)`) also take an optional trailing `{ lockContext, audit }` argument per call. Top-level `asc`/`desc` and the passthrough operators (`isNull`, `isNotNull`, `not`, `and`, `or`, `exists`, `notExists`) encrypt nothing and take no such argument. +`createEncryptionOperators(client, { lockContext, audit })` optionally sets defaults applied to every operand encryption; the async encrypting operators (`eq`, `ne`, `inArray`, `notInArray`, `gt`/`gte`/`lt`/`lte`, `between`/`notBetween`, `matches`, `contains`, and all methods returned by `selector(...)`) also take an optional trailing `{ lockContext, audit }` argument per call. Top-level `asc`/`desc` and the passthrough operators (`isNull`, `isNotNull`, `not`, `and`, `or`, `exists`, `notExists`) encrypt nothing and take no such argument. ### 4. Create Drizzle Instance @@ -404,7 +404,7 @@ Add an `email_encrypted` column **alongside** `email`. Crucially, the encrypted ```typescript // src/db/schema.ts -import { types } from '@cipherstash/stack-drizzle/v3' +import { types } from '@cipherstash/stack-drizzle' export const users = pgTable('users', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -418,10 +418,10 @@ Update the encryption client to harvest the encrypted columns from the table: ```typescript // src/encryption/index.ts import { EncryptionV3 } from '@cipherstash/stack/v3' -import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' +import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' import { users } from '../db/schema' -const usersEncryptionSchema = extractEncryptionSchemaV3(users) +const usersEncryptionSchema = extractEncryptionSchema(users) export const encryptionClient = await EncryptionV3({ schemas: [usersEncryptionSchema] }) ``` @@ -497,49 +497,14 @@ If something goes wrong (e.g. you discover the dual-write code wasn't actually l #### Switch reads to the encrypted column -**EQL v3 (the schema above): there is no cut-over.** The encrypted column keeps -its own name — you switch the application to it by name, verify reads, then drop -the plaintext column. Point your queries at `email_encrypted`, deploy, and -confirm reads decrypt correctly; then skip ahead to the drop step. Running -`stash encrypt cutover` on a **backfilled** v3 column reports "not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). +**EQL v3: there is no cut-over.** The encrypted column keeps its own name — you +switch the application to it by name, verify reads, then drop the plaintext +column. Running `stash encrypt cutover` on a **backfilled** v3 column reports +"not applicable" and exits 0 (it exits 1 if the backfill hasn't finished). -The rest of this subsection is the **EQL v2** path (a `eql_v2_encrypted` twin), -kept for existing v2 deployments. - -First, update the Drizzle schema to the post-cutover shape — switch `email` to the encrypted type and remove the `email_encrypted` column. - -> **Using CipherStash Proxy?** -> -> If using Proxy, re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): -> -> ```bash -> stash db push -> # → writes the new config as `pending`. Active config (still pointing at -> # `email_encrypted`) keeps serving while we complete the cutover. -> ``` - -Now run the cutover: - -```bash -stash encrypt cutover --table users --column email -``` - -Inside one transaction it: (1) renames `email` → `email_plaintext` and `email_encrypted` → `email`, (2) promotes the pending EQL config to `active` (and the prior active to `inactive`), (3) records a `cut_over` event in `cs_migrations`. - -The Drizzle schema you just edited now matches the physical DB shape — `email` is the encrypted column. Keep the temporary `email_plaintext: text('email_plaintext')` declaration in the schema file until the drop step: - -```typescript -// src/db/schema.ts (post-cutover) -export const users = pgTable('users', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), - email: types.TextSearch('email'), - email_plaintext: text('email_plaintext'), // temporary; dropped next -}) -``` - -App code that does `SELECT email FROM users` now returns ciphertext that must be decrypted via the encryption client. **This is the moment that breaks read paths if they aren't decrypting.** - -Update read paths to decrypt: +Point your read paths at `email_encrypted` and decrypt the selected envelopes +with the encryption client. **This is the moment that breaks read paths if they +aren't decrypting.** ```typescript // Before @@ -550,10 +515,10 @@ const email = rows[0].email const rows = await db.select().from(users).where(eq(users.id, id)) const decrypted = await encryptionClient.decryptModel(rows[0], usersEncryptionSchema) if (decrypted.failure) throw new Error(decrypted.failure.message) -const email = decrypted.data.email +const email = decrypted.data.email_encrypted ``` -For queries that filter on `email`, switch to the encrypted operators from `createEncryptionOperatorsV3` — `eq`, `matches`, `gte`, etc. (See `## Query Encrypted Data` above.) +For queries that filter on the column, switch to the encrypted operators from `createEncryptionOperators` — `eq`, `matches`, `gte`, etc. (See `## Query Encrypted Data` above.) Deploy, confirm reads decrypt correctly, then drop the plaintext column. #### Drop: remove the plaintext column @@ -563,10 +528,10 @@ Once read paths are updated and you're confident reads are decrypting correctly, stash encrypt drop --table users --column email ``` -The CLI emits a Drizzle migration file with the drop. **Which column it drops depends on the EQL version**, which the CLI auto-detects: - -- **v3** — drops the original plaintext column, `ALTER TABLE users DROP COLUMN email;`. There was no rename, so no `email_plaintext` exists. Requires the column to be in the `backfilled` phase, plus a live coverage check. -- **v2** — drops the post-rename leftover, `ALTER TABLE users DROP COLUMN email_plaintext;`. Requires the `cut-over` phase. +The CLI emits a Drizzle migration file with the drop. For a v3 column it drops +the original plaintext column, `ALTER TABLE users DROP COLUMN email;` — there was +no rename, so no `email_plaintext` exists. Requires the column to be in the +`backfilled` phase, plus a live coverage check. Review and apply with `drizzle-kit migrate`, then update the schema to its final shape — the encrypted column is the only one left: @@ -631,11 +596,11 @@ All comparison/containment operators auto-encrypt their operands and are async; ### Other v3 Exports -`types`, `makeEqlV3Column`, `getEqlV3Column`, `isEqlV3Column`, `extractEncryptionSchemaV3`, `createEncryptionOperatorsV3`, `EncryptionOperatorError`, and the codec helpers `v3ToDriver` / `v3FromDriver` / `EqlV3CodecError` — all from `@cipherstash/stack-drizzle/v3`. +`types`, `makeEqlV3Column`, `getEqlV3Column`, `isEqlV3Column`, `extractEncryptionSchema`, `createEncryptionOperators`, `EncryptionOperatorError`, and the codec helpers `v3ToDriver` / `v3FromDriver` / `EqlV3CodecError` — all from `@cipherstash/stack-drizzle`. ## Error Handling -Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-drizzle/v3`) whenever the query cannot be answered safely: +Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-drizzle`) whenever the query cannot be answered safely: - the column is not an encrypted v3 column (there is no plaintext fallback); - the column's domain lacks the operator's capability (e.g. ordering a `TextEq` column, `eq` on a `Json` column); @@ -644,7 +609,7 @@ Operators throw `EncryptionOperatorError` (exported from `@cipherstash/stack-dri - operand encryption itself fails. ```typescript -import { EncryptionOperatorError } from "@cipherstash/stack-drizzle/v3" +import { EncryptionOperatorError } from "@cipherstash/stack-drizzle" class EncryptionOperatorError extends Error { context?: { @@ -659,6 +624,4 @@ There is no `EncryptionConfigError` on the v3 path — capability problems surfa Encryption client operations (`encryptModel`, `bulkDecryptModels`, ...) don't throw — they return `Result` objects with `data` or `failure`. Check `.failure` before using `.data`. -## Legacy: EQL v2 - -The original v2 integration — `encryptedType` config-flag columns, `extractEncryptionSchema`, and `createEncryptionOperators` (with `like`/`ilike`) from the `@cipherstash/stack-drizzle` package root, over the `eql_v2_encrypted` column type installed via `stash eql install --drizzle` (the deprecated standalone `@cipherstash/drizzle` package shipped its own `generate-eql-migration` bin for the same purpose) — still exists for existing deployments and is documented at https://cipherstash.com/docs. New projects must use the `/v3` surface documented above — `stash init --drizzle` generates an EQL **v3** migration via `stash eql migration --drizzle`. Reaching the v2 install path now requires opting in explicitly with `stash eql install --drizzle --eql-version 2`. +> **EQL v2 removed.** `@cipherstash/stack-drizzle` no longer ships an EQL v2 authoring surface: the pre-v3 `encryptedType` config-flag columns and the `like`/`ilike` operators are gone, and the `./v3` subpath has collapsed into the package root (`@cipherstash/stack-drizzle`). All exports documented here are EQL v3. Existing v2 ciphertext is still decryptable via `@cipherstash/stack`; only the Drizzle-side authoring/query-building of new v2 columns is removed. `stash init --drizzle` generates an EQL v3 migration via `stash eql migration --drizzle`. From 30e7b5524da7ceea4c743ed2b476eed55f6999b4 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 12:10:46 +1000 Subject: [PATCH 15/25] fix(bench): infer typed v3 client through a helper after Encryption collapse PR 3 made EncryptionV3 an overloaded alias; ReturnType now resolves to the nominal overload (EncryptionClient) not the typed client. Infer through a single-signature helper so the bench handle keeps the typed client type. --- packages/bench/src/drizzle/setup.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/bench/src/drizzle/setup.ts b/packages/bench/src/drizzle/setup.ts index 267ffc951..16ba56d2e 100644 --- a/packages/bench/src/drizzle/setup.ts +++ b/packages/bench/src/drizzle/setup.ts @@ -34,8 +34,20 @@ export type BenchPlaintextRow = { enc_jsonb: { idx: number; group: number } } +/** + * Build the typed EQL v3 client this bench drives. Wrapped in a + * single-signature helper because `EncryptionV3` is now overloaded (typed v3 + * vs. nominal) — `ReturnType` resolves to the *last* + * (nominal) overload, so we infer the return type through this helper instead. + */ +function makeEncryptionClient() { + return EncryptionV3({ schemas: [encryptionBenchTable] }) +} + /** The typed EQL v3 client this bench drives. */ -export type BenchEncryptionClient = Awaited> +export type BenchEncryptionClient = Awaited< + ReturnType +> export type BenchHandle = { pgClient: pg.Client @@ -57,9 +69,7 @@ export async function buildBench(): Promise { const db = drizzle(pool) - const encryptionClient = await EncryptionV3({ - schemas: [encryptionBenchTable], - }) + const encryptionClient = await makeEncryptionClient() return { pgClient, pool, db, encryptionClient } } From 3d69a59be5617dd1795a703b7702cadf8001dad5 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 12:39:38 +1000 Subject: [PATCH 16/25] fix(stack-supabase)!: type single()/maybeSingle() as one row, not an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review of the query-builder split. Six findings, all pre-dating the refactor; these are the two worth acting on. `single()`/`maybeSingle()` have always returned ONE object at runtime, but returned `Self`, so the builder kept advertising the array shape it was created with — `data` was typed `T[] | null` while holding a single row. Callers had to launder it, and the test suite documented the lie in a comment while casting through `unknown` to reach the row's fields. Both now return `EncryptedSingleQueryBuilder`, awaiting `EncryptedSupabaseResponse` (`data: T | null`) — which already covers the zero-row case for `maybeSingle()` and the error case for both, so no separate null modelling was needed. The impl class carries the awaited shape as a `TData` parameter so the promise cannot keep advertising `T[]` after the runtime has been switched to single-row mode; `returns()` preserves that shape. Filters and transforms are deliberately absent from the single-row builder, matching supabase-js: applying one after `single()` would change the query the single-row promise was made about. Also drops two unnecessary `as unknown as T[]` bridges in query-results.ts (`[] as T[]` and the bulk-decrypt map both compile directly). Not acted on, with reasons: - The missing `assertPostgrestCanQueryEncryptedOperator` in the not-filter branch is a false positive. The guard fires upstream in `assertTermQueryable` (`contains`/`matches` map to `freeTextSearch`), before encryption, and `supabase-v3-json.test.ts` already covers `.not(col,'contains')` and `.not(col,'matches')` under EQL 3.0.2. The suggested guard keys on `wasEncrypted`, which that path never reaches. - Routing plaintext `in` arrays through `formatInListOperand` would change behaviour: `.filter()` is the raw escape hatch and forwards verbatim, as supabase-js does. The encrypted path only intervenes because it must encrypt element-wise. - The `as never` on `bulkEncrypt` args and the `term.column` double assertion need `ScalarQueryTerm['column']` widened in `@cipherstash/stack` — a different package's public type. --- .changeset/supabase-single-row-typing.md | 32 +++++++++++++++++ .../__tests__/supabase-v3-builder.test.ts | 9 +++-- packages/stack-supabase/src/query-builder.ts | 36 +++++++++++++------ packages/stack-supabase/src/query-results.ts | 17 +++++---- packages/stack-supabase/src/types.ts | 32 +++++++++++++++-- 5 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 .changeset/supabase-single-row-typing.md diff --git a/.changeset/supabase-single-row-typing.md b/.changeset/supabase-single-row-typing.md new file mode 100644 index 000000000..eff4d8e06 --- /dev/null +++ b/.changeset/supabase-single-row-typing.md @@ -0,0 +1,32 @@ +--- +'@cipherstash/stack-supabase': major +--- + +`single()` and `maybeSingle()` now type `data` as the ROW, not an array. + +Both have always returned one object at runtime, but the builder kept +advertising the array shape it was created with, so `data` was typed `T[] | null` +while holding a single row. Every caller had to launder it: + +```typescript +const { data } = await supabase.from('users').select('id, email').single() +// before: data is `User[] | null` — wrong; a cast was the only way through +const user = data as unknown as User +// after: data is `User | null` +data?.email +``` + +`single()`/`maybeSingle()` now return `EncryptedSingleQueryBuilder`, which +awaits to `EncryptedSupabaseResponse` (`data: T | null`). That covers the +zero-row case for `maybeSingle()` and the error case for both, so no separate +null modelling was needed. + +Filters and transforms are not chainable after `single()`/`maybeSingle()`, +matching supabase-js — applying one afterwards would change the query the +single-row promise was made about. `returns()` preserves the awaited shape, +so `.single().returns()` still awaits one row. + +**Migration:** delete the cast. Code that worked around the old typing with +`data as unknown as Row` (or read `data![0]`) should now use `data` directly; +the cast still compiles but is no longer needed, and `data![0]` becomes a type +error. diff --git a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts index 60d26c8f8..ce9835a2f 100644 --- a/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts +++ b/packages/stack-supabase/__tests__/supabase-v3-builder.test.ts @@ -949,11 +949,10 @@ describe('encryptedSupabaseV3 wire encoding', () => { expect(error).toBeNull() expect(supabase.callsFor('single')).toHaveLength(1) expect(Array.isArray(data)).toBe(false) - // `data` is declared `T[] | null` on the shared builder surface; single() - // narrows it to one row at runtime only. - const single = data as unknown as { email: string; createdAt: Date } - expect(single.email).toBe('a@b.com') - expect(single.createdAt).toBeInstanceOf(Date) + // `single()` narrows the awaited shape to ONE row at the type level too, + // so `data` is the row itself — no cast needed to reach its fields. + expect(data?.email).toBe('a@b.com') + expect(data?.createdAt).toBeInstanceOf(Date) }) it('maybeSingle() returns null for null result data without throwing', async () => { diff --git a/packages/stack-supabase/src/query-builder.ts b/packages/stack-supabase/src/query-builder.ts index 2b299965e..b2c9652ef 100644 --- a/packages/stack-supabase/src/query-builder.ts +++ b/packages/stack-supabase/src/query-builder.ts @@ -88,6 +88,11 @@ const warnedLikeDelegation = new Set() */ export class EncryptedQueryBuilderImpl< T extends Record = Record, + /** The shape this builder awaits to. `T[]` normally; narrowed to `T` by + * {@link single}/{@link maybeSingle}, which return ONE row. Carried as a + * parameter so the promise cannot keep advertising `T[]` after the runtime + * has been switched to single-row mode. */ + TData = T[], > { private tableName: string private table: AnyV3Table @@ -465,16 +470,19 @@ export class EncryptedQueryBuilderImpl< return this } - single(): this { + single(): EncryptedQueryBuilderImpl { this.resultMode = 'single' this.transforms.push({ kind: 'single' }) - return this + // Type-level narrowing only; builder state is preserved. `TData` appears in + // `then`/`execute` return positions, so the two instantiations are not + // mutually assignable and `this` cannot be re-typed without an assertion. + return this as unknown as EncryptedQueryBuilderImpl } - maybeSingle(): this { + maybeSingle(): EncryptedQueryBuilderImpl { this.resultMode = 'maybeSingle' this.transforms.push({ kind: 'maybeSingle' }) - return this + return this as unknown as EncryptedQueryBuilderImpl } csv(): this { @@ -493,9 +501,17 @@ export class EncryptedQueryBuilderImpl< return this } - returns>(): EncryptedQueryBuilderImpl { + /** Re-type the ROW. The awaited SHAPE is preserved: called after + * `single()`/`maybeSingle()` this still awaits one row, not `U[]`. */ + returns>(): EncryptedQueryBuilderImpl< + U, + TData extends readonly unknown[] ? U[] : U + > { // Type-level cast only; builder state is preserved - return this as unknown as EncryptedQueryBuilderImpl + return this as unknown as EncryptedQueryBuilderImpl< + U, + TData extends readonly unknown[] ? U[] : U + > } // --------------------------------------------------------------------------- @@ -516,10 +532,10 @@ export class EncryptedQueryBuilderImpl< // PromiseLike implementation (deferred execution) // --------------------------------------------------------------------------- - then, TResult2 = never>( + then, TResult2 = never>( onfulfilled?: | (( - value: EncryptedSupabaseResponse, + value: EncryptedSupabaseResponse, ) => TResult1 | PromiseLike) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, @@ -531,7 +547,7 @@ export class EncryptedQueryBuilderImpl< // Core execution // --------------------------------------------------------------------------- - private async execute(): Promise> { + private async execute(): Promise> { try { logger.debug(`Supabase encrypted query on table "${this.tableName}".`) @@ -558,7 +574,7 @@ export class EncryptedQueryBuilderImpl< ) // 6. Decrypt results - return await decryptResults(result, { + return await decryptResults(result, { ...ctx, selectColumns: this.selectColumns, resultMode: this.resultMode, diff --git a/packages/stack-supabase/src/query-results.ts b/packages/stack-supabase/src/query-results.ts index 8c1812bd8..5838e012d 100644 --- a/packages/stack-supabase/src/query-results.ts +++ b/packages/stack-supabase/src/query-results.ts @@ -80,10 +80,13 @@ function postprocessDecryptedRow( * than decrypted. To read v2 data, decrypt fetched rows with the core * `@cipherstash/stack` client, whose decrypt path is generation-agnostic. */ -export async function decryptResults>( +export async function decryptResults< + T extends Record, + TData = T[], +>( result: RawSupabaseResult, ctx: DecryptContext, -): Promise> { +): Promise> { // If there's an error from Supabase, pass it through if (result.error) { return { @@ -118,7 +121,7 @@ export async function decryptResults>( if (!hasSelect && !hasMutationWithReturning) { // No select means no data to decrypt (e.g., insert without .select()) return { - data: result.data as T[], + data: result.data as TData, error: null, count: result.count ?? null, status: result.status, @@ -155,10 +158,12 @@ export async function decryptResults>( } return { + // ONE row — `TData` is `T` on this path (`single`/`maybeSingle` narrow it), + // so no array wrapping and no cast pretending otherwise. data: postprocessDecryptedRow( decrypted.data as Record, ctx, - ) as unknown as T[], + ) as TData, error: null, count: result.count ?? null, status: result.status, @@ -170,7 +175,7 @@ export async function decryptResults>( const dataArray = result.data as Record[] if (dataArray.length === 0) { return { - data: [] as unknown as T[], + data: [] as TData, error: null, count: result.count ?? null, status: result.status, @@ -196,7 +201,7 @@ export async function decryptResults>( return { data: decrypted.data.map((row) => postprocessDecryptedRow(row as Record, ctx), - ) as unknown as T[], + ) as TData, error: null, count: result.count ?? null, status: result.status, diff --git a/packages/stack-supabase/src/types.ts b/packages/stack-supabase/src/types.ts index c74fc9e6b..dd4611273 100644 --- a/packages/stack-supabase/src/types.ts +++ b/packages/stack-supabase/src/types.ts @@ -461,6 +461,21 @@ export interface TypedEncryptedSupabaseInstance { // Response // --------------------------------------------------------------------------- +/** + * The builder returned by `single()`/`maybeSingle()`: awaits to a SINGLE row + * (`data: T | null`) instead of an array. + * + * Only the two post-hoc modifiers supabase-js also allows after `.single()` are + * carried over. Filters and transforms are deliberately absent — applying one + * after `single()` would change the query the single-row promise was made + * about. + */ +export interface EncryptedSingleQueryBuilder + extends PromiseLike> { + abortSignal(signal: AbortSignal): EncryptedSingleQueryBuilder + throwOnError(): EncryptedSingleQueryBuilder +} + export type EncryptedSupabaseResponse = { data: T | null error: EncryptedSupabaseError | null @@ -909,8 +924,21 @@ export interface EncryptedQueryBuilderCore< to: number, options?: { referencedTable?: string; foreignTable?: string }, ): Self - single(): Self - maybeSingle(): Self + /** + * Return ONE row rather than an array — so the awaited `data` is `T | null`, + * not `T[]`. Returns {@link EncryptedSingleQueryBuilder} rather than `Self` + * because that change of shape is the whole point of the call: typing it + * `Self` would keep promising `T[]` while the runtime hands back one object, + * forcing every caller through a cast (`data as unknown as Row`). + * + * Filters and transforms are not chainable afterwards, matching supabase-js — + * `single()` is applied last. + */ + single(): EncryptedSingleQueryBuilder + /** As {@link single}, but a zero-row result is `data: null` rather than an + * error. Same `T | null` awaited shape — `single()` reports the missing row + * through `error` instead. */ + maybeSingle(): EncryptedSingleQueryBuilder csv(): Self abortSignal(signal: AbortSignal): Self throwOnError(): Self From 8b1a459811588da1b021b9949886d48076316151 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 14:10:58 +1000 Subject: [PATCH 17/25] ci(bench): install EQL v3 through the real CLI instead of relying on the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bench fixture schema moved to concrete EQL v3 domains (`public.eql_v3_text_search`, `eql_v3.eq_term(...)`), but tests-bench.yml started `postgres-eql:17-2.3.1` — which ships EQL v2 — and ran the suite immediately. `applySchema` failed with `type "public.eql_v3_text_search" does not exist`. Nothing tied the image tag to the EQL version the fixtures need, so the coupling broke silently. Install v3 the way every integration suite already does: a vitest `globalSetup` calling test-kit's `installEqlV3`, which shells out to the real `stash eql install --eql-version 3`. That needs only a database URL — no CipherStash credentials — so `db-only.test.ts` stays the credential-free smoke test it is meant to be, and CI and `pnpm test:local` become the same path rather than CI depending on a step the local flow never ran. The globalSetup imports `@cipherstash/test-kit/install` (a new narrow subpath export) rather than the barrel: the barrel reaches `needle-for.ts`, which consumes stack source through the `@/` alias, and bench has no `stackSourceAlias` in its vitest config. `install.ts` depends on node builtins only. Workflow changes: - Use the `integration-setup` composite, which already builds the `stash` CLI the install needs (pinned to this job's existing Node 22). - Start only the `postgres` service; docker-compose.yml also defines a PostgREST that bench never talks to. - Widen the trigger paths to follow the package graph. bench depends on `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and now on the CLI's EQL installer, but the filter covered only `packages/bench/**` and `local/**` — a break upstream could never trigger this job. No changeset: `@cipherstash/bench` and `@cipherstash/test-kit` are both private, and workflow files are repo tooling. --- .github/workflows/tests-bench.yml | 53 +++++++++++++--------- packages/bench/package.json | 1 + packages/bench/src/harness/global-setup.ts | 31 +++++++++++++ packages/bench/vitest.config.ts | 8 ++++ packages/test-kit/package.json | 1 + pnpm-lock.yaml | 3 ++ 6 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 packages/bench/src/harness/global-setup.ts diff --git a/.github/workflows/tests-bench.yml b/.github/workflows/tests-bench.yml index eb3691d1d..5f1d8c5ad 100644 --- a/.github/workflows/tests-bench.yml +++ b/.github/workflows/tests-bench.yml @@ -1,20 +1,37 @@ name: "Test Benchmark JS" +# Trigger paths follow the package GRAPH, not the bench directory. bench depends +# on `@cipherstash/stack` and `@cipherstash/stack-drizzle`, and (since the fixture +# schema moved to EQL v3 domains) on the CLI's EQL installer — a break in any of +# them can red this job, so an edit to any of them has to be able to trigger it. +# Scoped the same way the `integration-*` workflows scope theirs rather than +# taking whole packages, to keep the trigger surface honest. on: push: branches: - 'main' paths: - 'packages/bench/**' + - 'packages/stack/src/eql/v3/**' + - 'packages/stack-drizzle/**' + - 'packages/test-kit/**' + - 'packages/cli/src/installer/**' - 'local/**' - '.github/workflows/tests-bench.yml' + - '.github/actions/integration-setup/**' pull_request: branches: - "**" + # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. paths: - 'packages/bench/**' + - 'packages/stack/src/eql/v3/**' + - 'packages/stack-drizzle/**' + - 'packages/test-kit/**' + - 'packages/cli/src/installer/**' - 'local/**' - '.github/workflows/tests-bench.yml' + - '.github/actions/integration-setup/**' jobs: tests-bench: @@ -25,25 +42,14 @@ jobs: - name: Checkout Repo uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6.0.9 - name: Install pnpm - with: - run_install: false - - - name: Install Node.js - uses: actions/setup-node@v6.4.0 + # The shared integration preamble, which also builds the `stash` CLI — not + # incidental here: the bench `globalSetup` installs EQL v3 by shelling out + # to the real `stash eql install --eql-version 3`. + - uses: ./.github/actions/integration-setup with: + # This job's existing Node; the composite defaults to 24 for the + # integration suites. node-version: 22 - cache: 'pnpm' - - # node-pty's install hook falls back to `node-gyp rebuild` when no - # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships - # node-gyp on PATH, so install it explicitly. - - name: Install node-gyp - run: npm install -g node-gyp - - - name: Install dependencies - run: pnpm recursive install --frozen-lockfile # `@cipherstash/stack` ships dist/-based `exports`; bench imports # from `@cipherstash/stack` and `@cipherstash/stack-drizzle` (the Drizzle @@ -54,11 +60,16 @@ jobs: - name: Build stack + adapter packages run: pnpm exec turbo run build --filter @cipherstash/stack --filter @cipherstash/stack-drizzle - # Starts the pinned postgres-eql container (PostgreSQL 17 + EQL - # pre-installed) via local/docker-compose.yml; waits for healthcheck. - - name: Start local Postgres (EQL) + # Service-scoped `postgres`: local/docker-compose.yml also defines a + # PostgREST that bench never talks to. + # + # This container has EQL v2 baked in and NOTHING to do with the v3 domains + # the fixture schema needs — those come from the `globalSetup` install + # below. Do not add an EQL step here; keeping the install inside vitest is + # what makes `pnpm test:local` and CI the same path. + - name: Start local Postgres working-directory: local - run: docker compose up --wait --wait-timeout 60 + run: docker compose up --wait --wait-timeout 60 postgres # `pnpm test:local` resolves to `vitest run`; the trailing `db-only` # is a vitest path filter that narrows to __tests__/db-only.test.ts diff --git a/packages/bench/package.json b/packages/bench/package.json index 8c95c944a..4134c57ce 100644 --- a/packages/bench/package.json +++ b/packages/bench/package.json @@ -18,6 +18,7 @@ "pg": "^8.22.0" }, "devDependencies": { + "@cipherstash/test-kit": "workspace:*", "@types/node": "^22.20.1", "@types/pg": "^8.20.0", "tsx": "catalog:repo", diff --git a/packages/bench/src/harness/global-setup.ts b/packages/bench/src/harness/global-setup.ts new file mode 100644 index 000000000..ffb318a6e --- /dev/null +++ b/packages/bench/src/harness/global-setup.ts @@ -0,0 +1,31 @@ +// The `/install` subpath, NOT the barrel: the barrel reaches `needle-for.ts`, +// which imports stack source through the `@/` alias, and bench has no +// `stackSourceAlias` in its vitest config (it consumes stack through its +// published dist/ exports). `install.ts` depends on node builtins only. +import { installEqlV3 } from '@cipherstash/test-kit/install' +import { getDatabaseUrl } from './db.js' + +/** + * Install EQL v3 once per run, before any suite applies `sql/schema.sql`. + * + * The fixture schema declares its columns as the concrete `eql_v3_*` domains, so + * the bundle has to be in the database before the first `CREATE TABLE`. Doing it + * here rather than in a CI-only step means the local `pnpm test:local` path and + * the CI path are the same path — the previous arrangement had the workflow rely + * on an EQL-v2-preinstalled image while the schema had already moved to v3, and + * nothing connected the two. + * + * Runs through the real `stash eql install`, matching the integration suites' + * harness (`@cipherstash/test-kit`'s `integration/global-setup.ts`): an installer + * regression fails here instead of hiding behind a test-only SQL apply. + * + * Unlike those suites this needs NO CipherStash credentials — `installEqlV3` + * wants only a database URL — which keeps `db-only.test.ts` the credential-free + * smoke test it is meant to be. + */ +export async function setup(): Promise { + // EXPLICIT, never inferred from the environment. `dbVariant()` would guess + // from `PGRST_URL`, and bench's compose stack happens to define a PostgREST + // it never talks to — a guess here would silently apply the Supabase grants. + await installEqlV3(getDatabaseUrl(), 'postgres') +} diff --git a/packages/bench/vitest.config.ts b/packages/bench/vitest.config.ts index c0f631c04..ac80b923e 100644 --- a/packages/bench/vitest.config.ts +++ b/packages/bench/vitest.config.ts @@ -3,6 +3,14 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['__tests__/**/*.test.ts'], + // Installs EQL v3 before any suite applies `sql/schema.sql`, which declares + // its columns as `eql_v3_*` domains. See the file for why this is not a + // CI-only step. + globalSetup: ['./src/harness/global-setup.ts'], + // `@cipherstash/test-kit` is consumed as unbuilt TypeScript source, so it + // must not be externalized — same reason the integration suites carry this + // (`packages/test-kit/src/integration/config.ts`). + server: { deps: { inline: [/packages\/test-kit/] } }, testTimeout: 300_000, hookTimeout: 300_000, pool: 'forks', diff --git a/packages/test-kit/package.json b/packages/test-kit/package.json index 656ffc96c..8c4738389 100644 --- a/packages/test-kit/package.json +++ b/packages/test-kit/package.json @@ -8,6 +8,7 @@ "./integration-clerk": "./src/integration/clerk.ts", ".": "./src/index.ts", "./catalog": "./src/catalog.ts", + "./install": "./src/install.ts", "./json-suite": "./src/run-json-suite.ts", "./suite": "./src/run-family-suite.ts" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33cdbce42..31f1f10fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: specifier: ^8.22.0 version: 8.22.0 devDependencies: + '@cipherstash/test-kit': + specifier: workspace:* + version: link:../test-kit '@types/node': specifier: ^22.20.1 version: 22.20.1 From 9fc0f39489125b4fcca10cc75936d1d737ee7bb3 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 14:59:31 +1000 Subject: [PATCH 18/25] docs(repo): repoint dead package paths + guard against recurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #760 review feedback. - `docs/query-api-walkthrough.md` pointed at `packages/protect/src/ffi/*`, deleted by this PR. Replaced with a note rooting the doc's relative paths, and corrected the two other stale facts in the same block: the protect-ffi pin (0.24.0/0.23.0 → the actual 0.30.0) and row 1a's query-builder paths, which moved to stack-drizzle/stack-supabase in the #627 split. - `.github/dependabot.yml`'s ignore-rule comment narrated the #673 incident in the present tense against a package that no longer exists. Kept the lesson, marked the package as removed, and stated why the rule still holds for the surviving consumers. - `.github/workflows/tests.yml` looped over `packages/stack-forge`, which has never existed. The `[ -f ]` guard made it a no-op; dropped it. `scripts/lint-no-dead-package-paths.mjs` fails CI on any `packages/` reference that doesn't resolve to a directory, across docs, .github, skills and the root meta files. Design archives (docs/plans, docs/superpowers) and CHANGELOGs are exempt — they record history, not the current tree. It catches all three of the above; self-tests follow the existing scripts/__tests__ pattern. --- .github/dependabot.yml | 8 +- .github/workflows/tests.yml | 7 +- docs/query-api-walkthrough.md | 6 +- package.json | 1 + .../lint-no-dead-package-paths/dead-ref.md | 4 + .../lint-no-dead-package-paths/dead-ref.yml | 3 + .../dir-with-changelog/CHANGELOG.md | 3 + .../lint-no-dead-package-paths/dir/nested.md | 1 + .../lint-no-dead-package-paths/globs.md | 2 + .../lint-no-dead-package-paths/live-refs.md | 5 + .../many-dead-refs.md | 4 + .../lint-no-dead-package-paths/prefix.md | 3 + .../lint-no-dead-package-paths.test.mjs | 88 ++++++++++++++ scripts/lint-no-dead-package-paths.mjs | 107 ++++++++++++++++++ 14 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md create mode 100644 scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md create mode 100644 scripts/__tests__/lint-no-dead-package-paths.test.mjs create mode 100644 scripts/lint-no-dead-package-paths.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5e475458e..3bcb587ca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -48,9 +48,11 @@ updates: - dependency-name: "@cipherstash/auth-*" # Release-managed manually alongside stack releases. Grouped bumps are # actively harmful here: Dependabot's "group consistency" once upgraded - # the sunsetting packages/protect off its 0.23.0 pin (0.24+ renames the - # exports it imports) while DOWNGRADING the stack packages from 0.29.0 - # (see #673). + # the (since-removed) `protect` package off its 0.23.0 pin — 0.24+ renamed + # the exports it imported — while DOWNGRADING the stack packages from + # 0.29.0 (see #673). The surviving consumers (stack, stack-drizzle, + # stack-supabase) pin one version between them and must stay in lockstep, + # so the rule stands. - dependency-name: "@cipherstash/protect-ffi" # 0.x bumps ship breaking type changes (e.g. 0.2 → 0.3 tightened the # FailureOption constraint). Review and apply manually. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ddc8f406..aff5d1da9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -139,6 +139,11 @@ jobs: - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners + # Deleting or renaming a package leaves `packages/` references + # dangling in docs and CI config. Nothing else catches it (#760 review). + - name: Lint — no references to deleted package directories + run: pnpm run lint:package-paths + - name: Test — lint script self-tests run: pnpm run test:scripts @@ -351,7 +356,7 @@ jobs: - name: Run tests with Bun run: | - for dir in packages/stack packages/stack-forge; do + for dir in packages/stack; do if [ -f "$dir/vitest.config.ts" ] || [ -f "$dir/package.json" ]; then echo "--- Testing $dir ---" (cd "$dir" && bunx --bun vitest run) || true diff --git a/docs/query-api-walkthrough.md b/docs/query-api-walkthrough.md index 8559a2ed7..ac44297f9 100644 --- a/docs/query-api-walkthrough.md +++ b/docs/query-api-walkthrough.md @@ -37,7 +37,7 @@ flowchart TD | # | Layer | Entry point | Role | |---|-------|-------------|------| | 1 | Public API | `encryption/index.ts:259/270` `encryptQuery()` | Overloaded: single value → `EncryptQueryOperation`; `ScalarQueryTerm[]` → `BatchEncryptQueryOperation`. | -| 1a | Query builders | `drizzle/operators.ts:976`, `supabase/query-builder.ts:44` | `eq/gt/...` operators & deferred builders that batch-encrypt RHS values, then emit a WHERE clause. | +| 1a | Query builders | `stack-drizzle/src/operators.ts`, `stack-supabase/src/query-builder.ts` | `eq/gt/...` operators & deferred builders that batch-encrypt RHS values, then emit a WHERE clause. | | 2 | Operations | `operations/encrypt-query.ts:41`, `operations/batch-encrypt-query.ts:115` | `execute()`: validate → resolve index → call FFI. `*WithLockContext` resolves `LockContextInput` via `resolveLockContext` before the FFI call. | | 3 | EQL resolution | `helpers/infer-index-type.ts:89`, `types.ts:292` | `resolveIndexType` + `queryTypeToFfi`/`queryTypeToQueryOp` map public `QueryTypeName` → FFI `indexType`/`queryOp`. | | 4 | FFI JS wrapper | `protect-ffi/lib/index.cjs:155` | `encryptQuery`/`encryptQueryBulk` → `wrapAsync(native.*)`. | @@ -73,5 +73,5 @@ flowchart LR - **Client init:** `EncryptionClient.init()` (`encryption/index.ts:81`) calls FFI `newClient()` once; the returned `Client` handle is passed into every `encryptQuery` call. - **`cipherstashclient`** = the CipherStash Client **Rust SDK**, compiled via Neon into the platform `.node` binary inside `@cipherstash/protect-ffi`. It performs the actual crypto and talks to ZeroKMS. - **Result shape:** `EncryptedQueryResult` (`types.ts:175`); shaped by `formatEncryptedResult(..., returnType)` (`eql` vs raw). -- **Version:** `package.json` pins `@cipherstash/protect-ffi@0.24.0` (installed tree observed at `0.23.0` — confirm before relying on it). -- `packages/protect/src/ffi/*` mirrors this flow under the older `protect` package name. +- **Version:** `packages/stack/package.json` pins `@cipherstash/protect-ffi@0.30.0` (`stack-drizzle` and `stack-supabase` pin the same version — they must move in lockstep). +- **Paths:** rows 1–3 and the notes above are relative to `packages/stack/src/`; row 1a is rooted at `packages/`; rows 4–5 are inside the installed `@cipherstash/protect-ffi`. Line numbers drift — search the symbol, don't trust the offset. diff --git a/package.json b/package.json index c0e4cd9b2..13cfd258b 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "code:check": "biome check", + "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", "lint:workflow-cache": "node scripts/lint-no-workflow-caching.mjs", "release": "pnpm run build && changeset publish", diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md new file mode 100644 index 000000000..4863381c2 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.md @@ -0,0 +1,4 @@ +# Dead reference + +- `packages/protect/src/ffi/*` mirrors this flow under the older name. +- `packages/stack` is fine. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml new file mode 100644 index 000000000..4975c8373 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dead-ref.yml @@ -0,0 +1,3 @@ +ignore: + # Dependabot once bumped packages/protect off its pin. + - dependency-name: "@cipherstash/protect-ffi" diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md new file mode 100644 index 000000000..66eedb203 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir-with-changelog/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Removed `packages/protect` and `packages/schema`. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md new file mode 100644 index 000000000..e84569e11 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/dir/nested.md @@ -0,0 +1 @@ +See `packages/protect/src/index.ts`. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md new file mode 100644 index 000000000..809fcbf07 --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/globs.md @@ -0,0 +1,2 @@ +Build everything with `turbo build --filter './packages/*'`. +The workspace glob is `packages/*`. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md new file mode 100644 index 000000000..f6ea4400f --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/live-refs.md @@ -0,0 +1,5 @@ +# Live references + +- `packages/stack/src/encryption/index.ts` is the client. +- `packages/cli` owns the `stash` binary; see `./packages/cli/AGENTS.md`. +- `packages/stack-drizzle` and `packages/stack-supabase` are the split adapters. diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md new file mode 100644 index 000000000..f3c075aad --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/many-dead-refs.md @@ -0,0 +1,4 @@ +- packages/protect +- packages/schema +- packages/protect-dynamodb +- packages/stack diff --git a/scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md b/scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md new file mode 100644 index 000000000..6c9aa347d --- /dev/null +++ b/scripts/__tests__/fixtures/lint-no-dead-package-paths/prefix.md @@ -0,0 +1,3 @@ +- `packages/stack-drizzle` exists. +- `packages/stack-forge` does not. +- `packages/stack` exists. diff --git a/scripts/__tests__/lint-no-dead-package-paths.test.mjs b/scripts/__tests__/lint-no-dead-package-paths.test.mjs new file mode 100644 index 000000000..bc9ea927f --- /dev/null +++ b/scripts/__tests__/lint-no-dead-package-paths.test.mjs @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-no-dead-package-paths.mjs', +) + +function run(...targets) { + try { + execFileSync('node', [SCRIPT, ...targets], { encoding: 'utf8' }) + return { exitCode: 0, output: '' } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +const fx = (name) => + resolve( + fileURLToPath(import.meta.url), + `../fixtures/lint-no-dead-package-paths/${name}`, + ) + +describe('lint-no-dead-package-paths', () => { + it('passes on the repo as it stands', () => { + const r = run() + expect(r.output).toBe('') + expect(r.exitCode).toBe(0) + }) + + it('passes when every `packages/` reference resolves', () => { + expect(run(fx('live-refs.md')).exitCode).toBe(0) + }) + + it('fails on a reference to a deleted package', () => { + const r = run(fx('dead-ref.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/protect/) + }) + + it('names the file and line of each offender', () => { + const r = run(fx('dead-ref.md')) + expect(r.output).toMatch(/dead-ref\.md:3/) + }) + + it('reports every offender, not just the first', () => { + const r = run(fx('many-dead-refs.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/protect\b/) + expect(r.output).toMatch(/packages\/schema\b/) + expect(r.output).toMatch(/packages\/protect-dynamodb\b/) + }) + + it('flags dead paths in YAML comments too', () => { + const r = run(fx('dead-ref.yml')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/protect/) + }) + + it('matches the longest package name, not a shorter prefix', () => { + // `packages/stack-drizzle` must not be read as `packages/stack` + suffix, + // and a dead `packages/stack-forge` must not be excused by live + // `packages/stack`. + const r = run(fx('prefix.md')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/packages\/stack-forge/) + expect(r.output).not.toMatch(/packages\/stack-drizzle/) + }) + + it('ignores `packages/*` globs and `./packages/*` filters', () => { + expect(run(fx('globs.md')).exitCode).toBe(0) + }) + + it('walks a directory target', () => { + const r = run(fx('dir')) + expect(r.exitCode).toBe(1) + expect(r.output).toMatch(/nested\.md/) + }) + + it('skips CHANGELOG.md — released history names deleted packages', () => { + expect(run(fx('dir-with-changelog')).exitCode).toBe(0) + }) +}) diff --git a/scripts/lint-no-dead-package-paths.mjs b/scripts/lint-no-dead-package-paths.mjs new file mode 100644 index 000000000..8ca468d27 --- /dev/null +++ b/scripts/lint-no-dead-package-paths.mjs @@ -0,0 +1,107 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +// Default targets — the surfaces a reader or agent navigates by. Override with +// argv[2..] for tests / ad-hoc checks. +// +// Deliberately NOT scanned: +// - `docs/plans/`, `docs/superpowers/` — design archives. They narrate the +// state of the tree at the time they were written, including packages the +// plan itself proposed removing. Rewriting history there is wrong. +// - `CHANGELOG.md` (anywhere) — released entries name what they removed. +const TARGETS = process.argv.slice(2).length + ? process.argv.slice(2) + : [ + 'AGENTS.md', + 'CLAUDE.md', + 'README.md', + 'SECURITY.md', + 'CONTRIBUTE.md', + 'docs', + '.github', + 'skills', + 'e2e/README.md', + 'packages/cli/AGENTS.md', + ] + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'plans', + 'superpowers', + '.git', +]) +const SKIP_FILES = new Set(['CHANGELOG.md']) +const TEXT_EXT = /\.(md|ya?ml|json|mjs|ts|txt)$/ + +// `packages/` where `` is a real directory name. The character +// class excludes `*`, so workspace globs (`packages/*`, `./packages/*`) are +// left alone, and `+` is greedy so `packages/stack-forge` is never excused by +// the live `packages/stack`. +const PACKAGE_REF = /packages\/([a-z0-9][a-z0-9._-]*)/g + +const livePackages = new Set( + readdirSync(resolve(REPO_ROOT, 'packages'), { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name), +) + +function* walk(abs) { + const stat = statSync(abs) + if (stat.isFile()) { + yield abs + return + } + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue + yield* walk(join(abs, entry.name)) + } else if (!SKIP_FILES.has(entry.name) && TEXT_EXT.test(entry.name)) { + yield join(abs, entry.name) + } + } +} + +const offenders = [] +for (const target of TARGETS) { + const abs = resolve(REPO_ROOT, target) + let exists = true + try { + statSync(abs) + } catch { + exists = false + } + // A default target that doesn't exist is not an error — the list covers + // optional files (CONTRIBUTE.md, per-package AGENTS.md). + if (!exists) continue + + for (const file of walk(abs)) { + const rel = relative(REPO_ROOT, file) + if (SKIP_FILES.has(rel.split('/').pop())) continue + const lines = readFileSync(file, 'utf8').split('\n') + lines.forEach((line, idx) => { + PACKAGE_REF.lastIndex = 0 + for (const m of line.matchAll(PACKAGE_REF)) { + if (livePackages.has(m[1])) continue + offenders.push(`${rel}:${idx + 1}: \`packages/${m[1]}\` does not exist`) + } + }) + } +} + +if (offenders.length > 0) { + console.error( + `Found ${offenders.length} reference(s) to a non-existent package directory:\n`, + ) + for (const o of offenders) console.error(` ${o}`) + console.error( + '\nA package was renamed or removed without updating the docs and config\n' + + 'that point at it. Repoint each reference at the surviving path, or drop\n' + + 'the line if it no longer describes anything. Design archives\n' + + '(docs/plans, docs/superpowers) and CHANGELOGs are exempt — they record\n' + + 'history, not the current tree.', + ) + process.exit(1) +} From b3a3499d5057e6d400dfa100b0400d822e14c682 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 15:07:51 +1000 Subject: [PATCH 19/25] refactor(cli): drop the unreachable v2 branch left by the classifier change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classifyEqlDomain` now returns `3 | null`, so `listEncryptedColumns` can never emit `version: 2` and the `c.version === 2` exemption in `explainUnresolved` is dead. Removing it is provably behaviour-preserving: a post-cutover v2 table (`` carrying the v2 domain) now reaches `explainUnresolved` with an EMPTY candidate list, which the `candidates.length === 0` guard above already falls through on. - Remove the branch; rewrite the doc comment and the stale v2 parentheticals in `drop.ts` / `cutover.ts` to say why the post-cutover state now arrives as "no EQL columns". - Rewrite the drop test that hand-built a `version: 2` candidate — a state resolution can no longer produce, so it only exercised the dead branch — to the state that actually occurs. - Add unit tests pinning `explainUnresolved`'s contract, including that a candidate sharing the plaintext column's name still fails closed (the removed branch's only behaviour, and wrong at v3, which has no cut-over rename). - Correct the backfill manifest comments: `null` now also means a legacy `eql_v2_encrypted` domain, so a v2 column backfilled from here on records no `eqlVersion` and reports no version in `encrypt status`. The live-domain fallback yields null for that case too. Existing manifests are unaffected. Noted in the changeset. - Drop the PR-1 plan doc that landed in this PR's diff. --- .../remove-eql-v2-migrate-classifier.md | 5 +- .../2026-07-22-eql-v2-removal-pr1-plan.md | 74 ------------------- .../encrypt/__tests__/encrypt-v3.test.ts | 12 ++- packages/cli/src/commands/encrypt/backfill.ts | 20 +++-- packages/cli/src/commands/encrypt/cutover.ts | 6 +- packages/cli/src/commands/encrypt/drop.ts | 5 +- .../encrypt/lib/__tests__/resolve-eql.test.ts | 59 +++++++++++++++ .../src/commands/encrypt/lib/resolve-eql.ts | 24 +++--- 8 files changed, 106 insertions(+), 99 deletions(-) delete mode 100644 docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md create mode 100644 packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts diff --git a/.changeset/remove-eql-v2-migrate-classifier.md b/.changeset/remove-eql-v2-migrate-classifier.md index 45d163efa..e6ae23fb6 100644 --- a/.changeset/remove-eql-v2-migrate-classifier.md +++ b/.changeset/remove-eql-v2-migrate-classifier.md @@ -9,7 +9,10 @@ domain — v3 is the sole generation this workspace authors and backfills, so a column's version is now determined solely from its self-describing `eql_v3_*` domain type. A legacy v2 column's version is carried by the manifest's recorded `eqlVersion` instead (the CLI's `encrypt status` / `status` renderers already -fall back to it), so status output is unchanged for v2 columns. +fall back to it), so status output is unchanged for v2 columns already recorded +in `.cipherstash/migrations.json`. A v2 column backfilled from here on records +no `eqlVersion` and so reports no version in `stash encrypt status` — the v2 +lifecycle itself (cut-over, then dropping `_plaintext`) is unaffected. This removes v2 *classification*, not the v2 read path: existing v2 ciphertext remains decryptable through `@cipherstash/stack`. `EqlVersion` keeps its `2` diff --git a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md b/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md deleted file mode 100644 index 54e53c516..000000000 --- a/docs/plans/2026-07-22-eql-v2-removal-pr1-plan.md +++ /dev/null @@ -1,74 +0,0 @@ -# EQL v2 removal — PR 1 step-plan (delete published v2 packages) - -Executes PR 1 of `docs/plans/2026-07-22-eql-v2-final-removal-design.md`. -Branch: `feat/remove-eql-v2-pr1-delete-packages` (worktree). Scoped against `origin/main` = `6ce53817`. - -## Goal -Delete the closed v2-only dependency chain — `@cipherstash/protect-dynamodb` → -`@cipherstash/protect` → `@cipherstash/schema` — and remove every reference so the -build, lockfile, and changeset state stay consistent. Mergeable in isolation. - -## Verified scope (live-code survey, not just design line-counts) -Closed-chain claim CONFIRMED for code imports: nothing outside the three imports them, -and `@cipherstash/stack` depends only on `@cipherstash/protect-ffi` (a different, external -package), not on any of the three. - -Corrections vs. the design's PR-1 paragraph: -- `.changeset/config.json` — the three are NOT in the `fixed` group and `ignore` is `[]`. - No config edit required (design assumed a fixed-group removal). -- `pnpm-workspace.yaml` uses globs (`packages/*`), no explicit entries to remove. -- No `tsconfig` `references` anywhere — nothing to unpick. -- Extra build blockers the design folded under "root config": `e2e/package.json` dep edge - and root `package.json` `build:js` turbo filter. -- Extra changeset-state fixes: pending `schema-stevec-standard-pin.md` targets the doomed - `@cipherstash/schema`; `pre.json` pins all three in `initialVersions`. - -## Steps - -### 1. Delete the three package directories -- `rm -rf packages/protect-dynamodb packages/protect packages/schema` - -### 2. Fix build blockers (dangling references that break compile/CI) -- `e2e/package.json` — remove the `"@cipherstash/protect": "workspace:*"` dependency line. -- root `package.json` — `build:js`: drop `--filter './packages/protect'`, keep `./packages/nextjs`. - -### 3. Clean stale (non-breaking) references -- `scripts/lint-no-hardcoded-runners.mjs` — remove the `packages/protect/src/bin/runner.ts` - allowlist entry (verify the script doesn't assert the path exists — if it does, this is - actually a blocker). -- `packages/nextjs/package.json` — description references `@cipherstash/protect`; repoint to - `@cipherstash/stack`. -- `skills/stash-drizzle/SKILL.md:38` — inspect the `@cipherstash/protect` mention; fix only if - the deletion made it wrong (a historical note about the legacy protect-based package may stay). - -### 4. Changeset / RC-mode housekeeping -- Delete `.changeset/schema-stevec-standard-pin.md` (only target is the deleted `@cipherstash/schema`; - already consumed in a prior rc per `pre.json`). -- `.changeset/pre.json` — remove the three from `initialVersions`; remove `schema-stevec-standard-pin` - from the `changesets` array (keeps it consistent with the deleted file). -- Add deletion-notice changeset `.changeset/remove-eql-v2-packages.md`: - `'@cipherstash/stack': patch` (successor surface for all three; group already major via - `stack-1-0-0-rc`) and `'@cipherstash/nextjs': patch` (its `package.json` description changes - from `@cipherstash/protect` to `@cipherstash/stack` — a published-metadata edit), prose body - naming each removed package and its migration path - (`@cipherstash/protect` → `@cipherstash/stack`; `@cipherstash/schema` → `@cipherstash/stack/schema`; - `@cipherstash/protect-dynamodb` → `@cipherstash/stack/dynamodb` `encryptedDynamoDB`). - Follows the `remove-legacy-drizzle-package.md` precedent. - -### 5. Meta-file honesty (trim what described the removed packages) -- `SECURITY.md` — drop the three rows from the package list. -- `AGENTS.md` — Repository Layout entries (protect, schema, protect-dynamodb) + prose mentions; - keep the "maintained implementation is `packages/stack/src/dynamodb`" guidance. - -### 6. Regenerate lockfile -- `pnpm install` (updates `pnpm-lock.yaml` for removed packages + e2e edge). CI is - `--frozen-lockfile`, so the committed lockfile must match. - -## Verification (green gate before commit) -- `pnpm changeset status` — no changeset references a missing package. -- `pnpm run build` — whole-repo turbo build; proves no dangling import/reference. -- `pnpm run code:check` — biome, error-free. -- `git grep -nP "@cipherstash/protect(?!-ffi)|@cipherstash/schema|@cipherstash/protect-dynamodb"` — - only intentional survivors (e.g. migration-path prose). The `(?!-ffi)` lookahead (PCRE, hence - `-P`) excludes the unrelated `@cipherstash/protect-ffi`; a plain `\b` would not, since a word - boundary sits between `protect` and `-ffi`. diff --git a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts index da2389aff..45def1ef2 100644 --- a/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts +++ b/packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts @@ -430,10 +430,14 @@ describe('encrypt drop — EQL version awareness', () => { // After cutover renamed the ciphertext onto `email`, no counterpart is // resolvable BY DESIGN. The fail-closed guard must recognize this state // rather than blocking the one drop the lifecycle actually wants. - lifecycleMock.mockResolvedValue({ - info: null, - candidates: [{ column: 'email', domain: 'eql_v2_encrypted', version: 2 }], - }) + // + // `candidates` is EMPTY, not `[{ column: 'email', version: 2 }]`: the + // classifier no longer recognises `eql_v2_encrypted`, so a post-cutover v2 + // column drops out of `listEncryptedColumns` entirely. The state reaches + // `explainUnresolved` as "no EQL columns at all", which is exactly the + // case it already falls through on. This pins that the v2 lifecycle still + // works through the narrower classifier. + lifecycleMock.mockResolvedValue({ info: null, candidates: [] }) migrateMocks.progress.mockResolvedValueOnce({ phase: 'cut-over' }) await dropCommand({ table: 'users', column: 'email' }) diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts index 7f7edb006..1698d8825 100644 --- a/packages/cli/src/commands/encrypt/backfill.ts +++ b/packages/cli/src/commands/encrypt/backfill.ts @@ -141,8 +141,11 @@ export async function backfillCommand(options: BackfillCommandOptions) { // v2 or v3 changes the rest of the LIFECYCLE (v3 has no cut-over — the // ladder is backfill → switch-by-name → drop), so detect it up front, // record it in the manifest, and tell the user which path they're on. - // `null` means the target column doesn't exist or isn't an EQL domain — - // let the existing checks below produce their specific errors. + // `null` means the target column doesn't exist, isn't an EQL domain, or is + // a legacy `eql_v2_encrypted` column (no longer classified — v3 is the sole + // authored generation). Every one of those falls through to the v2 ladder, + // which is the correct default for the v2 case and lets the existing checks + // below produce their specific errors for the other two. const eqlVersion = await detectColumnEqlVersion( db, options.table, @@ -553,12 +556,19 @@ function buildManifestEntry( // convention only, never relied upon. encryptedColumn, // v2's ladder ends with the rename cut-over; v3 has none — its end - // state is the plaintext column dropped. + // state is the plaintext column dropped. An unclassified column (null, + // which now includes a legacy v2 domain) takes the v2 ladder. targetPhase: eqlVersion === 3 ? 'dropped' : 'cut-over', } if (pkColumn) entry.pkColumn = pkColumn - // Absent means UNKNOWN (detection couldn't see the column), not v2 — - // readers fall back to the domain type in the database. + // Absent means the classifier returned null: the column isn't there, isn't + // an EQL domain, or carries the legacy `eql_v2_encrypted` domain (which + // `classifyEqlDomain` no longer recognises — v3 is the sole authored + // generation). Readers fall back to the live domain type, which yields null + // for those same three cases, so `encrypt status` reports no version rather + // than guessing one. Manifests written before v2 classification was dropped + // still carry `eqlVersion: 2`, so existing v2 columns keep their version; + // only a v2 column backfilled from here on records none. if (eqlVersion) entry.eqlVersion = eqlVersion return entry } diff --git a/packages/cli/src/commands/encrypt/cutover.ts b/packages/cli/src/commands/encrypt/cutover.ts index 6584ef075..676c4e244 100644 --- a/packages/cli/src/commands/encrypt/cutover.ts +++ b/packages/cli/src/commands/encrypt/cutover.ts @@ -78,8 +78,10 @@ export async function cutoverCommand(options: CutoverCommandOptions) { // Fail closed on ambiguity: `info === null` with EQL columns present // means we can't tell WHICH lifecycle applies — running the v2 config // machine against (possibly) v3 columns would only produce a misleading - // downstream error. (No EQL columns at all, or the post-cutover v2 - // same-name state, still falls through to the v2 preconditions below.) + // downstream error. (A table with no EQL v3 columns still falls through to + // the v2 preconditions below — which now also covers the post-cutover v2 + // same-name state, since an `eql_v2_encrypted` column is no longer + // classified and so never appears as a candidate.) const unresolved = explainUnresolved( options.table, options.column, diff --git a/packages/cli/src/commands/encrypt/drop.ts b/packages/cli/src/commands/encrypt/drop.ts index 2fe440bb0..9beb6953a 100644 --- a/packages/cli/src/commands/encrypt/drop.ts +++ b/packages/cli/src/commands/encrypt/drop.ts @@ -84,8 +84,9 @@ export async function dropCommand(options: DropCommandOptions) { // the wrong ciphertext and generate an irreversible drop of the wrong // data. (The post-cutover v2 state — `` itself carries the v2 // domain, counterpart legitimately unresolvable — falls through to the - // v2 path; live truth from the DB wins over the manifest's cached - // version throughout.) + // v2 path: the classifier recognises `eql_v3_*` only, so that column is + // not a candidate and the table reads as having no EQL columns. Live + // truth from the DB wins over the manifest's cached version throughout.) const unresolved = explainUnresolved( options.table, options.column, diff --git a/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts new file mode 100644 index 000000000..1e5bc6cae --- /dev/null +++ b/packages/cli/src/commands/encrypt/lib/__tests__/resolve-eql.test.ts @@ -0,0 +1,59 @@ +/** + * Pins {@link explainUnresolved}'s fail-closed contract now that the domain + * classifier recognises `eql_v3_*` only. + * + * `listEncryptedColumns` can no longer emit `version: 2` — a legacy + * `eql_v2_encrypted` column is not classified as an EQL column at all, so it + * never reaches this function as a candidate. The post-cutover v2 state (the + * ciphertext renamed onto the plaintext column's own name) therefore arrives + * here as an EMPTY candidate list, which the first guard already falls through + * on. These tests exist so removing the now-unreachable `version === 2` branch + * is provably behaviour-preserving, and so a future v2 sweep cannot delete the + * empty-list guard the v2 lifecycle actually depends on. + */ + +import type { EncryptedColumnInfo } from '@cipherstash/migrate' +import { describe, expect, it } from 'vitest' +import { explainUnresolved } from '../resolve-eql.js' + +const v3 = ( + column: string, + domain = 'eql_v3_text_eq', +): EncryptedColumnInfo => ({ + column, + domain, + version: 3, +}) + +describe('explainUnresolved', () => { + it('falls through (null) when the table has no EQL columns at all', () => { + // Both the not-yet-backfilled case and the post-cutover v2 same-name case + // land here: the caller's own preconditions produce the accurate error. + expect(explainUnresolved('users', 'email', [])).toBeNull() + }) + + it('fails closed, naming every candidate, when none is identifiable', () => { + const message = explainUnresolved('users', 'email', [ + v3('a_enc'), + v3('b_enc', 'eql_v3_text_search'), + ]) + + expect(message).toContain('Cannot identify which encrypted column') + expect(message).toContain('a_enc (eql_v3_text_eq)') + expect(message).toContain('b_enc (eql_v3_text_search)') + expect(message).toContain('--encrypted-column') + }) + + it('gives no free pass to a candidate sharing the plaintext column name', () => { + // The removed branch exempted a SAME-NAME candidate, but only at + // `version === 2`. A v3 domain on the plaintext column's own name is not + // the post-cutover state (v3 has no cut-over rename), so it must still + // fail closed rather than let a destructive command guess a lifecycle. + const message = explainUnresolved('users', 'email', [ + v3('email'), + v3('email_enc'), + ]) + + expect(message).toContain('Cannot identify which encrypted column') + }) +}) diff --git a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts index 9ce9afb6a..f3cb0afc2 100644 --- a/packages/cli/src/commands/encrypt/lib/resolve-eql.ts +++ b/packages/cli/src/commands/encrypt/lib/resolve-eql.ts @@ -68,16 +68,21 @@ export async function resolveColumnLifecycle( /** * Explain a failed resolution (`info === null`) to the user, or return - * `null` when the failure is fine to fall through to the v2 lifecycle: + * `null` when the failure is fine to fall through to the v2 lifecycle. * - * - No EQL columns at all → the v2 phase/config preconditions produce the - * accurate error ("not backfilled", "no pending config", …). - * - The plaintext column ITSELF carries the v2 domain → the normal - * post-cutover v2 state (`` was renamed onto the ciphertext), where - * "no counterpart" is expected, not a problem. + * The one fall-through case is "no EQL columns at all", which the v2 + * phase/config preconditions turn into an accurate error ("not backfilled", + * "no pending config", …). Since `classifyEqlDomain` recognises `eql_v3_*` + * only, that case now also covers the post-cutover v2 state — `` was + * renamed onto the ciphertext, and its `eql_v2_encrypted` domain is no longer + * classified, so the column never appears as a candidate. (It used to arrive + * here as a `version: 2` candidate and needed its own exemption.) * - * Anything else means EQL columns exist but none is identifiable — the - * caller must fail closed with this message rather than guess a lifecycle. + * A non-empty candidate list therefore means EQL v3 columns exist but none is + * identifiable — the caller must fail closed with this message rather than + * guess a lifecycle, including when one candidate happens to share the + * plaintext column's name (v3 has no cut-over rename, so that is not the + * post-cutover state). */ export function explainUnresolved( table: string, @@ -85,9 +90,6 @@ export function explainUnresolved( candidates: readonly EncryptedColumnInfo[], ): string | null { if (candidates.length === 0) return null - if (candidates.some((c) => c.column === column && c.version === 2)) { - return null - } const listed = candidates .map((c) => ` - ${c.column} (${c.domain})`) .join('\n') From 93286addf7a6698738772e36a7581d6e5b47279b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 15:08:58 +1000 Subject: [PATCH 20/25] =?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 From 7b783eefb82ab1085beee01aad893c286fb4503b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 15:52:53 +1000 Subject: [PATCH 21/25] =?UTF-8?q?fix(bench,cli,docs):=20address=20PR=20rev?= =?UTF-8?q?iew=20=E2=80=94=20dead=20JSON=20index,=20broken=20init=20scaffo?= =?UTF-8?q?ld,=20stale=20shipped=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings, each with the regression test that would have caught it. - `packages/bench/sql/schema.sql` indexed `eql_v3.ste_vec(enc_jsonb)`, a mechanical rename of the v2 expression. It builds, and `db-only.test.ts`'s `pg_indexes` check passes, but nothing the adapter emits ever mentions it: `@>` on `eql_v3_json_search` inlines to `eql_v3.to_ste_vec_query(a)::jsonb` (the bundle's own comment says so). A permanently-dead index in the one fixture whose job is to prove index engagement. Now indexed on the inlined expression, with `scripts/__tests__/bench-index-expressions.test.mjs` pinning each bench index against the operator body in the vendored EQL bundle — no database, no credentials, which matters because the bench's own EXPLAIN assertions need credentials and never run in CI. - `stash init --drizzle` scaffolded `extractEncryptionSchemaV3` from `@cipherstash/stack-drizzle/v3` into the user's repo — both removed here, so a freshly-initialised project would not resolve. Flipped the drizzle half of the generated strings (the `@cipherstash/stack/v3` half stays for PR 7) and pinned it in `utils-codegen-drizzle.test.ts`. - `skills/stash-encryption`, `packages/stack/README.md` and `AGENTS.md` still documented the `./v3` subpath and the `*V3` names. Both skills and the stack README are shipped artifacts. `no-removed-drizzle-surface.test.mjs` scans the shipped file set (deliberately not CHANGELOGs or specs, which should still name the old surface). - `vitest.shared.ts` aliased `@cipherstash/stack-drizzle/v3` to the deleted `src/v3/index.ts`; `vitest-shared-alias.test.mjs` asserts every alias target exists on disk. - The bench `matches` operand was the shared `value` prefix, which every seeded row contains — the bloom index had nothing to narrow, so the number measured a full scan. Uses a full seeded value now. - The bench importer's only CI gate was the Bun job's `turbo build`, which swallows its own test failures. Added an explicit typecheck step to the main test job. The eq/matches index assertions are sound and now say why: the operator wrappers are `LANGUAGE sql IMMUTABLE STRICT` single-SELECT bodies, so the planner inlines them and applies the same inlining to the stored index expression. The old comment claimed the adapter emitted `eql_v3.eq_term(col) = eql_v3.hmac_256(value)` — it emits `eql_v3.eq(col, term)`, and `eql_v3.hmac_256` does not exist in the bundle at all. --- .changeset/remove-eql-v2-drizzle-root.md | 7 +- .github/workflows/tests.yml | 10 ++ AGENTS.md | 4 +- .../__benches__/drizzle/operators.bench.ts | 9 +- .../drizzle/operators.explain.test.ts | 15 +- packages/bench/sql/schema.sql | 25 ++- .../__tests__/utils-codegen-drizzle.test.ts | 64 ++++++++ .../cli/src/commands/init/lib/setup-prompt.ts | 2 +- packages/cli/src/commands/init/utils.ts | 12 +- packages/stack/README.md | 23 +-- .../bench-index-expressions.test.mjs | 142 ++++++++++++++++++ .../no-removed-drizzle-surface.test.mjs | 69 +++++++++ .../__tests__/vitest-shared-alias.test.mjs | 34 +++++ skills/stash-encryption/SKILL.md | 6 +- vitest.shared.ts | 7 +- 15 files changed, 393 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts create mode 100644 scripts/__tests__/bench-index-expressions.test.mjs create mode 100644 scripts/__tests__/no-removed-drizzle-surface.test.mjs create mode 100644 scripts/__tests__/vitest-shared-alias.test.mjs diff --git a/.changeset/remove-eql-v2-drizzle-root.md b/.changeset/remove-eql-v2-drizzle-root.md index 66e61576f..64d918746 100644 --- a/.changeset/remove-eql-v2-drizzle-root.md +++ b/.changeset/remove-eql-v2-drizzle-root.md @@ -1,5 +1,6 @@ --- '@cipherstash/stack-drizzle': major +'@cipherstash/stack': patch 'stash': patch --- @@ -19,4 +20,8 @@ Remove the EQL v2 authoring surface from `@cipherstash/stack-drizzle` and collap The `types.*` column factories, `makeEqlV3Column` / `getEqlV3Column` / `isEqlV3Column`, the codec helpers (`v3ToDriver` / `v3FromDriver` / `EqlV3CodecError`), and `EncryptionOperatorError` are unchanged apart from moving to the root. -Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed. The bundled `stash-drizzle` skill is updated to the collapsed root imports. +Existing EQL v2 ciphertext remains decryptable via `@cipherstash/stack` — only the Drizzle-side v2 authoring and query-building is removed. + +**`stash` (patch):** `stash init --drizzle` scaffolded the removed surface — the generated encryption-client file and the Drizzle placeholder both imported `extractEncryptionSchemaV3` from `@cipherstash/stack-drizzle/v3`, so a freshly-initialised project would not resolve against this release. Both now emit the collapsed root import. The bundled `stash-drizzle` and `stash-encryption` skills are updated to match. + +**`@cipherstash/stack` (patch):** README only — its Drizzle section documented the removed `./v3` subpath and the `*V3` export names. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8ddc8f406..1d2d3743a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,6 +136,16 @@ jobs: - name: Typecheck (prisma-next — enforces v3 operator-capability gating) run: pnpm --filter @cipherstash/prisma-next run typecheck + # `packages/bench` is a live importer of `@cipherstash/stack` and + # `@cipherstash/stack-drizzle`, but it has no `test` script (its suites + # need a database), so `pnpm run test` never reaches it and an adapter + # rename could break it unnoticed. Its `build` is `tsc --noEmit`, and + # turbo's `^build` builds the two adapters first. This also runs in the + # Bun job's full `turbo build`, but that job swallows its own test + # failures — the importer guard should not depend on it. + - name: Typecheck (bench — guards the stack/stack-drizzle importers) + run: pnpm exec turbo run build --filter @cipherstash/bench + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners diff --git a/AGENTS.md b/AGENTS.md index 4f29f4fe4..74587aff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ If these variables are missing, tests that require live encryption will fail or - `packages/wizard`: AI-powered encryption setup (`@cipherstash/wizard`) - `packages/migrate`: Plaintext-to-encrypted column migration (`@cipherstash/migrate`) — resumable backfill, per-column state - `packages/prisma-next`: Prisma Next integration (`@cipherstash/prisma-next`) — searchable field-level encryption for Postgres. **EQL v3 only**: per-domain constructors (`cipherstash.TextSearch()` / `text()` / `bigIntOrd()` / …) and `cipherstashFromStack` (the `./v3` and `./stack` entries). The EQL v2 surface was removed — the adapter's baseline migration installs the EQL v3 bundle only (works on Supabase as a non-superuser) -- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — EQL v2 (`.`) and EQL v3 (`./v3`). Split out of `@cipherstash/stack`. +- `packages/stack-drizzle`: Drizzle ORM integration (`@cipherstash/stack-drizzle`), depends on `@cipherstash/stack` — **EQL v3 only**, on the package root (the v2 surface was removed and the old `./v3` subpath collapsed into `.`). Split out of `@cipherstash/stack`. - `packages/stack-supabase`: Supabase integration (`@cipherstash/stack-supabase`), depends on `@cipherstash/stack` — `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3). Split out of `@cipherstash/stack`. - `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export) - `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`) @@ -153,7 +153,7 @@ Three rules to remember when editing CI or pnpm config: - `encryptQuery(terms[])` for batch query encryption - **Identity-aware encryption**: Authenticate the client as the end user with `OidcFederationStrategy` (`config.authStrategy`, re-exported from `@cipherstash/stack`), then chain `.withLockContext({ identityClaim })` on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (`LockContext.identify()` from `@cipherstash/stack/identity` is deprecated — the strategy now handles token acquisition; `.withLockContext()` also accepts a `LockContext`.) - **Integrations**: - - **Drizzle ORM**: `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` (EQL v3 factories from `@cipherstash/stack-drizzle/v3`) + - **Drizzle ORM**: `types.*` column factories, `extractEncryptionSchema`, `createEncryptionOperators` from `@cipherstash/stack-drizzle` - **Supabase**: `encryptedSupabase` (v2) / `encryptedSupabaseV3` (v3) from `@cipherstash/stack-supabase` - **DynamoDB**: `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` diff --git a/packages/bench/__benches__/drizzle/operators.bench.ts b/packages/bench/__benches__/drizzle/operators.bench.ts index 53422497f..c7079a5a9 100644 --- a/packages/bench/__benches__/drizzle/operators.bench.ts +++ b/packages/bench/__benches__/drizzle/operators.bench.ts @@ -45,7 +45,14 @@ describe('drizzle', () => { }) bench('matches (free-text)', async () => { - const where = (await ops.matches(benchTable.encText, 'value')) as SQL + // A FULL seeded value, not the shared `value` prefix: every row is + // `value-<7 digits>`, so a bare `value` needle matches all 10k rows and the + // bloom index has nothing to narrow — the number would measure a full scan + // rather than the index path this bench exists to measure. + const where = (await ops.matches( + benchTable.encText, + 'value-0000042', + )) as SQL await handle.db.select().from(benchTable).where(where) }) diff --git a/packages/bench/__tests__/drizzle/operators.explain.test.ts b/packages/bench/__tests__/drizzle/operators.explain.test.ts index d3f58857e..546f4fb2c 100644 --- a/packages/bench/__tests__/drizzle/operators.explain.test.ts +++ b/packages/bench/__tests__/drizzle/operators.explain.test.ts @@ -105,8 +105,15 @@ async function tryExplainWhere(name: string, where: SQL): Promise { // --- #421: equality + array operators ------------------------------------- // // `bench_text_hmac_idx` (functional hash on eql_v3.eq_term) is the expected -// fast path. The v3 operators emit `eql_v3.eq_term(col) = eql_v3.hmac_256(value)` -// so the functional hash index scan kicks in instead of a seq scan. +// fast path. The v3 operators do NOT emit that expression directly — they emit +// the wrapper `eql_v3.eq(col, $1::eql_v3.query_text_search)`. It engages the +// index because the wrapper is `LANGUAGE sql IMMUTABLE STRICT` over a single +// SELECT, so the planner inlines it to `eql_v3.eq_term(col) = +// eql_v3.eq_term($1)` — and applies the same inlining to the stored index +// expression, which is how the two meet. Break the inlinability (plpgsql body, +// VOLATILE) and every assertion below silently degrades to a seq scan. +// `scripts/__tests__/bench-index-expressions.test.mjs` pins that contract +// against the shipped bundle without needing a database. // // `eq` and `inArray` are naturally high-selectivity (only a few rows match), // so the planner should pick the eq_term index — assertion enforces it. @@ -163,7 +170,9 @@ describe('#422: call-shaped operators (recorded, not asserted)', () => { it('records matches plan shape', async () => { await tryExplainWhere( 'matches', - (await ops.matches(benchTable.encText, 'value')) as SQL, + // A full seeded value — `value` alone is in every row, so the recorded + // plan would say nothing about the bloom index. See operators.bench.ts. + (await ops.matches(benchTable.encText, 'value-0000042')) as SQL, ) }) diff --git a/packages/bench/sql/schema.sql b/packages/bench/sql/schema.sql index 9390fa5e8..92e11c4e9 100644 --- a/packages/bench/sql/schema.sql +++ b/packages/bench/sql/schema.sql @@ -1,12 +1,31 @@ -- Bench fixture schema. -- Single bench table covering text / int / jsonb encrypted columns plus the --- three canonical EQL v3 functional indexes over the column-side index terms: --- eq_term (hash), match_term (GIN bloom), ste_vec (GIN). +-- three canonical EQL v3 functional indexes. -- -- Mirrors `src/drizzle/setup.ts`: the columns are concrete `eql_v3_*` Postgres -- domains (see `types.TextSearch` / `types.IntegerOrd` / `types.Json`). Install -- the domains and index-term functions first with `stash eql install --eql-version 3`. -- +-- Each index expression must be the expression the matching EQL v3 operator +-- INLINES TO, not merely a term extractor for the same column. The adapter +-- emits a function call (`eql_v3.eq(col, term)`, `eql_v3.matches(...)`, +-- `col @> needle`); every one of those is `LANGUAGE sql IMMUTABLE STRICT` +-- with a single-SELECT body, so the planner inlines it, and Postgres applies +-- the same inlining to the stored index expression. The two only meet if the +-- index is built on the inlined form: +-- +-- eql_v3.eq(a, b) -> eql_v3.eq_term(a) = eql_v3.eq_term(b) +-- eql_v3.matches(a, b) -> eql_v3.match_term(a) @> eql_v3.match_term(b) +-- a @> b (json) -> eql_v3.to_ste_vec_query(a)::jsonb +-- @> eql_v3.to_ste_vec_query(b)::jsonb +-- +-- That last one is why the JSON index is NOT on `eql_v3.ste_vec(...)`: that +-- function exists and the index builds happily, but nothing the adapter emits +-- ever mentions it, so the index is dead weight. The bundle says so itself, on +-- `eql_v3."@>"(eql_v3_json_search, eql_v3.query_json)`: "Inlines to native +-- `jsonb @>` over `eql_v3.to_ste_vec_query(a)::jsonb`, so a functional GIN +-- index on the same expression engages." +-- -- We deliberately do NOT create btree operator-class indexes: encrypted terms for -- full-feature columns blow past the 2704-byte btree page-size limit, and the -- bench's whole job is to validate that the functional-index path works. @@ -27,6 +46,6 @@ CREATE INDEX bench_text_bloom_idx ON bench USING gin (eql_v3.match_term(enc_text)); CREATE INDEX bench_jsonb_stevec_idx - ON bench USING gin (eql_v3.ste_vec(enc_jsonb)); + ON bench USING gin ((eql_v3.to_ste_vec_query(enc_jsonb)::jsonb)); ANALYZE bench; diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts new file mode 100644 index 000000000..50acc3682 --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import type { SchemaDef } from '../types.js' +import { + generateClientFromSchemas, + generatePlaceholderClient, +} from '../utils.js' + +// `stash init --drizzle` writes these strings into the USER's repo as real +// source. Nothing type-checks a template literal, so a rename in +// `@cipherstash/stack-drizzle` cannot break the build here — it breaks the +// scaffolded project instead, in someone else's checkout. These assertions are +// the only thing standing between a de-suffixed export and a broken `stash +// init`. +// +// Pinned by this suite (all three removed when `./v3` collapsed into the root): +// - the `@cipherstash/stack-drizzle/v3` specifier +// - `extractEncryptionSchemaV3` +// - `createEncryptionOperatorsV3` + +const SCHEMAS: SchemaDef[] = [ + { + tableName: 'users', + columns: [ + { name: 'email', dataType: 'string', searchOps: ['equality'] }, + { name: 'age', dataType: 'number', searchOps: ['orderAndRange'] }, + ], + }, +] + +/** Every drizzle-flavoured string `stash init` can write into a user's repo. */ +const GENERATED_SOURCES: Array<[string, string]> = [ + ['generateClientFromSchemas', generateClientFromSchemas('drizzle', SCHEMAS)], + ['generatePlaceholderClient', generatePlaceholderClient('drizzle')], +] + +describe.each( + GENERATED_SOURCES, +)('drizzle scaffold — %s', (_name, generated) => { + it('imports the drizzle surface from the package ROOT, never the removed ./v3 subpath', () => { + expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') + expect(generated).toContain('@cipherstash/stack-drizzle') + }) + + it('uses the de-suffixed export names, never the removed *V3 aliases', () => { + expect(generated).not.toContain('extractEncryptionSchemaV3') + expect(generated).not.toContain('createEncryptionOperatorsV3') + }) + + it('never scaffolds the removed EQL v2 authoring surface', () => { + expect(generated).not.toContain('encryptedType') + expect(generated).not.toContain('eql_v2_encrypted') + }) +}) + +describe('generateClientFromSchemas (drizzle)', () => { + const generated = generateClientFromSchemas('drizzle', SCHEMAS) + + it('names extractEncryptionSchema in both the import and the call site', () => { + expect(generated).toContain( + "import { types, extractEncryptionSchema } from '@cipherstash/stack-drizzle'", + ) + expect(generated).toContain('extractEncryptionSchema(usersTable)') + }) +}) diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 364728eea..6ff780d41 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -273,7 +273,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { 'Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal Drizzle / Supabase work plus the encryption client patterns from the integration skill.', '', "1. **If this is the first encrypted column in the project, configure the bundler exclusion first.** `@cipherstash/stack` cannot be bundled (it wraps a native FFI module). Next.js: add `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` to `next.config.*`. Webpack: `externals`. esbuild: `external`. Vite SSR: `ssr.external`. Without this, the encryption client crashes at runtime with `Cannot find module '@cipherstash/protect-ffi-*'`. See the `stash-encryption` skill's Installation section for the full snippets.", - "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", + "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — the `types.*` domain factories for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", `3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`, `4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`, '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index d2a86ff7f..bb5d65691 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -332,13 +332,13 @@ ${columnDefs.join('\n')} createdAt: timestamp('created_at').defaultNow(), }) -const ${schemaVarName} = extractEncryptionSchemaV3(${varName})` +const ${schemaVarName} = extractEncryptionSchema(${varName})` }) const schemaVarNames = schemas.map((s) => `${toCamelCase(s.tableName)}Schema`) return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { types, extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' +import { types, extractEncryptionSchema } from '@cipherstash/stack-drizzle' import { EncryptionV3 } from '@cipherstash/stack/v3' ${tableDefs.join('\n\n')} @@ -432,7 +432,7 @@ const DRIZZLE_PLACEHOLDER = `/** * pointing at this file. * * This project uses EQL v3. Encrypted columns are concrete Postgres domains - * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle/v3\`. + * built with the \`types.*\` factories from \`@cipherstash/stack-drizzle\`. * Each domain's query capabilities are FIXED by the type you pick — there is * no capability config object. Choose the factory whose capabilities you need: * types.Text / types.Integer / … storage only (encrypt/decrypt, no queries) @@ -447,7 +447,7 @@ const DRIZZLE_PLACEHOLDER = `/** * Encrypted twin column for an existing populated column (path 3 — lifecycle): * * import { pgTable, integer, text } from 'drizzle-orm/pg-core' - * import { types } from '@cipherstash/stack-drizzle/v3' + * import { types } from '@cipherstash/stack-drizzle' * * export const users = pgTable('users', { * id: integer('id').primaryKey().generatedAlwaysAsIdentity(), @@ -464,12 +464,12 @@ const DRIZZLE_PLACEHOLDER = `/** * * Once you have encrypted tables declared, harvest them and pass to EncryptionV3(): * - * import { extractEncryptionSchemaV3 } from '@cipherstash/stack-drizzle/v3' + * import { extractEncryptionSchema } from '@cipherstash/stack-drizzle' * import { EncryptionV3 } from '@cipherstash/stack/v3' * import { users, orders } from './db/schema' * * export const encryptionClient = await EncryptionV3({ - * schemas: [extractEncryptionSchemaV3(users), extractEncryptionSchemaV3(orders)], + * schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)], * }) */ import { EncryptionV3 } from '@cipherstash/stack/v3' diff --git a/packages/stack/README.md b/packages/stack/README.md index f5450a725..8ca24e116 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -362,7 +362,7 @@ The one limitation is the ORE-backed `*OrdOre` domains: their ordering term need ### Drizzle Integration -The `@cipherstash/stack-drizzle/v3` subpath (of the separate `@cipherstash/stack-drizzle` package) provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. +The separate `@cipherstash/stack-drizzle` package provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. It is EQL v3 only, all on the package root — the EQL v2 surface was removed and the old `./v3` subpath collapsed into `.`. Declare a Drizzle table using the `types` factories — each factory emits its domain as the column's SQL type, so `drizzle-kit generate` produces `ADD COLUMN email public.eql_v3_text_search` etc.: @@ -371,9 +371,9 @@ import { pgTable, integer } from "drizzle-orm/pg-core" import { drizzle } from "drizzle-orm/postgres-js" import { types, - createEncryptionOperatorsV3, - extractEncryptionSchemaV3, -} from "@cipherstash/stack-drizzle/v3" + createEncryptionOperators, + extractEncryptionSchema, +} from "@cipherstash/stack-drizzle" import { EncryptionV3 } from "@cipherstash/stack/v3" // Capabilities come from the concrete type — no flags to configure. @@ -389,9 +389,9 @@ const users = pgTable("users", { Derive the v3 schema from the table, build the typed client, and create the operators: ```ts -const usersSchema = extractEncryptionSchemaV3(users) +const usersSchema = extractEncryptionSchema(users) const client = await EncryptionV3({ schemas: [usersSchema] }) -const ops = createEncryptionOperatorsV3(client) +const ops = createEncryptionOperators(client) const db = drizzle({ client: sqlClient }) ``` @@ -762,9 +762,8 @@ depend on `@cipherstash/stack` (they are no longer subpaths of it): | Package | Provides | |-------|-----| -| `@cipherstash/stack-drizzle/v3` | EQL v3 Drizzle integration: `types` column factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, `makeEqlV3Column`, `EncryptionOperatorError` | +| `@cipherstash/stack-drizzle` | EQL v3 Drizzle integration (package root, v3 only): `types` column factories, `createEncryptionOperators`, `extractEncryptionSchema`, `makeEqlV3Column`, `EncryptionOperatorError` | | `@cipherstash/stack-supabase` | Supabase integration: `encryptedSupabaseV3` (and the legacy v2 `encryptedSupabase`) | -| `@cipherstash/stack-drizzle` | Legacy EQL v2 Drizzle integration (root subpath): `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` | ## Legacy: EQL v2 @@ -781,9 +780,11 @@ emitted by protect-ffi 0.30 and must migrate to v3 `types.Json`: - **Query formatting**: v2 query terms can be rendered as strings with `returnType: 'composite-literal'` / `'escaped-composite-literal'` for string-based APIs. -- **Integrations**: the v2 Drizzle surface is the root of - `@cipherstash/stack-drizzle` (`encryptedType`, `extractEncryptionSchema`, - `createEncryptionOperators`); the v2 Supabase surface is `encryptedSupabase`. +- **Integrations**: the v2 Supabase surface is `encryptedSupabase`. There is no + v2 Drizzle surface any more — `@cipherstash/stack-drizzle` dropped + `encryptedType` and the v2 operators, so existing v2 Drizzle columns are + read-only: decrypt them through `@cipherstash/stack` and migrate to a v3 + domain. - **DynamoDB still requires v2**: `encryptedDynamoDB` from `@cipherstash/stack/dynamodb` works with the v2 API only — v3 support is tracked in [#657](https://github.com/cipherstash/stack/issues/657). diff --git a/scripts/__tests__/bench-index-expressions.test.mjs b/scripts/__tests__/bench-index-expressions.test.mjs new file mode 100644 index 000000000..0ea8c9d10 --- /dev/null +++ b/scripts/__tests__/bench-index-expressions.test.mjs @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +// The bench exists to prove the functional-index path engages. An index whose +// expression is merely "a term extractor for the right column" satisfies +// `CREATE INDEX` and satisfies the db-only smoke test's `pg_indexes` check, and +// is still never used — the planner only matches an index against the +// expression the operator INLINES TO. `eql_v3.ste_vec(enc_jsonb)` was exactly +// that: a valid, buildable, permanently-dead index, because `@>` on +// `eql_v3_json_search` inlines to `eql_v3.to_ste_vec_query(a)::jsonb`. +// +// Every EQL v3 operator wrapper is `LANGUAGE sql IMMUTABLE STRICT` with a +// single-SELECT body, which is what makes it inlinable — and Postgres runs the +// same simplification over stored index expressions, so the two meet only if +// the index names the function in the operator's BODY. +// +// This test reads the vendored EQL bundle (the same SQL `stash eql install` +// applies) and pins each bench index to the function its operator actually +// lowers to. It needs no database and no credentials, which is the point: the +// bench's own EXPLAIN assertions require CipherStash credentials and never run +// in CI. + +const require = createRequire(resolve(REPO_ROOT, 'packages/cli/package.json')) + +/** The EQL v3 bundle `stash eql install --eql-version 3` applies. */ +function bundleSql() { + const pkgJson = require.resolve('@cipherstash/eql/package.json') + return readFileSync( + resolve(dirname(pkgJson), 'dist/sql/cipherstash-encrypt.sql'), + 'utf8', + ) +} + +/** + * The body of one `CREATE FUNCTION` in the bundle, from its signature line to + * the `$$` that closes it. + */ +function functionBody(sql, signature) { + const start = sql.indexOf(signature) + expect(start, `bundle has no ${signature}`).toBeGreaterThan(-1) + const bodyStart = sql.indexOf('$$', start) + const bodyEnd = sql.indexOf('$$', bodyStart + 2) + return sql.slice(bodyStart + 2, bodyEnd) +} + +/** `eql_v3.(` calls appearing in a SQL fragment. */ +function eqlCalls(fragment) { + return [...fragment.matchAll(/eql_v3\.([a-z_0-9]+)\s*\(/g)].map((m) => m[1]) +} + +const SCHEMA_SQL = readFileSync( + resolve(REPO_ROOT, 'packages/bench/sql/schema.sql'), + 'utf8', +) + +/** `CREATE INDEX ... ()` pairs from the bench fixture. */ +function benchIndexes() { + const found = new Map() + for (const m of SCHEMA_SQL.matchAll( + /CREATE INDEX\s+(\w+)\s+ON bench USING \w+\s*\(([\s\S]*?)\);/g, + )) { + found.set(m[1], m[2].trim()) + } + return found +} + +/** + * Each bench index, and the operator wrapper whose inlined body it has to + * match. Signatures are the `query_` overloads — the narrowed query + * term is what the Drizzle adapter casts its operand to. + */ +const INDEX_CONTRACTS = [ + { + index: 'bench_text_hmac_idx', + operator: + 'CREATE FUNCTION eql_v3.eq(a public.eql_v3_text_search, b eql_v3.query_text_search)', + inlinesTo: 'eq_term', + }, + { + index: 'bench_text_bloom_idx', + operator: + 'CREATE FUNCTION eql_v3.matches(a public.eql_v3_text_search, b eql_v3.query_text_search)', + inlinesTo: 'match_term', + }, + { + index: 'bench_jsonb_stevec_idx', + operator: + 'CREATE FUNCTION eql_v3."@>"(a public.eql_v3_json_search, b eql_v3.query_json)', + inlinesTo: 'to_ste_vec_query', + }, +] + +describe('bench index expressions match the EQL v3 operator lowering', () => { + const sql = bundleSql() + const indexes = benchIndexes() + + it('parses all three bench indexes (guards a silently-empty regex)', () => { + expect([...indexes.keys()].sort()).toEqual( + INDEX_CONTRACTS.map((c) => c.index).sort(), + ) + }) + + it.each( + INDEX_CONTRACTS, + )('$index is built on the function $operator inlines to', ({ + index, + operator, + inlinesTo, + }) => { + const body = functionBody(sql, operator) + + // The operator really does lower to this function — if the bundle + // changes shape, this fails here rather than silently passing the + // schema check below against a stale assumption. + expect( + eqlCalls(body), + `${operator} no longer calls eql_v3.${inlinesTo}`, + ).toContain(inlinesTo) + + expect( + eqlCalls(indexes.get(index)), + `${index} must index eql_v3.${inlinesTo}(...) — the expression the ` + + 'operator inlines to — or the planner can never match it', + ).toEqual([inlinesTo]) + }) + + it('the operator wrappers are inlinable (LANGUAGE sql IMMUTABLE STRICT)', () => { + for (const { operator } of INDEX_CONTRACTS) { + const start = sql.indexOf(operator) + const header = sql.slice(start, sql.indexOf('$$', start)) + // A plpgsql or VOLATILE wrapper would never be inlined, so no functional + // index on its body could ever engage. + expect(header.toLowerCase(), operator).toMatch(/language sql/) + expect(header.toLowerCase(), operator).toMatch(/immutable/) + } + }) +}) diff --git a/scripts/__tests__/no-removed-drizzle-surface.test.mjs b/scripts/__tests__/no-removed-drizzle-surface.test.mjs new file mode 100644 index 000000000..64334bce4 --- /dev/null +++ b/scripts/__tests__/no-removed-drizzle-surface.test.mjs @@ -0,0 +1,69 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../..') + +/** + * The `@cipherstash/stack-drizzle` names removed when EQL v2 went away and + * `./v3` collapsed into the package root. Nothing type-checks a README, a + * SKILL.md, or a template literal, so these three drifted silently before — + * and `skills/` ships inside the `stash` tarball, which means the drift lands + * in a customer's repo rather than in CI. + */ +const REMOVED = [ + '@cipherstash/stack-drizzle/v3', + 'extractEncryptionSchemaV3', + 'createEncryptionOperatorsV3', +] + +/** + * Files whose contents are SHIPPED — published to npm, copied into a user's + * repo, or written there by `stash init`. Deliberately not the whole tree: + * CHANGELOGs and `docs/superpowers/specs/**` are historical records that + * SHOULD still name the old surface, and rewriting history to appease a lint + * is worse than the drift it prevents. + */ +// `:(glob)` magic so `*` stops at a path separator — without it git's default +// wildmatch crosses `/` and `lib/*.ts` sweeps in `lib/__tests__/*.test.ts`. +const SHIPPED_GLOBS = [ + ':(glob)skills/*/SKILL.md', + ':(glob)packages/*/README.md', + 'README.md', + 'AGENTS.md', + // `stash init` writes these strings into the user's project as real source. + 'packages/cli/src/commands/init/utils.ts', + ':(glob)packages/cli/src/commands/init/lib/*.ts', + ':(glob)packages/cli/src/commands/init/doctrine/*.md', +] + +/** Tracked files matching the shipped globs, via git so it honours .gitignore. */ +function shippedFiles() { + const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + return out.split('\0').filter(Boolean) +} + +describe('removed stack-drizzle surface is absent from shipped files', () => { + const files = shippedFiles() + + it('finds the shipped file set (guards against a silently-empty glob)', () => { + expect(files.length).toBeGreaterThan(5) + expect(files).toContain('skills/stash-drizzle/SKILL.md') + expect(files).toContain('packages/stack/README.md') + }) + + it.each(files)('%s', (file) => { + const body = readFileSync(resolve(REPO_ROOT, file), 'utf8') + const found = REMOVED.filter((name) => body.includes(name)) + expect( + found, + `${file} names removed @cipherstash/stack-drizzle exports: ${found.join(', ')}. ` + + 'The `./v3` subpath collapsed into the package root and the *V3 suffixes were dropped.', + ).toEqual([]) + }) +}) diff --git a/scripts/__tests__/vitest-shared-alias.test.mjs b/scripts/__tests__/vitest-shared-alias.test.mjs new file mode 100644 index 000000000..771d68f8a --- /dev/null +++ b/scripts/__tests__/vitest-shared-alias.test.mjs @@ -0,0 +1,34 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { sharedAlias, stackSourceAlias } from '../../vitest.shared.ts' + +// A vite alias pointing at a file that no longer exists fails LATE and badly: +// nothing validates the map, so the entry survives a package refactor and the +// next test to use that specifier gets a "failed to resolve import" on a path +// the author never wrote, instead of the honest "subpath is not exported". +// `@cipherstash/stack-drizzle/v3` sat here for exactly that reason after the +// `./v3` collapse. + +/** Directory aliases are written with a trailing slash (`'@/'`). */ +const targets = (map) => + Object.entries(map).map(([specifier, target]) => [ + specifier, + target.endsWith('/') ? target.slice(0, -1) : target, + ]) + +describe('vitest.shared.ts alias targets exist on disk', () => { + it('exports both maps, non-empty (guards a silently-renamed export)', () => { + expect(Object.keys(sharedAlias).length).toBeGreaterThan(5) + expect(Object.keys(stackSourceAlias).length).toBeGreaterThan(0) + }) + + it.each(targets(sharedAlias))('sharedAlias %s', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) + + it.each( + targets(stackSourceAlias), + )('stackSourceAlias %s', (_specifier, target) => { + expect(existsSync(target), `missing alias target: ${target}`).toBe(true) + }) +}) diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 5b440c8f0..a8331d1f0 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -188,7 +188,7 @@ The SDK never logs plaintext data. | `@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-drizzle` | Drizzle ORM integration for EQL v3 schemas — the package root, EQL v3 only (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` — encrypt/write is **EQL v3 only** (`types.*`); decrypt still reads existing v2 items. See the `stash-dynamodb` skill | @@ -993,7 +993,7 @@ Useful when the backfill needs to run in a worker, on a schedule, or alongside a | Target | Package / entry point | Skill | |---|---|---| -| Drizzle ORM | `@cipherstash/stack-drizzle/v3` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | +| Drizzle ORM | `@cipherstash/stack-drizzle` — v3 column factories (each `types.*` factory emits its domain as the column's SQL type for `drizzle-kit generate`), schema extraction, auto-encrypting operators (`ops.eq`, `ops.matches`, `ops.contains`, `ops.selector`, `ops.asc`, ...) | `stash-drizzle` | | Supabase | `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` — encrypt is **EQL v3 only**; decrypt still reads existing v2 items | `stash-dynamodb` | @@ -1042,6 +1042,6 @@ const users = encryptedTable("users", { const client = await Encryption({ schemas: [users] }) ``` -The scalar v2 API — `Encryption` plus the `@cipherstash/stack/schema` builders, the v2 Drizzle/Supabase integrations (`@cipherstash/stack-drizzle`, `encryptedSupabase`), and `stash eql install --eql-version 2` — still exists for existing deployments. Legacy v2 `searchableJson()` is the exception: protect-ffi 0.30 cannot emit the removed selector envelope, so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) +The scalar v2 API — `Encryption` plus the `@cipherstash/stack/schema` builders, the v2 Supabase integration (`encryptedSupabase`), and `stash eql install --eql-version 2` — still exists for existing deployments, with two carve-outs. **Drizzle has no v2 surface at all**: `@cipherstash/stack-drizzle` dropped `encryptedType` and the `like`/`ilike` operators, so a v2 Drizzle column is read-only — decrypt it through `@cipherstash/stack` rather than through the adapter. **Legacy v2 `searchableJson()`** cannot be emitted by protect-ffi 0.30 (the selector envelope was removed), so migrate those columns to v3 `types.Json`. Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs). Remember: v2 and v3 tables cannot be mixed in one client. (If you are migrating code from the old `@cipherstash/protect` package, its `protect`/`csTable`/`csColumn` names map onto this v2 surface.) > **DynamoDB.** The DynamoDB integration (`encryptedDynamoDB` from `@cipherstash/stack/dynamodb`) now **encrypts EQL v3 only** — author tables with `types.*` from `@cipherstash/stack/eql/v3`. Its decrypt methods still accept a v2 table so previously stored v2 items remain readable. See the `stash-dynamodb` skill. diff --git a/vitest.shared.ts b/vitest.shared.ts index cbf78fd5b..56fee9202 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -78,11 +78,8 @@ export const sharedAlias: Record = { 'packages/stack-supabase/src/index.ts', ), // The Drizzle adapter package (was `@cipherstash/stack/drizzle` + - // `@cipherstash/stack/eql/v3/drizzle`). `/v3` first — longest prefix wins. - '@cipherstash/stack-drizzle/v3': resolve( - repoRoot, - 'packages/stack-drizzle/src/v3/index.ts', - ), + // `@cipherstash/stack/eql/v3/drizzle`). Root only: the `./v3` subpath + // collapsed into it, so there is no longer a longer prefix to order first. '@cipherstash/stack-drizzle': resolve( repoRoot, 'packages/stack-drizzle/src/index.ts', From f5548a1a6690629a6e37e9ebeafa7567dfa288bb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 17:44:14 +1000 Subject: [PATCH 22/25] test(cli): retarget the codegen drizzle assertions at the collapsed root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs against the PR MERGE commit, so it saw a file this branch never had: `remove-v2` gained `utils-codegen.test.ts` (from the v3 domain-picker work) after this branch was cut, and it pins the generated `stash init` client to `extractEncryptionSchemaV3` / `@cipherstash/stack-drizzle/v3` — exactly the two names this PR removes. Textually the merge is clean; the conflict is semantic, which is why it only surfaced in CI. Points those assertions at the collapsed root and adds the matching negatives, so a regression to the `./v3` specifier fails here rather than in a scaffolded project. Narrowed `utils-codegen-drizzle.test.ts` to what the merged-in suite does not cover — `generatePlaceholderClient`, still untested — and updated it to the new `ColumnDef` shape (`domain`, not `dataType`/`searchOps`). --- .../__tests__/utils-codegen-drizzle.test.ts | 35 ++++++++++--------- .../init/__tests__/utils-codegen.test.ts | 11 ++++-- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts index 50acc3682..d30246f14 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen-drizzle.test.ts @@ -8,21 +8,22 @@ import { // `stash init --drizzle` writes these strings into the USER's repo as real // source. Nothing type-checks a template literal, so a rename in // `@cipherstash/stack-drizzle` cannot break the build here — it breaks the -// scaffolded project instead, in someone else's checkout. These assertions are -// the only thing standing between a de-suffixed export and a broken `stash -// init`. +// scaffolded project instead, in someone else's checkout. // -// Pinned by this suite (all three removed when `./v3` collapsed into the root): -// - the `@cipherstash/stack-drizzle/v3` specifier -// - `extractEncryptionSchemaV3` -// - `createEncryptionOperatorsV3` +// `utils-codegen.test.ts` covers the generated client's happy path. This file +// covers the surface that had no test at all — `generatePlaceholderClient`, +// whose doc block is a copy-paste reference the handoff agent works from — and +// asserts, for BOTH strings, that nothing removed with EQL v2 survives: +// - the `@cipherstash/stack-drizzle/v3` specifier (`./v3` collapsed into `.`) +// - `extractEncryptionSchemaV3` / `createEncryptionOperatorsV3` +// - `encryptedType`, the v2 config-flag column factory const SCHEMAS: SchemaDef[] = [ { tableName: 'users', columns: [ - { name: 'email', dataType: 'string', searchOps: ['equality'] }, - { name: 'age', dataType: 'number', searchOps: ['orderAndRange'] }, + { name: 'email', domain: 'TextSearch' }, + { name: 'age', domain: 'IntegerOrd' }, ], }, ] @@ -36,9 +37,9 @@ const GENERATED_SOURCES: Array<[string, string]> = [ describe.each( GENERATED_SOURCES, )('drizzle scaffold — %s', (_name, generated) => { - it('imports the drizzle surface from the package ROOT, never the removed ./v3 subpath', () => { - expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') + it('names the drizzle package root, never the removed ./v3 subpath', () => { expect(generated).toContain('@cipherstash/stack-drizzle') + expect(generated).not.toContain('@cipherstash/stack-drizzle/v3') }) it('uses the de-suffixed export names, never the removed *V3 aliases', () => { @@ -52,13 +53,13 @@ describe.each( }) }) -describe('generateClientFromSchemas (drizzle)', () => { - const generated = generateClientFromSchemas('drizzle', SCHEMAS) +describe('generatePlaceholderClient (drizzle)', () => { + const placeholder = generatePlaceholderClient('drizzle') - it('names extractEncryptionSchema in both the import and the call site', () => { - expect(generated).toContain( - "import { types, extractEncryptionSchema } from '@cipherstash/stack-drizzle'", + it('shows the harvest pattern with the collapsed root import', () => { + expect(placeholder).toContain( + "import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'", ) - expect(generated).toContain('extractEncryptionSchema(usersTable)') + expect(placeholder).toContain('extractEncryptionSchema(users)') }) }) diff --git a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts index 1221065d5..5aabc73d4 100644 --- a/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils-codegen.test.ts @@ -27,8 +27,15 @@ describe('generateClientFromSchemas', () => { const out = generateClientFromSchemas('drizzle', schemas) expect(out).toContain("email: types.TextSearch('email'),") expect(out).toContain("age: types.IntegerOrd('age'),") - expect(out).toContain('extractEncryptionSchemaV3') - expect(out).toContain("from '@cipherstash/stack-drizzle/v3'") + // The collapsed root: `@cipherstash/stack-drizzle` dropped its EQL v2 + // surface and folded `./v3` into `.`, de-suffixing the exports. The + // negatives matter as much as the positives — this string is written into + // the user's repo as real source, and nothing type-checks a template + // literal, so the removed names can only be caught here. + expect(out).toContain('extractEncryptionSchema(') + expect(out).toContain("from '@cipherstash/stack-drizzle'") + expect(out).not.toContain('extractEncryptionSchemaV3') + expect(out).not.toContain('@cipherstash/stack-drizzle/v3') }) it('carries no residual v2 capability vocabulary', () => { From f928ff184aeae4131c3311d1ae17beeced83e06e Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 22 Jul 2026 21:57:02 +1000 Subject: [PATCH 23/25] feat(wizard): port migration rewriter to the EQL v3 domain family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard now scaffolds EQL v3 columns, so `drizzle-kit generate` emits `ALTER COLUMN ... SET DATA TYPE eql_v3_` — which Postgres rejects (no cast from text/numeric to an EQL domain). The post-agent rewriter matched only the single `eql_v2_encrypted` type, so those v3 statements slipped through unrepaired and failed at migrate time. Port the rewriter to the whole `eql_v3_*` concrete-domain family alongside legacy `eql_v2_encrypted`, mirroring the sibling CLI fix (#693): every mangled form drizzle-kit emits (incl. the 0.31.0+ `"undefined".` prefix and schema-qualified pgSchema tables), near-miss flagging for `SET DATA TYPE ... USING ...` it cannot safely repair, statement-breakpoints, and a clearer data-destroying / empty-table-only warning that points populated tables at the staged `stash encrypt` flow. Database introspection (`isEqlEncrypted`) now recognises BOTH `eql_v2_encrypted` and the `eql_v3_*` family as already-encrypted, matching migrate's `classifyEqlDomain` v3 convention — so the agent won't scaffold over existing encrypted data of either generation (v2 ciphertext stays valid and detected). Add a rewrite-migrations test suite (adapted from the CLI's). --- .changeset/wizard-eql-v3-migration-rewrite.md | 27 ++ .../src/__tests__/rewrite-migrations.test.ts | 399 ++++++++++++++++++ packages/wizard/src/lib/post-agent.ts | 16 +- packages/wizard/src/lib/rewrite-migrations.ts | 251 ++++++++--- packages/wizard/src/tools/wizard-tools.ts | 21 +- 5 files changed, 661 insertions(+), 53 deletions(-) create mode 100644 .changeset/wizard-eql-v3-migration-rewrite.md create mode 100644 packages/wizard/src/__tests__/rewrite-migrations.test.ts diff --git a/.changeset/wizard-eql-v3-migration-rewrite.md b/.changeset/wizard-eql-v3-migration-rewrite.md new file mode 100644 index 000000000..4e9ae5861 --- /dev/null +++ b/.changeset/wizard-eql-v3-migration-rewrite.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/wizard': minor +--- + +Teach the wizard's post-agent Drizzle step to repair EQL **v3** migrations, not +just legacy EQL v2. + +The wizard now scaffolds EQL v3 columns, so `drizzle-kit generate` emits +`ALTER TABLE … ALTER COLUMN … SET DATA TYPE eql_v3_` — which Postgres +rejects (there is no cast from `text`/`numeric` to an EQL domain). The migration +rewriter previously matched only the single `eql_v2_encrypted` type, so those v3 +statements slipped through unrepaired and failed at migrate time. + +The rewriter is ported to match the whole EQL v3 concrete-domain family +(`eql_v3_text_search`, `eql_v3_integer_ord`, …) alongside legacy +`eql_v2_encrypted`, across every mangled form drizzle-kit emits (including the +`"undefined".` prefix from 0.31.0+ and schema-qualified `pgSchema()` tables). It +now also flags near-miss `SET DATA TYPE … USING …` statements it cannot safely +repair instead of leaving broken SQL, and each rewritten file carries a clearer +warning that the ADD+DROP+RENAME is data-destroying and safe only on an empty +table — a populated table must use the staged `stash encrypt` flow. This +re-converges the rewriter with the sibling copy in the `stash` CLI. + +Database introspection also recognises v3 encrypted columns: `isEqlEncrypted` +now reports both `eql_v2_encrypted` and the `eql_v3_*` family as already +encrypted, so the agent won't scaffold over existing encrypted data of either +generation. diff --git a/packages/wizard/src/__tests__/rewrite-migrations.test.ts b/packages/wizard/src/__tests__/rewrite-migrations.test.ts new file mode 100644 index 000000000..21ae01cd6 --- /dev/null +++ b/packages/wizard/src/__tests__/rewrite-migrations.test.ts @@ -0,0 +1,399 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { rewriteEncryptedAlterColumns } from '../lib/rewrite-migrations.js' + +describe('rewriteEncryptedAlterColumns', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-rewrite-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('rewrites an in-place ALTER COLUMN with the bare v2 type name', async () => { + const original = `ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE eql_v2_encrypted;\n` + const filePath = path.join(tmpDir, '0002_alter.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).toContain( + 'ALTER TABLE "transactions" DROP COLUMN "amount";', + ) + expect(updated).toContain( + 'ALTER TABLE "transactions" RENAME COLUMN "amount__cipherstash_tmp" TO "amount";', + ) + expect(updated).not.toContain('SET DATA TYPE') + // Wizard-branded header. + expect(updated).toContain('-- Rewritten by @cipherstash/wizard') + }) + + it('rewrites a bare v3 domain (the generation the wizard now scaffolds)', async () => { + const original = `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n` + const filePath = path.join(tmpDir, '0002_v3.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the schema-qualified form produced by drizzle-kit', async () => { + const original = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v3_text_search";\n' + const filePath = path.join(tmpDir, '0003_alter.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites a schema-qualified table produced by pgSchema()', async () => { + // drizzle-kit emits `"app"."users"` for a table declared in a pgSchema(); + // the old `\s+` between the table and ALTER COLUMN could never cross the `.`. + const original = + 'ALTER TABLE "app"."users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_text_search";\n' + const filePath = path.join(tmpDir, '0014_qualified.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + // Every emitted statement keeps the schema qualifier. + expect(updated).toContain( + 'ALTER TABLE "app"."users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).toContain('ALTER TABLE "app"."users" DROP COLUMN "email";') + expect(updated).toContain( + 'ALTER TABLE "app"."users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the "undefined" schema form drizzle-kit emits for bare custom types', async () => { + const original = + 'ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE "undefined"."eql_v2_encrypted";\n' + const filePath = path.join(tmpDir, '0005_undef.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('rewrites the double-quoted form produced by stack 0.15.0', async () => { + const original = + 'ALTER TABLE "transactions" ALTER COLUMN "description" SET DATA TYPE "undefined".""public"."eql_v2_encrypted"";\n' + const filePath = path.join(tmpDir, '0006_double.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "transactions" ADD COLUMN "description__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('leaves unrelated migrations untouched', async () => { + const original = + 'CREATE TABLE "widgets" ("id" integer PRIMARY KEY, "name" text);\n' + const filePath = path.join(tmpDir, '0001_init.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('skips the file passed in options.skip', async () => { + const install = path.join(tmpDir, '0000_install-eql.sql') + const alter = path.join(tmpDir, '0002_alter.sql') + fs.writeFileSync(install, 'CREATE SCHEMA eql_v2;\n') + fs.writeFileSync( + alter, + 'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v2_encrypted;', + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir, { + skip: install, + }) + expect(rewritten).toEqual([alter]) + expect(fs.readFileSync(install, 'utf-8')).toBe('CREATE SCHEMA eql_v2;\n') + }) + + it('returns an empty result when the directory does not exist', async () => { + const missing = path.join(tmpDir, 'does-not-exist') + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(missing) + expect(rewritten).toEqual([]) + expect(skipped).toEqual([]) + }) + + // Every concrete `eql_v3_*` domain shipped by `@cipherstash/stack/eql/v3` + // (see `packages/stack/src/eql/v3/columns.ts`). Eight scalar bases carry the + // four storage/eq/ord flavours; text adds `_match`/`_search`; boolean and json + // stand alone. + const V3_SCALAR_BASES = [ + 'integer', + 'smallint', + 'bigint', + 'numeric', + 'real', + 'double', + 'date', + 'timestamp', + ] + const V3_DOMAINS = [ + ...V3_SCALAR_BASES.flatMap((base) => + ['', '_eq', '_ord', '_ord_ore'].map( + (flavour) => `eql_v3_${base}${flavour}`, + ), + ), + 'eql_v3_text', + 'eql_v3_text_eq', + 'eql_v3_text_match', + 'eql_v3_text_ord', + 'eql_v3_text_ord_ore', + 'eql_v3_text_search', + 'eql_v3_boolean', + 'eql_v3_json', + ] + + it.each(V3_DOMAINS)('rewrites an ALTER COLUMN to %s', async (domain) => { + const filePath = path.join(tmpDir, '0007_v3.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."${domain}";\n`, + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + `ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."${domain}";`, + ) + expect(updated).toContain('ALTER TABLE "users" DROP COLUMN "email";') + expect(updated).toContain( + 'ALTER TABLE "users" RENAME COLUMN "email__cipherstash_tmp" TO "email";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + // DOMAIN_RE is derived from ENCRYPTED_DOMAIN, so drift between the two can't + // silently leave a domain unrewritten. Prove every domain the alternation + // recognises is actually extracted into the emitted ADD COLUMN. + it.each([ + ...V3_DOMAINS, + 'eql_v2_encrypted', + ])('extracts the bare domain %s from a mangled ALTER', async (domain) => { + const filePath = path.join(tmpDir, '0015_drift.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE "undefined"."${domain}";\n`, + ) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + `ALTER TABLE "t" ADD COLUMN "c__cipherstash_tmp" "public"."${domain}";`, + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + // The mangled forms are the cross product of what `dataType()` returns and + // which drizzle-kit era renders it (see the file's comment table). + const MANGLED_FORMS: Array<[label: string, emitted: string]> = [ + ['plain, drizzle-kit <=0.30.6', 'eql_v3_text_search'], + [ + '"undefined"-prefixed, drizzle-kit >=0.31.0', + '"undefined"."eql_v3_text_search"', + ], + ['dotted, drizzle-kit <=0.30.6', 'public.eql_v3_text_search'], + [ + 'dotted inside "undefined", drizzle-kit >=0.31.0', + '"undefined"."public.eql_v3_text_search"', + ], + ['pre-quoted, drizzle-kit <=0.30.6', '"public"."eql_v3_text_search"'], + [ + 'pre-quoted inside "undefined", drizzle-kit >=0.31.0', + '"undefined".""public"."eql_v3_text_search""', + ], + ['bare-quoted (speculative)', '"eql_v3_text_search"'], + ] + + it.each(MANGLED_FORMS)('rewrites the v3 %s form', async (_label, emitted) => { + const filePath = path.join(tmpDir, '0008_form.sql') + fs.writeFileSync( + filePath, + `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${emitted};\n`, + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v3_text_search";', + ) + expect(updated).not.toContain('SET DATA TYPE') + }) + + it('names the target domain in the guidance comment', async () => { + const filePath = path.join(tmpDir, '0010_comment.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "undefined"."eql_v3_integer_ord";\n', + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain('-- eql_v3_integer_ord.') + }) + + it('warns that the rewrite is data-destroying / empty-table-only', async () => { + const filePath = path.join(tmpDir, '0016_warn.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain('safe ONLY if') + expect(updated).toContain('constraints, defaults, and indexes') + expect(updated).toContain('stash encrypt') + }) + + it('separates ADD/DROP/RENAME with --> statement-breakpoint', async () => { + const filePath = path.join(tmpDir, '0018_breakpoint.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8').trimEnd() + const chunks = updated.split('--> statement-breakpoint') + expect(chunks).toHaveLength(3) + expect(chunks[0]).toContain('ADD COLUMN') + expect(chunks[1]).toContain('DROP COLUMN') + expect(chunks[2]).toContain('RENAME COLUMN') + }) + + it('rewrites each statement to its own domain when v2 and v3 are mixed', async () => { + const filePath = path.join(tmpDir, '0011_mixed.sql') + fs.writeFileSync( + filePath, + [ + 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;', + 'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE "undefined"."eql_v3_json";', + ].join('\n'), + ) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated).toContain( + 'ALTER TABLE "a" ADD COLUMN "x__cipherstash_tmp" "public"."eql_v2_encrypted";', + ) + expect(updated).toContain( + 'ALTER TABLE "a" ADD COLUMN "y__cipherstash_tmp" "public"."eql_v3_json";', + ) + }) + + it.each([ + ['a plaintext type', 'text'], + ['jsonb', 'jsonb'], + ['a lookalike from another EQL major', 'eql_v4_text_search'], + ['a lookalike prefix', 'not_eql_v3_text_search'], + ])('leaves an ALTER COLUMN to %s untouched', async (_label, type) => { + const original = `ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE ${type};\n` + const filePath = path.join(tmpDir, '0012_other.sql') + fs.writeFileSync(filePath, original) + + const { rewritten } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('leaves a hand-authored SET DATA TYPE ... USING conversion untouched but flags it', async () => { + const original = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;\n' + const filePath = path.join(tmpDir, '0013_using.sql') + fs.writeFileSync(filePath, original) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([]) + expect(skipped).toHaveLength(1) + expect(skipped[0].file).toBe(filePath) + expect(skipped[0].statement).toContain('SET DATA TYPE') + expect(skipped[0].statement).toContain('eql_v3_text_search') + // Left untouched on disk — we flag, we don't guess. + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) + }) + + it('reports no skipped statements when the strict rewrite fully handled the file', async () => { + const filePath = path.join(tmpDir, '0021_handled.sql') + fs.writeFileSync( + filePath, + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n', + ) + + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(rewritten).toEqual([filePath]) + expect(skipped).toEqual([]) + }) + + it('handles multiple ALTER statements in one file', async () => { + const original = [ + 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v3_text_search;', + 'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE eql_v3_text_search;', + 'CREATE INDEX "a_z" ON "a" ("z");', + ].join('\n') + const filePath = path.join(tmpDir, '0004_multi.sql') + fs.writeFileSync(filePath, original) + + await rewriteEncryptedAlterColumns(tmpDir) + + const updated = fs.readFileSync(filePath, 'utf-8') + expect(updated.match(/ADD COLUMN/g)?.length).toBe(2) + expect(updated.match(/DROP COLUMN/g)?.length).toBe(2) + // Non-matching statement preserved + expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");') + }) +}) diff --git a/packages/wizard/src/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index 34c18f2a7..24f8bb918 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -76,8 +76,10 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { cwd, ) - // Rewrite any `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` that - // drizzle-kit just produced — those fail in Postgres. CIP-2991 + CIP-2994. + // Rewrite any `ALTER COLUMN ... SET DATA TYPE ` that + // drizzle-kit just produced — those fail in Postgres (no cast from + // text/numeric to an EQL domain). Covers the EQL v3 family the wizard now + // scaffolds, and legacy eql_v2_encrypted. CIP-2991 + CIP-2994 + #693. await rewriteEncryptedMigrations(cwd) const shouldMigrate = await p.confirm({ @@ -118,16 +120,22 @@ async function rewriteEncryptedMigrations(cwd: string): Promise { if (!existsSync(abs)) continue try { - const rewritten = await rewriteEncryptedAlterColumns(abs) + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(abs) if (rewritten.length > 0) { p.log.info( `Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`, ) for (const file of rewritten) p.log.step(` - ${file}`) p.log.warn( - 'If any of these tables already have rows, backfill the new column via @cipherstash/stack before running the migration in production. See the comments in the rewritten SQL.', + 'This rewrite is data-destroying — safe only on an EMPTY table. If any of these tables already have rows, do NOT run the migration; use the staged `stash encrypt` flow (add -> backfill via @cipherstash/stack -> cutover -> drop) instead. See the comments in the rewritten SQL.', ) } + if (skipped.length > 0) { + p.log.warn( + `${skipped.length} statement(s) look like an ALTER-to-encrypted the rewrite could not safely repair (e.g. a hand-authored SET DATA TYPE ... USING ...). Review them before migrating:`, + ) + for (const s of skipped) p.log.step(` - ${s.file}: ${s.statement}`) + } // Only rewrite the first dir that matches — running again on a // different candidate would double-transform already-rewritten SQL. return diff --git a/packages/wizard/src/lib/rewrite-migrations.ts b/packages/wizard/src/lib/rewrite-migrations.ts index 24635f10e..bbe2a754f 100644 --- a/packages/wizard/src/lib/rewrite-migrations.ts +++ b/packages/wizard/src/lib/rewrite-migrations.ts @@ -2,59 +2,158 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' /** - * Matches drizzle-kit's generated in-place type change to the encrypted - * column type. drizzle-kit's ALTER COLUMN path wraps the customType - * `dataType()` return value in double-quotes and prepends `"{typeSchema}".`. - * Custom types have no `typeSchema`, so we see several mangled forms - * depending on what `dataType()` returned. We match all of them: + * The encrypted column types this rewrite applies to: the single EQL v2 type, + * and the whole EQL v3 concrete-domain family (`eql_v3_text_search`, + * `eql_v3_integer_ord`, …). The v3 side is matched by shape rather than by an + * enumerated list so a newly added domain is covered without touching this file + * — the `eql_v3_` prefix is unambiguous in a `SET DATA TYPE` position. * - * - bare `eql_v2_encrypted` → `"undefined"."eql_v2_encrypted"` - * - pre-quoted `"public"."eql_v2_encrypted"` (stack 0.15.0 regression) → - * `"undefined".""public"."eql_v2_encrypted""` - * - the plain `eql_v2_encrypted` and `"public"."eql_v2_encrypted"` forms, - * in case a future drizzle-kit release stops prepending undefined. + * v2 stays matched even though the wizard now scaffolds v3 exclusively: a user's + * migration directory can hold files generated against an older stack version, + * and existing v2 ciphertext stays valid — we still want their ALTER-to-encrypted + * statements repaired. + */ +const ENCRYPTED_DOMAIN = String.raw`eql_v2_encrypted|eql_v3_[a-z0-9_]+` + +/** + * Extracts the bare domain name out of whichever mangled form matched. Derived + * from {@link ENCRYPTED_DOMAIN} so the two can never drift out of sync — a new + * domain added to the alternation is extracted here for free. + */ +const DOMAIN_RE = new RegExp(ENCRYPTED_DOMAIN, 'i') + +/** + * The mangled forms drizzle-kit emits for a customType in an ALTER COLUMN. + * + * drizzle-kit's ALTER COLUMN path wraps the customType `dataType()` return + * value in double-quotes and prepends `"{typeSchema}".`. Custom types have no + * `typeSchema`, so the emitted SQL depends on BOTH what `dataType()` returned + * and which drizzle-kit renders it. Verified against 0.24.2, 0.28.1, 0.30.6, + * 0.31.0, 0.31.1 and 0.31.10 through `drizzle-kit/api`'s `generateMigration`: + * + * | `dataType()` returns | <= 0.30.6 | >= 0.31.0 | + * | ------------------------------- | --------------------------- | ---------------------------------- | + * | `eql_v3_text_search` (current) | `eql_v3_text_search` | `"undefined"."eql_v3_text_search"` | + * | `public.eql_v3_text_search` | `public.eql_v3_text_search` | `"undefined"."public.eql_v3_…"` | + * | `"public"."eql_v2_encrypted"` | `"public"."eql_v2_…"` | `"undefined".""public"."eql_v2_…"` | + * + * The `"undefined".` prefix is a **0.31.0-and-later** behaviour — 0.30.6 and + * earlier emit the plain form. All six forms stay matched because a user's + * migration directory can hold files generated by any drizzle-kit version + * against any stack version — including the qualified `public.eql_v3_*` that + * stack emitted before the unqualified fix. + * + * Ordered longest-first so the alternation cannot match a prefix of a longer + * form and leave a trailing fragment behind. + * + * NOTE: this file is the sibling of `packages/cli/src/commands/db/rewrite-migrations.ts`, + * which cli's `stash eql migration --drizzle` uses. Both are tightly coupled to + * drizzle-kit's output format — if drizzle-kit changes, both need updating + * together. Keep them in sync. + */ +const MANGLED_TYPE_FORMS = [ + String.raw`"undefined"\.""public"\."(?:${ENCRYPTED_DOMAIN})""`, + String.raw`"undefined"\."public\.(?:${ENCRYPTED_DOMAIN})"`, + String.raw`"undefined"\."(?:${ENCRYPTED_DOMAIN})"`, + String.raw`"public"\."(?:${ENCRYPTED_DOMAIN})"`, + String.raw`public\.(?:${ENCRYPTED_DOMAIN})`, + // Not emitted by any released drizzle-kit ALTER path, but it is the shape the + // CREATE TABLE path renders — guard it in case ALTER converges on it. + String.raw`"(?:${ENCRYPTED_DOMAIN})"`, + String.raw`(?:${ENCRYPTED_DOMAIN})`, +].join('|') + +/** + * Matches drizzle-kit's generated in-place type change to an encrypted column + * type, in any of the forms above. * * Captures: - * - $1: table name (without quotes) - * - $2: column name (without quotes) + * - $1: table name, OR the schema when a qualifier follows (both without quotes) + * - $2: table name when schema-qualified (`"app"."users"`), else undefined + * - $3: column name (without quotes) + * - $4: the mangled type blob — feed it to {@link DOMAIN_RE} for the bare name * - * Note: a copy of this lives in `stash` (`db/rewrite-migrations.ts`) - * because cli's `eql install --drizzle` uses the same fix. Both copies are - * tightly coupled to drizzle-kit's output format — if drizzle-kit changes, - * both need to be updated together. + * The optional `(?:\."([^"]+)")?` after the first quoted name matches the + * schema qualifier drizzle-kit emits for a `pgSchema()` table (`"app"."users"`). + */ +const ALTER_COLUMN_TO_ENCRYPTED_RE = new RegExp( + // `\s*;` — not `[^;]*;` — so the type blob must run straight into the + // statement terminator. drizzle-kit emits exactly that (no trailing clause), + // and the tighter tail means a HAND-AUTHORED `SET DATA TYPE … USING ;` + // is left untouched instead of being silently rewritten (and its USING + // discarded). + String.raw`ALTER TABLE "([^"]+)"(?:\."([^"]+)")?\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (${MANGLED_TYPE_FORMS})\s*;`, + 'gi', +) + +/** + * A deliberately BROAD scan for statements that look like an in-place change to + * an encrypted column but that the strict {@link ALTER_COLUMN_TO_ENCRYPTED_RE} + * did not rewrite — a hand-authored `SET DATA TYPE … USING …;`, or some future + * drizzle-kit form the strict matcher doesn't yet cover. * - * The copies have DIVERGED as of #693: the `stash` one also matches the EQL v3 - * domain family (`eql_v3_*`) and two further mangled forms, and documents which - * drizzle-kit versions emit which. This copy stays v2-only because the wizard - * only ever scaffolds v2 columns. Port the v3 handling here if that changes. + * It matches any single (`;`-terminated) statement that contains both + * `SET DATA TYPE` and an `eql_v2`/`eql_v3` domain token at a word boundary (so + * lookalikes like `eql_v4_…` or `not_eql_v3_…` don't trip it). Run AFTER the + * strict rewrite so genuinely-rewritten statements — which no longer contain + * `SET DATA TYPE` — are already gone; whatever this still finds is a near-miss + * we flag for human review rather than silently ship as broken SQL. */ -const ALTER_COLUMN_TO_ENCRYPTED_RE = - /ALTER TABLE "([^"]+)"\s+ALTER COLUMN "([^"]+)"\s+SET DATA TYPE (?:"undefined"\.""public"\."eql_v2_encrypted""|"undefined"\."eql_v2_encrypted"|"public"\."eql_v2_encrypted"|eql_v2_encrypted)[^;]*;/gi +const NEAR_MISS_RE = + /[^;]*?\bSET\s+DATA\s+TYPE\b[^;]*?\beql_v[23][a-z0-9_]*[^;]*?;/gi + +/** A statement the sweep recognised as ALTER-to-encrypted but did NOT rewrite. */ +export interface SkippedAlter { + /** Absolute path of the migration file the statement lives in. */ + file: string + /** The offending statement, verbatim (trimmed), for the user to review. */ + statement: string +} + +/** Outcome of a sweep: the files rewritten, and near-misses left for review. */ +export interface RewriteResult { + /** Absolute paths of files whose unsafe ALTER COLUMN(s) were rewritten. */ + rewritten: string[] + /** Near-miss statements the strict matcher passed over — flag, don't guess. */ + skipped: SkippedAlter[] +} /** - * Replace in-place `ALTER COLUMN ... SET DATA TYPE eql_v2_encrypted` statements - * with an ADD + DROP + RENAME sequence. + * Replace in-place `ALTER COLUMN ... SET DATA TYPE ` + * statements with an ADD + DROP + RENAME sequence. * - * **Why this exists (CIP-2991, CIP-2994):** Postgres has no implicit cast from - * `text`/`numeric` to `eql_v2_encrypted`, so `ALTER COLUMN ... SET DATA TYPE - * eql_v2_encrypted` fails with `cannot cast type ... to eql_v2_encrypted`. - * The fix that works on both empty and non-empty tables is to add a new - * encrypted column, backfill it, drop the original, and rename the new - * column into place. For empty tables the UPDATE is a no-op and the - * sequence is effectively equivalent to DROP+ADD. + * **Why this exists (CIP-2991, CIP-2994, #693):** Postgres has no implicit cast + * from `text`/`numeric` to an encrypted domain, so `ALTER COLUMN ... SET DATA + * TYPE eql_v2_encrypted` (or any `eql_v3_*` domain) fails at migrate time with + * `cannot cast type ... to `. This applies equally to the single EQL v2 + * type and the whole EQL v3 concrete-domain family. * - * We only rewrite the statement — the actual encryption of existing rows has - * to happen in application code (via `encryptModel` from - * `@cipherstash/stack`), which is why the UPDATE is emitted as a guidance - * comment rather than real SQL. Running this migration against a populated - * table leaves the new column NULL until the app backfills it. + * The rewrite is an ADD+DROP+RENAME, which is **equivalent to DROP+ADD**: it + * makes the column type valid but does NOT preserve the column's data. It is + * therefore safe ONLY on an EMPTY table. On a populated table the new column + * starts NULL and the original is dropped in the same migration, so the + * plaintext is destroyed. The commented UPDATE is a placeholder that can never + * become real SQL (the encrypted value is the EQL envelope produced by ZeroKMS + * via the client — there is no expression Postgres can evaluate to fill it), so + * a populated table must instead use the staged `stash encrypt` lifecycle + * (add → backfill via `@cipherstash/stack`'s `encryptModel` → cutover → drop), + * which keeps both columns alive across deploys. Each rewritten file carries a + * header comment saying exactly this. + * + * Returns {@link RewriteResult}: the files rewritten, plus `skipped` near-misses + * — statements that look like an ALTER-to-encrypted but fall outside the strict + * matcher (a hand-authored `SET DATA TYPE … USING …;`, or a future drizzle-kit + * form). Near-misses are left untouched on disk and surfaced non-fatally so the + * caller can tell the user to review them, rather than silently shipping broken + * SQL. */ export async function rewriteEncryptedAlterColumns( outDir: string, options: { skip?: string } = {}, -): Promise { +): Promise { const entries = await readdir(outDir).catch(() => []) const rewritten: string[] = [] + const skipped: SkippedAlter[] = [] for (const entry of entries) { if (!entry.endsWith('.sql')) continue @@ -62,35 +161,91 @@ export async function rewriteEncryptedAlterColumns( if (options.skip && filePath === options.skip) continue const original = await readFile(filePath, 'utf-8') - if (!ALTER_COLUMN_TO_ENCRYPTED_RE.test(original)) continue // Reset the regex's lastIndex — it's stateful on /g ALTER_COLUMN_TO_ENCRYPTED_RE.lastIndex = 0 const updated = original.replace( ALTER_COLUMN_TO_ENCRYPTED_RE, - (_match, table: string, column: string) => renderSafeAlter(table, column), + ( + match: string, + first: string, + second: string | undefined, + column: string, + mangledType: string, + ) => { + // When schema-qualified (`"app"."users"`) the first capture is the + // schema and the second is the table; otherwise the first is the table. + const schema = second === undefined ? undefined : first + const table = second === undefined ? first : second + const domain = DOMAIN_RE.exec(mangledType)?.[0]?.toLowerCase() + // Unreachable — the outer regex only matches when a domain is present — + // but leave the statement alone rather than emit a broken rewrite. + if (!domain) return match + return renderSafeAlter(table, column, domain, schema) + }, ) if (updated !== original) { await writeFile(filePath, updated, 'utf-8') rewritten.push(filePath) } + + // Broad secondary scan on the POST-rewrite content: anything still carrying + // `SET DATA TYPE` near an eql_v2/eql_v3 token slipped past the strict + // matcher. Flag it — non-fatally — rather than leave the user shipping SQL + // that fails at migrate time. + for (const nearMiss of updated.matchAll(NEAR_MISS_RE)) { + skipped.push({ file: filePath, statement: nearMiss[0].trim() }) + } } - return rewritten + return { rewritten, skipped } } -function renderSafeAlter(table: string, column: string): string { +/** + * The rewrite is identical for v2 and v3, and the ADD+DROP+RENAME sequence is + * equivalent to DROP+ADD: it makes the column type valid but does NOT preserve + * the column's data. It is therefore safe only on an EMPTY table. On a populated + * table the new column starts NULL and the old one is dropped in the same + * migration, so the plaintext is destroyed. + * + * The commented UPDATE is only a placeholder — it can never become real SQL. The + * encrypted value is the EQL envelope produced by ZeroKMS via the client + * (`encryptModel` / `bulkEncryptModels`); there is no expression Postgres can + * evaluate to fill it. (v3 stores that envelope as jsonb rather than v2's + * composite, but this is equally true on both surfaces.) + * + * So the guidance does NOT tell the user to backfill and run this migration — + * that would still lose data on cutover. It points a populated table at the + * staged `stash encrypt` lifecycle (add → backfill → cutover → drop), which + * keeps both columns alive across deploys. + */ +function renderSafeAlter( + table: string, + column: string, + domain: string, + schema?: string, +): string { const tmp = `${column}__cipherstash_tmp` + // Preserve the schema qualifier drizzle-kit emitted for pgSchema() tables so + // the rewritten statements target the same object. + const qualifiedTable = schema ? `"${schema}"."${table}"` : `"${table}"` return [ '-- Rewritten by @cipherstash/wizard: in-place ALTER COLUMN cannot cast to', - `-- eql_v2_encrypted. If "${table}" already has rows, backfill the new`, - "-- column via @cipherstash/stack's encryptModel in application code BEFORE", - '-- running this migration in production. Empty tables are safe as-is.', - `ALTER TABLE "${table}" ADD COLUMN "${tmp}" "public"."eql_v2_encrypted";`, - `-- UPDATE "${table}" SET "${tmp}" = /* encrypted value for ${column} */ NULL;`, - `ALTER TABLE "${table}" DROP COLUMN "${column}";`, - `ALTER TABLE "${table}" RENAME COLUMN "${tmp}" TO "${column}";`, + `-- ${domain}. This ADD+DROP+RENAME equals DROP+ADD and is safe ONLY if`, + `-- ${qualifiedTable} is empty. On a populated table it DESTROYS existing "${column}"`, + '-- data (the new column starts NULL) — do NOT run it there. Use the staged', + "-- `stash encrypt` path instead: add -> backfill via @cipherstash/stack's", + '-- encryptModel in application code -> cutover -> drop.', + '-- NOTE: constraints, defaults, and indexes on the original column are NOT', + '-- carried over by this ADD/DROP/RENAME — re-add any NOT NULL, DEFAULT,', + '-- UNIQUE, or index definitions manually.', + `ALTER TABLE ${qualifiedTable} ADD COLUMN "${tmp}" "public"."${domain}";`, + `-- UPDATE ${qualifiedTable} SET "${tmp}" = /* encrypted value for ${column} */ NULL`, + '--> statement-breakpoint', + `ALTER TABLE ${qualifiedTable} DROP COLUMN "${column}";`, + '--> statement-breakpoint', + `ALTER TABLE ${qualifiedTable} RENAME COLUMN "${tmp}" TO "${column}";`, ].join('\n') } diff --git a/packages/wizard/src/tools/wizard-tools.ts b/packages/wizard/src/tools/wizard-tools.ts index 3a589b7bf..f60673aa5 100644 --- a/packages/wizard/src/tools/wizard-tools.ts +++ b/packages/wizard/src/tools/wizard-tools.ts @@ -149,6 +149,25 @@ interface DbColumn { isEqlEncrypted: boolean } +/** + * Is a Postgres domain-type (`udt_name`) an EQL-encrypted column? + * + * Used purely for DETECTION — telling the agent "this column is already + * encrypted, don't scaffold over it". It therefore recognises BOTH generations: + * the legacy single `eql_v2_encrypted` type AND the EQL v3 concrete-domain + * family (`eql_v3_text_search`, `eql_v3_integer_ord`, …). The wizard now only + * *emits* v3, but a project may already carry v2-encrypted columns whose + * ciphertext stays valid — misreporting them as plaintext would let the agent + * clobber real encrypted data. + * + * The `eql_v3_` prefix (trailing underscore included) matches the convention in + * `@cipherstash/migrate`'s `classifyEqlDomain` — a bare `eql_v3` would also + * claim a hypothetical future major like `eql_v30_*`. + */ +function isEqlEncryptedDomain(udtName: string): boolean { + return udtName === 'eql_v2_encrypted' || udtName.startsWith('eql_v3_') +} + interface DbTable { tableName: string columns: DbColumn[] @@ -183,7 +202,7 @@ export async function introspectDatabase( columnName: row.column_name, dataType: row.data_type, udtName: row.udt_name, - isEqlEncrypted: row.udt_name === 'eql_v2_encrypted', + isEqlEncrypted: isEqlEncryptedDomain(row.udt_name), }) tableMap.set(row.table_name, cols) } From a487beaa16203c6bd5a3773425d377b09efb1cf8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 15:02:25 +1000 Subject: [PATCH 24/25] fix(wizard,cli): sweep every migration dir; trim near-miss preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review feedback on the v3 migration-rewriter port. **Sweep every candidate migration directory.** `rewriteEncryptedMigrations` returned after the FIRST candidate that merely existed, even when that directory contained zero matches — so an empty or already-rewritten `drizzle/` sitting next to a project's real `migrations/` left those migrations unrepaired, and they then failed at migrate time with the very `cannot cast type ...` error the rewriter exists to prevent. The comment justifying the early return ("running again on a different candidate would double-transform already-rewritten SQL") was wrong on both counts: distinct directories hold distinct files, and the rewrite is idempotent anyway — a rewritten statement no longer contains `SET DATA TYPE`, so neither the strict matcher nor the near-miss scan can match it a second time. Extract the sweep into an exported, log-free `sweepMigrationDirs(cwd, dirs)` so it is directly testable without mocking @clack/prompts. It sweeps every existing candidate and reports a directory that throws via `error` rather than stranding the ones after it; post-agent keeps the reporting. **Trim the near-miss statement preamble.** `NEAR_MISS_RE` opens with a lazy `[^;]*?` whose only left boundary is the previous `;` — or the start of file when there is none. The reported statement therefore dragged in every comment and blank line since then, so a near-miss in a file opening with a comment block was quoted back to the user with the whole header glued to its front. Strip leading blank/comment lines (incl. `--> statement-breakpoint`) so the statement reads as the offending statement alone. Detection is unchanged; only the text shown to the user differs. Applied to the `stash` CLI sibling too — it carried the identical defect and its header mandates keeping the two in sync. The directory sweep does not apply there: the CLI takes a single explicit `outDir`. **Document the hardcoded `"public".""`.** Behaviour unchanged and not a regression, but worth stating: the domain qualifier is an assumption (EQL installs into `public`), not something read back from the matched SQL — the `schema` capture is the TABLE's schema and says nothing about where the domain lives. Non-public domain installs would need it threaded in here and in the CLI sibling. Already pinned by the existing pgSchema() test. 9 new tests across the two packages, each watched failing first. --- .changeset/cli-near-miss-statement-trim.md | 15 ++ .changeset/wizard-eql-v3-migration-rewrite.md | 9 + .../src/__tests__/rewrite-migrations.test.ts | 60 +++++++ .../cli/src/commands/db/rewrite-migrations.ts | 26 ++- .../src/__tests__/rewrite-migrations.test.ts | 167 +++++++++++++++++- packages/wizard/src/lib/post-agent.ts | 61 +++---- packages/wizard/src/lib/rewrite-migrations.ts | 89 +++++++++- 7 files changed, 390 insertions(+), 37 deletions(-) create mode 100644 .changeset/cli-near-miss-statement-trim.md diff --git a/.changeset/cli-near-miss-statement-trim.md b/.changeset/cli-near-miss-statement-trim.md new file mode 100644 index 000000000..6cd7180aa --- /dev/null +++ b/.changeset/cli-near-miss-statement-trim.md @@ -0,0 +1,15 @@ +--- +'stash': patch +--- + +Trim the leading comment block from near-miss statements reported by the Drizzle +migration rewriter (`stash eql migration --drizzle`, `stash eql install`). + +The broad near-miss scan is anchored on the previous `;`, so a +`SET DATA TYPE … USING …` it could not safely repair was quoted back to the user +with every preceding comment and blank line glued to its front — in a file +opening with a comment block, that meant the whole header. The reported +statement is now the offending statement alone. Detection is unchanged; only the +text shown to the user is affected. + +Keeps this rewriter in sync with its sibling in `@cipherstash/wizard`. diff --git a/.changeset/wizard-eql-v3-migration-rewrite.md b/.changeset/wizard-eql-v3-migration-rewrite.md index 4e9ae5861..a627e5eb9 100644 --- a/.changeset/wizard-eql-v3-migration-rewrite.md +++ b/.changeset/wizard-eql-v3-migration-rewrite.md @@ -21,6 +21,15 @@ warning that the ADD+DROP+RENAME is data-destroying and safe only on an empty table — a populated table must use the staged `stash encrypt` flow. This re-converges the rewriter with the sibling copy in the `stash` CLI. +The post-agent step now sweeps **every** candidate migration directory +(`drizzle/`, `migrations/`, `src/db/migrations/`) rather than stopping at the +first one that exists. Previously an empty or already-rewritten `drizzle/` +sitting next to a project's real `migrations/` caused those migrations to be +skipped entirely, so they still failed at migrate time. A directory that can't +be read is reported and the remaining candidates are still swept. Reported +near-miss statements are also trimmed of any preceding comment block, so the +statement quoted back to the user is the offending statement alone. + Database introspection also recognises v3 encrypted columns: `isEqlEncrypted` now reports both `eql_v2_encrypted` and the `eql_v3_*` family as already encrypted, so the agent won't scaffold over existing encrypted data of either diff --git a/packages/cli/src/__tests__/rewrite-migrations.test.ts b/packages/cli/src/__tests__/rewrite-migrations.test.ts index e213cc886..173cba0fb 100644 --- a/packages/cli/src/__tests__/rewrite-migrations.test.ts +++ b/packages/cli/src/__tests__/rewrite-migrations.test.ts @@ -424,6 +424,66 @@ describe('rewriteEncryptedAlterColumns', () => { expect(fs.readFileSync(filePath, 'utf-8')).toBe(original) }) + // A near-miss is quoted back to the user verbatim, so it must read as the + // offending statement alone. NEAR_MISS_RE opens with a lazy `[^;]*?`, which + // can only be bounded by the previous `;` — so without an explicit trim the + // reported "statement" drags in every comment and blank line since then. + it('reports a near-miss without the file-leading comment block', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;' + const filePath = path.join(tmpDir, '0022_preamble.sql') + fs.writeFileSync( + filePath, + [ + '-- Custom SQL migration file, put your code below! --', + '-- Hand-converts the email column in place.', + '', + statement, + '', + ].join('\n'), + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + it('reports a near-miss without a preceding statement-breakpoint marker', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "meta" SET DATA TYPE eql_v3_json USING (meta)::jsonb;' + const filePath = path.join(tmpDir, '0023_breakpoint-preamble.sql') + fs.writeFileSync( + filePath, + [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '--> statement-breakpoint', + statement, + '', + ].join('\n'), + ) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + it('keeps a multi-line near-miss statement intact after the preamble trim', async () => { + const statement = [ + 'ALTER TABLE "users"', + ' ALTER COLUMN "email"', + ' SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;', + ].join('\n') + const filePath = path.join(tmpDir, '0024_multiline.sql') + fs.writeFileSync(filePath, `-- leading note\n\n${statement}\n`) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + it('reports no skipped statements for a clean file', async () => { const filePath = path.join(tmpDir, '0020_clean.sql') fs.writeFileSync(filePath, 'CREATE TABLE "t" ("id" integer);\n') diff --git a/packages/cli/src/commands/db/rewrite-migrations.ts b/packages/cli/src/commands/db/rewrite-migrations.ts index a96dc5229..966cd54d8 100644 --- a/packages/cli/src/commands/db/rewrite-migrations.ts +++ b/packages/cli/src/commands/db/rewrite-migrations.ts @@ -95,6 +95,27 @@ const ALTER_COLUMN_TO_ENCRYPTED_RE = new RegExp( const NEAR_MISS_RE = /[^;]*?\bSET\s+DATA\s+TYPE\b[^;]*?\beql_v[23][a-z0-9_]*[^;]*?;/gi +/** + * Blank lines and `--` line comments (including drizzle-kit's + * `--> statement-breakpoint`) at the head of a {@link NEAR_MISS_RE} match. + * + * That regex opens with a lazy `[^;]*?`, whose only left boundary is the + * previous `;` — or the start of the file when there is no preceding statement. + * So the raw match drags in every comment and blank line since then, and a + * near-miss in a file that opens with a comment block gets reported to the user + * with that whole block glued to its front. Strip the preamble so the statement + * we quote back reads as the offending statement alone. + * + * Only line comments are handled — `/* … *\/` block comments are not something + * drizzle-kit emits, and a stray one costs cosmetics, not correctness. + */ +const STATEMENT_PREAMBLE_RE = /^(?:[^\S\n]*(?:--[^\n]*)?\n)+/ + +/** Drop the leading blank/comment lines a `[^;]*?`-anchored match dragged in. */ +function trimStatementPreamble(statement: string): string { + return statement.replace(STATEMENT_PREAMBLE_RE, '').trim() +} + /** A statement the sweep recognised as ALTER-to-encrypted but did NOT rewrite. */ export interface SkippedAlter { /** Absolute path of the migration file the statement lives in. */ @@ -189,7 +210,10 @@ export async function rewriteEncryptedAlterColumns( // matcher. Flag it — non-fatally — rather than leave the user shipping SQL // that fails at migrate time. for (const nearMiss of updated.matchAll(NEAR_MISS_RE)) { - skipped.push({ file: filePath, statement: nearMiss[0].trim() }) + skipped.push({ + file: filePath, + statement: trimStatementPreamble(nearMiss[0]), + }) } } diff --git a/packages/wizard/src/__tests__/rewrite-migrations.test.ts b/packages/wizard/src/__tests__/rewrite-migrations.test.ts index 21ae01cd6..41507fe86 100644 --- a/packages/wizard/src/__tests__/rewrite-migrations.test.ts +++ b/packages/wizard/src/__tests__/rewrite-migrations.test.ts @@ -2,7 +2,10 @@ import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { rewriteEncryptedAlterColumns } from '../lib/rewrite-migrations.js' +import { + rewriteEncryptedAlterColumns, + sweepMigrationDirs, +} from '../lib/rewrite-migrations.js' describe('rewriteEncryptedAlterColumns', () => { let tmpDir: string @@ -379,6 +382,62 @@ describe('rewriteEncryptedAlterColumns', () => { expect(skipped).toEqual([]) }) + // A near-miss is quoted back to the user verbatim, so it must read as the + // offending statement alone. NEAR_MISS_RE opens with a lazy `[^;]*?`, which + // can only be bounded by the previous `;` — so without an explicit trim the + // reported "statement" drags in every comment and blank line since then. + it('reports a near-miss without the file-leading comment block', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;' + const original = [ + '-- Custom SQL migration file, put your code below! --', + '-- Hand-converts the email column in place.', + '', + statement, + '', + ].join('\n') + const filePath = path.join(tmpDir, '0022_preamble.sql') + fs.writeFileSync(filePath, original) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + it('reports a near-miss without a preceding statement-breakpoint marker', async () => { + const statement = + 'ALTER TABLE "users" ALTER COLUMN "meta" SET DATA TYPE eql_v3_json USING (meta)::jsonb;' + const original = [ + 'CREATE TABLE "users" ("id" integer PRIMARY KEY);', + '--> statement-breakpoint', + statement, + '', + ].join('\n') + const filePath = path.join(tmpDir, '0023_breakpoint-preamble.sql') + fs.writeFileSync(filePath, original) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + + it('keeps a multi-line near-miss statement intact after the preamble trim', async () => { + const statement = [ + 'ALTER TABLE "users"', + ' ALTER COLUMN "email"', + ' SET DATA TYPE eql_v3_text_search USING (email)::eql_v3_text_search;', + ].join('\n') + const filePath = path.join(tmpDir, '0024_multiline.sql') + fs.writeFileSync(filePath, `-- leading note\n\n${statement}\n`) + + const { skipped } = await rewriteEncryptedAlterColumns(tmpDir) + + expect(skipped).toHaveLength(1) + expect(skipped[0].statement).toBe(statement) + }) + it('handles multiple ALTER statements in one file', async () => { const original = [ 'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v3_text_search;', @@ -397,3 +456,109 @@ describe('rewriteEncryptedAlterColumns', () => { expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");') }) }) + +describe('sweepMigrationDirs', () => { + let tmpDir: string + + const ALTER = + 'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE eql_v3_text_search;\n' + + /** Create `dir` under the sandbox, optionally seeding `name` with `sql`. */ + const seedDir = (dir: string, name?: string, sql?: string): string => { + const abs = path.join(tmpDir, dir) + fs.mkdirSync(abs, { recursive: true }) + if (name) fs.writeFileSync(path.join(abs, name), sql ?? ALTER) + return abs + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-sweep-')) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('skips candidate directories that do not exist', async () => { + const abs = seedDir('migrations', '0001_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results.map((r) => r.dir)).toEqual(['migrations']) + expect(results[0].rewritten).toEqual([path.join(abs, '0001_alter.sql')]) + }) + + // The regression this test locks in: the old loop `return`ed after the FIRST + // existing candidate, so a project with an empty `drizzle/` alongside a real + // `migrations/` had its actual migrations silently left unrepaired. + it('keeps sweeping when an earlier candidate directory yields no matches', async () => { + seedDir('drizzle') + const abs = seedDir('migrations', '0002_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results.map((r) => r.dir)).toEqual(['drizzle', 'migrations']) + expect(results[0].rewritten).toEqual([]) + expect(results[1].rewritten).toEqual([path.join(abs, '0002_alter.sql')]) + expect( + fs.readFileSync(path.join(abs, '0002_alter.sql'), 'utf-8'), + ).not.toContain('SET DATA TYPE') + }) + + it('rewrites every candidate directory that holds encrypted alters', async () => { + const drizzle = seedDir('drizzle', '0001_alter.sql') + const nested = seedDir('src/db/migrations', '0002_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, [ + 'drizzle', + 'src/db/migrations', + ]) + + expect(results.flatMap((r) => r.rewritten)).toEqual([ + path.join(drizzle, '0001_alter.sql'), + path.join(nested, '0002_alter.sql'), + ]) + }) + + it('is idempotent — a second sweep rewrites nothing', async () => { + const abs = seedDir('drizzle', '0001_alter.sql') + + await sweepMigrationDirs(tmpDir, ['drizzle']) + const afterFirst = fs.readFileSync( + path.join(abs, '0001_alter.sql'), + 'utf-8', + ) + const results = await sweepMigrationDirs(tmpDir, ['drizzle']) + + expect(results[0].rewritten).toEqual([]) + expect(fs.readFileSync(path.join(abs, '0001_alter.sql'), 'utf-8')).toBe( + afterFirst, + ) + }) + + it('surfaces a failing directory as an error and still sweeps the rest', async () => { + // A directory named `*.sql` makes readFile throw EISDIR mid-sweep. + const broken = seedDir('drizzle') + fs.mkdirSync(path.join(broken, '0001_alter.sql')) + const abs = seedDir('migrations', '0002_alter.sql') + + const results = await sweepMigrationDirs(tmpDir, ['drizzle', 'migrations']) + + expect(results[0].dir).toBe('drizzle') + expect(results[0].error).toBeDefined() + expect(results[1].rewritten).toEqual([path.join(abs, '0002_alter.sql')]) + }) + + it('reports near-misses per directory', async () => { + const abs = seedDir( + 'drizzle', + '0001_using.sql', + 'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v3_json USING (c)::jsonb;\n', + ) + + const results = await sweepMigrationDirs(tmpDir, ['drizzle']) + + expect(results[0].skipped).toHaveLength(1) + expect(results[0].skipped[0].file).toBe(path.join(abs, '0001_using.sql')) + }) +}) diff --git a/packages/wizard/src/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index 24f8bb918..e66af3b1a 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -6,11 +6,9 @@ */ import { execSync } from 'node:child_process' -import { existsSync } from 'node:fs' -import { resolve } from 'node:path' import * as p from '@clack/prompts' import type { GatheredContext } from './gather.js' -import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' +import { sweepMigrationDirs } from './rewrite-migrations.js' import type { DetectedPackageManager, Integration } from './types.js' interface PostAgentOptions { @@ -21,8 +19,10 @@ interface PostAgentOptions { } /** - * Candidate directories drizzle-kit may write migrations to. We check in - * order and rewrite the first one that exists; `drizzle` is the default. + * Candidate directories drizzle-kit may write migrations to. `drizzle` is the + * default, but a project's configured `out` is not discoverable from here, so + * every candidate that exists is swept — see {@link sweepMigrationDirs} for why + * stopping at the first one loses migrations. */ const DRIZZLE_OUT_DIRS = ['drizzle', 'migrations', 'src/db/migrations'] @@ -115,34 +115,29 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { } async function rewriteEncryptedMigrations(cwd: string): Promise { - for (const dir of DRIZZLE_OUT_DIRS) { - const abs = resolve(cwd, dir) - if (!existsSync(abs)) continue - - try { - const { rewritten, skipped } = await rewriteEncryptedAlterColumns(abs) - if (rewritten.length > 0) { - p.log.info( - `Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`, - ) - for (const file of rewritten) p.log.step(` - ${file}`) - p.log.warn( - 'This rewrite is data-destroying — safe only on an EMPTY table. If any of these tables already have rows, do NOT run the migration; use the staged `stash encrypt` flow (add -> backfill via @cipherstash/stack -> cutover -> drop) instead. See the comments in the rewritten SQL.', - ) - } - if (skipped.length > 0) { - p.log.warn( - `${skipped.length} statement(s) look like an ALTER-to-encrypted the rewrite could not safely repair (e.g. a hand-authored SET DATA TYPE ... USING ...). Review them before migrating:`, - ) - for (const s of skipped) p.log.step(` - ${s.file}: ${s.statement}`) - } - // Only rewrite the first dir that matches — running again on a - // different candidate would double-transform already-rewritten SQL. - return - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - p.log.warn(`Could not rewrite migrations in ${dir}: ${message}`) - return + const results = await sweepMigrationDirs(cwd, DRIZZLE_OUT_DIRS) + + for (const { dir, rewritten, skipped, error } of results) { + if (error) { + p.log.warn(`Could not rewrite migrations in ${dir}: ${error}`) + continue + } + + if (rewritten.length > 0) { + p.log.info( + `Rewrote ${rewritten.length} migration file(s) in ${dir}/ to use ADD+DROP+RENAME for encrypted columns.`, + ) + for (const file of rewritten) p.log.step(` - ${file}`) + p.log.warn( + 'This rewrite is data-destroying — safe only on an EMPTY table. If any of these tables already have rows, do NOT run the migration; use the staged `stash encrypt` flow (add -> backfill via @cipherstash/stack -> cutover -> drop) instead. See the comments in the rewritten SQL.', + ) + } + + if (skipped.length > 0) { + p.log.warn( + `${skipped.length} statement(s) look like an ALTER-to-encrypted the rewrite could not safely repair (e.g. a hand-authored SET DATA TYPE ... USING ...). Review them before migrating:`, + ) + for (const s of skipped) p.log.step(` - ${s.file}: ${s.statement}`) } } } diff --git a/packages/wizard/src/lib/rewrite-migrations.ts b/packages/wizard/src/lib/rewrite-migrations.ts index bbe2a754f..ce3ce1ab6 100644 --- a/packages/wizard/src/lib/rewrite-migrations.ts +++ b/packages/wizard/src/lib/rewrite-migrations.ts @@ -1,5 +1,6 @@ +import { existsSync } from 'node:fs' import { readdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, resolve } from 'node:path' /** * The encrypted column types this rewrite applies to: the single EQL v2 type, @@ -102,6 +103,27 @@ const ALTER_COLUMN_TO_ENCRYPTED_RE = new RegExp( const NEAR_MISS_RE = /[^;]*?\bSET\s+DATA\s+TYPE\b[^;]*?\beql_v[23][a-z0-9_]*[^;]*?;/gi +/** + * Blank lines and `--` line comments (including drizzle-kit's + * `--> statement-breakpoint`) at the head of a {@link NEAR_MISS_RE} match. + * + * That regex opens with a lazy `[^;]*?`, whose only left boundary is the + * previous `;` — or the start of the file when there is no preceding statement. + * So the raw match drags in every comment and blank line since then, and a + * near-miss in a file that opens with a comment block gets reported to the user + * with that whole block glued to its front. Strip the preamble so the statement + * we quote back reads as the offending statement alone. + * + * Only line comments are handled — `/* … *\/` block comments are not something + * drizzle-kit emits, and a stray one costs cosmetics, not correctness. + */ +const STATEMENT_PREAMBLE_RE = /^(?:[^\S\n]*(?:--[^\n]*)?\n)+/ + +/** Drop the leading blank/comment lines a `[^;]*?`-anchored match dragged in. */ +function trimStatementPreamble(statement: string): string { + return statement.replace(STATEMENT_PREAMBLE_RE, '').trim() +} + /** A statement the sweep recognised as ALTER-to-encrypted but did NOT rewrite. */ export interface SkippedAlter { /** Absolute path of the migration file the statement lives in. */ @@ -196,13 +218,67 @@ export async function rewriteEncryptedAlterColumns( // matcher. Flag it — non-fatally — rather than leave the user shipping SQL // that fails at migrate time. for (const nearMiss of updated.matchAll(NEAR_MISS_RE)) { - skipped.push({ file: filePath, statement: nearMiss[0].trim() }) + skipped.push({ + file: filePath, + statement: trimStatementPreamble(nearMiss[0]), + }) } } return { rewritten, skipped } } +/** One candidate migration directory's outcome from {@link sweepMigrationDirs}. */ +export interface DirRewriteResult extends RewriteResult { + /** The candidate directory as supplied (relative to `cwd`), for reporting. */ + dir: string + /** Set when this directory's sweep threw; the sweep continues regardless. */ + error?: string +} + +/** + * Sweep every candidate migration directory under `cwd`, rewriting each one's + * unsafe ALTER-to-encrypted statements. Directories that don't exist are + * skipped; the returned array carries one entry per directory that did. + * + * **Why every directory, not just the first:** a project's drizzle-kit `out` + * directory is not discoverable from here, so the caller passes a candidate + * list. Stopping at the first candidate that merely *exists* means an empty or + * already-rewritten `drizzle/` sitting next to the project's real `migrations/` + * silently leaves those migrations unrepaired — they then fail at migrate time + * with the very `cannot cast type ...` error this function exists to prevent. + * + * Sweeping all of them is safe because the rewrite is idempotent: a rewritten + * statement no longer contains `SET DATA TYPE`, so neither + * {@link ALTER_COLUMN_TO_ENCRYPTED_RE} nor {@link NEAR_MISS_RE} can match it a + * second time. Two candidates resolving to the same directory (via a symlink) + * therefore cost a redundant read, not a double transform. + * + * A directory that throws mid-sweep is reported via `error` rather than + * aborting — one unreadable candidate must not strand the others. + */ +export async function sweepMigrationDirs( + cwd: string, + dirs: readonly string[], +): Promise { + const results: DirRewriteResult[] = [] + + for (const dir of dirs) { + const abs = resolve(cwd, dir) + if (!existsSync(abs)) continue + + try { + const { rewritten, skipped } = await rewriteEncryptedAlterColumns(abs) + results.push({ dir, rewritten, skipped }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + results.push({ dir, rewritten: [], skipped: [], error: message }) + } + } + + return results +} + /** * The rewrite is identical for v2 and v3, and the ADD+DROP+RENAME sequence is * equivalent to DROP+ADD: it makes the column type valid but does NOT preserve @@ -241,6 +317,15 @@ function renderSafeAlter( '-- NOTE: constraints, defaults, and indexes on the original column are NOT', '-- carried over by this ADD/DROP/RENAME — re-add any NOT NULL, DEFAULT,', '-- UNIQUE, or index definitions manually.', + // The domain is emitted as `"public".""` unconditionally: EQL + // installs its domains into `public` (both `stash eql install` and the + // adapters' baseline migrations do), and the qualifier makes the rewrite + // independent of the session `search_path`. It is an ASSUMPTION, not + // something read back from the matched SQL — the `schema` capture above is + // the TABLE's schema (from a pgSchema() table) and says nothing about where + // the domain lives. If EQL ever supports installing into a non-`public` + // schema, this needs the install schema threaded in, here and in the + // sibling `packages/cli/src/commands/db/rewrite-migrations.ts`. `ALTER TABLE ${qualifiedTable} ADD COLUMN "${tmp}" "public"."${domain}";`, `-- UPDATE ${qualifiedTable} SET "${tmp}" = /* encrypted value for ${column} */ NULL`, '--> statement-breakpoint', From d7a612359187be11d74f44614c7c924ffa59f0e0 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 23 Jul 2026 17:46:31 +1000 Subject: [PATCH 25/25] fix(wizard): resolve @cipherstash/auth via the node condition; gate typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last open item from the #771 review — the 3 pre-existing `AutoStrategy` tsc errors flagged as "would bite a future strict type gate". **Root cause.** `@cipherstash/auth` uses conditional exports: `node` resolves to `index.d.ts` (the full surface, including `AutoStrategy`), `default` resolves to `wasm-types.d.ts` (which has no `AutoStrategy`). The wizard's tsconfig sets `moduleResolution: "bundler"`, which does NOT include the `node` condition, so tsc took the `default` branch and reported `AutoStrategy` as missing on all three call sites — `agent/fetch-prompt.ts`, `agent/interface.ts` and `lib/prerequisites.ts`. Nothing was actually broken at runtime: the wizard is a Node CLI and always loads the `node` branch. The types were simply resolved against the wrong entry point. **Fix.** Add `"customConditions": ["node"]`, matching what `packages/stack`, `packages/prisma-next`, `packages/test-kit`, `packages/stack-drizzle` and `packages/stack-supabase` already carry for the identical `protect-ffi` conditional-export problem. `tsc --noEmit` now exits 0. **Regression guard.** A type-level defect needs a type-level gate, or it comes back silently — the wizard is built by tsup with `dts: false`, so the build transpiles without ever typechecking, which is exactly why these three errors sat in `main` unnoticed. Add a `typecheck` script and wire it into `tests.yml` alongside the prisma-next gate (#684). Verified the gate fires: with `customConditions` removed the step exits 2 on all three errors; restored, it exits 0. No changeset — `dts: false` means the published tarball is byte-identical. Tooling only, no observable behaviour change. Green: wizard 265 pass / 5 env-skipped, typecheck 0 errors; stash 770 pass / 53 files; `code:check` 0 errors. --- .github/workflows/tests.yml | 6 ++++++ packages/wizard/package.json | 1 + packages/wizard/tsconfig.json | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d98cde74b..1b96cbb2f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,6 +146,12 @@ jobs: - name: Typecheck (bench — guards the stack/stack-drizzle importers) run: pnpm exec turbo run build --filter @cipherstash/bench + # The wizard is built by tsup, which transpiles without typechecking, so + # nothing here caught the three `auth.AutoStrategy` resolution errors that + # sat in `main` until #771. Gate it so they cannot come back silently. + - name: Typecheck (wizard) + run: pnpm --filter @cipherstash/wizard run typecheck + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners diff --git a/packages/wizard/package.json b/packages/wizard/package.json index e57cf8198..99353b657 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -26,6 +26,7 @@ "postbuild": "chmod +x ./dist/bin/wizard.js", "dev": "tsup --watch", "test": "vitest run", + "typecheck": "tsc --project tsconfig.json --noEmit", "lint": "biome check ." }, "dependencies": { diff --git a/packages/wizard/tsconfig.json b/packages/wizard/tsconfig.json index 1f60894ec..d88a9b847 100644 --- a/packages/wizard/tsconfig.json +++ b/packages/wizard/tsconfig.json @@ -7,6 +7,17 @@ "allowJs": true, "esModuleInterop": true, "moduleResolution": "bundler", + + // `@cipherstash/auth` uses conditional exports — `node` picks `index.d.ts` + // (the full surface, including `AutoStrategy`), `default` picks + // `wasm-types.d.ts` (which has no `AutoStrategy`). Bundler resolution does + // not include `node` by default, so without this the wizard's three + // `auth.AutoStrategy` call sites fail to typecheck even though they run + // fine — the wizard is a Node CLI and always loads the `node` branch. + // Matches `packages/stack`, `packages/prisma-next`, `packages/test-kit`, + // `packages/stack-drizzle` and `packages/stack-supabase`. + "customConditions": ["node"], + "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true,