This is the CipherStash Stack repository (cipherstash/stack) - 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.
- Node.js: >= 22 (enforced in
package.jsonengines) - pnpm: 10.33.2 (this repo uses pnpm workspaces and catalogs)
- Internet access to install the prebuilt native module
@cipherstash/protect-ffi
If running integration tests or examples, you will also need CipherStash credentials (see Environment variables below).
pnpm installpnpm run build
# or only JS libraries
pnpm run build:jsUnder the hood this uses Turborepo to build ./packages/* with each package's tsup configuration.
pnpm run dev- Default: run package tests via Turborepo
pnpm test- Filter to a single package (recommended for fast iteration):
pnpm --filter @cipherstash/stack test
pnpm --filter @cipherstash/nextjs testTests use Vitest. Many tests talk to the real CipherStash service; they require environment variables. Some tests (e.g., lock context) are skipped if optional tokens aren't present.
Place these in a local .env at the repo root or specific example directory:
CS_WORKSPACE_CRN=
CS_CLIENT_ID=
CS_CLIENT_KEY=
CS_CLIENT_ACCESS_KEY=
# Optional – enables identity-aware encryption tests
USER_JWT=
USER_2_JWT=
# Logging (plaintext is never logged by design)
STASH_STACK_LOG=debug|info|error # default: error (errors only)If these variables are missing, tests that require live encryption will fail or be skipped; prefer filtering to specific packages and tests while developing.
packages/stack: Main package (@cipherstash/stack) containing the encryption client and all integrations- Subpath exports:
@cipherstash/stack,@cipherstash/stack/identity,@cipherstash/stack/schema,@cipherstash/stack/eql/v3,@cipherstash/stack/v3,@cipherstash/stack/types,@cipherstash/stack/dynamodb,@cipherstash/stack/encryption,@cipherstash/stack/errors,@cipherstash/stack/adapter-kit,@cipherstash/stack/wasm-inline,@cipherstash/stack/diagnostics(the Drizzle and Supabase integrations moved to their own packages — see below)
- Subpath exports:
packages/cli: ThestashCLI — auth, init, encryption schema, and database setup (stash eql install). Has its ownAGENTS.md.packages/wizard: AI-powered encryption setup (@cipherstash/wizard)packages/migrate: Plaintext-to-encrypted column migration (@cipherstash/migrate) — resumable backfill, per-column statepackages/stack-prisma: Prisma Next integration (@cipherstash/stack-prisma) — searchable field-level encryption for Postgres. EQL v3 only: per-domain constructors (cipherstash.TextSearch()/text()/bigIntOrd()/ …) andcipherstashFromStack(the./v3and./stackentries). The EQL v2 surface was removed — the adapter's baseline migration installs the EQL v3 bundle only (works on Supabase as a non-superuser)packages/stack-drizzle: Drizzle ORM integration (@cipherstash/stack-drizzle), depends on@cipherstash/stack— EQL v3 only, on the package root (the v2 surface was removed and the old./v3subpath collapsed into.). Split out of@cipherstash/stack.packages/stack-supabase: Supabase integration (@cipherstash/stack-supabase), depends on@cipherstash/stack— EQL v3 only:encryptedSupabaseis the v3 factory (encryptedSupabaseV3remains as a@deprecatedalias). Split out of@cipherstash/stack.packages/nextjs: Next.js helpers and Clerk integration (./clerkexport)packages/utils: Shared config (utils/config) and logger (utils/logger)packages/bench: Performance / index-engagement benchmarks (private, not published)packages/protect-ffi: Native FFI bindings to the CipherStash Client SDK (@cipherstash/protect-ffi) — the Rust core thatpackages/stackencrypts and decrypts through, absorbed fromcipherstash/protectjs-ffi. Contains a nested Cargo workspace (crates/) and six per-platform binary packages underplatforms/*, each published as@cipherstash/protect-ffi-<platform>and linked here viaworkspace:*. See the "Working on protect-ffi" notes below before touching it — its defaulttestandbuildare deliberately Rust-free.e2e/*: Cross-package end-to-end tests (package managers, supply chain, Prisma example README)examples/*: Working apps (basic, prisma, supabase-worker)docs/plans/*: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo).skills/*: Agent skills (stash-cli,stash-encryption,stash-indexing,stash-deployment,stash-zerokms,stash-auth,stash-postgres,stash-edge,stash-drizzle,stash-dynamodb,stash-supabase,stash-prisma,stash-supply-chain-security)
packages/protect-ffi is the only Rust in this repo, and its scripts are split
so that stays true for everyone else.
- The default
testandbuildnever invoke cargo. Rootpnpm testrunsturbo test --filter './packages/*', which reaches this package — so a cargo process on that path is a Rust toolchain on every contributor's machine.testis the JS chain;buildistsc. - CI does build the binding, in the jobs that need it. That is the limit of
the rule above: it keeps cargo off the scripts, not out of the pipeline.
Absorbing protect-ffi turned
lib/,index.nodeanddist/wasm/**from tarball contents into build outputs, so every job that encrypts, decrypts, or typechecks against the package builds them first via.github/actions/build-ffi-binding— passingwasm: 'true'only where the job loads the real WASM build, which is the minority and costs a second cargo build against wasm32. The action cachesindex.nodeon a content hash of the Rust inputs, so a PR touching no Rust pays a restore rather than a compile. A new job that runs live encryption needs this step — without it the failure isCannot find module '.../protect-ffi-linux-x64-gnu/index.node', reported once per test rather than once per job.scripts/__tests__/ffi-binding-step-order.test.mjsholds both halves of this: every job that receives aCS_*credential must build the binding, and therequire-cs-secretspre-flight must come first. Both scan the workflow directory rather than a list, so a new job is covered the day it lands. - Rust checks live behind
test:cargo(cargo test+cargo fmt --check) andmise run lint:rust(clippy, host and wasm32).build:nativecarriescargo build --release. src/lintWiring.test.tsenforces the split: notest:*script may be unreachable from both entry points, nothing cargo may be reachable fromtest, and every cargo check must be reachable fromtest:cargo. A check nothing invokes reads exactly like a check that passes — that is why the file exists.- Do not write
pnpm run <script> -- --flag. npm strips the--; pnpm forwards it verbatim, and these scripts end in> cargo.log, so the flag lands after the redirect and cargo rejects it. lintWiring asserts this too. lib/is the packagemainand is generated, so a workspace consumer resolves an empty package untilbuildhas run.turbo.jsoncarries a@cipherstash/protect-ffi#buildoverride declaringoutputs: ["lib/**"]; without it Turbo caches the repo-widedist/**and a cache hit restores nothing while reporting success.- Three WASM declaration files are tracked (
dist/wasm/*.d.ts) so stack's declaration build resolves@cipherstash/protect-ffi/wasm-inlinewithout Rust. Everything else underdist/stays ignored. The re-inclusion chain spans the root.gitignore, the package's own, and a.gitignorewasm-pack generates — see the comments in each. - Publishing has not moved yet. All seven packages are still published from
cipherstash/protectjs-ffiuntil npm trusted publishing is repointed, so a changeset naming any of them fails CI (scripts/lint-no-ffi-changeset.mjs). Change the package freely — but write the changeset and park it as.changeset/<name>.md.deferred, don't skip it. Changesets and the guard both select on.endsWith('.md'), so that extension is inert tochangeset version; the cutover PR renames every one of them back (for f in .changeset/*.md.deferred; do git mv "$f" "${f%.deferred}"; done). Check what is parked rather than assuming a single file —ls .changeset/*.md.deferred. Two are waiting today: the lazy native load, and the manifest repoint tocipherstash/stack. - The pipeline that will publish them is built and inert.
release.ymlasksscripts/release-gate.mjswhich committed versions are missing from npm; if any FFI one is,_build-ffi-artifacts.ymlcompiles the six platforms with an explicitCARGO_BUILD_TARGETeach, packs all seven tarballs, andpublish-ffipublishes the six platform packages before the wrapper and tags all seven — becausechangeset publishpacks from the workspace, whereindex.nodedoes not exist, and tags only what it published itself. Nothing fires until a version is unpublished, which the changeset guard above prevents.ffi-preflight.ymlis the dry run (changeset publishhas no--dry-run); dispatch it against the Version Packages branch before the cutover. The seven manifests already namecipherstash/stack, which npm requires of the publishing repository — so a publish attempted from the old repository would now be rejected, and nothing publishes from there.
packages/protect-ffi/integration-tests/ is 19 files of live coverage —
encrypt/decrypt, lock context, keysets, JS auth strategies, JSON SteVec,
Postgres (EQL v2 and v3), and a WASM round trip — and it is the only place
several of those paths are exercised at all. It needs three things a normal
pnpm test does not have: Docker, CipherStash credentials, and both
EQL versions installed in the database.
-
It is not a pnpm workspace member.
pnpm-workspace.yamlglobspackages/*(one level) pluspackages/protect-ffi/platforms/*, so this directory is invisible to pnpm and has its ownpackage-lock.jsonwith pins that deliberately differ from the repo catalog (@cipherstash/auth ^0.39.0,vitest ^3.1.3,@cipherstash/eql 3.0.2).npm ciinstalls it. Absorbing it into the workspace is a follow-up, not a tidy-up: it changes those pins, and only a credentialed run can prove the change is neutral. -
Run it locally from
packages/protect-ffi:mise run setup # npm ci, docker compose up, EQL v2 + v3 mise run test:integration:all # includes tests/lock-context.test.ts
mise run test:integrationis the same suite minus lock context — prefer:all, which is what CI runs. Both tasks live inintegration-tests/tasks.toml, included frommise.toml's[task_config], and both build the binding themselves (debug) before invoking vitest. Postgres is on 5436 (docker-compose.ymlpublishes5436:5432);mise.toml's[env]carries the matchingPG*values, and mise's[env]overrides an inherited one, so a stalePGPORTin your shell cannot misroute the tests. -
In CI it runs from
.github/workflows/integration-protect-ffi.yml— path-filtered to this package (and the two actions it uses), credentialed, and fork-PR-skipped like the otherintegration-*.ymljobs. Two things there are deliberately not copies of upstream: it builds the binding with.github/actions/build-ffi-binding(wasm: 'true') rather thanmise run build:debug, because a releaseindex.nodeat the package root satisfiessrc/load.cts'sdebug:fallback and that action caches it; and it invokes vitest directly, since the mise task would recompile in the debug profile over the artifact the rest of CI already paid for. It does not use.github/actions/integration-db— the EQL installs intasks.tomlpipe SQL throughdocker exec -i protect-ffi-postgres, which only this suite's own compose file produces, and that action provides no EQL at all. -
Nothing else in the repo installs EQL v2.
eql:downloadpulls theeql-2.2.1release bundle from GitHub andeql:installapplies it;tests/postgres.test.tsneeds it (eql_v2_encrypted,eql_v2.add_encrypted_constraint) whiletests/postgres-v3.test.tsneeds theeql_v3_*domains. Skip either and half the suite fails on missing SQL functions. -
src/integrationSuiteCi.test.tsasserts a root workflow still runs it. The suite ran on every upstream PR and then ran nowhere for the whole absorption, because the workflow that drove it was deposited underpackages/protect-ffi/.github/— a directory GitHub never reads. That test is what stops it going quiet again, and it deliberately scans only the repo-root workflow directory.
skills/*/SKILL.md are published artifacts, not internal notes. Treat a wrong
sentence in one of them the way you'd treat a wrong line of code:
packages/cli/tsup.config.tscopiesskills/intodist/skills/, so they ship inside thestashnpm tarball (and the@cipherstash/wizardone).installSkills()(packages/cli/src/commands/init/lib/install-skills.ts) copies the per-integration set into the user's.claude/skills/or.codex/skills/at handoff time.readBundledSkill()inlines a skill's body into the user'sAGENTS.mdfor editor agents (Cursor / Windsurf / Cline), and as the Codex fallback for skills that could not be copied into.codex/skills/(#736). OnlySKILL.mdis inlined — content split into sibling files is silently dropped on that path, so keep eachSKILL.mdself-sufficient.
Every change to a package's public API, the CLI command surface, or a user-facing workflow must check the affected skills in the same PR. These skills drift silently: nothing type-checks them, and the damage lands in a customer's repo, not ours.
| If you change… | Check |
|---|---|
packages/cli commands, flags, or prompts |
skills/stash-cli |
packages/stack encryption API, schema builders, subpath exports |
skills/stash-encryption |
| Drizzle / Supabase / Prisma Next / DynamoDB integrations | skills/stash-drizzle, skills/stash-supabase, skills/stash-prisma, skills/stash-dynamodb |
The rollout/cutover lifecycle (packages/migrate, stash encrypt *) |
skills/stash-encryption and skills/stash-cli |
The deploy sequencing / deploy-gate story, stash env, or platform-specific deployment guidance |
skills/stash-deployment |
The @cipherstash/eql pin, eql install/eql migration behaviour, or index-related SQL guidance |
skills/stash-indexing |
The EQL operator/domain surface (eql_v3.query_* casts, predicate forms) |
skills/stash-postgres |
The keyset/client model (config.keyset, grants, the ZeroKMS access story) |
skills/stash-zerokms — the canonical source; other skills should point here rather than restate it |
Auth strategies (config.authStrategy), the CS_* variables, lock context, stash env / auth login behaviour |
skills/stash-auth — the canonical source; other skills should point here rather than restate it |
packages/stack/src/wasm-inline.ts, the WASM entry's exports, or stash env |
skills/stash-edge |
| pnpm config, CI workflows, dependency policy | skills/stash-supply-chain-security |
| The durable agent rules themselves | packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md |
For CLI changes there is a mechanical check — the command registry is the source of truth, so diff the skill against it rather than proofreading:
pnpm --filter stash build
node packages/cli/dist/bin/stash.js manifest --jsonEvery command and flag named in skills/stash-cli/SKILL.md must resolve against that
manifest (the deprecated db install / db upgrade / db status aliases excepted —
they're intentionally absent from the registry).
Skills must not contain Linear issue IDs; they're public. GitHub issue numbers are fine.
This repo applies a set of supply-chain controls (post-install script policy, install cooldown, frozen-lockfile CI, registry pinning, Dependabot cooldown, CODEOWNERS) sourced from lirantal/npm-security-best-practices. They're validated by e2e/tests/supply-chain.e2e.test.ts so silent regressions fail CI. See skills/stash-supply-chain-security/SKILL.md for the full guide.
Three rules to remember when editing CI or pnpm config:
- CI uses
pnpm install --frozen-lockfile. Don't drop the flag. - Adding to
pnpm.onlyBuiltDependenciesis an audit decision — vet the package and explain the addition in the PR. - Don't commit auth tokens in
.npmrc. Tokens belong in user-level~/.npmrcor environment variables.
- Initialization:
Encryption({ schemas })is the single client factory. It requires at least one concrete EQL v3encryptedTableand returnsEncryptionClient<S>, whose model and query types are derived from that schema tuple. TheEncryptionV3,typedClient,EncryptionClientFor, and nominal-client surfaces have been removed. - Schema: Define tables/columns with
encryptedTableand thetypes.*concrete-domain factories from@cipherstash/stack/eql/v3(types.TextSearch,types.IntegerOrd,types.Json, …) — each domain's query capabilities are fixed by its type; there are no chainable capability tuners. Build the client withEncryption(importEncryption,encryptedTable, andtypesfrom@cipherstash/stack/v3). Schema authoring and all writes are EQL v3-only;config.eqlVersionand the public EQL v2 schema builders have been removed. Both the native andwasm-inlineclients still decrypt existing v2 payloads. DynamoDB legacy reads use the same v3 table descriptor plus{ storedEqlVersion: 2 }, and the table must be one given toEncryption({ schemas }); nested v3 fields use a flat dotted column path ('profile.ssn': types.TextEq(...)). - Operations (all return Result-like objects and support chaining
.withLockContext(lockContext)and.audit()when applicable):encrypt(plaintext, { table, column })decrypt(encryptedPayload)encryptModel(model, table)/decryptModel(model)bulkEncrypt(plaintexts[], { table, column })/bulkDecrypt(encrypted[])bulkEncryptModels(models[], table)/bulkDecryptModels(models[])encryptQuery(value, { table, column, queryType?, returnType? })for searchable queriesencryptQuery(terms[])for batch query encryption
- Identity-aware encryption: Authenticate the client as the end user with
OidcFederationStrategy(config.authStrategy, re-exported from@cipherstash/stack), then chain.withLockContext({ identityClaim })on operations to bind the data key to a claim. The same claim must be used for encrypt and decrypt. (LockContext.identify()from@cipherstash/stack/identityis deprecated — the strategy now handles token acquisition;.withLockContext()also accepts aLockContext.) - Integrations:
- Drizzle ORM:
types.*column factories,extractEncryptionSchema,createEncryptionOperatorsfrom@cipherstash/stack-drizzle - Supabase:
encryptedSupabasefrom@cipherstash/stack-supabase(EQL v3;encryptedSupabaseV3is a@deprecatedalias) - DynamoDB:
encryptedDynamoDBfrom@cipherstash/stack/dynamodb
- Drizzle ORM:
- Native module vs WASM entry: The default
@cipherstash/stackentry relies on@cipherstash/protect-ffi(Node-API) and must be loaded via native Node.jsrequire— if your tooling bundles server code with it, externalize the module. For bundled or non-Node runtimes (Deno, Bun, Cloudflare Workers, Supabase Edge Functions), use@cipherstash/stack/wasm-inlineinstead: it inlines the WASM build into the JS bundle, so no externalization is needed. See the bundling guide: https://cipherstash.com/docs/stack/deploy/bundling - 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 errortypevalues inEncryptionErrorTypes. - Encrypted payload shape is contract: Keys like
cin the EQL payload are validated by tests and downstream tools. Don't change them. - Exports must support ESM and CJS: Each package's
exportsmaps must keep bothimportandrequirefields. Don't remove CJS.
- Formatting/Linting: Use Biome
pnpm run code:fix # format + lint, auto-fixing what it can
pnpm run code:check # read-only; this is what CI runsCI runs code:check (in tests.yml) and gates on errors — warnings are
allowed (tracked for tightening). So code:fix must leave the tree
error-free before you push.
A Biome GritQL plugin (biome-plugins/no-type-erasing-assertions.grit) warns
on as any / as never / as unknown in src — type-erasing assertions that
silence the checker instead of narrowing. Fix the type or use a specific
assertion; suppress a deliberate case with // biome-ignore lint/plugin: <reason>. The plugin is scoped to source via an overrides entry in
biome.json (test/integration files excluded) — see the plugin file's header
for why it must be scoped-in rather than globally-enabled-and-exempted.
- Build:
pnpm run build(Turborepo + tsup per package) - Test:
pnpm --filter <pkg> testfor targeted iterations - Releases: Use Changesets
pnpm changeset # create a changeset
pnpm changeset:version
pnpm changeset:publish- Use Vitest with
.test.tsfiles under each package's__tests__/. - Import
dotenv/configat the top when tests need environment variables. - Prefer testing via the public API. Avoid reaching into private internals.
- Some tests have larger timeouts (e.g., 30s) to accommodate network calls.
packages/clihas a second suite — pty-driven E2E tests underpackages/cli/tests/e2e/**run viapnpm --filter stash test:e2e(requires a build). Seepackages/cli/AGENTS.mdfor when to add or update them.
- Two deployment paths:
- Native (default entry): keep
@cipherstash/protect-ffiexternal and loaded via Node's runtime require — e.g. Next.jsserverExternalPackages. Covers Node servers where native modules are fine. - WASM (
@cipherstash/stack/wasm-inline): designed to be bundled — no native module, no externalization. Use for edge/serverless runtimes (Deno, Bun, Cloudflare Workers, Supabase Edge Functions) or wherever bundler externalization is awkward.
- Native (default entry): keep
- For SST/serverless and npm-lockfile-v3 quirks on Linux, see the bundling guide: https://cipherstash.com/docs/stack/deploy/bundling
-
Identify the target package(s) in
packages/*and confirm whether changes affect public APIs or payload shapes. -
If modifying
packages/stackencryption operations orEncryptionClient, 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).
-
If changing schema behavior (
packages/stackschema builders,@cipherstash/stack/schema), update type definitions and ensure validation still works inEncryptionClient.init. -
Add/extend tests in the same package. For features that require live credentials, guard with env checks or provide mock-friendly paths.
-
Run:
pnpm run code:fixpnpm --filter <changed-pkg> buildpnpm --filter <changed-pkg> test
-
If APIs change, update usage examples in this repo and flag that the docs site (cipherstash.com/docs, maintained separately) needs a corresponding update.
-
Keep the meta files honest. If your change adds/removes/renames a package, example, skill, or subpath export, update the Repository Layout in this file and the package list in
SECURITY.mdin the same PR. These files have drifted badly before; don't let them. -
Check the skills. If you changed a package's public API, the CLI command surface, or a user-facing workflow, open the affected
skills/*/SKILL.mdand fix anything your change made wrong — in the same PR. Skills ship inside thestashtarball and are copied into customer repos, so drift here becomes wrong guidance in someone else's codebase. See "Agent Skills — these ship to customers" above for the package→skill map and thestash manifest --jsoncheck. -
Add a changeset before opening or finalising the PR when the change affects a published package's public behaviour or surface (new feature, bug fix, breaking change, UX-visible tweak). Run
pnpm changeset(interactive) or hand-write a markdown file under.changeset/matching the existing format:--- '@cipherstash/<pkg>': minor # or patch / major --- <user-facing description of what changed and why>The repo's
changeset-botGitHub app posts a "🦋 No Changeset found" warning on PRs missing one. Skip changesets only for internal-only changes (test-only PRs, internal refactors with no observable behaviour change, repo tooling). When in doubt, add one — releases use Changesets to drive version bumps andCHANGELOG.mdentries, so a missing changeset means the change ships invisibly.A skills-only change is not internal:
skills/ships inside thestashtarball, so it needs astashpatch changeset.
README.mdfor quickstart and feature overviewpackages/cli/AGENTS.mdfor CLI-specific guidancee2e/README.mdfor the cross-package E2E suiteskills/*/SKILL.mdfor per-integration agent guides- User-facing docs (concepts, reference, how-to) live on the docs site:
- Module load errors on Linux/serverless: switch to
@cipherstash/stack/wasm-inline, or review the bundling guide (https://cipherstash.com/docs/stack/deploy/bundling). - Can't decrypt after encrypting with a lock context: ensure the exact same lock context is provided to decrypt.
- Tests failing due to missing credentials: provide
CS_*env vars; lock-context tests are skipped withoutUSER_JWT. - Performance testing: prefer bulk operations (
bulkEncrypt*/bulkDecrypt*) to exercise ZeroKMS bulk speed.