From b0ff3fbb1af0cf4d2d8e4e47ea88fffa38ce5e95 Mon Sep 17 00:00:00 2001
From: CJ Brewer
Date: Wed, 11 Feb 2026 14:16:05 -0700
Subject: [PATCH 1/9] refactor: rename Protect branding to Stack/Encryption
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rename public API from Protect naming to Stack/Encryption naming:
- protect() → Encryption()
- csTable/csColumn → encryptedTable/encryptedColumn
- ProtectClient → EncryptionClient
- ProtectError → EncryptionError
- @cipherstash/protect → @cipherstash/stack
All old names kept as deprecated aliases for backward compat.
Updates examples, docs, drizzle, dynamodb, and schema packages.
Adds packages/stack as the new core package.
Adds CLAUDE.md and MIGRATION.md.
Co-Authored-By: Claude Opus 4.6
---
.cursor/commands/create-example-app.md | 18 +-
.cursorrules | 6 +-
.github/workflows/rebuild-docs.yml | 2 +-
AGENTS.md | 29 +-
CLAUDE.md | 69 +
CONTRIBUTE.md | 22 +-
MIGRATION.md | 188 +++
README.md | 40 +-
SECURITY.md | 6 +-
docs/README.md | 6 +-
.../aws-kms-vs-cipherstash-comparison.md | 58 +-
docs/concepts/searchable-encryption.md | 10 +-
docs/getting-started.md | 64 +-
docs/how-to/lock-contexts-with-clerk.md | 4 +-
docs/how-to/nextjs-external-packages.md | 6 +-
docs/how-to/npm-lockfile-v3.md | 4 +-
docs/how-to/sst-external-packages.md | 6 +-
docs/prompts/init-protect.md | 28 +-
docs/reference/configuration.md | 22 +-
docs/reference/drizzle/DRIFT-TESTING.md | 2 +-
docs/reference/drizzle/drizzle-protect.md | 12 +-
docs/reference/drizzle/drizzle.md | 10 +-
docs/reference/model-operations.md | 14 +-
docs/reference/schema.md | 60 +-
.../searchable-encryption-postgres.md | 34 +-
docs/reference/supabase-sdk.md | 62 +-
examples/basic/README.md | 12 +-
examples/basic/package.json | 2 +-
examples/basic/protect.ts | 18 +-
examples/drizzle/README.md | 14 +-
examples/drizzle/package.json | 2 +-
examples/drizzle/src/protect/config.ts | 8 +-
examples/dynamo/README.md | 4 +-
examples/dynamo/package.json | 2 +-
examples/dynamo/src/common/protect.ts | 8 +-
examples/dynamo/src/encrypted-key-in-gsi.ts | 7 +-
.../dynamo/src/encrypted-partition-key.ts | 7 +-
examples/dynamo/src/encrypted-sort-key.ts | 7 +-
examples/hono-supabase/README.md | 6 +-
examples/hono-supabase/package.json | 2 +-
examples/hono-supabase/src/index.ts | 20 +-
examples/nest/README.md | 4 +-
examples/nest/package.json | 2 +-
examples/nest/src/app.controller.spec.ts | 2 +-
examples/nest/src/app.service.ts | 2 +-
.../protect/decorators/decrypt.decorator.ts | 12 +-
.../protect/decorators/encrypt.decorator.ts | 12 +-
.../interceptors/decrypt.interceptor.ts | 12 +-
.../interceptors/encrypt.interceptor.ts | 12 +-
.../interfaces/protect-config.interface.ts | 4 +-
examples/nest/src/protect/protect.module.ts | 32 +-
.../nest/src/protect/protect.service.spec.ts | 6 +-
examples/nest/src/protect/protect.service.ts | 16 +-
examples/nest/src/protect/schema.ts | 16 +-
examples/nest/test/app.e2e-spec.ts | 2 +-
examples/next-drizzle-mysql/README.md | 10 +-
examples/next-drizzle-mysql/next.config.ts | 2 +-
examples/next-drizzle-mysql/package.json | 2 +-
.../next-drizzle-mysql/src/protect/index.ts | 6 +-
.../next-drizzle-mysql/src/protect/schema.ts | 8 +-
examples/nextjs-clerk/.env.example | 2 +-
examples/nextjs-clerk/README.md | 22 +-
examples/nextjs-clerk/next.config.ts | 2 +-
examples/nextjs-clerk/package.json | 2 +-
examples/nextjs-clerk/src/app/layout.tsx | 4 +-
examples/nextjs-clerk/src/app/page.tsx | 2 +-
.../nextjs-clerk/src/components/Header.tsx | 2 +-
.../nextjs-clerk/src/core/protect/index.ts | 18 +-
examples/typeorm/README.md | 24 +-
examples/typeorm/package.json | 4 +-
examples/typeorm/src/data-source.ts | 2 +-
examples/typeorm/src/entity/User.ts | 2 +-
.../typeorm/src/helpers/protect-entity.ts | 12 +-
examples/typeorm/src/index.ts | 6 +-
examples/typeorm/src/protect.ts | 16 +-
.../typeorm/src/utils/encrypted-column.ts | 2 +-
package.json | 19 +-
packages/drizzle/README.md | 25 +-
packages/drizzle/__tests__/docs.test.ts | 6 +-
packages/drizzle/__tests__/drizzle.test.ts | 12 +-
packages/drizzle/package.json | 6 +-
packages/drizzle/src/pg/operators.ts | 145 +-
packages/drizzle/src/pg/schema-extraction.ts | 40 +-
.../{protect-dynamodb => dynamodb}/.npmignore | 0
.../CHANGELOG.md | 0
.../{protect-dynamodb => dynamodb}/README.md | 21 +-
.../__tests__/audit.test.ts | 20 +-
.../__tests__/dynamodb.test.ts | 26 +-
.../package.json | 6 +-
.../src/helpers.ts | 8 +-
.../src/index.ts | 14 +-
.../src/operations/base-operation.ts | 0
.../src/operations/bulk-decrypt-models.ts | 12 +-
.../src/operations/bulk-encrypt-models.ts | 12 +-
.../src/operations/decrypt-model.ts | 12 +-
.../src/operations/encrypt-model.ts | 12 +-
.../src/operations/search-terms.ts | 6 +-
.../src/types.ts | 18 +-
.../tsconfig.json | 0
.../tsup.config.ts | 0
packages/jseql/README.md | 4 -
packages/nextjs/README.md | 4 +-
packages/nextjs/package.json | 2 +-
packages/protect/README.md | 1091 +--------------
.../protect/__tests__/encrypt-query.test.ts | 299 +++-
packages/protect/__tests__/fixtures/index.ts | 14 +-
.../__tests__/infer-index-type.test.ts | 15 +-
.../protect/__tests__/jsonb-helpers.test.ts | 48 +-
packages/protect/__tests__/supabase.test.ts | 8 +-
.../src/ffi/helpers/infer-index-type.ts | 13 +-
.../protect/src/ffi/helpers/type-guards.ts | 2 +-
.../protect/src/ffi/helpers/validation.ts | 10 +-
packages/protect/src/ffi/index.ts | 21 +-
.../src/ffi/operations/batch-encrypt-query.ts | 65 +-
.../ffi/operations/deprecated/search-terms.ts | 26 +-
.../src/ffi/operations/encrypt-query.ts | 35 +-
packages/protect/src/helpers/index.ts | 9 +-
packages/protect/src/helpers/jsonb.ts | 2 +-
packages/protect/src/index.ts | 15 +-
packages/protect/src/types.ts | 2 +-
packages/schema/README.md | 108 +-
.../schema/__tests__/searchable-json.test.ts | 14 +-
packages/schema/package.json | 11 +-
packages/schema/src/index.ts | 84 +-
packages/stack/.npmignore | 5 +
packages/stack/README.md | 1094 +++++++++++++++
packages/stack/__tests__/audit.test.ts | 472 +++++++
.../stack/__tests__/backward-compat.test.ts | 65 +
.../stack/__tests__/basic-protect.test.ts | 44 +
packages/stack/__tests__/bulk-protect.test.ts | 597 ++++++++
.../__tests__/deprecated/search-terms.test.ts | 140 ++
.../stack/__tests__/encrypt-query.test.ts | 872 ++++++++++++
packages/stack/__tests__/fixtures/index.ts | 127 ++
packages/stack/__tests__/helpers.test.ts | 148 ++
.../stack/__tests__/infer-index-type.test.ts | 57 +
packages/stack/__tests__/json-protect.test.ts | 1223 +++++++++++++++++
.../stack/__tests__/jsonb-helpers.test.ts | 203 +++
packages/stack/__tests__/keysets.test.ts | 89 ++
packages/stack/__tests__/lock-context.test.ts | 208 +++
.../stack/__tests__/nested-models.test.ts | 962 +++++++++++++
.../stack/__tests__/number-protect.test.ts | 835 +++++++++++
packages/stack/__tests__/protect-ops.test.ts | 843 ++++++++++++
packages/stack/__tests__/supabase.test.ts | 307 +++++
packages/stack/package.json | 80 ++
packages/stack/src/bin/stash.ts | 499 +++++++
packages/stack/src/client.ts | 31 +
.../stack/src/ffi/helpers/infer-index-type.ts | 70 +
packages/stack/src/ffi/helpers/type-guards.ts | 18 +
packages/stack/src/ffi/helpers/validation.ts | 94 ++
packages/stack/src/ffi/index.ts | 438 ++++++
packages/stack/src/ffi/model-helpers.ts | 952 +++++++++++++
.../src/ffi/operations/base-operation.ts | 55 +
.../src/ffi/operations/batch-encrypt-query.ts | 233 ++++
.../src/ffi/operations/bulk-decrypt-models.ts | 109 ++
.../stack/src/ffi/operations/bulk-decrypt.ts | 175 +++
.../src/ffi/operations/bulk-encrypt-models.ts | 128 ++
.../stack/src/ffi/operations/bulk-encrypt.ts | 210 +++
.../stack/src/ffi/operations/decrypt-model.ts | 106 ++
packages/stack/src/ffi/operations/decrypt.ts | 127 ++
.../ffi/operations/deprecated/search-terms.ts | 132 ++
.../stack/src/ffi/operations/encrypt-model.ts | 125 ++
.../stack/src/ffi/operations/encrypt-query.ts | 162 +++
packages/stack/src/ffi/operations/encrypt.ts | 155 +++
.../stack/src/ffi/operations/search-terms.ts | 63 +
packages/stack/src/helpers/index.ts | 139 ++
packages/stack/src/helpers/jsonb.ts | 99 ++
packages/stack/src/identify/index.ts | 130 ++
packages/stack/src/index.ts | 177 +++
packages/stack/src/stash/index.ts | 474 +++++++
packages/stack/src/types.ts | 193 +++
packages/stack/tsconfig.json | 28 +
packages/stack/tsup.config.ts | 30 +
pnpm-lock.yaml | 117 +-
173 files changed, 15129 insertions(+), 2018 deletions(-)
create mode 100644 CLAUDE.md
create mode 100644 MIGRATION.md
rename packages/{protect-dynamodb => dynamodb}/.npmignore (100%)
rename packages/{protect-dynamodb => dynamodb}/CHANGELOG.md (100%)
rename packages/{protect-dynamodb => dynamodb}/README.md (90%)
rename packages/{protect-dynamodb => dynamodb}/__tests__/audit.test.ts (94%)
rename packages/{protect-dynamodb => dynamodb}/__tests__/dynamodb.test.ts (92%)
rename packages/{protect-dynamodb => dynamodb}/package.json (87%)
rename packages/{protect-dynamodb => dynamodb}/src/helpers.ts (97%)
rename packages/{protect-dynamodb => dynamodb}/src/index.ts (85%)
rename packages/{protect-dynamodb => dynamodb}/src/operations/base-operation.ts (100%)
rename packages/{protect-dynamodb => dynamodb}/src/operations/bulk-decrypt-models.ts (86%)
rename packages/{protect-dynamodb => dynamodb}/src/operations/bulk-encrypt-models.ts (86%)
rename packages/{protect-dynamodb => dynamodb}/src/operations/decrypt-model.ts (85%)
rename packages/{protect-dynamodb => dynamodb}/src/operations/encrypt-model.ts (85%)
rename packages/{protect-dynamodb => dynamodb}/src/operations/search-terms.ts (92%)
rename packages/{protect-dynamodb => dynamodb}/src/types.ts (83%)
rename packages/{protect-dynamodb => dynamodb}/tsconfig.json (100%)
rename packages/{protect-dynamodb => dynamodb}/tsup.config.ts (100%)
delete mode 100644 packages/jseql/README.md
create mode 100644 packages/stack/.npmignore
create mode 100644 packages/stack/README.md
create mode 100644 packages/stack/__tests__/audit.test.ts
create mode 100644 packages/stack/__tests__/backward-compat.test.ts
create mode 100644 packages/stack/__tests__/basic-protect.test.ts
create mode 100644 packages/stack/__tests__/bulk-protect.test.ts
create mode 100644 packages/stack/__tests__/deprecated/search-terms.test.ts
create mode 100644 packages/stack/__tests__/encrypt-query.test.ts
create mode 100644 packages/stack/__tests__/fixtures/index.ts
create mode 100644 packages/stack/__tests__/helpers.test.ts
create mode 100644 packages/stack/__tests__/infer-index-type.test.ts
create mode 100644 packages/stack/__tests__/json-protect.test.ts
create mode 100644 packages/stack/__tests__/jsonb-helpers.test.ts
create mode 100644 packages/stack/__tests__/keysets.test.ts
create mode 100644 packages/stack/__tests__/lock-context.test.ts
create mode 100644 packages/stack/__tests__/nested-models.test.ts
create mode 100644 packages/stack/__tests__/number-protect.test.ts
create mode 100644 packages/stack/__tests__/protect-ops.test.ts
create mode 100644 packages/stack/__tests__/supabase.test.ts
create mode 100644 packages/stack/package.json
create mode 100644 packages/stack/src/bin/stash.ts
create mode 100644 packages/stack/src/client.ts
create mode 100644 packages/stack/src/ffi/helpers/infer-index-type.ts
create mode 100644 packages/stack/src/ffi/helpers/type-guards.ts
create mode 100644 packages/stack/src/ffi/helpers/validation.ts
create mode 100644 packages/stack/src/ffi/index.ts
create mode 100644 packages/stack/src/ffi/model-helpers.ts
create mode 100644 packages/stack/src/ffi/operations/base-operation.ts
create mode 100644 packages/stack/src/ffi/operations/batch-encrypt-query.ts
create mode 100644 packages/stack/src/ffi/operations/bulk-decrypt-models.ts
create mode 100644 packages/stack/src/ffi/operations/bulk-decrypt.ts
create mode 100644 packages/stack/src/ffi/operations/bulk-encrypt-models.ts
create mode 100644 packages/stack/src/ffi/operations/bulk-encrypt.ts
create mode 100644 packages/stack/src/ffi/operations/decrypt-model.ts
create mode 100644 packages/stack/src/ffi/operations/decrypt.ts
create mode 100644 packages/stack/src/ffi/operations/deprecated/search-terms.ts
create mode 100644 packages/stack/src/ffi/operations/encrypt-model.ts
create mode 100644 packages/stack/src/ffi/operations/encrypt-query.ts
create mode 100644 packages/stack/src/ffi/operations/encrypt.ts
create mode 100644 packages/stack/src/ffi/operations/search-terms.ts
create mode 100644 packages/stack/src/helpers/index.ts
create mode 100644 packages/stack/src/helpers/jsonb.ts
create mode 100644 packages/stack/src/identify/index.ts
create mode 100644 packages/stack/src/index.ts
create mode 100644 packages/stack/src/stash/index.ts
create mode 100644 packages/stack/src/types.ts
create mode 100644 packages/stack/tsconfig.json
create mode 100644 packages/stack/tsup.config.ts
diff --git a/.cursor/commands/create-example-app.md b/.cursor/commands/create-example-app.md
index 1125be0b..480824b2 100644
--- a/.cursor/commands/create-example-app.md
+++ b/.cursor/commands/create-example-app.md
@@ -1,10 +1,10 @@
-# Cursor Super-Prompt: Protect.js Example Apps (Framework/ORM-Agnostic, No-Cheese)
+# Cursor Super-Prompt: Stash Encryption Example Apps (Framework/ORM-Agnostic, No-Cheese)
ROLE
-You are a senior systems engineer focused on developer experience and a core maintainer of `@cipherstash/protect`. Your mission: create a polished set of runnable **Protect.js example apps** across multiple stacks. Each example must be minimal, factual, and runnable in minutes.
+You are a senior systems engineer focused on developer experience and a core maintainer of `@cipherstash/stack`. Your mission: create a polished set of runnable **Stash Encryption example apps** across multiple stacks. Each example must be minimal, factual, and runnable in minutes.
GROUNDING & SOURCES (use @ref; do not guess)
-- Protect.js APIs: the Protect.js main README is the single source of truth. If not accessible, STOP and ask for the exact snippet/repo path. Do not invent APIs.
+- Stash Encryption APIs: the Stash Encryption main README is the single source of truth. If not accessible, STOP and ask for the exact snippet/repo path. Do not invent APIs.
- ORM/DB docs (pick per stack):
@ref https://www.prisma.io/docs
@ref https://typeorm.io
@@ -26,7 +26,7 @@ STACK MATRIX (generate now)
Optional (time-permitting): nextjs-prisma (App Router), fastify-knex.
NO-CHEESE RULES (hard requirements)
-Goal: smallest possible working example that clearly demonstrates Protect.js. Clarity > patterns > abstractions.
+Goal: smallest possible working example that clearly demonstrates Stash Encryption. Clarity > patterns > abstractions.
DON'TS
- No Singletons/Factories/Service-Locators/DI frameworks.
- No ports & adapters/custom repo abstractions for tiny demos—call the ORM/client directly.
@@ -41,7 +41,7 @@ README tone
---
Simplicity budget (per example)
- ≤ 8 TS source files (excluding migrations).
-- Deps: ORM/client + `@cipherstash/protect` + dev tooling (ts-node or tsx). Nothing else unless required by the stack.
+- Deps: ORM/client + `@cipherstash/stack` + dev tooling (ts-node or tsx). Nothing else unless required by the stack.
- One `.env.example`; use `dotenv`. No layered config.
CODE STYLE
@@ -61,7 +61,7 @@ REQUIRED DX & SCRIPTS (per example)
- `seed` → seed sensible data
- `demo` → prints proof of encryption & queries
- `typecheck` → `tsc --noEmit`
-- `.env.example` includes DB vars and **exact** Protect.js env names from the Protect README (do not invent):
+- `.env.example` includes DB vars and **exact** Stash Encryption env names from the Stack README (do not invent):
{{PROTECT_ENV_VARS := "e.g., PROTECT_PROJECT_ID, PROTECT_CLIENT_KEY, PROTECT_SERVER_URL (replace with real names from README)"}}
PROJECT LAYOUT (monorepo, minimal)
@@ -80,10 +80,10 @@ README REQUIREMENTS (every example)
- AI banner (exact text) at the very top with {{BOOK_CHAT_URL}}.
- 90-second Quickstart (copy/paste only).
- "What this shows" checklist (encrypted fields, CRUD, query).
-- "How encryption works here" (short, accurate, tied to Protect.js).
+- "How encryption works here" (short, accurate, tied to Stash Encryption).
- Config notes for the stack (e.g., why CJS for TypeORM).
- Troubleshooting (ESM/CJS, ts-node/tsx, migration pitfalls).
-- `@ref` links to stack docs + Protect README.
+- `@ref` links to stack docs + Stack README.
DELIVERABLES (return in one message/PR)
- Root `README.md` + `docker-compose.yml`.
@@ -118,4 +118,4 @@ OUTPUT FORMAT
VARIABLES TO FILL BEFORE RUN
- {{BOOK_CHAT_URL}} = your booking link
- {{STACKS}} = list of stacks to generate
-- {{PROTECT_ENV_VARS}} = exact names from Protect.js README
+- {{PROTECT_ENV_VARS}} = exact names from Stash Encryption README
diff --git a/.cursorrules b/.cursorrules
index 205bcb70..3aa6e40f 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -1,11 +1,11 @@
-## Protect.js Cursor Rules
+## Stash Encryption Cursor Rules
These rules guide agents when creating or updating example apps under `examples/*` in this repository.
### Example App Prompt (for agents)
- **Goals**
- - Show end-to-end usage of Protect.js with clear, minimal code.
+ - Show end-to-end usage of Stash Encryption with clear, minimal code.
- Demonstrate schema, encrypt/decrypt, and (when relevant) searchable encryption on PostgreSQL.
- **Hard guardrails (do not violate)**
@@ -33,7 +33,7 @@ These rules guide agents when creating or updating example apps under `examples/
- `docs/concepts/searchable-encryption.md`
- **Deliverables checklist for a new example**
- - A `protect.ts` (or equivalent) that initializes `protect({ schemas })` using `csTable`/`csColumn`.
+ - A `protect.ts` (or equivalent) that initializes `Encryption({ schemas })` using `encryptedTable`/`encryptedColumn`.
- If targeting Postgres searchable encryption, include `.freeTextSearch().equality().orderAndRange()` on appropriate columns.
- A minimal script or route/handler that encrypts and decrypts at least one value.
- A README covering:
diff --git a/.github/workflows/rebuild-docs.yml b/.github/workflows/rebuild-docs.yml
index 27695b63..04086878 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@*'
- '@cipherstash/drizzle@*'
jobs:
diff --git a/AGENTS.md b/AGENTS.md
index a4d2b0c6..a25033ca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,4 +1,4 @@
-This is the Protect.js repository - End-to-end, per-value encryption for JavaScript/TypeScript with zero‑knowledge key management (via CipherStash ZeroKMS). Encrypted data is stored as EQL JSON payloads; searchable encryption is currently supported for PostgreSQL.
+This is the Stash Encryption repository (protectjs) - End-to-end, per-value encryption for JavaScript/TypeScript with zero‑knowledge key management (via CipherStash ZeroKMS). Encrypted data is stored as EQL JSON payloads; searchable encryption is currently supported for PostgreSQL.
## Prerequisites
@@ -43,7 +43,7 @@ pnpm test
- Filter to a single package (recommended for fast iteration):
```bash
-pnpm --filter @cipherstash/protect test
+pnpm --filter @cipherstash/stack test
pnpm --filter @cipherstash/nextjs test
```
@@ -71,22 +71,23 @@ If these variables are missing, tests that require live encryption will fail or
## Repository Layout
-- `packages/protect`: Core library
- - `src/index.ts`: Public API (`protect`, exports)
- - `src/ffi/index.ts`: `ProtectClient` implementation, bridges to `@cipherstash/protect-ffi`
+- `packages/stack`: Core library (published as `@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/search-terms operations (thenable pattern with optional `.withLockContext()`)
- `__tests__/*`: End-to-end and API contract tests (Vitest)
-- `packages/schema`: Schema builder utilities and types (`csTable`, `csColumn`, `buildEncryptConfig`)
+- `packages/protect`: Deprecated — re-exports from `stack` for backward compatibility
+- `packages/schema`: Schema builder utilities and types (`encryptedTable`, `encryptedColumn`, `buildEncryptConfig`)
- `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export)
-- `packages/protect-dynamodb`: DynamoDB helpers for Protect.js
+- `packages/dynamodb`: DynamoDB helpers (published as `@cipherstash/protect-dynamodb`)
- `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`)
- `examples/*`: Working apps (basic, drizzle, nextjs-clerk, next-drizzle-mysql, dynamo, hono-supabase)
- `docs/*`: Concepts, how-to guides (Next.js bundling, SST, npm lockfile v3), reference
## Key Concepts and APIs
-- **Initialization**: `protect({ schemas })` returns an initialized `ProtectClient`. Provide at least one `csTable`.
-- **Schema**: Define tables/columns with `csTable` and `csColumn`. Add `.freeTextSearch().equality().orderAndRange()` to enable searchable encryption on PostgreSQL.
+- **Initialization**: `Encryption({ schemas })` returns an initialized `EncryptionClient`. Provide at least one `encryptedTable`.
+- **Schema**: Define tables/columns with `encryptedTable` and `encryptedColumn`. Add `.freeTextSearch().equality().orderAndRange()` to enable searchable encryption on PostgreSQL.
- **Operations** (all return Result-like objects and support chaining `.withLockContext(lockContext)` when applicable):
- `encrypt(plaintext, { table, column })`
- `decrypt(encryptedPayload)`
@@ -94,17 +95,17 @@ If these variables are missing, tests that require live encryption will fail or
- `bulkEncrypt(plaintexts[], { table, column })` / `bulkDecrypt(encrypted[])`
- `bulkEncryptModels(models[], table)` / `bulkDecryptModels(models[])`
- `createSearchTerms(terms)` for searchable queries
-- **Identity-aware encryption**: Use `LockContext` from `@cipherstash/protect/identify` and chain `.withLockContext()` on operations. Same context must be used for both encrypt and decrypt.
+- **Identity-aware encryption**: Use `LockContext` from `@cipherstash/stack/identity` and chain `.withLockContext()` on operations. Same context must be used for both encrypt and decrypt.
## Critical Gotchas (read before coding)
-- **Native Node.js module**: Protect.js relies on `@cipherstash/protect-ffi` (Node-API). It must be loaded via native Node.js `require`. Do NOT bundle this module; configure bundlers to externalize it.
+- **Native Node.js module**: Stash Encryption relies on `@cipherstash/protect-ffi` (Node-API). It must be loaded via native Node.js `require`. Do NOT bundle this module; configure bundlers to externalize it.
- Next.js: see `docs/how-to/nextjs-external-packages.md`
- SST/Serverless: see `docs/how-to/sst-external-packages.md`
- npm lockfile v3 on Linux: see `docs/how-to/npm-lockfile-v3.md`
- **Bun is not supported**: Due to Node-API compatibility gaps. Use Node.js.
- **Do not log plaintext**: The library never logs plaintext by design. Don’t add logs that risk leaking sensitive data.
-- **Result shape is contract**: Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `ProtectErrorTypes`.
+- **Result shape is contract**: Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `EncryptionErrorTypes`.
- **Encrypted payload shape is contract**: Keys like `c` in the EQL payload are validated by tests and downstream tools. Don’t change them.
- **Exports must support ESM and CJS**: Each package’s `exports` maps must keep both `import` and `require` fields. Don’t remove CJS.
@@ -142,11 +143,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 `ProtectClient`, ensure:
+2. If modifying `packages/stack` 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 `buildEncryptConfig` still validates with Zod in `ProtectClient.init`.
+3. If changing schema behavior (`packages/schema`), update type definitions and ensure `buildEncryptConfig` still validates with Zod 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/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..7d06d2a0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,69 @@
+# CLAUDE.md — Stash Encryption (protectjs)
+
+End-to-end, per-value encryption for JS/TS with zero-knowledge key management (CipherStash ZeroKMS). Encrypted data stored as EQL JSON payloads; searchable encryption supported for PostgreSQL.
+
+## Quick reference
+
+```bash
+pnpm install # install (requires pnpm 10.x, Node >= 22)
+pnpm run build # build all packages (Turborepo + tsup)
+pnpm run dev # watch mode
+pnpm test # run all package tests (Vitest)
+pnpm --filter @cipherstash/stack test # test a single package
+pnpm run code:fix # lint + format (Biome)
+pnpm changeset # create a changeset for release
+```
+
+## Monorepo structure
+
+pnpm workspaces with Turborepo orchestration. Packages under `packages/*`, examples under `examples/*`.
+
+| Package | Purpose |
+|---------|---------|
+| `protect` | Deprecated — re-exports from `stack` for backward compatibility |
+| `stack` | Core library — encrypt/decrypt, FFI bridge to `@cipherstash/protect-ffi` |
+| `schema` | Schema builder (`encryptedTable`, `encryptedColumn`, `buildEncryptConfig`) |
+| `nextjs` | Next.js helpers and Clerk integration |
+| `dynamodb` | DynamoDB helpers |
+| `drizzle` | Drizzle ORM integration |
+| `utils` | Shared config and logger |
+
+Build order is managed by Turborepo (`^build` dependency). Each package uses `tsup` for bundling.
+
+## Critical constraints
+
+1. **Native FFI** — `@cipherstash/protect-ffi` is a Node-API native module loaded via `require`. Bundlers **must** externalize it (Next.js: `serverExternalPackages`; SST/serverless: see `docs/how-to/`).
+2. **No plaintext logging** — The library never logs plaintext by design. Never add logs that could leak sensitive data.
+3. **Result contract** — All operations return `{ data }` or `{ failure }` with stable error `type` strings from `EncryptionErrorTypes`. Do not change this shape.
+4. **EQL payload shape is contract** — Keys like `c` in encrypted payloads are validated by tests and downstream tools. Do not alter.
+5. **ESM + CJS** — Every package's `exports` map must keep both `import` and `require` fields. Do not remove CJS support.
+6. **Bun is not supported** — Node-API compatibility gaps. Use Node.js only.
+
+## Environment variables (for tests/examples)
+
+```bash
+CS_WORKSPACE_CRN= # required for live encryption tests
+CS_CLIENT_ID=
+CS_CLIENT_KEY=
+CS_CLIENT_ACCESS_KEY=
+USER_JWT= # optional — identity-aware encryption tests
+USER_2_JWT=
+PROTECT_LOG_LEVEL=debug|info|error
+```
+
+Tests requiring credentials will fail or be skipped without these.
+
+## Code style
+
+- Biome for formatting and linting (single quotes, no semicolons, 2-space indent)
+- `noThenProperty` lint rule is disabled (operations use thenable pattern)
+- Tests use Vitest with `.test.ts` files under each package's `__tests__/`
+- Test via public API; avoid reaching into private internals
+
+## Making changes checklist
+
+1. Identify target package(s); check if changes affect public APIs or payload shapes
+2. Preserve Result contract, `.withLockContext()` chaining, and ESM/CJS exports
+3. Add/extend tests in the same package
+4. Run: `pnpm run code:fix && pnpm --filter build && pnpm --filter test`
+5. Create a changeset (`pnpm changeset`) if the change affects published packages
diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md
index 4b37ed61..9c5bc53c 100644
--- a/CONTRIBUTE.md
+++ b/CONTRIBUTE.md
@@ -1,4 +1,4 @@
-# How to contribute to @cipherstash/protect
+# How to contribute to @cipherstash/stack
## I want to report a bug, or make a feature request
@@ -8,9 +8,9 @@ Please use the GitHub issue tracker to report bugs, suggest features, or documen
---
-# Contributing to @cipherstash/protect
+# Contributing to @cipherstash/stack
-Thank you for your interest in contributing to **@cipherstash/protect**! This document will walk you through the repository’s structure, how to build and run the project locally, and how to make contributions effectively.
+Thank you for your interest in contributing to **@cipherstash/stack**! This document will walk you through the repository's structure, how to build and run the project locally, and how to make contributions effectively.
## Repository Structure
@@ -21,7 +21,7 @@ Thank you for your interest in contributing to **@cipherstash/protect**! This do
│ └── example-app-2/
│
├── packages/
-│ └── protect/ <-- Main package published to npm
+│ └── stack/ <-- Main package published to npm
│
├── .changeset/
├── .turbo/
@@ -34,13 +34,13 @@ Thank you for your interest in contributing to **@cipherstash/protect**! This do
This repo uses [Turborepo](https://turbo.build/) to manage multiple packages and examples in a monorepo structure. Turborepo orchestrates tasks (build, test, lint, etc.) across the different packages in a consistent and efficient manner.
-### `packages/protect`
+### `packages/stack`
-The **@cipherstash/protect** package is the core library that is published to npm under the `@cipherstash/protect` namespace. This is likely where you’ll spend most of your time if you’re contributing new features or bug fixes related to JSEQL’s core functionality.
+The **@cipherstash/stack** package is the core library that is published to npm under the `@cipherstash/stack` namespace. This is likely where you'll spend most of your time if you're contributing new features or bug fixes related to the core functionality.
### `examples/` Directory
-Within the `examples/` directory, you’ll find example applications that demonstrate how to use **@cipherstash/protect**. These examples reference the local `@cipherstash/protect` package, allowing you to test and verify your changes to **@cipherstash/protect** in a real-world application scenario.
+Within the `examples/` directory, you'll find example applications that demonstrate how to use **@cipherstash/stack**. These examples reference the local `@cipherstash/stack` package, allowing you to test and verify your changes to **@cipherstash/stack** in a real-world application scenario.
## Setup Instructions
@@ -59,13 +59,13 @@ pnpm install
### 4. Build the Main Package
-Before you can run any example, you need to build the `@cipherstash/protect` package:
+Before you can run any example, you need to build the `@cipherstash/stack` package:
```bash
pnpm run build
```
-This command triggers Turborepo’s build pipeline, compiling the **@cipherstash/protect** package in `packages/protect` and linking it locally so the example can reference it.
+This command triggers Turborepo's build pipeline, compiling the **@cipherstash/stack** package in `packages/stack` and linking it locally so the example can reference it.
### 5. Run an Example App
@@ -77,7 +77,7 @@ pnpm run dev
Navigate to one of the examples in `examples/` and follow the instructions for the corresponding example.
-Now, you can view the running application (if it’s a web or server app) or otherwise test the example’s output. This will help confirm your local build of **@cipherstash/protect** is working correctly.
+Now, you can view the running application (if it's a web or server app) or otherwise test the example's output. This will help confirm your local build of **@cipherstash/stack** is working correctly.
## Making Changes
@@ -86,7 +86,7 @@ Now, you can view the running application (if it’s a web or server app) or oth
git checkout -b feat/my-new-feature
```
-2. **Implement your changes** in the relevant package (most likely in `packages/protect`).
+2. **Implement your changes** in the relevant package (most likely in `packages/stack`).
3. **Write tests** to cover any new functionality or bug fixes.
diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 00000000..17caca88
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,188 @@
+# Migration Guide: `@cipherstash/protect` to `@cipherstash/stack`
+
+This guide covers migrating from `@cipherstash/protect` to `@cipherstash/stack`. All old names are preserved as deprecated aliases, so your existing code will continue to work. However, we recommend updating to the new names to stay current.
+
+## 1. Update your dependencies
+
+Replace `@cipherstash/protect` with `@cipherstash/stack`:
+
+```bash
+# Remove the old package
+npm uninstall @cipherstash/protect
+
+# Install the new package
+npm install @cipherstash/stack
+```
+
+If you use the DynamoDB helpers, update the peer dependency:
+
+```bash
+# @cipherstash/protect-dynamodb now expects @cipherstash/stack
+npm install @cipherstash/stack @cipherstash/protect-dynamodb
+```
+
+If you use the Drizzle integration, update the peer dependency:
+
+```bash
+npm install @cipherstash/stack @cipherstash/drizzle
+```
+
+## 2. Update your imports
+
+### Package imports
+
+| Before | After |
+|--------|-------|
+| `from '@cipherstash/protect'` | `from '@cipherstash/stack'` |
+| `from '@cipherstash/protect/client'` | `from '@cipherstash/stack/client'` |
+| `from '@cipherstash/protect/identify'` | `from '@cipherstash/stack/identity'` |
+
+### Named imports
+
+| Before | After |
+|--------|-------|
+| `protect` | `Encryption` |
+| `csTable` | `encryptedTable` |
+| `csColumn` | `encryptedColumn` |
+| `csValue` | `encryptedValue` |
+
+### Type imports
+
+| Before | After |
+|--------|-------|
+| `ProtectClient` | `EncryptionClient` |
+| `ProtectClientConfig` | `EncryptionClientConfig` |
+| `ProtectError` | `EncryptionError` |
+| `ProtectErrorTypes` | `EncryptionErrorTypes` |
+| `ProtectTable` | `EncryptedTable` |
+| `ProtectColumn` | `EncryptedColumn` |
+| `ProtectValue` | `EncryptedValue` |
+| `ProtectTableColumn` | `EncryptedTableColumn` |
+| `ProtectOperation` | `EncryptionOperation` |
+
+## 3. Update your code
+
+### Schema definition
+
+```diff
+-import { csTable, csColumn } from '@cipherstash/protect'
++import { encryptedTable, encryptedColumn } from '@cipherstash/stack'
+
+-const users = csTable('users', {
+- email: csColumn('email').equality().freeTextSearch(),
+- age: csColumn('age').dataType('number').orderAndRange(),
++const users = encryptedTable('users', {
++ email: encryptedColumn('email').equality().freeTextSearch(),
++ age: encryptedColumn('age').dataType('number').orderAndRange(),
+ })
+```
+
+### Client initialization
+
+```diff
+-import { protect, type ProtectClientConfig } from '@cipherstash/protect'
++import { Encryption, type EncryptionClientConfig } from '@cipherstash/stack'
+
+-const config: ProtectClientConfig = {
++const config: EncryptionClientConfig = {
+ schemas: [users],
+ }
+
+-const client = await protect(config)
++const client = await Encryption(config)
+```
+
+### Identity / Lock context
+
+```diff
+-import { LockContext } from '@cipherstash/protect/identify'
++import { LockContext } from '@cipherstash/stack/identity'
+```
+
+### Error handling
+
+```diff
+-import { ProtectErrorTypes } from '@cipherstash/protect'
++import { EncryptionErrorTypes } from '@cipherstash/stack'
+
+ if (result.failure) {
+- if (result.failure.type === ProtectErrorTypes.EncryptionError) {
++ if (result.failure.type === EncryptionErrorTypes.EncryptionError) {
+ // handle encryption error
+ }
+ }
+```
+
+### Drizzle integration
+
+```diff
+-import { protect } from '@cipherstash/protect'
++import { Encryption } from '@cipherstash/stack'
+ import { extractProtectSchema, createProtectOperators } from '@cipherstash/drizzle/pg'
+
+ const users = extractProtectSchema(usersTable)
+-const client = await protect({ schemas: [users] })
++const client = await Encryption({ schemas: [users] })
+ const ops = createProtectOperators(client)
+```
+
+> Note: `extractProtectSchema` and `createProtectOperators` retain their names in the Drizzle package.
+
+### DynamoDB integration
+
+```diff
+-import { protect, csTable, csColumn } from '@cipherstash/protect'
++import { Encryption, encryptedTable, encryptedColumn } from '@cipherstash/stack'
+ import { protectDynamoDB } from '@cipherstash/protect-dynamodb'
+
+-const users = csTable('users', {
+- email: csColumn('email').equality(),
++const users = encryptedTable('users', {
++ email: encryptedColumn('email').equality(),
+ })
+
+-const client = await protect({ schemas: [users] })
++const client = await Encryption({ schemas: [users] })
+ const dynamo = protectDynamoDB({ protectClient: client })
+```
+
+## 4. Deprecated aliases
+
+All old names continue to work as deprecated aliases. Your IDE will show strikethrough on deprecated names, and TypeScript will emit deprecation warnings. There is no runtime behavior change when using deprecated aliases.
+
+You can migrate incrementally — old and new names can coexist in the same codebase.
+
+## 5. What hasn't changed
+
+- The `Result` contract (`{ data }` or `{ failure }`) is unchanged
+- EQL payload shapes are unchanged
+- The `.withLockContext()` chaining pattern is unchanged
+- All encryption, decryption, and search operations work identically
+- Environment variables (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, etc.) are unchanged
+- The `@cipherstash/protect-ffi` native module is still used internally
+
+## 6. Find and replace cheat sheet
+
+For a quick migration, run these find-and-replace operations across your codebase:
+
+```
+@cipherstash/protect/identify → @cipherstash/stack/identity
+@cipherstash/protect/client → @cipherstash/stack/client
+@cipherstash/protect → @cipherstash/stack
+csTable( → encryptedTable(
+csColumn( → encryptedColumn(
+csValue( → encryptedValue(
+protect({ → Encryption({
+await protect( → await Encryption(
+ProtectClientConfig → EncryptionClientConfig
+ProtectClient → EncryptionClient
+ProtectErrorTypes → EncryptionErrorTypes
+ProtectError → EncryptionError
+ProtectTable → EncryptedTable
+ProtectColumn → EncryptedColumn
+ProtectValue → EncryptedValue
+ProtectTableColumn → EncryptedTableColumn
+ProtectOperation → EncryptionOperation
+```
+
+> **Important**: Run the more specific replacements first (e.g., `@cipherstash/protect/identify` before `@cipherstash/protect`) to avoid partial matches.
diff --git a/README.md b/README.md
index 14df6a49..843f35cb 100644
--- a/README.md
+++ b/README.md
@@ -2,35 +2,41 @@
- Protect.js
+ The CipherStash data security stack
-
-
-
+
+
+
## Getting Started
-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.
+CipherStash is the new standard for data security that feels invisible. Encrypt, control, and audit access to sensitive data directly in your TypeScript applications.
-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).
+What is the Stash Stack?
+Stash Stack is a collection of packages that provide a unified way to manage data security:
-Visit the [documentation](#documentation) below to get started with Protect.js and explore related products.
+- **Encryption** - Application-level encryption. Encrypt sensitive fields (names, emails, health records, etc.) while retaining search and filtering. Built for claim-based access control and identity-bound encryption.
+- **Secrets** - Secrets management. A secure vault for secrets and sensitive config. Manage your secrets easily with a zero-trust architecture and full audit trail.
+- **KMS** - Distributed key management. KMS is the key management system designed for both security and speed.
+
+Visit the [documentation](#documentation) below to get started.
## Documentation
Visit the documentation for our products to get started:
-- **[Protect.js](https://cipherstash.com/docs/protect-js)** - End-to-end field level encryption for JavaScript/TypeScript apps with zero‑knowledge key management
-- **[Stash - Secrets Manager](https://getstash.sh/docs)** - Store and manage secrets like API keys and database credentials with zero-trust encryption
-- **[Protect.js for Drizzle ORM](https://cipherstash.com/docs/drizzle)** - Seamlessly integrate Protect.js with Drizzle ORM and PostgreSQL
+- **[Encryption](https://cipherstash.com/docs/encryption)** - End-to-end field level encryption for JavaScript/TypeScript apps with zero‑knowledge key management
+- **[Secrets](https://cipherstash.com/docs/secrets)** - Store and manage secrets like API keys and database credentials with zero-trust encryption
+- **[KMS](https://cipherstash.com/docs/kms)** - Distributed key management. KMS is the key management system designed for both security and speed.
+- **[Stash + Drizzle](https://cipherstash.com/docs/encryption/drizzle)** - Seamlessly integrate Stash Encryption with Drizzle ORM and PostgreSQL
## Features
-Protect.js protects data using industry-standard AES encryption and [ZeroKMS](https://cipherstash.com/products/zerokms) for bulk encryption and decryption operations and is up to 14x faster than AWS KMS or Hashicorp Vault. This enables every encrypted value, in every column, in every row in your database to have a unique key, without sacrificing performance.
+Stash Encryption protects data using industry-standard AES encryption and [Stash KMS](https://cipherstash.com/stack/kms) for bulk encryption and decryption operations and is up to 14x faster than AWS KMS or Hashicorp Vault. This enables every encrypted value, in every column, in every row in your database to have a unique key, without sacrificing performance.
**Features:**
@@ -47,26 +53,26 @@ Protect.js protects data using industry-standard AES encryption and [ZeroKMS](ht
- **Reduce the blast radius of data breaches**: Limit the impact of exploited vulnerabilities to only the data your end-users can decrypt
> [!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`. See the [documentation](https://cipherstash.com/docs/protect-js) for bundling configuration guides.
+> **You need to opt-out of bundling when using `@cipherstash/stack`.** This package uses Node.js specific features and requires the use of the native Node.js `require`. See the [documentation](https://cipherstash.com/docs/protect-js) for bundling configuration guides.
## Community
-The Protect.js community can be found on [Discord](https://discord.gg/5qwXUFb6PB) where you can ask questions, voice ideas, and share your projects with other people.
+The CipherStash community can be found on [Discord](https://discord.gg/5qwXUFb6PB) where you can ask questions, voice ideas, and share your projects with other people.
-Do note that our [Code of Conduct](CODE_OF_CONDUCT.md) applies to all Protect.js community channels. Users are **highly encouraged** to read and adhere to it to avoid repercussions.
+Do note that our [Code of Conduct](CODE_OF_CONDUCT.md) applies to all CipherStash community channels. Users are **highly encouraged** to read and adhere to it to avoid repercussions.
## Contributing
-Contributions to Protect.js are welcome and highly appreciated. However, before you jump right into it, we would like you to review our [Contribution Guidelines](CONTRIBUTE.md) to make sure you have a smooth experience contributing to Protect.js.
+Contributions are welcome and highly appreciated. However, before you jump right into it, we would like you to review our [Contribution Guidelines](CONTRIBUTE.md) to make sure you have a smooth experience.
---
## Security
-If you believe you have found a security vulnerability in Protect.js, we encourage you to **_responsibly disclose this and NOT open a public issue_**.
+If you believe you have found a security vulnerability, we encourage you to **_responsibly disclose this and NOT open a public issue_**.
Please email [security@cipherstash.com](mailto:security@cipherstash.com) with details about the vulnerability. We will review your report and provide further instructions for submitting your report.
## License
-Protect.js is [MIT licensed](./LICENSE.md).
+This project is [MIT licensed](./LICENSE.md).
diff --git a/SECURITY.md b/SECURITY.md
index 466993a5..6516f076 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -5,7 +5,7 @@ This document describes the security posture, reporting process, and guidelines
## Supported Packages
-This repository contains the JavaScript/TypeScript SDK for CipherStash Protect and related packages.
+This repository contains the JavaScript/TypeScript SDK for CipherStash Stash Encryption and related packages.
The below tables list each package along with the currently supported (receiving security updates).
@@ -91,8 +91,8 @@ We will never take legal action against good-faith security researchers who foll
The following are **in scope**:
- The `cipherstash/protectjs` GitHub repository
-- All published NPM packages under the `@cipherstash/protect*` namespace
-- Protect.js cryptographic implementations, configuration layers, and CLI tooling
+- All published NPM packages under the `@cipherstash/*` namespace
+- Stash Encryption cryptographic implementations, configuration layers, and CLI tooling
- Key-handling, authenticated encryption behaviour, JSON/JSONB field-level encryption flows
- Documentation or code examples that could lead to insecure usage
- CipherStash’s internal infrastructure
diff --git a/docs/README.md b/docs/README.md
index 0e1192c9..6d8bb059 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,6 @@
-# Protect.js documentation
+# Stash Encryption documentation
-The documentation for Protect.js is organized into the following sections:
+The documentation for Stash Encryption is organized into the following sections:
- [Getting started](./getting-started.md)
@@ -12,7 +12,7 @@ The documentation for Protect.js is organized into the following sections:
- [Configuration and production deployment](./reference/configuration.md)
- [Searchable encryption with PostgreSQL](./reference/searchable-encryption-postgres.md)
-- [Protect.js schemas](./reference/schema.md)
+- [Stash Encryption schemas](./reference/schema.md)
- [Model operations with bulk crypto functions](./reference/model-operations.md)
### ORMs and frameworks
diff --git a/docs/concepts/aws-kms-vs-cipherstash-comparison.md b/docs/concepts/aws-kms-vs-cipherstash-comparison.md
index d0a52f19..e1a26a95 100644
--- a/docs/concepts/aws-kms-vs-cipherstash-comparison.md
+++ b/docs/concepts/aws-kms-vs-cipherstash-comparison.md
@@ -1,6 +1,6 @@
-# Why Protect.js Makes Encryption Simple: A Comparison with AWS KMS
+# Why Stash Encryption Makes Encryption Simple: A Comparison with AWS KMS
-Encrypting data shouldn't require managing binary buffers, base64 encoding, key ARNs, or building custom search solutions. Protect.js eliminates these complexities, giving you encryption that "just works" with a developer-friendly API.
+Encrypting data shouldn't require managing binary buffers, base64 encoding, key ARNs, or building custom search solutions. Stash Encryption eliminates these complexities, giving you encryption that "just works" with a developer-friendly API.
## The Simple Truth: Encrypting a Value
@@ -55,18 +55,18 @@ const encrypted = await encryptWithKMS('secret@squirrel.example');
- ❌ Region configuration
- ❌ AWS credential setup
-### Protect.js: One Simple Call
+### Stash Encryption: One Simple Call
```typescript
-import { protect, csTable, csColumn } from '@cipherstash/protect';
+import { Encryption, encryptedTable, encryptedColumn } from '@cipherstash/stack';
// One-time setup: Define your schema
-const users = csTable('users', {
- email: csColumn('email'),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
});
// One-time setup: Initialize client
-const protectClient = await protect({
+const protectClient = await Encryption({
schemas: [users],
});
@@ -125,7 +125,7 @@ async function decryptWithKMS(base64Ciphertext: string): Promise {
}
```
-### Protect.js: One Line
+### Stash Encryption: One Line
```typescript
// Decrypt: One call, returns typed value
@@ -147,12 +147,12 @@ const plaintext = decryptResult.data; // Already a string, typed correctly
- Storing plaintext indexes (defeats the purpose of encryption)
- Building a custom searchable encryption solution (months of work)
-**Protect.js:** Searchable encryption is built-in and works with PostgreSQL:
+**Stash Encryption:** Searchable encryption is built-in and works with PostgreSQL:
```typescript
// Just add search capabilities to your schema
-const users = csTable('users', {
- email: csColumn('email')
+const users = encryptedTable('users', {
+ email: encryptedColumn('email')
.freeTextSearch() // Full-text search
.equality() // WHERE email = ?
.orderAndRange(), // ORDER BY, range queries
@@ -196,10 +196,10 @@ const command = new EncryptCommand({
// You must manually ensure the same context is used for decryption
```
-**Protect.js:** Built-in identity-aware encryption with `LockContext`:
+**Stash Encryption:** Built-in identity-aware encryption with `LockContext`:
```typescript
-import { LockContext } from '@cipherstash/protect/identify';
+import { LockContext } from '@cipherstash/stack/identity';
// Create lock context from user JWT (one line)
const lc = new LockContext();
@@ -211,7 +211,7 @@ const encryptResult = await protectClient.encrypt(
{ column: users.email, table: users }
).withLockContext(lockContext);
-// Decrypt requires the same lock context (enforced by Protect.js)
+// Decrypt requires the same lock context (enforced by Stash Encryption)
const decryptResult = await protectClient.decrypt(ciphertext)
.withLockContext(lockContext);
```
@@ -234,10 +234,10 @@ const encryptedItems = await Promise.all(
// Hope you don't hit rate limits or need to retry
```
-**Protect.js:** Native bulk encryption optimized for performance:
+**Stash Encryption:** Native bulk encryption optimized for performance:
```typescript
-// Protect.js: One call for bulk encryption
+// Stash Encryption: One call for bulk encryption
const bulkPlaintexts = [
{ id: '1', plaintext: 'Alice' },
{ id: '2', plaintext: 'Bob' },
@@ -273,7 +273,7 @@ try {
}
```
-**Protect.js:** Type-safe Result pattern:
+**Stash Encryption:** Type-safe Result pattern:
```typescript
const result = await protectClient.encrypt(plaintext, options);
@@ -300,7 +300,7 @@ if (result.failure) {
const plaintext: string = Buffer.from(response.Plaintext).toString('utf-8');
```
-**Protect.js:** Full TypeScript support with inferred types:
+**Stash Encryption:** Full TypeScript support with inferred types:
```typescript
// TypeScript infers the return type automatically
@@ -317,7 +317,7 @@ const base64 = Buffer.from(ciphertext).toString('base64');
// Store in database as TEXT or BLOB
```
-**Protect.js:** JSON payload ready for database:
+**Stash Encryption:** JSON payload ready for database:
```typescript
// Returns JSON payload ready for JSONB storage
@@ -364,18 +364,18 @@ const decrypted = await decrypt(encrypted);
**Lines of code:** ~25 lines for basic encrypt/decrypt
**What you manage:** Key ARNs, binary conversions, base64 encoding, error handling, AWS credentials, regions
-### Protect.js: Full Implementation
+### Stash Encryption: Full Implementation
```typescript
-import { protect, csTable, csColumn } from '@cipherstash/protect';
+import { Encryption, encryptedTable, encryptedColumn } from '@cipherstash/stack';
// One-time schema definition
-const users = csTable('users', {
- email: csColumn('email'),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
});
// One-time initialization
-const protectClient = await protect({
+const protectClient = await Encryption({
schemas: [users],
});
@@ -402,11 +402,11 @@ const plaintext = decryptResult.data;
```
**Lines of code:** ~20 lines including setup
-**What you manage:** Nothing—Protect.js handles it all
+**What you manage:** Nothing—Stash Encryption handles it all
## Feature Comparison
-| Feature | AWS KMS | Protect.js |
+| Feature | AWS KMS | Stash Encryption |
|---------|---------|------------|
| **Basic Encryption** | ✅ Requires manual buffer/base64 handling | ✅ One-line API, JSON payload |
| **Key Management** | ❌ You manage key ARNs | ✅ Zero-knowledge, automatic |
@@ -430,7 +430,7 @@ const plaintext = decryptResult.data;
- Implementing identity-aware encryption yourself
- Managing key ARNs and AWS configuration
-**Protect.js** gives you:
+**Stash Encryption** gives you:
- A simple, type-safe API
- Built-in searchable encryption
- Built-in identity-aware encryption
@@ -448,7 +448,7 @@ const plaintext = decryptResult.data;
- You don't need to search encrypted data
- You're comfortable with manual buffer/base64 handling
-### Use Protect.js when:
+### Use Stash Encryption when:
- You're building applications with databases
- You need to search encrypted data
- You want a developer-friendly API
@@ -461,6 +461,6 @@ const plaintext = decryptResult.data;
## References
- [AWS KMS Documentation](https://docs.aws.amazon.com/kms/)
-- [CipherStash Protect.js Getting Started](./getting-started.md)
+- [CipherStash Stash Encryption Getting Started](./getting-started.md)
- [CipherStash Schema Reference](./reference/schema.md)
- [Searchable Encryption Concepts](./concepts/searchable-encryption.md)
diff --git a/docs/concepts/searchable-encryption.md b/docs/concepts/searchable-encryption.md
index 56ca41fa..499a80a1 100644
--- a/docs/concepts/searchable-encryption.md
+++ b/docs/concepts/searchable-encryption.md
@@ -1,6 +1,6 @@
# Searchable encryption
-Protect.js supports searching encrypted data, which enables trusted data access so that you can:
+Stash Encryption supports searching encrypted data, which enables trusted data access so that you can:
1. Prove to your customers that you can track exactly what data is being accessed in your application.
2. Provide evidence for compliance requirements, such as [SOC 2](https://cipherstash.com/compliance/soc2) and [BDSG](https://cipherstash.com/compliance/bdsg).
@@ -63,7 +63,7 @@ CipherStash's approach to searchable encryption solves the performance problem w
### Using Encrypt Query Language (EQL)
-CipherStash uses [EQL](https://github.com/cipherstash/encrypt-query-language) to perform queries on encrypted data, and Protect.js makes it easy to use EQL with any TypeScipt application.
+CipherStash uses [EQL](https://github.com/cipherstash/encrypt-query-language) to perform queries on encrypted data, and Stash Encryption makes it easy to use EQL with any TypeScipt application.
```ts
// 1) Encrypt the search term
@@ -92,7 +92,7 @@ const equalitySQL = `
const result = await client.query(equalitySQL, [ protectedUser.email.getName(), encryptedParam.data ])
```
-Using the above approach, Protect.js is generating the EQL payloads and which means you never have to drop down to writing complex SQL queries.
+Using the above approach, Stash Encryption is generating the EQL payloads and which means you never have to drop down to writing complex SQL queries.
So does this solve the original problem of searching on encrypted data?
@@ -103,7 +103,7 @@ So does this solve the original problem of searching on encrypted data?
1 | Alice Johnson | mBbKmsMMkbKBSN...
```
-The answer is yes! And you can use Protect.js to [decrypt the results in your application](../../README.md#decrypting-data).
+The answer is yes! And you can use Stash Encryption to [decrypt the results in your application](../../README.md#decrypting-data).
## How fast is CipherStash's searchable encryption?
@@ -133,7 +133,7 @@ With searchable encryption:
- Data can be encrypted, stored, and searched in your existing PostgreSQL database.
- Encrypted data can be searched using equality, free text search, and range queries.
-- Data remains encrypted, and will be decrypted using the Protect.js library in your application.
+- Data remains encrypted, and will be decrypted using the Stash Encryption library in your application.
- Queries are blazing fast, and won't slow down your application experience.
- Every decryption event is logged, giving you an audit trail of data access events.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 84c154c0..bc1a8b9a 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -2,7 +2,7 @@
This getting started guide steps you through:
-1. Installing and configuring Protect.js in a standalone project
+1. Installing and configuring Stash Encryption in a standalone project
2. Encrypting, searching, and decrypting data in a PostgreSQL database
> [!IMPORTANT]
@@ -15,10 +15,10 @@ This getting started guide steps you through:
## Table of contents
- [Step 0: Basic file structure](#step-0-basic-file-structure)
-- [Step 1: Install Protect.js](#step-1-install-protectjs)
+- [Step 1: Install Stash Encryption](#step-1-install-stash-encryption)
- [Step 2: Set up credentials](#step-2-set-up-credentials)
- [Step 3: Define your schema](#step-3-define-your-schema)
-- [Step 4: Initialize the Protect client](#step-4-initialize-the-protect-client)
+- [Step 4: Initialize the Encryption client](#step-4-initialize-the-encryption-client)
- [Step 5: Encrypt data](#step-5-encrypt-data)
- [Step 6: Decrypt data](#step-6-decrypt-data)
- [Step 7: Store encrypted data in a database](#step-7-store-encrypted-data-in-a-database)
@@ -26,7 +26,7 @@ This getting started guide steps you through:
## Step 0: Basic file structure
The following is the basic file structure of the standalone project for this getting started guide.
-In the `src/protect/` directory, we have the table definition in `schema.ts` and the Protect.js client in `index.ts`.
+In the `src/protect/` directory, we have the table definition in `schema.ts` and the Stash Encryption client in `index.ts`.
```
📦
@@ -42,7 +42,7 @@ In the `src/protect/` directory, we have the table definition in `schema.ts` and
└ 📜 tsconfig.json
```
-If you're following this getting started guide with an existing app, skip to [the next step](#step-1-install-protectjs).
+If you're following this getting started guide with an existing app, skip to [the next step](#step-1-install-stash-encryption).
If you're following this getting started guide with a clean slate, create a basic structure by running:
@@ -53,36 +53,36 @@ git init
npm init -y
```
-## Step 1: Install Protect.js
+## Step 1: Install Stash Encryption
-Install the [`@cipherstash/protect` package](https://www.npmjs.com/package/@cipherstash/protect) with your package manager of choice:
+Install the [`@cipherstash/stack` package](https://www.npmjs.com/package/@cipherstash/stack) with your package manager of choice:
```bash
-npm install @cipherstash/protect
+npm install @cipherstash/stack
# or
-yarn add @cipherstash/protect
+yarn add @cipherstash/stack
# or
-pnpm add @cipherstash/protect
+pnpm add @cipherstash/stack
```
> [!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).
+> [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, Stash Encryption uses [CipherStash Client](#cipherstash-client) which is written in Rust and embedded using [Neon](https://github.com/neon-bindings/neon).
> [!NOTE]
-> **You need to opt out of bundling when using Protect.js.**
+> **You need to opt out of bundling when using Stash Encryption.**
>
-> Protect.js uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid).
+> Stash Encryption uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid).
>
> 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).
+> Read more about [building and bundling with Stash Encryption](#builds-and-bundling).
## Step 2: Set up credentials
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.
+Follow the onboarding steps to get your first set of credentials required to use Stash Encryption.
By the end of the onboarding, you will have the following environment variables:
```bash
@@ -96,19 +96,19 @@ Save these environment variables to a `.env` file in your project.
## Step 3: Define your schema
-Protect.js uses a schema to define the tables and columns that you want to encrypt and decrypt.
+Stash Encryption uses a schema to define the tables and columns that you want to encrypt and decrypt.
To define your tables and columns, add the following to `src/protect/schema.ts`:
```ts
-import { csTable, csColumn } from "@cipherstash/protect";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
-export const users = csTable("users", {
- email: csColumn("email"),
+export const users = encryptedTable("users", {
+ email: encryptedColumn("email"),
});
-export const orders = csTable("orders", {
- address: csColumn("address"),
+export const orders = encryptedTable("orders", {
+ address: encryptedColumn("address"),
});
```
@@ -117,37 +117,37 @@ export const orders = csTable("orders", {
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";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
-export const users = csTable("users", {
- email: csColumn("email").freeTextSearch().equality().orderAndRange(),
+export const users = encryptedTable("users", {
+ email: encryptedColumn("email").freeTextSearch().equality().orderAndRange(),
});
-export const orders = csTable("orders", {
- address: csColumn("address"),
+export const orders = encryptedTable("orders", {
+ address: encryptedColumn("address"),
});
```
Read more about [defining your schema](./docs/reference/schema.md).
-## Step 4: Initialize the Protect client
+## Step 4: Initialize the Encryption client
-To import the `protect` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`:
+To import the `Encryption` 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 { Encryption, type EncryptionClientConfig } from "@cipherstash/stack";
import { users, orders } from "./schema";
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users, orders],
}
-export const protectClient = await protect(config);
+export const protectClient = await Encryption(config);
```
## Step 5: Encrypt data
-Protect.js provides the `encrypt` function on `protectClient` to encrypt data.
+Stash Encryption provides the `encrypt` function on `protectClient` to encrypt data.
`encrypt` takes a plaintext string, and an object with the table and column as parameters.
Start encrypting data by adding this to `src/index.ts`:
diff --git a/docs/how-to/lock-contexts-with-clerk.md b/docs/how-to/lock-contexts-with-clerk.md
index 7e267525..ad5f088f 100644
--- a/docs/how-to/lock-contexts-with-clerk.md
+++ b/docs/how-to/lock-contexts-with-clerk.md
@@ -68,7 +68,7 @@ export default async function Page() {
Since the CTS token is already available, you can construct a `LockContext` object with the existing CTS token.
```typescript
-import { LockContext } from '@cipherstash/protect/identify'
+import { LockContext } from '@cipherstash/stack/identity'
import { getCtsToken } from '@cipherstash/nextjs'
export default async function Page() {
@@ -95,7 +95,7 @@ export default async function Page() {
If you want to override the default context, you can pass a custom context to the `LockContext` constructor.
```typescript
-import { LockContext } from '@cipherstash/protect/identify'
+import { LockContext } from '@cipherstash/stack/identity'
// protectClient from the previous steps
const lc = new LockContext({
diff --git a/docs/how-to/nextjs-external-packages.md b/docs/how-to/nextjs-external-packages.md
index 0b4655c9..f342d5f5 100644
--- a/docs/how-to/nextjs-external-packages.md
+++ b/docs/how-to/nextjs-external-packages.md
@@ -1,6 +1,6 @@
# Next.js
-Using `@cipherstash/protect` with Next.js? You need to opt-out from the Server Components bundling and use native Node.js `require` instead.
+Using `@cipherstash/stack` with Next.js? You need to opt-out from the Server Components bundling and use native Node.js `require` instead.
## Using version 15 or later
@@ -9,7 +9,7 @@ Using `@cipherstash/protect` with Next.js? You need to opt-out from the Server C
```js
const nextConfig = {
...
- serverExternalPackages: ['@cipherstash/protect'],
+ serverExternalPackages: ['@cipherstash/stack'],
}
```
@@ -21,7 +21,7 @@ const nextConfig = {
const nextConfig = {
...
experimental: {
- serverComponentsExternalPackages: ['@cipherstash/protect'],
+ serverComponentsExternalPackages: ['@cipherstash/stack'],
},
}
```
diff --git a/docs/how-to/npm-lockfile-v3.md b/docs/how-to/npm-lockfile-v3.md
index 58559169..9f999a66 100644
--- a/docs/how-to/npm-lockfile-v3.md
+++ b/docs/how-to/npm-lockfile-v3.md
@@ -2,7 +2,7 @@
Some npm users see deployments fail on Linux (e.g., AWS Lambda) when their `package-lock.json` was created on macOS or Windows.
-This happens with `package-lock.json` version 3, where npm only records certain optional native pieces for the platform that created the lockfile. As a result, Linux builds can miss the native engine that Protect.js needs at runtime.
+This happens with `package-lock.json` version 3, where npm only records certain optional native pieces for the platform that created the lockfile. As a result, Linux builds can miss the native engine that Stash Encryption needs at runtime.
## Who is affected
@@ -12,7 +12,7 @@ This happens with `package-lock.json` version 3, where npm only records certain
## What you might see
-- Build succeeds, but the app fails to start on Linux with an error like “failed to load native addon” or “module not found” related to the Protect.js engine
+- Build succeeds, but the app fails to start on Linux with an error like "failed to load native addon" or "module not found" related to the Stash Encryption engine
## Fixes (pick one)
diff --git a/docs/how-to/sst-external-packages.md b/docs/how-to/sst-external-packages.md
index 714a795c..24e965f0 100644
--- a/docs/how-to/sst-external-packages.md
+++ b/docs/how-to/sst-external-packages.md
@@ -1,6 +1,6 @@
# SST and esbuild
-Using `@cipherstash/protect` in a serverless function deployed with [SST](https://sst.dev/)?
+Using `@cipherstash/stack` in a serverless function deployed with [SST](https://sst.dev/)?
You need to configure the `nodejs.esbuild.external` and `nodejs.install` options in your `sst.config.ts` file as documented [here](https://sst.dev/docs/component/aws/function/#nodejs):
@@ -8,9 +8,9 @@ You need to configure the `nodejs.esbuild.external` and `nodejs.install` options
...
nodejs: {
esbuild: {
- external: ['@cipherstash/protect'],
+ external: ['@cipherstash/stack'],
},
- install: ['@cipherstash/protect'],
+ install: ['@cipherstash/stack'],
},
...
```
diff --git a/docs/prompts/init-protect.md b/docs/prompts/init-protect.md
index df45ea33..3e46ab50 100644
--- a/docs/prompts/init-protect.md
+++ b/docs/prompts/init-protect.md
@@ -1,44 +1,44 @@
-# Implementing Protect.js into a Node.js application
+# Implementing Stash Encryption into a Node.js application
-Your task is to introduce Protect.js into a Node.js application. Protect.js requires the Node.js runtime so it will still work with framweworks like Next.js and Tanstack Start, since they support running code that only executes on the server.
+Your task is to introduce Stash Encryption into a Node.js application. Stash Encryption requires the Node.js runtime so it will still work with framweworks like Next.js and Tanstack Start, since they support running code that only executes on the server.
---
-## Installing Protect.js
+## Installing Stash Encryption
-Determine what package manager the application is using. This will either be `pnpm`, `npm`, or `bun` and then use the appropriate package manager to add Protect.js to the application:
+Determine what package manager the application is using. This will either be `pnpm`, `npm`, or `bun` and then use the appropriate package manager to add Stash Encryption to the application:
```bash
-npm install @cipehrstash/protect
+npm install @cipherstash/stack
# or
-pnpm add @cipehrstash/protect
+pnpm add @cipherstash/stack
# or
-bun add @cipehrstash/protect
+bun add @cipherstash/stack
```
-If you detect a mono repo, you need to ask the user which application they want Protect.js installed in.
+If you detect a mono repo, you need to ask the user which application they want Stash Encryption installed in.
---
-## Adding scafolding for the Protect.js client, schemas, and example code
+## Adding scafolding for the Stash Encryption client, schemas, and example code
In the rool of the application (if the application is configred to use something like `src` then this is where these operations will occur), you need to add a `protect` directory with the following files/content. If the application uses TypeScript use the `.ts` extension, else use the `.js` extension.
`protect/schemas.(ts/js)`
```js
-import { csTable, csColumn } from '@cipherstash/protect`
+import { encryptedTable, encryptedColumn } from '@cipherstash/stack'
-export const protectedExample = csTable('example_table', {
- sensitiveData: csColumn('sensitiveData'),
+export const protectedExample = encryptedTable('example_table', {
+ sensitiveData: encryptedColumn('sensitiveData'),
}
```
`protect/index.(ts/js)`
```js
-import { protect } from '@cipehrstash/protect'
+import { Encryption } from '@cipherstash/stack'
import { * as protectSchemas } from './schemas'
-export const protectClient = protect({
+export const protectClient = Encryption({
schemas: [...protectSchemas]
})
```
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 40f0c813..87122f5d 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -1,6 +1,6 @@
-# Protect.js configuration
+# Stash Encryption configuration
-Protect.js is configured with [toml](https://toml.io/en/) files or environment variables, and is used to initialize the client when Protect.js is initialized.
+Stash Encryption is configured with [toml](https://toml.io/en/) files or environment variables, and is used to initialize the client when Stash Encryption is initialized.
Environment variables will take precedence over configuration files and it's recommented to use them for sensitive values.
@@ -12,7 +12,7 @@ Environment variables will take precedence over configuration files and it's rec
- [`[auth]` section](#auth-section)
- [cipherstash.secret.toml](#cipherstashsecrettoml)
- [Environment variables](#environment-variables)
-- [Configuring the Protect client directly](#configuring-the-protect-client-directly)
+- [Configuring the Encryption client directly](#configuring-the-encryption-client-directly)
- [Deploying to production](#deploying-to-production)
- [Region configuration](#region-configuration)
- [File system write permissions](#file-system-write-permissions)
@@ -86,7 +86,7 @@ This is critical for encrypting/decrypting data.
## Environment variables
-You can also use environment variables to configure Protect.js.
+You can also use environment variables to configure Stash Encryption.
The following environment variables are supported:
| Variable name | Description | Required | Default |
@@ -97,16 +97,16 @@ The following environment variables are supported:
| `CS_CLIENT_ACCESS_KEY` | The access key for your CipherStash account. | Yes | |
| `CS_CONFIG_PATH` | A temporary path to store the CipherStash client configuration. | No | `/home/{username}/.cipherstash` |
-## Configuring the Protect client directly
+## Configuring the Encryption client directly
-You can also configure the Protect client directly by passing a `ProtectClientConfig` object to the `protect` function during initialization.
-This is useful if you want to configure the Protect client specific to your application.
+You can also configure the Encryption client directly by passing an `EncryptionClientConfig` object to the `Encryption` function during initialization.
+This is useful if you want to configure the Encryption client specific to your application.
An exmaple of this might be if you want to use a secret manager to store your client key and access key rather than relying on environment variables or configuration files.
```ts
-import { protect, type ProtectClientConfig } from "@cipherstash/protect";
+import { Encryption, type EncryptionClientConfig } from "@cipherstash/stack";
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users, orders],
workspaceCrn: "your-workspace-crn",
accessKey: "your-access-key",
@@ -114,13 +114,13 @@ const config: ProtectClientConfig = {
clientKey: "your-client-key",
}
-const protectClient = await protect(config);
+const protectClient = await Encryption(config);
```
## Deploying to production
> [!TIP]
-> There are some configuration details you should take note of when deploying `@cipherstash/protect` in your production examples.
+> There are some configuration details you should take note of when deploying `@cipherstash/stack` in your production examples.
### File system write permissions
diff --git a/docs/reference/drizzle/DRIFT-TESTING.md b/docs/reference/drizzle/DRIFT-TESTING.md
index 7d70de93..d1375d4f 100644
--- a/docs/reference/drizzle/DRIFT-TESTING.md
+++ b/docs/reference/drizzle/DRIFT-TESTING.md
@@ -1,6 +1,6 @@
# Documentation Drift Testing
-This document describes the documentation drift detection system for the Drizzle + Protect.js integration. The system ensures that code examples in documentation remain executable and accurate as the codebase evolves.
+This document describes the documentation drift detection system for the Drizzle + Stash Encryption integration. The system ensures that code examples in documentation remain executable and accurate as the codebase evolves.
## Overview
diff --git a/docs/reference/drizzle/drizzle-protect.md b/docs/reference/drizzle/drizzle-protect.md
index 8afc55a8..3d9ce7f7 100644
--- a/docs/reference/drizzle/drizzle-protect.md
+++ b/docs/reference/drizzle/drizzle-protect.md
@@ -1,7 +1,7 @@
-# Drizzle + Protect.js Query Examples
+# Drizzle + Stash Encryption Query Examples
## Manual Encryption Pattern (Verbose)
-This page demonstrates how to perform queries on encrypted data using **Drizzle ORM** with **explicit Protect.js encryption calls** for full control.
+This page demonstrates how to perform queries on encrypted data using **Drizzle ORM** with **explicit Stash Encryption encryption calls** for full control.
**Pattern:** Manually encrypt query values before passing them to standard Drizzle operators.
@@ -33,7 +33,7 @@ This gives you explicit visibility into the encryption/decryption workflow at th
✅ **Use the manual encryption pattern when:**
- You need fine-grained control over encryption timing
-- You want to understand how Protect.js works internally
+- You want to understand how Stash Encryption works internally
- You're building custom abstractions or utilities
- You need to cache encrypted values for performance
- You're implementing batch operations with encryption
@@ -348,7 +348,7 @@ return decrypted.data
## Order by encrypted data
-Protect.js supports ordering on encrypted fields using Order-Revealing Encryption (ORE). This allows the database to sort encrypted values without decrypting them, while preserving the original sort order.
+Stash Encryption supports ordering on encrypted fields using Order-Revealing Encryption (ORE). This allows the database to sort encrypted values without decrypting them, while preserving the original sort order.
### Order by encrypted number
@@ -477,7 +477,7 @@ When using the manual encryption pattern instead of protect operators, you have
✅ **Use manual encryption when:**
- You need fine-grained control over encryption timing
-- You want to understand how Protect.js works internally
+- You want to understand how Stash Encryption works internally
- You're building custom abstractions
- You need to cache encrypted values
- You're implementing batch operations
@@ -559,7 +559,7 @@ protectTransactions.created_at // Note: snake_case
- **Compare patterns**: Try the same query with both protect operators and manual encryption
- **Explore the code**: Check out the source code in the repository
- **Try different queries**: Modify the examples above and run them
-- **Read the docs**: Visit [CipherStash Protect.js documentation](https://docs.cipherstash.com/)
+- **Read the docs**: Visit [CipherStash Stash Encryption documentation](https://docs.cipherstash.com/)
- **Integrate into your app**: Use these patterns in your own applications
---
diff --git a/docs/reference/drizzle/drizzle.md b/docs/reference/drizzle/drizzle.md
index f250bab0..e32f38bb 100644
--- a/docs/reference/drizzle/drizzle.md
+++ b/docs/reference/drizzle/drizzle.md
@@ -1,7 +1,7 @@
-# Drizzle + Protect.js Query Examples
+# Drizzle + Stash Encryption Query Examples
## Protect Operators Pattern (Recommended)
-This page demonstrates how to perform queries on encrypted data using **Drizzle ORM** with **CipherStash Protect.js** using the **protect operators pattern**.
+This page demonstrates how to perform queries on encrypted data using **Drizzle ORM** with **CipherStash Stash Encryption** using the **protect operators pattern**.
**Pattern:** Auto-encrypting operators from `createProtectOperators()` provide clean syntax with automatic encryption.
@@ -234,7 +234,7 @@ return results
## Order by encrypted data
-Protect.js supports ordering on encrypted fields using Order-Revealing Encryption (ORE). This allows the database to sort encrypted values without decrypting them, while preserving the original sort order.
+Stash Encryption supports ordering on encrypted fields using Order-Revealing Encryption (ORE). This allows the database to sort encrypted values without decrypting them, while preserving the original sort order.
### Order by encrypted number
@@ -313,7 +313,7 @@ return result
## Understanding the results
-All results are automatically **decrypted** by Protect.js before being returned to you. The data remains encrypted in the database at all times.
+All results are automatically **decrypted** by Stash Encryption before being returned to you. The data remains encrypted in the database at all times.
### What's happening behind the scenes
@@ -337,7 +337,7 @@ All results are automatically **decrypted** by Protect.js before being returned
- **Explore the code**: Check out the source code in the repository
- **Try different queries**: Modify the examples above and run them
-- **Read the docs**: Visit [CipherStash Protect.js documentation](https://docs.cipherstash.com/)
+- **Read the docs**: Visit [CipherStash Stash Encryption documentation](https://docs.cipherstash.com/)
- **Integrate into your app**: Use these patterns in your own applications
---
diff --git a/docs/reference/model-operations.md b/docs/reference/model-operations.md
index 5a241214..865c576b 100644
--- a/docs/reference/model-operations.md
+++ b/docs/reference/model-operations.md
@@ -1,6 +1,6 @@
# Model operations
-Model operations in Protect.js provide a high-level interface for encrypting and decrypting entire objects.
+Model operations in Stash Encryption provide a high-level interface for encrypting and decrypting entire objects.
These operations automatically handle the encryption of fields defined in your schema while preserving other fields.
## Table of contents
@@ -115,7 +115,7 @@ const decryptedUsers = decryptedResult.data;
### Using type parameters
-Protect.js provides strong TypeScript support through generic type parameters:
+Stash Encryption provides strong TypeScript support through generic type parameters:
```typescript
// Define your model type
@@ -157,9 +157,9 @@ The type system ensures:
The model operations can infer types from your schema definition:
```typescript
-const users = csTable("users", {
- email: csColumn("email").freeTextSearch(),
- address: csColumn("address"),
+const users = encryptedTable("users", {
+ email: encryptedColumn("email").freeTextSearch(),
+ address: encryptedColumn("address"),
});
// Types are inferred from the schema
@@ -201,10 +201,10 @@ const result = await protectClient.encryptModel(user, users);
if (result.failure) {
// Handle specific error types
switch (result.failure.type) {
- case ProtectErrorTypes.EncryptionError:
+ case EncryptionErrorTypes.EncryptionError:
console.error("Encryption failed:", result.failure.message);
break;
- case ProtectErrorTypes.ClientInitError:
+ case EncryptionErrorTypes.ClientInitError:
console.error("Client not initialized:", result.failure.message);
break;
default:
diff --git a/docs/reference/schema.md b/docs/reference/schema.md
index b828bdf4..bd040712 100644
--- a/docs/reference/schema.md
+++ b/docs/reference/schema.md
@@ -1,6 +1,6 @@
-# Protect.js schema
+# Stash Encryption schema
-Protect.js lets you define a schema in TypeScript with properties that map to your database columns, and define indexes and casting for each column which are used when searching on encrypted data.
+Stash Encryption lets you define a schema in TypeScript with properties that map to your database columns, and define indexes and casting for each column which are used when searching on encrypted data.
## Table of contents
@@ -10,11 +10,11 @@ Protect.js lets you define a schema in TypeScript with properties that map to yo
- [Searchable encryption](#searchable-encryption)
- [Nested objects](#nested-objects)
- [Available index options](#available-index-options)
-- [Initializing the Protect client](#initializing-the-protect-client)
+- [Initializing the Encryption client](#initializing-the-encryption-client)
## Creating schema files
-You can declare your Protect.js schema directly in TypeScript either in a single `schema.ts` file, or you can split your schema into multiple files. It's up to you.
+You can declare your Stash Encryption schema directly in TypeScript either in a single `schema.ts` file, or you can split your schema into multiple files. It's up to you.
Example in a single file:
@@ -38,15 +38,15 @@ or in multiple files:
## Understanding schema files
-A schema represents a mapping of your database, and which columns you want to encrypt and index. Thus, it's a collection of tables and columns represented with `csTable` and `csColumn`.
+A schema represents a mapping of your database, and which columns you want to encrypt and index. Thus, it's a collection of tables and columns represented with `encryptedTable` and `encryptedColumn`.
The below is pseudo-code for how these mappings are defined:
```ts
-import { csTable, csColumn } from "@cipherstash/protect";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
-export const tableNameInTypeScript = csTable("tableNameInDatabase", {
- columnNameInTypeScript: csColumn("columnNameInDatabase"),
+export const tableNameInTypeScript = encryptedTable("tableNameInDatabase", {
+ columnNameInTypeScript: encryptedColumn("columnNameInDatabase"),
});
```
@@ -54,13 +54,13 @@ export const tableNameInTypeScript = csTable("tableNameInDatabase", {
Now that you understand how your schema is defined, let's dive into how you can configure your schema.
-Start by importing the `csTable` and `csColumn` functions from `@cipherstash/protect` and create a new table with a column.
+Start by importing the `encryptedTable` and `encryptedColumn` functions from `@cipherstash/stack` and create a new table with a column.
```ts
-import { csTable, csColumn } from "@cipherstash/protect";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
-export const protectedUsers = csTable("users", {
- email: csColumn("email"),
+export const protectedUsers = encryptedTable("users", {
+ email: encryptedColumn("email"),
});
```
@@ -69,35 +69,35 @@ export const protectedUsers = csTable("users", {
If you are looking to enable searchable encryption in a PostgreSQL database, you must declaratively enable the indexes in your schema by chaining the index options to the column.
```ts
-import { csTable, csColumn } from "@cipherstash/protect";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
-export const protectedUsers = csTable("users", {
- email: csColumn("email").freeTextSearch().equality().orderAndRange(),
+export const protectedUsers = encryptedTable("users", {
+ email: encryptedColumn("email").freeTextSearch().equality().orderAndRange(),
});
```
### Nested objects
-Protect.js supports nested objects in your schema, allowing you to encrypt **but not search on** nested properties. You can define nested objects up to 3 levels deep.
+Stash Encryption supports nested objects in your schema, allowing you to encrypt **but not search on** nested properties. You can define nested objects up to 3 levels deep.
This is useful for data stores that have less structured data, like NoSQL databases.
-You can define nested objects by using the `csValue` function to define a value in a nested object. The value naming convention of the `csValue` function is a dot-separated string of the nested object path, e.g. `profile.name` or `profile.address.street`.
+You can define nested objects by using the `encryptedValue` function to define a value in a nested object. The value naming convention of the `encryptedValue` function is a dot-separated string of the nested object path, e.g. `profile.name` or `profile.address.street`.
> [!NOTE]
> Using nested objects is not recommended for SQL databases, as it will not be searchable.
> You should either use a JSON data type and encrypt the entire object, or use a separate column for each nested property.
```ts
-import { csTable, csColumn, csValue } from "@cipherstash/protect";
+import { encryptedTable, encryptedColumn, encryptedValue } from "@cipherstash/stack";
-export const protectedUsers = csTable("users", {
- email: csColumn("email").freeTextSearch().equality().orderAndRange(),
+export const protectedUsers = encryptedTable("users", {
+ email: encryptedColumn("email").freeTextSearch().equality().orderAndRange(),
profile: {
- name: csValue("profile.name"),
+ name: encryptedValue("profile.name"),
address: {
- street: csValue("profile.address.street"),
+ street: encryptedValue("profile.address.street"),
location: {
- coordinates: csValue("profile.address.location.coordinates"),
+ coordinates: encryptedValue("profile.address.location.coordinates"),
},
},
},
@@ -112,7 +112,7 @@ When working with nested objects:
- Optional nested objects are supported
> [!WARNING]
-> TODO: The schema builder does not validate the values you supply to the `csValue` or `csColumn` functions.
+> TODO: The schema builder does not validate the values you supply to the `encryptedValue` or `encryptedColumn` functions.
> These values are meant to be unique, and and cause unexpected behavior if they are not defined correctly.
## Available index options
@@ -127,20 +127,20 @@ The following index options are available for your schema:
You can chain these methods to your column to configure them in any combination.
-## Initializing the Protect client
+## Initializing the Encryption client
You will use your defined schemas to initialize the EQL client.
-Simply import your schemas and pass them to the `protect` function.
+Simply import your schemas and pass them to the `Encryption` function.
```ts
-import { protect, type ProtectClientConfig } from "@cipherstash/protect";
+import { Encryption, type EncryptionClientConfig } from "@cipherstash/stack";
import { protectedUsers } from "./schemas/users";
-const config: ProtectClientConfig = {
- schemas: [protectedUsers], // At least one csTable is required
+const config: EncryptionClientConfig = {
+ schemas: [protectedUsers], // At least one encryptedTable is required
}
-const protectClient = await protect(config);
+const protectClient = await Encryption(config);
```
---
diff --git a/docs/reference/searchable-encryption-postgres.md b/docs/reference/searchable-encryption-postgres.md
index 74ead6a4..c348b3e9 100644
--- a/docs/reference/searchable-encryption-postgres.md
+++ b/docs/reference/searchable-encryption-postgres.md
@@ -1,6 +1,6 @@
-# Searchable encryption with Protect.js and PostgreSQL
+# Searchable encryption with Stash Encryption and PostgreSQL
-This reference guide outlines the different query patterns you can use to search encrypted data with Protect.js.
+This reference guide outlines the different query patterns you can use to search encrypted data with Stash Encryption.
## Table of contents
@@ -22,11 +22,11 @@ This reference guide outlines the different query patterns you can use to search
Before you can use searchable encryption with PostgreSQL, you need to:
1. Install the [EQL custom types and functions](https://github.com/cipherstash/encrypt-query-language?tab=readme-ov-file#installation)
-2. Set up your Protect.js schema with the appropriate search capabilities
+2. Set up your Stash Encryption schema with the appropriate search capabilities
> [!WARNING]
> The formal EQL repo documentation is heavily focused on the underlying custom function implementation.
-> It also has a bias towards the [CipherStash Proxy](https://github.com/cipherstash/proxy) product, so this guide is the best place to get started when using Protect.js.
+> It also has a bias towards the [CipherStash Proxy](https://github.com/cipherstash/proxy) product, so this guide is the best place to get started when using Stash Encryption.
## What is EQL?
@@ -36,26 +36,26 @@ EQL (Encrypt Query Language) is a set of PostgreSQL extensions that enable searc
- Functions for comparing and searching encrypted values
- Support for range queries and sorting on encrypted data
-When you install EQL, it adds these capabilities to your PostgreSQL database, allowing Protect.js to perform operations on encrypted data without decrypting it first.
+When you install EQL, it adds these capabilities to your PostgreSQL database, allowing Stash Encryption to perform operations on encrypted data without decrypting it first.
> [!IMPORTANT]
> Any column that is encrypted with EQL must be of type `eql_v2_encrypted` which is included in the EQL extension.
## Setting up your schema
-Define your Protect.js schema using `csTable` and `csColumn` to specify how each field should be encrypted and searched:
+Define your Stash Encryption schema using `encryptedTable` and `encryptedColumn` to specify how each field should be encrypted and searched:
```typescript
-import { protect, csTable, csColumn } from '@cipherstash/protect'
+import { Encryption, encryptedTable, encryptedColumn } from '@cipherstash/stack'
-const schema = csTable('users', {
- email: csColumn('email_encrypted')
+const schema = encryptedTable('users', {
+ email: encryptedColumn('email_encrypted')
.equality() // Enables exact matching
.freeTextSearch() // Enables text search
.orderAndRange(), // Enables sorting and range queries
- phone: csColumn('phone_encrypted')
+ phone: encryptedColumn('phone_encrypted')
.equality(), // Only exact matching
- age: csColumn('age_encrypted')
+ age: encryptedColumn('age_encrypted')
.orderAndRange() // Only sorting and range queries
})
```
@@ -174,10 +174,10 @@ const result = await client.query(
```typescript
import { Client } from 'pg'
-import { protect, csTable, csColumn } from '@cipherstash/protect'
+import { Encryption, encryptedTable, encryptedColumn } from '@cipherstash/stack'
-const schema = csTable('users', {
- email: csColumn('email_encrypted')
+const schema = encryptedTable('users', {
+ email: encryptedColumn('email_encrypted')
.equality()
.freeTextSearch()
.orderAndRange()
@@ -187,7 +187,7 @@ const client = new Client({
// your connection details
})
-const protectClient = await protect({
+const protectClient = await Encryption({
schemas: [schema]
})
@@ -228,7 +228,7 @@ const decryptedData = await protectClient.bulkDecryptModels(result.rows)
### Using Supabase SDK
-For Supabase users, we provide a specific implementation guide. [Read more about using Protect.js with Supabase](./supabase-sdk.md).
+For Supabase users, we provide a specific implementation guide. [Read more about using Stash Encryption with Supabase](./supabase-sdk.md).
## Best practices
@@ -253,7 +253,7 @@ For Supabase users, we provide a specific implementation guide. [Read more about
- Cache frequently accessed data
4. **Error Handling**
- - Always check for failures with any Protect.js method
+ - Always check for failures with any Stash Encryption method
- Handle encryption errors aggressively
- Handle decryption errors gracefully
diff --git a/docs/reference/supabase-sdk.md b/docs/reference/supabase-sdk.md
index 594c3122..da32fc8d 100644
--- a/docs/reference/supabase-sdk.md
+++ b/docs/reference/supabase-sdk.md
@@ -1,10 +1,10 @@
-# Using CipherStash Protect.js with Supabase SDK
+# Using CipherStash Stash Encryption with Supabase SDK
-You can encrypt data [in-use](../concepts/searchable-encryption.md) with Protect.js and store it in your Supabase project all while maintaining the ability to search the data without decryption.
+You can encrypt data [in-use](../concepts/searchable-encryption.md) with Stash Encryption and store it in your Supabase project all while maintaining the ability to search the data without decryption.
This reference guide will show you how to do this with the Supabase SDK.
> [!NOTE]
-> The following assumes you have installed the [latest version of the EQL v2 extension](https://github.com/cipherstash/encrypt-query-language/releases) which has a specific release for Supabase, and gone through the [Protect.js setup guide](https://github.com/cipherstash/protectjs).
+> The following assumes you have installed the [latest version of the EQL v2 extension](https://github.com/cipherstash/encrypt-query-language/releases) which has a specific release for Supabase, and gone through the [Stash Encryption setup guide](https://github.com/cipherstash/protectjs).
## Defining your column types
@@ -22,27 +22,27 @@ Under the hood, the EQL payload is a JSON object that is stored as a composite t
## Inserting data
-You can insert encrypted data into the table using Protect.js and the Supabase SDK. Since the `eql_v2_encrypted` column is a composite type, you'll need to use the `encryptedToPgComposite` helper to properly format the data:
+You can insert encrypted data into the table using Stash Encryption and the Supabase SDK. Since the `eql_v2_encrypted` column is a composite type, you'll need to use the `encryptedToPgComposite` helper to properly format the data:
```typescript
-import {
- protect,
- csTable,
- csColumn,
- encryptedToPgComposite,
- type ProtectClientConfig
-} from '@cipherstash/protect'
-
-const users = csTable('users', {
- name: csColumn('name').freeTextSearch().equality(),
- email: csColumn('email').freeTextSearch().equality()
+import {
+ Encryption,
+ encryptedTable,
+ encryptedColumn,
+ encryptedToPgComposite,
+ type EncryptionClientConfig
+} from '@cipherstash/stack'
+
+const users = encryptedTable('users', {
+ name: encryptedColumn('name').freeTextSearch().equality(),
+ email: encryptedColumn('email').freeTextSearch().equality()
})
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users],
}
-const protectClient = await protect(config)
+const protectClient = await Encryption(config)
const encryptedResult = await protectClient.encryptModel(
{
@@ -71,7 +71,7 @@ const { data, error } = await supabase
.select('id, email::jsonb, name::jsonb')
```
-Without the `::jsonb` cast, the encrypted payload would be wrapped in an object with a `data` key, which would require additional handling before decryption. The cast ensures you get the raw encrypted payload that can be directly used with Protect.js for decryption:
+Without the `::jsonb` cast, the encrypted payload would be wrapped in an object with a `data` key, which would require additional handling before decryption. The cast ensures you get the raw encrypted payload that can be directly used with Stash Encryption for decryption:
```typescript
const decryptedResult = await protectClient.decryptModel(data[0])
@@ -88,24 +88,24 @@ console.log('Decrypted user:', decryptedResult.data)
When working with models that contain multiple encrypted fields, you can use the `modelToEncryptedPgComposites` helper to handle the conversion to PostgreSQL composite types:
```typescript
-import {
- protect,
- csTable,
- csColumn,
- modelToEncryptedPgComposites,
- type ProtectClientConfig
-} from '@cipherstash/protect'
-
-const users = csTable('users', {
- name: csColumn('name').freeTextSearch().equality(),
- email: csColumn('email').freeTextSearch().equality()
+import {
+ Encryption,
+ encryptedTable,
+ encryptedColumn,
+ modelToEncryptedPgComposites,
+ type EncryptionClientConfig
+} from '@cipherstash/stack'
+
+const users = encryptedTable('users', {
+ name: encryptedColumn('name').freeTextSearch().equality(),
+ email: encryptedColumn('email').freeTextSearch().equality()
})
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users],
}
-const protectClient = await protect(config)
+const protectClient = await Encryption(config)
const model = {
name: 'John Doe',
diff --git a/examples/basic/README.md b/examples/basic/README.md
index c25c6de6..8e22b72a 100644
--- a/examples/basic/README.md
+++ b/examples/basic/README.md
@@ -1,6 +1,6 @@
-# Basic example of using @cipherstash/protect
+# Basic example of using @cipherstash/stack
-This basic example demonstrates how to use the `@cipherstash/protect` package to encrypt arbitrary input.
+This basic example demonstrates how to use the `@cipherstash/stack` package to encrypt arbitrary input.
## Installing the basic example
@@ -43,7 +43,7 @@ Lastly, install the CipherStash CLI:
> [!IMPORTANT]
> Make sure you have [installed the CipherStash CLI](#installation) before following these steps.
-Set up all the configuration and credentials required for Protect.js:
+Set up all the configuration and credentials required for Stash Encryption:
```bash
stash setup
@@ -53,8 +53,8 @@ If you have not already signed up for a CipherStash account, this will prompt yo
At the end of `stash setup`, you will have two files in your project:
-- `cipherstash.toml` which contains the configuration for Protect.js
-- `cipherstash.secret.toml` which contains the credentials for Protect.js
+- `cipherstash.toml` which contains the configuration for Stash Encryption
+- `cipherstash.secret.toml` which contains the credentials for Stash Encryption
> [!WARNING]
> Do not commit `cipherstash.secret.toml` to git, because it contains sensitive credentials.
@@ -72,4 +72,4 @@ The application will log the plaintext to the console that has been encrypted us
## Next steps
-Check out the [Protect.js + Next.js + Clerk example app](../nextjs-clerk) to see how to add end-user identity as an extra control when encrypting data.
+Check out the [Stash Encryption + Next.js + Clerk example app](../nextjs-clerk) to see how to add end-user identity as an extra control when encrypting data.
diff --git a/examples/basic/package.json b/examples/basic/package.json
index 9a23f43c..bfd574f3 100644
--- a/examples/basic/package.json
+++ b/examples/basic/package.json
@@ -11,7 +11,7 @@
"license": "ISC",
"description": "",
"dependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"dotenv": "^16.4.7"
},
"devDependencies": {
diff --git a/examples/basic/protect.ts b/examples/basic/protect.ts
index 0feb8f63..17b18962 100644
--- a/examples/basic/protect.ts
+++ b/examples/basic/protect.ts
@@ -1,17 +1,17 @@
import 'dotenv/config'
import {
- type ProtectClientConfig,
- csColumn,
- csTable,
- protect,
-} from '@cipherstash/protect'
+ type EncryptionClientConfig,
+ encryptedColumn,
+ encryptedTable,
+ Encryption,
+} from '@cipherstash/stack'
-export const users = csTable('users', {
- name: csColumn('name'),
+export const users = encryptedTable('users', {
+ name: encryptedColumn('name'),
})
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users],
}
-export const protectClient = await protect(config)
+export const protectClient = await Encryption(config)
diff --git a/examples/drizzle/README.md b/examples/drizzle/README.md
index 6138e78b..e6983cfd 100644
--- a/examples/drizzle/README.md
+++ b/examples/drizzle/README.md
@@ -1,6 +1,6 @@
-# Express REST API with Drizzle ORM and Protect.js
+# Express REST API with Drizzle ORM and Stash Encryption
-This example demonstrates a FinTech REST API built with Express.js, Drizzle ORM, and Protect.js. It showcases how to encrypt sensitive financial data (account numbers, amounts, transaction descriptions) while maintaining the ability to search and query encrypted fields.
+This example demonstrates a FinTech REST API built with Express.js, Drizzle ORM, and Stash Encryption. It showcases how to encrypt sensitive financial data (account numbers, amounts, transaction descriptions) while maintaining the ability to search and query encrypted fields.
## Prerequisites
@@ -12,7 +12,7 @@ This example demonstrates a FinTech REST API built with Express.js, Drizzle ORM,
- [Express](https://expressjs.com/) - Web framework
- [Drizzle ORM](https://orm.drizzle.team/) - TypeScript ORM
-- [Protect.js](https://github.com/cipherstash/protectjs) - End-to-end encryption
+- [Stash Encryption](https://github.com/cipherstash/protectjs) - End-to-end encryption
- [PostgreSQL](https://www.postgresql.org/) - Database
## Setup
@@ -291,7 +291,7 @@ The `transactions` table has the following structure:
### Encryption
-- Sensitive fields (`accountNumber`, `amount`, `description`) are encrypted using Protect.js before being stored in the database
+- Sensitive fields (`accountNumber`, `amount`, `description`) are encrypted using Stash Encryption before being stored in the database
- The `@cipherstash/drizzle` package provides `encryptedType` helper to define encrypted columns in Drizzle schemas
- Data is automatically encrypted when inserting/updating and decrypted when reading
@@ -301,7 +301,7 @@ The `transactions` table has the following structure:
- **Text search** on `accountNumber` and `description` using `ilike` operator
- **Range queries** on `amount` using `gte` and `lte` operators
- **Equality queries** on `accountNumber` and `amount`
-- All encrypted field queries use Protect.js operators that automatically handle encryption
+- All encrypted field queries use Stash Encryption operators that automatically handle encryption
### Type Safety
@@ -310,8 +310,8 @@ The `transactions` table has the following structure:
## Notes
-- **Native Module**: Protect.js uses `@cipherstash/protect-ffi`, a native Node-API module. Express doesn't bundle code, so no special configuration is needed. If deploying to serverless platforms, ensure the native module is properly externalized.
-- **Error Handling**: All Protect.js operations return a Result type (`{ data }` or `{ failure }`). The API properly handles these results and returns appropriate HTTP status codes.
+- **Native Module**: Stash Encryption uses `@cipherstash/protect-ffi`, a native Node-API module. Express doesn't bundle code, so no special configuration is needed. If deploying to serverless platforms, ensure the native module is properly externalized.
+- **Error Handling**: All Stash Encryption operations return a Result type (`{ data }` or `{ failure }`). The API properly handles these results and returns appropriate HTTP status codes.
- **Bulk Operations**: The API uses `bulkEncryptModels` and `bulkDecryptModels` for efficient batch operations when querying multiple transactions.
## License
diff --git a/examples/drizzle/package.json b/examples/drizzle/package.json
index 13bf1e46..63c39234 100644
--- a/examples/drizzle/package.json
+++ b/examples/drizzle/package.json
@@ -20,7 +20,7 @@
},
"dependencies": {
"@cipherstash/drizzle": "workspace:*",
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"drizzle-orm": "^0.44.7",
"express": "^5.2.1",
"pg": "^8.16.3",
diff --git a/examples/drizzle/src/protect/config.ts b/examples/drizzle/src/protect/config.ts
index 05482ba0..1e3d8a12 100644
--- a/examples/drizzle/src/protect/config.ts
+++ b/examples/drizzle/src/protect/config.ts
@@ -3,14 +3,14 @@ import {
createProtectOperators,
extractProtectSchema,
} from '@cipherstash/drizzle/pg'
-import { protect } from '@cipherstash/protect'
+import { Encryption } from '@cipherstash/stack'
import { transactions } from '../db/schema'
-// Extract Protect.js schema from Drizzle table
+// Extract Stash Encryption schema from Drizzle table
export const transactionsSchema = extractProtectSchema(transactions)
-// Initialize Protect.js client
-export const protectClient = await protect({
+// Initialize Stash Encryption client
+export const protectClient = await Encryption({
schemas: [transactionsSchema],
})
diff --git a/examples/dynamo/README.md b/examples/dynamo/README.md
index 6b5f8d63..33cbc534 100644
--- a/examples/dynamo/README.md
+++ b/examples/dynamo/README.md
@@ -1,6 +1,6 @@
# DynamoDB Examples
-Examples of using Protect.js with DynamoDB.
+Examples of using Stash Encryption with DynamoDB.
## Prereqs
- [Node.js](https://nodejs.org/en) (tested with v22.11.0)
@@ -10,7 +10,7 @@ Examples of using Protect.js with DynamoDB.
## Setup
-Install the workspace dependencies and build Protect.js:
+Install the workspace dependencies and build Stash Encryption:
```
# change to the workspace root directory
cd ../..
diff --git a/examples/dynamo/package.json b/examples/dynamo/package.json
index 3d8830a9..b518007c 100644
--- a/examples/dynamo/package.json
+++ b/examples/dynamo/package.json
@@ -21,7 +21,7 @@
"@aws-sdk/client-dynamodb": "^3.817.0",
"@aws-sdk/lib-dynamodb": "^3.817.0",
"@aws-sdk/util-dynamodb": "^3.817.0",
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"@cipherstash/protect-dynamodb": "workspace:*",
"pg": "^8.13.1"
},
diff --git a/examples/dynamo/src/common/protect.ts b/examples/dynamo/src/common/protect.ts
index 78441f07..6e5edefa 100644
--- a/examples/dynamo/src/common/protect.ts
+++ b/examples/dynamo/src/common/protect.ts
@@ -1,9 +1,9 @@
-import { csColumn, csTable, protect } from '@cipherstash/protect'
+import { encryptedColumn, encryptedTable, Encryption } from '@cipherstash/stack'
-export const users = csTable('users', {
- email: csColumn('email').equality(),
+export const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
})
-export const protectClient = await protect({
+export const protectClient = await Encryption({
schemas: [users],
})
diff --git a/examples/dynamo/src/encrypted-key-in-gsi.ts b/examples/dynamo/src/encrypted-key-in-gsi.ts
index 6fa9e356..9d50d949 100644
--- a/examples/dynamo/src/encrypted-key-in-gsi.ts
+++ b/examples/dynamo/src/encrypted-key-in-gsi.ts
@@ -68,7 +68,12 @@ const main = async () => {
// Use encryptQuery to create the search term for GSI query
const encryptedResult = await protectClient.encryptQuery([
- { value: 'abc@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'abc@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
if (encryptedResult.failure) {
diff --git a/examples/dynamo/src/encrypted-partition-key.ts b/examples/dynamo/src/encrypted-partition-key.ts
index a3fd6eb0..183f119f 100644
--- a/examples/dynamo/src/encrypted-partition-key.ts
+++ b/examples/dynamo/src/encrypted-partition-key.ts
@@ -51,7 +51,12 @@ const main = async () => {
// Use encryptQuery to create the search term for partition key lookup
const encryptedResult = await protectClient.encryptQuery([
- { value: 'abc@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'abc@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
if (encryptedResult.failure) {
diff --git a/examples/dynamo/src/encrypted-sort-key.ts b/examples/dynamo/src/encrypted-sort-key.ts
index e8e08175..b868ea45 100644
--- a/examples/dynamo/src/encrypted-sort-key.ts
+++ b/examples/dynamo/src/encrypted-sort-key.ts
@@ -60,7 +60,12 @@ const main = async () => {
// Use encryptQuery to create the search term for sort key range query
const encryptedResult = await protectClient.encryptQuery([
- { value: 'abc@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'abc@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
if (encryptedResult.failure) {
diff --git a/examples/hono-supabase/README.md b/examples/hono-supabase/README.md
index 0093c327..de7240df 100644
--- a/examples/hono-supabase/README.md
+++ b/examples/hono-supabase/README.md
@@ -1,6 +1,6 @@
# CipherStash JSEQL + Supabase + Hono Example
-This project demonstrates how to encrypt data using [@cipherstash/protect](https://www.npmjs.com/package/@cipherstash/protect) before storing it in a [Supabase](https://supabase.com/) Postgres database. It uses [Hono](https://hono.dev/) to create a minimal RESTful API, showcasing how to seamlessly integrate field-level encryption into a typical web application workflow.
+This project demonstrates how to encrypt data using [@cipherstash/stack](https://www.npmjs.com/package/@cipherstash/stack) before storing it in a [Supabase](https://supabase.com/) Postgres database. It uses [Hono](https://hono.dev/) to create a minimal RESTful API, showcasing how to seamlessly integrate field-level encryption into a typical web application workflow.
## Table of Contents
- [Overview](#overview)
@@ -22,7 +22,7 @@ This project demonstrates how to encrypt data using [@cipherstash/protect](https
## Overview
**What does this example show?**
-1. **Encrypting data** with [@cipherstash/protect](https://www.npmjs.com/package/@cipherstash/protect).
+1. **Encrypting data** with [@cipherstash/stack](https://www.npmjs.com/package/@cipherstash/stack).
2. **Storing encrypted data** in a Postgres database (using Supabase).
3. **Retrieving and decrypting** that data in a minimal Hono-based REST API.
@@ -157,6 +157,6 @@ Creates a new user with an **encrypted** email field.
## Additional Resources
-- [@cipherstash/protect Documentation](https://github.com/cipherstash/protectjs)
+- [@cipherstash/stack Documentation](https://github.com/cipherstash/protectjs)
- [Hono Framework](https://hono.dev/)
- [Supabase Documentation](https://supabase.com/docs)
\ No newline at end of file
diff --git a/examples/hono-supabase/package.json b/examples/hono-supabase/package.json
index 741079b6..563b47fd 100644
--- a/examples/hono-supabase/package.json
+++ b/examples/hono-supabase/package.json
@@ -6,7 +6,7 @@
"dev": "tsx watch src/index.ts"
},
"dependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"dotenv": "^16.4.7",
diff --git a/examples/hono-supabase/src/index.ts b/examples/hono-supabase/src/index.ts
index 8f5bef13..32f32c28 100644
--- a/examples/hono-supabase/src/index.ts
+++ b/examples/hono-supabase/src/index.ts
@@ -5,21 +5,21 @@ import { Hono } from 'hono'
// Consolidated protect and it's schemas into a single file
import {
- type ProtectClientConfig,
- csColumn,
- csTable,
- protect,
-} from '@cipherstash/protect'
-
-export const users = csTable('users', {
- email: csColumn('email'),
+ type EncryptionClientConfig,
+ encryptedColumn,
+ encryptedTable,
+ Encryption,
+} from '@cipherstash/stack'
+
+export const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
})
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users],
}
-export const protectClient = await protect(config)
+export const protectClient = await Encryption(config)
// Create a single supabase client for interacting with the database
const supabaseUrl = process.env.SUPABASE_URL
diff --git a/examples/nest/README.md b/examples/nest/README.md
index 7235ce70..2f54cc35 100644
--- a/examples/nest/README.md
+++ b/examples/nest/README.md
@@ -1,4 +1,4 @@
-# Protect.js Example with NestJS
+# Stash Encryption Example with NestJS
> ⚠️ **Heads-up:** This example was generated with AI with some very specific prompting to make it as useful as possible for you :)
> If you find any issues, think this example is absolutely terrible, or would like to speak with a human, book a call with the [CipherStash solutions engineering team](https://calendly.com/cipherstash-gtm/cipherstash-discovery-call?month=2025-09)
@@ -43,7 +43,7 @@ CS_CLIENT_ACCESS_KEY=
- If you integrate bundlers, externalize `@cipherstash/protect-ffi` (native module).
### References
-- Protect.js: see repo root `README.md`
+- Stash Encryption: see repo root `README.md`
- NestJS docs: `https://docs.nestjs.com/`
- Next.js external packages: `docs/how-to/nextjs-external-packages.md`
- SST external packages: `docs/how-to/sst-external-packages.md`
diff --git a/examples/nest/package.json b/examples/nest/package.json
index bdb34f76..7727e77f 100644
--- a/examples/nest/package.json
+++ b/examples/nest/package.json
@@ -20,7 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^11.0.1",
diff --git a/examples/nest/src/app.controller.spec.ts b/examples/nest/src/app.controller.spec.ts
index b61e370f..b2deea6d 100644
--- a/examples/nest/src/app.controller.spec.ts
+++ b/examples/nest/src/app.controller.spec.ts
@@ -1,4 +1,4 @@
-import type { Decrypted, EncryptedPayload } from '@cipherstash/protect'
+import type { Decrypted, EncryptedPayload } from '@cipherstash/stack'
import { Test, type TestingModule } from '@nestjs/testing'
import { AppController } from './app.controller'
import { AppService, type CreateUserDto, type User } from './app.service'
diff --git a/examples/nest/src/app.service.ts b/examples/nest/src/app.service.ts
index e67dad28..7e4ddc4b 100644
--- a/examples/nest/src/app.service.ts
+++ b/examples/nest/src/app.service.ts
@@ -1,4 +1,4 @@
-import type { Decrypted, EncryptedPayload } from '@cipherstash/protect'
+import type { Decrypted, EncryptedPayload } from '@cipherstash/stack'
import { Injectable } from '@nestjs/common'
import type { ProtectService } from './protect'
import { users } from './protect'
diff --git a/examples/nest/src/protect/decorators/decrypt.decorator.ts b/examples/nest/src/protect/decorators/decrypt.decorator.ts
index c6572fe5..bb79589f 100644
--- a/examples/nest/src/protect/decorators/decrypt.decorator.ts
+++ b/examples/nest/src/protect/decorators/decrypt.decorator.ts
@@ -3,14 +3,14 @@ import { getProtectService } from '../utils/get-protect-service.util'
import type {
ProtectColumn,
- ProtectTable,
- ProtectTableColumn,
- ProtectValue,
-} from '@cipherstash/protect'
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptedValue,
+} from '@cipherstash/stack'
export interface DecryptOptions {
- table: ProtectTable
- column: ProtectColumn | ProtectValue
+ table: EncryptedTable
+ column: ProtectColumn | EncryptedValue
lockContext?: unknown // JWT or LockContext
}
diff --git a/examples/nest/src/protect/decorators/encrypt.decorator.ts b/examples/nest/src/protect/decorators/encrypt.decorator.ts
index 9abdb352..ca23d626 100644
--- a/examples/nest/src/protect/decorators/encrypt.decorator.ts
+++ b/examples/nest/src/protect/decorators/encrypt.decorator.ts
@@ -5,14 +5,14 @@ import { getProtectService } from '../utils/get-protect-service.util'
import type {
ProtectColumn,
- ProtectTable,
- ProtectTableColumn,
- ProtectValue,
-} from '@cipherstash/protect'
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptedValue,
+} from '@cipherstash/stack'
export interface EncryptOptions {
- table: ProtectTable
- column: ProtectColumn | ProtectValue
+ table: EncryptedTable
+ column: ProtectColumn | EncryptedValue
lockContext?: unknown // JWT or LockContext
}
diff --git a/examples/nest/src/protect/interceptors/decrypt.interceptor.ts b/examples/nest/src/protect/interceptors/decrypt.interceptor.ts
index 1078c3c8..30c8ddb3 100644
--- a/examples/nest/src/protect/interceptors/decrypt.interceptor.ts
+++ b/examples/nest/src/protect/interceptors/decrypt.interceptor.ts
@@ -11,15 +11,15 @@ import { getProtectService } from '../utils/get-protect-service.util'
import type {
ProtectColumn,
- ProtectTable,
- ProtectTableColumn,
- ProtectValue,
-} from '@cipherstash/protect'
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptedValue,
+} from '@cipherstash/stack'
export interface DecryptInterceptorOptions {
fields?: string[]
- table: ProtectTable
- column: ProtectColumn | ProtectValue
+ table: EncryptedTable
+ column: ProtectColumn | EncryptedValue
lockContext?: unknown
}
diff --git a/examples/nest/src/protect/interceptors/encrypt.interceptor.ts b/examples/nest/src/protect/interceptors/encrypt.interceptor.ts
index d1a2fa6c..fa5dd44a 100644
--- a/examples/nest/src/protect/interceptors/encrypt.interceptor.ts
+++ b/examples/nest/src/protect/interceptors/encrypt.interceptor.ts
@@ -11,15 +11,15 @@ import { getProtectService } from '../utils/get-protect-service.util'
import type {
ProtectColumn,
- ProtectTable,
- ProtectTableColumn,
- ProtectValue,
-} from '@cipherstash/protect'
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptedValue,
+} from '@cipherstash/stack'
export interface EncryptInterceptorOptions {
fields?: string[]
- table: ProtectTable
- column: ProtectColumn | ProtectValue
+ table: EncryptedTable
+ column: ProtectColumn | EncryptedValue
lockContext?: unknown
}
diff --git a/examples/nest/src/protect/interfaces/protect-config.interface.ts b/examples/nest/src/protect/interfaces/protect-config.interface.ts
index 2a7ef944..0fc2494c 100644
--- a/examples/nest/src/protect/interfaces/protect-config.interface.ts
+++ b/examples/nest/src/protect/interfaces/protect-config.interface.ts
@@ -1,4 +1,4 @@
-import type { ProtectTable, ProtectTableColumn } from '@cipherstash/protect'
+import type { EncryptedTable, EncryptedTableColumn } from '@cipherstash/stack'
export interface ProtectConfig {
workspaceCrn: string
@@ -6,5 +6,5 @@ export interface ProtectConfig {
clientKey: string
clientAccessKey: string
logLevel?: 'debug' | 'info' | 'error'
- schemas?: ProtectTable[]
+ schemas?: EncryptedTable[]
}
diff --git a/examples/nest/src/protect/protect.module.ts b/examples/nest/src/protect/protect.module.ts
index 6aca610f..5722197f 100644
--- a/examples/nest/src/protect/protect.module.ts
+++ b/examples/nest/src/protect/protect.module.ts
@@ -1,10 +1,10 @@
import {
- type ProtectClient,
- type ProtectClientConfig,
- type ProtectTable,
- type ProtectTableColumn,
- protect,
-} from '@cipherstash/protect'
+ type EncryptionClient,
+ type EncryptionClientConfig,
+ type EncryptedTable,
+ type EncryptedTableColumn,
+ Encryption,
+} from '@cipherstash/stack'
import { type DynamicModule, Global, Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import type { ProtectConfig } from './interfaces/protect-config.interface'
@@ -63,17 +63,17 @@ export class ProtectModule {
},
{
provide: PROTECT_CLIENT,
- useFactory: async (config: ProtectConfig): Promise => {
- const protectConfig: ProtectClientConfig = {
+ useFactory: async (config: ProtectConfig): Promise => {
+ const protectConfig: EncryptionClientConfig = {
schemas: (config.schemas && config.schemas.length > 0
? config.schemas
: [users]) as [
- ProtectTable,
- ...ProtectTable[],
+ EncryptedTable,
+ ...EncryptedTable[],
],
}
- return await protect(protectConfig)
+ return await Encryption(protectConfig)
},
inject: [PROTECT_CONFIG],
},
@@ -98,17 +98,17 @@ export class ProtectModule {
},
{
provide: PROTECT_CLIENT,
- useFactory: async (config: ProtectConfig): Promise => {
- const protectConfig: ProtectClientConfig = {
+ useFactory: async (config: ProtectConfig): Promise => {
+ const protectConfig: EncryptionClientConfig = {
schemas: (config.schemas && config.schemas.length > 0
? config.schemas
: [users]) as [
- ProtectTable,
- ...ProtectTable[],
+ EncryptedTable,
+ ...EncryptedTable[],
],
}
- return await protect(protectConfig)
+ return await Encryption(protectConfig)
},
inject: [PROTECT_CONFIG],
},
diff --git a/examples/nest/src/protect/protect.service.spec.ts b/examples/nest/src/protect/protect.service.spec.ts
index fb4a4d96..be8bcd84 100644
--- a/examples/nest/src/protect/protect.service.spec.ts
+++ b/examples/nest/src/protect/protect.service.spec.ts
@@ -1,4 +1,4 @@
-import type { EncryptedPayload, ProtectClient } from '@cipherstash/protect'
+import type { EncryptedPayload, EncryptionClient } from '@cipherstash/stack'
import { Test, type TestingModule } from '@nestjs/testing'
import { PROTECT_CLIENT } from './protect.constants'
import { ProtectService } from './protect.service'
@@ -6,7 +6,7 @@ import { users } from './schema'
describe('ProtectService', () => {
let service: ProtectService
- let mockClient: jest.Mocked
+ let mockClient: jest.Mocked
const mockEncryptedPayload: EncryptedPayload = {
c: 'mock-encrypted-data',
@@ -23,7 +23,7 @@ describe('ProtectService', () => {
bulkDecrypt: jest.fn(),
bulkEncryptModels: jest.fn(),
bulkDecryptModels: jest.fn(),
- } as jest.Mocked
+ } as jest.Mocked
const module: TestingModule = await Test.createTestingModule({
providers: [
diff --git a/examples/nest/src/protect/protect.service.ts b/examples/nest/src/protect/protect.service.ts
index be3a8a12..ce900602 100644
--- a/examples/nest/src/protect/protect.service.ts
+++ b/examples/nest/src/protect/protect.service.ts
@@ -3,10 +3,10 @@ import type {
EncryptOptions,
EncryptedPayload,
LockContext,
- ProtectClient,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
+ EncryptionClient,
+ EncryptedTable,
+ EncryptedTableColumn,
+} from '@cipherstash/stack'
import { Inject, Injectable } from '@nestjs/common'
import { PROTECT_CLIENT } from './protect.constants'
@@ -14,7 +14,7 @@ import { PROTECT_CLIENT } from './protect.constants'
export class ProtectService {
constructor(
@Inject(PROTECT_CLIENT)
- private readonly client: ProtectClient,
+ private readonly client: EncryptionClient,
) {}
async encrypt(plaintext: string, options: EncryptOptions) {
@@ -27,7 +27,7 @@ export class ProtectService {
async encryptModel>(
model: Decrypted,
- table: ProtectTable,
+ table: EncryptedTable,
) {
return this.client.encryptModel(model, table)
}
@@ -51,7 +51,7 @@ export class ProtectService {
async bulkEncryptModels>(
models: Decrypted[],
- table: ProtectTable,
+ table: EncryptedTable,
) {
return this.client.bulkEncryptModels(models, table)
}
@@ -78,7 +78,7 @@ export class ProtectService {
async encryptModelWithLockContext>(
model: Decrypted,
- table: ProtectTable,
+ table: EncryptedTable,
lockContext: LockContext,
) {
return this.client
diff --git a/examples/nest/src/protect/schema.ts b/examples/nest/src/protect/schema.ts
index bf104f79..cce7ba9e 100644
--- a/examples/nest/src/protect/schema.ts
+++ b/examples/nest/src/protect/schema.ts
@@ -1,17 +1,17 @@
-import { csColumn, csTable } from '@cipherstash/protect'
+import { encryptedColumn, encryptedTable } from '@cipherstash/stack'
-export const users = csTable('users', {
- email_encrypted: csColumn('email_encrypted')
+export const users = encryptedTable('users', {
+ email_encrypted: encryptedColumn('email_encrypted')
.equality()
.orderAndRange()
.freeTextSearch(),
- phone_encrypted: csColumn('phone_encrypted').equality().orderAndRange(),
- ssn_encrypted: csColumn('ssn_encrypted').equality(),
+ phone_encrypted: encryptedColumn('phone_encrypted').equality().orderAndRange(),
+ ssn_encrypted: encryptedColumn('ssn_encrypted').equality(),
})
-export const orders = csTable('orders', {
- address_encrypted: csColumn('address_encrypted').freeTextSearch(),
- creditCard_encrypted: csColumn('creditCard_encrypted').equality(),
+export const orders = encryptedTable('orders', {
+ address_encrypted: encryptedColumn('address_encrypted').freeTextSearch(),
+ creditCard_encrypted: encryptedColumn('creditCard_encrypted').equality(),
})
// Export all schemas for easy import
diff --git a/examples/nest/test/app.e2e-spec.ts b/examples/nest/test/app.e2e-spec.ts
index 948e3e0d..5a7a65c6 100644
--- a/examples/nest/test/app.e2e-spec.ts
+++ b/examples/nest/test/app.e2e-spec.ts
@@ -1,4 +1,4 @@
-import type { EncryptedPayload } from '@cipherstash/protect'
+import type { EncryptedPayload } from '@cipherstash/stack'
import type { INestApplication } from '@nestjs/common'
import { Test, type TestingModule } from '@nestjs/testing'
import request from 'supertest'
diff --git a/examples/next-drizzle-mysql/README.md b/examples/next-drizzle-mysql/README.md
index d11fe529..acaca4e1 100644
--- a/examples/next-drizzle-mysql/README.md
+++ b/examples/next-drizzle-mysql/README.md
@@ -1,16 +1,16 @@
-# Next.js + Drizzle ORM + MySQL + Protect.js Example
+# Next.js + Drizzle ORM + MySQL + Stash Encryption Example
This example demonstrates how to build a modern web application using:
- [Next.js](https://nextjs.org/) - React framework for production
- [Drizzle ORM](https://orm.drizzle.team/) - TypeScript ORM for SQL databases
- [MySQL](https://www.mysql.com/) - Popular open-source relational database
-- [Protect.js](https://cipherstash.com/protect) - Data protection and encryption library
+- [Stash Encryption](https://cipherstash.com/protect) - Data protection and encryption library
## Features
- Full-stack TypeScript application
- Database migrations and schema management with Drizzle
-- Data protection and encryption with Protect.js
+- Data protection and encryption with Stash Encryption
- Modern UI with Tailwind CSS
- Form handling with React Hook Form and Zod validation
- Docker-based MySQL database setup
@@ -33,7 +33,7 @@ This example demonstrates how to build a modern web application using:
```bash
cp .env.example .env.local
```
- Then update the environment variables in `.env.local` with your Protect.js configuration values.
+ Then update the environment variables in `.env.local` with your Stash Encryption configuration values.
3. Start the MySQL database using Docker:
```bash
@@ -73,5 +73,5 @@ The application will be available at `http://localhost:3000`.
- [Next.js Documentation](https://nextjs.org/docs)
- [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview)
-- [Protect.js Documentation](https://cipherstash.com/protect/docs)
+- [Stash Encryption Documentation](https://cipherstash.com/protect/docs)
- [MySQL Documentation](https://dev.mysql.com/doc/)
diff --git a/examples/next-drizzle-mysql/next.config.ts b/examples/next-drizzle-mysql/next.config.ts
index 0786faa6..c2c60b7f 100644
--- a/examples/next-drizzle-mysql/next.config.ts
+++ b/examples/next-drizzle-mysql/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
- serverExternalPackages: ['@cipherstash/protect', 'mysql2'],
+ serverExternalPackages: ['@cipherstash/stack', 'mysql2'],
}
export default nextConfig
diff --git a/examples/next-drizzle-mysql/package.json b/examples/next-drizzle-mysql/package.json
index a64f84c5..3b624020 100644
--- a/examples/next-drizzle-mysql/package.json
+++ b/examples/next-drizzle-mysql/package.json
@@ -10,7 +10,7 @@
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"@hookform/resolvers": "^5.0.1",
"drizzle-orm": "^0.44.0",
"mysql2": "^3.14.1",
diff --git a/examples/next-drizzle-mysql/src/protect/index.ts b/examples/next-drizzle-mysql/src/protect/index.ts
index 339a8cb6..2864f7ce 100644
--- a/examples/next-drizzle-mysql/src/protect/index.ts
+++ b/examples/next-drizzle-mysql/src/protect/index.ts
@@ -1,8 +1,8 @@
-import { type ProtectClientConfig, protect } from '@cipherstash/protect'
+import { type EncryptionClientConfig, Encryption } from '@cipherstash/stack'
import { users } from './schema'
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users],
}
-export const protectClient = await protect(config)
+export const protectClient = await Encryption(config)
diff --git a/examples/next-drizzle-mysql/src/protect/schema.ts b/examples/next-drizzle-mysql/src/protect/schema.ts
index a9591051..8b62b46d 100644
--- a/examples/next-drizzle-mysql/src/protect/schema.ts
+++ b/examples/next-drizzle-mysql/src/protect/schema.ts
@@ -1,6 +1,6 @@
-import { csColumn, csTable } from '@cipherstash/protect'
+import { encryptedColumn, encryptedTable } from '@cipherstash/stack'
-export const users = csTable('users', {
- email: csColumn('email'),
- name: csColumn('name'),
+export const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
+ name: encryptedColumn('name'),
})
diff --git a/examples/nextjs-clerk/.env.example b/examples/nextjs-clerk/.env.example
index dbb3cfa2..fa9da01b 100644
--- a/examples/nextjs-clerk/.env.example
+++ b/examples/nextjs-clerk/.env.example
@@ -5,7 +5,7 @@ CLERK_SECRET_KEY=your_clerk_secret_key
# Postres - Try out Supabase for free https://supabase.com/
POSTGRES_URL=your_postgres_url
-# CipherStash Protect.js
+# CipherStash Stash Encryption
CS_WORKSPACE_ID=your_workspace_id
CS_CLIENT_ID=your_client_id
CS_CLIENT_KEY=your_client_secret
diff --git a/examples/nextjs-clerk/README.md b/examples/nextjs-clerk/README.md
index 273c900b..99b78736 100644
--- a/examples/nextjs-clerk/README.md
+++ b/examples/nextjs-clerk/README.md
@@ -1,6 +1,6 @@
-# Protect.js + Next.js + Clerk example
+# Stash Encryption + Next.js + Clerk example
-This example demonstrates how to use Protect.js with Next.js. It also demonstrates how to use Lock Contexts to ensure that only the intended users can access sensitive data, by using Clerk for authentication.
+This example demonstrates how to use Stash Encryption with Next.js. It also demonstrates how to use Lock Contexts to ensure that only the intended users can access sensitive data, by using Clerk for authentication.
This project uses the following technologies:
@@ -49,7 +49,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
The database is hosted on Supabase and has the following schema which is defined using the Drizzle ORM:
```ts
-// Data that is encrypted using protect.js is stored as jsonb in postgres
+// Data that is encrypted using Stash Encryption is stored as jsonb in postgres
export const users = pgTable("users", {
id: serial("id").primaryKey(),
@@ -65,20 +65,20 @@ export const users = pgTable("users", {
> The EQL library ships with custom types that are used to define encrypted fields.
> See the [EQL documentation](https://github.com/cipherstash/encrypted-query-language) for more information.
-## @cipherstash/protect
+## @cipherstash/stack
-All the email data is encrypted using Protect.js.
+All the email data is encrypted using Stash Encryption.
The cipherstext is stored in the `email` column of the `users` table.
The application is configured to only decrypt the data when the user is signed in, otherwise it will display the encrypted data.
### Npm package
-`@cipherstash/protect` uses custom Rust bindings to the CipherStash Client in order to perform encryptions and decryptions.
+`@cipherstash/stack` uses custom Rust bindings to the CipherStash Client in order to perform encryptions and decryptions.
We leverage the [Neon project](https://neon-rs.dev/) to provide a JavaScript API for these bindings.
### Encryption
-When a user is added to the database, the email address is encrypted using Protect.js.
+When a user is added to the database, the email address is encrypted using Stash Encryption.
To view the encryption implementation, see the `addUser` function in [src/lib/actions.ts](src/lib/actions.ts).
### Decryption
@@ -87,7 +87,7 @@ To view the decrpytion implementation, see the `getUsers` function in [src/app/p
### Next.js
-Since `@cipherstash/protect` is a native Node.js module, you need to opt-out from the Server Components bundling and use native Node.js `require` instead.
+Since `@cipherstash/stack` is a native Node.js module, you need to opt-out from the Server Components bundling and use native Node.js `require` instead.
#### Using version 15 or later
@@ -96,7 +96,7 @@ Since `@cipherstash/protect` is a native Node.js module, you need to opt-out fro
```js
const nextConfig = {
...
- serverExternalPackages: ['@cipherstash/protect'],
+ serverExternalPackages: ['@cipherstash/stack'],
}
```
@@ -108,7 +108,7 @@ const nextConfig = {
const nextConfig = {
...
experimental: {
- serverComponentsExternalPackages: ['@cipherstash/protect'],
+ serverComponentsExternalPackages: ['@cipherstash/stack'],
},
}
```
@@ -118,4 +118,4 @@ const nextConfig = {
`serverExternalPackages` does not work with workspace packages and the issues is being tracked [here](https://github.com/vercel/next.js/issues/43433).
Once this is fixed upstream, this application can use the workspace package for development.
-For the time being, it used `@cipherstash/protect` from the npm registry.
\ No newline at end of file
+For the time being, it used `@cipherstash/stack` from the npm registry.
\ No newline at end of file
diff --git a/examples/nextjs-clerk/next.config.ts b/examples/nextjs-clerk/next.config.ts
index 69f6bd42..087316a4 100644
--- a/examples/nextjs-clerk/next.config.ts
+++ b/examples/nextjs-clerk/next.config.ts
@@ -13,7 +13,7 @@ const nextConfig: NextConfig = {
// https://github.com/vercel/next.js/issues/43433
// ---
// TODO: Once this is fixed upstream, we can use the workspace packages
- serverExternalPackages: ['@cipherstash/protect'],
+ serverExternalPackages: ['@cipherstash/stack'],
}
export default nextConfig
diff --git a/examples/nextjs-clerk/package.json b/examples/nextjs-clerk/package.json
index 502ca3b2..c50cdee0 100644
--- a/examples/nextjs-clerk/package.json
+++ b/examples/nextjs-clerk/package.json
@@ -11,7 +11,7 @@
},
"dependencies": {
"@cipherstash/nextjs": "workspace:*",
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"@clerk/nextjs": "catalog:security",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-select": "^2.1.4",
diff --git a/examples/nextjs-clerk/src/app/layout.tsx b/examples/nextjs-clerk/src/app/layout.tsx
index b835533a..584628e0 100644
--- a/examples/nextjs-clerk/src/app/layout.tsx
+++ b/examples/nextjs-clerk/src/app/layout.tsx
@@ -4,8 +4,8 @@ import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
- title: 'Protect.js + Next.js + Clerk',
- description: 'An example of using Protect.js with Next.js and Clerk',
+ title: 'Stash Encryption + Next.js + Clerk',
+ description: 'An example of using Stash Encryption with Next.js and Clerk',
}
export default function Layout({ children }: { children: React.ReactNode }) {
diff --git a/examples/nextjs-clerk/src/app/page.tsx b/examples/nextjs-clerk/src/app/page.tsx
index 0008947c..e883f685 100644
--- a/examples/nextjs-clerk/src/app/page.tsx
+++ b/examples/nextjs-clerk/src/app/page.tsx
@@ -2,7 +2,7 @@ import { db } from '@/core/db'
import { users } from '@/core/db/schema'
import { getLockContext, protectClient } from '@/core/protect'
import { getCtsToken } from '@cipherstash/nextjs'
-import type { EncryptedData } from '@cipherstash/protect'
+import type { EncryptedData } from '@cipherstash/stack'
import { auth, currentUser } from '@clerk/nextjs/server'
import Header from '../components/Header'
import UserTable from '../components/UserTable'
diff --git a/examples/nextjs-clerk/src/components/Header.tsx b/examples/nextjs-clerk/src/components/Header.tsx
index 3163b87a..fb2a0e82 100644
--- a/examples/nextjs-clerk/src/components/Header.tsx
+++ b/examples/nextjs-clerk/src/components/Header.tsx
@@ -27,7 +27,7 @@ export default function Header() {
/
- protect.js
+ stash encryption
/
diff --git a/examples/nextjs-clerk/src/core/protect/index.ts b/examples/nextjs-clerk/src/core/protect/index.ts
index 05303ea9..ecd2040a 100644
--- a/examples/nextjs-clerk/src/core/protect/index.ts
+++ b/examples/nextjs-clerk/src/core/protect/index.ts
@@ -2,21 +2,21 @@ import 'dotenv/config'
import {
type CtsToken,
LockContext,
- type ProtectClientConfig,
- csColumn,
- csTable,
- protect,
-} from '@cipherstash/protect'
+ type EncryptionClientConfig,
+ encryptedColumn,
+ encryptedTable,
+ Encryption,
+} from '@cipherstash/stack'
-export const users = csTable('users', {
- email: csColumn('email'),
+export const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
})
-const config: ProtectClientConfig = {
+const config: EncryptionClientConfig = {
schemas: [users],
}
-export const protectClient = await protect(config)
+export const protectClient = await Encryption(config)
export const getLockContext = (cts_token?: CtsToken) => {
if (!cts_token) {
diff --git a/examples/typeorm/README.md b/examples/typeorm/README.md
index efa6731f..dd905a3c 100644
--- a/examples/typeorm/README.md
+++ b/examples/typeorm/README.md
@@ -1,4 +1,4 @@
-# Protect.js Example with TypeORM
+# Stash Encryption Example with TypeORM
> ⚠️ **Heads-up:** This example was generated with AI with some very specific prompting to make it as useful as possible for you :)
> If you find any issues, think this example is absolutely terrible, or would like to speak with a human, book a call with the [CipherStash solutions engineering team](https://calendly.com/cipherstash-gtm/cipherstash-discovery-call?month=2025-09)
@@ -33,7 +33,7 @@ DB_DATABASE=cipherstash
```typescript
// src/entity/User.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'
-import type { EncryptedData } from '@cipherstash/protect'
+import type { EncryptedData } from '@cipherstash/stack'
import { EncryptedColumn } from '../decorators/encrypted-column'
@Entity()
@@ -68,19 +68,19 @@ export class User {
}
```
-### 4. Configure Protect.js
+### 4. Configure Stash Encryption
```typescript
// src/protect.ts
-import { protect, csTable, csColumn } from '@cipherstash/protect'
+import { Encryption, encryptedTable, encryptedColumn } from '@cipherstash/stack'
-export const protectedUser = csTable('user', {
- email: csColumn('email').equality().orderAndRange(),
- ssn: csColumn('ssn').equality(),
- phone: csColumn('phone').equality(),
+export const protectedUser = encryptedTable('user', {
+ email: encryptedColumn('email').equality().orderAndRange(),
+ ssn: encryptedColumn('ssn').equality(),
+ phone: encryptedColumn('phone').equality(),
})
-export const protectClient = await protect({
+export const protectClient = await Encryption({
schemas: [protectedUser],
})
```
@@ -298,7 +298,7 @@ if (result.failure) {
### 3. Environment Configuration
```typescript
// Use environment variables for all sensitive data
-export const protectClient = await protect({
+export const protectClient = await Encryption({
schemas: [protectedUser],
})
```
@@ -358,7 +358,7 @@ The demo will show:
## 🔗 Next Steps
- **Explore the demo**: Run `npm start` to see all features in action
-- **Read the docs**: Check out [Protect.js documentation](https://github.com/cipherstash/protectjs/tree/main/docs)
+- **Read the docs**: Check out [Stash Encryption documentation](https://github.com/cipherstash/protectjs/tree/main/docs)
- **Learn concepts**: Understand [searchable encryption](https://github.com/cipherstash/protectjs/blob/main/docs/concepts/searchable-encryption.md)
- **See other examples**: Browse the [examples directory](https://github.com/cipherstash/protectjs/tree/main/examples)
@@ -380,7 +380,7 @@ The demo will show:
### Getting Help
-- 📚 [Protect.js Documentation](https://github.com/cipherstash/protectjs/tree/main/docs)
+- 📚 [Stash Encryption Documentation](https://github.com/cipherstash/protectjs/tree/main/docs)
- 🐛 [GitHub Issues](https://github.com/cipherstash/protectjs/issues)
- 💬 [Community Support](https://cipherstash.com)
diff --git a/examples/typeorm/package.json b/examples/typeorm/package.json
index bdfbb63c..d0f98e65 100644
--- a/examples/typeorm/package.json
+++ b/examples/typeorm/package.json
@@ -2,7 +2,7 @@
"name": "@cipherstash/typeorm-example",
"version": "0.1.9",
"private": true,
- "description": "Protect.js with TypeORM example",
+ "description": "Stash Encryption with TypeORM example",
"type": "commonjs",
"devDependencies": {
"@types/node": "^22.13.10",
@@ -11,7 +11,7 @@
"typescript": "^5.8.2"
},
"dependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"dotenv": "^16.4.7",
"pg": "^8.14.1",
"reflect-metadata": "^0.2.2",
diff --git a/examples/typeorm/src/data-source.ts b/examples/typeorm/src/data-source.ts
index 7e315bd0..df7412bd 100644
--- a/examples/typeorm/src/data-source.ts
+++ b/examples/typeorm/src/data-source.ts
@@ -3,7 +3,7 @@ import { User } from './entity/User'
const originalConnectionConnectFunction = DataSource.prototype.initialize
-// Patch DataSource to support custom column type for Protect.js
+// Patch DataSource to support custom column type for Stash Encryption
DataSource.prototype.initialize = async function (...params) {
// TypeORM's supportedDataTypes is typed as ColumnType[], but we need to add our custom type.
// Use 'as any' to bypass the type error for custom types.
diff --git a/examples/typeorm/src/entity/User.ts b/examples/typeorm/src/entity/User.ts
index 24ecceda..040725a5 100644
--- a/examples/typeorm/src/entity/User.ts
+++ b/examples/typeorm/src/entity/User.ts
@@ -1,4 +1,4 @@
-import type { EncryptedData } from '@cipherstash/protect'
+import type { EncryptedData } from '@cipherstash/stack'
import {
Column,
CreateDateColumn,
diff --git a/examples/typeorm/src/helpers/protect-entity.ts b/examples/typeorm/src/helpers/protect-entity.ts
index 4880998c..eca0c805 100644
--- a/examples/typeorm/src/helpers/protect-entity.ts
+++ b/examples/typeorm/src/helpers/protect-entity.ts
@@ -1,7 +1,7 @@
import {
- type ProtectClient,
+ type EncryptionClientConfig,
encryptedToPgComposite,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import type { EntityTarget } from 'typeorm'
import { AppDataSource } from '../data-source'
@@ -9,7 +9,7 @@ import { AppDataSource } from '../data-source'
* Helper functions for working with encrypted entities in TypeORM
*/
export class ProtectEntityHelper {
- constructor(private protectClient: ProtectClient) {}
+ constructor(private protectClient: EncryptionClientConfig) {}
/**
* Bulk encrypt and save entities to the database
@@ -33,7 +33,7 @@ export class ProtectEntityHelper {
entityClass: EntityTarget,
// biome-ignore lint/suspicious/noExplicitAny: Required for dynamic entity types
entities: Array>,
- // biome-ignore lint/suspicious/noExplicitAny: Required for Protect.js schema types
+ // biome-ignore lint/suspicious/noExplicitAny: Required for Stash Encryption schema types
encryptFields: Record,
): Promise {
// First, prepare all entities for encryption
@@ -108,7 +108,7 @@ export class ProtectEntityHelper {
// biome-ignore lint/suspicious/noExplicitAny: Required for dynamic entity types
async bulkDecrypt>(
entities: T[],
- // biome-ignore lint/suspicious/noExplicitAny: Required for Protect.js schema types
+ // biome-ignore lint/suspicious/noExplicitAny: Required for Stash Encryption schema types
decryptFields: Record,
): Promise {
// Prepare encrypted data for bulk decryption
@@ -199,7 +199,7 @@ export class ProtectEntityHelper {
entityClass: EntityTarget,
fieldName: string,
searchValue: string,
- // biome-ignore lint/suspicious/noExplicitAny: Required for Protect.js schema types
+ // biome-ignore lint/suspicious/noExplicitAny: Required for Stash Encryption schema types
fieldConfig: { table: any; column: any },
): Promise {
// Use encryptQuery instead of deprecated createSearchTerms
diff --git a/examples/typeorm/src/index.ts b/examples/typeorm/src/index.ts
index 24c0da7c..34a31d51 100644
--- a/examples/typeorm/src/index.ts
+++ b/examples/typeorm/src/index.ts
@@ -11,14 +11,14 @@ async function main() {
await AppDataSource.initialize()
console.log('✅ Database connection established')
- // Initialize the Protect client
+ // Initialize the Encryption client
const protectClient = await initializeProtectClient()
- console.log('✅ Protect client initialized')
+ console.log('✅ Encryption client initialized')
// Initialize the helper for streamlined operations
const helper = new ProtectEntityHelper(protectClient)
- console.log('\n🔐 Protect.js TypeORM Integration Demo')
+ console.log('\n🔐 Stash Encryption TypeORM Integration Demo')
console.log('=====================================')
// Example 1: Single user encryption and saving
diff --git a/examples/typeorm/src/protect.ts b/examples/typeorm/src/protect.ts
index 491067f0..af979019 100644
--- a/examples/typeorm/src/protect.ts
+++ b/examples/typeorm/src/protect.ts
@@ -1,24 +1,24 @@
-import { csColumn, csTable, protect } from '@cipherstash/protect'
+import { encryptedColumn, encryptedTable, Encryption } from '@cipherstash/stack'
/**
* Define the protected schema for the User entity
* This maps to the encrypted fields in your TypeORM entity
*/
-export const protectedUser = csTable('user', {
- email: csColumn('email').equality().orderAndRange(),
- ssn: csColumn('ssn').equality(),
- phone: csColumn('phone').equality(),
+export const protectedUser = encryptedTable('user', {
+ email: encryptedColumn('email').equality().orderAndRange(),
+ ssn: encryptedColumn('ssn').equality(),
+ phone: encryptedColumn('phone').equality(),
})
/**
- * Initialize the Protect client with the defined schema
+ * Initialize the Encryption client with the defined schema
* This will be used throughout the application for encryption/decryption operations
*/
-let protectClient: Awaited>
+let protectClient: Awaited>
export async function initializeProtectClient() {
if (!protectClient) {
- protectClient = await protect({
+ protectClient = await Encryption({
schemas: [protectedUser],
})
}
diff --git a/examples/typeorm/src/utils/encrypted-column.ts b/examples/typeorm/src/utils/encrypted-column.ts
index 50ab5eb1..c47a487a 100644
--- a/examples/typeorm/src/utils/encrypted-column.ts
+++ b/examples/typeorm/src/utils/encrypted-column.ts
@@ -1,4 +1,4 @@
-import type { EncryptedData } from '@cipherstash/protect'
+import type { EncryptedData } from '@cipherstash/stack'
import type { ColumnOptions } from 'typeorm'
/**
diff --git a/package.json b/package.json
index 20258f73..31565e24 100644
--- a/package.json
+++ b/package.json
@@ -1,21 +1,18 @@
{
- "name": "@cipherstash/protectjs",
- "description": "CipherStash Protect for JavaScript/TypeScript",
+ "name": "@cipherstash/stack",
+ "description": "CipherStash Stack",
"author": "CipherStash ",
"keywords": [
- "encrypted",
- "query",
- "language",
- "typescript",
- "ts",
- "eql"
+ "encryption",
+ "secrets",
+ "typescript"
],
"bugs": {
- "url": "https://github.com/cipherstash/protectjs/issues"
+ "url": "https://github.com/cipherstash/stack/issues"
},
"repository": {
"type": "git",
- "url": "git+https://github.com/cipherstash/protectjs.git"
+ "url": "git+https://github.com/cipherstash/stack.git"
},
"license": "MIT",
"workspaces": [
@@ -24,7 +21,7 @@
],
"scripts": {
"build": "turbo build --filter './packages/*'",
- "build:js": "turbo build --filter './packages/protect' --filter './packages/nextjs'",
+ "build:js": "turbo build --filter './packages/stack' --filter './packages/nextjs'",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "changeset publish",
diff --git a/packages/drizzle/README.md b/packages/drizzle/README.md
index 2f4e82ff..d6b09514 100644
--- a/packages/drizzle/README.md
+++ b/packages/drizzle/README.md
@@ -1,8 +1,8 @@
-# Protect.js Drizzle ORM Integration
+# Stash Encryption — Drizzle ORM Integration
**Type-safe encryption for Drizzle ORM with searchable queries**
-Seamlessly integrate Protect.js with Drizzle ORM and PostgreSQL to encrypt your data while maintaining full query capabilities—equality, range queries, text search, and sorting—all with complete TypeScript type safety.
+Seamlessly integrate Stash Encryption with Drizzle ORM and PostgreSQL to encrypt your data while maintaining full query capabilities—equality, range queries, text search, and sorting—all with complete TypeScript type safety.
## Features
@@ -14,9 +14,12 @@ Seamlessly integrate Protect.js with Drizzle ORM and PostgreSQL to encrypt your
## Installation
```bash
-npm install @cipherstash/protect @cipherstash/drizzle drizzle-orm
+npm install @cipherstash/stack @cipherstash/drizzle drizzle-orm
```
+> [!NOTE]
+> **Migrating from `@cipherstash/protect`?** Replace `@cipherstash/protect` with `@cipherstash/stack` in your imports. The `protect()` function is now `Encryption()`. All old names remain available as deprecated aliases.
+
## Database Setup
Before using encrypted columns, you need to install the CipherStash EQL (Encrypt Query Language) functions in your PostgreSQL database.
@@ -109,19 +112,19 @@ export const usersTable = pgTable('users', {
>
> This is because the database only stores and returns encrypted ciphertext, so it doesn't know the underlying original type. You must specify the decrypted type in your ORM schema for full type safety.
-### 2. Initialize Protect.js
+### 2. Initialize Stash Encryption
```typescript
// protect/config.ts
-import { protect } from '@cipherstash/protect'
+import { Encryption } from '@cipherstash/stack'
import { extractProtectSchema } from '@cipherstash/drizzle/pg'
import { usersTable } from '../db/schema'
-// Extract Protect.js schema from Drizzle table
+// Extract Stash Encryption schema from Drizzle table
export const users = extractProtectSchema(usersTable)
-// Initialize Protect.js client
-export const protectClient = await protect({
+// Initialize Stash Encryption client
+export const protectClient = await Encryption({
schemas: [users]
})
```
@@ -307,18 +310,18 @@ Creates an encrypted column type for Drizzle schemas.
### `extractProtectSchema(table)`
-Extracts a Protect.js schema from a Drizzle table definition.
+Extracts a Stash Encryption schema from a Drizzle table definition.
**Parameters:**
- `table` - Drizzle table definition with encrypted columns
-**Returns:** Protect.js schema object
+**Returns:** Stash Encryption schema object
### `createProtectOperators(protectClient)`
Creates Drizzle-compatible operators that automatically handle encryption.
**Parameters:**
-- `protectClient` - Initialized Protect.js client
+- `protectClient` - Initialized Stash Encryption client
**Returns:** Object with all operator functions
diff --git a/packages/drizzle/__tests__/docs.test.ts b/packages/drizzle/__tests__/docs.test.ts
index fec07e06..1e379048 100644
--- a/packages/drizzle/__tests__/docs.test.ts
+++ b/packages/drizzle/__tests__/docs.test.ts
@@ -1,7 +1,7 @@
import 'dotenv/config'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
-import { protect } from '@cipherstash/protect'
+import { Encryption } from '@cipherstash/stack'
import * as drizzleOrm from 'drizzle-orm'
import { integer, pgTable } from 'drizzle-orm/pg-core'
import { drizzle } from 'drizzle-orm/postgres-js'
@@ -67,14 +67,14 @@ const protectTransactions = extractProtectSchema(transactions)
describe('Documentation Drift Tests', () => {
let db: ReturnType
let client: ReturnType
- let protectClient: Awaited>
+ let protectClient: Awaited>
let protectOps: ReturnType
let seedDataIds: number[] = []
beforeAll(async () => {
client = postgres(process.env.DATABASE_URL as string)
db = drizzle({ client })
- protectClient = await protect({ schemas: [protectTransactions] })
+ protectClient = await Encryption({ schemas: [protectTransactions] })
protectOps = createProtectOperators(protectClient)
// Create test table with EQL encrypted columns (drop if exists for clean state)
diff --git a/packages/drizzle/__tests__/drizzle.test.ts b/packages/drizzle/__tests__/drizzle.test.ts
index 5c42ea81..d9ebc0af 100644
--- a/packages/drizzle/__tests__/drizzle.test.ts
+++ b/packages/drizzle/__tests__/drizzle.test.ts
@@ -1,5 +1,5 @@
import 'dotenv/config'
-import { protect } from '@cipherstash/protect'
+import { Encryption } from '@cipherstash/stack'
import { and, eq, inArray, sql } from 'drizzle-orm'
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { drizzle } from 'drizzle-orm/postgres-js'
@@ -56,7 +56,7 @@ const drizzleUsersTable = pgTable('protect-ci', {
testRunId: text('test_run_id'),
})
-// Extract Protect.js schema from Drizzle table
+// Extract Stash Encryption schema from Drizzle table
const users = extractProtectSchema(drizzleUsersTable)
// Hard code this as the CI database doesn't support order by on encrypted columns
@@ -78,14 +78,14 @@ interface DecryptedUser {
}
}
-let protectClient: Awaited>
+let protectClient: Awaited>
let protectOps: ReturnType
let db: ReturnType
const testData: TestUser[] = []
beforeAll(async () => {
- // Initialize Protect.js client using schema extracted from Drizzle table
- protectClient = await protect({ schemas: [users] })
+ // Initialize Stash Encryption client using schema extracted from Drizzle table
+ protectClient = await Encryption({ schemas: [users] })
protectOps = createProtectOperators(protectClient)
const client = postgres(process.env.DATABASE_URL as string)
@@ -180,7 +180,7 @@ afterAll(async () => {
.where(eq(drizzleUsersTable.testRunId, TEST_RUN_ID))
}, 30000)
-describe('Drizzle ORM Integration with Protect.js', () => {
+describe('Drizzle ORM Integration with Stash Encryption', () => {
it('should perform equality search using Protect operators', async () => {
const searchEmail = 'jane.smith@example.com'
diff --git a/packages/drizzle/package.json b/packages/drizzle/package.json
index bd5d3c5a..72e09009 100644
--- a/packages/drizzle/package.json
+++ b/packages/drizzle/package.json
@@ -1,7 +1,7 @@
{
"name": "@cipherstash/drizzle",
"version": "2.3.0",
- "description": "CipherStash Protect.js Drizzle ORM integration for TypeScript",
+ "description": "CipherStash Stash Encryption Drizzle ORM integration for TypeScript",
"keywords": [
"encrypted",
"drizzle",
@@ -43,7 +43,7 @@
"release": "tsup"
},
"peerDependencies": {
- "@cipherstash/protect": ">=10",
+ "@cipherstash/stack": ">=0",
"@cipherstash/schema": ">=2",
"@types/pg": "*",
"drizzle-kit": ">=0.20",
@@ -66,7 +66,7 @@
}
},
"devDependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"@cipherstash/schema": "workspace:*",
"dotenv": "^16.4.7",
"fast-check": "^4.3.0",
diff --git a/packages/drizzle/src/pg/operators.ts b/packages/drizzle/src/pg/operators.ts
index 9addf834..0b54ab36 100644
--- a/packages/drizzle/src/pg/operators.ts
+++ b/packages/drizzle/src/pg/operators.ts
@@ -1,10 +1,10 @@
+import type { QueryTypeName } from '@cipherstash/stack'
import type {
- ProtectClient,
- ProtectColumn,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect/client'
-import type { QueryTypeName } from '@cipherstash/protect'
+ EncryptedColumn,
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptionClient,
+} from '@cipherstash/stack/client'
import {
type SQL,
type SQLWrapper,
@@ -131,8 +131,8 @@ function getDrizzleTableFromColumn(drizzleColumn: SQLWrapper): unknown {
*/
function getProtectTableFromColumn(
drizzleColumn: SQLWrapper,
- protectTableCache: Map>,
-): ProtectTable | undefined {
+ protectTableCache: Map>,
+): EncryptedTable | undefined {
const drizzleTable = getDrizzleTableFromColumn(drizzleColumn)
if (!drizzleTable) {
return undefined
@@ -162,12 +162,12 @@ function getProtectTableFromColumn(
}
/**
- * Helper to get the ProtectColumn for a Drizzle column from the ProtectTable
+ * Helper to get the EncryptedColumn for a Drizzle column from the ProtectTable
*/
-function getProtectColumn(
+function getEncryptedColumn(
drizzleColumn: SQLWrapper,
- protectTable: ProtectTable,
-): ProtectColumn | undefined {
+ protectTable: EncryptedTable,
+): EncryptedColumn | undefined {
const column = drizzleColumn as unknown as Record
const columnName = column.name as string | undefined
if (!columnName) {
@@ -175,28 +175,28 @@ function getProtectColumn(
}
const protectTableAny = protectTable as unknown as Record
- return protectTableAny[columnName] as ProtectColumn | undefined
+ return protectTableAny[columnName] as EncryptedColumn | undefined
}
/**
* Column metadata extracted from a Drizzle column
*/
interface ColumnInfo {
- readonly protectColumn: ProtectColumn | undefined
+ readonly protectColumn: EncryptedColumn | undefined
readonly config: (EncryptedColumnConfig & { name: string }) | undefined
- readonly protectTable: ProtectTable | undefined
+ readonly protectTable: EncryptedTable | undefined
readonly columnName: string
readonly tableName: string | undefined
}
/**
- * Helper to get the ProtectColumn and column config for a Drizzle column
+ * Helper to get the EncryptedColumn and column config for a Drizzle column
* If protectTable is not provided, it will be derived from the column
*/
function getColumnInfo(
drizzleColumn: SQLWrapper,
- protectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
): ColumnInfo {
const column = drizzleColumn as unknown as Record
const columnName = (column.name as string | undefined) || 'unknown'
@@ -225,7 +225,7 @@ function getColumnInfo(
}
}
- const protectColumn = getProtectColumn(drizzleColumn, resolvedProtectTable)
+ const protectColumn = getEncryptedColumn(drizzleColumn, resolvedProtectTable)
const config = getEncryptedColumnConfig(columnName, drizzleColumn)
return {
@@ -268,10 +268,14 @@ interface ValueToEncrypt {
* Returns an array of encrypted search terms or original values if not encrypted
*/
async function encryptValues(
- protectClient: ProtectClient,
- values: Array<{ value: unknown; column: SQLWrapper; queryType?: QueryTypeName }>,
- protectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectClient: EncryptionClient,
+ values: Array<{
+ value: unknown
+ column: SQLWrapper
+ queryType?: QueryTypeName
+ }>,
+ protectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
): Promise {
if (values.length === 0) {
return []
@@ -312,9 +316,13 @@ async function encryptValues(
const columnGroups = new Map<
string,
{
- column: ProtectColumn
- table: ProtectTable
- values: Array<{ value: string | number; index: number; queryType?: QueryTypeName }>
+ column: EncryptedColumn
+ table: EncryptedTable
+ values: Array<{
+ value: string | number
+ index: number
+ queryType?: QueryTypeName
+ }>
resultIndices: number[]
}
>()
@@ -402,11 +410,11 @@ async function encryptValues(
* Returns the encrypted search term or the original value if not encrypted
*/
async function encryptValue(
- protectClient: ProtectClient,
+ protectClient: EncryptionClient,
value: unknown,
drizzleColumn: SQLWrapper,
- protectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
queryType?: QueryTypeName,
): Promise {
const results = await encryptValues(
@@ -468,9 +476,9 @@ function createLazyOperator(
) => SQL,
needsEncryption: boolean,
columnInfo: ColumnInfo,
- protectClient: ProtectClient,
- defaultProtectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectClient: EncryptionClient,
+ defaultProtectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
min?: unknown,
max?: unknown,
queryType?: QueryTypeName,
@@ -602,9 +610,9 @@ async function executeLazyOperator(
*/
async function executeLazyOperatorDirect(
lazyOp: LazyOperator,
- protectClient: ProtectClient,
- defaultProtectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectClient: EncryptionClient,
+ defaultProtectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
): Promise {
if (!lazyOp.needsEncryption) {
return lazyOp.execute(lazyOp.right)
@@ -649,9 +657,9 @@ function createComparisonOperator(
left: SQLWrapper,
right: unknown,
columnInfo: ColumnInfo,
- protectClient: ProtectClient,
- defaultProtectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectClient: EncryptionClient,
+ defaultProtectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
): Promise | SQL {
const { config } = columnInfo
@@ -698,8 +706,8 @@ function createComparisonOperator(
protectClient,
defaultProtectTable,
protectTableCache,
- undefined, // min
- undefined, // max
+ undefined, // min
+ undefined, // max
'orderAndRange',
) as Promise
}
@@ -732,8 +740,8 @@ function createComparisonOperator(
protectClient,
defaultProtectTable,
protectTableCache,
- undefined, // min
- undefined, // max
+ undefined, // min
+ undefined, // max
'equality',
) as Promise
}
@@ -751,9 +759,9 @@ function createRangeOperator(
min: unknown,
max: unknown,
columnInfo: ColumnInfo,
- protectClient: ProtectClient,
- defaultProtectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectClient: EncryptionClient,
+ defaultProtectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
): Promise | SQL {
const { config } = columnInfo
@@ -810,9 +818,9 @@ function createTextSearchOperator(
left: SQLWrapper,
right: unknown,
columnInfo: ColumnInfo,
- protectClient: ProtectClient,
- defaultProtectTable: ProtectTable | undefined,
- protectTableCache: Map>,
+ protectClient: EncryptionClient,
+ defaultProtectTable: EncryptedTable | undefined,
+ protectTableCache: Map>,
): Promise | SQL {
const { config } = columnInfo
@@ -855,8 +863,8 @@ function createTextSearchOperator(
protectClient,
defaultProtectTable,
protectTableCache,
- undefined, // min
- undefined, // max
+ undefined, // min
+ undefined, // max
'freeTextSearch',
) as Promise
}
@@ -866,7 +874,7 @@ function createTextSearchOperator(
// ============================================================================
/**
- * Creates a set of Protect.js-aware operators that automatically encrypt values
+ * Creates a set of Stash Encryption-aware operators that automatically encrypt values
* for encrypted columns before using them with Drizzle operators.
*
* For equality and text search operators (eq, ne, like, ilike, inArray, etc.):
@@ -877,7 +885,7 @@ function createTextSearchOperator(
* Values are encrypted and then use eql_v2.* functions (eql_v2.gt(), eql_v2.gte(), etc.)
* which are required for ORE (Order-Revealing Encryption) comparisons.
*
- * @param protectClient - The Protect.js client instance
+ * @param protectClient - The Stash Encryption client instance
* @returns An object with all Drizzle operators wrapped for encrypted columns
*
* @example
@@ -898,7 +906,7 @@ function createTextSearchOperator(
* .where(await protectOps.gte(usersTable.age, 25))
* ```
*/
-export function createProtectOperators(protectClient: ProtectClient): {
+export function createProtectOperators(protectClient: EncryptionClient): {
// Comparison operators
/**
* Equality operator - encrypts value for encrypted columns.
@@ -1066,8 +1074,11 @@ export function createProtectOperators(protectClient: ProtectClient): {
arrayOverlaps: typeof arrayOverlaps
} {
// Create a cache for protect tables keyed by table name
- const protectTableCache = new Map>()
- const defaultProtectTable: ProtectTable | undefined =
+ const protectTableCache = new Map<
+ string,
+ EncryptedTable
+ >()
+ const defaultProtectTable: EncryptedTable | undefined =
undefined
/**
@@ -1334,7 +1345,11 @@ export function createProtectOperators(protectClient: ProtectClient): {
// Encrypt all values in the array in a single batch
const encryptedValues = await encryptValues(
protectClient,
- right.map((value) => ({ value, column: left, queryType: 'equality' as const })),
+ right.map((value) => ({
+ value,
+ column: left,
+ queryType: 'equality' as const,
+ })),
defaultProtectTable,
protectTableCache,
)
@@ -1380,7 +1395,11 @@ export function createProtectOperators(protectClient: ProtectClient): {
// Encrypt all values in the array in a single batch
const encryptedValues = await encryptValues(
protectClient,
- right.map((value) => ({ value, column: left, queryType: 'equality' as const })),
+ right.map((value) => ({
+ value,
+ column: left,
+ queryType: 'equality' as const,
+ })),
defaultProtectTable,
protectTableCache,
)
@@ -1519,7 +1538,11 @@ export function createProtectOperators(protectClient: ProtectClient): {
// Batch encrypt all values
const encryptedResults = await encryptValues(
protectClient,
- valuesToEncrypt.map((v) => ({ value: v.value, column: v.column, queryType: v.queryType })),
+ valuesToEncrypt.map((v) => ({
+ value: v.value,
+ column: v.column,
+ queryType: v.queryType,
+ })),
defaultProtectTable,
protectTableCache,
)
@@ -1674,7 +1697,11 @@ export function createProtectOperators(protectClient: ProtectClient): {
const encryptedResults = await encryptValues(
protectClient,
- valuesToEncrypt.map((v) => ({ value: v.value, column: v.column, queryType: v.queryType })),
+ valuesToEncrypt.map((v) => ({
+ value: v.value,
+ column: v.column,
+ queryType: v.queryType,
+ })),
defaultProtectTable,
protectTableCache,
)
diff --git a/packages/drizzle/src/pg/schema-extraction.ts b/packages/drizzle/src/pg/schema-extraction.ts
index a655e07c..00dcd511 100644
--- a/packages/drizzle/src/pg/schema-extraction.ts
+++ b/packages/drizzle/src/pg/schema-extraction.ts
@@ -1,18 +1,18 @@
import {
- type ProtectColumn,
- csColumn,
- csTable,
-} from '@cipherstash/protect/client'
+ type EncryptedColumn,
+ encryptedColumn,
+ encryptedTable,
+} from '@cipherstash/stack/client'
import type { PgTable } from 'drizzle-orm/pg-core'
import { getEncryptedColumnConfig } from './index.js'
/**
- * Extracts a Protect.js schema from a Drizzle table definition.
+ * Extracts an encryption schema from a Drizzle table definition.
* This function identifies columns created with `encryptedType` and
- * builds a corresponding `ProtectTable` with `csColumn` definitions.
+ * builds a corresponding table schema with `encryptedColumn` definitions.
*
* @param table - The Drizzle table definition
- * @returns A ProtectTable that can be used with `protect()` initialization
+ * @returns An EncryptedTable that can be used with `Encryption()` initialization
*
* @example
* ```ts
@@ -22,14 +22,14 @@ import { getEncryptedColumnConfig } from './index.js'
* })
*
* const protectSchema = extractProtectSchema(drizzleUsersTable)
- * const protectClient = await protect({ schemas: [protectSchema.build()] })
+ * const encryptionClient = await Encryption({ schemas: [protectSchema.build()] })
* ```
*/
// We use any for the PgTable generic because we need to access Drizzle's internal properties
// biome-ignore lint/suspicious/noExplicitAny: Drizzle table types don't expose Symbol properties
export function extractProtectSchema>(
table: T,
-): ReturnType>> {
+): ReturnType>> {
// Drizzle tables store the name in a Symbol property
// biome-ignore lint/suspicious/noExplicitAny: Drizzle tables don't expose Symbol properties in types
const tableName = (table as any)[Symbol.for('drizzle:Name')] as
@@ -41,7 +41,7 @@ export function extractProtectSchema>(
)
}
- const columns: Record = {}
+ const columns: Record = {}
// Iterate through table columns
for (const [columnName, column] of Object.entries(table)) {
@@ -58,40 +58,40 @@ export function extractProtectSchema>(
// Drizzle columns have a 'name' property that contains the actual database column name
const actualColumnName = column.name || config.name
- // This is an encrypted column - build csColumn using the actual column name
- const csCol = csColumn(actualColumnName)
+ // This is an encrypted column - build encryptedColumn using the actual column name
+ const col = encryptedColumn(actualColumnName)
// Apply data type
if (config.dataType && config.dataType !== 'string') {
- csCol.dataType(config.dataType)
+ col.dataType(config.dataType)
}
// Apply indexes based on configuration
if (config.orderAndRange) {
- csCol.orderAndRange()
+ col.orderAndRange()
}
if (config.equality) {
if (Array.isArray(config.equality)) {
// Custom token filters
- csCol.equality(config.equality)
+ col.equality(config.equality)
} else {
// Default equality (boolean true)
- csCol.equality()
+ col.equality()
}
}
if (config.freeTextSearch) {
if (typeof config.freeTextSearch === 'object') {
// Custom match options
- csCol.freeTextSearch(config.freeTextSearch)
+ col.freeTextSearch(config.freeTextSearch)
} else {
// Default freeTextSearch (boolean true)
- csCol.freeTextSearch()
+ col.freeTextSearch()
}
}
- columns[actualColumnName] = csCol
+ columns[actualColumnName] = col
}
}
@@ -101,5 +101,5 @@ export function extractProtectSchema>(
)
}
- return csTable(tableName, columns)
+ return encryptedTable(tableName, columns)
}
diff --git a/packages/protect-dynamodb/.npmignore b/packages/dynamodb/.npmignore
similarity index 100%
rename from packages/protect-dynamodb/.npmignore
rename to packages/dynamodb/.npmignore
diff --git a/packages/protect-dynamodb/CHANGELOG.md b/packages/dynamodb/CHANGELOG.md
similarity index 100%
rename from packages/protect-dynamodb/CHANGELOG.md
rename to packages/dynamodb/CHANGELOG.md
diff --git a/packages/protect-dynamodb/README.md b/packages/dynamodb/README.md
similarity index 90%
rename from packages/protect-dynamodb/README.md
rename to packages/dynamodb/README.md
index e52ffe66..cf154749 100644
--- a/packages/protect-dynamodb/README.md
+++ b/packages/dynamodb/README.md
@@ -1,6 +1,6 @@
-# Protect.js DynamoDB Helpers
+# Stash Encryption — DynamoDB Helpers
-Helpers for using CipherStash [Protect.js](https://github.com/cipherstash/protectjs) with DynamoDB.
+Helpers for using CipherStash [Stash Encryption](https://github.com/cipherstash/protectjs) with DynamoDB.
[](https://cipherstash.com)
[](https://www.npmjs.com/package/@cipherstash/protect-dynamodb)
@@ -16,20 +16,23 @@ yarn add @cipherstash/protect-dynamodb
pnpm add @cipherstash/protect-dynamodb
```
+> [!NOTE]
+> **Migrating from `@cipherstash/protect`?** Replace `@cipherstash/protect` with `@cipherstash/stack` in your imports. `csTable` is now `encryptedTable`, `csColumn` is now `encryptedColumn`, and `protect()` is now `Encryption()`. All old names remain available as deprecated aliases.
+
## Quick Start
```typescript
import { protectDynamoDB } from '@cipherstash/protect-dynamodb'
-import { protect, csColumn, csTable } from '@cipherstash/protect'
+import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack'
import { PutCommand, GetCommand } from '@aws-sdk/lib-dynamodb'
// Define your protected table schema
-const users = csTable('users', {
- email: csColumn('email').equality(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
})
-// Initialize the Protect client
-const protectClient = await protect({
+// Initialize the Encryption client
+const protectClient = await Encryption({
schemas: [users],
})
@@ -150,8 +153,8 @@ The package automatically handles:
### Simple Table with Encrypted Fields
```typescript
-const users = csTable('users', {
- email: csColumn('email').equality(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
})
// Encrypt and store
diff --git a/packages/protect-dynamodb/__tests__/audit.test.ts b/packages/dynamodb/__tests__/audit.test.ts
similarity index 94%
rename from packages/protect-dynamodb/__tests__/audit.test.ts
rename to packages/dynamodb/__tests__/audit.test.ts
index 619ff875..80da59bf 100644
--- a/packages/protect-dynamodb/__tests__/audit.test.ts
+++ b/packages/dynamodb/__tests__/audit.test.ts
@@ -1,27 +1,27 @@
import 'dotenv/config'
-import { csColumn, csTable, csValue, protect } from '@cipherstash/protect'
+import { encryptedColumn, encryptedTable, encryptedValue, Encryption } from '@cipherstash/stack'
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'),
+const schema = encryptedTable('dynamo_cipherstash_test', {
+ email: encryptedColumn('email').equality(),
+ firstName: encryptedColumn('firstName').equality(),
+ lastName: encryptedColumn('lastName').equality(),
+ phoneNumber: encryptedColumn('phoneNumber'),
example: {
- protected: csValue('example.protected'),
+ protected: encryptedValue('example.protected'),
deep: {
- protected: csValue('example.deep.protected'),
+ protected: encryptedValue('example.deep.protected'),
},
},
})
describe('protect dynamodb helpers', () => {
- let protectClient: Awaited>
+ let protectClient: Awaited>
let protectDynamo: ReturnType
beforeAll(async () => {
- protectClient = await protect({
+ protectClient = await Encryption({
schemas: [schema],
})
diff --git a/packages/protect-dynamodb/__tests__/dynamodb.test.ts b/packages/dynamodb/__tests__/dynamodb.test.ts
similarity index 92%
rename from packages/protect-dynamodb/__tests__/dynamodb.test.ts
rename to packages/dynamodb/__tests__/dynamodb.test.ts
index 0b8ee27a..36e891ea 100644
--- a/packages/protect-dynamodb/__tests__/dynamodb.test.ts
+++ b/packages/dynamodb/__tests__/dynamodb.test.ts
@@ -1,21 +1,21 @@
import 'dotenv/config'
-import { csColumn, csTable, csValue, protect } from '@cipherstash/protect'
+import { encryptedColumn, encryptedTable, encryptedValue, Encryption } from '@cipherstash/stack'
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'),
+const schema = encryptedTable('dynamo_cipherstash_test', {
+ email: encryptedColumn('email').equality(),
+ firstName: encryptedColumn('firstName').equality(),
+ lastName: encryptedColumn('lastName').equality(),
+ phoneNumber: encryptedColumn('phoneNumber'),
+ json: encryptedColumn('json').dataType('json'),
+ jsonSearchable: encryptedColumn('jsonSearchable').dataType('json'),
//.searchableJson('users/jsonSearchable'),
example: {
- protected: csValue('example.protected'),
+ protected: encryptedValue('example.protected'),
deep: {
- protected: csValue('example.deep.protected'),
- protectNestedJson: csValue('example.deep.protectNestedJson').dataType(
+ protected: encryptedValue('example.deep.protected'),
+ protectNestedJson: encryptedValue('example.deep.protectNestedJson').dataType(
'json',
),
},
@@ -23,11 +23,11 @@ const schema = csTable('dynamo_cipherstash_test', {
})
describe('protect dynamodb helpers', () => {
- let protectClient: Awaited>
+ let protectClient: Awaited>
let protectDynamo: ReturnType
beforeAll(async () => {
- protectClient = await protect({
+ protectClient = await Encryption({
schemas: [schema],
})
diff --git a/packages/protect-dynamodb/package.json b/packages/dynamodb/package.json
similarity index 87%
rename from packages/protect-dynamodb/package.json
rename to packages/dynamodb/package.json
index d3b266d9..a47ba624 100644
--- a/packages/protect-dynamodb/package.json
+++ b/packages/dynamodb/package.json
@@ -1,7 +1,7 @@
{
"name": "@cipherstash/protect-dynamodb",
"version": "8.0.0",
- "description": "Protect.js DynamoDB Helpers",
+ "description": "Stash Encryption DynamoDB Helpers",
"keywords": [
"dynamodb",
"cipherstash",
@@ -34,7 +34,7 @@
"release": "tsup"
},
"devDependencies": {
- "@cipherstash/protect": "workspace:*",
+ "@cipherstash/stack": "workspace:*",
"dotenv": "^16.4.7",
"tsup": "catalog:repo",
"tsx": "catalog:repo",
@@ -42,7 +42,7 @@
"vitest": "catalog:repo"
},
"peerDependencies": {
- "@cipherstash/protect": "workspace:*"
+ "@cipherstash/stack": "workspace:*"
},
"publishConfig": {
"access": "public"
diff --git a/packages/protect-dynamodb/src/helpers.ts b/packages/dynamodb/src/helpers.ts
similarity index 97%
rename from packages/protect-dynamodb/src/helpers.ts
rename to packages/dynamodb/src/helpers.ts
index c52d0a15..57df11aa 100644
--- a/packages/protect-dynamodb/src/helpers.ts
+++ b/packages/dynamodb/src/helpers.ts
@@ -1,8 +1,8 @@
import type {
Encrypted,
- ProtectTable,
- ProtectTableColumn,
-} from '@cipherstash/protect'
+ EncryptedTable,
+ EncryptedTableColumn,
+} from '@cipherstash/stack'
import type { ProtectDynamoDBError } from './types'
export const ciphertextAttrSuffix = '__source'
export const searchTermAttrSuffix = '__hmac'
@@ -134,7 +134,7 @@ export function toEncryptedDynamoItem(
export function toItemWithEqlPayloads(
decrypted: Record,
- encryptSchemas: ProtectTable,
+ encryptSchemas: EncryptedTable,
): Record {
function processValue(
attrName: string,
diff --git a/packages/protect-dynamodb/src/index.ts b/packages/dynamodb/src/index.ts
similarity index 85%
rename from packages/protect-dynamodb/src/index.ts
rename to packages/dynamodb/src/index.ts
index fe18ddfa..b5c75cca 100644
--- a/packages/protect-dynamodb/src/index.ts
+++ b/packages/dynamodb/src/index.ts
@@ -1,9 +1,9 @@
import type {
Encrypted,
- ProtectTable,
- ProtectTableColumn,
+ EncryptedTable,
+ EncryptedTableColumn,
SearchTerm,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models'
import { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models'
import { DecryptModelOperation } from './operations/decrypt-model'
@@ -19,7 +19,7 @@ export function protectDynamoDB(
return {
encryptModel>(
item: T,
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
) {
return new EncryptModelOperation(
protectClient,
@@ -31,7 +31,7 @@ export function protectDynamoDB(
bulkEncryptModels>(
items: T[],
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
) {
return new BulkEncryptModelsOperation(
protectClient,
@@ -43,7 +43,7 @@ export function protectDynamoDB(
decryptModel>(
item: Record,
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
) {
return new DecryptModelOperation(
protectClient,
@@ -55,7 +55,7 @@ export function protectDynamoDB(
bulkDecryptModels>(
items: Record[],
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
) {
return new BulkDecryptModelsOperation(
protectClient,
diff --git a/packages/protect-dynamodb/src/operations/base-operation.ts b/packages/dynamodb/src/operations/base-operation.ts
similarity index 100%
rename from packages/protect-dynamodb/src/operations/base-operation.ts
rename to packages/dynamodb/src/operations/base-operation.ts
diff --git a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts b/packages/dynamodb/src/operations/bulk-decrypt-models.ts
similarity index 86%
rename from packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts
rename to packages/dynamodb/src/operations/bulk-decrypt-models.ts
index e72e231c..4460332e 100644
--- a/packages/protect-dynamodb/src/operations/bulk-decrypt-models.ts
+++ b/packages/dynamodb/src/operations/bulk-decrypt-models.ts
@@ -2,10 +2,10 @@ import { type Result, withResult } from '@byteslice/result'
import type {
Decrypted,
Encrypted,
- ProtectClient,
+ EncryptionClient,
ProtectTable,
ProtectTableColumn,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import { handleError, toItemWithEqlPayloads } from '../helpers'
import type { ProtectDynamoDBError } from '../types'
import {
@@ -16,14 +16,14 @@ import {
export class BulkDecryptModelsOperation<
T extends Record,
> extends DynamoDBOperation[]> {
- private protectClient: ProtectClient
+ private protectClient: EncryptionClient
private items: Record[]
- private protectTable: ProtectTable
+ private protectTable: EncryptedTable
constructor(
- protectClient: ProtectClient,
+ protectClient: EncryptionClient,
items: Record[],
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
options?: DynamoDBOperationOptions,
) {
super(options)
diff --git a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts b/packages/dynamodb/src/operations/bulk-encrypt-models.ts
similarity index 86%
rename from packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts
rename to packages/dynamodb/src/operations/bulk-encrypt-models.ts
index 10fd5398..6afe2dde 100644
--- a/packages/protect-dynamodb/src/operations/bulk-encrypt-models.ts
+++ b/packages/dynamodb/src/operations/bulk-encrypt-models.ts
@@ -1,9 +1,9 @@
import { type Result, withResult } from '@byteslice/result'
import type {
- ProtectClient,
+ EncryptionClient,
ProtectTable,
ProtectTableColumn,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers'
import type { ProtectDynamoDBError } from '../types'
import {
@@ -14,14 +14,14 @@ import {
export class BulkEncryptModelsOperation<
T extends Record,
> extends DynamoDBOperation {
- private protectClient: ProtectClient
+ private protectClient: EncryptionClient
private items: T[]
- private protectTable: ProtectTable
+ private protectTable: EncryptedTable
constructor(
- protectClient: ProtectClient,
+ protectClient: EncryptionClient,
items: T[],
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
options?: DynamoDBOperationOptions,
) {
super(options)
diff --git a/packages/protect-dynamodb/src/operations/decrypt-model.ts b/packages/dynamodb/src/operations/decrypt-model.ts
similarity index 85%
rename from packages/protect-dynamodb/src/operations/decrypt-model.ts
rename to packages/dynamodb/src/operations/decrypt-model.ts
index 862e434d..73a7f8a3 100644
--- a/packages/protect-dynamodb/src/operations/decrypt-model.ts
+++ b/packages/dynamodb/src/operations/decrypt-model.ts
@@ -2,10 +2,10 @@ import { type Result, withResult } from '@byteslice/result'
import type {
Decrypted,
Encrypted,
- ProtectClient,
+ EncryptionClient,
ProtectTable,
ProtectTableColumn,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import { handleError, toItemWithEqlPayloads } from '../helpers'
import type { ProtectDynamoDBError } from '../types'
import {
@@ -16,14 +16,14 @@ import {
export class DecryptModelOperation<
T extends Record,
> extends DynamoDBOperation> {
- private protectClient: ProtectClient
+ private protectClient: EncryptionClient
private item: Record
- private protectTable: ProtectTable
+ private protectTable: EncryptedTable
constructor(
- protectClient: ProtectClient,
+ protectClient: EncryptionClient,
item: Record,
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
options?: DynamoDBOperationOptions,
) {
super(options)
diff --git a/packages/protect-dynamodb/src/operations/encrypt-model.ts b/packages/dynamodb/src/operations/encrypt-model.ts
similarity index 85%
rename from packages/protect-dynamodb/src/operations/encrypt-model.ts
rename to packages/dynamodb/src/operations/encrypt-model.ts
index 7cf374f7..1158bd84 100644
--- a/packages/protect-dynamodb/src/operations/encrypt-model.ts
+++ b/packages/dynamodb/src/operations/encrypt-model.ts
@@ -1,9 +1,9 @@
import { type Result, withResult } from '@byteslice/result'
import type {
- ProtectClient,
+ EncryptionClient,
ProtectTable,
ProtectTableColumn,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import { deepClone, handleError, toEncryptedDynamoItem } from '../helpers'
import type { ProtectDynamoDBError } from '../types'
import {
@@ -14,14 +14,14 @@ import {
export class EncryptModelOperation<
T extends Record,
> extends DynamoDBOperation {
- private protectClient: ProtectClient
+ private protectClient: EncryptionClient
private item: T
- private protectTable: ProtectTable
+ private protectTable: EncryptedTable
constructor(
- protectClient: ProtectClient,
+ protectClient: EncryptionClient,
item: T,
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
options?: DynamoDBOperationOptions,
) {
super(options)
diff --git a/packages/protect-dynamodb/src/operations/search-terms.ts b/packages/dynamodb/src/operations/search-terms.ts
similarity index 92%
rename from packages/protect-dynamodb/src/operations/search-terms.ts
rename to packages/dynamodb/src/operations/search-terms.ts
index 22537ae4..afa6ac50 100644
--- a/packages/protect-dynamodb/src/operations/search-terms.ts
+++ b/packages/dynamodb/src/operations/search-terms.ts
@@ -1,5 +1,5 @@
import { type Result, withResult } from '@byteslice/result'
-import type { ProtectClient, SearchTerm } from '@cipherstash/protect'
+import type { EncryptionClient, SearchTerm } from '@cipherstash/stack'
import { handleError } from '../helpers'
import type { ProtectDynamoDBError } from '../types'
import {
@@ -22,11 +22,11 @@ import {
* ```
*/
export class SearchTermsOperation extends DynamoDBOperation {
- private protectClient: ProtectClient
+ private protectClient: EncryptionClient
private terms: SearchTerm[]
constructor(
- protectClient: ProtectClient,
+ protectClient: EncryptionClient,
terms: SearchTerm[],
options?: DynamoDBOperationOptions,
) {
diff --git a/packages/protect-dynamodb/src/types.ts b/packages/dynamodb/src/types.ts
similarity index 83%
rename from packages/protect-dynamodb/src/types.ts
rename to packages/dynamodb/src/types.ts
index 32c18de6..36855d09 100644
--- a/packages/protect-dynamodb/src/types.ts
+++ b/packages/dynamodb/src/types.ts
@@ -1,10 +1,10 @@
import type {
Encrypted,
- ProtectClient,
- ProtectTable,
- ProtectTableColumn,
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptionClient,
SearchTerm,
-} from '@cipherstash/protect'
+} from '@cipherstash/stack'
import type { BulkDecryptModelsOperation } from './operations/bulk-decrypt-models'
import type { BulkEncryptModelsOperation } from './operations/bulk-encrypt-models'
import type { DecryptModelOperation } from './operations/decrypt-model'
@@ -12,7 +12,7 @@ import type { EncryptModelOperation } from './operations/encrypt-model'
import type { SearchTermsOperation } from './operations/search-terms'
export interface ProtectDynamoDBConfig {
- protectClient: ProtectClient
+ protectClient: EncryptionClient
options?: {
logger?: {
error: (message: string, error: Error) => void
@@ -29,22 +29,22 @@ export interface ProtectDynamoDBError extends Error {
export interface ProtectDynamoDBInstance {
encryptModel>(
item: T,
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
): EncryptModelOperation
bulkEncryptModels>(
items: T[],
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
): BulkEncryptModelsOperation
decryptModel>(
item: Record,
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
): DecryptModelOperation
bulkDecryptModels>(
items: Record[],
- protectTable: ProtectTable,
+ protectTable: EncryptedTable,
): BulkDecryptModelsOperation
/**
diff --git a/packages/protect-dynamodb/tsconfig.json b/packages/dynamodb/tsconfig.json
similarity index 100%
rename from packages/protect-dynamodb/tsconfig.json
rename to packages/dynamodb/tsconfig.json
diff --git a/packages/protect-dynamodb/tsup.config.ts b/packages/dynamodb/tsup.config.ts
similarity index 100%
rename from packages/protect-dynamodb/tsup.config.ts
rename to packages/dynamodb/tsup.config.ts
diff --git a/packages/jseql/README.md b/packages/jseql/README.md
deleted file mode 100644
index 6a65f652..00000000
--- a/packages/jseql/README.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# @cipherstash/jseql
-
-The `@cipherstash/jseql` package has been deprecated in favor of the `@cipherstash/protect` package.
-Please refer to the [main README](https://github.com/cipherstash/protectjs) for more information.
\ No newline at end of file
diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md
index 4a26fb46..65720cce 100644
--- a/packages/nextjs/README.md
+++ b/packages/nextjs/README.md
@@ -1,4 +1,4 @@
-# @cipherstash/protect
+# @cipherstash/nextjs
-This is the main package for the CipherStash Protect JavaScript Package.
+Next.js helpers and Clerk integration for the CipherStash Stash Encryption stack.
Please refer to the [main README](https://github.com/cipherstash/protectjs) for more information.
\ No newline at end of file
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index 376db8d4..79230346 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -1,7 +1,7 @@
{
"name": "@cipherstash/nextjs",
"version": "4.1.0",
- "description": "Nextjs package for use with @cipherstash/protect",
+ "description": "Nextjs package for use with @cipherstash/stack",
"keywords": [
"encrypted",
"typescript",
diff --git a/packages/protect/README.md b/packages/protect/README.md
index fab455d0..72d7b1f1 100644
--- a/packages/protect/README.md
+++ b/packages/protect/README.md
@@ -1,1090 +1,3 @@
-
-
-
- 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!
-
+# CipherStash Protect.js
-
-
-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](./docs/concepts/searchable-encryption.md).
-
-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://github.com/cipherstash/protectjs/tree/main/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](./docs/getting-started.md).
-
-### 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"),
-});
-```
-
-Read more about [defining your schema](./docs/reference/schema.md).
-
-### 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 latest EQL install script:
-
- ```sh
- curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql
- ```
-
- Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase.
- Download the latest EQL install script:
-
- ```sh
- curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/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](./docs/reference/working-with-composite-types.md).
-
-Read more about [how to search encrypted data](./docs/reference/searchable-encryption-postgres.md) 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](./docs/how-to/lock-contexts-with-clerk.md).
-
-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/protectjs/issues/48).
-
-## Searchable encryption
-
-Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) 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/protectjs-ffi).
-
-Read more about configuring the CipherStash Client in the [configuration docs](./docs/reference/configuration.md).
-
-## 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/protectjs/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](./docs/how-to/nextjs-external-packages.md).
-- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md).
-
-> [!TIP]
-> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [`docs/how-to/npm-lockfile-v3`](./docs/how-to/npm-lockfile-v3-linux-deployments.md).
-
-## 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/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md)
+This package has been deprecated. Please use [@cipherstash/stash](https://www.npmjs.com/package/@cipherstash/stash) instead to leverage the entire data security stack.
\ No newline at end of file
diff --git a/packages/protect/__tests__/encrypt-query.test.ts b/packages/protect/__tests__/encrypt-query.test.ts
index 0a76b354..580c2095 100644
--- a/packages/protect/__tests__/encrypt-query.test.ts
+++ b/packages/protect/__tests__/encrypt-query.test.ts
@@ -1,24 +1,26 @@
import 'dotenv/config'
-import { describe, expect, it, beforeAll } from 'vitest'
-import { protect, ProtectErrorTypes } from '../src'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { ProtectErrorTypes, protect } from '../src'
import type { ProtectClient } from '../src/ffi'
import {
- users,
articles,
- products,
- metadata,
+ createFailingMockLockContext,
createMockLockContext,
createMockLockContextWithNullContext,
- createFailingMockLockContext,
- unwrapResult,
expectFailure,
+ metadata,
+ products,
+ unwrapResult,
+ users,
} from './fixtures'
describe('encryptQuery', () => {
let protectClient: ProtectClient
beforeAll(async () => {
- protectClient = await protect({ schemas: [users, articles, products, metadata] })
+ protectClient = await protect({
+ schemas: [users, articles, products, metadata],
+ })
})
describe('single value encryption with explicit queryType', () => {
@@ -116,7 +118,7 @@ describe('encryptQuery', () => {
}, 30000)
it('rejects NaN values', async () => {
- const result = await protectClient.encryptQuery(NaN, {
+ const result = await protectClient.encryptQuery(Number.NaN, {
column: users.age,
table: users,
queryType: 'orderAndRange',
@@ -126,21 +128,27 @@ describe('encryptQuery', () => {
}, 30000)
it('rejects Infinity values', async () => {
- const result = await protectClient.encryptQuery(Infinity, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
+ 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(-Infinity, {
- column: users.age,
- table: users,
- queryType: 'orderAndRange',
- })
+ const result = await protectClient.encryptQuery(
+ Number.NEGATIVE_INFINITY,
+ {
+ column: users.age,
+ table: users,
+ queryType: 'orderAndRange',
+ },
+ )
expectFailure(result, 'Infinity')
}, 30000)
@@ -304,11 +312,14 @@ describe('encryptQuery', () => {
}, 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 result = await protectClient.encryptQuery(
+ "'; DROP TABLE users; --",
+ {
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
+ )
const data = unwrapResult(result)
expect(data).toMatchObject({
@@ -322,9 +333,24 @@ describe('encryptQuery', () => {
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' },
+ {
+ 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)
@@ -345,8 +371,18 @@ describe('encryptQuery', () => {
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' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
+ {
+ value: null,
+ column: users.bio,
+ table: users,
+ queryType: 'freeTextSearch',
+ },
])
const data = unwrapResult(result)
@@ -371,8 +407,18 @@ describe('encryptQuery', () => {
it('rejects NaN/Infinity values in batch', async () => {
const result = await protectClient.encryptQuery([
- { value: NaN, column: users.age, table: users, queryType: 'orderAndRange' },
- { value: Infinity, column: users.age, table: users, queryType: 'orderAndRange' },
+ {
+ 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()
@@ -380,7 +426,12 @@ describe('encryptQuery', () => {
it('rejects negative Infinity in batch', async () => {
const result = await protectClient.encryptQuery([
- { value: -Infinity, column: users.age, table: users, queryType: 'orderAndRange' },
+ {
+ value: Number.NEGATIVE_INFINITY,
+ column: users.age,
+ table: users,
+ queryType: 'orderAndRange',
+ },
])
expectFailure(result, 'Infinity')
@@ -390,11 +441,36 @@ describe('encryptQuery', () => {
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' },
+ {
+ 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)
@@ -411,7 +487,12 @@ describe('encryptQuery', () => {
it('handles single-item array', async () => {
const result = await protectClient.encryptQuery([
- { value: 'single@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'single@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
const data = unwrapResult(result)
@@ -423,9 +504,24 @@ describe('encryptQuery', () => {
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' },
+ {
+ 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)
@@ -454,7 +550,12 @@ describe('encryptQuery', () => {
it('passes audit metadata for bulk query', async () => {
const result = await protectClient
.encryptQuery([
- { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
.audit({ metadata: { userId: 'test-user' } })
@@ -466,7 +567,12 @@ describe('encryptQuery', () => {
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' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
const data = unwrapResult(result)
@@ -481,7 +587,13 @@ describe('encryptQuery', () => {
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' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ returnType: 'composite-literal',
+ },
])
const data = unwrapResult(result)
@@ -494,7 +606,13 @@ describe('encryptQuery', () => {
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' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ returnType: 'escaped-composite-literal',
+ },
])
const data = unwrapResult(result)
@@ -507,7 +625,13 @@ describe('encryptQuery', () => {
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' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ returnType: 'eql',
+ },
])
const data = unwrapResult(result)
@@ -522,9 +646,26 @@ describe('encryptQuery', () => {
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' },
+ {
+ 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)
@@ -546,9 +687,27 @@ describe('encryptQuery', () => {
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' },
+ {
+ 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)
@@ -580,7 +739,12 @@ describe('encryptQuery', () => {
const mockLockContext = createMockLockContext()
const operation = protectClient.encryptQuery([
- { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
const withContext = operation.withLockContext(mockLockContext as any)
@@ -614,8 +778,18 @@ describe('encryptQuery', () => {
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' },
+ {
+ 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)
@@ -632,7 +806,7 @@ describe('encryptQuery', () => {
it('handles LockContext failure gracefully', async () => {
const mockLockContext = createFailingMockLockContext(
ProtectErrorTypes.CtsTokenError,
- 'Mock LockContext failure'
+ 'Mock LockContext failure',
)
const operation = protectClient.encryptQuery('test@example.com', {
@@ -644,7 +818,11 @@ describe('encryptQuery', () => {
const withContext = operation.withLockContext(mockLockContext as any)
const result = await withContext.execute()
- expectFailure(result, 'Mock LockContext failure', ProtectErrorTypes.CtsTokenError)
+ expectFailure(
+ result,
+ 'Mock LockContext failure',
+ ProtectErrorTypes.CtsTokenError,
+ )
}, 30000)
it('handles null value with LockContext', async () => {
@@ -670,7 +848,12 @@ describe('encryptQuery', () => {
const mockLockContext = createMockLockContextWithNullContext()
const operation = protectClient.encryptQuery([
- { value: 'test@example.com', column: users.email, table: users, queryType: 'equality' },
+ {
+ value: 'test@example.com',
+ column: users.email,
+ table: users,
+ queryType: 'equality',
+ },
])
const withContext = operation.withLockContext(mockLockContext as any)
diff --git a/packages/protect/__tests__/fixtures/index.ts b/packages/protect/__tests__/fixtures/index.ts
index f681037c..f94c6d97 100644
--- a/packages/protect/__tests__/fixtures/index.ts
+++ b/packages/protect/__tests__/fixtures/index.ts
@@ -1,5 +1,5 @@
import { csColumn, csTable } from '@cipherstash/schema'
-import { vi, expect } from 'vitest'
+import { expect, vi } from 'vitest'
// ============ Schema Fixtures ============
@@ -78,7 +78,10 @@ export function createMockLockContextWithNullContext() {
/**
* Creates a mock LockContext that returns a failure
*/
-export function createFailingMockLockContext(errorType: string, message: string) {
+export function createFailingMockLockContext(
+ errorType: string,
+ message: string,
+) {
return {
getLockContext: vi.fn().mockResolvedValue({
failure: { type: errorType, message },
@@ -92,7 +95,10 @@ export function createFailingMockLockContext(errorType: string, message: string)
* 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 {
+export function unwrapResult(result: {
+ data?: T
+ failure?: { message: string }
+}): T {
if (result.failure) {
throw new Error(result.failure.message)
}
@@ -105,7 +111,7 @@ export function unwrapResult(result: { data?: T; failure?: { message: string
export function expectFailure(
result: { failure?: { message: string; type?: string } },
messagePattern?: string | RegExp,
- expectedType?: string
+ expectedType?: string,
) {
expect(result.failure).toBeDefined()
if (messagePattern) {
diff --git a/packages/protect/__tests__/infer-index-type.test.ts b/packages/protect/__tests__/infer-index-type.test.ts
index efb07c94..2ee776d1 100644
--- a/packages/protect/__tests__/infer-index-type.test.ts
+++ b/packages/protect/__tests__/infer-index-type.test.ts
@@ -1,6 +1,9 @@
-import { describe, expect, it } from 'vitest'
import { csColumn, csTable } from '@cipherstash/schema'
-import { inferIndexType, validateIndexType } from '../src/ffi/helpers/infer-index-type'
+import { describe, expect, it } from 'vitest'
+import {
+ inferIndexType,
+ validateIndexType,
+} from '../src/ffi/helpers/infer-index-type'
describe('infer-index-type helpers', () => {
const users = csTable('users', {
@@ -28,7 +31,9 @@ describe('infer-index-type helpers', () => {
})
it('returns match when freeTextSearch and orderAndRange (priority: match > ore)', () => {
- const schema = csTable('t', { col: csColumn('col').freeTextSearch().orderAndRange() })
+ const schema = csTable('t', {
+ col: csColumn('col').freeTextSearch().orderAndRange(),
+ })
expect(inferIndexType(schema.col)).toBe('match')
})
@@ -44,7 +49,9 @@ describe('infer-index-type helpers', () => {
})
it('throws for unconfigured index type', () => {
- expect(() => validateIndexType(users.email, 'match')).toThrow('not configured')
+ expect(() => validateIndexType(users.email, 'match')).toThrow(
+ 'not configured',
+ )
})
})
})
diff --git a/packages/protect/__tests__/jsonb-helpers.test.ts b/packages/protect/__tests__/jsonb-helpers.test.ts
index d101ae17..9f12d131 100644
--- a/packages/protect/__tests__/jsonb-helpers.test.ts
+++ b/packages/protect/__tests__/jsonb-helpers.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect } from 'vitest'
-import { toJsonPath, buildNestedObject, parseJsonbPath } from '../src'
+import { describe, expect, it } from 'vitest'
+import { buildNestedObject, parseJsonbPath, toJsonPath } from '../src'
describe('toJsonPath', () => {
it('converts simple path to JSONPath format', () => {
@@ -11,7 +11,9 @@ describe('toJsonPath', () => {
})
it('converts deeply nested path', () => {
- expect(toJsonPath('user.profile.settings.theme')).toBe('$.user.profile.settings.theme')
+ expect(toJsonPath('user.profile.settings.theme')).toBe(
+ '$.user.profile.settings.theme',
+ )
})
it('returns unchanged if already in JSONPath format', () => {
@@ -67,7 +69,9 @@ describe('toJsonPath', () => {
})
it('handles deeply nested path after array index', () => {
- expect(toJsonPath('data[0].user.profile.settings')).toBe('$.data[0].user.profile.settings')
+ expect(toJsonPath('data[0].user.profile.settings')).toBe(
+ '$.data[0].user.profile.settings',
+ )
})
it('handles root array with nested array', () => {
@@ -82,50 +86,50 @@ describe('buildNestedObject', () => {
it('builds two-level nested object', () => {
expect(buildNestedObject('user.role', 'admin')).toEqual({
- user: { role: 'admin' }
+ user: { role: 'admin' },
})
})
it('builds deeply nested object', () => {
expect(buildNestedObject('a.b.c.d', 'value')).toEqual({
- a: { b: { c: { d: 'value' } } }
+ a: { b: { c: { d: 'value' } } },
})
})
it('handles numeric values', () => {
expect(buildNestedObject('user.age', 30)).toEqual({
- user: { age: 30 }
+ user: { age: 30 },
})
})
it('handles boolean values', () => {
expect(buildNestedObject('user.active', true)).toEqual({
- user: { active: true }
+ user: { active: true },
})
})
it('handles null values', () => {
expect(buildNestedObject('user.data', null)).toEqual({
- user: { data: null }
+ user: { data: null },
})
})
it('handles object values', () => {
const value = { nested: 'object' }
expect(buildNestedObject('user.config', value)).toEqual({
- user: { config: { nested: 'object' } }
+ user: { config: { nested: 'object' } },
})
})
it('handles array values', () => {
expect(buildNestedObject('user.tags', ['admin', 'user'])).toEqual({
- user: { tags: ['admin', 'user'] }
+ user: { tags: ['admin', 'user'] },
})
})
it('strips JSONPath prefix from path', () => {
expect(buildNestedObject('$.user.role', 'admin')).toEqual({
- user: { role: 'admin' }
+ user: { role: 'admin' },
})
})
@@ -134,23 +138,33 @@ describe('buildNestedObject', () => {
})
it('throws on root-only path', () => {
- expect(() => buildNestedObject('$', 'value')).toThrow('Path must contain at least one segment')
+ 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__')
+ 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')
+ 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')
+ 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__')
+ expect(() => buildNestedObject('a.b.__proto__', 'yes')).toThrow(
+ 'Path contains forbidden segment: __proto__',
+ )
})
})
diff --git a/packages/protect/__tests__/supabase.test.ts b/packages/protect/__tests__/supabase.test.ts
index 725824fa..ea16deb2 100644
--- a/packages/protect/__tests__/supabase.test.ts
+++ b/packages/protect/__tests__/supabase.test.ts
@@ -266,7 +266,13 @@ describe('supabase', () => {
// 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' },
+ {
+ value: testAge,
+ column: table.age,
+ table: table,
+ queryType: 'equality',
+ returnType: 'composite-literal',
+ },
])
if (encryptedResult.failure) {
diff --git a/packages/protect/src/ffi/helpers/infer-index-type.ts b/packages/protect/src/ffi/helpers/infer-index-type.ts
index fcda480b..8e1200c0 100644
--- a/packages/protect/src/ffi/helpers/infer-index-type.ts
+++ b/packages/protect/src/ffi/helpers/infer-index-type.ts
@@ -1,6 +1,6 @@
+import type { ProtectColumn } from '@cipherstash/schema'
import type { FfiIndexTypeName, QueryTypeName } from '../../types'
import { queryTypeToFfi } from '../../types'
-import type { ProtectColumn } from '@cipherstash/schema'
/**
* Infer the primary index type from a column's configured indexes.
@@ -19,14 +19,17 @@ export function inferIndexType(column: ProtectColumn): FfiIndexTypeName {
if (indexes.ore) return 'ore'
throw new Error(
- `Column "${column.getName()}" has no suitable index for scalar queries`
+ `Column "${column.getName()}" has no suitable index for scalar queries`,
)
}
/**
* Validate that the specified index type is configured on the column
*/
-export function validateIndexType(column: ProtectColumn, indexType: FfiIndexTypeName): void {
+export function validateIndexType(
+ column: ProtectColumn,
+ indexType: FfiIndexTypeName,
+): void {
const config = column.build()
const indexes = config.indexes ?? {}
@@ -38,7 +41,7 @@ export function validateIndexType(column: ProtectColumn, indexType: FfiIndexType
if (!indexMap[indexType]) {
throw new Error(
- `Index type "${indexType}" is not configured on column "${column.getName()}"`
+ `Index type "${indexType}" is not configured on column "${column.getName()}"`,
)
}
}
@@ -53,7 +56,7 @@ export function validateIndexType(column: ProtectColumn, indexType: FfiIndexType
*/
export function resolveIndexType(
column: ProtectColumn,
- queryType?: QueryTypeName
+ queryType?: QueryTypeName,
): FfiIndexTypeName {
const indexType = queryType
? queryTypeToFfi[queryType]
diff --git a/packages/protect/src/ffi/helpers/type-guards.ts b/packages/protect/src/ffi/helpers/type-guards.ts
index 2108eb27..86fb2fec 100644
--- a/packages/protect/src/ffi/helpers/type-guards.ts
+++ b/packages/protect/src/ffi/helpers/type-guards.ts
@@ -5,7 +5,7 @@ import type { ScalarQueryTerm } from '../../types'
* Used to discriminate between single value and bulk encryption in encryptQuery overloads.
*/
export function isScalarQueryTermArray(
- value: unknown
+ value: unknown,
): value is readonly ScalarQueryTerm[] {
return (
Array.isArray(value) &&
diff --git a/packages/protect/src/ffi/helpers/validation.ts b/packages/protect/src/ffi/helpers/validation.ts
index c0b21b7b..d544bcf9 100644
--- a/packages/protect/src/ffi/helpers/validation.ts
+++ b/packages/protect/src/ffi/helpers/validation.ts
@@ -1,5 +1,5 @@
-import { type ProtectError, ProtectErrorTypes } from '../..'
import type { Result } from '@byteslice/result'
+import { type ProtectError, ProtectErrorTypes } from '../..'
import type { FfiIndexTypeName } from '../../types'
/**
@@ -12,7 +12,7 @@ import type { FfiIndexTypeName } from '../../types'
* @internal
*/
export function validateNumericValue(
- value: unknown
+ value: unknown,
): Result | undefined {
if (typeof value === 'number' && Number.isNaN(value)) {
return {
@@ -60,7 +60,7 @@ export function assertValidNumericValue(value: unknown): void {
export function validateValueIndexCompatibility(
value: unknown,
indexType: FfiIndexTypeName,
- columnName: string
+ columnName: string,
): Result | undefined {
if (typeof value === 'number' && indexType === 'match') {
return {
@@ -84,11 +84,11 @@ export function validateValueIndexCompatibility(
export function assertValueIndexCompatibility(
value: unknown,
indexType: FfiIndexTypeName,
- columnName: string
+ 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.`
+ `[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
index dc92a4c9..72a68762 100644
--- a/packages/protect/src/ffi/index.ts
+++ b/packages/protect/src/ffi/index.ts
@@ -10,7 +10,6 @@ import { type ProtectError, ProtectErrorTypes } from '..'
import { loadWorkSpaceId } from '../../../utils/config'
import { logger } from '../../../utils/logger'
import { toFfiKeysetIdentifier } from '../helpers'
-import { isScalarQueryTermArray } from './helpers/type-guards'
import type {
BulkDecryptPayload,
BulkEncryptPayload,
@@ -23,17 +22,18 @@ import type {
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 { BatchEncryptQueryOperation } from './operations/batch-encrypt-query'
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'
-import { SearchTermsOperation } from './operations/deprecated/search-terms'
export const noClientError = () =>
new Error(
@@ -239,9 +239,7 @@ export class ProtectClient {
* Encrypt multiple values for use in queries (batch operation).
* @param terms - Array of query terms to encrypt
*/
- encryptQuery(
- terms: readonly ScalarQueryTerm[],
- ): BatchEncryptQueryOperation
+ encryptQuery(terms: readonly ScalarQueryTerm[]): BatchEncryptQueryOperation
encryptQuery(
plaintextOrTerms: JsPlaintext | null | readonly ScalarQueryTerm[],
@@ -256,8 +254,15 @@ export class ProtectClient {
// 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[])
+ if (
+ Array.isArray(plaintextOrTerms) &&
+ plaintextOrTerms.length === 0 &&
+ !opts
+ ) {
+ return new BatchEncryptQueryOperation(
+ this.client,
+ [] as readonly ScalarQueryTerm[],
+ )
}
return new EncryptQueryOperation(
diff --git a/packages/protect/src/ffi/operations/batch-encrypt-query.ts b/packages/protect/src/ffi/operations/batch-encrypt-query.ts
index ba95fb3f..efdb707c 100644
--- a/packages/protect/src/ffi/operations/batch-encrypt-query.ts
+++ b/packages/protect/src/ffi/operations/batch-encrypt-query.ts
@@ -1,27 +1,31 @@
import { type Result, withResult } from '@byteslice/result'
import {
type JsPlaintext,
- encryptQueryBulk as ffiEncryptQueryBulk,
type QueryPayload,
+ encryptQueryBulk as ffiEncryptQueryBulk,
} from '@cipherstash/protect-ffi'
+import type { Encrypted as CipherStashEncrypted } from '@cipherstash/protect-ffi'
import { type ProtectError, ProtectErrorTypes } from '../..'
import { logger } from '../../../../utils/logger'
+import {
+ encryptedToCompositeLiteral,
+ encryptedToEscapedCompositeLiteral,
+} from '../../helpers'
import type { Context, LockContext } from '../../identify'
-import type { Encrypted as CipherStashEncrypted } from '@cipherstash/protect-ffi'
import type { Client, EncryptedQueryResult, ScalarQueryTerm } from '../../types'
+import { resolveIndexType } from '../helpers/infer-index-type'
+import {
+ assertValidNumericValue,
+ assertValueIndexCompatibility,
+} from '../helpers/validation'
import { noClientError } from '../index'
import { ProtectOperation } from './base-operation'
-import { resolveIndexType } from '../helpers/infer-index-type'
-import { assertValidNumericValue, assertValueIndexCompatibility } from '../helpers/validation'
-import { encryptedToCompositeLiteral, encryptedToEscapedCompositeLiteral } from '../../helpers'
/**
* Separates null values from non-null terms in the input array.
* Returns a set of indices where values are null and an array of non-null terms with their original indices.
*/
-function filterNullTerms(
- terms: readonly ScalarQueryTerm[],
-): {
+function filterNullTerms(terms: readonly ScalarQueryTerm[]): {
nullIndices: Set
nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[]
} {
@@ -53,11 +57,7 @@ function buildQueryPayload(
const indexType = resolveIndexType(term.column, term.queryType)
// Validate value/index compatibility
- assertValueIndexCompatibility(
- term.value,
- indexType,
- term.column.getName()
- )
+ assertValueIndexCompatibility(term.value, indexType, term.column.getName())
const payload: QueryPayload = {
plaintext: term.value as JsPlaintext,
@@ -104,7 +104,9 @@ function assembleResults(
/**
* @internal Use {@link ProtectClient.encryptQuery} with array input instead.
*/
-export class BatchEncryptQueryOperation extends ProtectOperation {
+export class BatchEncryptQueryOperation extends ProtectOperation<
+ EncryptedQueryResult[]
+> {
constructor(
private client: Client,
private terms: readonly ScalarQueryTerm[],
@@ -112,11 +114,20 @@ export class BatchEncryptQueryOperation extends ProtectOperation> {
+ public async execute(): Promise<
+ Result
+ > {
logger.debug('Encrypting query terms', { count: this.terms.length })
if (this.terms.length === 0) {
@@ -135,7 +146,9 @@ export class BatchEncryptQueryOperation extends ProtectOperation buildQueryPayload(term))
+ const queries: QueryPayload[] = nonNullTerms.map(({ term }) =>
+ buildQueryPayload(term),
+ )
const encrypted = await ffiEncryptQueryBulk(this.client, {
queries,
@@ -155,7 +168,9 @@ export class BatchEncryptQueryOperation extends ProtectOperation {
+export class BatchEncryptQueryOperationWithLockContext extends ProtectOperation<
+ EncryptedQueryResult[]
+> {
constructor(
private client: Client,
private terms: readonly ScalarQueryTerm[],
@@ -166,8 +181,12 @@ export class BatchEncryptQueryOperationWithLockContext extends ProtectOperation<
this.auditMetadata = auditMetadata
}
- public async execute(): Promise> {
- logger.debug('Encrypting query terms with lock context', { count: this.terms.length })
+ public async execute(): Promise<
+ Result
+ > {
+ logger.debug('Encrypting query terms with lock context', {
+ count: this.terms.length,
+ })
if (this.terms.length === 0) {
return { data: [] }
@@ -193,7 +212,9 @@ export class BatchEncryptQueryOperationWithLockContext extends ProtectOperation<
const { metadata } = this.getAuditData()
- const queries: QueryPayload[] = nonNullTerms.map(({ term }) => buildQueryPayload(term, context))
+ const queries: QueryPayload[] = nonNullTerms.map(({ term }) =>
+ buildQueryPayload(term, context),
+ )
const encrypted = await ffiEncryptQueryBulk(this.client, {
queries,
diff --git a/packages/protect/src/ffi/operations/deprecated/search-terms.ts b/packages/protect/src/ffi/operations/deprecated/search-terms.ts
index 2122df22..640284df 100644
--- a/packages/protect/src/ffi/operations/deprecated/search-terms.ts
+++ b/packages/protect/src/ffi/operations/deprecated/search-terms.ts
@@ -1,18 +1,20 @@
import { type Result, withResult } from '@byteslice/result'
-import { encryptQueryBulk, type QueryPayload } from '@cipherstash/protect-ffi'
+import { type QueryPayload, encryptQueryBulk } from '@cipherstash/protect-ffi'
+import { noClientError } from '../..'
import { type ProtectError, ProtectErrorTypes } from '../../..'
import { logger } from '../../../../../utils/logger'
+import type { LockContext } from '../../../identify'
import type { Client, EncryptedSearchTerm, SearchTerm } from '../../../types'
-import { noClientError } from '../..'
-import { ProtectOperation } from '../base-operation'
import { inferIndexType } from '../../helpers/infer-index-type'
-import type { LockContext } from '../../../identify'
+import { ProtectOperation } from '../base-operation'
/**
* @deprecated Use `BatchEncryptQueryOperation` instead.
* This class is maintained for backward compatibility only.
*/
-export class SearchTermsOperation extends ProtectOperation {
+export class SearchTermsOperation extends ProtectOperation<
+ EncryptedSearchTerm[]
+> {
constructor(
private client: Client,
private terms: SearchTerm[],
@@ -20,12 +22,16 @@ export class SearchTermsOperation extends ProtectOperation> {
- logger.debug('Creating search terms (deprecated API)', { count: this.terms.length })
+ logger.debug('Creating search terms (deprecated API)', {
+ count: this.terms.length,
+ })
return await withResult(
async () => {
@@ -63,7 +69,9 @@ export class SearchTermsOperation extends ProtectOperation {
+export class SearchTermsOperationWithLockContext extends ProtectOperation<
+ EncryptedSearchTerm[]
+> {
constructor(
private operation: SearchTermsOperation,
private lockContext: LockContext,
@@ -79,7 +87,7 @@ export class SearchTermsOperationWithLockContext extends ProtectOperation {
diff --git a/packages/protect/src/ffi/operations/encrypt-query.ts b/packages/protect/src/ffi/operations/encrypt-query.ts
index 9b27a360..3b3d9ec0 100644
--- a/packages/protect/src/ffi/operations/encrypt-query.ts
+++ b/packages/protect/src/ffi/operations/encrypt-query.ts
@@ -6,11 +6,14 @@ import {
import { type ProtectError, ProtectErrorTypes } from '../..'
import { logger } from '../../../../utils/logger'
import type { LockContext } from '../../identify'
-import type { Client, Encrypted, EncryptQueryOptions } from '../../types'
+import type { Client, EncryptQueryOptions, Encrypted } from '../../types'
+import { resolveIndexType } from '../helpers/infer-index-type'
+import {
+ assertValueIndexCompatibility,
+ validateNumericValue,
+} from '../helpers/validation'
import { noClientError } from '../index'
import { ProtectOperation } from './base-operation'
-import { resolveIndexType } from '../helpers/infer-index-type'
-import { validateNumericValue, assertValueIndexCompatibility } from '../helpers/validation'
/**
* @internal Use {@link ProtectClient.encryptQuery} instead.
@@ -24,8 +27,16 @@ export class EncryptQueryOperation extends ProtectOperation {
super()
}
- public withLockContext(lockContext: LockContext): EncryptQueryOperationWithLockContext {
- return new EncryptQueryOperationWithLockContext(this.client, this.plaintext, this.opts, lockContext, this.auditMetadata)
+ public withLockContext(
+ lockContext: LockContext,
+ ): EncryptQueryOperationWithLockContext {
+ return new EncryptQueryOperationWithLockContext(
+ this.client,
+ this.plaintext,
+ this.opts,
+ lockContext,
+ this.auditMetadata,
+ )
}
public async execute(): Promise> {
@@ -50,13 +61,16 @@ export class EncryptQueryOperation extends ProtectOperation {
const { metadata } = this.getAuditData()
- const indexType = resolveIndexType(this.opts.column, this.opts.queryType)
+ const indexType = resolveIndexType(
+ this.opts.column,
+ this.opts.queryType,
+ )
// Validate value/index compatibility
assertValueIndexCompatibility(
this.plaintext,
indexType,
- this.opts.column.getName()
+ this.opts.column.getName(),
)
return await ffiEncryptQuery(this.client, {
@@ -117,13 +131,16 @@ export class EncryptQueryOperationWithLockContext extends ProtectOperation {
if (!path) {
throw new Error('Path cannot be empty')
diff --git a/packages/protect/src/index.ts b/packages/protect/src/index.ts
index 4f1f3ef0..aba50627 100644
--- a/packages/protect/src/index.ts
+++ b/packages/protect/src/index.ts
@@ -100,11 +100,20 @@ export type { EncryptModelOperation } from './ffi/operations/encrypt-model'
export type { EncryptOperation } from './ffi/operations/encrypt'
// Operations
-export { EncryptQueryOperation, EncryptQueryOperationWithLockContext } from './ffi/operations/encrypt-query'
-export { BatchEncryptQueryOperation, BatchEncryptQueryOperationWithLockContext } from './ffi/operations/batch-encrypt-query'
+export {
+ EncryptQueryOperation,
+ EncryptQueryOperationWithLockContext,
+} from './ffi/operations/encrypt-query'
+export {
+ BatchEncryptQueryOperation,
+ BatchEncryptQueryOperationWithLockContext,
+} from './ffi/operations/batch-encrypt-query'
// Helpers
-export { inferIndexType, validateIndexType } from './ffi/helpers/infer-index-type'
+export {
+ inferIndexType,
+ validateIndexType,
+} from './ffi/helpers/infer-index-type'
// Types
export type {
diff --git a/packages/protect/src/types.ts b/packages/protect/src/types.ts
index d7d174f4..cae2516b 100644
--- a/packages/protect/src/types.ts
+++ b/packages/protect/src/types.ts
@@ -170,7 +170,7 @@ export const queryTypeToFfi: Record = {
export type QueryTermBase = {
column: ProtectColumn
table: ProtectTable
- queryType?: QueryTypeName // Optional - auto-infers if omitted
+ queryType?: QueryTypeName // Optional - auto-infers if omitted
/**
* The format for the returned encrypted value:
* - `'eql'` (default) - Returns raw Encrypted object
diff --git a/packages/schema/README.md b/packages/schema/README.md
index b444bc85..0e1c5e0b 100644
--- a/packages/schema/README.md
+++ b/packages/schema/README.md
@@ -1,10 +1,10 @@
# @cipherstash/schema
-A TypeScript schema builder for CipherStash Protect.js that enables you to define encryption schemas with searchable encryption capabilities.
+A TypeScript schema builder for the Stash Stack that enables you to define encryption schemas with searchable encryption capabilities.
## Overview
-`@cipherstash/schema` is a standalone package that provides the schema building functionality used by `@cipherstash/protect`. While not required for basic Protect.js usage, this package is available if you need to build encryption configuration schemas directly or want to understand the underlying schema structure.
+`@cipherstash/schema` is a standalone package that provides the schema building functionality used by `@cipherstash/stack`. While not required for basic Stash Encryption usage, this package is available if you need to build encryption configuration schemas directly or want to understand the underlying schema structure.
## Installation
@@ -19,13 +19,13 @@ pnpm add @cipherstash/schema
## Quick Start
```typescript
-import { csTable, csColumn, buildEncryptConfig } from '@cipherstash/schema'
+import { encryptedTable, encryptedColumn, buildEncryptConfig } from '@cipherstash/schema'
// Define your schema
-const users = csTable('users', {
- email: csColumn('email').freeTextSearch().equality().orderAndRange(),
- name: csColumn('name').freeTextSearch(),
- age: csColumn('age').orderAndRange(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ name: encryptedColumn('name').freeTextSearch(),
+ age: encryptedColumn('age').orderAndRange(),
})
// Build the encryption configuration
@@ -35,48 +35,48 @@ console.log(config)
## Core Functions
-### `csTable(tableName, columns)`
+### `encryptedTable(tableName, columns)`
Creates a table definition with encrypted columns.
```typescript
-import { csTable, csColumn } from '@cipherstash/schema'
+import { encryptedTable, encryptedColumn } from '@cipherstash/schema'
-const users = csTable('users', {
- email: csColumn('email'),
- name: csColumn('name'),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
+ name: encryptedColumn('name'),
})
```
-### `csColumn(columnName)`
+### `encryptedColumn(columnName)`
Creates a column definition with configurable indexes and data types.
```typescript
-import { csColumn } from '@cipherstash/schema'
+import { encryptedColumn } from '@cipherstash/schema'
-const emailColumn = csColumn('email')
+const emailColumn = encryptedColumn('email')
.freeTextSearch() // Enable text search
.equality() // Enable exact matching
.orderAndRange() // Enable sorting and range queries
.dataType('string') // Set data type
```
-### `csValue(valueName)`
+### `encryptedValue(valueName)`
Creates a value definition for nested objects (up to 3 levels deep).
```typescript
-import { csTable, csColumn, csValue } from '@cipherstash/schema'
+import { encryptedTable, encryptedColumn, encryptedValue } from '@cipherstash/schema'
-const users = csTable('users', {
- email: csColumn('email').equality(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
profile: {
- name: csValue('profile.name'),
+ name: encryptedValue('profile.name'),
address: {
- street: csValue('profile.address.street'),
+ street: encryptedValue('profile.address.street'),
location: {
- coordinates: csValue('profile.address.location.coordinates'),
+ coordinates: encryptedValue('profile.address.location.coordinates'),
},
},
},
@@ -100,7 +100,7 @@ const config = buildEncryptConfig(users, orders, products)
Enables exact matching queries.
```typescript
-const emailColumn = csColumn('email').equality()
+const emailColumn = encryptedColumn('email').equality()
// SQL equivalent: WHERE email = 'example@example.com'
```
@@ -109,7 +109,7 @@ const emailColumn = csColumn('email').equality()
Enables text search with configurable options.
```typescript
-const descriptionColumn = csColumn('description').freeTextSearch({
+const descriptionColumn = encryptedColumn('description').freeTextSearch({
tokenizer: { kind: 'ngram', token_length: 3 },
token_filters: [{ kind: 'downcase' }],
k: 6,
@@ -124,7 +124,7 @@ const descriptionColumn = csColumn('description').freeTextSearch({
Enables sorting and range queries.
```typescript
-const priceColumn = csColumn('price').orderAndRange()
+const priceColumn = encryptedColumn('price').orderAndRange()
// SQL equivalent: ORDER BY price ASC, WHERE price > 100
```
@@ -133,7 +133,7 @@ const priceColumn = csColumn('price').orderAndRange()
Set the data type for a column using `.dataType()`:
```typescript
-const column = csColumn('field')
+const column = encryptedColumn('field')
.dataType('string') // text (default)
.dataType('number') // Javascript number (i.e. integer or float)
.dataType('jsonb') // JSON binary
@@ -144,15 +144,15 @@ const column = csColumn('field')
Support for nested object encryption (up to 3 levels deep):
```typescript
-const users = csTable('users', {
- email: csColumn('email').equality(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
profile: {
- name: csValue('profile.name'),
+ name: encryptedValue('profile.name'),
address: {
- street: csValue('profile.address.street'),
- city: csValue('profile.address.city'),
+ street: encryptedValue('profile.address.street'),
+ city: encryptedValue('profile.address.city'),
location: {
- coordinates: csValue('profile.address.location.coordinates'),
+ coordinates: encryptedValue('profile.address.location.coordinates'),
},
},
},
@@ -166,7 +166,7 @@ const users = csTable('users', {
### Custom Token Filters
```typescript
-const column = csColumn('field').equality([
+const column = encryptedColumn('field').equality([
{ kind: 'downcase' }
])
```
@@ -174,7 +174,7 @@ const column = csColumn('field').equality([
### Custom Match Options
```typescript
-const column = csColumn('field').freeTextSearch({
+const column = encryptedColumn('field').freeTextSearch({
tokenizer: { kind: 'standard' },
token_filters: [{ kind: 'downcase' }],
k: 8,
@@ -188,29 +188,29 @@ const column = csColumn('field').freeTextSearch({
The schema builder provides full TypeScript support:
```typescript
-import { csTable, csColumn, type ProtectTableColumn } from '@cipherstash/schema'
+import { encryptedTable, encryptedColumn, type EncryptedTableColumn } from '@cipherstash/schema'
-const users = csTable('users', {
- email: csColumn('email').equality(),
- name: csColumn('name').freeTextSearch(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
+ name: encryptedColumn('name').freeTextSearch(),
} as const)
// TypeScript will infer the correct types
type UsersTable = typeof users
```
-## Integration with Protect.js
+## Integration with Stash Encryption
-While this package can be used standalone, it's typically used through `@cipherstash/protect`:
+While this package can be used standalone, it's typically used through `@cipherstash/stack`:
```typescript
-import { csTable, csColumn } from '@cipherstash/protect'
+import { encryptedTable, encryptedColumn, Encryption } from '@cipherstash/stack'
-const users = csTable('users', {
- email: csColumn('email').equality().freeTextSearch(),
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality().freeTextSearch(),
})
-const protectClient = await protect({
+const client = await Encryption({
schemas: [users],
})
```
@@ -245,14 +245,14 @@ The `buildEncryptConfig` function generates a configuration object like this:
## Use Cases
-- **Standalone schema building**: When you need to generate encryption configurations outside of Protect.js
+- **Standalone schema building**: When you need to generate encryption configurations outside of Stash Encryption
- **Custom tooling**: Building tools that work with CipherStash encryption schemas
-- **Schema validation**: Validating schema structures before using them with Protect.js
+- **Schema validation**: Validating schema structures before using them with Stash Encryption
- **Documentation generation**: Creating documentation from schema definitions
## API Reference
-### `csTable(tableName: string, columns: ProtectTableColumn)`
+### `encryptedTable(tableName: string, columns: EncryptionTableColumn)`
Creates a table definition.
@@ -260,16 +260,16 @@ Creates a table definition.
- `tableName`: The name of the table in the database
- `columns`: Object defining the columns and their configurations
-**Returns:** `ProtectTable & T`
+**Returns:** `EncryptionTable & T`
-### `csColumn(columnName: string)`
+### `encryptedColumn(columnName: string)`
Creates a column definition.
**Parameters:**
- `columnName`: The name of the column in the database
-**Returns:** `ProtectColumn`
+**Returns:** `EncryptionColumn`
**Methods:**
- `.dataType(castAs: CastAs)`: Set the data type
@@ -278,26 +278,26 @@ Creates a column definition.
- `.orderAndRange()`: Enable order and range index
- `.searchableJson()`: Enable searchable JSON index
-### `csValue(valueName: string)`
+### `encryptedValue(valueName: string)`
Creates a value definition for nested objects.
**Parameters:**
- `valueName`: Dot-separated path to the value (e.g., 'profile.name')
-**Returns:** `ProtectValue`
+**Returns:** `EncryptionValue`
**Methods:**
- `.dataType(castAs: CastAs)`: Set the data type
-### `buildEncryptConfig(...tables: ProtectTable[])`
+### `buildEncryptConfig(...tables: EncryptionTable[])`
Builds the encryption configuration.
**Parameters:**
- `...tables`: Variable number of table definitions
-**Returns:** `EncryptConfig`
+**Returns:** `EncryptionConfigSchema`
## License
diff --git a/packages/schema/__tests__/searchable-json.test.ts b/packages/schema/__tests__/searchable-json.test.ts
index 4e94d920..8f14ec7c 100644
--- a/packages/schema/__tests__/searchable-json.test.ts
+++ b/packages/schema/__tests__/searchable-json.test.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect } from 'vitest'
-import { buildEncryptConfig, csTable, csColumn } from '../src/index'
+import { describe, expect, it } from 'vitest'
+import { buildEncryptConfig, csColumn, csTable } from '../src/index'
describe('searchableJson()', () => {
it('sets cast_as to json and ste_vec marker on column build', () => {
@@ -19,26 +19,28 @@ describe('searchableJson()', () => {
describe('ProtectTable.build() with searchableJson', () => {
it('transforms prefix to table/column format', () => {
const users = csTable('users', {
- metadata: csColumn('metadata').searchableJson()
+ metadata: csColumn('metadata').searchableJson(),
})
const built = users.build()
expect(built.columns.metadata.cast_as).toBe('json')
- expect(built.columns.metadata.indexes.ste_vec?.prefix).toBe('users/metadata')
+ expect(built.columns.metadata.indexes.ste_vec?.prefix).toBe(
+ 'users/metadata',
+ )
})
})
describe('buildEncryptConfig with searchableJson', () => {
it('emits ste_vec index with table/column prefix', () => {
const users = csTable('users', {
- metadata: csColumn('metadata').searchableJson()
+ metadata: csColumn('metadata').searchableJson(),
})
const config = buildEncryptConfig(users)
expect(config.tables.users.metadata.cast_as).toBe('json')
expect(config.tables.users.metadata.indexes.ste_vec?.prefix).toBe(
- 'users/metadata'
+ 'users/metadata',
)
})
})
diff --git a/packages/schema/package.json b/packages/schema/package.json
index 5ea65575..0e2f212f 100644
--- a/packages/schema/package.json
+++ b/packages/schema/package.json
@@ -3,17 +3,20 @@
"version": "2.0.2",
"description": "CipherStash schema builder for TypeScript",
"keywords": [
- "encrypted",
- "protect",
+ "encryption",
+ "kms",
+ "typescript",
+ "searchable-encryption",
+ "zero-trust",
"schema",
"builder"
],
"bugs": {
- "url": "https://github.com/cipherstash/protectjs/issues"
+ "url": "https://github.com/cipherstash/stack/issues"
},
"repository": {
"type": "git",
- "url": "git+https://github.com/cipherstash/protectjs.git"
+ "url": "git+https://github.com/cipherstash/stack.git"
},
"license": "MIT",
"author": "CipherStash ",
diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts
index dcc11c8c..66404297 100644
--- a/packages/schema/src/index.ts
+++ b/packages/schema/src/index.ts
@@ -101,17 +101,17 @@ export type UniqueIndexOpts = z.infer
export type OreIndexOpts = z.infer
export type ColumnSchema = z.infer
-export type ProtectTableColumn = {
+export type EncryptionTableColumn = {
[key: string]:
- | ProtectColumn
+ | EncryptionColumn
| {
[key: string]:
- | ProtectValue
+ | EncryptionValue
| {
[key: string]:
- | ProtectValue
+ | EncryptionValue
| {
- [key: string]: ProtectValue
+ [key: string]: EncryptionValue
}
}
}
@@ -121,7 +121,7 @@ export type EncryptConfig = z.infer
// ------------------------
// Interface definitions
// ------------------------
-export class ProtectValue {
+export class EncryptionValue {
private valueName: string
private castAsValue: CastAs
@@ -150,7 +150,7 @@ export class ProtectValue {
}
}
-export class ProtectColumn {
+export class EncryptionColumn {
private columnName: string
private castAsValue: CastAs
private indexesValue: {
@@ -238,7 +238,7 @@ interface TableDefinition {
columns: Record
}
-export class ProtectTable {
+export class EncryptionTable {
constructor(
public readonly tableName: string,
private readonly columnBuilders: T,
@@ -252,19 +252,22 @@ export class ProtectTable {
const processColumn = (
builder:
- | ProtectColumn
+ | EncryptionColumn
| Record<
string,
- | ProtectValue
+ | EncryptionValue
| Record<
string,
- | ProtectValue
- | Record>
+ | EncryptionValue
+ | Record<
+ string,
+ EncryptionValue | Record
+ >
>
>,
colName: string,
) => {
- if (builder instanceof ProtectColumn) {
+ if (builder instanceof EncryptionColumn) {
const builtColumn = builder.build()
// Hanlde building the ste_vec index for JSON columns so users don't have to pass the prefix.
@@ -286,7 +289,7 @@ export class ProtectTable {
}
} else {
for (const [key, value] of Object.entries(builder)) {
- if (value instanceof ProtectValue) {
+ if (value instanceof EncryptionValue) {
builtColumns[value.getName()] = value.build()
} else {
processColumn(value, key)
@@ -306,43 +309,74 @@ export class ProtectTable {
}
}
+// ------------------------
+// New public type aliases
+// ------------------------
+export type EncryptedTable = EncryptionTable
+export type EncryptedColumn = EncryptionColumn
+export type EncryptedValue = EncryptionValue
+export type EncryptedTableColumn = EncryptionTableColumn
+
+// ------------------------
+// Deprecated Protect* type aliases (backward compat)
+// ------------------------
+/** @deprecated Use EncryptedTable */
+export type ProtectTable = EncryptionTable
+/** @deprecated Use EncryptedColumn */
+export type ProtectColumn = EncryptionColumn
+/** @deprecated Use EncryptedValue */
+export type ProtectValue = EncryptionValue
+/** @deprecated Use EncryptedTableColumn */
+export type ProtectTableColumn = EncryptionTableColumn
+
// ------------------------
// User facing functions
// ------------------------
-export function csTable(
+export function encryptedTable(
tableName: string,
columns: T,
-): ProtectTable & T {
- const tableBuilder = new ProtectTable(tableName, columns) as ProtectTable &
- T
+): EncryptionTable & T {
+ const tableBuilder = new EncryptionTable(
+ tableName,
+ columns,
+ ) as EncryptionTable & T
for (const [colName, colBuilder] of Object.entries(columns)) {
- ;(tableBuilder as ProtectTableColumn)[colName] = colBuilder
+ ;(tableBuilder as EncryptionTableColumn)[colName] = colBuilder
}
return tableBuilder
}
-export function csColumn(columnName: string) {
- return new ProtectColumn(columnName)
+/** @deprecated Use encryptedTable */
+export const csTable = encryptedTable
+
+export function encryptedColumn(columnName: string) {
+ return new EncryptionColumn(columnName)
}
-export function csValue(valueName: string) {
- return new ProtectValue(valueName)
+/** @deprecated Use encryptedColumn */
+export const csColumn = encryptedColumn
+
+export function encryptedValue(valueName: string) {
+ return new EncryptionValue(valueName)
}
+/** @deprecated Use encryptedValue */
+export const csValue = encryptedValue
+
// ------------------------
// Internal functions
// ------------------------
export function buildEncryptConfig(
- ...protectTables: Array>
+ ...encryptionTables: Array>
): EncryptConfig {
const config: EncryptConfig = {
v: 2,
tables: {},
}
- for (const tb of protectTables) {
+ for (const tb of encryptionTables) {
const tableDef = tb.build()
config.tables[tableDef.tableName] = tableDef.columns
}
diff --git a/packages/stack/.npmignore b/packages/stack/.npmignore
new file mode 100644
index 00000000..3490e24d
--- /dev/null
+++ b/packages/stack/.npmignore
@@ -0,0 +1,5 @@
+.env
+.turbo
+node_modules
+cipherstash.secret.toml
+cipherstash.toml
\ No newline at end of file
diff --git a/packages/stack/README.md b/packages/stack/README.md
new file mode 100644
index 00000000..2f876cba
--- /dev/null
+++ b/packages/stack/README.md
@@ -0,0 +1,1094 @@
+
+
+
+ Stash Encryption
+
+
+ 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!
+
+
+
+
+Stash Encryption 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](./docs/concepts/searchable-encryption.md).
+
+Looking for DynamoDB support? Check out the [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/stack
+```
+
+Start encrypting data:
+
+```ts
+import { Encryption } from "@cipherstash/stack";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
+
+// 1) Define a schema
+const users = encryptedTable("users", { email: encryptedColumn("email") });
+
+// 2) Create a client (requires CS_* env vars)
+const client = await Encryption({ 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);
+```
+
+> [!NOTE]
+> **Migrating from `@cipherstash/protect`?** See the [migration guide](./MIGRATION.md) for a complete list of renames and how to update your code.
+> All old names (`protect`, `csTable`, `csColumn`, `ProtectClient`, etc.) are still available as deprecated aliases.
+
+## Architecture (high level)
+
+
+
+## Table of contents
+
+- [Quick start (60 seconds)](#quick-start-60-seconds)
+- [Architecture (high level)](#architecture-high-level)
+- [Features](#features)
+- [Installation](#installation)
+- [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://github.com/cipherstash/protectjs/tree/main/docs).
+
+## Features
+
+Stash Encryption protects data in using industry-standard AES encryption.
+Stash Encryption 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**: Stash Encryption 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? Stash Encryption has you covered.
+- **Really fast:** ZeroKMS's performance makes using millions of unique keys feasible and performant for real-world applications built with Stash Encryption.
+- **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**: Stash Encryption 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.
+
+## Installation
+
+Install the [`@cipherstash/stack` package](https://www.npmjs.com/package/@cipherstash/stack) with your package manager of choice:
+
+```bash
+npm install @cipherstash/stack
+# or
+yarn add @cipherstash/stack
+# or
+pnpm add @cipherstash/stack
+```
+
+> [!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, Stash Encryption 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 Stash Encryption.**
+
+Stash Encryption uses Node.js specific features and requires the use of the [native Node.js `require`](https://nodejs.org/api/modules.html#requireid).
+
+When using Stash Encryption, 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 Stash Encryption](#builds-and-bundling).
+
+## Getting started
+
+- **Existing app?** Skip to [the next step](#configuration).
+- **Clean slate?** Check out the [getting started tutorial](./docs/getting-started.md).
+
+### 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 Stash Encryption.
+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
+
+Stash Encryption 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 { encryptedTable, encryptedColumn } from "@cipherstash/stack";
+
+export const users = encryptedTable("users", {
+ email: encryptedColumn("email"),
+});
+
+export const orders = encryptedTable("orders", {
+ address: encryptedColumn("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 { encryptedTable, encryptedColumn } from "@cipherstash/stack";
+
+export const users = encryptedTable("users", {
+ email: encryptedColumn("email").freeTextSearch().equality().orderAndRange(),
+});
+
+export const orders = encryptedTable("orders", {
+ address: encryptedColumn("address"),
+});
+```
+
+Read more about [defining your schema](./docs/reference/schema.md).
+
+### Initialize the encryption client
+
+To import the `Encryption` function and initialize a client with your defined schema, add the following to `src/protect/index.ts`:
+
+```ts
+import { Encryption, type EncryptionClientConfig } from "@cipherstash/stack";
+import { users, orders } from "./schema";
+
+const config: EncryptionClientConfig = {
+ schemas: [users, orders],
+}
+
+// Pass all your tables to the Encryption function to initialize the client
+export const protectClient = await Encryption(config);
+```
+
+The `Encryption` function requires at least one `encryptedTable` be provided in the `schemas` array.
+
+### Encrypt data
+
+Stash Encryption 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
+
+Stash Encryption 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
+
+Stash Encryption 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
+
+Stash Encryption 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 Stash Encryption's model operations.
+
+Example with Drizzle infered types:
+
+```typescript
+import { protectClient } from "./protect";
+import { jsonb, pgTable, serial, InferSelectModel } from "drizzle-orm/pg-core";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack";
+
+const protectUsers = encryptedTable("users", {
+ email: encryptedColumn("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
+
+Stash Encryption 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/stack/identity";
+
+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 latest EQL install script:
+
+ ```sh
+ curl -sLo cipherstash-encrypt.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/cipherstash-encrypt.sql
+ ```
+
+ Using [Supabase](https://supabase.com/)? We ship an EQL release specifically for Supabase.
+ Download the latest EQL install script:
+
+ ```sh
+ curl -sLo cipherstash-encrypt-supabase.sql https://github.com/cipherstash/encrypt-query-language/releases/latest/download/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](./docs/reference/working-with-composite-types.md).
+
+Read more about [how to search encrypted data](./docs/reference/searchable-encryption-postgres.md) 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](./docs/how-to/lock-contexts-with-clerk.md).
+
+Stash Encryption 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.
+
+Stash Encryption 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/stack/identity";
+
+// 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
+
+Stash Encryption 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 Stash Encryption 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/protectjs/issues/48).
+
+## Searchable encryption
+
+Read more about [searching encrypted data](./docs/concepts/searchable-encryption.md) in the docs.
+
+## Multi-tenant encryption
+
+Stash Encryption 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 Stash Encryption client.
+
+```typescript
+import { Encryption } from "@cipherstash/stack";
+import { users } from "./protect/schema";
+
+const protectClient = await Encryption({
+ 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 Encryption({
+ 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/stack` will NEVER log plaintext data.
+> This is by design to prevent sensitive data from leaking into logs.
+
+`@cipherstash/stack` 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
+
+Stash Encryption 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/protectjs-ffi).
+
+Read more about configuring the CipherStash Client in the [configuration docs](./docs/reference/configuration.md).
+
+## Example applications
+
+Looking for examples of how to use Stash Encryption?
+Check out the [example applications](./examples):
+
+- [Basic example](/examples/basic) demonstrates how to perform encryption operations
+- [Drizzle example](/examples/drizzle) demonstrates how to use Stash Encryption with an ORM
+- [Next.js and lock contexts example using Clerk](/examples/nextjs-clerk) demonstrates how to protect data with identity-aware encryption
+
+`@cipherstash/stack` can be used with most ORMs.
+If you're interested in using `@cipherstash/stack` with a specific ORM, please [create an issue](https://github.com/cipherstash/protectjs/issues/new).
+
+## Builds and bundling
+
+`@cipherstash/stack` 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](./docs/how-to/nextjs-external-packages.md).
+- [SST and AWS serverless functions](./docs/how-to/sst-external-packages.md).
+
+> [!TIP]
+> Deploying to Linux (e.g., AWS Lambda) with npm lockfile v3 and seeing runtime module load errors? See the troubleshooting guide: [`docs/how-to/npm-lockfile-v3`](./docs/how-to/npm-lockfile-v3-linux-deployments.md).
+
+## Contributing
+
+Please read the [contribution guide](CONTRIBUTE.md).
+
+## License
+
+Stash Encryption 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/protectjs/issues/new?template=docs-feedback.yml&title=[Docs:]%20Feedback%20on%20README.md)
diff --git a/packages/stack/__tests__/audit.test.ts b/packages/stack/__tests__/audit.test.ts
new file mode 100644
index 00000000..4021e18c
--- /dev/null
+++ b/packages/stack/__tests__/audit.test.ts
@@ -0,0 +1,472 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption, LockContext } from '../src'
+
+const users = encryptedTable('users', {
+ auditable: encryptedColumn('auditable'),
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('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 Encryption({
+ 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/stack/__tests__/backward-compat.test.ts b/packages/stack/__tests__/backward-compat.test.ts
new file mode 100644
index 00000000..add6e554
--- /dev/null
+++ b/packages/stack/__tests__/backward-compat.test.ts
@@ -0,0 +1,65 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
+})
+
+describe('k-field backward compatibility', () => {
+ let protectClient: Awaited>
+
+ beforeAll(async () => {
+ protectClient = await Encryption({ schemas: [users] })
+ })
+
+ it('should encrypt new data WITHOUT k field (forward compatibility)', 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}`)
+ }
+
+ // Forward compatibility: new encryptions should NOT have k field
+ expect(result.data).not.toHaveProperty('k')
+ expect(result.data).toHaveProperty('c')
+ expect(result.data).toHaveProperty('v')
+ expect(result.data).toHaveProperty('i')
+ }, 30000)
+
+ it('should decrypt data with legacy k field (backward compatibility)', async () => {
+ // First encrypt some data
+ const testData = 'legacy@example.com'
+
+ const encrypted = await protectClient.encrypt(testData, {
+ column: users.email,
+ table: users,
+ })
+
+ if (encrypted.failure) {
+ throw new Error(`Encryption failed: ${encrypted.failure.message}`)
+ }
+
+ // Simulate legacy payload by adding k field to the encrypted data
+ // Use non-null assertion since we've already checked for failure above
+ const legacyPayload = {
+ ...encrypted.data!,
+ k: 'ct', // Legacy discriminant field - should be ignored during decryption
+ }
+
+ // Decrypt should succeed even with legacy k field present
+ const result = await protectClient.decrypt(legacyPayload)
+
+ if (result.failure) {
+ throw new Error(`Decryption failed: ${result.failure.message}`)
+ }
+
+ expect(result.data).toBe(testData)
+ }, 30000)
+})
diff --git a/packages/stack/__tests__/basic-protect.test.ts b/packages/stack/__tests__/basic-protect.test.ts
new file mode 100644
index 00000000..e795f28c
--- /dev/null
+++ b/packages/stack/__tests__/basic-protect.test.ts
@@ -0,0 +1,44 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('address').freeTextSearch(),
+ json: encryptedColumn('json').dataType('json'),
+})
+
+let protectClient: Awaited>
+
+beforeAll(async () => {
+ protectClient = await Encryption({
+ 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/stack/__tests__/bulk-protect.test.ts b/packages/stack/__tests__/bulk-protect.test.ts
new file mode 100644
index 00000000..21b502bb
--- /dev/null
+++ b/packages/stack/__tests__/bulk-protect.test.ts
@@ -0,0 +1,597 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { type EncryptedPayload, Encryption, LockContext } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('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 Encryption({
+ 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/stack/__tests__/deprecated/search-terms.test.ts b/packages/stack/__tests__/deprecated/search-terms.test.ts
new file mode 100644
index 00000000..ab25ef00
--- /dev/null
+++ b/packages/stack/__tests__/deprecated/search-terms.test.ts
@@ -0,0 +1,140 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { describe, expect, it } from 'vitest'
+import { Encryption, type SearchTerm } from '../../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('address').freeTextSearch(),
+ age: encryptedColumn('age').dataType('number').equality(),
+ score: encryptedColumn('score').dataType('number').equality(),
+})
+
+describe('createSearchTerms (deprecated - backward compatibility)', () => {
+ it('should create search terms with default return type', async () => {
+ const protectClient = await Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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/stack/__tests__/encrypt-query.test.ts b/packages/stack/__tests__/encrypt-query.test.ts
new file mode 100644
index 00000000..095c7a52
--- /dev/null
+++ b/packages/stack/__tests__/encrypt-query.test.ts
@@ -0,0 +1,872 @@
+import 'dotenv/config'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption, EncryptionErrorTypes } from '../src'
+import type { EncryptionClient } from '../src/ffi'
+import {
+ articles,
+ createFailingMockLockContext,
+ createMockLockContext,
+ createMockLockContextWithNullContext,
+ expectFailure,
+ metadata,
+ products,
+ unwrapResult,
+ users,
+} from './fixtures'
+
+describe('encryptQuery', () => {
+ let protectClient: EncryptionClient
+
+ beforeAll(async () => {
+ protectClient = await Encryption({
+ 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',
+ EncryptionErrorTypes.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('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(
+ EncryptionErrorTypes.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',
+ EncryptionErrorTypes.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/stack/__tests__/fixtures/index.ts b/packages/stack/__tests__/fixtures/index.ts
new file mode 100644
index 00000000..4d68ff1a
--- /dev/null
+++ b/packages/stack/__tests__/fixtures/index.ts
@@ -0,0 +1,127 @@
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { expect, vi } from 'vitest'
+
+// ============ Schema Fixtures ============
+
+/**
+ * Users table with multiple index types for testing
+ */
+export const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
+ bio: encryptedColumn('bio').freeTextSearch(),
+ age: encryptedColumn('age').dataType('number').orderAndRange(),
+})
+
+/**
+ * Articles table with only freeTextSearch (for auto-inference test)
+ */
+export const articles = encryptedTable('articles', {
+ content: encryptedColumn('content').freeTextSearch(),
+})
+
+/**
+ * Products table with only orderAndRange (for auto-inference test)
+ */
+export const products = encryptedTable('products', {
+ price: encryptedColumn('price').dataType('number').orderAndRange(),
+})
+
+/**
+ * Metadata table with no indexes (for validation error test)
+ */
+export const metadata = encryptedTable('metadata', {
+ raw: encryptedColumn('raw'),
+})
+
+// ============ 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/stack/__tests__/helpers.test.ts b/packages/stack/__tests__/helpers.test.ts
new file mode 100644
index 00000000..abc2f6d8
--- /dev/null
+++ b/packages/stack/__tests__/helpers.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from 'vitest'
+import {
+ bulkModelsToEncryptedPgComposites,
+ encryptedToCompositeLiteral,
+ encryptedToPgComposite,
+ isEncryptedPayload,
+ 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('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/stack/__tests__/infer-index-type.test.ts b/packages/stack/__tests__/infer-index-type.test.ts
new file mode 100644
index 00000000..81f86837
--- /dev/null
+++ b/packages/stack/__tests__/infer-index-type.test.ts
@@ -0,0 +1,57 @@
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { describe, expect, it } from 'vitest'
+import {
+ inferIndexType,
+ validateIndexType,
+} from '../src/ffi/helpers/infer-index-type'
+
+describe('infer-index-type helpers', () => {
+ const users = encryptedTable('users', {
+ email: encryptedColumn('email').equality(),
+ bio: encryptedColumn('bio').freeTextSearch(),
+ age: encryptedColumn('age').orderAndRange(),
+ name: encryptedColumn('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 = encryptedTable('t', {
+ col: encryptedColumn('col').freeTextSearch().orderAndRange(),
+ })
+ expect(inferIndexType(schema.col)).toBe('match')
+ })
+
+ it('throws for column with no indexes', () => {
+ const noIndex = encryptedTable('t', { col: encryptedColumn('col') })
+ expect(() => inferIndexType(noIndex.col)).toThrow('no indexes configured')
+ })
+ })
+
+ 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',
+ )
+ })
+ })
+})
diff --git a/packages/stack/__tests__/json-protect.test.ts b/packages/stack/__tests__/json-protect.test.ts
new file mode 100644
index 00000000..830a2221
--- /dev/null
+++ b/packages/stack/__tests__/json-protect.test.ts
@@ -0,0 +1,1223 @@
+import 'dotenv/config'
+import {
+ encryptedColumn,
+ encryptedTable,
+ encryptedValue,
+} from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption, LockContext } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('address').freeTextSearch(),
+ json: encryptedColumn('json').dataType('json'),
+ metadata: {
+ profile: encryptedValue('metadata.profile').dataType('json'),
+ settings: {
+ preferences: encryptedValue('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 Encryption({
+ schemas: [users],
+ })
+})
+
+describe('JSON encryption and decryption', () => {
+ it('should encrypt and decrypt a simple JSON payload', async () => {
+ const json = {
+ name: 'John Doe',
+ age: 30,
+ }
+
+ const ciphertext = await protectClient.encrypt(json, {
+ column: users.json,
+ table: users,
+ })
+
+ if (ciphertext.failure) {
+ throw new Error(`[protect]: ${ciphertext.failure.message}`)
+ }
+
+ // Verify encrypted field
+ expect(ciphertext.data).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+ expect(encryptedModel.data.address).not.toHaveProperty('k')
+ expect(encryptedModel.data.json).not.toHaveProperty('k')
+
+ // 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).not.toHaveProperty('k')
+ expect(encryptedModel.data.address).not.toHaveProperty('k')
+ 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).not.toHaveProperty('k')
+ expect(encryptedModel.data.address).not.toHaveProperty('k')
+ 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).not.toHaveProperty('k')
+ expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
+ expect(encryptedData.data[1]).toHaveProperty('data')
+ expect(encryptedData.data[1].data).not.toHaveProperty('k')
+ expect(encryptedData.data[2]).toHaveProperty('id', 'user3')
+ expect(encryptedData.data[2]).toHaveProperty('data')
+ expect(encryptedData.data[2].data).not.toHaveProperty('k')
+
+ // 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).not.toHaveProperty('k')
+ 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).not.toHaveProperty('k')
+
+ // 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).not.toHaveProperty('k')
+ expect(encryptedModels.data[0].address).not.toHaveProperty('k')
+ expect(encryptedModels.data[0].json).not.toHaveProperty('k')
+ expect(encryptedModels.data[1].email).not.toHaveProperty('k')
+ expect(encryptedModels.data[1].address).not.toHaveProperty('k')
+ expect(encryptedModels.data[1].json).not.toHaveProperty('k')
+
+ // 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+ expect(encryptedModel.data.json).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+ expect(encryptedData.data[1]).toHaveProperty('id', 'user2')
+ expect(encryptedData.data[1]).toHaveProperty('data')
+ expect(encryptedData.data[1].data).not.toHaveProperty('k')
+
+ // Decrypt with lock context
+ const decryptedData = await protectClient
+ .bulkDecrypt(encryptedData.data)
+ .withLockContext(lockContext.data)
+
+ if (decryptedData.failure) {
+ throw new Error(`[protect]: ${decryptedData.failure.message}`)
+ }
+
+ // Verify decrypted data
+ expect(decryptedData.data).toHaveLength(2)
+ expect(decryptedData.data[0]).toHaveProperty('id', 'user1')
+ expect(decryptedData.data[0]).toHaveProperty('data', {
+ name: 'Alice',
+ age: 25,
+ })
+ expect(decryptedData.data[1]).toHaveProperty('id', 'user2')
+ expect(decryptedData.data[1]).toHaveProperty('data', {
+ name: 'Bob',
+ age: 30,
+ })
+ }, 30000)
+})
+
+describe('JSON nested object encryption', () => {
+ it('should encrypt and decrypt nested JSON objects', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ metadata: {
+ profile: {
+ name: 'John Doe',
+ age: 30,
+ preferences: {
+ theme: 'dark',
+ notifications: true,
+ },
+ },
+ settings: {
+ preferences: {
+ language: 'en-US',
+ timezone: 'UTC',
+ },
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).not.toHaveProperty('k')
+ expect(encryptedModel.data.metadata?.profile).not.toHaveProperty('k')
+ expect(encryptedModel.data.metadata?.settings?.preferences).toHaveProperty(
+ 'c',
+ )
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle null values in nested JSON objects', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '2',
+ email: 'test2@example.com',
+ metadata: {
+ profile: null,
+ settings: {
+ preferences: null,
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify null fields are preserved
+ expect(encryptedModel.data.email).not.toHaveProperty('k')
+ expect(encryptedModel.data.metadata?.profile).toBeNull()
+ expect(encryptedModel.data.metadata?.settings?.preferences).toBeNull()
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle undefined values in nested JSON objects', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '3',
+ email: 'test3@example.com',
+ metadata: {
+ profile: undefined,
+ settings: {
+ preferences: undefined,
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify undefined fields are preserved
+ expect(encryptedModel.data.email).not.toHaveProperty('k')
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ 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).not.toHaveProperty('k')
+
+ const plaintext = await protectClient.decrypt(ciphertext.data)
+
+ expect(plaintext).toEqual({
+ data: json,
+ })
+ }, 30000)
+})
diff --git a/packages/stack/__tests__/jsonb-helpers.test.ts b/packages/stack/__tests__/jsonb-helpers.test.ts
new file mode 100644
index 00000000..9f12d131
--- /dev/null
+++ b/packages/stack/__tests__/jsonb-helpers.test.ts
@@ -0,0 +1,203 @@
+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/stack/__tests__/keysets.test.ts b/packages/stack/__tests__/keysets.test.ts
new file mode 100644
index 00000000..5f093993
--- /dev/null
+++ b/packages/stack/__tests__/keysets.test.ts
@@ -0,0 +1,89 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { describe, expect, it } from 'vitest'
+import { Encryption } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email'),
+})
+
+describe('encryption and decryption with keyset id', () => {
+ it('should encrypt and decrypt a payload', async () => {
+ const protectClient = await Encryption({
+ schemas: [users],
+ keyset: {
+ id: '4152449b-505a-4186-93b6-d3d87eba7a47',
+ },
+ })
+
+ 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 Encryption({
+ 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(
+ Encryption({
+ schemas: [users],
+ keyset: {
+ id: 'invalid-uuid',
+ },
+ }),
+ ).rejects.toThrow(
+ '[encryption]: Invalid UUID provided for keyset id. Must be a valid UUID.',
+ )
+ })
+})
diff --git a/packages/stack/__tests__/lock-context.test.ts b/packages/stack/__tests__/lock-context.test.ts
new file mode 100644
index 00000000..53e392ad
--- /dev/null
+++ b/packages/stack/__tests__/lock-context.test.ts
@@ -0,0 +1,208 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption } from '../src'
+import { LockContext } from '../src/identify'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('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 Encryption({
+ 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/stack/__tests__/nested-models.test.ts b/packages/stack/__tests__/nested-models.test.ts
new file mode 100644
index 00000000..98bb25b0
--- /dev/null
+++ b/packages/stack/__tests__/nested-models.test.ts
@@ -0,0 +1,962 @@
+import 'dotenv/config'
+import {
+ encryptedColumn,
+ encryptedTable,
+ encryptedValue,
+} from '@cipherstash/schema'
+import { describe, expect, it, vi } from 'vitest'
+import { Encryption, LockContext } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('address').freeTextSearch(),
+ name: encryptedColumn('name').freeTextSearch(),
+ example: {
+ field: encryptedValue('example.field'),
+ nested: {
+ deeper: encryptedValue('example.nested.deeper'),
+ },
+ },
+})
+
+type User = {
+ id: string
+ email?: string | null
+ createdAt?: Date
+ updatedAt?: Date
+ address?: string | null
+ notEncrypted?: string | null
+ example: {
+ field: string | undefined | null
+ nested?: {
+ deeper: string | undefined | null
+ plaintext?: string | undefined | null
+ notInSchema?: {
+ deeper: string | undefined | null
+ }
+ deeperNotInSchema?: string | undefined | null
+ extra?: {
+ plaintext: string | undefined | null
+ }
+ }
+ plaintext?: string | undefined | null
+ fieldNotInSchema?: string | undefined | null
+ notInSchema?: {
+ deeper: string | undefined | null
+ }
+ }
+}
+
+describe('encrypt models with nested fields', () => {
+ it('should encrypt and decrypt a single value from a nested schema', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const encryptResponse = await protectClient.encrypt('hello world', {
+ column: users.example.field,
+ table: users,
+ })
+
+ if (encryptResponse.failure) {
+ throw new Error(`[protect]: ${encryptResponse.failure.message}`)
+ }
+
+ // Verify encrypted field
+ expect(encryptResponse.data).toHaveProperty('c')
+
+ const decryptResponse = await protectClient.decrypt(encryptResponse.data)
+
+ if (decryptResponse.failure) {
+ throw new Error(`[protect]: ${decryptResponse.failure.message}`)
+ }
+
+ expect(decryptResponse).toEqual({
+ data: 'hello world',
+ })
+ })
+
+ it('should encrypt and decrypt a model with nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ address: '123 Main St',
+ notEncrypted: 'not encrypted',
+ createdAt: new Date('2021-01-01'),
+ updatedAt: new Date('2021-01-01'),
+ example: {
+ field: 'test',
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.address).toHaveProperty('c')
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+ expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
+ expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
+ expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle null values in nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '2',
+ email: null,
+ address: null,
+ example: {
+ field: null,
+ nested: {
+ deeper: null,
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify null fields are preserved
+ expect(encryptedModel.data.email).toBeNull()
+ expect(encryptedModel.data.address).toBeNull()
+ expect(encryptedModel.data.example.field).toBeNull()
+ expect(encryptedModel.data.example.nested?.deeper).toBeNull()
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle undefined values in nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '3',
+ example: {
+ field: undefined,
+ nested: {
+ deeper: undefined,
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify undefined fields are preserved
+ expect(encryptedModel.data.email).toBeUndefined()
+ expect(encryptedModel.data.example.field).toBeUndefined()
+ expect(encryptedModel.data.example.nested?.deeper).toBeUndefined()
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle mixed null and undefined values in nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '4',
+ email: 'test@example.com',
+ address: undefined,
+ notEncrypted: 'not encrypted',
+ createdAt: new Date('2021-01-01'),
+ updatedAt: new Date('2021-01-01'),
+ example: {
+ field: null,
+ nested: {
+ deeper: undefined,
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+
+ // Verify null/undefined fields are preserved
+ expect(encryptedModel.data.address).toBeUndefined()
+ expect(encryptedModel.data.example.field).toBeNull()
+ expect(encryptedModel.data.example.nested?.deeper).toBeUndefined()
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('4')
+ expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
+ expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
+ expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle deeply nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '3',
+ example: {
+ field: 'outer',
+ nested: {
+ deeper: 'inner value',
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+ expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('3')
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle missing optional nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '5',
+ example: {
+ field: 'present',
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('5')
+ expect(encryptedModel.data.example.nested).toBeUndefined()
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ describe('bulk operations with nested fields', () => {
+ it('should handle bulk encryption and decryption of models with nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = [
+ {
+ id: '1',
+ email: 'test1@example.com',
+ example: {
+ field: 'test1',
+ nested: {
+ deeper: 'value1',
+ },
+ },
+ },
+ {
+ id: '2',
+ email: 'test2@example.com',
+ example: {
+ field: 'test2',
+ nested: {
+ deeper: 'value2',
+ },
+ },
+ },
+ ]
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ // Verify encrypted fields for each model
+ expect(encryptedModels.data[0].email).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
+ expect(encryptedModels.data[1].email).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModels.data[0].id).toBe('1')
+ expect(encryptedModels.data[1].id).toBe('2')
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual(decryptedModels)
+ }, 30000)
+
+ it('should handle bulk operations with null and undefined values in nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = [
+ {
+ id: '1',
+ email: null,
+ example: {
+ field: null,
+ nested: {
+ deeper: undefined,
+ },
+ },
+ },
+ {
+ id: '2',
+ email: undefined,
+ example: {
+ field: undefined,
+ nested: {
+ deeper: null,
+ },
+ },
+ },
+ ]
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ // Verify null/undefined fields are preserved
+ expect(encryptedModels.data[0].email).toBeNull()
+ expect(encryptedModels.data[0].example.field).toBeNull()
+ expect(encryptedModels.data[0].example.nested?.deeper).toBeUndefined()
+ expect(encryptedModels.data[1].email).toBeUndefined()
+ expect(encryptedModels.data[1].example.field).toBeUndefined()
+ expect(encryptedModels.data[1].example.nested?.deeper).toBeNull()
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModels.data[0].id).toBe('1')
+ expect(encryptedModels.data[1].id).toBe('2')
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual(decryptedModels)
+ }, 30000)
+
+ it('should handle bulk operations with missing optional nested fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = [
+ {
+ id: '1',
+ email: 'test1@example.com',
+ example: {
+ field: 'test1',
+ },
+ },
+ {
+ id: '2',
+ email: 'test2@example.com',
+ example: {
+ field: 'test2',
+ nested: {
+ deeper: 'value2',
+ },
+ },
+ },
+ ]
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ // Verify encrypted fields for each model
+ expect(encryptedModels.data[0].email).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[1].email).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModels.data[0].id).toBe('1')
+ expect(encryptedModels.data[0].example.nested).toBeUndefined()
+ expect(encryptedModels.data[1].id).toBe('2')
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual(decryptedModels)
+ }, 30000)
+
+ it('should handle empty array in bulk operations', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = []
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ expect(encryptedModels.data).toEqual([])
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual([])
+ }, 30000)
+ })
+})
+
+describe('nested fields with a plaintext field', () => {
+ it('should handle nested fields with a plaintext field', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ address: '123 Main St',
+ notEncrypted: 'not encrypted',
+ createdAt: new Date('2021-01-01'),
+ updatedAt: new Date('2021-01-01'),
+ example: {
+ field: 'test',
+ plaintext: 'plaintext',
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.address).toHaveProperty('c')
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+ expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
+ expect(encryptedModel.data.createdAt).toEqual(new Date('2021-01-01'))
+ expect(encryptedModel.data.updatedAt).toEqual(new Date('2021-01-01'))
+ expect(encryptedModel.data.example.plaintext).toBe('plaintext')
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ })
+
+ it('should handle multiple plaintext fields at different nesting levels', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ address: '123 Main St',
+ notEncrypted: 'not encrypted',
+ example: {
+ field: 'encrypted field',
+ plaintext: 'top level plaintext',
+ nested: {
+ deeper: 'encrypted deeper',
+ plaintext: 'nested plaintext',
+ extra: {
+ plaintext: 'deeply nested plaintext',
+ },
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.address).toHaveProperty('c')
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+ expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+ expect(encryptedModel.data.notEncrypted).toBe('not encrypted')
+ expect(encryptedModel.data.example.plaintext).toBe('top level plaintext')
+ expect(encryptedModel.data.example.nested?.plaintext).toBe(
+ 'nested plaintext',
+ )
+ expect(encryptedModel.data.example.nested?.extra?.plaintext).toBe(
+ 'deeply nested plaintext',
+ )
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ })
+
+ it('should handle partial path matches in nested objects', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ example: {
+ field: 'encrypted field',
+ nested: {
+ deeper: 'encrypted deeper',
+ // This should not be encrypted as it's not in the schema
+ notInSchema: {
+ deeper: 'not encrypted',
+ },
+ },
+ // This should not be encrypted as it's not in the schema
+ notInSchema: {
+ deeper: 'not encrypted',
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+ expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+ expect(encryptedModel.data.example.nested?.notInSchema?.deeper).toBe(
+ 'not encrypted',
+ )
+ expect(encryptedModel.data.example.notInSchema?.deeper).toBe(
+ 'not encrypted',
+ )
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ })
+
+ it('should handle mixed encrypted and plaintext fields with similar paths', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ example: {
+ field: 'encrypted field',
+ fieldNotInSchema: 'not encrypted',
+ nested: {
+ deeper: 'encrypted deeper',
+ deeperNotInSchema: 'not encrypted',
+ },
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.example.field).toHaveProperty('c')
+ expect(encryptedModel.data.example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+ expect(encryptedModel.data.example.fieldNotInSchema).toBe('not encrypted')
+ expect(encryptedModel.data.example.nested?.deeperNotInSchema).toBe(
+ 'not encrypted',
+ )
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ })
+
+ describe('bulk operations with plaintext fields', () => {
+ it('should handle bulk encryption and decryption with plaintext fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = [
+ {
+ id: '1',
+ email: 'test1@example.com',
+ address: '123 Main St',
+ example: {
+ field: 'encrypted field 1',
+ plaintext: 'plaintext 1',
+ nested: {
+ deeper: 'encrypted deeper 1',
+ plaintext: 'nested plaintext 1',
+ },
+ },
+ },
+ {
+ id: '2',
+ email: 'test2@example.com',
+ address: '456 Main St',
+ example: {
+ field: 'encrypted field 2',
+ plaintext: 'plaintext 2',
+ nested: {
+ deeper: 'encrypted deeper 2',
+ plaintext: 'nested plaintext 2',
+ },
+ },
+ },
+ ]
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModels.data[0].email).toHaveProperty('c')
+ expect(encryptedModels.data[0].address).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
+ expect(encryptedModels.data[1].email).toHaveProperty('c')
+ expect(encryptedModels.data[1].address).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModels.data[0].id).toBe('1')
+ expect(encryptedModels.data[0].example.plaintext).toBe('plaintext 1')
+ expect(encryptedModels.data[0].example.nested?.plaintext).toBe(
+ 'nested plaintext 1',
+ )
+ expect(encryptedModels.data[1].id).toBe('2')
+ expect(encryptedModels.data[1].example.plaintext).toBe('plaintext 2')
+ expect(encryptedModels.data[1].example.nested?.plaintext).toBe(
+ 'nested plaintext 2',
+ )
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual(decryptedModels)
+ })
+
+ it('should handle bulk operations with mixed encrypted and non-encrypted fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = [
+ {
+ id: '1',
+ email: 'test1@example.com',
+ example: {
+ field: 'encrypted field 1',
+ fieldNotInSchema: 'not encrypted 1',
+ nested: {
+ deeper: 'encrypted deeper 1',
+ deeperNotInSchema: 'not encrypted deeper 1',
+ },
+ },
+ },
+ {
+ id: '2',
+ email: 'test2@example.com',
+ example: {
+ field: 'encrypted field 2',
+ fieldNotInSchema: 'not encrypted 2',
+ nested: {
+ deeper: 'encrypted deeper 2',
+ deeperNotInSchema: 'not encrypted deeper 2',
+ },
+ },
+ },
+ ]
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModels.data[0].email).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
+ expect(encryptedModels.data[1].email).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModels.data[0].id).toBe('1')
+ expect(encryptedModels.data[0].example.fieldNotInSchema).toBe(
+ 'not encrypted 1',
+ )
+ expect(encryptedModels.data[0].example.nested?.deeperNotInSchema).toBe(
+ 'not encrypted deeper 1',
+ )
+ expect(encryptedModels.data[1].id).toBe('2')
+ expect(encryptedModels.data[1].example.fieldNotInSchema).toBe(
+ 'not encrypted 2',
+ )
+ expect(encryptedModels.data[1].example.nested?.deeperNotInSchema).toBe(
+ 'not encrypted deeper 2',
+ )
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual(decryptedModels)
+ })
+
+ it('should handle bulk operations with deeply nested plaintext fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModels: User[] = [
+ {
+ id: '1',
+ email: 'test1@example.com',
+ example: {
+ field: 'encrypted field 1',
+ nested: {
+ deeper: 'encrypted deeper 1',
+ extra: {
+ plaintext: 'deeply nested plaintext 1',
+ },
+ },
+ },
+ },
+ {
+ id: '2',
+ email: 'test2@example.com',
+ example: {
+ field: 'encrypted field 2',
+ nested: {
+ deeper: 'encrypted deeper 2',
+ extra: {
+ plaintext: 'deeply nested plaintext 2',
+ },
+ },
+ },
+ },
+ ]
+
+ const encryptedModels = await protectClient.bulkEncryptModels(
+ decryptedModels,
+ users,
+ )
+
+ if (encryptedModels.failure) {
+ throw new Error(`[protect]: ${encryptedModels.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModels.data[0].email).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[0].example.nested?.deeper).toHaveProperty('c')
+ expect(encryptedModels.data[1].email).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.field).toHaveProperty('c')
+ expect(encryptedModels.data[1].example.nested?.deeper).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModels.data[0].id).toBe('1')
+ expect(encryptedModels.data[0].example.nested?.extra?.plaintext).toBe(
+ 'deeply nested plaintext 1',
+ )
+ expect(encryptedModels.data[1].id).toBe('2')
+ expect(encryptedModels.data[1].example.nested?.extra?.plaintext).toBe(
+ 'deeply nested plaintext 2',
+ )
+
+ const decryptedResults = await protectClient.bulkDecryptModels(
+ encryptedModels.data,
+ )
+
+ if (decryptedResults.failure) {
+ throw new Error(`[protect]: ${decryptedResults.failure.message}`)
+ }
+
+ expect(decryptedResults.data).toEqual(decryptedModels)
+ })
+ })
+})
diff --git a/packages/stack/__tests__/number-protect.test.ts b/packages/stack/__tests__/number-protect.test.ts
new file mode 100644
index 00000000..abafbb0d
--- /dev/null
+++ b/packages/stack/__tests__/number-protect.test.ts
@@ -0,0 +1,835 @@
+import 'dotenv/config'
+import {
+ encryptedColumn,
+ encryptedTable,
+ encryptedValue,
+} from '@cipherstash/schema'
+import { beforeAll, describe, expect, it, test } from 'vitest'
+import { Encryption, LockContext } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('address').freeTextSearch(),
+ age: encryptedColumn('age').dataType('number').equality().orderAndRange(),
+ score: encryptedColumn('score').dataType('number').equality().orderAndRange(),
+ metadata: {
+ count: encryptedValue('metadata.count').dataType('number'),
+ level: encryptedValue('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 Encryption({
+ schemas: [users],
+ })
+})
+
+const cases = [
+ 25,
+ 0,
+ -42,
+ 2147483647,
+ 77.9,
+ 0.0,
+ -117.123456,
+ 1e15,
+ -1e15, // Very large floats
+ 9007199254740991, // Max safe integer in JavaScript
+]
+
+describe('Number encryption and decryption', () => {
+ test.each(cases)(
+ 'should encrypt and decrypt a number: %d',
+ async (age) => {
+ const ciphertext = await protectClient.encrypt(age, {
+ column: users.age,
+ table: users,
+ })
+
+ if (ciphertext.failure) {
+ throw new Error(`[protect]: ${ciphertext.failure.message}`)
+ }
+
+ // Verify encrypted field
+ expect(ciphertext.data).toHaveProperty('c')
+
+ const plaintext = await protectClient.decrypt(ciphertext.data)
+
+ expect(plaintext).toEqual({
+ data: age,
+ })
+ },
+ 30000,
+ )
+
+ 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')
+
+ // Forward compatibility: new encryptions should NOT have k field
+ expect(encryptedData.data[0].data).not.toHaveProperty('k')
+ expect(encryptedData.data[1].data).not.toHaveProperty('k')
+ expect(encryptedData.data[2].data).not.toHaveProperty('k')
+
+ // 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 Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '1',
+ email: 'test@example.com',
+ metadata: {
+ count: 100,
+ level: 5,
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify encrypted fields
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.metadata?.count).toHaveProperty('c')
+ expect(encryptedModel.data.metadata?.level).toHaveProperty('c')
+
+ // Verify non-encrypted fields remain unchanged
+ expect(encryptedModel.data.id).toBe('1')
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle null values in nested objects with number fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel: User = {
+ id: '2',
+ email: 'test2@example.com',
+ metadata: {
+ count: undefined,
+ level: undefined,
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify null fields are preserved
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.metadata?.count).toBeUndefined()
+ expect(encryptedModel.data.metadata?.level).toBeUndefined()
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+
+ it('should handle undefined values in nested objects with number fields', async () => {
+ const protectClient = await Encryption({ schemas: [users] })
+
+ const decryptedModel = {
+ id: '3',
+ email: 'test3@example.com',
+ metadata: {
+ count: undefined,
+ level: undefined,
+ },
+ }
+
+ const encryptedModel = await protectClient.encryptModel(
+ decryptedModel,
+ users,
+ )
+
+ if (encryptedModel.failure) {
+ throw new Error(`[protect]: ${encryptedModel.failure.message}`)
+ }
+
+ // Verify undefined fields are preserved
+ expect(encryptedModel.data.email).toHaveProperty('c')
+ expect(encryptedModel.data.metadata?.count).toBeUndefined()
+ expect(encryptedModel.data.metadata?.level).toBeUndefined()
+
+ const decryptedResult = await protectClient.decryptModel(
+ encryptedModel.data,
+ )
+
+ if (decryptedResult.failure) {
+ throw new Error(`[protect]: ${decryptedResult.failure.message}`)
+ }
+
+ expect(decryptedResult.data).toEqual(decryptedModel)
+ }, 30000)
+})
+
+describe('encryptQuery for numbers', () => {
+ it('should create encrypted query for number fields', async () => {
+ const result = await protectClient.encryptQuery([
+ { value: 25, column: users.age, table: users, queryType: 'equality' },
+ { value: 100, column: users.score, table: users, queryType: 'equality' },
+ ])
+
+ if (result.failure) {
+ throw new Error(`[protect]: ${result.failure.message}`)
+ }
+
+ expect(result.data).toHaveLength(2)
+ expect(result.data[0]).toHaveProperty('v', 2)
+ expect(result.data[1]).toHaveProperty('v', 2)
+ }, 30000)
+})
+
+describe('Performance tests', () => {
+ it('should handle large numbers of numbers efficiently', async () => {
+ const largeNumArray = Array.from({ length: 100 }, (_, i) => ({
+ id: i,
+ data: {
+ age: i + 18, // Ages 18-117
+ score: (i % 100) + 1, // Scores 1-100
+ },
+ }))
+
+ const numPayloads = largeNumArray.map((item, index) => ({
+ id: `user${index}`,
+ plaintext: item.data.age,
+ }))
+
+ const encryptedData = await protectClient.bulkEncrypt(numPayloads, {
+ column: users.age,
+ table: users,
+ })
+
+ if (encryptedData.failure) {
+ throw new Error(`[protect]: ${encryptedData.failure.message}`)
+ }
+
+ // Verify structure
+ expect(encryptedData.data).toHaveLength(100)
+
+ // Decrypt the data
+ const decryptedData = await protectClient.bulkDecrypt(encryptedData.data)
+
+ if (decryptedData.failure) {
+ throw new Error(`[protect]: ${decryptedData.failure.message}`)
+ }
+
+ // Verify all data is preserved
+ expect(decryptedData.data).toHaveLength(100)
+
+ for (let i = 0; i < 100; i++) {
+ expect(decryptedData.data[i].id).toBe(`user${i}`)
+ expect(decryptedData.data[i].data).toEqual(largeNumArray[i].data.age)
+ }
+ }, 60000)
+})
+
+describe('Advanced scenarios', () => {
+ it('should handle boundary values', async () => {
+ const boundaryValues = [
+ Number.MIN_SAFE_INTEGER,
+ -2147483648, // Min 32-bit signed integer
+ -1,
+ 0,
+ 1,
+ 2147483647, // Max 32-bit signed integer
+ Number.MAX_SAFE_INTEGER,
+ ]
+
+ for (const value of boundaryValues) {
+ const ciphertext = await protectClient.encrypt(value, {
+ column: users.age,
+ table: users,
+ })
+
+ if (ciphertext.failure) {
+ throw new Error(`[protect]: ${ciphertext.failure.message}`)
+ }
+
+ // Verify encrypted field
+ expect(ciphertext.data).toHaveProperty('c')
+
+ const plaintext = await protectClient.decrypt(ciphertext.data)
+
+ expect(plaintext).toEqual({
+ data: value,
+ })
+ }
+ }, 30000)
+})
+
+const invalidPlaintexts = [
+ '400',
+ 'aaa',
+ '100a',
+ '73.51',
+ {},
+ [],
+ [123],
+ { num: 123 },
+]
+
+describe('Invalid or uncoercable values', () => {
+ test.each(invalidPlaintexts)(
+ 'should fail to encrypt',
+ async (input) => {
+ const result = await protectClient.encrypt(input, {
+ column: users.age,
+ table: users,
+ })
+
+ expect(result.failure).toBeDefined()
+ expect(result.failure?.message).toContain('Cannot convert')
+ },
+ 30000,
+ )
+})
diff --git a/packages/stack/__tests__/protect-ops.test.ts b/packages/stack/__tests__/protect-ops.test.ts
new file mode 100644
index 00000000..b31e4f11
--- /dev/null
+++ b/packages/stack/__tests__/protect-ops.test.ts
@@ -0,0 +1,843 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { beforeAll, describe, expect, it } from 'vitest'
+import { Encryption, LockContext } from '../src'
+
+const users = encryptedTable('users', {
+ email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ address: encryptedColumn('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 Encryption({
+ 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/stack/__tests__/supabase.test.ts b/packages/stack/__tests__/supabase.test.ts
new file mode 100644
index 00000000..03392efc
--- /dev/null
+++ b/packages/stack/__tests__/supabase.test.ts
@@ -0,0 +1,307 @@
+import 'dotenv/config'
+import { encryptedColumn, encryptedTable } from '@cipherstash/schema'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import {
+ type Encrypted,
+ Encryption,
+ bulkModelsToEncryptedPgComposites,
+ encryptedToPgComposite,
+ isEncryptedPayload,
+ modelToEncryptedPgComposites,
+} from '../src'
+
+import { createClient } from '@supabase/supabase-js'
+
+if (!process.env.SUPABASE_URL) {
+ throw new Error('Missing env.SUPABASE_URL')
+}
+if (!process.env.SUPABASE_ANON_KEY) {
+ throw new Error('Missing env.SUPABASE_ANON_KEY')
+}
+
+const supabase = createClient(
+ process.env.SUPABASE_URL,
+ process.env.SUPABASE_ANON_KEY,
+)
+
+const table = encryptedTable('protect-ci', {
+ encrypted: encryptedColumn('encrypted').freeTextSearch().equality(),
+ age: encryptedColumn('age').dataType('number').equality(),
+ score: encryptedColumn('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 () => {
+ // 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}`)
+ }
+})
+
+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('supabase', () => {
+ it('should insert and select encrypted data', async () => {
+ const protectClient = await Encryption({ 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 Encryption({ 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 Encryption({ 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 Encryption({ 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/stack/package.json b/packages/stack/package.json
new file mode 100644
index 00000000..869c1051
--- /dev/null
+++ b/packages/stack/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@cipherstash/stack",
+ "version": "0.0.0",
+ "description": "The CipherStash data security stack",
+ "keywords": [
+ "encryption",
+ "secrets",
+ "kms",
+ "typescript",
+ "searchable-encryption",
+ "zero-trust"
+ ],
+ "bugs": {
+ "url": "https://github.com/cipherstash/stack/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/cipherstash/stack.git"
+ },
+ "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"
+ },
+ "./identity": {
+ "types": "./dist/identity/index.d.ts",
+ "import": "./dist/identity/index.js",
+ "require": "./dist/identity/index.cjs"
+ },
+ "./secrets": {
+ "types": "./dist/secrets/index.d.ts",
+ "import": "./dist/secrets/index.js",
+ "require": "./dist/secrets/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.47.10",
+ "execa": "^9.5.2",
+ "json-schema-to-typescript": "^15.0.2",
+ "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.20.1",
+ "@cipherstash/schema": "workspace:*",
+ "@stricli/core": "^1.2.5",
+ "dotenv": "16.4.7",
+ "zod": "^3.24.2"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-linux-x64-gnu": "4.24.0"
+ }
+}
\ No newline at end of file
diff --git a/packages/stack/src/bin/stash.ts b/packages/stack/src/bin/stash.ts
new file mode 100644
index 00000000..0293f013
--- /dev/null
+++ b/packages/stack/src/bin/stash.ts
@@ -0,0 +1,499 @@
+import { config } from 'dotenv'
+config()
+import readline from 'node:readline'
+import {
+ buildApplication,
+ buildCommand,
+ buildRouteMap,
+ run,
+} from '@stricli/core'
+import { Stash } from '../stash/index.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}`,
+}
+
+/**
+ * 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:
+ stash secrets set --name DATABASE_URL --value "postgres://..." --environment production
+ stash secrets set -n DATABASE_URL -V "postgres://..." -e production
+ stash 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:
+ stash secrets get --name DATABASE_URL --environment production
+ stash secrets get -n DATABASE_URL -e production
+ stash 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:
+ stash secrets list --environment production
+ stash secrets list -e production
+ stash 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:
+ stash secrets delete --name DATABASE_URL --environment production
+ stash secrets delete -n DATABASE_URL -e production --yes
+ stash 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:
+ stash secrets set --name DATABASE_URL --value "postgres://..." --environment production
+ stash secrets set -n DATABASE_URL -V "postgres://..." -e production
+ stash secrets get --name DATABASE_URL --environment production
+ stash secrets get -n DATABASE_URL -e production
+ stash secrets list --environment production
+ stash secrets list -e production
+ stash secrets delete --name DATABASE_URL --environment production
+ stash secrets delete -n DATABASE_URL -e production --yes
+ stash 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 'stash secrets set' to store your first secret
+ 3. Use 'stash secrets get' to retrieve secrets when needed
+
+Commands:
+ secrets Manage encrypted secrets
+
+Run 'stash --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/stack/src/client.ts b/packages/stack/src/client.ts
new file mode 100644
index 00000000..79204b4d
--- /dev/null
+++ b/packages/stack/src/client.ts
@@ -0,0 +1,31 @@
+/**
+ * Client-safe exports for @cipherstash/stack
+ *
+ * This entry point exports types and utilities that can be used in client-side code
+ * without requiring the @cipherstash/protect-ffi native module.
+ *
+ * Use this import path: `@cipherstash/stack/client`
+ */
+
+// Schema types and utilities - client-safe (new names)
+export {
+ encryptedTable,
+ encryptedColumn,
+ encryptedValue,
+ csTable,
+ csColumn,
+ csValue,
+} from '@cipherstash/schema'
+export type {
+ EncryptedColumn,
+ EncryptedTable,
+ EncryptedTableColumn,
+ EncryptedValue,
+ ProtectColumn,
+ ProtectTable,
+ ProtectTableColumn,
+ ProtectValue,
+} from '@cipherstash/schema'
+export type { EncryptionClient } from './ffi'
+/** @deprecated Use EncryptionClient */
+export type { EncryptionClient as ProtectClient } from './ffi'
diff --git a/packages/stack/src/ffi/helpers/infer-index-type.ts b/packages/stack/src/ffi/helpers/infer-index-type.ts
new file mode 100644
index 00000000..de7fd8a1
--- /dev/null
+++ b/packages/stack/src/ffi/helpers/infer-index-type.ts
@@ -0,0 +1,70 @@
+import type { EncryptedColumn } from '@cipherstash/schema'
+import type { FfiIndexTypeName, QueryTypeName } from '../../types'
+import { queryTypeToFfi } from '../../types'
+
+/**
+ * Infer the primary index type from a column's configured indexes.
+ * Priority: unique > match > ore (for scalar queries)
+ */
+export function inferIndexType(column: EncryptedColumn): 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`)
+ }
+
+ if (indexes.unique) return 'unique'
+ if (indexes.match) return 'match'
+ if (indexes.ore) return 'ore'
+
+ throw new Error(
+ `Column "${column.getName()}" has no suitable index for scalar queries`,
+ )
+}
+
+/**
+ * Validate that the specified index type is configured on the column
+ */
+export function validateIndexType(
+ column: EncryptedColumn,
+ indexType: FfiIndexTypeName,
+): void {
+ const config = column.build()
+ const indexes = config.indexes ?? {}
+
+ const indexMap: Record = {
+ unique: !!indexes.unique,
+ match: !!indexes.match,
+ ore: !!indexes.ore,
+ }
+
+ if (!indexMap[indexType]) {
+ throw new Error(
+ `Index type "${indexType}" is not configured on column "${column.getName()}"`,
+ )
+ }
+}
+
+/**
+ * Resolve the index type for a query, either from explicit queryType or by inference.
+ * Validates the index type is configured on the column when queryType is explicit.
+ *
+ * @param column - The column to resolve the index type for
+ * @param queryType - Optional explicit query type (if provided, validates against column config)
+ * @returns The FFI index type name to use for the query
+ */
+export function resolveIndexType(
+ column: EncryptedColumn,
+ queryType?: QueryTypeName,
+): FfiIndexTypeName {
+ const indexType = queryType
+ ? queryTypeToFfi[queryType]
+ : inferIndexType(column)
+
+ if (queryType) {
+ validateIndexType(column, indexType)
+ }
+
+ return indexType
+}
diff --git a/packages/stack/src/ffi/helpers/type-guards.ts b/packages/stack/src/ffi/helpers/type-guards.ts
new file mode 100644
index 00000000..86fb2fec
--- /dev/null
+++ b/packages/stack/src/ffi/helpers/type-guards.ts
@@ -0,0 +1,18 @@
+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/stack/src/ffi/helpers/validation.ts b/packages/stack/src/ffi/helpers/validation.ts
new file mode 100644
index 00000000..39ec839b
--- /dev/null
+++ b/packages/stack/src/ffi/helpers/validation.ts
@@ -0,0 +1,94 @@
+import type { Result } from '@byteslice/result'
+import { type EncryptionError, EncryptionErrorTypes } 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: EncryptionErrorTypes.EncryptionError,
+ message: '[protect]: Cannot encrypt NaN value',
+ },
+ }
+ }
+ if (typeof value === 'number' && !Number.isFinite(value)) {
+ return {
+ failure: {
+ type: EncryptionErrorTypes.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: EncryptionErrorTypes.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/stack/src/ffi/index.ts b/packages/stack/src/ffi/index.ts
new file mode 100644
index 00000000..2da0b8dd
--- /dev/null
+++ b/packages/stack/src/ffi/index.ts
@@ -0,0 +1,438 @@
+import { type Result, withResult } from '@byteslice/result'
+import { type JsPlaintext, newClient } from '@cipherstash/protect-ffi'
+import {
+ type EncryptConfig,
+ type EncryptedTable,
+ type EncryptedTableColumn,
+ encryptConfigSchema,
+} from '@cipherstash/schema'
+import { type EncryptionError, EncryptionErrorTypes } from '..'
+import { loadWorkSpaceId } from '../../../utils/config'
+import { logger } from '../../../utils/logger'
+import { toFfiKeysetIdentifier } from '../helpers'
+import type {
+ BulkDecryptPayload,
+ BulkEncryptPayload,
+ Client,
+ Decrypted,
+ EncryptOptions,
+ EncryptQueryOptions,
+ Encrypted,
+ 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 EncryptionClient is the main entry point for interacting with the CipherStash encryption library.
+ * It provides methods for encrypting and decrypting individual values, as well as models (objects) and bulk operations.
+ *
+ * The client must be initialized using the {@link Encryption} function before it can be used.
+ */
+export class EncryptionClient {
+ private client: Client
+ private encryptConfig: EncryptConfig | undefined
+ private workspaceId: string | undefined
+
+ constructor(workspaceCrn?: string) {
+ const workspaceId = loadWorkSpaceId(workspaceCrn)
+ this.workspaceId = workspaceId
+ }
+
+ /**
+ * Initializes the EncryptionClient with the provided configuration.
+ * @internal
+ * @param config - The configuration object for initializing the client.
+ * @returns A promise that resolves to a {@link Result} containing the initialized EncryptionClient or a {@link EncryptionError}.
+ **/
+ 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 Stash Encryption client with the following encrypt config:',
+ {
+ encryptConfig: validated,
+ },
+ )
+
+ 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 Stash Encryption client.')
+ return this
+ },
+ (error: unknown) => ({
+ type: EncryptionErrorTypes.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 Encryption client.
+ * It includes defining an encryption schema with {@link encryptedTable} and {@link encryptedColumn},
+ * initializing the client with {@link Encryption}, 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 EncryptionError}.
+ *
+ * ```typescript
+ * // Define encryption schema
+ * import { encryptedTable, encryptedColumn } from "@cipherstash/stack"
+ * const userSchema = encryptedTable("users", {
+ * email: encryptedColumn("email"),
+ * });
+ *
+ * // Initialize Encryption client
+ * const encryptionClient = await Encryption({ schemas: [userSchema] })
+ *
+ * // Encrypt a value
+ * const encryptedResult = await encryptionClient.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 encryptionClient.encrypt(
+ * "person@example.com",
+ * { column: userSchema.email, table: userSchema }
+ * )
+ * .withLockContext(lockContext)
+ * ```
+ *
+ * @see {@link Result}
+ * @see {@link encryptedTable}
+ * @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 Encryption client.
+ *
+ * ```typescript
+ * // Define encryption schema
+ * import { encryptedTable, encryptedColumn } from "@cipherstash/stack"
+ * const userSchema = encryptedTable("users", {
+ * email: encryptedColumn("email").equality(),
+ * });
+ *
+ * // Initialize Encryption client
+ * const encryptionClient = await Encryption({ schemas: [userSchema] })
+ *
+ * // Encrypt a query value
+ * const encryptedResult = await encryptionClient.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 encryptionClient.encryptQuery(
+ * "person@example.com",
+ * { column: userSchema.email, table: userSchema }
+ * )
+ * ```
+ *
+ * @see {@link EncryptQueryOperation}
+ */
+ 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 = encryptedTable('users', {
+ * email: encryptedColumn('email').freeTextSearch().equality().orderAndRange(),
+ * })
+ *
+ * // Initialize the Encryption client
+ * const encryptionClient = await Encryption({ schemas: [usersSchema] })
+ *
+ * // Encrypt a user model
+ * const encryptedModel = await encryptionClient.encryptModel(
+ * { id: 'user_123', email: 'person@example.com' },
+ * usersSchema,
+ * )
+ * ```
+ */
+ encryptModel>(
+ input: Decrypted,
+ table: EncryptedTable,
+ ): 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: EncryptedTable,
+ ): 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)
+ }
+
+ /** e.g., debugging or environment info */
+ clientInfo() {
+ return {
+ workspaceId: this.workspaceId,
+ }
+ }
+}
+
+/** @deprecated Use EncryptionClient */
+export { EncryptionClient as ProtectClient }
diff --git a/packages/stack/src/ffi/model-helpers.ts b/packages/stack/src/ffi/model-helpers.ts
new file mode 100644
index 00000000..79df7014
--- /dev/null
+++ b/packages/stack/src/ffi/model-helpers.ts
@@ -0,0 +1,952 @@
+import {
+ type Encrypted as CipherStashEncrypted,
+ type DecryptBulkOptions,
+ type JsPlaintext,
+ decryptBulk,
+ encryptBulk,
+} from '@cipherstash/protect-ffi'
+import type { EncryptedTable, EncryptedTableColumn } 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: EncryptedTable,
+): {
+ 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: EncryptedTable,
+ 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