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.
-
-[](https://cipherstash.com)
-[](https://www.npmjs.com/package/@cipherstash/protect-dynamodb)
-[](https://github.com/cipherstash/stack/blob/main/LICENSE.md)
-
-## Installation
-
-```bash
-npm install @cipherstash/protect-dynamodb
-# or
-yarn add @cipherstash/protect-dynamodb
-# or
-pnpm add @cipherstash/protect-dynamodb
-```
-
-## Quick Start
-
-```typescript
-import { protectDynamoDB } from '@cipherstash/protect-dynamodb'
-import { protect, csColumn, csTable } from '@cipherstash/protect'
-import { PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb'
-
-// Define your protected table schema
-const users = csTable('users', {
- email: csColumn('email').equality(),
-})
-
-// Initialize the Protect client
-const protectClient = await protect({
- schemas: [users],
-})
-
-// Create the DynamoDB helper instance
-const protectDynamo = protectDynamoDB({
- protectClient,
-})
-
-// Encrypt and store a user
-const user = {
- email: 'user@example.com',
-}
-
-const encryptResult = await protectDynamo.encryptModel(user, users)
-if (encryptResult.failure) {
- throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`)
-}
-
-// Store in DynamoDB
-await docClient.send(new PutCommand({
- TableName: 'Users',
- Item: encryptResult.data,
-}))
-
-// Create search terms for querying
-const searchTermsResult = await protectDynamo.createSearchTerms([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- },
-])
-
-if (searchTermsResult.failure) {
- throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`)
-}
-
-// Query using the search term
-const [emailHmac] = searchTermsResult.data
-const result = await docClient.send(new GetCommand({
- TableName: 'Users',
- Key: { email__hmac: emailHmac },
-}))
-
-if (!result.Item) {
- throw new Error('Item not found')
-}
-
-// Decrypt the result
-const decryptResult = await protectDynamo.decryptModel(
- result.Item,
- users,
-)
-
-if (decryptResult.failure) {
- throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`)
-}
-
-const decryptedUser = decryptResult.data
-```
-
-## Features
-
-### Encryption and Decryption
-
-The package provides methods to encrypt and decrypt data for DynamoDB:
-
-- `encryptModel`: Encrypts a single model
-- `bulkEncryptModels`: Encrypts multiple models in bulk
-- `decryptModel`: Decrypts a single model
-- `bulkDecryptModels`: Decrypts multiple models in bulk
-
-All methods return a `Result` type that must be checked for failures:
-
-```typescript
-const result = await protectDynamo.encryptModel(user, users)
-if (result.failure) {
- // Handle error
- console.error(result.failure.message)
-} else {
- // Use encrypted data
- const encryptedData = result.data
-}
-```
-
-### Search Terms
-
-Create search terms for querying encrypted data:
-
-- `createSearchTerms`: Creates search terms for one or more columns
-
-```typescript
-const searchTermsResult = await protectDynamo.createSearchTerms([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- },
-])
-
-if (searchTermsResult.failure) {
- throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`)
-}
-
-const [emailHmac] = searchTermsResult.data
-```
-
-### DynamoDB Integration
-
-The package automatically handles:
-- Converting encrypted data to DynamoDB's format
-- Adding HMAC attributes for searchable fields
-- Preserving unencrypted fields
-- Converting DynamoDB items back to encrypted format for decryption
-
-## Usage Patterns
-
-### Simple Table with Encrypted Fields
-
-```typescript
-const users = csTable('users', {
- email: csColumn('email').equality(),
-})
-
-// Encrypt and store
-const encryptResult = await protectDynamo.encryptModel({
- pk: 'user#1',
- email: 'user@example.com',
-}, users)
-
-if (encryptResult.failure) {
- throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`)
-}
-
-// Query using search terms
-const searchTermsResult = await protectDynamo.createSearchTerms([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- },
-])
-
-if (searchTermsResult.failure) {
- throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`)
-}
-```
-
-### Encrypted Partition Key
-
-```typescript
-// Table with encrypted partition key
-const table = {
- TableName: 'Users',
- AttributeDefinitions: [
- {
- AttributeName: 'email__hmac',
- AttributeType: 'S',
- },
- ],
- KeySchema: [
- {
- AttributeName: 'email__hmac',
- KeyType: 'HASH',
- },
- ],
-}
-
-// Create search terms for querying
-const searchTermsResult = await protectDynamo.createSearchTerms([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- },
-])
-
-if (searchTermsResult.failure) {
- throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`)
-}
-
-const [emailHmac] = searchTermsResult.data
-```
-
-### Encrypted Sort Key
-
-```typescript
-// Table with encrypted sort key
-const table = {
- TableName: 'Users',
- AttributeDefinitions: [
- {
- AttributeName: 'pk',
- AttributeType: 'S',
- },
- {
- AttributeName: 'email__hmac',
- AttributeType: 'S',
- },
- ],
- KeySchema: [
- {
- AttributeName: 'pk',
- KeyType: 'HASH',
- },
- {
- AttributeName: 'email__hmac',
- KeyType: 'RANGE',
- },
- ],
-}
-
-// Create search terms for querying
-const searchTermsResult = await protectDynamo.createSearchTerms([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- },
-])
-
-if (searchTermsResult.failure) {
- throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`)
-}
-
-const [emailHmac] = searchTermsResult.data
-```
-
-### Global Secondary Index with Encrypted Key
-
-```typescript
-// Table with GSI using encrypted key
-const table = {
- TableName: 'Users',
- AttributeDefinitions: [
- {
- AttributeName: 'pk',
- AttributeType: 'S',
- },
- {
- AttributeName: 'email__hmac',
- AttributeType: 'S',
- },
- ],
- KeySchema: [
- {
- AttributeName: 'pk',
- KeyType: 'HASH',
- },
- ],
- GlobalSecondaryIndexes: [
- {
- IndexName: 'EmailIndex',
- KeySchema: [
- {
- AttributeName: 'email__hmac',
- KeyType: 'HASH',
- },
- ],
- Projection: {
- ProjectionType: 'INCLUDE',
- NonKeyAttributes: ['email__source'],
- },
- },
- ],
-}
-
-// Create search terms for querying
-const searchTermsResult = await protectDynamo.createSearchTerms([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- },
-])
-
-if (searchTermsResult.failure) {
- throw new Error(`Failed to create search terms: ${searchTermsResult.failure.message}`)
-}
-
-const [emailHmac] = searchTermsResult.data
-```
-
-## Error Handling
-
-All methods return a `Result` type from `@byteslice/result` that must be checked for failures:
-
-```typescript
-const result = await protectDynamo.encryptModel(user, users)
-
-if (result.failure) {
- // Handle error
- console.error(result.failure.message)
-} else {
- // Use encrypted data
- const encryptedData = result.data
-}
-```
-
-## Type Safety
-
-The package is fully typed and supports TypeScript:
-
-```typescript
-type User = {
- pk: string
- email: string
-}
-
-// Type-safe encryption
-const encryptResult = await protectDynamo.encryptModel(user, users)
-if (encryptResult.failure) {
- throw new Error(`Failed to encrypt user: ${encryptResult.failure.message}`)
-}
-const encryptedUser = encryptResult.data
-
-// Type-safe decryption
-const decryptResult = await protectDynamo.decryptModel(item, users)
-if (decryptResult.failure) {
- throw new Error(`Failed to decrypt user: ${decryptResult.failure.message}`)
-}
-const decryptedUser = decryptResult.data
-```
diff --git a/packages/protect-dynamodb/__tests__/audit.test.ts b/packages/protect-dynamodb/__tests__/audit.test.ts
deleted file mode 100644
index 619ff8754..000000000
--- a/packages/protect-dynamodb/__tests__/audit.test.ts
+++ /dev/null
@@ -1,316 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable, csValue, protect } from '@cipherstash/protect'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protectDynamoDB } from '../src'
-
-const schema = csTable('dynamo_cipherstash_test', {
- email: csColumn('email').equality(),
- firstName: csColumn('firstName').equality(),
- lastName: csColumn('lastName').equality(),
- phoneNumber: csColumn('phoneNumber'),
- example: {
- protected: csValue('example.protected'),
- deep: {
- protected: csValue('example.deep.protected'),
- },
- },
-})
-
-describe('protect dynamodb helpers', () => {
- let protectClient: Awaited>
- let protectDynamo: ReturnType
-
- beforeAll(async () => {
- protectClient = await protect({
- schemas: [schema],
- })
-
- protectDynamo = protectDynamoDB({
- protectClient,
- })
- })
-
- it('should encrypt columns', async () => {
- const testData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test.user@example.com',
- address: '123 Main Street',
- createdAt: '2024-08-15T22:14:49.948Z',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- companyName: 'Acme Corp',
- batteryBrands: ['Brand1', 'Brand2'],
- metadata: { role: 'admin' },
- example: {
- protected: 'hello world',
- notProtected: 'I am not protected',
- deep: {
- protected: 'deep protected',
- notProtected: 'deep not protected',
- },
- },
- }
-
- const result = await protectDynamo.encryptModel(testData, schema).audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
- })
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify equality columns are encrypted
- expect(encryptedData).toHaveProperty('email__source')
- expect(encryptedData).toHaveProperty('email__hmac')
- expect(encryptedData).toHaveProperty('firstName__source')
- expect(encryptedData).toHaveProperty('firstName__hmac')
- expect(encryptedData).toHaveProperty('lastName__source')
- expect(encryptedData).toHaveProperty('lastName__hmac')
- expect(encryptedData).toHaveProperty('phoneNumber__source')
- expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
- expect(encryptedData.example).toHaveProperty('protected__source')
- expect(encryptedData.example.deep).toHaveProperty('protected__source')
-
- // Verify other fields remain unchanged
- expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
- expect(encryptedData.address).toBe('123 Main Street')
- expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z')
- expect(encryptedData.companyName).toBe('Acme Corp')
- expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2'])
- expect(encryptedData.example.notProtected).toBe('I am not protected')
- expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
- expect(encryptedData.metadata).toEqual({ role: 'admin' })
- })
-
- it('should handle null and undefined values', async () => {
- const testData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: null,
- firstName: undefined,
- lastName: 'Smith',
- phoneNumber: null,
- metadata: { role: null },
- example: {
- protected: null,
- notProtected: 'I am not protected',
- deep: {
- protected: undefined,
- notProtected: 'deep not protected',
- },
- },
- }
-
- const result = await protectDynamo.encryptModel(testData, schema).audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
- })
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify null/undefined equality columns are handled
- expect(encryptedData).toHaveProperty('lastName__source')
- expect(encryptedData).toHaveProperty('lastName__hmac')
-
- // Verify other fields remain unchanged
- expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
- expect(encryptedData.phoneNumber).toBeNull()
- expect(encryptedData.email).toBeNull()
- expect(encryptedData.firstName).toBeUndefined()
- expect(encryptedData.metadata).toEqual({ role: null })
- expect(encryptedData.example.protected).toBeNull()
- expect(encryptedData.example.deep.protected).toBeUndefined()
- expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
- })
-
- it('should handle empty strings and special characters', async () => {
- const testData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: '',
- firstName: 'John!@#$%^&*()',
- lastName: 'Smith ',
- phoneNumber: '',
- metadata: { role: 'admin!@#$%^&*()' },
- }
-
- const result = await protectDynamo.encryptModel(testData, schema).audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
- })
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify equality columns are encrypted
- expect(encryptedData).toHaveProperty('email__source')
- expect(encryptedData).toHaveProperty('email__hmac')
- expect(encryptedData).toHaveProperty('firstName__source')
- expect(encryptedData).toHaveProperty('firstName__hmac')
- expect(encryptedData).toHaveProperty('lastName__source')
- expect(encryptedData).toHaveProperty('lastName__hmac')
- expect(encryptedData).toHaveProperty('phoneNumber__source')
- expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
-
- // Verify other fields remain unchanged
- expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
- expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' })
- })
-
- it('should handle bulk encryption', async () => {
- const testData = [
- {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test1@example.com',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- },
- {
- id: '02ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test2@example.com',
- firstName: 'Jane',
- lastName: 'Doe',
- phoneNumber: '555-555-5556',
- },
- ]
-
- const result = await protectDynamo
- .bulkEncryptModels(testData, schema)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'dynamo_bulk_encrypt_models',
- },
- })
- if (result.failure) {
- throw new Error(`Bulk encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify both items are encrypted
- expect(encryptedData).toHaveLength(2)
-
- // Verify first item
- expect(encryptedData[0]).toHaveProperty('email__source')
- expect(encryptedData[0]).toHaveProperty('email__hmac')
- expect(encryptedData[0]).toHaveProperty('firstName__source')
- expect(encryptedData[0]).toHaveProperty('firstName__hmac')
- expect(encryptedData[0]).toHaveProperty('lastName__source')
- expect(encryptedData[0]).toHaveProperty('lastName__hmac')
- expect(encryptedData[0]).toHaveProperty('phoneNumber__source')
-
- // Verify second item
- expect(encryptedData[1]).toHaveProperty('email__source')
- expect(encryptedData[1]).toHaveProperty('email__hmac')
- expect(encryptedData[1]).toHaveProperty('firstName__source')
- expect(encryptedData[1]).toHaveProperty('firstName__hmac')
- expect(encryptedData[1]).toHaveProperty('lastName__source')
- expect(encryptedData[1]).toHaveProperty('lastName__hmac')
- expect(encryptedData[1]).toHaveProperty('phoneNumber__source')
- })
-
- it('should handle decryption of encrypted data', async () => {
- const originalData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test.user@example.com',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- example: {
- protected: 'hello world',
- notProtected: 'I am not protected',
- deep: {
- protected: 'deep protected',
- notProtected: 'deep not protected',
- },
- },
- }
-
- // First encrypt
- const encryptResult = await protectDynamo
- .encryptModel(originalData, schema)
- .audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_encrypt_model' },
- })
-
- if (encryptResult.failure) {
- throw new Error(`Encryption failed: ${encryptResult.failure.message}`)
- }
-
- // Then decrypt
- const decryptResult = await protectDynamo
- .decryptModel(encryptResult.data, schema)
- .audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_decrypt_model' },
- })
- if (decryptResult.failure) {
- throw new Error(`Decryption failed: ${decryptResult.failure.message}`)
- }
-
- const decryptedData = decryptResult.data
-
- // Verify all fields match original data
- expect(decryptedData).toEqual(originalData)
- })
-
- it('should handle decryption of bulk encrypted data', async () => {
- const originalData = [
- {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test1@example.com',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- },
- {
- id: '02ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test2@example.com',
- firstName: 'Jane',
- lastName: 'Doe',
- phoneNumber: '555-555-5556',
- example: {
- protected: 'hello world',
- notProtected: 'I am not protected',
- deep: {
- protected: 'deep protected',
- notProtected: 'deep not protected',
- },
- },
- },
- ]
-
- // First encrypt
- const encryptResult = await protectDynamo
- .bulkEncryptModels(originalData, schema)
- .audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_encrypt_models' },
- })
- if (encryptResult.failure) {
- throw new Error(
- `Bulk encryption failed: ${encryptResult.failure.message}`,
- )
- }
-
- // Then decrypt
- const decryptResult = await protectDynamo
- .bulkDecryptModels(encryptResult.data, schema)
- .audit({
- metadata: { sub: 'cj@cjb.io', type: 'dynamo_bulk_decrypt_models' },
- })
- if (decryptResult.failure) {
- throw new Error(
- `Bulk decryption failed: ${decryptResult.failure.message}`,
- )
- }
-
- const decryptedData = decryptResult.data
-
- // Verify all items match original data
- expect(decryptedData).toEqual(originalData)
- })
-})
diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/protect-dynamodb/__tests__/dynamodb.test.ts
deleted file mode 100644
index 0b8ee27af..000000000
--- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts
+++ /dev/null
@@ -1,331 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable, csValue, protect } from '@cipherstash/protect'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protectDynamoDB } from '../src'
-
-const schema = csTable('dynamo_cipherstash_test', {
- email: csColumn('email').equality(),
- firstName: csColumn('firstName').equality(),
- lastName: csColumn('lastName').equality(),
- phoneNumber: csColumn('phoneNumber'),
- json: csColumn('json').dataType('json'),
- jsonSearchable: csColumn('jsonSearchable').dataType('json'),
- //.searchableJson('users/jsonSearchable'),
- example: {
- protected: csValue('example.protected'),
- deep: {
- protected: csValue('example.deep.protected'),
- protectNestedJson: csValue('example.deep.protectNestedJson').dataType(
- 'json',
- ),
- },
- },
-})
-
-describe('protect dynamodb helpers', () => {
- let protectClient: Awaited>
- let protectDynamo: ReturnType
-
- beforeAll(async () => {
- protectClient = await protect({
- schemas: [schema],
- })
-
- protectDynamo = protectDynamoDB({
- protectClient,
- })
- })
-
- it('should encrypt and decrypt a model', async () => {
- const testData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test.user@example.com',
- address: '123 Main Street',
- createdAt: '2024-08-15T22:14:49.948Z',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- json: {
- name: 'John Doe',
- age: 30,
- preferences: {
- theme: 'dark',
- notifications: true,
- },
- },
- jsonSearchable: {
- name: 'John Doe',
- age: 30,
- preferences: {
- theme: 'dark',
- notifications: true,
- },
- },
- companyName: 'Acme Corp',
- batteryBrands: ['Brand1', 'Brand2'],
- metadata: { role: 'admin' },
- example: {
- protected: 'hello world',
- notProtected: 'I am not protected',
- deep: {
- protected: 'deep protected',
- notProtected: 'deep not protected',
- protectNestedJson: {
- hello: 'world',
- },
- },
- },
- }
-
- const result = await protectDynamo.encryptModel(testData, schema)
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify equality columns are encrypted
- expect(encryptedData).toHaveProperty('email__source')
- expect(encryptedData).toHaveProperty('email__hmac')
- expect(encryptedData).toHaveProperty('firstName__source')
- expect(encryptedData).toHaveProperty('firstName__hmac')
- expect(encryptedData).toHaveProperty('lastName__source')
- expect(encryptedData).toHaveProperty('lastName__hmac')
- expect(encryptedData).toHaveProperty('phoneNumber__source')
- expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
- expect(encryptedData.example).toHaveProperty('protected__source')
- expect(encryptedData.example.deep).toHaveProperty('protected__source')
-
- // Verify other fields remain unchanged
- expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
- expect(encryptedData.address).toBe('123 Main Street')
- expect(encryptedData.createdAt).toBe('2024-08-15T22:14:49.948Z')
- expect(encryptedData.companyName).toBe('Acme Corp')
- expect(encryptedData.batteryBrands).toEqual(['Brand1', 'Brand2'])
- expect(encryptedData.example.notProtected).toBe('I am not protected')
- expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
- expect(encryptedData.metadata).toEqual({ role: 'admin' })
-
- const decryptResult = await protectDynamo.decryptModel(
- encryptedData,
- schema,
- )
- if (decryptResult.failure) {
- throw new Error(`Decryption failed: ${decryptResult.failure.message}`)
- }
-
- expect(decryptResult.data).toEqual(testData)
- })
-
- it('should handle null and undefined values', async () => {
- const testData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: null,
- firstName: undefined,
- lastName: 'Smith',
- phoneNumber: null,
- metadata: { role: null },
- example: {
- protected: null,
- notProtected: 'I am not protected',
- deep: {
- protected: undefined,
- notProtected: 'deep not protected',
- },
- },
- }
-
- const result = await protectDynamo.encryptModel(testData, schema)
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify null/undefined equality columns are handled
- expect(encryptedData).toHaveProperty('lastName__source')
- expect(encryptedData).toHaveProperty('lastName__hmac')
-
- // Verify other fields remain unchanged
- expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
- expect(encryptedData.phoneNumber).toBeNull()
- expect(encryptedData.email).toBeNull()
- expect(encryptedData.firstName).toBeUndefined()
- expect(encryptedData.metadata).toEqual({ role: null })
- expect(encryptedData.example.protected).toBeNull()
- expect(encryptedData.example.deep.protected).toBeUndefined()
- expect(encryptedData.example.deep.notProtected).toBe('deep not protected')
- })
-
- it('should handle empty strings and special characters', async () => {
- const testData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: '',
- firstName: 'John!@#$%^&*()',
- lastName: 'Smith ',
- phoneNumber: '',
- metadata: { role: 'admin!@#$%^&*()' },
- }
-
- const result = await protectDynamo.encryptModel(testData, schema)
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify equality columns are encrypted
- expect(encryptedData).toHaveProperty('email__source')
- expect(encryptedData).toHaveProperty('email__hmac')
- expect(encryptedData).toHaveProperty('firstName__source')
- expect(encryptedData).toHaveProperty('firstName__hmac')
- expect(encryptedData).toHaveProperty('lastName__source')
- expect(encryptedData).toHaveProperty('lastName__hmac')
- expect(encryptedData).toHaveProperty('phoneNumber__source')
- expect(encryptedData).not.toHaveProperty('phoneNumber__hmac')
-
- // Verify other fields remain unchanged
- expect(encryptedData.id).toBe('01ABCDEFGHIJKLMNOPQRSTUVWX')
- expect(encryptedData.metadata).toEqual({ role: 'admin!@#$%^&*()' })
- })
-
- it('should handle bulk encryption', async () => {
- const testData = [
- {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test1@example.com',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- },
- {
- id: '02ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test2@example.com',
- firstName: 'Jane',
- lastName: 'Doe',
- phoneNumber: '555-555-5556',
- },
- ]
-
- const result = await protectDynamo.bulkEncryptModels(testData, schema)
- if (result.failure) {
- throw new Error(`Bulk encryption failed: ${result.failure.message}`)
- }
-
- const encryptedData = result.data
-
- // Verify both items are encrypted
- expect(encryptedData).toHaveLength(2)
-
- // Verify first item
- expect(encryptedData[0]).toHaveProperty('email__source')
- expect(encryptedData[0]).toHaveProperty('email__hmac')
- expect(encryptedData[0]).toHaveProperty('firstName__source')
- expect(encryptedData[0]).toHaveProperty('firstName__hmac')
- expect(encryptedData[0]).toHaveProperty('lastName__source')
- expect(encryptedData[0]).toHaveProperty('lastName__hmac')
- expect(encryptedData[0]).toHaveProperty('phoneNumber__source')
-
- // Verify second item
- expect(encryptedData[1]).toHaveProperty('email__source')
- expect(encryptedData[1]).toHaveProperty('email__hmac')
- expect(encryptedData[1]).toHaveProperty('firstName__source')
- expect(encryptedData[1]).toHaveProperty('firstName__hmac')
- expect(encryptedData[1]).toHaveProperty('lastName__source')
- expect(encryptedData[1]).toHaveProperty('lastName__hmac')
- expect(encryptedData[1]).toHaveProperty('phoneNumber__source')
- })
-
- it('should handle decryption of encrypted data', async () => {
- const originalData = {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test.user@example.com',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- example: {
- protected: 'hello world',
- notProtected: 'I am not protected',
- deep: {
- protected: 'deep protected',
- notProtected: 'deep not protected',
- },
- },
- }
-
- // First encrypt
- const encryptResult = await protectDynamo.encryptModel(originalData, schema)
-
- if (encryptResult.failure) {
- throw new Error(`Encryption failed: ${encryptResult.failure.message}`)
- }
-
- // Then decrypt
- const decryptResult = await protectDynamo.decryptModel(
- encryptResult.data,
- schema,
- )
- if (decryptResult.failure) {
- throw new Error(`Decryption failed: ${decryptResult.failure.message}`)
- }
-
- const decryptedData = decryptResult.data
-
- // Verify all fields match original data
- expect(decryptedData).toEqual(originalData)
- })
-
- it('should handle decryption of bulk encrypted data', async () => {
- const originalData = [
- {
- id: '01ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test1@example.com',
- firstName: 'John',
- lastName: 'Smith',
- phoneNumber: '555-555-5555',
- },
- {
- id: '02ABCDEFGHIJKLMNOPQRSTUVWX',
- email: 'test2@example.com',
- firstName: 'Jane',
- lastName: 'Doe',
- phoneNumber: '555-555-5556',
- example: {
- protected: 'hello world',
- notProtected: 'I am not protected',
- deep: {
- protected: 'deep protected',
- notProtected: 'deep not protected',
- },
- },
- },
- ]
-
- // First encrypt
- const encryptResult = await protectDynamo.bulkEncryptModels(
- originalData,
- schema,
- )
- if (encryptResult.failure) {
- throw new Error(
- `Bulk encryption failed: ${encryptResult.failure.message}`,
- )
- }
-
- // Then decrypt
- const decryptResult = await protectDynamo.bulkDecryptModels(
- encryptResult.data,
- schema,
- )
- if (decryptResult.failure) {
- throw new Error(
- `Bulk decryption failed: ${decryptResult.failure.message}`,
- )
- }
-
- const decryptedData = decryptResult.data
-
- // Verify all items match original data
- expect(decryptedData).toEqual(originalData)
- })
-})
diff --git a/packages/protect-dynamodb/__tests__/error-codes.test.ts b/packages/protect-dynamodb/__tests__/error-codes.test.ts
deleted file mode 100644
index 918331c95..000000000
--- a/packages/protect-dynamodb/__tests__/error-codes.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import 'dotenv/config'
-import type { ProtectClient } from '@cipherstash/protect'
-import {
- csColumn,
- csTable,
- FfiProtectError,
- protect,
-} from '@cipherstash/protect'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protectDynamoDB } from '../src'
-import type { ProtectDynamoDBError } from '../src/types'
-
-const FFI_TEST_TIMEOUT = 30_000
-
-describe('ProtectDynamoDB Error Code Preservation', () => {
- let protectClient: ProtectClient
- let protectDynamo: ReturnType
-
- const testSchema = csTable('test_table', {
- email: csColumn('email').equality(),
- })
-
- const badSchema = csTable('test_table', {
- nonexistent: csColumn('nonexistent_column'),
- })
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [testSchema] })
- protectDynamo = protectDynamoDB({ protectClient })
- })
-
- describe('handleError FFI error code extraction', () => {
- it('FfiProtectError has code property accessible', () => {
- const ffiError = new FfiProtectError({
- code: 'UNKNOWN_COLUMN',
- message: 'Test error',
- })
- expect(ffiError.code).toBe('UNKNOWN_COLUMN')
- expect(ffiError instanceof FfiProtectError).toBe(true)
- })
- })
-
- describe('encryptModel error codes', () => {
- it(
- 'preserves FFI error codes',
- async () => {
- const model = { nonexistent: 'test value' }
-
- const result = await protectDynamo.encryptModel(model, badSchema)
-
- expect(result.failure).toBeDefined()
- expect((result.failure as ProtectDynamoDBError).code).toBe(
- 'UNKNOWN_COLUMN',
- )
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('decryptModel error codes', () => {
- it(
- 'uses PROTECT_DYNAMODB_ERROR for IO/parsing errors without FFI codes',
- async () => {
- // Malformed ciphertext causes IO/parsing errors that don't have FFI error codes
- const malformedItem = {
- email__source: 'invalid_ciphertext_data',
- }
-
- const result = await protectDynamo.decryptModel(
- malformedItem,
- testSchema,
- )
-
- expect(result.failure).toBeDefined()
- // FFI returns undefined code for IO/parsing errors, so we fall back to generic code
- expect((result.failure as ProtectDynamoDBError).code).toBe(
- 'PROTECT_DYNAMODB_ERROR',
- )
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('bulkEncryptModels error codes', () => {
- it(
- 'preserves FFI error codes',
- async () => {
- const models = [{ nonexistent: 'value1' }, { nonexistent: 'value2' }]
-
- const result = await protectDynamo.bulkEncryptModels(models, badSchema)
-
- expect(result.failure).toBeDefined()
- expect((result.failure as ProtectDynamoDBError).code).toBe(
- 'UNKNOWN_COLUMN',
- )
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('bulkDecryptModels error codes', () => {
- it(
- 'uses PROTECT_DYNAMODB_ERROR for IO/parsing errors without FFI codes',
- async () => {
- // Malformed ciphertext causes IO/parsing errors that don't have FFI error codes
- const malformedItems = [
- { email__source: 'invalid1' },
- { email__source: 'invalid2' },
- ]
-
- const result = await protectDynamo.bulkDecryptModels(
- malformedItems,
- testSchema,
- )
-
- expect(result.failure).toBeDefined()
- // FFI returns undefined code for IO/parsing errors, so we fall back to generic code
- expect((result.failure as ProtectDynamoDBError).code).toBe(
- 'PROTECT_DYNAMODB_ERROR',
- )
- },
- FFI_TEST_TIMEOUT,
- )
- })
-})
diff --git a/packages/protect-dynamodb/__tests__/helpers.test.ts b/packages/protect-dynamodb/__tests__/helpers.test.ts
deleted file mode 100644
index aeb22bdc6..000000000
--- a/packages/protect-dynamodb/__tests__/helpers.test.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { csColumn, csTable } from '@cipherstash/protect'
-import { describe, expect, it } from 'vitest'
-import {
- ciphertextAttrSuffix,
- searchTermAttrSuffix,
- toItemWithEqlPayloads,
-} from '../src/helpers'
-
-describe('toItemWithEqlPayloads', () => {
- it('wraps searchable JSON columns as ste_vec (k: "sv") EQL payloads', () => {
- // Regression test for `cast_as === 'json'` (previously 'jsonb'). The
- // searchableJson() builder produces cast_as: 'json' + ste_vec index, and
- // this branch must emit k: 'sv' so DynamoDB items round-trip through
- // decrypt without losing the ste_vec discriminant.
- const schema = csTable('users', {
- preferences: csColumn('preferences').searchableJson(),
- })
-
- const stored = [{ s: 'selector', t: 'term' }]
- const item = {
- id: 'user-1',
- [`preferences${ciphertextAttrSuffix}`]: stored,
- }
-
- const result = toItemWithEqlPayloads(item, schema)
-
- expect(result).toEqual({
- id: 'user-1',
- preferences: {
- i: { c: 'preferences', t: 'users' },
- v: 2,
- k: 'sv',
- sv: stored,
- },
- })
- })
-
- it('wraps non-ste_vec columns as scalar ciphertext (k: "ct") EQL payloads', () => {
- const schema = csTable('users', {
- email: csColumn('email').equality(),
- })
-
- const ciphertext = 'mp_base85_ciphertext'
- const item = {
- id: 'user-1',
- [`email${ciphertextAttrSuffix}`]: ciphertext,
- [`email${searchTermAttrSuffix}`]: 'hmac-value',
- }
-
- const result = toItemWithEqlPayloads(item, schema)
-
- // HMAC attribute is stripped; ciphertext is wrapped as k: 'ct'.
- expect(result).toEqual({
- id: 'user-1',
- email: {
- i: { c: 'email', t: 'users' },
- v: 2,
- k: 'ct',
- c: ciphertext,
- },
- })
- })
-
- it('wraps non-searchable JSON columns as scalar ciphertext (k: "ct")', () => {
- // A plain `dataType('json')` column has cast_as: 'json' but no ste_vec
- // index — it must take the default `k: 'ct'` branch, not `k: 'sv'`.
- const schema = csTable('users', {
- metadata: csColumn('metadata').dataType('json'),
- })
-
- const ciphertext = 'mp_base85_ciphertext'
- const item = {
- id: 'user-1',
- [`metadata${ciphertextAttrSuffix}`]: ciphertext,
- }
-
- const result = toItemWithEqlPayloads(item, schema)
-
- expect(result).toEqual({
- id: 'user-1',
- metadata: {
- i: { c: 'metadata', t: 'users' },
- v: 2,
- k: 'ct',
- c: ciphertext,
- },
- })
- })
-})
diff --git a/packages/protect-dynamodb/package.json b/packages/protect-dynamodb/package.json
deleted file mode 100644
index 2e1d99d01..000000000
--- a/packages/protect-dynamodb/package.json
+++ /dev/null
@@ -1,54 +0,0 @@
-{
- "name": "@cipherstash/protect-dynamodb",
- "version": "12.0.2-rc.0",
- "description": "Protect.js DynamoDB Helpers",
- "keywords": [
- "dynamodb",
- "cipherstash",
- "protect",
- "encrypt",
- "decrypt",
- "security"
- ],
- "bugs": {
- "url": "https://github.com/cipherstash/stack/issues"
- },
- "repository": {
- "type": "git",
- "url": "git+https://github.com/cipherstash/stack.git",
- "directory": "packages/protect-dynamodb"
- },
- "license": "MIT",
- "author": "CipherStash ",
- "type": "module",
- "exports": {
- ".": {
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js",
- "require": "./dist/index.cjs"
- }
- },
- "scripts": {
- "build": "tsup",
- "dev": "tsup --watch",
- "test": "vitest run",
- "release": "tsup"
- },
- "devDependencies": {
- "@cipherstash/protect": "workspace:*",
- "dotenv": "^17.4.2",
- "tsup": "catalog:repo",
- "tsx": "catalog:repo",
- "typescript": "catalog:repo",
- "vitest": "catalog:repo"
- },
- "peerDependencies": {
- "@cipherstash/protect": "workspace:*"
- },
- "publishConfig": {
- "access": "public"
- },
- "dependencies": {
- "@byteslice/result": "^0.2.0"
- }
-}
diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/protect-dynamodb/src/helpers.ts
deleted file mode 100644
index 505e5c54b..000000000
--- a/packages/protect-dynamodb/src/helpers.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import type {
- Encrypted,
- ProtectErrorCode,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
-import { FfiProtectError } from '@cipherstash/protect'
-import type { ProtectDynamoDBError } from './types'
-export const ciphertextAttrSuffix = '__source'
-export const searchTermAttrSuffix = '__hmac'
-
-export class ProtectDynamoDBErrorImpl
- extends Error
- implements ProtectDynamoDBError
-{
- constructor(
- message: string,
- public code: ProtectErrorCode | 'PROTECT_DYNAMODB_ERROR',
- public details?: Record,
- ) {
- super(message)
- this.name = 'ProtectDynamoDBError'
- }
-}
-
-export function handleError(
- error: unknown,
- context: string,
- options?: {
- logger?: {
- error: (message: string, error: Error) => void
- }
- errorHandler?: (error: ProtectDynamoDBError) => void
- },
-): ProtectDynamoDBError {
- // Preserve FFI error code if available, otherwise use generic DynamoDB error code
- // Check for FfiProtectError instance or plain ProtectError objects with code property
- const errorObj = error as Record
- const errorCode =
- error instanceof FfiProtectError
- ? error.code
- : errorObj &&
- typeof errorObj === 'object' &&
- 'code' in errorObj &&
- typeof errorObj.code === 'string'
- ? (errorObj.code as ProtectErrorCode)
- : 'PROTECT_DYNAMODB_ERROR'
-
- const errorMessage =
- error instanceof Error
- ? error.message
- : errorObj && typeof errorObj.message === 'string'
- ? errorObj.message
- : String(error)
-
- const protectError = new ProtectDynamoDBErrorImpl(errorMessage, errorCode, {
- context,
- })
-
- if (options?.errorHandler) {
- options.errorHandler(protectError)
- }
-
- if (options?.logger) {
- options.logger.error(`Error in ${context}`, protectError)
- }
-
- return protectError
-}
-
-export function deepClone(obj: T): T {
- if (obj === null || typeof obj !== 'object') {
- return obj
- }
-
- if (Array.isArray(obj)) {
- return obj.map((item) => deepClone(item)) as unknown as T
- }
-
- return Object.entries(obj as Record).reduce(
- (acc, [key, value]) => ({
- // biome-ignore lint/performance/noAccumulatingSpread: TODO later
- ...acc,
- [key]: deepClone(value),
- }),
- {} as T,
- )
-}
-
-export function toEncryptedDynamoItem(
- encrypted: Record,
- encryptedAttrs: string[],
-): Record {
- function processValue(
- attrName: string,
- attrValue: unknown,
- isNested: boolean,
- ): Record {
- if (attrValue === null || attrValue === undefined) {
- return { [attrName]: attrValue }
- }
-
- // Handle encrypted payload
- if (
- encryptedAttrs.includes(attrName) ||
- (isNested &&
- typeof attrValue === 'object' &&
- 'c' in (attrValue as object))
- ) {
- const encryptPayload = attrValue as Encrypted
- if (encryptPayload?.k === 'ct' && encryptPayload.c) {
- const result: Record = {}
- if (encryptPayload.hm) {
- result[`${attrName}${searchTermAttrSuffix}`] = encryptPayload.hm
- }
- result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.c
- return result
- }
-
- if (encryptPayload?.k === 'sv' && encryptPayload.sv) {
- const result: Record = {}
- result[`${attrName}${ciphertextAttrSuffix}`] = encryptPayload.sv
- return result
- }
- }
-
- // Handle nested objects recursively
- if (typeof attrValue === 'object' && !Array.isArray(attrValue)) {
- const nestedResult = Object.entries(
- attrValue as Record,
- ).reduce(
- (acc, [key, val]) => {
- const processed = processValue(key, val, true)
- return Object.assign({}, acc, processed)
- },
- {} as Record,
- )
- return { [attrName]: nestedResult }
- }
-
- // Handle non-encrypted values
- return { [attrName]: attrValue }
- }
-
- return Object.entries(encrypted).reduce(
- (putItem, [attrName, attrValue]) => {
- const processed = processValue(attrName, attrValue, false)
- return Object.assign({}, putItem, processed)
- },
- {} as Record,
- )
-}
-
-export function toItemWithEqlPayloads(
- decrypted: Record,
- encryptSchemas: ProtectTable,
-): Record {
- function processValue(
- attrName: string,
- attrValue: unknown,
- isNested: boolean,
- ): Record {
- if (attrValue === null || attrValue === undefined) {
- return { [attrName]: attrValue }
- }
-
- // Skip HMAC fields
- if (attrName.endsWith(searchTermAttrSuffix)) {
- return {}
- }
-
- const encryptConfig = encryptSchemas.build()
- const encryptedAttrs = Object.keys(encryptConfig.columns)
- const columnName = attrName.slice(0, -ciphertextAttrSuffix.length)
-
- // Handle encrypted payload
- if (
- attrName.endsWith(ciphertextAttrSuffix) &&
- (encryptedAttrs.includes(columnName) || isNested)
- ) {
- // TODO: Implement the standardized typing for Encrypted Payloads through the FFI interface
- const i = { c: columnName, t: encryptConfig.tableName }
- const v = 2
-
- // Nested values are not searchable, so we can just return the standard EQL payload.
- // Worth noting, that encryptConfig.columns[columnName] will be undefined if isNested is true.
- if (
- !isNested &&
- encryptConfig.columns[columnName].cast_as === 'json' &&
- encryptConfig.columns[columnName].indexes.ste_vec
- ) {
- return {
- [columnName]: {
- i,
- v,
- k: 'sv',
- sv: attrValue,
- },
- }
- }
-
- return {
- [columnName]: {
- i,
- v,
- k: 'ct',
- c: attrValue,
- },
- }
- }
-
- // Handle nested objects recursively
- if (typeof attrValue === 'object' && !Array.isArray(attrValue)) {
- const nestedResult = Object.entries(
- attrValue as Record,
- ).reduce(
- (acc, [key, val]) => {
- const processed = processValue(key, val, true)
- return Object.assign({}, acc, processed)
- },
- {} as Record,
- )
- return { [attrName]: nestedResult }
- }
-
- // Handle non-encrypted values
- return { [attrName]: attrValue }
- }
-
- return Object.entries(decrypted).reduce(
- (formattedItem, [attrName, attrValue]) => {
- const processed = processValue(attrName, attrValue, false)
- return Object.assign({}, formattedItem, processed)
- },
- {} as Record,
- )
-}
diff --git a/packages/protect-dynamodb/src/index.ts b/packages/protect-dynamodb/src/index.ts
deleted file mode 100644
index fe18ddfa7..000000000
--- a/packages/protect-dynamodb/src/index.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type {
- Encrypted,
- ProtectTable,
- ProtectTableColumn,
- SearchTerm,
-} from '@cipherstash/protect'
-import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models'
-import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models'
-import { DecryptModelOperation } from './operations/decrypt-model'
-import { EncryptModelOperation } from './operations/encrypt-model'
-import { SearchTermsOperation } from './operations/search-terms'
-import type { ProtectDynamoDBConfig, ProtectDynamoDBInstance } from './types'
-
-export function protectDynamoDB(
- config: ProtectDynamoDBConfig,
-): ProtectDynamoDBInstance {
- const { protectClient, options } = config
-
- return {
- encryptModel>(
- item: T,
- protectTable: ProtectTable,
- ) {
- return new EncryptModelOperation(
- protectClient,
- item,
- protectTable,
- options,
- )
- },
-
- bulkEncryptModels>(
- items: T[],
- protectTable: ProtectTable,
- ) {
- return new BulkEncryptModelsOperation(
- protectClient,
- items,
- protectTable,
- options,
- )
- },
-
- decryptModel>(
- item: Record,
- protectTable: ProtectTable,
- ) {
- return new DecryptModelOperation(
- protectClient,
- item,
- protectTable,
- options,
- )
- },
-
- bulkDecryptModels>(
- items: Record[],
- protectTable: ProtectTable,
- ) {
- return new BulkDecryptModelsOperation(
- protectClient,
- items,
- protectTable,
- options,
- )
- },
-
- /**
- * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups.
- */
- createSearchTerms(terms: SearchTerm[]) {
- return new SearchTermsOperation(protectClient, terms, options)
- },
- }
-}
-
-export * from './types'
diff --git a/packages/protect-dynamodb/src/operations/base-operation.ts b/packages/protect-dynamodb/src/operations/base-operation.ts
deleted file mode 100644
index e9ea0bb64..000000000
--- a/packages/protect-dynamodb/src/operations/base-operation.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import type { Result } from '@byteslice/result'
-import type { ProtectDynamoDBError } from '../types'
-
-export type AuditConfig = {
- metadata?: Record
-}
-
-export type AuditData = {
- metadata?: Record
-}
-
-export type DynamoDBOperationOptions = {
- logger?: {
- error: (message: string, error: Error) => void
- }
- errorHandler?: (error: ProtectDynamoDBError) => void
-}
-
-export abstract class DynamoDBOperation {
- protected auditMetadata?: Record
- protected logger?: DynamoDBOperationOptions['logger']
- protected errorHandler?: DynamoDBOperationOptions['errorHandler']
-
- constructor(options?: DynamoDBOperationOptions) {
- this.logger = options?.logger
- this.errorHandler = options?.errorHandler
- }
-
- /**
- * Attach audit metadata to this operation. Can be chained.
- */
- audit(config: AuditConfig): this {
- this.auditMetadata = config.metadata
- return this
- }
-
- /**
- * Get the audit metadata for this operation.
- */
- protected getAuditData(): AuditData {
- return {
- metadata: this.auditMetadata,
- }
- }
-
- /**
- * Execute the operation and return a Result
- */
- abstract execute(): Promise>
-
- /**
- * Make the operation thenable
- */
- public then, TResult2 = never>(
- onfulfilled?:
- | ((
- value: Result,
- ) => TResult1 | PromiseLike)
- | null,
- onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null,
- ): Promise {
- return this.execute().then(onfulfilled, onrejected)
- }
-}
diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts
deleted file mode 100644
index f27e6dd4a..000000000
--- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { type Result, withResult } from '@byteslice/result'
-import type {
- Decrypted,
- Encrypted,
- ProtectClient,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
-import { handleError, toItemWithEqlPayloads } from '../helpers'
-import type { ProtectDynamoDBError } from '../types'
-import {
- DynamoDBOperation,
- type DynamoDBOperationOptions,
-} from './base-operation'
-
-export class BulkDecryptModelsOperation<
- T extends Record,
-> extends DynamoDBOperation[]> {
- private protectClient: ProtectClient
- private items: Record[]
- private protectTable: ProtectTable
-
- constructor(
- protectClient: ProtectClient,
- items: Record[],
- protectTable: ProtectTable,
- options?: DynamoDBOperationOptions,
- ) {
- super(options)
- this.protectClient = protectClient
- this.items = items
- this.protectTable = protectTable
- }
-
- public async execute(): Promise<
- Result[], ProtectDynamoDBError>
- > {
- return await withResult(
- async () => {
- const itemsWithEqlPayloads = this.items.map((item) =>
- toItemWithEqlPayloads(item, this.protectTable),
- )
-
- const decryptResult = await this.protectClient
- .bulkDecryptModels(itemsWithEqlPayloads as T[])
- .audit(this.getAuditData())
-
- if (decryptResult.failure) {
- // Create an Error object that preserves the FFI error code
- // This is necessary because withResult's ensureError wraps non-Error objects
- const error = new Error(decryptResult.failure.message) as Error & {
- code?: string
- }
- error.code = decryptResult.failure.code
- throw error
- }
-
- return decryptResult.data
- },
- (error) =>
- handleError(error, 'bulkDecryptModels', {
- logger: this.logger,
- errorHandler: this.errorHandler,
- }),
- )
- }
-}
diff --git a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts b/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts
deleted file mode 100644
index 855f9d142..000000000
--- a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { type Result, withResult } from '@byteslice/result'
-import type {
- ProtectClient,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
-import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers'
-import type { ProtectDynamoDBError } from '../types'
-import {
- DynamoDBOperation,
- type DynamoDBOperationOptions,
-} from './base-operation'
-
-export class BulkEncryptModelsOperation<
- T extends Record,
-> extends DynamoDBOperation {
- private protectClient: ProtectClient
- private items: T[]
- private protectTable: ProtectTable
-
- constructor(
- protectClient: ProtectClient,
- items: T[],
- protectTable: ProtectTable,
- options?: DynamoDBOperationOptions,
- ) {
- super(options)
- this.protectClient = protectClient
- this.items = items
- this.protectTable = protectTable
- }
-
- public async execute(): Promise> {
- return await withResult(
- async () => {
- const encryptResult = await this.protectClient
- .bulkEncryptModels(
- this.items.map((item) => deepClone(item)),
- this.protectTable,
- )
- .audit(this.getAuditData())
-
- if (encryptResult.failure) {
- // Create an Error object that preserves the FFI error code
- // This is necessary because withResult's ensureError wraps non-Error objects
- const error = new Error(encryptResult.failure.message) as Error & {
- code?: string
- }
- error.code = encryptResult.failure.code
- throw error
- }
-
- const data = encryptResult.data.map((item) => deepClone(item))
- const encryptedAttrs = Object.keys(this.protectTable.build().columns)
-
- return data.map(
- (encrypted) => toEncryptedDynamoItem(encrypted, encryptedAttrs) as T,
- )
- },
- (error) =>
- handleError(error, 'bulkEncryptModels', {
- logger: this.logger,
- errorHandler: this.errorHandler,
- }),
- )
- }
-}
diff --git a/packages/protect-dynamodb/src/operations/decrypt-model.ts b/packages/protect-dynamodb/src/operations/decrypt-model.ts
deleted file mode 100644
index c6980108b..000000000
--- a/packages/protect-dynamodb/src/operations/decrypt-model.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { type Result, withResult } from '@byteslice/result'
-import type {
- Decrypted,
- Encrypted,
- ProtectClient,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
-import { handleError, toItemWithEqlPayloads } from '../helpers'
-import type { ProtectDynamoDBError } from '../types'
-import {
- DynamoDBOperation,
- type DynamoDBOperationOptions,
-} from './base-operation'
-
-export class DecryptModelOperation<
- T extends Record,
-> extends DynamoDBOperation> {
- private protectClient: ProtectClient
- private item: Record
- private protectTable: ProtectTable
-
- constructor(
- protectClient: ProtectClient,
- item: Record,
- protectTable: ProtectTable,
- options?: DynamoDBOperationOptions,
- ) {
- super(options)
- this.protectClient = protectClient
- this.item = item
- this.protectTable = protectTable
- }
-
- public async execute(): Promise, ProtectDynamoDBError>> {
- return await withResult(
- async () => {
- const withEqlPayloads = toItemWithEqlPayloads(
- this.item,
- this.protectTable,
- )
-
- const decryptResult = await this.protectClient
- .decryptModel(withEqlPayloads as T)
- .audit(this.getAuditData())
-
- if (decryptResult.failure) {
- // Create an Error object that preserves the FFI error code
- // This is necessary because withResult's ensureError wraps non-Error objects
- const error = new Error(decryptResult.failure.message) as Error & {
- code?: string
- }
- error.code = decryptResult.failure.code
- throw error
- }
-
- return decryptResult.data
- },
- (error) =>
- handleError(error, 'decryptModel', {
- logger: this.logger,
- errorHandler: this.errorHandler,
- }),
- )
- }
-}
diff --git a/packages/protect-dynamodb/src/operations/encrypt-model.ts b/packages/protect-dynamodb/src/operations/encrypt-model.ts
deleted file mode 100644
index 82ee1bd85..000000000
--- a/packages/protect-dynamodb/src/operations/encrypt-model.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { type Result, withResult } from '@byteslice/result'
-import type {
- ProtectClient,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
-import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers'
-import type { ProtectDynamoDBError } from '../types'
-import {
- DynamoDBOperation,
- type DynamoDBOperationOptions,
-} from './base-operation'
-
-export class EncryptModelOperation<
- T extends Record,
-> extends DynamoDBOperation {
- private protectClient: ProtectClient
- private item: T
- private protectTable: ProtectTable
-
- constructor(
- protectClient: ProtectClient,
- item: T,
- protectTable: ProtectTable,
- options?: DynamoDBOperationOptions,
- ) {
- super(options)
- this.protectClient = protectClient
- this.item = item
- this.protectTable = protectTable
- }
-
- public async execute(): Promise> {
- return await withResult(
- async () => {
- const encryptResult = await this.protectClient
- .encryptModel(deepClone(this.item), this.protectTable)
- .audit(this.getAuditData())
-
- if (encryptResult.failure) {
- // Create an Error object that preserves the FFI error code
- // This is necessary because withResult's ensureError wraps non-Error objects
- const error = new Error(encryptResult.failure.message) as Error & {
- code?: string
- }
- error.code = encryptResult.failure.code
- throw error
- }
-
- const data = deepClone(encryptResult.data)
- const encryptedAttrs = Object.keys(this.protectTable.build().columns)
-
- return toEncryptedDynamoItem(data, encryptedAttrs) as T
- },
- (error) =>
- handleError(error, 'encryptModel', {
- logger: this.logger,
- errorHandler: this.errorHandler,
- }),
- )
- }
-}
diff --git a/packages/protect-dynamodb/src/operations/search-terms.ts b/packages/protect-dynamodb/src/operations/search-terms.ts
deleted file mode 100644
index 4da416bae..000000000
--- a/packages/protect-dynamodb/src/operations/search-terms.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { type Result, withResult } from '@byteslice/result'
-import {
- isEncryptedScalarQuery,
- type ProtectClient,
- type SearchTerm,
-} from '@cipherstash/protect'
-import { handleError } from '../helpers'
-import type { ProtectDynamoDBError } from '../types'
-import {
- DynamoDBOperation,
- type DynamoDBOperationOptions,
-} from './base-operation'
-
-/**
- * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups.
- *
- * @example
- * ```typescript
- * // Before (deprecated)
- * const result = await protectDynamo.createSearchTerms([{ value, column, table }])
- * const hmac = result.data[0]
- *
- * // After (new API)
- * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }])
- * const hmac = encrypted.hm
- * ```
- */
-export class SearchTermsOperation extends DynamoDBOperation {
- private protectClient: ProtectClient
- private terms: SearchTerm[]
-
- constructor(
- protectClient: ProtectClient,
- terms: SearchTerm[],
- options?: DynamoDBOperationOptions,
- ) {
- super(options)
- this.protectClient = protectClient
- this.terms = terms
- }
-
- public async execute(): Promise> {
- return await withResult(
- async () => {
- const searchTermsResult = await this.protectClient
- .createSearchTerms(this.terms)
- .audit(this.getAuditData())
-
- if (searchTermsResult.failure) {
- throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
- }
-
- return searchTermsResult.data.map((term) => {
- if (typeof term === 'string') {
- throw new Error(
- 'expected encrypted search term to be an EncryptedPayload',
- )
- }
-
- // DynamoDB lookups go through equality queries → the FFI returns an
- // EncryptedScalarQuery carrying `hm`. Anything else (scalar storage,
- // a `bf`/`ob` query, or a SteVec payload) is a misconfiguration.
- if (!isEncryptedScalarQuery(term) || !('hm' in term)) {
- throw new Error('expected encrypted search term to have an HMAC')
- }
-
- return term.hm
- })
- },
- (error) =>
- handleError(error, 'createSearchTerms', {
- logger: this.logger,
- errorHandler: this.errorHandler,
- }),
- )
- }
-}
diff --git a/packages/protect-dynamodb/src/types.ts b/packages/protect-dynamodb/src/types.ts
deleted file mode 100644
index 6cd7338ba..000000000
--- a/packages/protect-dynamodb/src/types.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import type {
- Encrypted,
- ProtectClient,
- ProtectErrorCode,
- ProtectTable,
- ProtectTableColumn,
- SearchTerm,
-} from '@cipherstash/protect'
-import type { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models'
-import type { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models'
-import type { DecryptModelOperation } from './operations/decrypt-model'
-import type { EncryptModelOperation } from './operations/encrypt-model'
-import type { SearchTermsOperation } from './operations/search-terms'
-
-export interface ProtectDynamoDBConfig {
- protectClient: ProtectClient
- options?: {
- logger?: {
- error: (message: string, error: Error) => void
- }
- errorHandler?: (error: ProtectDynamoDBError) => void
- }
-}
-
-export interface ProtectDynamoDBError extends Error {
- code: ProtectErrorCode | 'PROTECT_DYNAMODB_ERROR'
- details?: Record
-}
-
-export interface ProtectDynamoDBInstance {
- encryptModel>(
- item: T,
- protectTable: ProtectTable,
- ): EncryptModelOperation
-
- bulkEncryptModels>(
- items: T[],
- protectTable: ProtectTable,
- ): BulkEncryptModelsOperation
-
- decryptModel>(
- item: Record,
- protectTable: ProtectTable,
- ): DecryptModelOperation
-
- bulkDecryptModels>(
- items: Record[],
- protectTable: ProtectTable,
- ): BulkDecryptModelsOperation
-
- /**
- * @deprecated Use `protectClient.encryptQuery(terms)` instead and extract the `hm` field for DynamoDB key lookups.
- *
- * @example
- * ```typescript
- * // Before (deprecated)
- * const result = await protectDynamo.createSearchTerms([{ value, column, table }])
- * const hmac = result.data[0]
- *
- * // After (new API)
- * const [encrypted] = await protectClient.encryptQuery([{ value, column, table, queryType: 'equality' }])
- * const hmac = encrypted.hm
- * ```
- */
- createSearchTerms(terms: SearchTerm[]): SearchTermsOperation
-}
diff --git a/packages/protect-dynamodb/tsconfig.json b/packages/protect-dynamodb/tsconfig.json
deleted file mode 100644
index 6da81c927..000000000
--- a/packages/protect-dynamodb/tsconfig.json
+++ /dev/null
@@ -1,28 +0,0 @@
-{
- "compilerOptions": {
- // Enable latest features
- "lib": ["ESNext", "DOM"],
- "target": "ESNext",
- "module": "ESNext",
- "moduleDetection": "force",
- "jsx": "react-jsx",
- "allowJs": true,
- "esModuleInterop": true,
-
- // Bundler mode
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "noEmit": true,
-
- // Best practices
- "strict": true,
- "skipLibCheck": true,
- "noFallthroughCasesInSwitch": true,
-
- // Some stricter flags (disabled by default)
- "noUnusedLocals": false,
- "noUnusedParameters": false,
- "noPropertyAccessFromIndexSignature": false
- }
-}
diff --git a/packages/protect-dynamodb/tsup.config.ts b/packages/protect-dynamodb/tsup.config.ts
deleted file mode 100644
index 0ced0a354..000000000
--- a/packages/protect-dynamodb/tsup.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { defineConfig } from 'tsup'
-
-export default defineConfig({
- entry: ['src/index.ts'],
- format: ['cjs', 'esm'],
- sourcemap: true,
- dts: true,
-})
diff --git a/packages/protect/.npmignore b/packages/protect/.npmignore
deleted file mode 100644
index 3490e24db..000000000
--- a/packages/protect/.npmignore
+++ /dev/null
@@ -1,5 +0,0 @@
-.env
-.turbo
-node_modules
-cipherstash.secret.toml
-cipherstash.toml
\ No newline at end of file
diff --git a/packages/protect/CHANGELOG.md b/packages/protect/CHANGELOG.md
deleted file mode 100644
index 9c79eaf46..000000000
--- a/packages/protect/CHANGELOG.md
+++ /dev/null
@@ -1,439 +0,0 @@
-# @cipherstash/protect
-
-## 12.0.2-rc.0
-
-### Patch Changes
-
-- Updated dependencies [229ce59]
- - @cipherstash/schema@3.0.2-rc.0
-
-## 12.0.1
-
-### Patch Changes
-
-- aa9c4b1: Documentation: refresh package READMEs after the protectjs → stack repository rename. Fixed repository and license links, replaced dead in-repo docs links with cipherstash.com/docs URLs, rewrote the incorrect @cipherstash/nextjs README, and added guidance pointing new projects to @cipherstash/stack.
-- Updated dependencies [aa9c4b1]
- - @cipherstash/schema@3.0.1
-
-## 12.0.0
-
-### Major Changes
-
-- f743fcc: Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`.
-
- Breaking upstream changes adopted in this release:
-
- - **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code.
- - **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`.
- - **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK.
- - **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)).
- - **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases.
- - **`match` index validation**: protect-ffi now rejects `match` indexes on columns whose `cast_as` is not text-family (`'text'` / `'string'`) with `MATCH_REQUIRES_TEXT`. The stack's `freeTextSearch()` builder is unaffected because it only targets string-typed columns.
- - **`Encrypted` ciphertext shape**: protect-ffi's `Encrypted` type is now a discriminated union keyed on `k` (`'ct'` for scalars, `'sv'` for SteVec). SteVec storage payloads now place the root document ciphertext at `sv[0].c`. The stack's `isEncryptedPayload` runtime check continues to work because storage payloads still carry `c` (scalar) or `sv` (SteVec). The DynamoDB helpers (`toEncryptedDynamoItem`, `SearchTermsOperation`) now narrow on `k` before reading variant-only fields.
- - **Config-validation error message wording**: error messages for config-validation failures now come from upstream `ConfigError`. `ProtectError.code` values are preserved; consumers that string-match on `err.message` for config-validation errors must update.
-
-### Patch Changes
-
-- Updated dependencies [f743fcc]
- - @cipherstash/schema@3.0.0
-
-## 11.1.2
-
-### Patch Changes
-
-- a8dbb65: Render every user-facing CLI string and execute every shell-out under the detected package manager (`npx` / `bunx` / `pnpm dlx` / `yarn dlx`), completing the work started in #379. Affected surfaces: `@cipherstash/cli` top-level + `auth` + `env` help, `db install` Drizzle migration steps, `db migrate` not-implemented warning, the Supabase migration SQL header, the Supabase status fallback exec, the `@cipherstash/protect` `stash` Stricli help (set/get/list/delete), the `@cipherstash/wizard` usage line and agent command allowlist, and the `@cipherstash/drizzle` `generate-eql-migration` help + drizzle-kit invocation. A new `pnpm run lint:runners` lint runs in CI and fails on any reintroduction of a hardcoded runner literal.
-
-## 11.1.1
-
-### Patch Changes
-
-- afe6810: Bump protect-ffi version
-
-## 11.1.0
-
-### Minor Changes
-
-- 068f820: Release the consolidated CipherStash CLI npm package.
-
-## 11.0.0
-
-### Major Changes
-
-- b0e56b8: Upgrade protect-ffi to 0.21.0 and enable array_index_mode for searchable JSON
-
- - Upgrade `@cipherstash/protect-ffi` to 0.21.0 across all packages
- - Enable `array_index_mode: 'all'` on STE vec indexes so JSON array operations
- (jsonb_array_elements, jsonb_array_length, array containment) work correctly
- - Delegate credential resolution entirely to protect-ffi's `withEnvCredentials`
- - Download latest EQL at build/runtime instead of bundling hardcoded SQL files
-
-### Patch Changes
-
-- Updated dependencies [b0e56b8]
- - @cipherstash/schema@2.2.0
-
-## 10.5.0
-
-### Minor Changes
-
-- db72e2c: Add `encryptQuery` API for encrypting query terms with explicit query type selection.
-
- - New `encryptQuery()` method replaces `createSearchTerms()` with improved query type handling
- - Supports `equality`, `freeTextSearch`, and `orderAndRange` query types
- - Deprecates `createSearchTerms()` - use `encryptQuery()` instead
- - Updates drizzle operators to use correct index selection via `queryType` parameter
-
-- e769740: Add encrypted JSONB query support with `searchableJson()` (recommended).
-
- - New `searchableJson()` schema method enables encrypted JSONB path and containment queries
- - Automatic query operation inference: string values become JSONPath selector queries, objects/arrays become containment queries
- - Also supports explicit `queryType: 'steVecSelector'` and `queryType: 'steVecTerm'` for advanced use cases
- - JSONB path utilities (`toJsonPath`, `buildNestedObject`, `parseJsonbPath`) for building encrypted JSON column queries
-
-### Patch Changes
-
-- Updated dependencies [e769740]
- - @cipherstash/schema@2.1.0
-
-## 10.4.0
-
-### Minor Changes
-
-- 9ccaf68: Allow stash cli tool to read env files from .env.\*.
-
-## 10.3.0
-
-### Minor Changes
-
-- a1fce2b: Add Stash interface and CLI tool.
-
-### Patch Changes
-
-- 622b684: Update @cipherstash/protect-ffi to 0.19.0
-
-## 10.2.1
-
-### Patch Changes
-
-- Updated dependencies [532ac3a]
- - @cipherstash/schema@2.0.2
-
-## 10.2.0
-
-### Minor Changes
-
-- de029de: Add client safe exports.
-
-## 10.1.1
-
-### Patch Changes
-
-- ff4421f: Expanded typedoc documentation
-- Updated dependencies [ff4421f]
- - @cipherstash/schema@2.0.1
-
-## 10.1.0
-
-### Minor Changes
-
-- 6b87c17: Added support for multi-tenant encryption with configurable keysets.
-
-## 10.0.2
-
-### Patch Changes
-
-- Updated dependencies [9005484]
- - @cipherstash/schema@2.0.0
-
-## 10.0.1
-
-### Patch Changes
-
-- Updated dependencies [d8ed4d4]
- - @cipherstash/schema@1.1.0
-
-## 10.0.0
-
-### Major Changes
-
-- 788dbfc: Added JSON and INT data type support and update FFI to v0.17.1 with x86_64 musl environment platform support.
-
- - Update @cipherstash/protect-ffi from 0.16.0 to 0.17.1 with support for x86_64 musl platforms.
- - Add searchableJson() method to schema for JSON field indexing (the search operations still don't work but this interface exists)
- - Refactor type system: EncryptedPayload → Encrypted, add JsPlaintext
- - Add comprehensive test suites for JSON, integer, and basic encryption
- - Update encryption format to use 'k' property for searchable JSON
- - Remove deprecated search terms tests for JSON fields
- - Simplify schema data types to text, int, json only
- - Update model helpers to handle new encryption format
- - Fix type safety issues in bulk operations and model encryption
-
-### Patch Changes
-
-- Updated dependencies [788dbfc]
- - @cipherstash/schema@1.0.0
-
-## 9.6.0
-
-### Minor Changes
-
-- c7ed7ab: Support TypeORM example with ES2022.
-- 211e979: Added support for ES2022 and later.
-
-## 9.5.0
-
-### Minor Changes
-
-- 6f45b02: Fully implemented audit metadata functionality.
-
-## 9.4.1
-
-### Patch Changes
-
-- Updated dependencies [d0b02ea]
- - @cipherstash/schema@0.1.0
-
-## 9.4.0
-
-### Minor Changes
-
-- 1cc4772: Released support for bulk encryption and decryption.
-
-## 9.3.0
-
-### Minor Changes
-
-- 01fed9e: Added audit support for all protect and protect-dynamodb interfaces.
-
-## 9.2.0
-
-### Minor Changes
-
-- 587f222: Added support for deeply nested protect schemas to support more complex model objects.
-
-## 9.1.0
-
-### Minor Changes
-
-- c8468ee: Released initial version of the DynamoDB helper interface.
-
-## 9.0.0
-
-### Major Changes
-
-- 1bc55a0: Implemented a more configurable pattern for the Protect client.
-
- This release introduces a new `ProtectClientConfig` type that can be used to configure the Protect client.
- This is useful if you want to configure the Protect client specific to your application, and will future proof any additional configuration options that are added in the future.
-
- ```ts
- import { protect, type ProtectClientConfig } from "@cipherstash/protect";
-
- const config: ProtectClientConfig = {
- schemas: [users, orders],
- workspaceCrn: "your-workspace-crn",
- accessKey: "your-access-key",
- clientId: "your-client-id",
- clientKey: "your-client-key",
- };
-
- const protectClient = await protect(config);
- ```
-
- The now deprecated method of passing your tables to the `protect` client is no longer supported.
-
- ```ts
- import { protect, type ProtectClientConfig } from "@cipherstash/protect";
-
- // old method (no longer supported)
- const protectClient = await protect(users, orders);
-
- // required method
- const config: ProtectClientConfig = {
- schemas: [users, orders],
- };
-
- const protectClient = await protect(config);
- ```
-
-## 8.4.0
-
-### Minor Changes
-
-- a471821: Fixed a bug in the model interface to correctly handle undefined and null values.
-
-## 8.3.0
-
-### Minor Changes
-
-- 628acdc: Implemented createSearchTerms for a streamlined way of working with encrypted search terms.
-
-## 8.2.0
-
-### Minor Changes
-
-- 0883e16: Fix cipherstash.toml and cipherstash.secret.toml file loading by bumping to @cipherstash/protect-ffi v0.14.2
-
-## 8.1.0
-
-### Minor Changes
-
-- 95c891d: Implemented CipherStash CRN in favor of workspace ID.
-
- - Replaces the environment variable `CS_WORKSPACE_ID` with `CS_WORKSPACE_CRN`
- - Replaces `workspace_id` with `workspace_crn` in the `cipherstash.toml` file
-
-- 18d3653: Fixed handling composite types for EQL v2.
-
-## 8.0.0
-
-### Major Changes
-
-- 8a4ea80: Implement EQL v2 data structure.
-
- - Support for Protect.js searchable encryption when using Supabase.
- - Encrypted payloads are now composite types which support searchable encryption with EQL v2 functions.
- - The `data` property is an object that matches the EQL v2 data structure.
-
-## 7.0.0
-
-### Major Changes
-
-- 2cb2d84: Replaced bulk operations with model operations.
-
-## 6.3.0
-
-### Minor Changes
-
-- a564f21: Bumped versions of dependencies to address CWE-346.
-
-## 6.2.0
-
-### Minor Changes
-
-- fe4b443: Added symbolic link for protect readme.
-
-## 6.1.0
-
-### Minor Changes
-
-- 43e1acb: \* Added support for searching encrypted data
- - Added a schema strategy for defining your schema
- - Required schema to initialize the protect client
-
-## 6.0.0
-
-### Major Changes
-
-- f4d8334: Released protectjs-ffi with toml file configuration support.
- Added a `withResult` pattern to all public facing functions for better error handling.
- Updated all documentation to reflect the new configuration pattern.
-
-## 5.2.0
-
-### Minor Changes
-
-- 499c246: Implemented protectjs-ffi.
-
-## 5.1.0
-
-### Minor Changes
-
-- 5a34e76: Rebranded logging context and fixed tests.
-
-## 5.0.0
-
-### Major Changes
-
-- 76599e5: Rebrand jseql to protect.
-
-## 4.0.0
-
-### Major Changes
-
-- 5c08fe5: Enforced lock context to be called as a proto function rather than an optional argument for crypto functions.
- There was a bug that caused the lock context to be interpreted as undefined when the users intention was to use it causing the encryption/decryption to fail.
- This is a breaking change for users who were using the lock context as an optional argument.
- To use the lock context, call the `withLockContext` method on the encrypt, decrypt, and bulk encrypt/decrypt functions, passing the lock context as a parameter rather than as an optional argument.
-
-## 3.9.0
-
-### Minor Changes
-
-- e885975: Fixed improper use of throwing errors, and log with jseql logger.
-
-## 3.8.0
-
-### Minor Changes
-
-- eeaec18: Implemented typing and import synatx for es6.
-
-## 3.7.0
-
-### Minor Changes
-
-- 7b8ec52: Implement packageless logging framework.
-
-## 3.6.0
-
-### Minor Changes
-
-- 7480cfd: Fixed node:util package bundling.
-
-## 3.5.0
-
-### Minor Changes
-
-- c0123be: Replaced logtape with native node debuglog.
-
-## 3.4.0
-
-### Minor Changes
-
-- 9a3132c: Implemented bulk encryption and decryptions.
-- 9a3132c: Fixed the logtape peer dependency version.
-
-## 3.3.0
-
-### Minor Changes
-
-- 80ee5af: Fixed bugs when implmenting the lock context with CTS v2 tokens.
-
-## 3.2.0
-
-### Minor Changes
-
-- 0526f60: Use the latest jseql-ffi (0.4.0)
-- fbb2bcb: Implemented CTS v2 for identity lock.
-
-## 3.1.0
-
-### Minor Changes
-
-- 71ce612: Released support for LockContext initializer.
-- e484718: Refactored init function to not require envrionment variables as arguments.
-- e484718: Replaces jset with vitest for better typescript support.
-
-## 3.0.0
-
-### Major Changes
-
-- 2eefb5f: Implemented jseql-ffi for inline crypto.
-
-## 2.1.0
-
-### Minor Changes
-
-- 0536f03: Implemented new CsPlaintextV1Schema type and schema.
-
-## 2.0.0
-
-### Major Changes
-
-- bea60c4: Added release management.
-
-## 1.0.0
-
-### Major Changes
-
-- Released the initial version of jseql.
diff --git a/packages/protect/README.md b/packages/protect/README.md
deleted file mode 100644
index 06e45b53e..000000000
--- a/packages/protect/README.md
+++ /dev/null
@@ -1,1102 +0,0 @@
-
-
-
- Protect.js
-
-
- End-to-end field level encryption for JavaScript/TypeScript apps with zero‑knowledge key management. Search encrypted data without decrypting it.
-
-
-
-⭐ Please star this repo if you find it useful!
-
-
-
-
-> [!TIP]
-> `@cipherstash/protect` is the core encryption library that powers [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack). For new projects we recommend `@cipherstash/stack`, which re-exports this functionality alongside integrations for Drizzle, Supabase, DynamoDB, secrets, and identity-aware encryption. See the [CipherStash docs](https://cipherstash.com/docs) for the current API.
-
-Protect.js lets you encrypt every value with its own key—without sacrificing performance or usability. Encryption happens in your app; ciphertext is stored in your database.
-
-Per‑value unique keys are powered by CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) bulk key operations, backed by a root key in [AWS KMS](https://docs.aws.amazon.com/kms/latest/developerguide/overview.html).
-
-Encrypted data is structured as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload and can be stored in any database that supports JSONB.
-
-> [!IMPORTANT]
-> Searching, sorting, and filtering on encrypted data is currently only supported when storing encrypted data in PostgreSQL.
-> Read more about [searching encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption).
-
-Looking for DynamoDB support? Check out the [Protect.js for DynamoDB helper library](https://www.npmjs.com/package/@cipherstash/protect-dynamodb).
-
-## Quick start (60 seconds)
-
-Create an account and workspace in the [CipherStash dashboard](https://cipherstash.com/signup), then follow the onboarding guide to generate your client credentials and store them in your `.env` file.
-
-Install the package:
-
-```bash
-npm install @cipherstash/protect
-```
-
-Start encrypting data:
-
-```ts
-import { protect } from "@cipherstash/protect";
-import { csTable, csColumn } from "@cipherstash/protect";
-
-// 1) Define a schema
-const users = csTable("users", { email: csColumn("email") });
-
-// 2) Create a client (requires CS_* env vars)
-const client = await protect({ schemas: [users] });
-
-// 3) Encrypt → store JSONB payload
-const encrypted = await client.encrypt("alice@example.com", {
- table: users,
- column: users.email,
-});
-
-if (encrypted.failure) {
- // You decide how to handle the failure and the user experience
-}
-
-// 4) Decrypt later
-const decrypted = await client.decrypt(encrypted.data);
-```
-
-## Architecture (high level)
-
-
-
-## Table of contents
-
-- [Quick start (60 seconds)](#quick-start-60-seconds)
-- [Architecture (high level)](#architecture-high-level)
-- [Features](#features)
-- [Installing Protect.js](#installing-protectjs)
-- [Getting started](#getting-started)
-- [Identity-aware encryption](#identity-aware-encryption)
-- [Supported data types](#supported-data-types)
-- [Searchable encryption](#searchable-encryption)
-- [Multi-tenant encryption](#multi-tenant-encryption)
-- [Logging](#logging)
-- [CipherStash Client](#cipherstash-client)
-- [Example applications](#example-applications)
-- [Builds and bundling](#builds-and-bundling)
-- [Contributing](#contributing)
-- [License](#license)
-
-For more specific documentation, refer to the [docs](https://cipherstash.com/docs).
-
-## Features
-
-Protect.js protects data in using industry-standard AES encryption.
-Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations.
-This enables every encrypted value, in every column, in every row in your database to have a unique key — without sacrificing performance.
-
-**Features:**
-
-- **Bulk encryption and decryption**: Protect.js uses [ZeroKMS](https://cipherstash.com/products/zerokms) for encrypting and decrypting thousands of records at once, while using a unique key for every value.
-- **Single item encryption and decryption**: Just looking for a way to encrypt and decrypt single values? Protect.js has you covered.
-- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Protect.js.
-- **Identity-aware encryption**: Lock down access to sensitive data by requiring a valid JWT to perform a decryption.
-- **Audit trail**: Every decryption event will be logged in ZeroKMS to help you prove compliance.
-- **Searchable encryption**: Protect.js supports searching encrypted data in PostgreSQL.
-- **TypeScript support**: Strongly typed with TypeScript interfaces and types.
-
-**Use cases:**
-
-- **Trusted data access**: make sure only your end-users can access their sensitive data stored in your product.
-- **Meet compliance requirements faster:** meet and exceed the data encryption requirements of SOC2 and ISO27001.
-- **Reduce the blast radius of data breaches:** limit the impact of exploited vulnerabilities to only the data your end-users can decrypt.
-
-## Installing Protect.js
-
-Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice:
-
-```bash
-npm install @cipherstash/protect
-# or
-yarn add @cipherstash/protect
-# or
-pnpm add @cipherstash/protect
-```
-
-> [!TIP]
-> [Bun](https://bun.sh/) is not currently supported due to a lack of [Node-API compatibility](https://github.com/oven-sh/bun/issues/158). Under the hood, Protect.js uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon).
-
-### Opt-out of bundling
-
-> [!IMPORTANT]
-> **You need to opt-out of bundling when using Protect.js.**
-
-Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid).
-
-When using Protect.js, you need to opt-out of bundling for tools like [Webpack](https://webpack.js.org/configuration/externals/), [esbuild](https://webpack.js.org/configuration/externals/), or [Next.js](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages).
-
-Read more about [building and bundling with Protect.js](#builds-and-bundling).
-
-## Getting started
-
-- 🆕 **Existing app?** Skip to [the next step](#configuration).
-- 🌱 **Clean slate?** Check out the [getting started tutorial](https://cipherstash.com/docs/stack/quickstart).
-
-### Configuration
-
-If you haven't already, sign up for a [CipherStash account](https://cipherstash.com/signup).
-Once you have an account, you will create a Workspace which is scoped to your application environment.
-
-Follow the onboarding steps to get your first set of credentials required to use Protect.js.
-By the end of the onboarding, you will have the following environment variables:
-
-```bash
-CS_WORKSPACE_CRN= # The workspace identifier
-CS_CLIENT_ID= # The client identifier
-CS_CLIENT_KEY= # The client key which is used as key material in combination with ZeroKMS
-CS_CLIENT_ACCESS_KEY= # The API key used for authenticating with the CipherStash API
-```
-
-Save these environment variables to a `.env` file in your project.
-
-### Basic file structure
-
-The following is the basic file structure of the project.
-In the `src/protect/` directory, we have the table definition in `schema.ts` and the protect client in `index.ts`.
-
-```
-📦
- ├ 📂 src
- │ ├ 📂 protect
- │ │ ├ 📜 index.ts
- │ │ └ 📜 schema.ts
- │ └ 📜 index.ts
- ├ 📜 .env
- ├ 📜 cipherstash.toml
- ├ 📜 cipherstash.secret.toml
- ├ 📜 package.json
- └ 📜 tsconfig.json
-```
-
-### Define your schema
-
-Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt.
-
-Define your tables and columns by adding this to `src/protect/schema.ts`:
-
-```ts
-import { csTable, csColumn } from "@cipherstash/protect";
-
-export const users = csTable("users", {
- email: csColumn("email"),
-});
-
-export const orders = csTable("orders", {
- address: csColumn("address"),
-});
-```
-
-**Searchable encryption:**
-
-If you want to search encrypted data in your PostgreSQL database, you must declare the indexes in schema in `src/protect/schema.ts`:
-
-```ts
-import { csTable, csColumn } from "@cipherstash/protect";
-
-export const users = csTable("users", {
- email: csColumn("email").freeTextSearch().equality().orderAndRange(),
-});
-
-export const orders = csTable("orders", {
- address: csColumn("address"),
-});
-
-export const documents = csTable("documents", {
- metadata: csColumn("metadata").searchableJson(), // Enables encrypted JSONB queries
-});
-```
-
-Read more about [defining your schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema).
-
-### Initialize the Protect client
-
-To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`:
-
-```ts
-import { protect, type ProtectClientConfig } from "@cipherstash/protect";
-import { users, orders } from "./schema";
-
-const config: ProtectClientConfig = {
- schemas: [users, orders],
-}
-
-// Pass all your tables to the protect function to initialize the client
-export const protectClient = await protect(config);
-```
-
-The `protect` function requires at least one `csTable` be provided in the `schemas` array.
-
-### Encrypt data
-
-Protect.js provides the `encrypt` function on `protectClient` to encrypt data.
-`encrypt` takes a plaintext string, and an object with the table and column as parameters.
-
-To start encrypting data, add the following to `src/index.ts`:
-
-```typescript
-import { users } from "./protect/schema";
-import { protectClient } from "./protect";
-
-const encryptResult = await protectClient.encrypt("secret@squirrel.example", {
- column: users.email,
- table: users,
-});
-
-if (encryptResult.failure) {
- // Handle the failure
- console.log(
- "error when encrypting:",
- encryptResult.failure.type,
- encryptResult.failure.message
- );
-}
-
-console.log("EQL Payload containing ciphertexts:", encryptResult.data);
-```
-
-The `encrypt` function will return a `Result` object with either a `data` key, or a `failure` key.
-The `encryptResult` will return one of the following:
-
-```typescript
-// Success
-{
- data: EncryptedPayload
-}
-
-// Failure
-{
- failure: {
- type: 'EncryptionError',
- message: 'A message about the error'
- }
-}
-```
-
-### Decrypt data
-
-Protect.js provides the `decrypt` function on `protectClient` to decrypt data.
-`decrypt` takes an encrypted data object as a parameter.
-
-To start decrypting data, add the following to `src/index.ts`:
-
-```typescript
-import { protectClient } from "./protect";
-
-// encryptResult is the EQL payload from the previous step
-const decryptResult = await protectClient.decrypt(encryptResult.data);
-
-if (decryptResult.failure) {
- // Handle the failure
- console.log(
- "error when decrypting:",
- decryptResult.failure.type,
- decryptResult.failure.message
- );
-}
-
-const plaintext = decryptResult.data;
-console.log("plaintext:", plaintext);
-```
-
-The `decrypt` function returns a `Result` object with either a `data` key, or a `failure` key.
-The `decryptResult` will return one of the following:
-
-```typescript
-// Success
-{
- data: 'secret@squirrel.example'
-}
-
-// Failure
-{
- failure: {
- type: 'DecryptionError',
- message: 'A message about the error'
- }
-}
-```
-
-### Working with models and objects
-
-Protect.js provides model-level encryption methods that make it easy to encrypt and decrypt entire objects.
-These methods automatically handle the encryption of fields defined in your schema.
-
-If you are working with a large data set, the model operations are significantly faster than encrypting and decrypting individual objects as they are able to perform bulk operations.
-
-> [!TIP]
-> CipherStash [ZeroKMS](https://cipherstash.com/products/zerokms) is optimized for bulk operations.
->
-> All the model operations are able to take advantage of this performance for real-world use cases by only making a single call to ZeroKMS regardless of the number of objects you are encrypting or decrypting while still using a unique key for each record.
-
-#### Encrypting a model
-
-Use the `encryptModel` method to encrypt a model's fields that are defined in your schema:
-
-```typescript
-import { protectClient } from "./protect";
-import { users } from "./protect/schema";
-
-// Your model with plaintext values
-const user = {
- id: "1",
- email: "user@example.com",
- address: "123 Main St",
- createdAt: new Date("2024-01-01"),
-};
-
-const encryptedResult = await protectClient.encryptModel(user, users);
-
-if (encryptedResult.failure) {
- // Handle the failure
- console.log(
- "error when encrypting:",
- encryptedResult.failure.type,
- encryptedResult.failure.message
- );
-}
-
-const encryptedUser = encryptedResult.data;
-console.log("encrypted user:", encryptedUser);
-```
-
-The `encryptModel` function will only encrypt fields that are defined in your schema.
-Other fields (like `id` and `createdAt` in the example above) will remain unchanged.
-
-#### Type safety with models
-
-Protect.js provides strong TypeScript support for model operations.
-You can specify your model's type to ensure end-to-end type safety:
-
-```typescript
-import { protectClient } from "./protect";
-import { users } from "./protect/schema";
-
-// Define your model type
-type User = {
- id: string;
- email: string | null;
- address: string | null;
- createdAt: Date;
- updatedAt: Date;
- metadata?: {
- preferences?: {
- notifications: boolean;
- theme: string;
- };
- };
-};
-
-// The encryptModel method will ensure type safety
-const encryptedResult = await protectClient.encryptModel(user, users);
-
-if (encryptedResult.failure) {
- // Handle the failure
-}
-
-const encryptedUser = encryptedResult.data;
-// TypeScript knows that encryptedUser matches the User type structure
-// but with encrypted fields for those defined in the schema
-
-// Decryption maintains type safety
-const decryptedResult = await protectClient.decryptModel(encryptedUser);
-
-if (decryptedResult.failure) {
- // Handle the failure
-}
-
-const decryptedUser = decryptedResult.data;
-// decryptedUser is fully typed as User
-
-// Bulk operations also support type safety
-const bulkEncryptedResult = await protectClient.bulkEncryptModels(
- userModels,
- users
-);
-
-const bulkDecryptedResult = await protectClient.bulkDecryptModels(
- bulkEncryptedResult.data
-);
-```
-
-The type system ensures that:
-
-- Input models match your defined type structure
-- Only fields defined in your schema are encrypted
-- Encrypted and decrypted results maintain the correct type structure
-- Optional and nullable fields are properly handled
-- Nested object structures are preserved
-- Additional properties not defined in the schema remain unchanged
-
-This type safety helps catch potential issues at compile time and provides better IDE support with autocompletion and type hints.
-
-> [!TIP]
-> When using TypeScript with an ORM, you can reuse your ORM's model types directly with Protect.js's model operations.
-
-Example with Drizzle infered types:
-
-```typescript
-import { protectClient } from "./protect";
-import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core";
-import { csTable, csColumn } from "@cipherstash/protect";
-
-const protectUsers = csTable("users", {
- email: csColumn("email"),
-});
-
-const users = pgTable("users", {
- id: serial("id").primaryKey(),
- email: jsonb("email").notNull(),
-});
-
-type User = InferSelectModel;
-
-const user = {
- id: "1",
- email: "user@example.com",
-};
-
-// Drizzle User type works directly with model operations
-const encryptedResult = await protectClient.encryptModel(
- user,
- protectUsers
-);
-```
-
-#### Decrypting a model
-
-Use the `decryptModel` method to decrypt a model's encrypted fields:
-
-```typescript
-import { protectClient } from "./protect";
-
-const decryptedResult = await protectClient.decryptModel(encryptedUser);
-
-if (decryptedResult.failure) {
- // Handle the failure
- console.log(
- "error when decrypting:",
- decryptedResult.failure.type,
- decryptedResult.failure.message
- );
-}
-
-const decryptedUser = decryptedResult.data;
-console.log("decrypted user:", decryptedUser);
-```
-
-#### Bulk model operations
-
-For better performance when working with multiple models, use the `bulkEncryptModels` and `bulkDecryptModels` methods:
-
-```typescript
-import { protectClient } from "./protect";
-import { users } from "./protect/schema";
-
-// Array of models with plaintext values
-const userModels = [
- {
- id: "1",
- email: "user1@example.com",
- address: "123 Main St",
- },
- {
- id: "2",
- email: "user2@example.com",
- address: "456 Oak Ave",
- },
-];
-
-// Encrypt multiple models at once
-const encryptedResult = await protectClient.bulkEncryptModels(
- userModels,
- users
-);
-
-if (encryptedResult.failure) {
- // Handle the failure
-}
-
-const encryptedUsers = encryptedResult.data;
-
-// Decrypt multiple models at once
-const decryptedResult = await protectClient.bulkDecryptModels(encryptedUsers);
-
-if (decryptedResult.failure) {
- // Handle the failure
-}
-
-const decryptedUsers = decryptedResult.data;
-```
-
-The model encryption methods provide a higher-level interface that's particularly useful when working with ORMs or when you need to encrypt multiple fields in an object.
-They automatically handle the mapping between your model's structure and the encrypted fields defined in your schema.
-
-### Bulk operations
-
-Protect.js provides direct access to ZeroKMS bulk operations through the `bulkEncrypt` and `bulkDecrypt` methods. These methods are ideal when you need maximum performance and want to handle the correlation between encrypted/decrypted values and your application data manually.
-
-> [!TIP]
-> The bulk operations provide the most direct interface to ZeroKMS's blazing fast bulk encryption and decryption capabilities. Each value gets a unique key while maintaining optimal performance through a single call to ZeroKMS.
-
-#### Bulk encryption
-
-Use the `bulkEncrypt` method to encrypt multiple plaintext values at once:
-
-```typescript
-import { protectClient } from "./protect";
-import { users } from "./protect/schema";
-
-// Array of plaintext values with optional IDs for correlation
-const plaintexts = [
- { id: "user1", plaintext: "alice@example.com" },
- { id: "user2", plaintext: "bob@example.com" },
- { id: "user3", plaintext: "charlie@example.com" },
-];
-
-const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
-});
-
-if (encryptedResult.failure) {
- // Handle the failure
- console.log(
- "error when bulk encrypting:",
- encryptedResult.failure.type,
- encryptedResult.failure.message
- );
-}
-
-const encryptedData = encryptedResult.data;
-console.log("encrypted data:", encryptedData);
-```
-
-The `bulkEncrypt` method returns an array of objects with the following structure:
-
-```typescript
-[
- { id: "user1", data: EncryptedPayload },
- { id: "user2", data: EncryptedPayload },
- { id: "user3", data: EncryptedPayload },
-]
-```
-
-You can also encrypt without IDs if you don't need correlation:
-
-```typescript
-const plaintexts = [
- { plaintext: "alice@example.com" },
- { plaintext: "bob@example.com" },
- { plaintext: "charlie@example.com" },
-];
-
-const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
-});
-```
-
-#### Bulk decryption
-
-Use the `bulkDecrypt` method to decrypt multiple encrypted values at once:
-
-```typescript
-import { protectClient } from "./protect";
-
-// encryptedData is the result from bulkEncrypt
-const decryptedResult = await protectClient.bulkDecrypt(encryptedData);
-
-if (decryptedResult.failure) {
- // Handle the failure
- console.log(
- "error when bulk decrypting:",
- decryptedResult.failure.type,
- decryptedResult.failure.message
- );
-}
-
-const decryptedData = decryptedResult.data;
-console.log("decrypted data:", decryptedData);
-```
-
-The `bulkDecrypt` method returns an array of objects with the following structure:
-
-```typescript
-[
- { id: "user1", data: "alice@example.com" },
- { id: "user2", data: "bob@example.com" },
- { id: "user3", data: "charlie@example.com" },
-]
-```
-
-#### Response structure
-
-The `bulkDecrypt` method returns a `Result` object that represents the overall operation status. When successful from an HTTP and execution perspective, the `data` field contains an array where each item can have one of two outcomes:
-
-- **Success**: The item has a `data` field containing the decrypted plaintext
-- **Failure**: The item has an `error` field containing a specific error message explaining why that particular decryption failed
-
-```typescript
-// Example response structure
-{
- data: [
- { id: "user1", data: "alice@example.com" }, // Success
- { id: "user2", error: "Invalid ciphertext format" }, // Failure
- { id: "user3", data: "charlie@example.com" }, // Success
- ]
-}
-```
-
-> [!NOTE]
-> The underlying ZeroKMS response uses HTTP status code 207 (Multi-Status) to indicate that the bulk operation completed, but individual items within the batch may have succeeded or failed. This allows you to handle partial failures gracefully while still processing the successful decryptions.
-
-You can handle mixed results by checking each item:
-
-```typescript
-const decryptedResult = await protectClient.bulkDecrypt(encryptedData);
-
-if (decryptedResult.failure) {
- // Handle overall operation failure
- console.log("Bulk decryption failed:", decryptedResult.failure.message);
- return;
-}
-
-// Process individual results
-decryptedResult.data.forEach((item) => {
- if ('data' in item) {
- // Success - item.data contains the decrypted plaintext
- console.log(`Decrypted ${item.id}:`, item.data);
- } else if ('error' in item) {
- // Failure - item.error contains the specific error message
- console.log(`Failed to decrypt ${item.id}:`, item.error);
- }
-});
-```
-
-#### Handling null values
-
-Bulk operations properly handle null values in both encryption and decryption:
-
-```typescript
-const plaintexts = [
- { id: "user1", plaintext: "alice@example.com" },
- { id: "user2", plaintext: null },
- { id: "user3", plaintext: "charlie@example.com" },
-];
-
-const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
-});
-
-// Null values are preserved in the encrypted result
-// encryptedResult.data[1].data will be null
-
-const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data);
-
-// Null values are preserved in the decrypted result
-// decryptedResult.data[1].data will be null
-```
-
-#### Using bulk operations with lock contexts
-
-Bulk operations support identity-aware encryption through lock contexts:
-
-```typescript
-import { LockContext } from "@cipherstash/protect/identify";
-
-const lc = new LockContext();
-const lockContext = await lc.identify(userJwt);
-
-if (lockContext.failure) {
- // Handle the failure
-}
-
-const plaintexts = [
- { id: "user1", plaintext: "alice@example.com" },
- { id: "user2", plaintext: "bob@example.com" },
-];
-
-// Encrypt with lock context
-const encryptedResult = await protectClient
- .bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
- .withLockContext(lockContext.data);
-
-// Decrypt with lock context
-const decryptedResult = await protectClient
- .bulkDecrypt(encryptedResult.data)
- .withLockContext(lockContext.data);
-```
-
-#### Performance considerations
-
-Bulk operations are optimized for performance and can handle thousands of values efficiently:
-
-```typescript
-// Create a large array of values
-const plaintexts = Array.from({ length: 1000 }, (_, i) => ({
- id: `user${i}`,
- plaintext: `user${i}@example.com`,
-}));
-
-// Single call to ZeroKMS for all 1000 values
-const encryptedResult = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
-});
-
-// Single call to ZeroKMS for all 1000 values
-const decryptedResult = await protectClient.bulkDecrypt(encryptedResult.data);
-```
-
-The bulk operations maintain the same security guarantees as individual operations - each value gets a unique key - while providing optimal performance through ZeroKMS's bulk processing capabilities.
-
-### Store encrypted data in a database
-
-Encrypted data can be stored in any database that supports JSONB, noting that searchable encryption is only supported in PostgreSQL at the moment.
-
-To store the encrypted data, specify the column type as `jsonb`.
-
-```sql
-CREATE TABLE users (
- id SERIAL PRIMARY KEY,
- email jsonb NOT NULL,
-);
-```
-
-#### Searchable encryption in PostgreSQL
-
-To enable searchable encryption in PostgreSQL, [install the EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation).
-
-1. Download the EQL install script. The version is pinned to match this release of Protect.js — install exactly this version:
-
- ```sh
- curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt.sql
- ```
-
- Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase.
- Download the matching Supabase EQL install script:
-
- ```sh
- curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/download/eql-2.3.1/cipherstash-encrypt-supabase.sql
- ```
-
-2. Run this command to install the custom types and functions:
-
- ```sh
- psql -f cipherstash-encrypt.sql
- ```
-
- or with Supabase:
-
- ```sh
- psql -f cipherstash-encrypt-supabase.sql
- ```
-
-EQL is now installed in your database and you can enable searchable encryption by adding the `eql_v2_encrypted` type to a column.
-
-```sql
-CREATE TABLE users (
- id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
- email eql_v2_encrypted
-);
-```
-
-> [!WARNING]
-> The `eql_v2_encrypted` type is a [composite type](https://www.postgresql.org/docs/current/rowtypes.html) and each ORM/client has a different way of handling inserts and selects.
-> We've documented how to handle inserts and selects for the different ORMs/clients in the [docs](https://cipherstash.com/docs).
-
-Read more about [how to search encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) in the docs.
-
-## Identity-aware encryption
-
-> [!IMPORTANT]
-> Right now identity-aware encryption is only supported if you are using [Clerk](https://clerk.com/) as your identity provider.
-> Read more about [lock contexts with Clerk and Next.js](https://cipherstash.com/docs/stack/cipherstash/encryption/identity).
-
-Protect.js can add an additional layer of protection to your data by requiring a valid JWT to perform a decryption.
-
-This ensures that only the user who encrypted data is able to decrypt it.
-
-Protect.js does this through a mechanism called a _lock context_.
-
-### Lock context
-
-Lock contexts ensure that only specific users can access sensitive data.
-
-> [!CAUTION]
-> You must use the same lock context to encrypt and decrypt data.
-> If you use different lock contexts, you will be unable to decrypt the data.
-
-To use a lock context, initialize a `LockContext` object with the identity claims.
-
-```typescript
-import { LockContext } from "@cipherstash/protect/identify";
-
-// protectClient from the previous steps
-const lc = new LockContext();
-```
-
-> [!NOTE]
-> When initializing a `LockContext`, the default context is set to use the `sub` Identity Claim.
-
-### Identifying a user for a lock context
-
-A lock context needs to be locked to a user.
-To identify the user, call the `identify` method on the lock context object, and pass a valid JWT from a user's session:
-
-```typescript
-const identifyResult = await lc.identify(jwt);
-
-// The identify method returns the same Result pattern as the encrypt and decrypt methods.
-if (identifyResult.failure) {
- // Hanlde the failure
-}
-
-const lockContext = identifyResult.data;
-```
-
-### Encrypting data with a lock context
-
-To encrypt data with a lock context, call the optional `withLockContext` method on the `encrypt` function and pass the lock context object as a parameter:
-
-```typescript
-import { protectClient } from "./protect";
-import { users } from "./protect/schema";
-
-const encryptResult = await protectClient
- .encrypt("plaintext", {
- table: users,
- column: users.email,
- })
- .withLockContext(lockContext);
-
-if (encryptResult.failure) {
- // Handle the failure
-}
-
-console.log("EQL Payload containing ciphertexts:", encryptResult.data);
-```
-
-### Decrypting data with a lock context
-
-To decrypt data with a lock context, call the optional `withLockContext` method on the `decrypt` function and pass the lock context object as a parameter:
-
-```typescript
-import { protectClient } from "./protect";
-
-const decryptResult = await protectClient
- .decrypt(encryptResult.data)
- .withLockContext(lockContext);
-
-if (decryptResult.failure) {
- // Handle the failure
-}
-
-const plaintext = decryptResult.data;
-```
-
-### Model encryption with lock context
-
-All model operations support lock contexts for identity-aware encryption:
-
-```typescript
-import { protectClient } from "./protect";
-import { users } from "./protect/schema";
-
-const myUsers = [
- {
- id: "1",
- email: "user@example.com",
- address: "123 Main St",
- createdAt: new Date("2024-01-01"),
- },
- {
- id: "2",
- email: "user2@example.com",
- address: "456 Oak Ave",
- },
-];
-
-// Encrypt a model with lock context
-const encryptedResult = await protectClient
- .encryptModel(myUsers[0], users)
- .withLockContext(lockContext);
-
-if (encryptedResult.failure) {
- // Handle the failure
-}
-
-// Decrypt a model with lock context
-const decryptedResult = await protectClient
- .decryptModel(encryptedResult.data)
- .withLockContext(lockContext);
-
-// Bulk operations also support lock contexts
-const bulkEncryptedResult = await protectClient
- .bulkEncryptModels(myUsers, users)
- .withLockContext(lockContext);
-
-const bulkDecryptedResult = await protectClient
- .bulkDecryptModels(bulkEncryptedResult.data)
- .withLockContext(lockContext);
-```
-
-## Supported data types
-
-Protect.js currently supports encrypting and decrypting text.
-Other data types like booleans, dates, ints, floats, and JSON are well-supported in other CipherStash products, and will be coming to Protect.js soon.
-
-Until support for other data types are available, you can express interest in this feature by adding a :+1: on this [GitHub Issue](https://github.com/cipherstash/stack/issues/48).
-
-## Searchable encryption
-
-Protect.js supports searching encrypted data in PostgreSQL:
-
-- **Text columns**: Use `.equality()`, `.freeTextSearch()`, and `.orderAndRange()` for exact match, text search, and sorting/range queries.
-- **JSONB columns**: Use `.searchableJson()` (recommended) to enable encrypted JSONPath selector and containment queries on JSON data. The query operation is automatically inferred from the plaintext type.
-
-Read more about [searching encrypted data](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) and the [PostgreSQL implementation details](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption) in the docs.
-
-## Multi-tenant encryption
-
-Protect.js supports multi-tenant encryption by using keysets.
-Each keyset is cryptographically isolated from other keysets which esentially means that each tenant has their own unique keyspace.
-If you are using a multi-tenant application, you can use keysets to encrypt data for each tenant creating a strong security boundary.
-
-In the [CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/encryption/keysets), you can create and manage keysets and then use the keyset identifier to encrypt data for each tenant when initializing the Protect.js client.
-
-```typescript
-import { protect } from "@cipherstash/protect";
-import { users } from "./protect/schema";
-
-const protectClient = await protect({
- schemas: [users],
- keyset: {
- // Must be a valid UUID which can be found in the CipherStash Dashboard
- id: '123e4567-e89b-12d3-a456-426614174000'
- },
-})
-
-// or with a keyset name
-
-const protectClient = await protect({
- schemas: [users],
- keyset: {
- name: 'Company A'
- },
-})
-```
-
-> [!IMPORTANT]
-> When creating a new keyset, make sure to grant your client access to the keyset or client initialization will fail.
-> Read more about [managing keyset access](https://cipherstash.com/docs/platform/workspaces/key-sets).
-
-## Logging
-
-> [!TIP]
-> `@cipherstash/protect` will NEVER log plaintext data.
-> This is by design to prevent sensitive data from leaking into logs.
-
-`@cipherstash/protect` and `@cipherstash/nextjs` will log to the console with a log level of `info` by default.
-To enable the logger, configure the following environment variable:
-
-```bash
-PROTECT_LOG_LEVEL=debug # Enable debug logging
-PROTECT_LOG_LEVEL=info # Enable info logging
-PROTECT_LOG_LEVEL=error # Enable error logging
-```
-
-## CipherStash Client
-
-Protect.js is built on top of the CipherStash Client Rust SDK which is embedded with the `@cipherstash/protect-ffi` package.
-The `@cipherstash/protect-ffi` source code is available on [GitHub](https://github.com/cipherstash/stack-ffi).
-
-Read more about configuring the CipherStash Client in the [configuration docs](https://cipherstash.com/docs/stack/reference).
-
-## Example applications
-
-Looking for examples of how to use Protect.js?
-Check out the [example applications](./examples):
-
-- [Basic example](/examples/basic) demonstrates how to perform encryption operations
-- [Drizzle example](/examples/drizzle) demonstrates how to use Protect.js with an ORM
-- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption
-
-`@cipherstash/protect` can be used with most ORMs.
-If you're interested in using `@cipherstash/protect` with a specific ORM, please [create an issue](https://github.com/cipherstash/stack/issues/new).
-
-## Builds and bundling
-
-`@cipherstash/protect` is a native Node.js module, and relies on native Node.js `require` to load the package.
-
-Here are a few resources to help based on your tool set:
-
-- [Required Next.js configuration](https://cipherstash.com/docs/stack/deploy/bundling).
-- [SST and AWS serverless functions](https://cipherstash.com/docs/stack/deploy/bundling).
-
-> [!TIP]
-> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [bundling guide](https://cipherstash.com/docs/stack/deploy/bundling).
-
-## Contributing
-
-Please read the [contribution guide](CONTRIBUTE.md).
-
-## License
-
-Protect.js is [MIT licensed](./LICENSE.md).
-
----
-
-### Didn't find what you wanted?
-
-[Click here to let us know what was missing from our docs.](https://github.com/cipherstash/stack/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md)
diff --git a/packages/protect/__tests__/audit.test.ts b/packages/protect/__tests__/audit.test.ts
deleted file mode 100644
index 6d508f585..000000000
--- a/packages/protect/__tests__/audit.test.ts
+++ /dev/null
@@ -1,472 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { LockContext, protect } from '../src'
-
-const users = csTable('users', {
- auditable: csColumn('auditable'),
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
-})
-
-type User = {
- id: string
- email?: string | null
- address?: string | null
- auditable?: string | null
- createdAt?: Date
- updatedAt?: Date
- number?: number
-}
-
-let protectClient: Awaited>
-
-beforeAll(async () => {
- protectClient = await protect({
- schemas: [users],
- })
-})
-
-describe('encryption and decryption with audit', () => {
- it('should encrypt and decrypt a payload with audit metadata', async () => {
- const email = 'very_secret_data'
-
- const ciphertext = await protectClient
- .encrypt(email, {
- column: users.auditable,
- table: users,
- })
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'encrypt',
- },
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const plaintext = await protectClient.decrypt(ciphertext.data).audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'decrypt',
- },
- })
-
- expect(plaintext).toEqual({
- data: email,
- })
- }, 30000)
-
- it('should encrypt and decrypt a model with audit metadata', async () => {
- // Create a model with decrypted values
- const decryptedModel: User = {
- id: '1',
- email: 'test@example.com',
- address: '123 Main St',
- auditable: 'sensitive_data',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 1,
- }
-
- // Encrypt the model with audit
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'encrypt_model',
- },
- })
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.auditable).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.number).toBe(1)
-
- // Decrypt the model with audit
- const decryptedResult = await protectClient
- .decryptModel(encryptedModel.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'decrypt_model',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle null values in a model with audit metadata', async () => {
- // Create a model with null values
- const decryptedModel: User = {
- id: '1',
- email: null,
- address: null,
- auditable: null,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 1,
- }
-
- // Encrypt the model with audit
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'encrypt_model_nulls',
- },
- })
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify null fields are preserved
- expect(encryptedModel.data.email).toBeNull()
- expect(encryptedModel.data.address).toBeNull()
- expect(encryptedModel.data.auditable).toBeNull()
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.number).toBe(1)
-
- // Decrypt the model with audit
- const decryptedResult = await protectClient
- .decryptModel(encryptedModel.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'decrypt_model_nulls',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-})
-
-describe('bulk encryption with audit', () => {
- it('should bulk encrypt and decrypt models with audit metadata', async () => {
- // Create models with decrypted values
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- address: '123 Main St',
- auditable: 'sensitive_data_1',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 1,
- },
- {
- id: '2',
- email: 'test2@example.com',
- address: '456 Oak St',
- auditable: 'sensitive_data_2',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 2,
- },
- ]
-
- // Encrypt the models with audit
- const encryptedModels = await protectClient
- .bulkEncryptModels(decryptedModels, users)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_encrypt_models',
- },
- })
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields for each model
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].address).toHaveProperty('c')
- expect(encryptedModels.data[0].auditable).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].address).toHaveProperty('c')
- expect(encryptedModels.data[1].auditable).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[0].number).toBe(1)
- expect(encryptedModels.data[1].id).toBe('2')
- expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[1].number).toBe(2)
-
- // Decrypt the models with audit
- const decryptedResult = await protectClient
- .bulkDecryptModels(encryptedModels.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_decrypt_models',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModels)
- }, 30000)
-
- it('should handle mixed null and non-null values in bulk operations with audit', async () => {
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- address: null,
- auditable: 'sensitive_data_1',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 1,
- },
- {
- id: '2',
- email: null,
- address: '123 Main St',
- auditable: null,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 2,
- },
- {
- id: '3',
- email: 'test3@example.com',
- address: '456 Oak St',
- auditable: 'sensitive_data_3',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- number: 3,
- },
- ]
-
- // Encrypt the models with audit
- const encryptedModels = await protectClient
- .bulkEncryptModels(decryptedModels, users)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_encrypt_mixed_nulls',
- },
- })
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields for each model
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].address).toBeNull()
- expect(encryptedModels.data[0].auditable).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toBeNull()
- expect(encryptedModels.data[1].address).toHaveProperty('c')
- expect(encryptedModels.data[1].auditable).toBeNull()
- expect(encryptedModels.data[2].email).toHaveProperty('c')
- expect(encryptedModels.data[2].address).toHaveProperty('c')
- expect(encryptedModels.data[2].auditable).toHaveProperty('c')
-
- // Decrypt the models with audit
- const decryptedResult = await protectClient
- .bulkDecryptModels(decryptedModels)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_decrypt_mixed_nulls',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModels)
- }, 30000)
-
- it('should return empty array if models is empty with audit', async () => {
- // Encrypt empty array of models with audit
- const encryptedModels = await protectClient
- .bulkEncryptModels([], users)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_encrypt_empty',
- },
- })
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- expect(encryptedModels.data).toEqual([])
-
- // Decrypt empty array of models with audit
- const decryptedResult = await protectClient
- .bulkDecryptModels([])
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_decrypt_empty',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual([])
- }, 30000)
-})
-
-describe('audit with lock context', () => {
- it('should encrypt and decrypt a model with both audit and lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- // Create a model with decrypted values
- const decryptedModel: User = {
- id: '1',
- email: 'test@example.com',
- auditable: 'sensitive_with_context',
- }
-
- // Encrypt the model with both audit and lock context
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .withLockContext(lockContext.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'encrypt_with_context',
- },
- })
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Decrypt the model with both audit and lock context
- const decryptedResult = await protectClient
- .decryptModel(encryptedModel.data)
- .withLockContext(lockContext.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'decrypt_with_context',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should bulk encrypt and decrypt models with both audit and lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- // Create models with decrypted values
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- auditable: 'bulk_sensitive_1',
- },
- {
- id: '2',
- email: 'test2@example.com',
- auditable: 'bulk_sensitive_2',
- },
- ]
-
- // Encrypt the models with both audit and lock context
- const encryptedModels = await protectClient
- .bulkEncryptModels(decryptedModels, users)
- .withLockContext(lockContext.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_encrypt_with_context',
- },
- })
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Decrypt the models with both audit and lock context
- const decryptedResult = await protectClient
- .bulkDecryptModels(encryptedModels.data)
- .withLockContext(lockContext.data)
- .audit({
- metadata: {
- sub: 'cj@cjb.io',
- type: 'bulk_decrypt_with_context',
- },
- })
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModels)
- }, 30000)
-})
diff --git a/packages/protect/__tests__/basic-protect.test.ts b/packages/protect/__tests__/basic-protect.test.ts
deleted file mode 100644
index 6277a842a..000000000
--- a/packages/protect/__tests__/basic-protect.test.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
- json: csColumn('json').dataType('json'),
-})
-
-let protectClient: Awaited>
-
-beforeAll(async () => {
- protectClient = await protect({
- schemas: [users],
- })
-})
-
-describe('encryption and decryption', () => {
- it('should encrypt and decrypt a payload', async () => {
- const email = 'hello@example.com'
-
- const ciphertext = await protectClient.encrypt(email, {
- column: users.email,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const a = ciphertext.data
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: email,
- })
- }, 30000)
-})
diff --git a/packages/protect/__tests__/bulk-protect.test.ts b/packages/protect/__tests__/bulk-protect.test.ts
deleted file mode 100644
index 893bea86a..000000000
--- a/packages/protect/__tests__/bulk-protect.test.ts
+++ /dev/null
@@ -1,597 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { type EncryptedPayload, LockContext, protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
-})
-
-type User = {
- id: string
- email?: string | null
- createdAt?: Date
- updatedAt?: Date
- address?: string | null
- number?: number
-}
-
-let protectClient: Awaited>
-
-beforeAll(async () => {
- protectClient = await protect({
- schemas: [users],
- })
-})
-
-describe('bulk encryption and decryption', () => {
- describe('bulk encrypt', () => {
- it('should bulk encrypt an array of plaintexts with IDs', async () => {
- const plaintexts = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: 'bob@example.com' },
- { id: 'user3', plaintext: 'charlie@example.com' },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
-
- // Verify all encrypted values are different
- expect(encryptedData.data[0].data?.c).not.toBe(
- encryptedData.data[1].data?.c,
- )
- expect(encryptedData.data[1].data?.c).not.toBe(
- encryptedData.data[2].data?.c,
- )
- expect(encryptedData.data[0].data?.c).not.toBe(
- encryptedData.data[2].data?.c,
- )
- }, 30000)
-
- it('should bulk encrypt an array of plaintexts without IDs', async () => {
- const plaintexts = [
- { plaintext: 'alice@example.com' },
- { plaintext: 'bob@example.com' },
- { plaintext: 'charlie@example.com' },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', undefined)
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', undefined)
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toHaveProperty('c')
- expect(encryptedData.data[2]).toHaveProperty('id', undefined)
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('c')
- }, 30000)
-
- it('should handle null values in bulk encrypt', async () => {
- const plaintexts = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: 'charlie@example.com' },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toBeNull()
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('c')
- }, 30000)
-
- it('should handle all null values in bulk encrypt', async () => {
- const plaintexts = [
- { id: 'user1', plaintext: null },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: null },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toBeNull()
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toBeNull()
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toBeNull()
- }, 30000)
-
- it('should handle empty array in bulk encrypt', async () => {
- const plaintexts: Array<{ id?: string; plaintext: string | null }> = []
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- expect(encryptedData.data).toHaveLength(0)
- }, 30000)
- })
-
- describe('bulk decrypt', () => {
- it('should bulk decrypt an array of encrypted payloads with IDs', async () => {
- // First encrypt some data
- const plaintexts = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: 'bob@example.com' },
- { id: 'user3', plaintext: 'charlie@example.com' },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com')
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com')
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty(
- 'data',
- 'charlie@example.com',
- )
- }, 30000)
-
- it('should bulk decrypt an array of encrypted payloads without IDs', async () => {
- // First encrypt some data
- const plaintexts = [
- { plaintext: 'alice@example.com' },
- { plaintext: 'bob@example.com' },
- { plaintext: 'charlie@example.com' },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', undefined)
- expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com')
- expect(decryptedData.data[1]).toHaveProperty('id', undefined)
- expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com')
- expect(decryptedData.data[2]).toHaveProperty('id', undefined)
- expect(decryptedData.data[2]).toHaveProperty(
- 'data',
- 'charlie@example.com',
- )
- }, 30000)
-
- it('should handle null values in bulk decrypt', async () => {
- // First encrypt some data with nulls
- const plaintexts = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: 'charlie@example.com' },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com')
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', null)
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty(
- 'data',
- 'charlie@example.com',
- )
- }, 30000)
-
- it('should handle all null values in bulk decrypt', async () => {
- // First encrypt some data with all nulls
- const plaintexts = [
- { id: 'user1', plaintext: null },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: null },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', null)
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', null)
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty('data', null)
- }, 30000)
-
- it('should handle empty array in bulk decrypt', async () => {
- const encryptedPayloads: Array<{ id?: string; data: EncryptedPayload }> =
- []
-
- const decryptedData = await protectClient.bulkDecrypt(encryptedPayloads)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- expect(decryptedData.data).toHaveLength(0)
- }, 30000)
- })
-
- describe('bulk operations with lock context', () => {
- it('should bulk encrypt and decrypt with lock context', async () => {
- // This test requires a valid JWT token, so we'll skip it in CI
- // TODO: Add proper JWT token handling for CI
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const plaintexts = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: 'bob@example.com' },
- { id: 'user3', plaintext: 'charlie@example.com' },
- ]
-
- // Encrypt with lock context
- const encryptedData = await protectClient
- .bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toHaveProperty('c')
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('c')
-
- // Decrypt with lock context
- const decryptedData = await protectClient
- .bulkDecrypt(encryptedData.data)
- .withLockContext(lockContext.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', 'alice@example.com')
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com')
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty(
- 'data',
- 'charlie@example.com',
- )
- }, 30000)
-
- it('should handle null values with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const plaintexts = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: 'charlie@example.com' },
- ]
-
- // Encrypt with lock context
- const encryptedData = await protectClient
- .bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify null is preserved
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toBeNull()
-
- // Decrypt with lock context
- const decryptedData = await protectClient
- .bulkDecrypt(encryptedData.data)
- .withLockContext(lockContext.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify null is preserved
- expect(decryptedData.data[1]).toHaveProperty('data')
- expect(decryptedData.data[1].data).toBeNull()
- }, 30000)
-
- it('should decrypt mixed lock context payloads with specific lock context', async () => {
- const userJwt = process.env.USER_JWT
- const user2Jwt = process.env.USER_2_JWT
-
- if (!userJwt || !user2Jwt) {
- console.log(
- 'Skipping mixed lock context test - missing USER_JWT or USER_2_JWT',
- )
- return
- }
-
- const lc = new LockContext()
- const lc2 = new LockContext()
- const lockContext1 = await lc.identify(userJwt)
- const lockContext2 = await lc2.identify(user2Jwt)
-
- if (lockContext1.failure) {
- throw new Error(`[protect]: ${lockContext1.failure.message}`)
- }
-
- if (lockContext2.failure) {
- throw new Error(`[protect]: ${lockContext2.failure.message}`)
- }
-
- // Encrypt first value with USER_JWT lock context
- const encryptedData1 = await protectClient
- .bulkEncrypt([{ id: 'user1', plaintext: 'alice@example.com' }], {
- column: users.email,
- table: users,
- })
- .withLockContext(lockContext1.data)
-
- if (encryptedData1.failure) {
- throw new Error(`[protect]: ${encryptedData1.failure.message}`)
- }
-
- // Encrypt second value with USER_2_JWT lock context
- const encryptedData2 = await protectClient
- .bulkEncrypt([{ id: 'user2', plaintext: 'bob@example.com' }], {
- column: users.email,
- table: users,
- })
- .withLockContext(lockContext2.data)
-
- if (encryptedData2.failure) {
- throw new Error(`[protect]: ${encryptedData2.failure.message}`)
- }
-
- // Combine both encrypted payloads
- const combinedEncryptedData = [
- ...encryptedData1.data,
- ...encryptedData2.data,
- ]
-
- // Decrypt with USER_2_JWT lock context
- const decryptedData = await protectClient
- .bulkDecrypt(combinedEncryptedData)
- .withLockContext(lockContext2.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify both payloads are returned
- expect(decryptedData.data).toHaveLength(2)
-
- // First payload should fail to decrypt since it was encrypted with different lock context
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('error')
- expect(decryptedData.data[0]).not.toHaveProperty('data')
-
- // Second payload should be decrypted since it was encrypted with the same lock context
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', 'bob@example.com')
- expect(decryptedData.data[1]).not.toHaveProperty('error')
- }, 30000)
- })
-
- describe('bulk operations round-trip', () => {
- it('should maintain data integrity through encrypt/decrypt cycle', async () => {
- const originalData = [
- { id: 'user1', plaintext: 'alice@example.com' },
- { id: 'user2', plaintext: 'bob@example.com' },
- { id: 'user3', plaintext: null },
- { id: 'user4', plaintext: 'dave@example.com' },
- ]
-
- // Encrypt
- const encryptedData = await protectClient.bulkEncrypt(originalData, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Decrypt
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify round-trip integrity
- expect(decryptedData.data).toHaveLength(originalData.length)
-
- for (let i = 0; i < originalData.length; i++) {
- expect(decryptedData.data[i].id).toBe(originalData[i].id)
- expect(decryptedData.data[i].data).toBe(originalData[i].plaintext)
- }
- }, 30000)
-
- it('should handle large arrays efficiently', async () => {
- const originalData = Array.from({ length: 100 }, (_, i) => ({
- id: `user${i}`,
- plaintext: `user${i}@example.com`,
- }))
-
- // Encrypt
- const encryptedData = await protectClient.bulkEncrypt(originalData, {
- column: users.email,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Decrypt
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify all data is preserved
- expect(decryptedData.data).toHaveLength(100)
-
- for (let i = 0; i < 100; i++) {
- expect(decryptedData.data[i].id).toBe(`user${i}`)
- expect(decryptedData.data[i].data).toBe(`user${i}@example.com`)
- }
- }, 30000)
- })
-})
diff --git a/packages/protect/__tests__/deprecated/search-terms.test.ts b/packages/protect/__tests__/deprecated/search-terms.test.ts
deleted file mode 100644
index d3c12a773..000000000
--- a/packages/protect/__tests__/deprecated/search-terms.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { describe, expect, it } from 'vitest'
-import { protect, type SearchTerm } from '../../src'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
- age: csColumn('age').dataType('number').equality(),
- score: csColumn('score').dataType('number').equality(),
-})
-
-describe('createSearchTerms (deprecated - backward compatibility)', () => {
- it('should create search terms with default return type', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const searchTerms = [
- {
- value: 'hello',
- column: users.email,
- table: users,
- },
- {
- value: 'world',
- column: users.address,
- table: users,
- },
- ] as SearchTerm[]
-
- const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
-
- if (searchTermsResult.failure) {
- throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
- }
-
- expect(searchTermsResult.data).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- v: 2,
- }),
- ]),
- )
- }, 30000)
-
- it('should create search terms with composite-literal return type', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const searchTerms = [
- {
- value: 'hello',
- column: users.email,
- table: users,
- returnType: 'composite-literal',
- },
- ] as SearchTerm[]
-
- const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
-
- if (searchTermsResult.failure) {
- throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
- }
-
- const result = searchTermsResult.data[0] as string
- expect(result).toMatch(/^\(.*\)$/)
- expect(() => JSON.parse(result.slice(1, -1))).not.toThrow()
- }, 30000)
-
- it('should create search terms with escaped-composite-literal return type', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const searchTerms = [
- {
- value: 'hello',
- column: users.email,
- table: users,
- returnType: 'escaped-composite-literal',
- },
- ] as SearchTerm[]
-
- const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
-
- if (searchTermsResult.failure) {
- throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
- }
-
- const result = searchTermsResult.data[0] as string
- expect(result).toMatch(/^".*"$/)
- const unescaped = JSON.parse(result)
- expect(unescaped).toMatch(/^\(.*\)$/)
- expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow()
- }, 30000)
-
- it('should create search terms with composite-literal return type for numbers', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const searchTerms = [
- {
- value: 42,
- column: users.age,
- table: users,
- returnType: 'composite-literal' as const,
- },
- ]
-
- const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
-
- if (searchTermsResult.failure) {
- throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
- }
-
- const result = searchTermsResult.data[0] as string
- expect(result).toMatch(/^\(.*\)$/)
- expect(() => JSON.parse(result.slice(1, -1))).not.toThrow()
- }, 30000)
-
- it('should create search terms with escaped-composite-literal return type for numbers', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const searchTerms = [
- {
- value: 99,
- column: users.score,
- table: users,
- returnType: 'escaped-composite-literal' as const,
- },
- ]
-
- const searchTermsResult = await protectClient.createSearchTerms(searchTerms)
-
- if (searchTermsResult.failure) {
- throw new Error(`[protect]: ${searchTermsResult.failure.message}`)
- }
-
- const result = searchTermsResult.data[0] as string
- expect(result).toMatch(/^".*"$/)
- const unescaped = JSON.parse(result)
- expect(unescaped).toMatch(/^\(.*\)$/)
- expect(() => JSON.parse(unescaped.slice(1, -1))).not.toThrow()
- }, 30000)
-})
diff --git a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts b/packages/protect/__tests__/encrypt-query-searchable-json.test.ts
deleted file mode 100644
index faa25cb84..000000000
--- a/packages/protect/__tests__/encrypt-query-searchable-json.test.ts
+++ /dev/null
@@ -1,1061 +0,0 @@
-import 'dotenv/config'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { ProtectErrorTypes, protect } from '../src'
-
-type ProtectClient = Awaited>
-
-import {
- createFailingMockLockContext,
- createMockLockContext,
- createMockLockContextWithNullContext,
- expectFailure,
- jsonbSchema,
- metadata,
- unwrapResult,
-} from './fixtures'
-
-/*
- * The `searchableJson` queryType provides a friendlier API by auto-inferring the
- * underlying query operation from the plaintext type. It's equivalent to omitting
- * queryType on ste_vec columns, but explicit for code clarity.
- * - String values → ste_vec_selector (JSONPath queries)
- * - Object/Array values → ste_vec_term (containment queries)
- */
-
-/** Assert encrypted selector output has valid shape */
-function expectSelector(data: any) {
- expect(data).toHaveProperty('s')
- expect(typeof data.s).toBe('string')
- expect(data.s.length).toBeGreaterThan(0)
-}
-
-/** Assert encrypted term output has valid shape */
-function expectTerm(data: any) {
- expect(data).toHaveProperty('sv')
- expect(Array.isArray(data.sv)).toBe(true)
-}
-
-describe('encryptQuery with searchableJson queryType', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- // Core functionality: auto-inference from plaintext type
-
- it('auto-infers ste_vec_selector for string plaintext (JSONPath)', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectSelector(data)
- }, 30000)
-
- it('auto-infers ste_vec_term for object plaintext (containment)', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('auto-infers ste_vec_term for nested object', async () => {
- const result = await protectClient.encryptQuery(
- { user: { profile: { role: 'admin' } } },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('auto-infers ste_vec_term for array plaintext', async () => {
- const result = await protectClient.encryptQuery(['admin', 'user'], {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('returns null for null plaintext', async () => {
- const result = await protectClient.encryptQuery(null, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeNull()
- }, 30000)
-
- // Edge cases: number/boolean require wrapping (same as steVecTerm)
-
- it('fails for bare number plaintext (requires wrapping)', async () => {
- const result = await protectClient.encryptQuery(42, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- expectFailure(result, /Wrap the number in a JSON object/)
- }, 30000)
-
- it('fails for bare boolean plaintext (requires wrapping)', async () => {
- const result = await protectClient.encryptQuery(true, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- expectFailure(result, /Wrap the boolean in a JSON object/)
- }, 30000)
-})
-
-describe('encryptQuery with searchableJson column and omitted queryType', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- it('auto-infers ste_vec_selector for string plaintext (JSONPath)', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectSelector(data)
- }, 30000)
-
- it('auto-infers ste_vec_term for object plaintext (containment)', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('returns null for null plaintext', async () => {
- const result = await protectClient.encryptQuery(null, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- })
-
- const data = unwrapResult(result)
- expect(data).toBeNull()
- }, 30000)
-
- it('fails for bare number plaintext (requires wrapping)', async () => {
- const result = await protectClient.encryptQuery(42, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- })
-
- expectFailure(result, /Wrap the number in a JSON object/)
- }, 30000)
-
- it('fails for bare boolean plaintext (requires wrapping)', async () => {
- const result = await protectClient.encryptQuery(true, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- })
-
- expectFailure(result, /Wrap the boolean in a JSON object/)
- }, 30000)
-})
-
-describe('searchableJson validation', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- it('throws when used on column without ste_vec index', async () => {
- const result = await protectClient.encryptQuery('$.path', {
- column: metadata.raw, // raw column has no ste_vec index
- table: metadata,
- queryType: 'searchableJson',
- })
-
- expectFailure(result)
- }, 30000)
-})
-
-describe('searchableJson batch operations', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('handles mixed plaintext types in single batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: '$.user.email', // string → ste_vec_selector
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: { role: 'admin' }, // object → ste_vec_term
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: ['tag1', 'tag2'], // array → ste_vec_term
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(3)
- expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- expectSelector(data[0])
- expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- expectTerm(data[1])
- expect(data[2]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- expectTerm(data[2])
- }, 30000)
-
- it('handles null values in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(3)
- expect(data[0]).toBeNull()
- expect(data[1]).not.toBeNull()
- expectSelector(data[1])
- expect(data[2]).toBeNull()
- }, 30000)
-
- it('can be mixed with explicit steVecSelector/steVecTerm in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: '$.path1',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson', // auto-infer
- },
- {
- value: '$.path2',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector', // explicit
- },
- {
- value: { key: 'value' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm', // explicit
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(3)
- expectSelector(data[0])
- expectSelector(data[1])
- expectTerm(data[2])
- }, 30000)
-
- it('can omit queryType for searchableJson in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: '$.path1',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- },
- {
- value: { key: 'value' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- },
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(3)
- expectSelector(data[0])
- expectTerm(data[1])
- expect(data[2]).toBeNull()
- }, 30000)
-})
-
-describe('searchableJson with returnType formatting', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('supports composite-literal returnType', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- returnType: 'composite-literal',
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(1)
- expect(typeof data[0]).toBe('string')
- // Format: ("json")
- expect(data[0]).toMatch(/^\(".*"\)$/)
- }, 30000)
-
- it('supports escaped-composite-literal returnType', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: { role: 'admin' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- returnType: 'escaped-composite-literal',
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(1)
- expect(typeof data[0]).toBe('string')
- // Format: "(\"json\")" - outer quotes with escaped inner quotes
- expect(data[0]).toMatch(/^"\(.*\)"$/)
- }, 30000)
-
- describe('single-value returnType', () => {
- it('returns composite-literal for selector', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- returnType: 'composite-literal',
- })
-
- const data = unwrapResult(result)
- expect(typeof data).toBe('string')
- expect(data).toMatch(/^\(".*"\)$/)
- }, 30000)
-
- it('returns composite-literal for term', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- returnType: 'composite-literal',
- },
- )
-
- const data = unwrapResult(result)
- expect(typeof data).toBe('string')
- expect(data).toMatch(/^\(".*"\)$/)
- }, 30000)
-
- it('returns escaped-composite-literal for selector', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- returnType: 'escaped-composite-literal',
- })
-
- const data = unwrapResult(result)
- expect(typeof data).toBe('string')
- expect(data as string).toMatch(/^"\(.*\)"$/)
- // JSON.parse should yield the composite-literal format
- const parsed = JSON.parse(data as string)
- expect(parsed).toMatch(/^\(.*\)$/)
- }, 30000)
-
- it('returns escaped-composite-literal for term', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- returnType: 'escaped-composite-literal',
- },
- )
-
- const data = unwrapResult(result)
- expect(typeof data).toBe('string')
- expect(data as string).toMatch(/^"\(.*\)"$/)
- const parsed = JSON.parse(data as string)
- expect(parsed).toMatch(/^\(.*\)$/)
- }, 30000)
-
- it('returns Encrypted object when returnType is omitted', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result) as any
- expect(typeof data).toBe('object')
- expect(data).toHaveProperty('i')
- expect(data.i).toHaveProperty('t')
- expect(data.i).toHaveProperty('c')
- }, 30000)
- })
-})
-
-describe('searchableJson with LockContext', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('exposes withLockContext method', async () => {
- const operation = protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- expect(operation.withLockContext).toBeDefined()
- expect(typeof operation.withLockContext).toBe('function')
- })
-
- it('executes string plaintext with LockContext mock', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectSelector(data)
- }, 30000)
-
- it('executes object plaintext with LockContext mock', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- // LockContext should be called even if the actual encryption fails
- // with a mock token (ste_vec_term operations may require real auth)
- expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)
-
- // Ensure the operation actually completed (has either data or failure)
- expect(result.data !== undefined || result.failure !== undefined).toBe(true)
-
- // The result may fail due to mock token, but we verify LockContext integration worked
- if (result.data) {
- expect(result.data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(result.data)
- }
- }, 30000)
-
- it('executes batch with LockContext mock', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery([
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: { role: 'admin' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- // LockContext should be called even if the actual encryption fails
- // with a mock token (ste_vec_term operations may require real auth)
- expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)
-
- // The result may fail due to mock token, but we verify LockContext integration worked
- if (result.data) {
- expect(result.data).toHaveLength(2)
- }
- }, 30000)
-
- it('handles LockContext failure gracefully', async () => {
- const mockLockContext = createFailingMockLockContext(
- ProtectErrorTypes.CtsTokenError,
- 'Mock LockContext failure',
- )
-
- const operation = protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- expectFailure(
- result,
- 'Mock LockContext failure',
- ProtectErrorTypes.CtsTokenError,
- )
- }, 30000)
-
- it('handles null value with LockContext', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery(null, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- // Null values should return null without calling LockContext
- // since there's nothing to encrypt
- expect(mockLockContext.getLockContext).not.toHaveBeenCalled()
- const data = unwrapResult(result)
- expect(data).toBeNull()
- }, 30000)
-
- it('handles explicit null context from getLockContext gracefully', async () => {
- const mockLockContext = createMockLockContextWithNullContext()
-
- const operation = protectClient.encryptQuery([
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- // Should succeed - null context should not be passed to FFI
- const data = unwrapResult(result)
- expect(data).toHaveLength(1)
- expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- expectSelector(data[0])
- }, 30000)
-})
-
-describe('searchableJson equivalence', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('produces identical metadata to omitting queryType for string', async () => {
- const explicitResult = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const implicitResult = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- })
-
- // Both should succeed and have identical metadata structure
- const explicitData = unwrapResult(explicitResult)
- const implicitData = unwrapResult(implicitResult)
-
- expect(explicitData.i).toEqual(implicitData.i)
- expect(explicitData.v).toEqual(implicitData.v)
- expectSelector(explicitData)
- expectSelector(implicitData)
- }, 30000)
-
- it('produces identical metadata to omitting queryType for object', async () => {
- const explicitResult = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const implicitResult = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- },
- )
-
- const explicitData = unwrapResult(explicitResult)
- const implicitData = unwrapResult(implicitResult)
-
- expect(explicitData.i).toEqual(implicitData.i)
- expect(explicitData.v).toEqual(implicitData.v)
- expectTerm(explicitData)
- expectTerm(implicitData)
- }, 30000)
-
- it('produces identical metadata to explicit steVecSelector for string', async () => {
- const searchableJsonResult = await protectClient.encryptQuery(
- '$.user.email',
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const steVecSelectorResult = await protectClient.encryptQuery(
- '$.user.email',
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- )
-
- const searchableJsonData = unwrapResult(searchableJsonResult)
- const steVecSelectorData = unwrapResult(steVecSelectorResult)
-
- expect(searchableJsonData.i).toEqual(steVecSelectorData.i)
- expect(searchableJsonData.v).toEqual(steVecSelectorData.v)
- expectSelector(searchableJsonData)
- expectSelector(steVecSelectorData)
- }, 30000)
-
- it('produces identical metadata to explicit steVecTerm for object', async () => {
- const searchableJsonResult = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const steVecTermResult = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- },
- )
-
- const searchableJsonData = unwrapResult(searchableJsonResult)
- const steVecTermData = unwrapResult(steVecTermResult)
-
- expect(searchableJsonData.i).toEqual(steVecTermData.i)
- expect(searchableJsonData.v).toEqual(steVecTermData.v)
- expectTerm(searchableJsonData)
- expectTerm(steVecTermData)
- }, 30000)
-})
-
-describe('searchableJson edge cases', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- // Valid edge cases that should succeed
-
- it('succeeds for empty object', async () => {
- const result = await protectClient.encryptQuery(
- {},
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('succeeds for empty array', async () => {
- const result = await protectClient.encryptQuery([], {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('succeeds for object with wrapped number', async () => {
- const result = await protectClient.encryptQuery(
- { value: 42 },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('succeeds for object with wrapped boolean', async () => {
- const result = await protectClient.encryptQuery(
- { active: true },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('succeeds for object with null value', async () => {
- const result = await protectClient.encryptQuery(
- { field: null },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- it('succeeds for deeply nested object (3+ levels)', async () => {
- const result = await protectClient.encryptQuery(
- {
- level1: {
- level2: {
- level3: {
- level4: {
- value: 'deep',
- },
- },
- },
- },
- },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectTerm(data)
- }, 30000)
-
- // String edge cases for JSONPath selectors
-
- it('succeeds for JSONPath with array index notation', async () => {
- const result = await protectClient.encryptQuery('$.items[0].name', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectSelector(data)
- }, 30000)
-
- it('succeeds for JSONPath with wildcard', async () => {
- const result = await protectClient.encryptQuery('$.items[*].name', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- expectSelector(data)
- }, 30000)
-})
-
-describe('searchableJson batch edge cases', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('handles single-item batch identically to scalar', async () => {
- const scalarResult = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- })
-
- const batchResult = await protectClient.encryptQuery([
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const scalarData = unwrapResult(scalarResult)
- const batchData = unwrapResult(batchResult)
-
- expect(batchData).toHaveLength(1)
- expect(batchData[0].i).toEqual(scalarData.i)
- expect(batchData[0].v).toEqual(scalarData.v)
- expectSelector(scalarData)
- expectSelector(batchData[0])
- }, 30000)
-
- it('handles all-null batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(3)
- expect(data[0]).toBeNull()
- expect(data[1]).toBeNull()
- expect(data[2]).toBeNull()
- }, 30000)
-
- it('handles empty batch', async () => {
- const result = await protectClient.encryptQuery([])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(0)
- }, 30000)
-
- it('handles large batch (10+ items)', async () => {
- const items = Array.from({ length: 12 }, (_, i) => ({
- value: i % 2 === 0 ? `$.path${i}` : { index: i },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson' as const,
- }))
-
- const result = await protectClient.encryptQuery(items)
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(12)
- data.forEach((item: any, idx: number) => {
- expect(item).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- v: 2,
- })
- if (idx % 2 === 0) {
- expectSelector(item)
- } else {
- expectTerm(item)
- }
- })
- }, 30000)
-
- it('handles multiple interspersed nulls at various positions', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: { role: 'admin' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'searchableJson',
- },
- ])
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(5)
- expect(data[0]).toBeNull()
- expect(data[1]).not.toBeNull()
- expectSelector(data[1])
- expect(data[2]).toBeNull()
- expect(data[3]).not.toBeNull()
- expectTerm(data[3])
- expect(data[4]).toBeNull()
- }, 30000)
-})
diff --git a/packages/protect/__tests__/encrypt-query-stevec.test.ts b/packages/protect/__tests__/encrypt-query-stevec.test.ts
deleted file mode 100644
index 7c536d725..000000000
--- a/packages/protect/__tests__/encrypt-query-stevec.test.ts
+++ /dev/null
@@ -1,392 +0,0 @@
-import 'dotenv/config'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protect } from '../src'
-
-type ProtectClient = Awaited>
-
-import { expectFailure, jsonbSchema, metadata, unwrapResult } from './fixtures'
-
-describe('encryptQuery with steVecSelector', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- it('encrypts a JSONPath selector', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('encrypts nested path selector', async () => {
- const result = await protectClient.encryptQuery('$.user.profile.settings', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('fails for non-string plaintext with steVecSelector (object)', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- )
-
- expectFailure(result)
- }, 30000)
-})
-
-describe('encryptQuery with steVecTerm', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- it('encrypts an object for containment query', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('encrypts nested object for containment', async () => {
- const result = await protectClient.encryptQuery(
- { user: { profile: { role: 'admin' } } },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('encrypts array for containment query', async () => {
- const result = await protectClient.encryptQuery([1, 2, 3], {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('rejects string plaintext with steVecTerm', async () => {
- // steVecTerm requires object or array, not string
- // For path queries like '$.field', use steVecSelector instead
- const result = await protectClient.encryptQuery('search text', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- })
-
- expectFailure(result, /expected JSON object or array/)
- }, 30000)
-})
-
-describe('encryptQuery STE Vec validation', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- it('throws when steVecSelector used on non-ste_vec column', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: metadata.raw, // raw column has no ste_vec index
- table: metadata,
- queryType: 'steVecSelector',
- })
-
- expectFailure(result)
- }, 30000)
-
- it('throws when steVecTerm used on non-ste_vec column', async () => {
- const result = await protectClient.encryptQuery(
- { field: 'value' },
- {
- column: metadata.raw, // raw column has no ste_vec index
- table: metadata,
- queryType: 'steVecTerm',
- },
- )
-
- expectFailure(result)
- }, 30000)
-})
-
-describe('encryptQuery batch with STE Vec', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema, metadata] })
- })
-
- it('handles mixed query types in batch (steVecSelector + steVecTerm)', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- {
- value: { role: 'admin' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(2)
- expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- }, 30000)
-
- it('handles multiple steVecSelector queries in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- {
- value: '$.settings.theme',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(2)
- expect(data[0]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- }, 30000)
-
- it('handles null values with steVecSelector in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- {
- value: '$.user.email',
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecSelector',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(2)
- expect(data[0]).toBeNull()
- expect(data[1]).not.toBeNull()
- expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- }, 30000)
-
- it('handles null values with steVecTerm in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- },
- {
- value: { role: 'admin' },
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(2)
- expect(data[0]).toBeNull()
- expect(data[1]).not.toBeNull()
- expect(data[1]).toMatchObject({ i: { t: 'documents', c: 'metadata' } })
- }, 30000)
-})
-
-describe('encryptQuery with queryType inference', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('infers steVecSelector for string plaintext without queryType', async () => {
- const result = await protectClient.encryptQuery('$.user.email', {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType - should infer steVecSelector from string
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('infers steVecTerm for object plaintext without queryType', async () => {
- const result = await protectClient.encryptQuery(
- { role: 'admin' },
- {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType - should infer steVecTerm from object
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('infers steVecTerm for array plaintext without queryType', async () => {
- const result = await protectClient.encryptQuery(['admin', 'user'], {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType - should infer steVecTerm from array
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-
- it('infers steVecTerm for number plaintext but FFI requires wrapping', async () => {
- // Numbers infer steVecTerm but FFI requires wrapping in object/array
- const result = await protectClient.encryptQuery(42, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType - infers steVecTerm, FFI rejects with helpful message
- })
-
- expectFailure(result, /Wrap the number in a JSON object/)
- }, 30000)
-
- it('infers steVecTerm for boolean plaintext but FFI requires wrapping', async () => {
- // Booleans infer steVecTerm but FFI requires wrapping in object/array
- const result = await protectClient.encryptQuery(true, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType - infers steVecTerm, FFI rejects with helpful message
- })
-
- expectFailure(result, /Wrap the boolean in a JSON object/)
- }, 30000)
-
- it('returns null for null plaintext (no inference needed)', async () => {
- const result = await protectClient.encryptQuery(null, {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType and null plaintext - should return null
- })
-
- // Null returns null, doesn't throw
- const data = unwrapResult(result)
- expect(data).toBeNull()
- }, 30000)
-
- it('uses explicit queryType over plaintext inference', async () => {
- // String plaintext would normally infer steVecSelector, but explicit steVecTerm should be used
- // Note: steVecTerm with string fails FFI validation, so we test the opposite direction
- // Using a number (which would infer steVecTerm) with explicit steVecSelector would also fail
- // So we verify with array + steVecTerm (already tested) and trust unit test coverage for precedence
- const result = await protectClient.encryptQuery([42], {
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- queryType: 'steVecTerm', // Explicit - matches inference but proves explicit path works
- })
-
- const data = unwrapResult(result)
- expect(data).toBeDefined()
- expect(data).toMatchObject({
- i: { t: 'documents', c: 'metadata' },
- })
- }, 30000)
-})
-
-describe('encryptQuery batch with queryType inference', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [jsonbSchema] })
- })
-
- it('infers queryOp for each term independently in batch', async () => {
- const results = await protectClient.encryptQuery([
- {
- value: '$.user.email', // string → steVecSelector
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType
- },
- {
- value: { role: 'admin' }, // object → steVecTerm
- column: jsonbSchema.metadata,
- table: jsonbSchema,
- // No queryType
- },
- ])
-
- const data = unwrapResult(results)
- expect(data).toHaveLength(2)
- expect(data[0]).toBeDefined()
- expect(data[1]).toBeDefined()
- }, 30000)
-})
diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts
deleted file mode 100644
index 1f64000b2..000000000
--- a/packages/protect/__tests__/encrypt-query.test.ts
+++ /dev/null
@@ -1,946 +0,0 @@
-import 'dotenv/config'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { ProtectErrorTypes, protect } from '../src'
-import type { ProtectClient } from '../src/ffi'
-import {
- articles,
- createFailingMockLockContext,
- createMockLockContext,
- createMockLockContextWithNullContext,
- expectFailure,
- metadata,
- products,
- unwrapResult,
- users,
-} from './fixtures'
-
-describe('encryptQuery', () => {
- let protectClient: ProtectClient
-
- beforeAll(async () => {
- protectClient = await protect({
- schemas: [users, articles, products, metadata],
- })
- })
-
- describe('single value encryption with explicit queryType', () => {
- it('encrypts for equality query type', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const data = unwrapResult(result)
-
- expect(data).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(data).toHaveProperty('hm')
- }, 30000)
-
- it('encrypts for freeTextSearch query type', async () => {
- const result = await protectClient.encryptQuery('hello world', {
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- })
-
- const data = unwrapResult(result)
-
- expect(data).toMatchObject({
- i: { t: 'users', c: 'bio' },
- v: 2,
- })
- expect(data).toHaveProperty('bf')
- }, 30000)
-
- it('encrypts for orderAndRange query type', async () => {
- const result = await protectClient.encryptQuery(25, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
-
- const data = unwrapResult(result)
-
- expect(data).toMatchObject({
- i: { t: 'users', c: 'age' },
- v: 2,
- })
- expect(data).toHaveProperty('ob')
- }, 30000)
- })
-
- describe('auto-inference when queryType omitted', () => {
- it('auto-infers equality for column with .equality()', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- })
-
- const data = unwrapResult(result)
- expect(data).toHaveProperty('hm')
- }, 30000)
-
- it('auto-infers freeTextSearch for match-only column', async () => {
- const result = await protectClient.encryptQuery('search content', {
- column: articles.content,
- table: articles,
- })
-
- const data = unwrapResult(result)
- expect(data).toHaveProperty('bf')
- }, 30000)
-
- it('auto-infers orderAndRange for ore-only column', async () => {
- const result = await protectClient.encryptQuery(99.99, {
- column: products.price,
- table: products,
- })
-
- const data = unwrapResult(result)
- expect(data).toHaveProperty('ob')
- }, 30000)
- })
-
- describe('edge cases', () => {
- it('handles null values', async () => {
- const result = await protectClient.encryptQuery(null, {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const data = unwrapResult(result)
- expect(data).toBeNull()
- }, 30000)
-
- it('rejects NaN values', async () => {
- const result = await protectClient.encryptQuery(Number.NaN, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
-
- expectFailure(result, 'NaN')
- }, 30000)
-
- it('rejects Infinity values', async () => {
- const result = await protectClient.encryptQuery(
- Number.POSITIVE_INFINITY,
- {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- )
-
- expectFailure(result, 'Infinity')
- }, 30000)
-
- it('rejects negative Infinity values', async () => {
- const result = await protectClient.encryptQuery(
- Number.NEGATIVE_INFINITY,
- {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- )
-
- expectFailure(result, 'Infinity')
- }, 30000)
- })
-
- describe('validation errors', () => {
- it('fails when queryType does not match column config', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'freeTextSearch', // email only has equality
- })
-
- expectFailure(result, 'not configured')
- }, 30000)
-
- it('fails when column has no indexes configured', async () => {
- const result = await protectClient.encryptQuery('raw data', {
- column: metadata.raw,
- table: metadata,
- })
-
- expectFailure(result, 'no indexes configured')
- }, 30000)
-
- it('provides descriptive error for queryType mismatch', async () => {
- const result = await protectClient.encryptQuery(42, {
- column: users.age,
- table: users,
- queryType: 'equality', // age only has orderAndRange
- })
-
- expectFailure(result, 'unique')
- expectFailure(result, 'not configured', ProtectErrorTypes.EncryptionError)
- }, 30000)
- })
-
- describe('value/index type compatibility', () => {
- it('fails when encrypting number with match index (explicit queryType)', async () => {
- const result = await protectClient.encryptQuery(123, {
- column: articles.content, // match-only column
- table: articles,
- queryType: 'freeTextSearch',
- })
-
- expectFailure(result, 'match')
- expectFailure(result, 'numeric')
- }, 30000)
-
- it('fails when encrypting number with auto-inferred match index', async () => {
- const result = await protectClient.encryptQuery(123, {
- column: articles.content, // match-only column, will infer 'match'
- table: articles,
- })
-
- expectFailure(result, 'match')
- }, 30000)
-
- it('fails in batch when number used with match index', async () => {
- const result = await protectClient.encryptQuery([
- { value: 123, column: articles.content, table: articles },
- ])
-
- expectFailure(result, 'match')
- }, 30000)
-
- it('allows string with match index', async () => {
- const result = await protectClient.encryptQuery('search text', {
- column: articles.content,
- table: articles,
- })
-
- const data = unwrapResult(result)
- expect(data).toHaveProperty('bf') // bloom filter
- }, 30000)
-
- it('allows number with ore index', async () => {
- const result = await protectClient.encryptQuery(42, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
-
- const data = unwrapResult(result)
- expect(data).toHaveProperty('ob') // order bits
- }, 30000)
- })
-
- describe('numeric edge cases', () => {
- it('encrypts MAX_SAFE_INTEGER', async () => {
- const result = await protectClient.encryptQuery(Number.MAX_SAFE_INTEGER, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'users', c: 'age' },
- v: 2,
- })
- expect(data).toHaveProperty('ob')
- }, 30000)
-
- it('encrypts MIN_SAFE_INTEGER', async () => {
- const result = await protectClient.encryptQuery(Number.MIN_SAFE_INTEGER, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'users', c: 'age' },
- v: 2,
- })
- expect(data).toHaveProperty('ob')
- }, 30000)
-
- it('encrypts negative zero', async () => {
- const result = await protectClient.encryptQuery(-0, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
-
- const data = unwrapResult(result)
- expect(data).toHaveProperty('ob')
- }, 30000)
- })
-
- describe('string edge cases', () => {
- it('encrypts empty string', async () => {
- const result = await protectClient.encryptQuery('', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(data).toHaveProperty('hm')
- }, 30000)
-
- it('encrypts unicode/emoji strings', async () => {
- const result = await protectClient.encryptQuery('Hello 世界 🌍🚀', {
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'users', c: 'bio' },
- v: 2,
- })
- expect(data).toHaveProperty('bf')
- }, 30000)
-
- it('encrypts strings with SQL special characters', async () => {
- const result = await protectClient.encryptQuery(
- "'; DROP TABLE users; --",
- {
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- )
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(data).toHaveProperty('hm')
- }, 30000)
- })
-
- describe('encryptQuery bulk (array overload)', () => {
- it('encrypts multiple terms in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- {
- value: 'search term',
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- },
- {
- value: 42,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(3)
- expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } })
- expect(data[1]).toMatchObject({ i: { t: 'users', c: 'bio' } })
- expect(data[2]).toMatchObject({ i: { t: 'users', c: 'age' } })
- }, 30000)
-
- it('handles empty array', async () => {
- // Empty arrays without opts are treated as empty batch for backward compatibility
- const result = await protectClient.encryptQuery([])
-
- const data = unwrapResult(result)
- expect(data).toEqual([])
- }, 30000)
-
- it('handles null values in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- {
- value: null,
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(2)
- expect(data[0]).not.toBeNull()
- expect(data[1]).toBeNull()
- }, 30000)
-
- it('auto-infers queryType when omitted', async () => {
- const result = await protectClient.encryptQuery([
- { value: 'user@example.com', column: users.email, table: users },
- { value: 42, column: users.age, table: users },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(2)
- expect(data[0]).toHaveProperty('hm')
- expect(data[1]).toHaveProperty('ob')
- }, 30000)
-
- it('rejects NaN/Infinity values in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: Number.NaN,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- {
- value: Number.POSITIVE_INFINITY,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- ])
-
- expect(result.failure).toBeDefined()
- }, 30000)
-
- it('rejects negative Infinity in batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: Number.NEGATIVE_INFINITY,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- ])
-
- expectFailure(result, 'Infinity')
- }, 30000)
- })
-
- describe('bulk index preservation', () => {
- it('preserves exact positions with multiple nulls interspersed', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- {
- value: 'user@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- {
- value: null,
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- },
- {
- value: null,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- {
- value: 42,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(5)
- expect(data[0]).toBeNull()
- expect(data[1]).not.toBeNull()
- expect(data[1]).toHaveProperty('hm')
- expect(data[2]).toBeNull()
- expect(data[3]).toBeNull()
- expect(data[4]).not.toBeNull()
- expect(data[4]).toHaveProperty('ob')
- }, 30000)
-
- it('handles single-item array', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'single@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(1)
- expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } })
- expect(data[0]).toHaveProperty('hm')
- }, 30000)
-
- it('handles all-null array', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- {
- value: null,
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- },
- {
- value: null,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(3)
- expect(data[0]).toBeNull()
- expect(data[1]).toBeNull()
- expect(data[2]).toBeNull()
- }, 30000)
- })
-
- describe('audit support', () => {
- it('passes audit metadata for single query', async () => {
- const result = await protectClient
- .encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
- .audit({ metadata: { userId: 'test-user' } })
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({ i: { t: 'users', c: 'email' } })
- }, 30000)
-
- it('passes audit metadata for bulk query', async () => {
- const result = await protectClient
- .encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- ])
- .audit({ metadata: { userId: 'test-user' } })
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(1)
- }, 30000)
- })
-
- describe('returnType formatting', () => {
- it('returns Encrypted by default (no returnType)', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(1)
- expect(data[0]).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(typeof data[0]).toBe('object')
- }, 30000)
-
- it('returns composite-literal format when specified', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'composite-literal',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(1)
- expect(typeof data[0]).toBe('string')
- // Format: ("json")
- expect(data[0]).toMatch(/^\(".*"\)$/)
- }, 30000)
-
- it('returns escaped-composite-literal format when specified', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'escaped-composite-literal',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(1)
- expect(typeof data[0]).toBe('string')
- // Format: "(\"json\")" - outer quotes with escaped inner quotes
- expect(data[0]).toMatch(/^"\(.*\)"$/)
- }, 30000)
-
- it('returns eql format when explicitly specified', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'eql',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(1)
- expect(data[0]).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(typeof data[0]).toBe('object')
- }, 30000)
-
- it('handles mixed returnType values in same batch', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- }, // default
- {
- value: 'search term',
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- returnType: 'composite-literal',
- },
- {
- value: 42,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- returnType: 'escaped-composite-literal',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(3)
-
- // First: default (Encrypted object)
- expect(typeof data[0]).toBe('object')
- expect(data[0]).toMatchObject({ i: { t: 'users', c: 'email' } })
-
- // Second: composite-literal (string)
- expect(typeof data[1]).toBe('string')
- expect(data[1]).toMatch(/^\(".*"\)$/)
-
- // Third: escaped-composite-literal (string)
- expect(typeof data[2]).toBe('string')
- expect(data[2]).toMatch(/^"\(.*\)"$/)
- }, 30000)
-
- it('handles returnType with null values', async () => {
- const result = await protectClient.encryptQuery([
- {
- value: null,
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'composite-literal',
- },
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'composite-literal',
- },
- {
- value: null,
- column: users.bio,
- table: users,
- queryType: 'freeTextSearch',
- returnType: 'escaped-composite-literal',
- },
- ])
-
- const data = unwrapResult(result)
-
- expect(data).toHaveLength(3)
- expect(data[0]).toBeNull()
- expect(typeof data[1]).toBe('string')
- expect(data[1]).toMatch(/^\(".*"\)$/)
- expect(data[2]).toBeNull()
- }, 30000)
- })
-
- describe('single-value returnType formatting', () => {
- it('returns Encrypted by default (no returnType)', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const data = unwrapResult(result)
-
- expect(data).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(typeof data).toBe('object')
- }, 30000)
-
- it('returns composite-literal format when specified', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'composite-literal',
- })
-
- const data = unwrapResult(result)
-
- expect(typeof data).toBe('string')
- // Format: ("json")
- expect(data).toMatch(/^\(".*"\)$/)
- }, 30000)
-
- it('returns escaped-composite-literal format when specified', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'escaped-composite-literal',
- })
-
- const data = unwrapResult(result)
-
- expect(typeof data).toBe('string')
- // Format: "(\"json\")" - outer quotes with escaped inner quotes
- expect(data).toMatch(/^"\(.*\)"$/)
- }, 30000)
-
- it('returns eql format when explicitly specified', async () => {
- const result = await protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'eql',
- })
-
- const data = unwrapResult(result)
-
- expect(data).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(typeof data).toBe('object')
- }, 30000)
-
- it('handles null value with composite-literal returnType', async () => {
- const result = await protectClient.encryptQuery(null, {
- column: users.email,
- table: users,
- queryType: 'equality',
- returnType: 'composite-literal',
- })
-
- const data = unwrapResult(result)
-
- expect(data).toBeNull()
- }, 30000)
- })
-
- describe('LockContext support', () => {
- it('single query with LockContext calls getLockContext', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- expect(withContext).toHaveProperty('execute')
- expect(typeof withContext.execute).toBe('function')
- }, 30000)
-
- it('bulk query with LockContext calls getLockContext', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- ])
-
- const withContext = operation.withLockContext(mockLockContext as any)
- expect(withContext).toHaveProperty('execute')
- expect(typeof withContext.execute).toBe('function')
- }, 30000)
-
- it('executes single query with LockContext mock', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)
-
- const data = unwrapResult(result)
- expect(data).toMatchObject({
- i: { t: 'users', c: 'email' },
- v: 2,
- })
- expect(data).toHaveProperty('hm')
- }, 30000)
-
- it('executes bulk query with LockContext mock', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- {
- value: 42,
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- },
- ])
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- expect(mockLockContext.getLockContext).toHaveBeenCalledTimes(1)
-
- const data = unwrapResult(result)
- expect(data).toHaveLength(2)
- expect(data[0]).toHaveProperty('hm')
- expect(data[1]).toHaveProperty('ob')
- }, 30000)
-
- it('handles LockContext failure gracefully', async () => {
- const mockLockContext = createFailingMockLockContext(
- ProtectErrorTypes.CtsTokenError,
- 'Mock LockContext failure',
- )
-
- const operation = protectClient.encryptQuery('test@example.com', {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- expectFailure(
- result,
- 'Mock LockContext failure',
- ProtectErrorTypes.CtsTokenError,
- )
- }, 30000)
-
- it('handles null value with LockContext', async () => {
- const mockLockContext = createMockLockContext()
-
- const operation = protectClient.encryptQuery(null, {
- column: users.email,
- table: users,
- queryType: 'equality',
- })
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- // Null values should return null without calling LockContext
- // since there's nothing to encrypt
- const data = unwrapResult(result)
- expect(data).toBeNull()
- }, 30000)
-
- it('handles explicit null context from getLockContext gracefully', async () => {
- // Simulate a runtime scenario where context is null (bypasses TypeScript)
- const mockLockContext = createMockLockContextWithNullContext()
-
- const operation = protectClient.encryptQuery([
- {
- value: 'test@example.com',
- column: users.email,
- table: users,
- queryType: 'equality',
- },
- ])
-
- const withContext = operation.withLockContext(mockLockContext as any)
- const result = await withContext.execute()
-
- // Should succeed - null context should not be passed to FFI
- const data = unwrapResult(result)
- expect(data).toHaveLength(1)
- expect(data[0]).toHaveProperty('hm')
- }, 30000)
- })
-})
diff --git a/packages/protect/__tests__/error-codes.test.ts b/packages/protect/__tests__/error-codes.test.ts
deleted file mode 100644
index d0a3d8596..000000000
--- a/packages/protect/__tests__/error-codes.test.ts
+++ /dev/null
@@ -1,369 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import type { ProtectClient } from '../src'
-import { FfiProtectError, ProtectErrorTypes, protect } from '../src'
-
-/** FFI tests require longer timeout due to client initialization */
-const FFI_TEST_TIMEOUT = 30_000
-
-/**
- * Tests for FFI error code preservation in ProtectError.
- * These tests verify that specific FFI error codes are preserved when errors occur,
- * enabling programmatic error handling.
- */
-describe('FFI Error Code Preservation', () => {
- let protectClient: ProtectClient
-
- // Schema with a valid column for testing
- const testSchema = csTable('test_table', {
- email: csColumn('email').equality(),
- bio: csColumn('bio').freeTextSearch(),
- age: csColumn('age').dataType('number').orderAndRange(),
- metadata: csColumn('metadata').searchableJson(),
- })
-
- // Schema without indexes for testing non-FFI validation
- const noIndexSchema = csTable('no_index_table', {
- raw: csColumn('raw'),
- })
-
- // Schema with non-existent column for triggering FFI UNKNOWN_COLUMN error
- const badModelSchema = csTable('test_table', {
- nonexistent: csColumn('nonexistent_column'),
- })
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [testSchema, noIndexSchema] })
- })
-
- describe('FfiProtectError class', () => {
- it('constructs with code and message', () => {
- const error = new FfiProtectError({
- code: 'UNKNOWN_COLUMN',
- message: 'Test error',
- })
- expect(error.code).toBe('UNKNOWN_COLUMN')
- expect(error.message).toBe('Test error')
- })
- })
-
- describe('encryptQuery error codes', () => {
- it(
- 'returns UNKNOWN_COLUMN code for non-existent column',
- async () => {
- // Create a fake column that doesn't exist in the schema
- const fakeColumn = csColumn('nonexistent_column').equality()
-
- const result = await protectClient.encryptQuery('test', {
- column: fakeColumn,
- table: testSchema,
- queryType: 'equality',
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
-
- it(
- 'returns undefined code for columns without indexes (non-FFI validation)',
- async () => {
- // This error is caught during pre-FFI validation, not by FFI itself
- const result = await protectClient.encryptQuery('test', {
- column: noIndexSchema.raw,
- table: noIndexSchema,
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.message).toContain('no indexes configured')
- // Pre-FFI validation errors don't have FFI error codes
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
-
- it(
- 'returns undefined code for non-FFI validation errors',
- async () => {
- // NaN validation happens before FFI call
- const result = await protectClient.encryptQuery(Number.NaN, {
- column: testSchema.age,
- table: testSchema,
- queryType: 'orderAndRange',
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- // Non-FFI errors should have undefined code
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('batch encryptQuery error codes', () => {
- it(
- 'preserves error code in batch operations',
- async () => {
- const fakeColumn = csColumn('nonexistent_column').equality()
-
- const result = await protectClient.encryptQuery([
- {
- value: 'test',
- column: fakeColumn,
- table: testSchema,
- queryType: 'equality',
- },
- ])
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
-
- it(
- 'returns undefined code for non-FFI batch errors',
- async () => {
- const result = await protectClient.encryptQuery([
- {
- value: Number.NaN,
- column: testSchema.age,
- table: testSchema,
- queryType: 'orderAndRange',
- },
- ])
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('encrypt error codes', () => {
- it(
- 'returns UNKNOWN_COLUMN code for non-existent column in encrypt',
- async () => {
- const fakeColumn = csColumn('nonexistent_column')
-
- const result = await protectClient.encrypt('test', {
- column: fakeColumn,
- table: testSchema,
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
-
- it(
- 'returns undefined code for non-FFI encrypt errors',
- async () => {
- const result = await protectClient.encrypt(Number.NaN, {
- column: testSchema.age,
- table: testSchema,
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- // NaN validation happens before FFI call
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('decrypt error codes', () => {
- it(
- 'returns undefined code for malformed ciphertext (non-FFI validation)',
- async () => {
- // This error occurs during ciphertext parsing, not FFI decryption
- const invalidCiphertext = {
- i: { t: 'test_table', c: 'nonexistent' },
- v: 2,
- c: 'invalid_ciphertext_data',
- }
-
- const result = await protectClient.decrypt(invalidCiphertext)
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError)
- // Malformed ciphertext errors are caught before FFI and don't have codes
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('bulkEncrypt error codes', () => {
- it(
- 'returns UNKNOWN_COLUMN code for non-existent column',
- async () => {
- const fakeColumn = csColumn('nonexistent_column')
-
- const result = await protectClient.bulkEncrypt(
- [{ plaintext: 'test1' }, { plaintext: 'test2' }],
- {
- column: fakeColumn,
- table: testSchema,
- },
- )
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
-
- it(
- 'returns undefined code for non-FFI validation errors',
- async () => {
- const result = await protectClient.bulkEncrypt(
- [{ plaintext: Number.NaN }],
- {
- column: testSchema.age,
- table: testSchema,
- },
- )
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('bulkDecrypt error codes', () => {
- it(
- 'returns undefined code for malformed ciphertexts (non-FFI validation)',
- async () => {
- // bulkDecrypt uses the "fallible" FFI API (decryptBulkFallible) which normally:
- // - Succeeds at the operation level
- // - Returns per-item results with either { data } or { error }
- //
- // However, malformed ciphertexts cause parsing errors BEFORE the fallible API,
- // which throws and triggers a top-level failure (not per-item errors).
- // These pre-FFI errors don't have structured FFI error codes.
- const invalidCiphertexts = [
- { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' } },
- { data: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' } },
- ]
-
- const result = await protectClient.bulkDecrypt(invalidCiphertexts)
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError)
- // FFI parsing errors don't have structured error codes
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('encryptModel error codes', () => {
- it(
- 'returns UNKNOWN_COLUMN code for model with non-existent column',
- async () => {
- const model = { nonexistent: 'test value' }
-
- const result = await protectClient.encryptModel(model, badModelSchema)
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('decryptModel error codes', () => {
- it(
- 'returns undefined code for malformed model (non-FFI validation)',
- async () => {
- const malformedModel = {
- email: {
- i: { t: 'test_table', c: 'email' },
- v: 2,
- c: 'invalid_ciphertext',
- },
- }
-
- const result = await protectClient.decryptModel(malformedModel)
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError)
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('bulkEncryptModels error codes', () => {
- it(
- 'returns UNKNOWN_COLUMN code for models with non-existent column',
- async () => {
- const models = [{ nonexistent: 'value1' }, { nonexistent: 'value2' }]
-
- const result = await protectClient.bulkEncryptModels(
- models,
- badModelSchema,
- )
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('bulkDecryptModels error codes', () => {
- it(
- 'returns undefined code for malformed models (non-FFI validation)',
- async () => {
- const malformedModels = [
- {
- email: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid1' },
- },
- {
- email: { i: { t: 'test_table', c: 'email' }, v: 2, c: 'invalid2' },
- },
- ]
-
- const result = await protectClient.bulkDecryptModels(malformedModels)
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.DecryptionError)
- expect(result.failure?.code).toBeUndefined()
- },
- FFI_TEST_TIMEOUT,
- )
- })
-
- describe('searchTerms (deprecated) error codes', () => {
- it(
- 'returns UNKNOWN_COLUMN code for non-existent column',
- async () => {
- const fakeColumn = csColumn('nonexistent_column').equality()
-
- const result = await protectClient.createSearchTerms([
- { value: 'test', column: fakeColumn, table: testSchema },
- ])
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.type).toBe(ProtectErrorTypes.EncryptionError)
- expect(result.failure?.code).toBe('UNKNOWN_COLUMN')
- },
- FFI_TEST_TIMEOUT,
- )
- })
-})
diff --git a/packages/protect/__tests__/fixtures/index.ts b/packages/protect/__tests__/fixtures/index.ts
deleted file mode 100644
index fa892b375..000000000
--- a/packages/protect/__tests__/fixtures/index.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { csColumn, csTable } from '@cipherstash/schema'
-import { expect, vi } from 'vitest'
-
-// ============ Schema Fixtures ============
-
-/**
- * Users table with multiple index types for testing
- */
-export const users = csTable('users', {
- email: csColumn('email').equality(),
- bio: csColumn('bio').freeTextSearch(),
- age: csColumn('age').dataType('number').orderAndRange(),
-})
-
-/**
- * Articles table with only freeTextSearch (for auto-inference test)
- */
-export const articles = csTable('articles', {
- content: csColumn('content').freeTextSearch(),
-})
-
-/**
- * Products table with only orderAndRange (for auto-inference test)
- */
-export const products = csTable('products', {
- price: csColumn('price').dataType('number').orderAndRange(),
-})
-
-/**
- * Metadata table with no indexes (for validation error test)
- */
-export const metadata = csTable('metadata', {
- raw: csColumn('raw'),
-})
-
-/**
- * Documents table with searchable JSON column (for STE Vec queries)
- */
-export const jsonbSchema = csTable('documents', {
- id: csColumn('id'),
- metadata: csColumn('metadata').searchableJson(),
-})
-
-/**
- * Schema fixture with mixed column types including JSON.
- */
-export const mixedSchema = csTable('records', {
- id: csColumn('id'),
- email: csColumn('email').equality(),
- name: csColumn('name').freeTextSearch(),
- metadata: csColumn('metadata').searchableJson(),
-})
-
-// ============ Mock Factories ============
-
-/**
- * Creates a mock LockContext with successful response
- */
-export function createMockLockContext(overrides?: {
- accessToken?: string
- expiry?: number
- identityClaim?: string[]
-}) {
- return {
- getLockContext: vi.fn().mockResolvedValue({
- data: {
- ctsToken: {
- accessToken: overrides?.accessToken ?? 'mock-token',
- expiry: overrides?.expiry ?? Date.now() + 3600000,
- },
- context: {
- identityClaim: overrides?.identityClaim ?? ['sub'],
- },
- },
- }),
- }
-}
-
-/**
- * Creates a mock LockContext with explicit null context (simulates runtime edge case)
- */
-export function createMockLockContextWithNullContext() {
- return {
- getLockContext: vi.fn().mockResolvedValue({
- data: {
- ctsToken: {
- accessToken: 'mock-token',
- expiry: Date.now() + 3600000,
- },
- context: null, // Explicit null - simulating runtime edge case
- },
- }),
- }
-}
-
-/**
- * Creates a mock LockContext that returns a failure
- */
-export function createFailingMockLockContext(
- errorType: string,
- message: string,
-) {
- return {
- getLockContext: vi.fn().mockResolvedValue({
- failure: { type: errorType, message },
- }),
- }
-}
-
-// ============ Test Helpers ============
-
-/**
- * Unwraps a Result type, throwing an error if it's a failure.
- * Use this to simplify test assertions when you expect success.
- */
-export function unwrapResult(result: {
- data?: T
- failure?: { message: string }
-}): T {
- if (result.failure) {
- throw new Error(result.failure.message)
- }
- return result.data as T
-}
-
-/**
- * Asserts that a result is a failure with optional message and type matching
- */
-export function expectFailure(
- result: { failure?: { message: string; type?: string } },
- messagePattern?: string | RegExp,
- expectedType?: string,
-) {
- expect(result.failure).toBeDefined()
- if (messagePattern) {
- if (typeof messagePattern === 'string') {
- expect(result.failure?.message).toContain(messagePattern)
- } else {
- expect(result.failure?.message).toMatch(messagePattern)
- }
- }
- if (expectedType) {
- expect(result.failure?.type).toBe(expectedType)
- }
-}
diff --git a/packages/protect/__tests__/helpers.test.ts b/packages/protect/__tests__/helpers.test.ts
deleted file mode 100644
index 566ff2554..000000000
--- a/packages/protect/__tests__/helpers.test.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import {
- bulkModelsToEncryptedPgComposites,
- encryptedToCompositeLiteral,
- encryptedToPgComposite,
- isEncryptedPayload,
- isEncryptedScalarQuery,
- modelToEncryptedPgComposites,
-} from '../src/helpers'
-
-describe('helpers', () => {
- describe('encryptedToPgComposite', () => {
- it('should convert encrypted payload to pg composite', () => {
- const encrypted = {
- v: 1,
- c: 'ciphertext',
- i: {
- c: 'iv',
- t: 't',
- },
- k: 'k',
- ob: ['a', 'b'],
- bf: [1, 2, 3],
- hm: 'hm',
- }
-
- const pgComposite = encryptedToPgComposite(encrypted)
- expect(pgComposite).toEqual({
- data: encrypted,
- })
- })
- })
-
- describe('encryptedToCompositeLiteral', () => {
- it('should convert encrypted payload to pg composite literal string', () => {
- const encrypted = {
- v: 1,
- c: 'ciphertext',
- i: {
- c: 'iv',
- t: 't',
- },
- }
-
- const literal = encryptedToCompositeLiteral(encrypted)
- // Should produce PostgreSQL composite literal format: ("json_string")
- expect(literal).toMatch(/^\(.*\)$/)
- // The inner content should be a valid JSON string (double-stringified)
- const innerContent = literal.slice(1, -1) // Remove outer parentheses
- expect(() => JSON.parse(innerContent)).not.toThrow()
- // Parsing the inner content should give us the original JSON
- const parsedJson = JSON.parse(JSON.parse(innerContent))
- expect(parsedJson).toEqual(encrypted)
- })
- })
-
- describe('isEncryptedPayload', () => {
- it('should return true for valid encrypted payload', () => {
- const encrypted = {
- v: 1,
- c: 'ciphertext',
- i: { c: 'iv', t: 't' },
- }
- expect(isEncryptedPayload(encrypted)).toBe(true)
- })
-
- it('should return false for null', () => {
- expect(isEncryptedPayload(null)).toBe(false)
- })
-
- it('should return false for non-encrypted object', () => {
- expect(isEncryptedPayload({ foo: 'bar' })).toBe(false)
- })
- })
-
- describe('isEncryptedScalarQuery', () => {
- const baseIndex = { c: 'email', t: 'users' }
-
- it('returns true for a unique (hm) scalar query term', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: baseIndex,
- hm: 'abc123',
- }),
- ).toBe(true)
- })
-
- it('returns true for a match (bf) scalar query term', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: baseIndex,
- bf: [1, 2, 3],
- }),
- ).toBe(true)
- })
-
- it('returns true for an ore (ob) scalar query term', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: baseIndex,
- ob: ['a', 'b'],
- }),
- ).toBe(true)
- })
-
- it('returns false when the storage ciphertext (c) is present', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: baseIndex,
- c: 'ciphertext',
- hm: 'abc123',
- }),
- ).toBe(false)
- })
-
- it('returns false when more than one lookup term is present', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: baseIndex,
- hm: 'abc123',
- bf: [1, 2, 3],
- }),
- ).toBe(false)
- })
-
- it('returns false when no lookup term is present', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: baseIndex,
- }),
- ).toBe(false)
- })
-
- it('returns false for a ste_vec query term (k: "sv")', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'sv',
- i: baseIndex,
- sv: [{ s: 'selector', t: 'term' }],
- }),
- ).toBe(false)
- })
-
- it('returns false when version (v) is missing or not a number', () => {
- expect(
- isEncryptedScalarQuery({
- k: 'ct',
- i: baseIndex,
- hm: 'abc123',
- }),
- ).toBe(false)
- expect(
- isEncryptedScalarQuery({
- v: '2',
- k: 'ct',
- i: baseIndex,
- hm: 'abc123',
- }),
- ).toBe(false)
- })
-
- it('returns false when index (i) is missing or null', () => {
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- hm: 'abc123',
- }),
- ).toBe(false)
- expect(
- isEncryptedScalarQuery({
- v: 2,
- k: 'ct',
- i: null,
- hm: 'abc123',
- }),
- ).toBe(false)
- })
-
- it('returns false for null, primitives, and non-objects', () => {
- expect(isEncryptedScalarQuery(null)).toBe(false)
- expect(isEncryptedScalarQuery(undefined)).toBe(false)
- expect(isEncryptedScalarQuery('string')).toBe(false)
- expect(isEncryptedScalarQuery(42)).toBe(false)
- })
- })
-
- describe('modelToEncryptedPgComposites', () => {
- it('should transform model with encrypted fields', () => {
- const model = {
- name: 'John',
- email: {
- v: 1,
- c: 'encrypted_email',
- i: { c: 'iv', t: 't' },
- },
- age: 30,
- }
-
- const result = modelToEncryptedPgComposites(model)
- expect(result).toEqual({
- name: 'John',
- email: {
- data: {
- v: 1,
- c: 'encrypted_email',
- i: { c: 'iv', t: 't' },
- },
- },
- age: 30,
- })
- })
- })
-
- describe('bulkModelsToEncryptedPgComposites', () => {
- it('should transform multiple models with encrypted fields', () => {
- const models = [
- {
- name: 'John',
- email: {
- v: 1,
- c: 'encrypted_email1',
- i: { c: 'iv', t: 't' },
- },
- },
- {
- name: 'Jane',
- email: {
- v: 1,
- c: 'encrypted_email2',
- i: { c: 'iv', t: 't' },
- },
- },
- ]
-
- const result = bulkModelsToEncryptedPgComposites(models)
- expect(result).toEqual([
- {
- name: 'John',
- email: {
- data: {
- v: 1,
- c: 'encrypted_email1',
- i: { c: 'iv', t: 't' },
- },
- },
- },
- {
- name: 'Jane',
- email: {
- data: {
- v: 1,
- c: 'encrypted_email2',
- i: { c: 'iv', t: 't' },
- },
- },
- },
- ])
- })
- })
-})
diff --git a/packages/protect/__tests__/infer-index-type.test.ts b/packages/protect/__tests__/infer-index-type.test.ts
deleted file mode 100644
index ac9c9340f..000000000
--- a/packages/protect/__tests__/infer-index-type.test.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { csColumn, csTable } from '@cipherstash/schema'
-import { describe, expect, it } from 'vitest'
-import { inferQueryOpFromPlaintext } from '../src/ffi/helpers/infer-index-type'
-import { inferIndexType, validateIndexType } from '../src/index'
-
-describe('infer-index-type helpers', () => {
- const users = csTable('users', {
- email: csColumn('email').equality(),
- bio: csColumn('bio').freeTextSearch(),
- age: csColumn('age').orderAndRange(),
- name: csColumn('name').equality().freeTextSearch(),
- })
-
- describe('inferIndexType', () => {
- it('returns unique for equality-only column', () => {
- expect(inferIndexType(users.email)).toBe('unique')
- })
-
- it('returns match for freeTextSearch-only column', () => {
- expect(inferIndexType(users.bio)).toBe('match')
- })
-
- it('returns ore for orderAndRange-only column', () => {
- expect(inferIndexType(users.age)).toBe('ore')
- })
-
- it('returns unique when multiple indexes (priority: unique > match > ore)', () => {
- expect(inferIndexType(users.name)).toBe('unique')
- })
-
- it('returns match when freeTextSearch and orderAndRange (priority: match > ore)', () => {
- const schema = csTable('t', {
- col: csColumn('col').freeTextSearch().orderAndRange(),
- })
- expect(inferIndexType(schema.col)).toBe('match')
- })
-
- it('throws for column with no indexes', () => {
- const noIndex = csTable('t', { col: csColumn('col') })
- expect(() => inferIndexType(noIndex.col)).toThrow('no indexes configured')
- })
-
- it('returns ste_vec for searchableJson-only column', () => {
- const schema = csTable('t', { col: csColumn('col').searchableJson() })
- expect(inferIndexType(schema.col)).toBe('ste_vec')
- })
- })
-
- describe('validateIndexType', () => {
- it('does not throw for valid index type', () => {
- expect(() => validateIndexType(users.email, 'unique')).not.toThrow()
- })
-
- it('throws for unconfigured index type', () => {
- expect(() => validateIndexType(users.email, 'match')).toThrow(
- 'not configured',
- )
- })
-
- it('accepts ste_vec when configured', () => {
- const schema = csTable('t', { col: csColumn('col').searchableJson() })
- expect(() => validateIndexType(schema.col, 'ste_vec')).not.toThrow()
- })
-
- it('rejects ste_vec when not configured', () => {
- const schema = csTable('t', { col: csColumn('col').equality() })
- expect(() => validateIndexType(schema.col, 'ste_vec')).toThrow(
- 'not configured',
- )
- })
- })
-
- describe('inferQueryOpFromPlaintext', () => {
- it('returns ste_vec_selector for string plaintext', () => {
- expect(inferQueryOpFromPlaintext('$.user.email')).toBe('ste_vec_selector')
- })
-
- it('returns ste_vec_term for object plaintext', () => {
- expect(inferQueryOpFromPlaintext({ role: 'admin' })).toBe('ste_vec_term')
- })
-
- it('returns ste_vec_term for array plaintext', () => {
- expect(inferQueryOpFromPlaintext(['admin', 'user'])).toBe('ste_vec_term')
- })
-
- it('returns ste_vec_term for number plaintext', () => {
- expect(inferQueryOpFromPlaintext(42)).toBe('ste_vec_term')
- })
-
- it('returns ste_vec_term for boolean plaintext', () => {
- expect(inferQueryOpFromPlaintext(true)).toBe('ste_vec_term')
- })
- })
-})
diff --git a/packages/protect/__tests__/json-protect.test.ts b/packages/protect/__tests__/json-protect.test.ts
deleted file mode 100644
index 22fc203ec..000000000
--- a/packages/protect/__tests__/json-protect.test.ts
+++ /dev/null
@@ -1,1217 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable, csValue } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { LockContext, protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
- json: csColumn('json').dataType('json'),
- metadata: {
- profile: csValue('metadata.profile').dataType('json'),
- settings: {
- preferences: csValue('metadata.settings.preferences').dataType('json'),
- },
- },
-})
-
-type User = {
- id: string
- email?: string | null
- createdAt?: Date
- updatedAt?: Date
- address?: string | null
- json?: Record | null
- metadata?: {
- profile?: Record | null
- settings?: {
- preferences?: Record | null
- }
- }
-}
-
-let protectClient: Awaited>
-
-beforeAll(async () => {
- protectClient = await protect({
- schemas: [users],
- })
-})
-
-describe('JSON encryption and decryption', () => {
- it('should encrypt and decrypt a simple JSON payload', async () => {
- const json = {
- name: 'John Doe',
- age: 30,
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should encrypt and decrypt a complex JSON payload', async () => {
- const json = {
- user: {
- id: 123,
- name: 'Jane Smith',
- email: 'jane@example.com',
- preferences: {
- theme: 'dark',
- notifications: true,
- language: 'en-US',
- },
- tags: ['premium', 'verified'],
- metadata: {
- created: '2023-01-01T00:00:00Z',
- lastLogin: '2023-12-01T10:30:00Z',
- },
- },
- settings: {
- privacy: {
- public: false,
- shareData: true,
- },
- features: {
- beta: true,
- experimental: false,
- },
- },
- array: [1, 2, 3, { nested: 'value' }],
- nullValue: null,
- booleanValue: true,
- numberValue: 42.5,
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle null JSON payload', async () => {
- const ciphertext = await protectClient.encrypt(null, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify null is preserved
- expect(ciphertext.data).toBeNull()
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: null,
- })
- }, 30000)
-
- it('should handle empty JSON object', async () => {
- const json = {}
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with special characters', async () => {
- const json = {
- message: 'Hello "world" with \'quotes\' and \\backslashes\\',
- unicode: '🚀 emoji and ñ special chars',
- symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?/~`',
- multiline: 'Line 1\nLine 2\tTabbed',
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-})
-
-describe('JSON model encryption and decryption', () => {
- it('should encrypt and decrypt a model with JSON field', async () => {
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- address: '123 Main St',
- json: {
- name: 'John Doe',
- age: 30,
- preferences: {
- theme: 'dark',
- notifications: true,
- },
- },
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.address).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.json).toHaveProperty('k', 'ct')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle null JSON in model', async () => {
- const decryptedModel = {
- id: '2',
- email: 'test2@example.com',
- address: '456 Oak St',
- json: null,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.address).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.json).toBeNull()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle undefined JSON in model', async () => {
- const decryptedModel = {
- id: '3',
- email: 'test3@example.com',
- address: '789 Pine St',
- json: undefined,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.address).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.json).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-})
-
-describe('JSON bulk encryption and decryption', () => {
- it('should bulk encrypt and decrypt JSON payloads', async () => {
- const jsonPayloads = [
- { id: 'user1', plaintext: { name: 'Alice', age: 25 } },
- { id: 'user2', plaintext: { name: 'Bob', age: 30 } },
- { id: 'user3', plaintext: { name: 'Charlie', age: 35 } },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, {
- column: users.json,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('k', 'ct')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toHaveProperty('k', 'ct')
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('k', 'ct')
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', {
- name: 'Alice',
- age: 25,
- })
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', {
- name: 'Bob',
- age: 30,
- })
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty('data', {
- name: 'Charlie',
- age: 35,
- })
- }, 30000)
-
- it('should handle mixed null and non-null JSON in bulk operations', async () => {
- const jsonPayloads = [
- { id: 'user1', plaintext: { name: 'Alice', age: 25 } },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: { name: 'Charlie', age: 35 } },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, {
- column: users.json,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('k', 'ct')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toBeNull()
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('k', 'ct')
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', {
- name: 'Alice',
- age: 25,
- })
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', null)
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty('data', {
- name: 'Charlie',
- age: 35,
- })
- }, 30000)
-
- it('should bulk encrypt and decrypt models with JSON fields', async () => {
- const decryptedModels = [
- {
- id: '1',
- email: 'test1@example.com',
- address: '123 Main St',
- json: {
- name: 'Alice',
- preferences: { theme: 'dark' },
- },
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- },
- {
- id: '2',
- email: 'test2@example.com',
- address: '456 Oak St',
- json: {
- name: 'Bob',
- preferences: { theme: 'light' },
- },
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields for each model
- expect(encryptedModels.data[0].email).toHaveProperty('k', 'ct')
- expect(encryptedModels.data[0].address).toHaveProperty('k', 'ct')
- expect(encryptedModels.data[0].json).toHaveProperty('k', 'ct')
- expect(encryptedModels.data[1].email).toHaveProperty('k', 'ct')
- expect(encryptedModels.data[1].address).toHaveProperty('k', 'ct')
- expect(encryptedModels.data[1].json).toHaveProperty('k', 'ct')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[1].id).toBe('2')
- expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01'))
-
- const decryptedResult = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModels)
- }, 30000)
-})
-
-describe('JSON encryption with lock context', () => {
- it('should encrypt and decrypt JSON with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const json = {
- name: 'John Doe',
- age: 30,
- preferences: {
- theme: 'dark',
- notifications: true,
- },
- }
-
- const ciphertext = await protectClient
- .encrypt(json, {
- column: users.json,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient
- .decrypt(ciphertext.data)
- .withLockContext(lockContext.data)
-
- if (plaintext.failure) {
- throw new Error(`[protect]: ${plaintext.failure.message}`)
- }
-
- expect(plaintext.data).toEqual(json)
- }, 30000)
-
- it('should encrypt JSON model with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- json: {
- name: 'John Doe',
- preferences: { theme: 'dark' },
- },
- }
-
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .withLockContext(lockContext.data)
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.json).toHaveProperty('k', 'ct')
-
- const decryptedResult = await protectClient
- .decryptModel(encryptedModel.data)
- .withLockContext(lockContext.data)
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should bulk encrypt JSON with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const jsonPayloads = [
- { id: 'user1', plaintext: { name: 'Alice', age: 25 } },
- { id: 'user2', plaintext: { name: 'Bob', age: 30 } },
- ]
-
- const encryptedData = await protectClient
- .bulkEncrypt(jsonPayloads, {
- column: users.json,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(2)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('k', 'ct')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toHaveProperty('k', 'ct')
-
- // Decrypt with lock context
- const decryptedData = await protectClient
- .bulkDecrypt(encryptedData.data)
- .withLockContext(lockContext.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(2)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', {
- name: 'Alice',
- age: 25,
- })
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', {
- name: 'Bob',
- age: 30,
- })
- }, 30000)
-})
-
-describe('JSON nested object encryption', () => {
- it('should encrypt and decrypt nested JSON objects', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- metadata: {
- profile: {
- name: 'John Doe',
- age: 30,
- preferences: {
- theme: 'dark',
- notifications: true,
- },
- },
- settings: {
- preferences: {
- language: 'en-US',
- timezone: 'UTC',
- },
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.metadata?.profile).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.metadata?.settings?.preferences).toHaveProperty(
- 'c',
- )
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle null values in nested JSON objects', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '2',
- email: 'test2@example.com',
- metadata: {
- profile: null,
- settings: {
- preferences: null,
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify null fields are preserved
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.metadata?.profile).toBeNull()
- expect(encryptedModel.data.metadata?.settings?.preferences).toBeNull()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle undefined values in nested JSON objects', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '3',
- email: 'test3@example.com',
- metadata: {
- profile: undefined,
- settings: {
- preferences: undefined,
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify undefined fields are preserved
- expect(encryptedModel.data.email).toHaveProperty('k', 'ct')
- expect(encryptedModel.data.metadata?.profile).toBeUndefined()
- expect(encryptedModel.data.metadata?.settings?.preferences).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-})
-
-describe('JSON edge cases and error handling', () => {
- it('should handle very large JSON objects', async () => {
- const largeJson = {
- data: Array.from({ length: 1000 }, (_, i) => ({
- id: i,
- name: `User ${i}`,
- email: `user${i}@example.com`,
- metadata: {
- preferences: {
- theme: i % 2 === 0 ? 'dark' : 'light',
- notifications: i % 3 === 0,
- },
- },
- })),
- metadata: {
- total: 1000,
- created: new Date().toISOString(),
- },
- }
-
- const ciphertext = await protectClient.encrypt(largeJson, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: largeJson,
- })
- }, 30000)
-
- it('should handle JSON with circular references (should fail gracefully)', async () => {
- const circularObj: Record = { name: 'test' }
- circularObj.self = circularObj
-
- try {
- await protectClient.encrypt(circularObj, {
- column: users.json,
- table: users,
- })
- // This should not reach here as JSON.stringify should fail
- expect(true).toBe(false)
- } catch (error) {
- // Expected to fail due to circular reference
- expect(error).toBeDefined()
- }
- }, 30000)
-
- it('should handle JSON with special data types', async () => {
- const json = {
- string: 'hello',
- number: 42,
- boolean: true,
- null: null,
- array: [1, 2, 3],
- object: { nested: 'value' },
- date: new Date('2023-01-01T00:00:00Z'),
- // Note: Functions and undefined are not JSON serializable
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- // Date objects get serialized to strings in JSON
- const expectedJson = {
- ...json,
- date: '2023-01-01T00:00:00.000Z',
- }
-
- expect(plaintext).toEqual({
- data: expectedJson,
- })
- }, 30000)
-})
-
-describe('JSON performance tests', () => {
- it('should handle large numbers of JSON objects efficiently', async () => {
- const largeJsonArray = Array.from({ length: 100 }, (_, i) => ({
- id: i,
- data: {
- name: `User ${i}`,
- preferences: {
- theme: i % 2 === 0 ? 'dark' : 'light',
- notifications: i % 3 === 0,
- },
- metadata: {
- created: new Date().toISOString(),
- version: i,
- },
- },
- }))
-
- const jsonPayloads = largeJsonArray.map((item, index) => ({
- id: `user${index}`,
- plaintext: item,
- }))
-
- const encryptedData = await protectClient.bulkEncrypt(jsonPayloads, {
- column: users.json,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(100)
-
- // Decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify all data is preserved
- expect(decryptedData.data).toHaveLength(100)
-
- for (let i = 0; i < 100; i++) {
- expect(decryptedData.data[i].id).toBe(`user${i}`)
- expect(decryptedData.data[i].data).toEqual(largeJsonArray[i])
- }
- }, 5000)
-})
-
-describe('JSON advanced scenarios', () => {
- it('should handle JSON with deeply nested arrays', async () => {
- const json = {
- users: [
- {
- id: 1,
- name: 'Alice',
- roles: [
- { name: 'admin', permissions: ['read', 'write', 'delete'] },
- { name: 'user', permissions: ['read'] },
- ],
- },
- {
- id: 2,
- name: 'Bob',
- roles: [{ name: 'user', permissions: ['read'] }],
- },
- ],
- metadata: {
- total: 2,
- lastUpdated: new Date().toISOString(),
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with mixed data types in arrays', async () => {
- const json = {
- mixedArray: ['string', 42, true, null, { nested: 'object' }, [1, 2, 3]],
- metadata: {
- types: ['string', 'number', 'boolean', 'null', 'object', 'array'],
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with Unicode and international characters', async () => {
- const json = {
- international: {
- chinese: '你好世界',
- japanese: 'こんにちは世界',
- korean: '안녕하세요 세계',
- arabic: 'مرحبا بالعالم',
- russian: 'Привет мир',
- emoji: '🚀 🌍 💻 🎉',
- },
- metadata: {
- languages: ['Chinese', 'Japanese', 'Korean', 'Arabic', 'Russian'],
- encoding: 'UTF-8',
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with scientific notation and large numbers', async () => {
- const json = {
- numbers: {
- integer: 1234567890,
- float: Math.PI,
- scientific: 1.23e10,
- negative: -9876543210,
- zero: 0,
- verySmall: 1.23e-10,
- },
- metadata: {
- precision: 'high',
- format: 'scientific',
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with boolean edge cases', async () => {
- const json = {
- booleans: {
- true: true,
- false: false,
- stringTrue: 'true',
- stringFalse: 'false',
- numberOne: 1,
- numberZero: 0,
- emptyString: '',
- nullValue: null,
- },
- metadata: {
- type: 'boolean_edge_cases',
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-})
-
-describe('JSON error handling and edge cases', () => {
- it('should handle malformed JSON gracefully', async () => {
- // This test ensures the library handles JSON serialization errors
- const invalidJson = {
- valid: 'data',
- // This will cause JSON.stringify to fail
- circular: null as unknown,
- }
-
- // Create a circular reference
- invalidJson.circular = invalidJson
-
- try {
- await protectClient.encrypt(invalidJson, {
- column: users.json,
- table: users,
- })
- expect(true).toBe(false) // Should not reach here
- } catch (error) {
- expect(error).toBeDefined()
- expect(error).toBeInstanceOf(Error)
- }
- }, 30000)
-
- it('should handle empty arrays and objects', async () => {
- const json = {
- emptyArray: [],
- emptyObject: {},
- nestedEmpty: {
- array: [],
- object: {},
- },
- mixedEmpty: {
- data: 'present',
- empty: [],
- null: null,
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with very long strings', async () => {
- const longString = 'A'.repeat(10000) // 10KB string
- const json = {
- longString,
- metadata: {
- length: longString.length,
- type: 'long_string',
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-
- it('should handle JSON with all primitive types', async () => {
- const json = {
- string: 'hello world',
- number: 42,
- float: 3.14,
- boolean: true,
- null: null,
- array: [1, 2, 3],
- object: { key: 'value' },
- nested: {
- level1: {
- level2: {
- level3: 'deep value',
- },
- },
- },
- }
-
- const ciphertext = await protectClient.encrypt(json, {
- column: users.json,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('k', 'ct')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: json,
- })
- }, 30000)
-})
diff --git a/packages/protect/__tests__/jsonb-helpers.test.ts b/packages/protect/__tests__/jsonb-helpers.test.ts
deleted file mode 100644
index 9f12d131f..000000000
--- a/packages/protect/__tests__/jsonb-helpers.test.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { buildNestedObject, parseJsonbPath, toJsonPath } from '../src'
-
-describe('toJsonPath', () => {
- it('converts simple path to JSONPath format', () => {
- expect(toJsonPath('name')).toBe('$.name')
- })
-
- it('converts nested path to JSONPath format', () => {
- expect(toJsonPath('user.email')).toBe('$.user.email')
- })
-
- it('converts deeply nested path', () => {
- expect(toJsonPath('user.profile.settings.theme')).toBe(
- '$.user.profile.settings.theme',
- )
- })
-
- it('returns unchanged if already in JSONPath format', () => {
- expect(toJsonPath('$.user.email')).toBe('$.user.email')
- })
-
- it('normalizes bare $ prefix', () => {
- expect(toJsonPath('$user.email')).toBe('$.user.email')
- })
-
- it('handles path starting with dot', () => {
- expect(toJsonPath('.user.email')).toBe('$.user.email')
- })
-
- it('handles root path', () => {
- expect(toJsonPath('$')).toBe('$')
- })
-
- it('handles empty string', () => {
- expect(toJsonPath('')).toBe('$')
- })
-
- it('handles array index in path', () => {
- expect(toJsonPath('user.roles[0]')).toBe('$.user.roles[0]')
- })
-
- it('handles array index with nested property', () => {
- expect(toJsonPath('items[0].name')).toBe('$.items[0].name')
- })
-
- it('handles already-prefixed path with array index', () => {
- expect(toJsonPath('$.data[2]')).toBe('$.data[2]')
- })
-
- it('handles nested array indices', () => {
- expect(toJsonPath('matrix[0][1]')).toBe('$.matrix[0][1]')
- })
-
- it('handles array index at root level', () => {
- expect(toJsonPath('[0].name')).toBe('$[0].name')
- })
-
- it('preserves already-prefixed root array index', () => {
- expect(toJsonPath('$[0]')).toBe('$[0]')
- })
-
- it('preserves already-prefixed root array index with property', () => {
- expect(toJsonPath('$[0].name')).toBe('$[0].name')
- })
-
- it('handles large array index', () => {
- expect(toJsonPath('items[999].value')).toBe('$.items[999].value')
- })
-
- it('handles deeply nested path after array index', () => {
- expect(toJsonPath('data[0].user.profile.settings')).toBe(
- '$.data[0].user.profile.settings',
- )
- })
-
- it('handles root array with nested array', () => {
- expect(toJsonPath('[0].items[1].name')).toBe('$[0].items[1].name')
- })
-})
-
-describe('buildNestedObject', () => {
- it('builds single-level object', () => {
- expect(buildNestedObject('name', 'alice')).toEqual({ name: 'alice' })
- })
-
- it('builds two-level nested object', () => {
- expect(buildNestedObject('user.role', 'admin')).toEqual({
- user: { role: 'admin' },
- })
- })
-
- it('builds deeply nested object', () => {
- expect(buildNestedObject('a.b.c.d', 'value')).toEqual({
- a: { b: { c: { d: 'value' } } },
- })
- })
-
- it('handles numeric values', () => {
- expect(buildNestedObject('user.age', 30)).toEqual({
- user: { age: 30 },
- })
- })
-
- it('handles boolean values', () => {
- expect(buildNestedObject('user.active', true)).toEqual({
- user: { active: true },
- })
- })
-
- it('handles null values', () => {
- expect(buildNestedObject('user.data', null)).toEqual({
- user: { data: null },
- })
- })
-
- it('handles object values', () => {
- const value = { nested: 'object' }
- expect(buildNestedObject('user.config', value)).toEqual({
- user: { config: { nested: 'object' } },
- })
- })
-
- it('handles array values', () => {
- expect(buildNestedObject('user.tags', ['admin', 'user'])).toEqual({
- user: { tags: ['admin', 'user'] },
- })
- })
-
- it('strips JSONPath prefix from path', () => {
- expect(buildNestedObject('$.user.role', 'admin')).toEqual({
- user: { role: 'admin' },
- })
- })
-
- it('throws on empty path', () => {
- expect(() => buildNestedObject('', 'value')).toThrow('Path cannot be empty')
- })
-
- it('throws on root-only path', () => {
- expect(() => buildNestedObject('$', 'value')).toThrow(
- 'Path must contain at least one segment',
- )
- })
-
- it('throws on __proto__ segment', () => {
- expect(() => buildNestedObject('__proto__.polluted', 'yes')).toThrow(
- 'Path contains forbidden segment: __proto__',
- )
- })
-
- it('throws on prototype segment', () => {
- expect(() => buildNestedObject('user.prototype.hack', 'yes')).toThrow(
- 'Path contains forbidden segment: prototype',
- )
- })
-
- it('throws on constructor segment', () => {
- expect(() => buildNestedObject('constructor', 'yes')).toThrow(
- 'Path contains forbidden segment: constructor',
- )
- })
-
- it('throws on nested forbidden segment', () => {
- expect(() => buildNestedObject('a.b.__proto__', 'yes')).toThrow(
- 'Path contains forbidden segment: __proto__',
- )
- })
-})
-
-describe('parseJsonbPath', () => {
- it('parses simple path', () => {
- expect(parseJsonbPath('name')).toEqual(['name'])
- })
-
- it('parses nested path', () => {
- expect(parseJsonbPath('user.email')).toEqual(['user', 'email'])
- })
-
- it('parses deeply nested path', () => {
- expect(parseJsonbPath('a.b.c.d')).toEqual(['a', 'b', 'c', 'd'])
- })
-
- it('strips JSONPath prefix', () => {
- expect(parseJsonbPath('$.user.email')).toEqual(['user', 'email'])
- })
-
- it('strips bare $ prefix', () => {
- expect(parseJsonbPath('$user.email')).toEqual(['user', 'email'])
- })
-
- it('handles empty string', () => {
- expect(parseJsonbPath('')).toEqual([])
- })
-
- it('handles root only', () => {
- expect(parseJsonbPath('$')).toEqual([])
- })
-
- it('filters empty segments', () => {
- expect(parseJsonbPath('user..email')).toEqual(['user', 'email'])
- })
-})
diff --git a/packages/protect/__tests__/k-discriminator.test.ts b/packages/protect/__tests__/k-discriminator.test.ts
deleted file mode 100644
index 002ff842f..000000000
--- a/packages/protect/__tests__/k-discriminator.test.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email'),
-})
-
-describe('k-field discriminator (EQL v2.3)', () => {
- let protectClient: Awaited>
-
- beforeAll(async () => {
- protectClient = await protect({ schemas: [users] })
- })
-
- it('encrypts scalar data with k: "ct" discriminator', async () => {
- const testData = 'test@example.com'
-
- const result = await protectClient.encrypt(testData, {
- column: users.email,
- table: users,
- })
-
- if (result.failure) {
- throw new Error(`Encryption failed: ${result.failure.message}`)
- }
-
- expect(result.data).toHaveProperty('k', 'ct')
- expect(result.data).toHaveProperty('c')
- expect(result.data).toHaveProperty('v')
- expect(result.data).toHaveProperty('i')
- }, 30000)
-
- it('decrypts a payload round-trips back to the original plaintext', async () => {
- const testData = 'roundtrip@example.com'
-
- const encrypted = await protectClient.encrypt(testData, {
- column: users.email,
- table: users,
- })
-
- if (encrypted.failure) {
- throw new Error(`Encryption failed: ${encrypted.failure.message}`)
- }
-
- const result = await protectClient.decrypt(encrypted.data!)
-
- if (result.failure) {
- throw new Error(`Decryption failed: ${result.failure.message}`)
- }
-
- expect(result.data).toBe(testData)
- }, 30000)
-})
diff --git a/packages/protect/__tests__/keysets.test.ts b/packages/protect/__tests__/keysets.test.ts
deleted file mode 100644
index e8f4e0fed..000000000
--- a/packages/protect/__tests__/keysets.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import 'dotenv/config'
-import { ensureKeyset } from '@cipherstash/protect-ffi'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email'),
-})
-
-let testKeysetId: string
-
-beforeAll(async () => {
- const keyset = await ensureKeyset({ name: 'Test' })
- testKeysetId = keyset.id
-})
-
-describe('encryption and decryption with keyset id', () => {
- it('should encrypt and decrypt a payload', async () => {
- const protectClient = await protect({
- schemas: [users],
- keyset: {
- id: testKeysetId,
- },
- })
-
- const email = 'hello@example.com'
-
- const ciphertext = await protectClient.encrypt(email, {
- column: users.email,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const a = ciphertext.data
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: email,
- })
- }, 30000)
-})
-
-describe('encryption and decryption with keyset name', () => {
- it('should encrypt and decrypt a payload', async () => {
- const protectClient = await protect({
- schemas: [users],
- keyset: {
- name: 'Test',
- },
- })
-
- const email = 'hello@example.com'
-
- const ciphertext = await protectClient.encrypt(email, {
- column: users.email,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const a = ciphertext.data
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: email,
- })
- }, 30000)
-})
-
-describe('encryption and decryption with invalid keyset id', () => {
- it('should throw an error', async () => {
- await expect(
- protect({
- schemas: [users],
- keyset: {
- id: 'invalid-uuid',
- },
- }),
- ).rejects.toThrow(
- '[protect]: Invalid UUID provided for keyset id. Must be a valid UUID.',
- )
- })
-})
diff --git a/packages/protect/__tests__/lock-context.test.ts b/packages/protect/__tests__/lock-context.test.ts
deleted file mode 100644
index 4a0894b29..000000000
--- a/packages/protect/__tests__/lock-context.test.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it } from 'vitest'
-import { protect } from '../src'
-import { LockContext } from '../src/identify'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
-})
-
-type User = {
- id: string
- email?: string | null
- createdAt?: Date
- updatedAt?: Date
- address?: string | null
- number?: number
-}
-
-let protectClient: Awaited>
-
-beforeAll(async () => {
- protectClient = await protect({
- schemas: [users],
- })
-})
-
-describe('encryption and decryption with lock context', () => {
- it('should encrypt and decrypt a payload with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const email = 'hello@example.com'
-
- const ciphertext = await protectClient
- .encrypt(email, {
- column: users.email,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- const plaintext = await protectClient
- .decrypt(ciphertext.data)
- .withLockContext(lockContext.data)
-
- if (plaintext.failure) {
- throw new Error(`[protect]: ${plaintext.failure.message}`)
- }
-
- expect(plaintext.data).toEqual(email)
- }, 30000)
-
- it('should encrypt and decrypt a model with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- // Create a model with decrypted values
- const decryptedModel = {
- id: '1',
- email: 'plaintext',
- }
-
- // Encrypt the model with lock context
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .withLockContext(lockContext.data)
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Decrypt the model with lock context
- const decryptedResult = await protectClient
- .decryptModel(encryptedModel.data)
- .withLockContext(lockContext.data)
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual({
- id: '1',
- email: 'plaintext',
- })
- }, 30000)
-
- it('should encrypt with context and be unable to decrypt without context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- // Create a model with decrypted values
- const decryptedModel = {
- id: '1',
- email: 'plaintext',
- }
-
- // Encrypt the model with lock context
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .withLockContext(lockContext.data)
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- try {
- await protectClient.decryptModel(encryptedModel.data)
- } catch (error) {
- const e = error as Error
- expect(e.message.startsWith('Failed to retrieve key')).toEqual(true)
- }
- }, 30000)
-
- it('should bulk encrypt and decrypt models with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- // Create models with decrypted values
- const decryptedModels = [
- {
- id: '1',
- email: 'test',
- },
- {
- id: '2',
- email: 'test2',
- },
- ]
-
- // Encrypt the models with lock context
- const encryptedModels = await protectClient
- .bulkEncryptModels(decryptedModels, users)
- .withLockContext(lockContext.data)
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Decrypt the models with lock context
- const decryptedResult = await protectClient
- .bulkDecryptModels(encryptedModels.data)
- .withLockContext(lockContext.data)
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual([
- {
- id: '1',
- email: 'test',
- },
- {
- id: '2',
- email: 'test2',
- },
- ])
- }, 30000)
-})
diff --git a/packages/protect/__tests__/nested-models.test.ts b/packages/protect/__tests__/nested-models.test.ts
deleted file mode 100644
index 8f44f809b..000000000
--- a/packages/protect/__tests__/nested-models.test.ts
+++ /dev/null
@@ -1,958 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable, csValue } from '@cipherstash/schema'
-import { describe, expect, it, vi } from 'vitest'
-import { LockContext, protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
- name: csColumn('name').freeTextSearch(),
- example: {
- field: csValue('example.field'),
- nested: {
- deeper: csValue('example.nested.deeper'),
- },
- },
-})
-
-type User = {
- id: string
- email?: string | null
- createdAt?: Date
- updatedAt?: Date
- address?: string | null
- notEncrypted?: string | null
- example: {
- field: string | undefined | null
- nested?: {
- deeper: string | undefined | null
- plaintext?: string | undefined | null
- notInSchema?: {
- deeper: string | undefined | null
- }
- deeperNotInSchema?: string | undefined | null
- extra?: {
- plaintext: string | undefined | null
- }
- }
- plaintext?: string | undefined | null
- fieldNotInSchema?: string | undefined | null
- notInSchema?: {
- deeper: string | undefined | null
- }
- }
-}
-
-describe('encrypt models with nested fields', () => {
- it('should encrypt and decrypt a single value from a nested schema', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const encryptResponse = await protectClient.encrypt('hello world', {
- column: users.example.field,
- table: users,
- })
-
- if (encryptResponse.failure) {
- throw new Error(`[protect]: ${encryptResponse.failure.message}`)
- }
-
- // Verify encrypted field
- expect(encryptResponse.data).toHaveProperty('c')
-
- const decryptResponse = await protectClient.decrypt(encryptResponse.data)
-
- if (decryptResponse.failure) {
- throw new Error(`[protect]: ${decryptResponse.failure.message}`)
- }
-
- expect(decryptResponse).toEqual({
- data: 'hello world',
- })
- })
-
- it('should encrypt and decrypt a model with nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- address: '123 Main St',
- notEncrypted: 'not encrypted',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- example: {
- field: 'test',
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.example.field).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle null values in nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '2',
- email: null,
- address: null,
- example: {
- field: null,
- nested: {
- deeper: null,
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify null fields are preserved
- expect(encryptedModel.data.email).toBeNull()
- expect(encryptedModel.data.address).toBeNull()
- expect(encryptedModel.data.example.field).toBeNull()
- expect(encryptedModel.data.example.nested?.deeper).toBeNull()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle undefined values in nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '3',
- example: {
- field: undefined,
- nested: {
- deeper: undefined,
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify undefined fields are preserved
- expect(encryptedModel.data.email).toBeUndefined()
- expect(encryptedModel.data.example.field).toBeUndefined()
- expect(encryptedModel.data.example.nested?.deeper).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle mixed null and undefined values in nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '4',
- email: 'test@example.com',
- address: undefined,
- notEncrypted: 'not encrypted',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- example: {
- field: null,
- nested: {
- deeper: undefined,
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
-
- // Verify null/undefined fields are preserved
- expect(encryptedModel.data.address).toBeUndefined()
- expect(encryptedModel.data.example.field).toBeNull()
- expect(encryptedModel.data.example.nested?.deeper).toBeUndefined()
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('4')
- expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle deeply nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '3',
- example: {
- field: 'outer',
- nested: {
- deeper: 'inner value',
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.example.field).toHaveProperty('c')
- expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('3')
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle missing optional nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '5',
- example: {
- field: 'present',
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.example.field).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('5')
- expect(encryptedModel.data.example.nested).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- describe('bulk operations with nested fields', () => {
- it('should handle bulk encryption and decryption of models with nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- example: {
- field: 'test1',
- nested: {
- deeper: 'value1',
- },
- },
- },
- {
- id: '2',
- email: 'test2@example.com',
- example: {
- field: 'test2',
- nested: {
- deeper: 'value2',
- },
- },
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields for each model
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].example.field).toHaveProperty('c')
- expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].example.field).toHaveProperty('c')
- expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[1].id).toBe('2')
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual(decryptedModels)
- }, 30000)
-
- it('should handle bulk operations with null and undefined values in nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = [
- {
- id: '1',
- email: null,
- example: {
- field: null,
- nested: {
- deeper: undefined,
- },
- },
- },
- {
- id: '2',
- email: undefined,
- example: {
- field: undefined,
- nested: {
- deeper: null,
- },
- },
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify null/undefined fields are preserved
- expect(encryptedModels.data[0].email).toBeNull()
- expect(encryptedModels.data[0].example.field).toBeNull()
- expect(encryptedModels.data[0].example.nested?.deeper).toBeUndefined()
- expect(encryptedModels.data[1].email).toBeUndefined()
- expect(encryptedModels.data[1].example.field).toBeUndefined()
- expect(encryptedModels.data[1].example.nested?.deeper).toBeNull()
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[1].id).toBe('2')
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual(decryptedModels)
- }, 30000)
-
- it('should handle bulk operations with missing optional nested fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- example: {
- field: 'test1',
- },
- },
- {
- id: '2',
- email: 'test2@example.com',
- example: {
- field: 'test2',
- nested: {
- deeper: 'value2',
- },
- },
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields for each model
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].example.field).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].example.field).toHaveProperty('c')
- expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].example.nested).toBeUndefined()
- expect(encryptedModels.data[1].id).toBe('2')
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual(decryptedModels)
- }, 30000)
-
- it('should handle empty array in bulk operations', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = []
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- expect(encryptedModels.data).toEqual([])
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual([])
- }, 30000)
- })
-})
-
-describe('nested fields with a plaintext field', () => {
- it('should handle nested fields with a plaintext field', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- address: '123 Main St',
- notEncrypted: 'not encrypted',
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- example: {
- field: 'test',
- plaintext: 'plaintext',
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.example.field).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.example.plaintext).toBe('plaintext')
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- })
-
- it('should handle multiple plaintext fields at different nesting levels', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- address: '123 Main St',
- notEncrypted: 'not encrypted',
- example: {
- field: 'encrypted field',
- plaintext: 'top level plaintext',
- nested: {
- deeper: 'encrypted deeper',
- plaintext: 'nested plaintext',
- extra: {
- plaintext: 'deeply nested plaintext',
- },
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.example.field).toHaveProperty('c')
- expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
- expect(encryptedModel.data.example.plaintext).toBe('top level plaintext')
- expect(encryptedModel.data.example.nested?.plaintext).toBe(
- 'nested plaintext',
- )
- expect(encryptedModel.data.example.nested?.extra?.plaintext).toBe(
- 'deeply nested plaintext',
- )
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- })
-
- it('should handle partial path matches in nested objects', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- example: {
- field: 'encrypted field',
- nested: {
- deeper: 'encrypted deeper',
- // This should not be encrypted as it's not in the schema
- notInSchema: {
- deeper: 'not encrypted',
- },
- },
- // This should not be encrypted as it's not in the schema
- notInSchema: {
- deeper: 'not encrypted',
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.example.field).toHaveProperty('c')
- expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.example.nested?.notInSchema?.deeper).toBe(
- 'not encrypted',
- )
- expect(encryptedModel.data.example.notInSchema?.deeper).toBe(
- 'not encrypted',
- )
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- })
-
- it('should handle mixed encrypted and plaintext fields with similar paths', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- example: {
- field: 'encrypted field',
- fieldNotInSchema: 'not encrypted',
- nested: {
- deeper: 'encrypted deeper',
- deeperNotInSchema: 'not encrypted',
- },
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.example.field).toHaveProperty('c')
- expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.example.fieldNotInSchema).toBe('not encrypted')
- expect(encryptedModel.data.example.nested?.deeperNotInSchema).toBe(
- 'not encrypted',
- )
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- })
-
- describe('bulk operations with plaintext fields', () => {
- it('should handle bulk encryption and decryption with plaintext fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- address: '123 Main St',
- example: {
- field: 'encrypted field 1',
- plaintext: 'plaintext 1',
- nested: {
- deeper: 'encrypted deeper 1',
- plaintext: 'nested plaintext 1',
- },
- },
- },
- {
- id: '2',
- email: 'test2@example.com',
- address: '456 Main St',
- example: {
- field: 'encrypted field 2',
- plaintext: 'plaintext 2',
- nested: {
- deeper: 'encrypted deeper 2',
- plaintext: 'nested plaintext 2',
- },
- },
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].address).toHaveProperty('c')
- expect(encryptedModels.data[0].example.field).toHaveProperty('c')
- expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].address).toHaveProperty('c')
- expect(encryptedModels.data[1].example.field).toHaveProperty('c')
- expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].example.plaintext).toBe('plaintext 1')
- expect(encryptedModels.data[0].example.nested?.plaintext).toBe(
- 'nested plaintext 1',
- )
- expect(encryptedModels.data[1].id).toBe('2')
- expect(encryptedModels.data[1].example.plaintext).toBe('plaintext 2')
- expect(encryptedModels.data[1].example.nested?.plaintext).toBe(
- 'nested plaintext 2',
- )
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual(decryptedModels)
- })
-
- it('should handle bulk operations with mixed encrypted and non-encrypted fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- example: {
- field: 'encrypted field 1',
- fieldNotInSchema: 'not encrypted 1',
- nested: {
- deeper: 'encrypted deeper 1',
- deeperNotInSchema: 'not encrypted deeper 1',
- },
- },
- },
- {
- id: '2',
- email: 'test2@example.com',
- example: {
- field: 'encrypted field 2',
- fieldNotInSchema: 'not encrypted 2',
- nested: {
- deeper: 'encrypted deeper 2',
- deeperNotInSchema: 'not encrypted deeper 2',
- },
- },
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].example.field).toHaveProperty('c')
- expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].example.field).toHaveProperty('c')
- expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].example.fieldNotInSchema).toBe(
- 'not encrypted 1',
- )
- expect(encryptedModels.data[0].example.nested?.deeperNotInSchema).toBe(
- 'not encrypted deeper 1',
- )
- expect(encryptedModels.data[1].id).toBe('2')
- expect(encryptedModels.data[1].example.fieldNotInSchema).toBe(
- 'not encrypted 2',
- )
- expect(encryptedModels.data[1].example.nested?.deeperNotInSchema).toBe(
- 'not encrypted deeper 2',
- )
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual(decryptedModels)
- })
-
- it('should handle bulk operations with deeply nested plaintext fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModels: User[] = [
- {
- id: '1',
- email: 'test1@example.com',
- example: {
- field: 'encrypted field 1',
- nested: {
- deeper: 'encrypted deeper 1',
- extra: {
- plaintext: 'deeply nested plaintext 1',
- },
- },
- },
- },
- {
- id: '2',
- email: 'test2@example.com',
- example: {
- field: 'encrypted field 2',
- nested: {
- deeper: 'encrypted deeper 2',
- extra: {
- plaintext: 'deeply nested plaintext 2',
- },
- },
- },
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].example.field).toHaveProperty('c')
- expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].example.field).toHaveProperty('c')
- expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].example.nested?.extra?.plaintext).toBe(
- 'deeply nested plaintext 1',
- )
- expect(encryptedModels.data[1].id).toBe('2')
- expect(encryptedModels.data[1].example.nested?.extra?.plaintext).toBe(
- 'deeply nested plaintext 2',
- )
-
- const decryptedResults = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResults.failure) {
- throw new Error(`[protect]: ${decryptedResults.failure.message}`)
- }
-
- expect(decryptedResults.data).toEqual(decryptedModels)
- })
- })
-})
diff --git a/packages/protect/__tests__/number-protect.test.ts b/packages/protect/__tests__/number-protect.test.ts
deleted file mode 100644
index 754e42e6d..000000000
--- a/packages/protect/__tests__/number-protect.test.ts
+++ /dev/null
@@ -1,823 +0,0 @@
-import 'dotenv/config'
-import { csColumn, csTable, csValue } from '@cipherstash/schema'
-import { beforeAll, describe, expect, it, test } from 'vitest'
-import { LockContext, protect } from '../src'
-
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- address: csColumn('address').freeTextSearch(),
- age: csColumn('age').dataType('number').equality().orderAndRange(),
- score: csColumn('score').dataType('number').equality().orderAndRange(),
- metadata: {
- count: csValue('metadata.count').dataType('number'),
- level: csValue('metadata.level').dataType('number'),
- },
-})
-
-type User = {
- id: string
- email?: string
- createdAt?: Date
- updatedAt?: Date
- address?: string
- age?: number
- score?: number
- metadata?: {
- count?: number
- level?: number
- }
-}
-
-let protectClient: Awaited>
-
-beforeAll(async () => {
- protectClient = await protect({
- schemas: [users],
- })
-})
-
-const cases = [
- 25,
- 0,
- -42,
- 2147483647,
- 77.9,
- 0.0,
- -117.123456,
- 1e15,
- -1e15, // Very large floats
- 9007199254740991, // Max safe integer in JavaScript
-]
-
-describe('Number encryption and decryption', () => {
- test.each(cases)('should encrypt and decrypt a number: %d', async (age) => {
- const ciphertext = await protectClient.encrypt(age, {
- column: users.age,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: age,
- })
- }, 30000)
-
- it('should handle null integer', async () => {
- const ciphertext = await protectClient.encrypt(null, {
- column: users.age,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify null is preserved
- expect(ciphertext.data).toBeNull()
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: null,
- })
- }, 30000)
-
- // Special case
- it('should treat a negative zero valued float as 0.0', async () => {
- const score = -0.0
-
- const ciphertext = await protectClient.encrypt(score, {
- column: users.score,
- table: users,
- })
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const plaintext = await protectClient.decrypt(ciphertext.data)
-
- expect(plaintext).toEqual({
- data: 0.0,
- })
- }, 30000)
-
- // Special case
- it('should error for a NaN float', async () => {
- const score = Number.NaN
-
- const result = await protectClient.encrypt(score, {
- column: users.score,
- table: users,
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.message).toContain('Cannot encrypt NaN value')
- }, 30000)
-
- // Special case
- it('should error for Infinity', async () => {
- const score = Number.POSITIVE_INFINITY
-
- const result = await protectClient.encrypt(score, {
- column: users.score,
- table: users,
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.message).toContain('Cannot encrypt Infinity value')
- }, 30000)
-
- // Special case
- it('should error for -Infinity', async () => {
- const score = Number.NEGATIVE_INFINITY
-
- const result = await protectClient.encrypt(score, {
- column: users.score,
- table: users,
- })
-
- expect(result.failure).toBeDefined()
- expect(result.failure?.message).toContain('Cannot encrypt Infinity value')
- }, 30000)
-})
-
-describe('Model encryption and decryption', () => {
- it('should encrypt and decrypt a model with number fields', async () => {
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- address: '123 Main St',
- age: 30,
- score: 95,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.age).toHaveProperty('c')
- expect(encryptedModel.data.score).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
- expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle null numbers in model', async () => {
- const decryptedModel: User = {
- id: '2',
- email: 'test2@example.com',
- address: '456 Oak St',
- age: undefined,
- score: undefined,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.age).toBeUndefined()
- expect(encryptedModel.data.score).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle undefined numbers in model', async () => {
- const decryptedModel = {
- id: '3',
- email: 'test3@example.com',
- address: '789 Pine St',
- age: undefined,
- score: undefined,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.address).toHaveProperty('c')
- expect(encryptedModel.data.age).toBeUndefined()
- expect(encryptedModel.data.score).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-})
-
-describe('Bulk encryption and decryption', () => {
- it('should bulk encrypt and decrypt number payloads', async () => {
- const intPayloads = [
- { id: 'user1', plaintext: 25 },
- { id: 'user2', plaintext: 30.7 },
- { id: 'user3', plaintext: -35.123 },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(intPayloads, {
- column: users.age,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toHaveProperty('c')
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('c')
-
- // EQL v2.3: scalar encryptions carry the `k: 'ct'` discriminator
- expect(encryptedData.data[0].data).toHaveProperty('k', 'ct')
- expect(encryptedData.data[1].data).toHaveProperty('k', 'ct')
- expect(encryptedData.data[2].data).toHaveProperty('k', 'ct')
-
- // Verify all encrypted values are different
- const getCiphertext = (data: { c?: unknown } | null | undefined) => data?.c
-
- expect(getCiphertext(encryptedData.data[0].data)).not.toBe(
- getCiphertext(encryptedData.data[1].data),
- )
- expect(getCiphertext(encryptedData.data[1].data)).not.toBe(
- getCiphertext(encryptedData.data[2].data),
- )
- expect(getCiphertext(encryptedData.data[0].data)).not.toBe(
- getCiphertext(encryptedData.data[2].data),
- )
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', 25)
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', 30.7)
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty('data', -35.123)
- }, 30000)
-
- it('should handle mixed null and non-null numbers in bulk operations', async () => {
- const intPayloads = [
- { id: 'user1', plaintext: 25 },
- { id: 'user2', plaintext: null },
- { id: 'user3', plaintext: 35 },
- ]
-
- const encryptedData = await protectClient.bulkEncrypt(intPayloads, {
- column: users.age,
- table: users,
- })
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(3)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toBeNull()
- expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(encryptedData.data[2]).toHaveProperty('data')
- expect(encryptedData.data[2].data).toHaveProperty('c')
-
- // Now decrypt the data
- const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(3)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', 25)
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', null)
- expect(decryptedData.data[2]).toHaveProperty('id', 'user3')
- expect(decryptedData.data[2]).toHaveProperty('data', 35)
- }, 30000)
-
- it('should bulk encrypt and decrypt models with number fields', async () => {
- const decryptedModels = [
- {
- id: '1',
- email: 'test1@example.com',
- address: '123 Main St',
- age: 25,
- score: 85,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- },
- {
- id: '2',
- email: 'test2@example.com',
- address: '456 Oak St',
- age: 30,
- score: 90,
- createdAt: new Date('2021-01-01'),
- updatedAt: new Date('2021-01-01'),
- },
- ]
-
- const encryptedModels = await protectClient.bulkEncryptModels(
- decryptedModels,
- users,
- )
-
- if (encryptedModels.failure) {
- throw new Error(`[protect]: ${encryptedModels.failure.message}`)
- }
-
- // Verify encrypted fields for each model
- expect(encryptedModels.data[0].email).toHaveProperty('c')
- expect(encryptedModels.data[0].address).toHaveProperty('c')
- expect(encryptedModels.data[0].age).toHaveProperty('c')
- expect(encryptedModels.data[0].score).toHaveProperty('c')
- expect(encryptedModels.data[1].email).toHaveProperty('c')
- expect(encryptedModels.data[1].address).toHaveProperty('c')
- expect(encryptedModels.data[1].age).toHaveProperty('c')
- expect(encryptedModels.data[1].score).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModels.data[0].id).toBe('1')
- expect(encryptedModels.data[0].createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[0].updatedAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[1].id).toBe('2')
- expect(encryptedModels.data[1].createdAt).toEqual(new Date('2021-01-01'))
- expect(encryptedModels.data[1].updatedAt).toEqual(new Date('2021-01-01'))
-
- const decryptedResult = await protectClient.bulkDecryptModels(
- encryptedModels.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModels)
- }, 30000)
-})
-
-describe('Encryption with lock context', () => {
- it('should encrypt and decrypt number with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const age = 42
-
- const ciphertext = await protectClient
- .encrypt(age, {
- column: users.age,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (ciphertext.failure) {
- throw new Error(`[protect]: ${ciphertext.failure.message}`)
- }
-
- // Verify encrypted field
- expect(ciphertext.data).toHaveProperty('c')
-
- const plaintext = await protectClient
- .decrypt(ciphertext.data)
- .withLockContext(lockContext.data)
-
- if (plaintext.failure) {
- throw new Error(`[protect]: ${plaintext.failure.message}`)
- }
-
- expect(plaintext.data).toEqual(age)
- }, 30000)
-
- it('should encrypt model with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- age: 30,
- score: 95,
- }
-
- const encryptedModel = await protectClient
- .encryptModel(decryptedModel, users)
- .withLockContext(lockContext.data)
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.age).toHaveProperty('c')
- expect(encryptedModel.data.score).toHaveProperty('c')
-
- const decryptedResult = await protectClient
- .decryptModel(encryptedModel.data)
- .withLockContext(lockContext.data)
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should bulk encrypt numbers with lock context', async () => {
- const userJwt = process.env.USER_JWT
-
- if (!userJwt) {
- console.log('Skipping lock context test - no USER_JWT provided')
- return
- }
-
- const lc = new LockContext()
- const lockContext = await lc.identify(userJwt)
-
- if (lockContext.failure) {
- throw new Error(`[protect]: ${lockContext.failure.message}`)
- }
-
- const intPayloads = [
- { id: 'user1', plaintext: 25 },
- { id: 'user2', plaintext: 30 },
- ]
-
- const encryptedData = await protectClient
- .bulkEncrypt(intPayloads, {
- column: users.age,
- table: users,
- })
- .withLockContext(lockContext.data)
-
- if (encryptedData.failure) {
- throw new Error(`[protect]: ${encryptedData.failure.message}`)
- }
-
- // Verify structure
- expect(encryptedData.data).toHaveLength(2)
- expect(encryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(encryptedData.data[0]).toHaveProperty('data')
- expect(encryptedData.data[0].data).toHaveProperty('c')
- expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(encryptedData.data[1]).toHaveProperty('data')
- expect(encryptedData.data[1].data).toHaveProperty('c')
-
- // Decrypt with lock context
- const decryptedData = await protectClient
- .bulkDecrypt(encryptedData.data)
- .withLockContext(lockContext.data)
-
- if (decryptedData.failure) {
- throw new Error(`[protect]: ${decryptedData.failure.message}`)
- }
-
- // Verify decrypted data
- expect(decryptedData.data).toHaveLength(2)
- expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
- expect(decryptedData.data[0]).toHaveProperty('data', 25)
- expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
- expect(decryptedData.data[1]).toHaveProperty('data', 30)
- }, 30000)
-})
-
-describe('Nested object encryption', () => {
- it('should encrypt and decrypt nested number objects', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '1',
- email: 'test@example.com',
- metadata: {
- count: 100,
- level: 5,
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify encrypted fields
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.metadata?.count).toHaveProperty('c')
- expect(encryptedModel.data.metadata?.level).toHaveProperty('c')
-
- // Verify non-encrypted fields remain unchanged
- expect(encryptedModel.data.id).toBe('1')
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle null values in nested objects with number fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel: User = {
- id: '2',
- email: 'test2@example.com',
- metadata: {
- count: undefined,
- level: undefined,
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify null fields are preserved
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.metadata?.count).toBeUndefined()
- expect(encryptedModel.data.metadata?.level).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- encryptedModel.data,
- )
-
- if (decryptedResult.failure) {
- throw new Error(`[protect]: ${decryptedResult.failure.message}`)
- }
-
- expect(decryptedResult.data).toEqual(decryptedModel)
- }, 30000)
-
- it('should handle undefined values in nested objects with number fields', async () => {
- const protectClient = await protect({ schemas: [users] })
-
- const decryptedModel = {
- id: '3',
- email: 'test3@example.com',
- metadata: {
- count: undefined,
- level: undefined,
- },
- }
-
- const encryptedModel = await protectClient.encryptModel(
- decryptedModel,
- users,
- )
-
- if (encryptedModel.failure) {
- throw new Error(`[protect]: ${encryptedModel.failure.message}`)
- }
-
- // Verify undefined fields are preserved
- expect(encryptedModel.data.email).toHaveProperty('c')
- expect(encryptedModel.data.metadata?.count).toBeUndefined()
- expect(encryptedModel.data.metadata?.level).toBeUndefined()
-
- const decryptedResult = await protectClient.decryptModel(
- 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