diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..bdf3caf --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,62 @@ +# Blocking documentation checks for pull requests. +# +# `validate-links` also runs inside `prebuild`, so a broken link already fails +# the Vercel build. This job exists to make that a fast, named status check +# that branch protection can require, rather than a failure buried in a +# deploy log. +name: Docs + +on: + pull_request: + # v2 is the live documentation line. `main` is deliberately excluded: it + # trails v2 by ~100 commits and its bun.lock is out of sync with its + # package.json, so `--frozen-lockfile` fails there for reasons that have + # nothing to do with links. Add `main` here once #37 lands and makes it + # current. + branches: [v2] + push: + branches: [v2] + workflow_dispatch: + +# A PR that gets pushed to twice shouldn't queue behind its own stale run. +concurrency: + group: docs-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + links: + name: Internal links + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + + - uses: oven-sh/setup-bun@v2 + with: + # Pinned rather than `latest` so a bun release can't turn a + # required check red on its own. + bun-version: 1.3.13 + + - run: bun install --frozen-lockfile + + # The TypeDoc references under the V2 integration/reference sections are + # gitignored and built from a clone of cipherstash/stack. Links point + # into them, so without this step those targets report as missing. + # `prebuild` generates before validating for the same reason. + - name: Generate the SDK reference + run: bun run generate-docs + + - name: Generate the Supabase reference + run: bun run generate-docs:supabase + + - name: Generate the Drizzle reference + run: bun run generate-docs:drizzle + + - name: Generate the Prisma reference + run: bun run generate-docs:prisma + + - name: Validate internal links and anchors + run: bun run validate-links diff --git a/.gitignore b/.gitignore index 1520277..ac2a70f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,18 @@ content/stack/reference/stack/latest/ content/stack/reference/stack/v*/ content/stack/reference/drizzle/latest/ content/stack/reference/drizzle/v*/ +content/docs/integrations/prisma/api-reference/* +!content/docs/integrations/prisma/api-reference/meta.json +content/docs/integrations/supabase/api-reference/* +!content/docs/integrations/supabase/api-reference/meta.json +content/docs/integrations/drizzle/api-reference/* +!content/docs/integrations/drizzle/api-reference/meta.json +content/docs/reference/stack/api-reference/* +!content/docs/reference/stack/api-reference/meta.json .tmp-* .env.local + +# serena (local MCP tooling state) +.serena/ +# EQL release manifest extracted by generate-eql-docs.ts (see generate-eql-api-docs.ts) +.eql-manifest.release.json diff --git a/CLAUDE.md b/CLAUDE.md index c827c47..761d148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,46 +102,3 @@ Fumadocs UI theme with custom purple primary color (`hsl(269, 70%, 45%)`). Dark ## Formatting Biome handles both linting and formatting. 2-space indentation. Biome organizes imports automatically. Run `bun run format` before committing. - -## Required Skills - -Always use the following agent skills when working in this repository. These skills contain the canonical API references and must be consulted to ensure documentation accuracy. - -### CipherStash Product Skills - -Use these skills whenever writing or editing documentation about the corresponding product area. They contain the complete, up-to-date API surface, code examples, and type signatures. - -| Skill | Use when editing docs in | -|---|---| -| `encryption` | `content/docs/encryption/` — Schema definition, encrypt/decrypt, searchable encryption, bulk operations, identity-aware encryption, error handling, migration | -| `secrets` | `content/docs/secrets/` — Secrets SDK (`set`/`get`/`getMany`/`list`/`delete`), `stash` CLI usage, environment isolation | -| `drizzle` | `content/docs/encryption/drizzle.mdx` — `encryptedType` column, `extractEncryptionSchema`, `createEncryptionOperators`, encrypted query operators, batched and/or, EQL migrations | -| `supabase` | `content/docs/encryption/supabase.mdx` — `encryptedSupabase` wrapper, transparent encrypt/decrypt on insert/update/select, query filters (eq, like, gt, in, or, match), identity-aware encryption | -| `dynamodb` | `content/docs/encryption/dynamodb.mdx` — `encryptedDynamoDB` helper, `__source`/`__hmac` attribute naming, encrypted partition/sort keys, bulk operations, audit logging | - -### Documentation Update Skill - -Use the `update-docs` skill whenever: -- Creating new documentation pages -- Updating existing documentation based on code changes -- Reviewing documentation completeness for a PR -- Scaffolding docs for a new feature - -The `update-docs` skill enforces the following workflow: - -1. **Analyze changes** — Diff the branch to identify affected files -2. **Map to docs** — Use the code-to-docs mapping to find which MDX files need updates -3. **Review each doc** — Walk through updates with user confirmation before editing -4. **Validate** — Run `bun run lint` to check formatting -5. **Commit** — Stage documentation changes - -#### Documentation Validation Checklist - -Before committing documentation changes, verify: - -- Frontmatter has `title` and `description` -- Code blocks have `filename` attribute -- TypeScript examples come first, with `switcher` and a JS variant where appropriate -- Props/options tables are properly formatted -- Notes use the `> **Good to know**:` callout pattern -- `bun run lint` passes diff --git a/IA.md b/IA.md new file mode 100644 index 0000000..a3b8254 --- /dev/null +++ b/IA.md @@ -0,0 +1,448 @@ +# Docs V2 — Information Architecture & migration checklist + +Living checklist for the docs overhaul. Tracked in Linear under +[CIP-3307](https://linear.app/cipherstash/issue/CIP-3307); the full IA rationale +(design principles, audience doors, correctness strategy) lives in +`CipherStash docs IA v1.md` in the content repo. **Tick items here as they land +on the `v2` branch.** Legend: `[ ]` todo · `[x]` done · 🚧 stub exists · ⛔ blocked +on a product decision (see CIP-3307 checklist). + +## How this branch works + +- New IA lives in `content/docs`, served from the site root (`/docs/
/…`). +- The legacy tree (`content/stack`) is served alongside it at `/docs/stack/…` + until every section migrates, then deleted (CIP-3335). +- The full legacy→v2 redirect map is `v2-redirects.mjs`, gated behind + `ENABLE_V2_REDIRECTS=1` (flipped on at merge). `bun run validate-redirects` + enforces that every legacy page has a mapping. +- Frontmatter facets (`type`, `components`, `audience`, `integration`, + `verifiedAgainst`, `reviewBy`) are defined in `source.config.ts` (`v2docs`). +- **Moving a page** = move the file into `content/docs`, update its facets, + fix inbound links, confirm its `v2-redirects.mjs` entry, tick it here. + +## URL conventions + +Lowercase, hyphens, no trailing slashes, no version numbers in paths. +Integrations are **flat** (no category segment). Error pages (future, miette) +live at `/docs/errors/` — permanent, never restructured (CIP-3338). + +--- + +## Content model — two axes + +The migration checklist below tracks _what_ moves; this section fixes _where +things go and why_, so placement stops being re-litigated per PR. + +### Terms + +- **Mode** — what the reader is trying to do. The four Diátaxis kinds + (tutorial, how-to, reference, explanation), plus our three audience doors + (integrations, solutions, security) which serve the same placement role. + A page has exactly one mode; the mode decides its sidebar home. +- **Component** — a layer of the product stack, drawn from the `components` + facet enum: `encryption` (Stack SDK), `platform` (CTS + ZeroKMS), `eql`, + `proxy`, `cli`. A page can touch several components at once. +- **Facet** — structured frontmatter that classifies a page along one axis + (`type`, `components`, `audience`, `integration`). Queryable; never shown + raw to users; independent of nav position. +- **Hub** — a per-component page at `/components/` that gathers + every page tagged with that component. A generated view, not a subtree. + Not every component has a hub: hubs are for _layers_ of the stack + (`encryption`, `platform`, `eql`, `proxy`). `cli` is a component but a + cross-cutting tool, not a layer, so it has no hub. +- **Locator** — the one hand-written paragraph (plus mini stack diagram) at the + top of a hub, orienting the reader: what the component is, what sits above and + below it. Prose, nothing else. + +### The rule + +**The sidebar is organized only by mode. The by-component view lives only on the hubs.** + +CipherStash is a dependency stack, not a set of independent product pillars: +integrations sit on the Stack SDK and Proxy, both consume EQL, and EQL sits on +the Platform (CTS + ZeroKMS). That is a graph. A sidebar is a tree. So we split +the two axes rather than forcing the graph into the tree. + +- **Mode axis (the tree).** Every page has exactly one home, chosen by _what the + reader is doing_: Diátaxis mode (`get-started`, `guides`, `reference`, + `concepts`) plus three audience doors (`integrations`, `solutions`, + `security`). The sidebar is built from `meta.json` and nothing else. +- **Component axis (the graph).** Which layers a page touches is the `components` + facet — never a sidebar section. The dependency graph is expressed by facets + and surfaced by hub pages, so a shared layer like EQL is documented once and + referenced everywhere. + +Why the pillar layouts (Supabase, Clerk, GitHub) don't transfer: those are +feature-pillar products whose parts are independent, so each pillar can be its +own subtree. Our parts are layers in a dependency stack — a shared layer has no +single natural parent, so it cannot be a tree node without duplication. + +### Placement test + +Two questions, in this order, for every page: + +1. _Which mode is this?_ → decides the folder (its one sidebar home). +2. _Which components does it touch?_ → sets the `components` facet. + +Never the reverse. If you are tempted to file a page _under_ a component, that is +the signal to file it by mode and tag the component instead. + +### Invariants + +- The sidebar tree is `meta.json` only. Facets never affect nav position + (already asserted by the schema comment in `source.config.ts`). +- A page appears in exactly one place in the tree. Cross-cutting reuse happens + through links and hubs, never by duplicating a page under a second parent. +- Shared mechanics have exactly one canonical page; every other page links, + never restates. This generalises the existing EQL rule (mechanics live in + `/reference/eql/core-concepts`; category pages link to it) to every component. +- Each component has exactly one hub at `/components/`. A hub is a + facet-driven _view_: it links to canonical pages and owns no mechanics. +- `/get-started/what-is-cipherstash` renders the components map; its nodes link + to the hubs. The hubs are the _only_ component-first surface, and they hold no + primary content — so they do not recreate the legacy component-first tree + (the old `root: true` Encryption/KMS/Proxy/Platform tabs). + +#### A note on hub URLs + +Hubs live at the top-level path `/components/` for a short, memorable +front door, but are anchored in the sidebar under Get started (Fumadocs takes +nav position from `meta.json` independently of the URL). If you would rather the +URL mirror the nav, use `/get-started/components/` instead — either +works; pick one and add it to `v2-redirects.mjs`. + +### Sidebar + +Mode-first, seven top-level sections. `compare` is folded into `concepts` (each +comparison already belongs to a door: aws-kms and vault are security-review +material, fhe and rls-and-tde are concepts). Component hubs are anchored under +Get started, not given their own top-level tab. + +``` +Get started +├─ What is CipherStash mental model · components map · audience router +├─ Quickstart +├─ Choose your stack platform × ORM × auth matrix +├─ Examples +└─ Components ← hub views (facet-driven, link-only) + ├─ Stack SDK + ├─ Proxy + ├─ EQL (shared) + └─ Platform (CTS + ZeroKMS) +Integrations flat; category grid on the index +├─ Supabase (Database · Auth · Dashboard) +├─ Drizzle · Prisma · Next.js · TypeScript +├─ Clerk · Auth0 · Okta +├─ AWS (RDS/Aurora · DynamoDB) +└─ Serverless · Docker +Concepts +├─ Privacy-first design +├─ Application-level encryption +├─ Searchable encryption canonical leakage model +├─ EQL typed-column model +├─ Key management +├─ Threat modelling +└─ Compare aws-kms · fhe · rls-and-tde · hashicorp-vault +Guides +├─ Development local setup · schema design · testing & CI · onboarding +├─ Migration encrypt existing data · adopt incrementally · key rotation +├─ Deployment production · serverless & bundling · proxy deployment +└─ Troubleshooting query performance · runtime errors · cli · proxy +Security +├─ Architecture one reconciled ZeroKMS story +├─ ZeroKMS · CTS · Stack SDK · Proxy +├─ Threat scenarios +├─ Availability · Audit logging · Key ownership +└─ Compliance HIPAA · SOC 2 · GDPR +Solutions +└─ Protecting PII · Healthcare/HIPAA · AI & RAG · Data residency · Provable access +Reference +├─ EQL core-concepts · numbers · dates · text · json · … · joins +├─ Stack SDK overview · usage · generated package API +├─ Auth lock-contexts · cts-tokens · oidc · access-keys +├─ CLI · Proxy · Workspace +└─ Benchmarks · Agent skills · Glossary +``` + +### Hub pages + +One per component, at `/components/`. Same template every time: + +1. **Locator** — one paragraph plus the stack diagram with this layer + highlighted: what it is, what it sits on, what sits on it. +2. **Start here** — the one or two canonical entry pages. +3. **By mode** — grouped links (Concepts · Guides · Reference · Security · + Integrations that use it). Every entry links to its canonical mode-home. +4. Nothing else. No mechanics, no examples that live elsewhere. + +The "By mode" lists are generated, not hand-maintained: filter +`source.getPages()` to pages whose `components` facet includes this component, +then group by `type`. "Integrations that use it" is the subset whose frontmatter +also carries an `integration` block. This is why the facet earns its keep — the +hubs cost nothing to maintain once a page is correctly tagged. + +**Tagging rule — `eql` is conditional, not automatic.** Stack encrypts values +in three modes: general-purpose (a value in your app), Postgres columns, and +non-Postgres stores like DynamoDB. Only the Postgres path involves EQL. So tag +`eql` only when the page is actually about queryable-in-Postgres ciphertext. +General-purpose and DynamoDB pages are `components: [encryption, platform]` with +_no_ `eql` — DynamoDB (`encryptedDynamoDB`) is the canonical no-EQL example and +the reason "Stack depends on EQL" is wrong. EQL is the Postgres searchability +layer, not a layer every Stack page sits on. + +#### Stack SDK — `/components/stack-sdk` (facet: `encryption`) + +- Locator: the TypeScript SDK; encrypt and decrypt values in your app. Sits on + the Platform. Add EQL when the data lives in Postgres and you want the + ciphertext queryable there — but Stack also encrypts general-purpose values + and non-Postgres stores (e.g. DynamoDB) with no EQL at all. +- Start here: Quickstart · Reference → Stack SDK (client + configuration) +- Concepts: application-level encryption · searchable encryption · key management +- Guides: schema design · encrypt existing data · testing & CI · serverless & bundling +- Reference: `/reference/stack/*` (overview · usage · generated package API) +- Security: `/security/stack-sdk` +- Integrations (auto): Supabase · Drizzle · Prisma · DynamoDB · Next.js … + +#### Proxy — `/components/proxy` (facet: `proxy`) + +- Locator: the no-app-changes path; sits in front of Postgres and speaks EQL for you. +- Start here: Reference → Proxy (configuration) · Guides → Proxy deployment +- Concepts: application-level encryption · searchable encryption +- Guides: proxy deployment · going to production +- Reference: `/reference/proxy/*` (configuration · message-flow · multitenant · errors) +- Security: `/security/proxy` +- Integrations (auto): AWS RDS/Aurora · Docker +- See also: EQL hub (shared dependency) + +#### EQL — `/components/eql` (facet: `eql`) ← the shared spine + +- Locator: the Postgres searchability layer. Makes ciphertext queryable in + Postgres by declaring an encrypted column's capability in the schema. The + Proxy always speaks EQL; the Stack SDK uses it only on its Postgres path (not + for general-purpose or DynamoDB encryption). +- Start here: Reference → EQL (install) · EQL core-concepts +- Concepts: EQL (typed-column model) · searchable encryption (leakage model) +- Reference: the whole `/reference/eql/*` tree +- Guides: troubleshooting → query performance +- Consumed by: Proxy hub (always) · Stack SDK hub (Postgres path only) +- Anti-drift: mechanics live in `/reference/eql/core-concepts`; this hub links only. + +#### Platform — `/components/platform` (facet: see vocab note) ← the foundation + +- Locator: the base everything relies on — key management (ZeroKMS) and identity-bound access (CTS). +- Start here: Security → Architecture · Concepts → Key management +- Concepts: key management +- Reference: `/reference/auth/*` (lock-contexts · cts-tokens · oidc · access-keys) +- Security: architecture · zerokms · cts · availability · audit logging · key ownership +- Integrations (auto): Clerk · Auth0 · Okta + +### Worked example: how auth fits + +Auth is the concern most likely to feel like it needs its own section — it spans +the SDK, the Proxy, local dev, the Platform, and every auth provider. It doesn't +get one. It's the case that shows the model absorbing a cross-cutting concern +without bending: auth isn't a _layer_, it's a concern that shows up _on_ layers, +so it lives as facets and links, never as a tree section or a hub. + +"Auth" names several distinct things. Keep them separate: + +| Thing | What it is | `components` | Canonical home | +|---|---|---|---| +| CTS | Identity service in the Platform | `platform` | `/security/cts`, `/reference/auth/*` | +| `@cipherstash/auth` | Stack package (identity-aware encryption) | `[encryption, platform]` | `/reference/stack/auth` | +| Proxy stack-auth | How auth works inside the Proxy | `[proxy, platform]` | `/reference/proxy/*`, `/security/proxy` | +| Clerk / Auth0 / Okta | Auth-provider integrations | `[platform]` | `/integrations/*` (`category: auth-provider`) | + +None of these is filed under an "auth" section, because there isn't one. Each is +filed by _mode_ (service → Security/Reference, package → Reference, providers → +Integrations) and tagged by _component_. The through-line is reassembled by +queries, not by a subtree: + +- The Platform hub gathers all of it — everything above carries `platform`. +- Faceted search on `integration.category: auth-provider` gives the providers. +- `/solutions/provable-access` is the explanatory page that ties identity-bound + access together in prose; everything else links to it (anti-drift rule). + +Naming caution: `/reference/auth/*` means the CTS _service_; the `@cipherstash/auth` +_package_ lives at `/reference/stack/auth`. Two different things both called +"auth" — keep the paths distinct so contributors don't merge them. + +The `components` facet enum is `[encryption, platform, eql, proxy, cli]` +(collapsing the former `auth` and `zerokms` into a single `platform`, matching +the product story "Platform = CTS + ZeroKMS"). This maps 1:1 onto the hubs: +`encryption` → Stack SDK, `platform`, `eql`, `proxy`. + +Two notes: + +- The `encryption` facet value and its hub title **Stack SDK** differ on + purpose — the facet names the capability, the hub names the product. Document + once, move on. +- Collapsing `auth`/`zerokms` into `platform` means you can't isolate the + CTS/identity pages by facet query anymore — a `platform` query returns all of + Platform. That distinction still lives in nav location (`/security/cts` vs + `/security/zerokms`) and in `integration.category: auth-provider` for the + Clerk/Auth0/Okta pages, so it isn't lost — it just isn't on the `components` + axis. + +--- + +## Get started — CIP-3327 + +- [x] Section scaffold 🚧 +- [ ] `/get-started/what-is-cipherstash` — mental model, components map, audience router +- [ ] `/get-started/quickstart` — rewritten on EQL v3 (fixes `cs_match_v1`, broken scaffold imports) +- [ ] `/get-started/choose-your-stack` — static matrix v1 (platform × ORM × auth) +- [ ] `/get-started/examples` — runnable example apps index +- [ ] `/docs` landing page 🚧 — now `content/docs/index.mdx` rendered inside the docs + nav (the old standalone `(home)` route is deleted; recoverable from git history). + CIP-3327 refines the content (what-is + audience router) + +## Integrations — CIP-3328 (Supabase), CIP-3330 (auth), CIP-3336 (rest) + +- [x] Section scaffold 🚧 (index + supabase stub with facet exemplar) +- [x] `/integrations` index — category grid w/ setup badges +- [x] `/integrations/supabase` — flagship tutorial (CIP-3328) +- [x] `/integrations/supabase/database` +- [x] `/integrations/supabase/auth` +- [x] `/integrations/supabase/dashboard-experience` — Table Editor, expose eql schema +- [ ] ⛔ `/integrations/supabase/edge-functions` — pending Deno/FFI answer +- [ ] ⛔ `/integrations/supabase/realtime` — pending product verification +- [x] `/integrations/drizzle` — overview and generated `@cipherstash/stack-drizzle` API reference +- [x] `/integrations/prisma` — Prisma ORM 8 RC, EQL v3, Prisma Postgres, Prisma Compute, and generated API reference +- [ ] `/integrations/aws/rds-aurora` — Proxy path +- [x] `/integrations/aws/dynamodb` — Stack 1.0 helper, equality lookups, and legacy reads +- [ ] `/integrations/clerk` +- [ ] `/integrations/auth0` — end-to-end example (Clerk parity) +- [ ] `/integrations/okta` — end-to-end example (Clerk parity) +- [ ] `/integrations/typescript` — thin router to Stack SDK reference +- [ ] `/integrations/serverless` — Vercel/Lambda, bundling, CS_CONFIG_PATH +- [ ] `/integrations/docker` +- [ ] ⛔ `/integrations/edge-workers` — pending Deno/workerd answer + +## Concepts — CIP-3333 (searchable-encryption), others per section tickets + +- [x] Section scaffold 🚧 +- [ ] `/concepts/privacy-first-design` +- [ ] `/concepts/application-level-encryption` — vs TDE/pgcrypto/RLS +- [x] `/concepts/searchable-encryption` — REWRITE with honest leakage model (canonical leakage page) +- [ ] `/concepts/eql` — the typed-column model (declare capability in the schema) +- [x] `/concepts/key-management` — per-value keys, split control, revocation, isolation, and audit +- [ ] `/concepts/threat-modelling` + +## Comparisons — CIP-3333 + +Folded into Concepts (see the sidebar spec above): the comparison pages live at +`/concepts/compare/*`, not a top-level `/compare` tab. `/stack/reference/comparisons` +and the old `/compare/*` paths redirect there (`v2-redirects.mjs`). + +- [x] Section scaffold 🚧 (moved under `concepts/`) +- [ ] `/concepts/compare/aws-kms` (port) +- [ ] `/concepts/compare/fhe` (port) +- [ ] `/concepts/compare/zerokms-vs-hsm` (ZeroKMS vs hardware security modules) +- [ ] `/concepts/compare/rls-and-tde` (new — expand the Supabase-listing RLS contrast) +- [ ] `/concepts/compare/hashicorp-vault` (in flight on `docs/vault-comparison` branch — land there or here, then port) + +## Guides + +- [x] Section scaffold 🚧 (development, migration, deployment, troubleshooting) +- [ ] `/guides/development/local-setup` — profiles, device auth, workspaces, keys +- [ ] `/guides/development/schema-design` — which encrypted type/variant per column (CIP-3327) +- [ ] `/guides/development/testing-and-ci` (port deploy/testing) +- [ ] `/guides/development/team-onboarding` (port) +- [x] `/guides/migration` — the backfill guide, runnable (CIP-3329) +- [ ] ⛔ `/guides/migration/upgrading-from-eql-v2` — REQUIRED; mechanics pending product answer (CIP-3329) +- [ ] `/guides/migration/adopting-incrementally` (CIP-3329) +- [ ] `/guides/migration/key-rotation-operations` +- [x] `/guides/deployment` — production rollout, bundling, CI, onboarding, and Proxy deployment consolidated into one page +- [ ] `/guides/troubleshooting` index — symptom-based router +- [ ] `/guides/troubleshooting/query-performance` — seq-scan diagnosis, typed-operand gotcha +- [ ] `/guides/troubleshooting/runtime-errors` +- [ ] `/guides/troubleshooting/cli` (port) +- [ ] `/guides/troubleshooting/proxy` (port) + +## Architecture & security — CIP-3331, CIP-3332 (compliance) + +- [x] Section scaffold 🚧 +- [x] `/security/cryptography` — ONE reconciled ZeroKMS mechanism story (kills the 3 conflicting accounts) +- [ ] `/security/zerokms` +- [x] `/security/cts` — auth layer architecture (hidden from nav until the section is expanded) +- [ ] `/security/stack-sdk` +- [ ] `/security/proxy` +- [ ] `/security/threat-scenarios` +- [ ] ⛔ `/security/availability-and-continuity` — DR (port) + SLA + exit story; pending SLA answer +- [x] `/security/audit-logging` — Proxy event mechanics; retention/export remains deployment-specific and the nav stays hidden +- [ ] ⛔ `/security/key-ownership` — BYOK/self-hosted; pending product answer +- [x] `/security/compliance` index — capability/responsibility mapping (nav remains hidden) +- [ ] `/security/compliance/hipaa` — BAA scope, §164.312 mapping (CIP-3332) +- [ ] `/security/compliance/soc2` — verify Type II report exists +- [ ] `/security/compliance/gdpr` + +## Solutions + +- [x] Section scaffold 🚧 +- [ ] `/solutions/protecting-pii` (new) +- [ ] `/solutions/healthcare-hipaa` (new; pairs with compliance/hipaa) +- [x] `/solutions/ai-and-rag` — source protection, retrieval flow, and isolation boundaries +- [ ] `/solutions/data-residency` (port) +- [ ] `/solutions/provable-access` (port) + +## Reference + +- [x] Section scaffold 🚧 (eql, stack, auth, cli, proxy, workspace) +- **EQL (v3 rewrite — CIP-3326; Tailwind-shaped: install → core concepts → type + categories → indexes → query patterns). Anti-drift rule: shared mechanics + (typed operands, blockers, envelope, variant model, ORE-equality) live ONLY in + core-concepts — category/query pages link, never restate:** +- [x] `/reference/eql` — install (single SQL file, permissions split, dbdev, Docker) +- [x] `/reference/eql/core-concepts` — variant model, payload anatomy (absorbs + cipher-cell), typed-operand rule, fail-loud blockers, term leakage pointer +- [x] `/reference/eql/numbers` — int*/float*/numeric +- [x] `/reference/eql/dates-and-times` — date/timestamp (same traits as numbers, + distinct semantics) +- [x] `/reference/eql/text` — all six text variants; owns the no-LIKE treatment +- [x] `/reference/eql/json` — ste_vec + sv payload shape + containment/path queries +- [x] `/reference/eql/booleans` — storage-only variants (bool has only that one) +- [x] `/reference/eql/indexes` — functional indexes on extractors; Supabase-compatible +- [x] `/reference/eql/filtering` — =, IN, ranges, token match, containment +- [x] `/reference/eql/sorting` — ORDER BY, extractor sort-key form, pagination +- [x] `/reference/eql/grouping-and-aggregates` — GROUP BY/DISTINCT, min/max, no SUM/AVG +- [x] `/reference/eql/joins` — equijoins, the same-keyset constraint +- [ ] ⛔ `/reference/eql/query-performance` — port the EQL repo performance guide once + rewritten for v3 upstream (v3 branch folded it into database-indexes.md; verify + nothing from the v2 guide on main was lost) — see CIP-3351 +- **Stack SDK:** +- [x] `/reference/stack` — core package overview and usage +- [x] `/reference/stack/api-reference` — generated `@cipherstash/stack` API +- [x] `/integrations/supabase/api-reference` — generated `@cipherstash/stack-supabase` API (CIP-3328) +- **Auth (CIP-3330):** +- [ ] `/reference/auth/lock-contexts` +- [ ] `/reference/auth/cts-tokens` +- [x] `/reference/auth/oidc-configuration` +- [x] `/reference/auth/access-keys` + `/reference/auth/clients` +- **CLI / Proxy / Workspace:** +- [ ] `/reference/cli/*` (port 9 pages) +- [ ] `/reference/proxy/*` (configuration, message-flow, multitenant, errors) +- [ ] `/reference/workspace/billing` + `/members` + `/configuration` +- **Cross-cutting:** +- [x] `/reference/benchmarks` — listing numbers + methodology (CIP-3334) +- [ ] `/reference/agent-skills` (port; expand per CIP-3339) +- [ ] `/reference/glossary` (port) +- [ ] Repoint `scripts/generate-docs.ts` TypeDoc output → `content/docs/reference/stack` + +## Infrastructure / final pass + +- [x] `v2` branch + this checklist +- [x] `v2docs` collection + facet schema (`source.config.ts`) +- [x] Root catch-all routes (`src/app/[...slug]`), llms.mdx mirror, sitemap/llms.txt include v2 +- [x] `v2-redirects.mjs` (flag-gated) + `validate-redirects` gate in prebuild +- [x] `/quickstart` vanity redirect +- [ ] OG images for v2 pages (route only covers legacy tree) +- [ ] Correctness CI: snippet type-checking, SQL-vs-EQL-Docker, terminology lint (CIP-3337) +- [ ] llms.txt curation + Cloudflare AI crawl policy + md-degradation check (CIP-3339) +- [x] EQL 3.0.4 release alignment (CIP-3352) — the EQL reference and CLI target + the released v3 schema and payload. +- [x] Stack SDK and Supabase-wrapper v3 alignment (CIP-3355) — + `@cipherstash/stack` and `@cipherstash/stack-supabase` 1.0.0 are released, + and the integration docs target their stable v3 surfaces. +- [ ] Flip `ENABLE_V2_REDIRECTS=1`, delete `content/stack` + `/stack` routes + legacy loader (CIP-3335) +- [ ] Consistency sweep + Supabase listing v3 revision (CIP-3335) diff --git a/biome.json b/biome.json index 95df3a1..ebbb858 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -7,7 +7,15 @@ }, "files": { "ignoreUnknown": true, - "includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!.source"] + "includes": [ + "**", + "!node_modules", + "!.next", + "!dist", + "!build", + "!.source", + "!src/app/global.css" + ] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 8a38c5d..7af7f31 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,8 @@ "fumadocs-ui": "16.6.0", "github-slugger": "^2.0.0", "lucide-react": "^0.563.0", - "next": "16.2.3", + "mermaid": "^11.16.0", + "next": "16.2.6", "posthog-js": "^1.354.0", "posthog-node": "^5.26.0", "react": "^19.2.4", @@ -20,10 +21,12 @@ "devDependencies": { "@biomejs/biome": "^2.3.14", "@tailwindcss/postcss": "^4.1.18", + "@types/jsdom": "^28.0.3", "@types/mdx": "^2.0.13", "@types/node": "^25.2.1", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "tsx": "^4.0.0", @@ -37,6 +40,16 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@biomejs/biome": ["@biomejs/biome@2.3.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="], @@ -55,6 +68,24 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.9", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.6", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -109,6 +140,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "1.7.4", "@floating-ui/utils": "0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], @@ -125,6 +158,10 @@ "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.22.0", "", { "dependencies": { "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "10.0.2" } }, "sha512-jMpciqEVUBKE1QwU64S4saNMzpsSza6diNCk4MWAeCxO2+LFi2FIFmL2S0VDLzEJCxuvCbU783xi8Hp/gkM5CQ=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -187,23 +224,25 @@ "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "1.0.8", "@types/estree-jsx": "1.0.5", "@types/hast": "3.0.4", "@types/mdx": "2.0.13", "acorn": "8.15.0", "collapse-white-space": "2.1.0", "devlop": "1.1.0", "estree-util-is-identifier-name": "3.0.0", "estree-util-scope": "1.0.0", "estree-walker": "3.0.3", "hast-util-to-jsx-runtime": "2.3.6", "markdown-extensions": "2.0.0", "recma-build-jsx": "1.0.0", "recma-jsx": "1.0.1", "recma-stringify": "1.0.0", "rehype-recma": "1.0.0", "remark-mdx": "3.1.1", "remark-parse": "11.0.0", "remark-rehype": "11.1.2", "source-map": "0.7.6", "unified": "11.0.5", "unist-util-position-from-estree": "2.0.0", "unist-util-stringify-position": "4.0.0", "unist-util-visit": "5.1.0", "vfile": "6.0.3" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], - "@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="], + "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -373,14 +412,80 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "8.5.6", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "2.1.0" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "1.0.8" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/jsdom": ["@types/jsdom@28.0.3", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "3.0.3" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], @@ -393,12 +498,16 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "19.2.14" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "8.15.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -415,6 +524,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], @@ -441,20 +552,106 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "3.1.1", "shebang-command": "2.0.0", "which": "2.0.2" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "2.0.2" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -463,11 +660,13 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "2.0.3" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "4.2.11", "tapable": "2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "1.0.5", "devlop": "1.1.0", "estree-util-visit": "2.0.0", "unist-util-position-from-estree": "2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], @@ -517,6 +716,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "3.0.4", "@types/unist": "3.0.3", "devlop": "1.1.0", "hastscript": "9.0.1", "property-information": "7.1.0", "vfile": "6.0.3", "vfile-location": "5.0.3", "web-namespaces": "2.0.1" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "3.0.4" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], @@ -537,12 +738,20 @@ "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "3.0.4", "comma-separated-tokens": "2.0.3", "hast-util-parse-selector": "4.0.0", "property-information": "7.1.0", "space-separated-tokens": "2.0.2" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "2.0.1", "is-decimal": "2.0.1" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -553,12 +762,22 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -585,10 +804,14 @@ "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "2.1.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "19.2.4" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], "lunr": ["lunr@2.3.9", "", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], @@ -601,6 +824,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "escape-string-regexp": "5.0.0", "unist-util-is": "6.0.1", "unist-util-visit-parents": "6.0.2" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "4.0.4", "@types/unist": "3.0.3", "decode-named-character-reference": "1.3.0", "devlop": "1.1.0", "mdast-util-to-string": "4.0.0", "micromark": "4.0.2", "micromark-util-decode-numeric-character-reference": "2.0.2", "micromark-util-decode-string": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2", "unist-util-stringify-position": "4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], @@ -633,8 +858,12 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "4.0.4" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "4.1.12", "debug": "4.4.3", "decode-named-character-reference": "1.3.0", "devlop": "1.1.0", "micromark-core-commonmark": "2.0.3", "micromark-factory-space": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-chunked": "2.0.1", "micromark-util-combine-extensions": "2.0.1", "micromark-util-decode-numeric-character-reference": "2.0.2", "micromark-util-encode": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-resolve-all": "2.0.1", "micromark-util-sanitize-uri": "2.0.1", "micromark-util-subtokenize": "2.1.0", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "1.3.0", "devlop": "1.1.0", "micromark-factory-destination": "2.0.1", "micromark-factory-label": "2.0.1", "micromark-factory-space": "2.0.1", "micromark-factory-title": "2.0.1", "micromark-factory-whitespace": "2.0.1", "micromark-util-character": "2.1.1", "micromark-util-chunked": "2.0.1", "micromark-util-classify-character": "2.0.1", "micromark-util-html-tag-name": "2.0.1", "micromark-util-normalize-identifier": "2.0.1", "micromark-util-resolve-all": "2.0.1", "micromark-util-subtokenize": "2.1.0", "micromark-util-symbol": "2.0.1", "micromark-util-types": "2.0.2" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -719,7 +948,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="], + "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -729,9 +958,13 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "0.12.1", "regex": "6.1.0", "regex-recursion": "6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "package-manager-detector": ["package-manager-detector@1.7.0", "", {}, "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "2.0.11", "character-entities-legacy": "3.0.0", "character-reference-invalid": "2.0.1", "decode-named-character-reference": "1.3.0", "is-alphanumerical": "2.0.1", "is-decimal": "2.0.1", "is-hexadecimal": "2.0.1" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -741,6 +974,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "3.0.0", "util-deprecate": "1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], @@ -755,6 +992,8 @@ "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "1.1.2", "@protobufjs/base64": "1.1.2", "@protobufjs/codegen": "2.0.4", "@protobufjs/eventemitter": "1.1.0", "@protobufjs/fetch": "1.1.0", "@protobufjs/float": "1.0.2", "@protobufjs/inquire": "1.1.0", "@protobufjs/path": "1.1.2", "@protobufjs/pool": "1.1.0", "@protobufjs/utf8": "1.1.0", "@types/node": "25.2.3", "long": "5.3.2" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -803,8 +1042,20 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "4.0.4", "mdast-util-to-markdown": "2.1.2", "unified": "11.0.5" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "3.1.1" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], @@ -833,6 +1084,10 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -843,10 +1098,20 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tldts": ["tldts@7.4.7", "", { "dependencies": { "tldts-core": "^7.4.7" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw=="], + + "tldts-core": ["tldts-core@7.4.7", "", {}, "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "0.27.3", "get-tsconfig": "4.13.6" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], @@ -861,7 +1126,9 @@ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "undici-types": ["undici-types@7.28.0", "", {}, "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "3.0.3", "bail": "2.0.2", "devlop": "1.1.0", "extend": "3.0.2", "is-plain-obj": "4.1.0", "trough": "2.2.0", "vfile": "6.0.3" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -885,18 +1152,32 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "vfile-message": "4.0.3" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "vfile": "6.0.3" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "3.0.3", "unist-util-stringify-position": "4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-vitals": ["web-vitals@5.1.0", "", {}, "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -921,17 +1202,35 @@ "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "19.2.14" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "fumadocs-core/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "2.9.19", "caniuse-lite": "1.0.30001769", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "@opentelemetry/api": "1.9.0", "sharp": "0.34.5" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" }, "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], "fumadocs-mdx/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "2.9.19", "caniuse-lite": "1.0.30001769", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "@opentelemetry/api": "1.9.0", "sharp": "0.34.5" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" }, "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], "fumadocs-ui/next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "2.9.19", "caniuse-lite": "1.0.30001769", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "@opentelemetry/api": "1.9.0", "sharp": "0.34.5" }, "peerDependencies": { "react": "19.2.4", "react-dom": "19.2.4" }, "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "6.0.1" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "markdown-it/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "posthog-js/dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "fumadocs-core/next/@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], @@ -992,5 +1291,7 @@ "fumadocs-ui/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], "fumadocs-ui/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], } } diff --git a/content/docs/concepts/compare/aws-kms.mdx b/content/docs/concepts/compare/aws-kms.mdx new file mode 100644 index 0000000..bd2a380 --- /dev/null +++ b/content/docs/concepts/compare/aws-kms.mdx @@ -0,0 +1,103 @@ +--- +title: CipherStash vs AWS KMS +navTitle: AWS KMS +description: "Compare AWS KMS envelope encryption with CipherStash ZeroKMS: trust boundaries, per-value keys, batching, audit, searchable encryption, and where each fits." +type: concept +components: [encryption, platform] +audience: [developer, cto, ciso] +reviewBy: "2027-01-31" +--- + +AWS Key Management Service and CipherStash overlap at key management, but they operate at different layers. + +AWS KMS is a general-purpose cloud key service. It protects root keys in AWS-managed hardware security modules and performs cryptographic operations under IAM control. CipherStash is an application-data protection system: the Stack SDK or CipherStash Proxy encrypts individual values, while ZeroKMS supplies one half of a split key-derivation process. + +They are often complementary. AWS KMS is a strong fit for infrastructure encryption and conventional envelope encryption. CipherStash is designed for field-level application data that needs per-value isolation, searchable ciphertext, and a key service that cannot decrypt the data by itself. + +## The architectural difference + +The conventional AWS KMS pattern is envelope encryption: + +1. The application asks KMS to generate or unwrap a data encryption key. +2. KMS returns the plaintext data key to the authorized application. +3. The application encrypts data locally. +4. It stores the wrapped copy of the data key beside the ciphertext. +5. Decryption sends the wrapped key back to KMS to recover the plaintext data key. + +ZeroKMS does not return a data key: + +1. ZeroKMS produces a key seed from its authority key and the value's key ID. +2. The application processes that seed with a client key that never leaves its environment. +3. A unique data key comes into existence locally for one value. +4. The data key is discarded after the operation; it is never stored or transmitted. + +The full mechanism is described in [Cryptography](/security/cryptography#how-a-data-key-is-produced). + +| Property | AWS KMS envelope encryption | CipherStash ZeroKMS | +| --- | --- | --- | +| Root of trust | KMS key protected by AWS-managed HSMs | ZeroKMS authority key plus a client key held by the application | +| Material returned to the client | Plaintext and wrapped data key when using `GenerateDataKey`, or a plaintext key from `Decrypt` | A key seed that is unusable without the client key | +| Data-key persistence | Wrapped data key is normally stored with the ciphertext | Data key is never stored; the payload carries a reproducible key ID | +| Key granularity | Chosen by the application; keys are commonly reused or cached | Unique data key for every encrypted value | +| Server acting alone | KMS can perform an authorized decrypt operation | ZeroKMS cannot derive a usable data key without the client side of the split | + +## Trust and compromise + +AWS KMS keeps key material inside its managed boundary and gives customers detailed IAM policies, grants, encryption context, rotation, and CloudTrail integration. The application still needs credentials that authorize KMS operations, and an authorized KMS response contains the plaintext data key or plaintext data. + +ZeroKMS separates the capability across two systems. Stealing only the application's client key does not permit offline decryption of historical ciphertext, because the attacker still needs an authorized ZeroKMS interaction. Compromising only ZeroKMS is also insufficient because it never receives the client key. + +A live attacker controlling an authorized application can still request operations available to that application until access is revoked. The difference is that the capability remains scoped by keyset and authorization, is observed outside the application, and cannot be copied once as a reusable master key. See the [ZeroKMS trust model](/security/cryptography#trust-model). + +## Per-value keys without data-key caching + +AWS KMS cryptographic APIs operate on individual requests and share regional request quotas. AWS recommends data-key caching when applications need to reduce `GenerateDataKey` traffic. Caching improves throughput by allowing one plaintext data key to protect multiple values, but it also increases the amount of data that key can decrypt. + +ZeroKMS batches the server-side work, then derives each value's distinct key locally. A bulk operation therefore avoids one network round trip per value without turning the batch into one cryptographic failure domain. + +This distinction matters for reads as well as writes. With envelope encryption, independently keyed values require their wrapped keys to be unwrapped. Reusing cached data keys reduces those calls only by placing more values under each reusable key. ZeroKMS retains per-value granularity across scattered query results. + +## Audit granularity + +CloudTrail can record the KMS API operation, principal, KMS key, and request context. If one unwrapped or cached data key decrypts many application values locally, KMS cannot infer which of those values the application read. + +CipherStash key derivation is tied to the value's key ID and keyset. ZeroKMS participates before plaintext recovery, so the independent audit event follows the same per-value boundary as the encryption design. When federated identity is used, that event can also be associated with the authenticated user and lock context. + +## Searching encrypted data + +AWS KMS encrypts and decrypts bytes; it does not make ciphertext queryable. Building searchable fields on top of envelope encryption requires a separate indexing design, query transformation, and leakage analysis. + +CipherStash includes that layer. The client emits ciphertext plus only the searchable terms declared for the column, and [EQL](/reference/eql) gives PostgreSQL typed equality, ordering, range, and fuzzy-match operations. The trade-offs of those terms are covered in [Searchable encryption](/concepts/searchable-encryption). + +## Performance and cost model + +AWS KMS charges for customer-managed keys and API use; cryptographic operations also consume regional request quotas. The exact price and quota depend on key type and region, so production estimates should use the current [AWS KMS pricing](https://aws.amazon.com/kms/pricing/) and [request quota](https://docs.aws.amazon.com/kms/latest/developerguide/requests-per-second.html) documentation. + +CipherStash moves repeated work into local derivation and supports bulk operations. That makes its network cost track the batch rather than the number of values in the batch. Database query performance is a separate concern: published EQL measurements are in [Benchmarks](/reference/benchmarks). + +## Which should you use? + +### AWS KMS is usually the better fit when + +- you are encrypting AWS infrastructure such as S3 objects, EBS volumes, or service-managed resources; +- AWS IAM, CloudTrail, and regional KMS controls are already the operational standard; or +- the application does not need to query encrypted fields. + +### CipherStash is usually the better fit when + +- performance is a primary requirement: ZeroKMS batches the server-side work and derives per-value keys locally, avoiding one KMS network operation per value; +- individual application values need independent keys; +- the key service must not be capable of decrypting data by itself; +- encrypted PostgreSQL fields still need equality, range, ordering, or fuzzy-match queries; +- application identity needs to participate in cryptographic authorization; or +- batching must not require reusing data keys. + +### Use both when + +AWS KMS protects infrastructure and root key material while CipherStash protects sensitive fields inside the application. CipherStash does not replace disk, volume, object, or backup encryption; those layers protect different failure modes. + +## Related + +- [Cryptography and key derivation](/security/cryptography) +- [Searchable encryption](/concepts/searchable-encryption) +- [AWS KMS concepts](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html) diff --git a/content/docs/concepts/compare/fhe.mdx b/content/docs/concepts/compare/fhe.mdx new file mode 100644 index 0000000..0371dfc --- /dev/null +++ b/content/docs/concepts/compare/fhe.mdx @@ -0,0 +1,130 @@ +--- +title: CipherStash vs homomorphic encryption +navTitle: Homomorphic encryption +description: "Compare CipherStash searchable encryption with fully homomorphic encryption: supported computations, performance, leakage, PostgreSQL integration, and where each fits." +type: concept +components: [encryption, eql] +audience: [developer, cto, ciso] +reviewBy: "2027-01-31" +--- + +CipherStash is sometimes described as homomorphic encryption for databases. It is not. CipherStash uses several specialized searchable-encryption mechanisms, each built for a database operation such as equality, ordering, range, or fuzzy text matching. + +Fully homomorphic encryption (FHE) pursues a more general goal: evaluate arbitrary computations over ciphertext and decrypt the result without revealing the inputs to the computing party. + +That difference in ambition drives the practical trade-off. FHE can express computations that CipherStash cannot. CipherStash supports a deliberately narrower query surface that runs inside ordinary PostgreSQL with conventional indexes. + +## At a glance + +| | Fully homomorphic encryption | CipherStash searchable encryption | +| --- | --- | --- | +| Goal | General computation over ciphertext | Practical encrypted database queries | +| Computation model | Arithmetic or Boolean circuits supported by the FHE scheme | Purpose-built equality, ordering, range, fuzzy-match, and JSON terms | +| Database integration | Usually custom application and execution layers | PostgreSQL domains, operators, functions, B-tree and GIN indexes through EQL | +| Information revealed to the data store | Depends on the scheme and surrounding protocol; ciphertext aims to hide inputs and intermediate values | Explicitly reveals equality, order, or probabilistic token overlap for enabled query capabilities | +| Unsupported today in CipherStash | Arbitrary functions, encrypted `SUM` and `AVG`, general ML inference | Not applicable | +| Best fit | Computation where hiding inputs from the computing party justifies substantial complexity | OLTP and application search over protected PostgreSQL fields | + +## Why specialization is faster + +An FHE comparison or equality operation is evaluated as a cryptographic circuit. CipherStash does not evaluate that circuit. It stores a purpose-built term that PostgreSQL can compare or index directly. + +For example: + +- an equality-enabled column carries an HMAC term; +- an orderable column carries an OPE or block-ORE term; and +- a fuzzy-match text column carries an encrypted Bloom-filter term. + +The database learns the structure required for that operation, but gains a query path that resembles a normal indexed PostgreSQL query. [Searchable encryption](/concepts/searchable-encryption) explains exactly what each term reveals. + +## Primitive benchmark: TFHE and ORE + +CipherStash maintains a small open-source benchmark that compares similar primitive operations in the `tfhe` and `ore-rs` libraries. On the recorded Apple M2 run: + +| Operation | TFHE | ORE | Approximate difference | +| --- | ---: | ---: | ---: | +| Encrypt one value | 11.3 ms | 374 µs | 30x | +| Equality | 151 ms | 756 ns | 200,000x | +| Greater than | 162 ms | 750 ns | 215,000x | +| Less than | 166 ms | 755 ns | 220,000x | + +These are **primitive microbenchmarks**, not a claim that every FHE system or every CipherStash query has those timings. They compare one TFHE implementation with one ORE implementation on one machine. The harness and full results are published at [cipherstash/tfhe-ore-bench](https://github.com/cipherstash/tfhe-ore-bench). + +The comparison illustrates why a specialized predicate can be dramatically cheaper than evaluating a general encrypted circuit. End-to-end database performance also includes planning, index traversal, row retrieval, network time, and client decryption. + +## Database performance + +EQL's database benchmarks compare encrypted columns with plaintext PostgreSQL using equivalent indexes. At one million rows on the reference machine: + +| Query | Encrypted median | Relative to plaintext | +| --- | ---: | ---: | +| Exact equality | 0.12 ms | 1.3x | +| OPE range and ordering | 0.12 ms | 1.2x | +| Block-ORE range and ordering | 0.52 ms | 5.2x | +| JSON containment | 0.40 ms | 1.4x | + +Those results measure EQL v3 on PostgreSQL 17, not the SDK-to-database round trip. Methodology, scaling results, ingest performance, and reproduction commands are in [Benchmarks](/reference/benchmarks). + +## What CipherStash supports + +EQL v3 column domains declare the searchable capability stored with each value: + +| Capability | Typical operations | Stored structure | +| --- | --- | --- | +| Equality | `=`, `<>`, `IN`, equijoins, `GROUP BY`, `DISTINCT` | HMAC equality term, or an equality-lossless ordering term for some scalar types | +| Ordering and range | `<`, `<=`, `>`, `>=`, `BETWEEN`, `ORDER BY`, `MIN`, `MAX` | CLLW OPE or block-ORE term | +| Fuzzy text match | `@@` | Encrypted Bloom-filter term | +| JSON search | Path equality, range, and containment | Per-path searchable terms | + +The full type and operator surface is in [EQL core concepts](/reference/eql/core-concepts). + +CipherStash does not currently support arithmetic over encrypted values. `SUM`, `AVG`, multiplication, and arbitrary user-defined computations require decryption at the application boundary or a different cryptographic system. That boundary is intentional rather than hidden behind an inefficient fallback. + +## Leakage is the central trade-off + +FHE is designed so the computing party can evaluate a supported circuit without learning the plaintext inputs or intermediate values. Its practical security still depends on the complete protocol, including who holds keys, which outputs are revealed, and whether access patterns are visible. + +CipherStash makes a different bargain. A column stores only the terms required by its declared query capabilities: + +- equality terms reveal which encrypted values repeat; +- ordering terms reveal relative order; and +- fuzzy-match terms reveal probabilistic token overlap. + +This structure is what lets PostgreSQL use ordinary index machinery. Teams should enable only the capabilities the application actually needs, especially for low-cardinality or highly predictable data. + +## Operational differences + +CipherStash keeps the database model familiar: + +- encrypted values are JSON-backed EQL domains; +- ordinary SQL operators express supported queries; +- B-tree and GIN indexes accelerate the corresponding terms; +- the Stack SDK, ORM integrations, or Proxy encrypt query operands; and +- PostgreSQL never receives the data key or plaintext operand. + +FHE applications generally need a computation expressed for a particular scheme or compiler, ciphertext parameter management, evaluation-key distribution, and an execution environment sized for the circuit. That can be the correct engineering choice when the computation itself must remain hidden, but it is a different system from adding encrypted predicates to an existing relational application. + +## Which should you use? + +### FHE is the better fit when + +- an untrusted compute service must evaluate a genuinely general function over private inputs; +- the required computation cannot be reduced to CipherStash's supported query capabilities; +- encrypted inference, private analytics, or multiparty computation is central to the product; and +- the latency, ciphertext size, and operational complexity fit the workload. + +### CipherStash is the better fit when + +- the application needs equality, range, ordering, fuzzy matching, or JSON search over protected fields; +- queries must run in standard PostgreSQL and work with existing SQL tooling; +- low-latency OLTP behavior matters; and +- the team accepts the explicit leakage profile of the selected capabilities. + +The choice is not “strong cryptography versus weak cryptography.” It is general encrypted computation versus specialized encrypted queries with different performance and disclosure properties. + +## Related + +- [Searchable encryption](/concepts/searchable-encryption) +- [EQL reference](/reference/eql) +- [EQL benchmarks](/reference/benchmarks) +- [TFHE/ORE benchmark source](https://github.com/cipherstash/tfhe-ore-bench) diff --git a/content/docs/concepts/compare/hashicorp-vault.mdx b/content/docs/concepts/compare/hashicorp-vault.mdx new file mode 100644 index 0000000..241f5e3 --- /dev/null +++ b/content/docs/concepts/compare/hashicorp-vault.mdx @@ -0,0 +1,123 @@ +--- +title: CipherStash vs HashiCorp Vault +navTitle: HashiCorp Vault +description: "Why ZeroKMS is a faster and stronger security model than HashiCorp Vault Transit for high-volume application data encryption." +type: concept +components: [encryption, platform] +audience: [developer, cto, ciso] +reviewBy: "2027-01-31" +--- + +HashiCorp Vault is a broad secrets-management and cryptographic platform. The closest comparison with CipherStash is its **Transit secrets engine**, which offers encryption operations without storing the application data. + +For high-volume application data encryption, ZeroKMS has two decisive advantages: + +1. **Performance:** ZeroKMS was designed to derive a unique key for every value in bulk. A batch requires one service interaction, while final key derivation and encryption happen locally and scale with the application. +2. **Security:** Vault either receives the plaintext or holds everything required to unwrap its data keys. ZeroKMS never receives plaintext and cannot derive a usable data key without the client key held in your environment. + +Vault remains a strong general-purpose platform for secrets, PKI, signing, and centralized cryptographic services. ZeroKMS is the stronger fit when the workload is protecting large volumes of application values. + +## Performance is the first difference + +Vault Transit supports batching, but the work still follows one of two centralized models. + +### Direct Transit centralizes every cryptographic operation + +In direct mode, the application sends every plaintext value to Vault. Vault encrypts every value and returns the ciphertext. `batch_input` reduces the number of HTTP round trips, but it does not move the cryptographic work out of the Vault cluster. + +Throughput therefore depends on the capacity of the Vault deployment. Scaling application workers does not scale encryption unless the Vault cluster scales with them. Every byte of plaintext also crosses the service boundary in both directions over its lifetime: into Vault for encryption and out of Vault after decryption. + +### Transit envelope mode still produces complete data keys + +Envelope mode moves the final encryption into the application. Vault generates a plaintext data key plus a wrapped copy, the application encrypts locally, and the wrapped data key is stored with the ciphertext. Decryption sends that wrapped key back to Vault to recover the plaintext data key. + +Current Vault versions document a plural `datakeys` endpoint that can return multiple key pairs in one request. That removes the old one-request-per-key limitation, but Vault must still generate and wrap a complete data key for every independently keyed value. The application must persist every wrapped data key and send it back for unwrapping on reads. + +### ZeroKMS was designed for per-value bulk encryption + +ZeroKMS returns key seeds rather than complete data keys. One bulk service interaction supplies the material for a batch, then the application combines each seed with its client key and derives each value's unique data key locally. + +The final derivation and AES operations scale horizontally with the application rather than being concentrated in the key-service cluster. No plaintext data key is transmitted, no wrapped data key is persisted, and performance does not depend on reusing or caching a data key across values. + +| Performance property | Vault direct | Vault envelope | ZeroKMS | +| --- | --- | --- | --- | +| Network interactions for a batch | One with `batch_input` | One where plural `datakeys` is available | One bulk interaction | +| Who performs value encryption? | Vault cluster | Application | Application | +| Server-side work per value | Encrypt or decrypt the value | Generate, wrap, or unwrap a complete data key | Produce seed material; final derivation remains local | +| Material stored per value | Vault ciphertext | Ciphertext plus wrapped data key | Ciphertext plus non-secret key ID | +| How encryption throughput scales | Scale the Vault cluster | Scale Vault key generation and application workers | Scale application workers | +| Does performance require data-key reuse? | A named Transit key is normally shared | Reuse is optional but expands key blast radius | No; every value always receives a unique key | + +The exact latency of Vault depends on cluster size, storage, seal configuration, network placement, and mode. The architectural distinction is stable: ZeroKMS keeps the network interaction at batch granularity while distributing the final cryptographic work across the application fleet. + +## The security model is the second difference + +### Vault direct mode sees plaintext + +Direct Transit is encryption as a remote service. The application sends plaintext to Vault and Vault returns it during decryption. Anyone who controls the authorized Vault boundary has a path to both keys and data during the operation. + +Policies, audit devices, HSM-backed seals, and operational controls can harden that boundary. They do not remove it from the plaintext trust model. + +### Vault envelope mode can unwrap every data key + +Envelope encryption keeps application plaintext out of Vault, but Vault still holds the Transit key that unwraps every stored data key. An authorized Vault operation can return a complete plaintext data key without needing cryptographic material held separately by the application. + +Envelope mode also introduces durable wrapped-key material that must be stored and mapped to the correct ciphertext. If a plaintext data key is copied from application memory, it remains usable offline for every value protected by that key. + +### ZeroKMS splits the decryption capability + +ZeroKMS holds an authority key. The application holds a client key that never leaves its environment. Neither side can derive a data key alone, and the two long-lived components are never brought together. + +The encrypted payload contains a key ID, not a wrapped data key. Stealing the client key alone does not permit offline decryption of historical ciphertext; ZeroKMS must still authorize and participate in future derivations. Compromising ZeroKMS alone is also insufficient because it never receives the client key. + +A live attacker controlling an authorized application can use the operations granted to it until access is revoked. The important difference is that the capability remains **scoped, observable, and revocable** rather than becoming a complete key that can be copied and used offline. + +| Security property | Vault direct | Vault envelope | ZeroKMS | +| --- | --- | --- | --- | +| Plaintext reaches the key service | Yes | No | No | +| Key service can enable decryption alone | Yes | Yes, by unwrapping the data key | No | +| Client-held cryptographic half required | No | No | Yes | +| Complete data key crosses the network | Not exposed to the application in direct mode | Yes | No | +| Per-value key material stored | Ciphertext carries the Transit key version | Wrapped data key | Non-secret key ID | +| Unique key per value | Must be designed through context or separate keys | Must be designed through separate data keys | Always | +| Offline value of a copied application secret | A Vault token still needs the Vault service | A copied plaintext data key can decrypt every value that reused it | The client key alone cannot derive any historical data key | + +The full ZeroKMS mechanism and compromise model are in [Cryptography](/security/cryptography#trust-model). + +## Audit granularity + +Vault audit devices record requests to the service. In direct mode, a batch records a Transit operation over the batch. In envelope mode, Vault observes data-key generation and unwrapping; how those keys map to application records is left to the application's storage and metadata design. + +ZeroKMS makes the value's key ID and keyset part of derivation. Every decryption needs server participation before plaintext recovery, so independent audit follows the same per-value boundary as the key model. Federated identity and lock contexts can also bind that operation to an authenticated end user. See [Provable access control](/solutions/provable-access). + +## Searching encrypted data + +Vault's convergent encryption can make the same plaintext and context produce the same ciphertext, enabling limited deterministic lookup. It exposes equality directly in the ciphertext and does not provide range, ordering, fuzzy matching, JSON search, or PostgreSQL operator integration by itself. + +CipherStash separates randomized ciphertext from purpose-built search terms. The EQL domain selected for a column declares exactly which operations it supports and which structure the database learns. See [Searchable encryption](/concepts/searchable-encryption) for the leakage model and [EQL core concepts](/reference/eql/core-concepts) for the type system. + +## Which should you use? + +### CipherStash ZeroKMS is usually the better fit when + +- **performance is a primary requirement for high-volume field encryption;** +- the key service must never receive plaintext or possess enough material to decrypt alone; +- unique per-value keys must not require key caching or reuse; +- PostgreSQL must query protected fields while they remain encrypted; or +- key derivation, database types, SDKs, ORM wrappers, audit, and identity binding should work as one system. + +### Vault Transit is usually the better fit when + +- Vault is already the organization's standard security platform; +- the workload needs Vault's broader secrets, PKI, signing, HMAC, or tokenization capabilities; +- centralized encryption inside the Vault boundary is acceptable; or +- the organization wants to own and operate the complete cryptographic service. + +The products can coexist: Vault can manage infrastructure secrets and other cryptographic workloads while CipherStash protects high-volume, queryable application data. + +## Related + +- [Vault Transit documentation](https://developer.hashicorp.com/vault/docs/secrets/transit) +- [Vault Transit API](https://developer.hashicorp.com/vault/api-docs/secret/transit) +- [Cryptography and key derivation](/security/cryptography) +- [Searchable encryption](/concepts/searchable-encryption) diff --git a/content/docs/concepts/compare/index.mdx b/content/docs/concepts/compare/index.mdx new file mode 100644 index 0000000..c5e64de --- /dev/null +++ b/content/docs/concepts/compare/index.mdx @@ -0,0 +1,22 @@ +--- +title: Comparisons +description: "How CipherStash compares with cloud key management, HashiCorp Vault, hardware security modules, and fully homomorphic encryption." +type: concept +--- + +CipherStash combines application-level encryption, zero-trust key management, and purpose-built searchable encryption. These pages compare that combination with tools that overlap with one part of the system. + + + + Centralized cloud key management compared with ZeroKMS split control and per-value key derivation. + + + Vault Transit direct and envelope modes compared with ZeroKMS and searchable encryption. + + + Where HSMs fit, what operating one entails, and how the two approaches can complement each other. + + + Specialized encrypted database queries compared with general computation over ciphertext. + + diff --git a/content/docs/concepts/compare/meta.json b/content/docs/concepts/compare/meta.json new file mode 100644 index 0000000..2357b87 --- /dev/null +++ b/content/docs/concepts/compare/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Comparisons", + "icon": "Scale", + "pages": ["!index", "aws-kms", "hashicorp-vault", "zerokms-vs-hsm", "fhe"] +} diff --git a/content/docs/concepts/compare/zerokms-vs-hsm.mdx b/content/docs/concepts/compare/zerokms-vs-hsm.mdx new file mode 100644 index 0000000..5ad1539 --- /dev/null +++ b/content/docs/concepts/compare/zerokms-vs-hsm.mdx @@ -0,0 +1,91 @@ +--- +title: ZeroKMS vs hardware security modules +navTitle: ZeroKMS vs HSMs +description: "Compare ZeroKMS with dedicated hardware security modules: custody, trust boundaries, integration, availability, per-value keys, and complementary deployment patterns." +type: concept +components: [platform] +audience: [cto, ciso] +reviewBy: "2027-01-31" +--- + +A hardware security module (HSM) is a tamper-resistant hardware boundary for generating, storing, and using cryptographic keys. ZeroKMS is a key-management protocol and service for deriving application data keys without either the service or application holding enough long-lived material to derive them alone. + +They are not exact substitutes. An HSM answers **where and under whose physical control a key is protected**. ZeroKMS answers **how authority is divided and how a unique key is made practical for every application value**. + +## Where HSMs fit + +HSMs are commonly used to protect certificate-authority keys, payment keys, code-signing keys, database master keys, and the root keys behind managed KMS products. They provide a well-defined cryptographic boundary, strong resistance to key extraction, and certifications required by some regulatory or procurement regimes. + +An HSM does not by itself provide an application encryption model. Teams still need to decide: + +- which keys exist and how much data each protects; +- how applications authenticate and are authorized; +- whether applications send plaintext to the HSM boundary or receive data keys; +- how keys are rotated, backed up, replicated, and destroyed; and +- how database queries work over encrypted fields. + +ZeroKMS is focused on those application-level decisions. Its authority keys are protected server-side, while each workload holds a separate client key. The two sides produce unique data keys locally through the process described in [Cryptography](/security/cryptography#how-a-data-key-is-produced). + +## Comparison + +| Property | Dedicated HSM deployment | CipherStash ZeroKMS | +| --- | --- | --- | +| Primary security boundary | Tamper-resistant hardware and its operator controls | Split authority key and client key in separate environments | +| Long-lived key custody | Customer or cloud operator controls the HSM partition | CipherStash protects the authority side; customer protects the client side | +| Application data-key model | Designed by the customer or KMS layer built above it | Unique derived key for every encrypted value | +| Can one boundary decrypt alone? | Usually yes, when the HSM and authorization policy permit the operation | No; neither authority nor client key is sufficient alone | +| Application integration | PKCS #11, JCE, CNG, or a KMS/service layer | Stack SDK, ORM integrations, or CipherStash Proxy | +| Scaling | Appliance, partition, cluster, or provider capacity planning | Batched server interaction and local per-value derivation | +| High availability and recovery | Customer or provider must design clustering, backup, quorum, and ceremonies | Operated as part of the ZeroKMS service; encrypted application data remains in the customer's database | +| Searchable database fields | Not supplied by the HSM | Built into CipherStash through EQL | + +## Custody and split control + +Owning an HSM gives an organization direct control over the hardware boundary and its administrative procedures. That can be essential when a policy requires customer-held hardware, witnessed key ceremonies, a particular FIPS validation level, or an offline signing environment. + +Hardware custody does not automatically create split cryptographic control. An authorized application or operator may still ask the HSM to use the protected key. The security boundary is strong, but it is centralized. + +ZeroKMS distributes that capability. The authority side can produce only a key seed; it cannot turn that seed into a data key. The client side cannot reproduce seeds by itself. Compromise must cross both boundaries, and client access can be revoked at ZeroKMS without relying on the application to honor the decision. + +## Integration and operations + +Direct HSM integration normally uses low-level standards such as PKCS #11, or a higher-level key-management service built on top. Production operation also needs capacity planning, redundant devices or partitions, secure backup, credential quorum, monitoring, firmware management, and tested disaster recovery. + +Those costs can be worthwhile when the HSM boundary is itself the requirement. They are often unnecessary complexity when the real requirement is protecting application fields with fine-grained, revocable keys. + +CipherStash provides that higher layer. The SDK and Proxy handle encryption payloads, key IDs, bulk derivation, searchable terms, and identity binding. The database stores ciphertext while ZeroKMS handles only the server side of key derivation. + +## Performance and key granularity + +An HSM performs bounded cryptographic operations. Giving every database value a unique HSM-backed key can turn a bulk query into many service or device operations, so systems commonly introduce envelope keys, caching, or reuse. + +ZeroKMS batches the server-side work and derives the final keys locally. Every value can retain a distinct key without sending every encryption operation through a hardware boundary and without keeping a cache of plaintext data keys. + +## When to choose an HSM + +A dedicated HSM is usually appropriate when: + +- a regulation, customer contract, or internal policy explicitly requires a validated hardware boundary; +- the organization must retain physical or administrative custody of root keys; +- keys must operate in an offline or air-gapped environment; +- the workload is certificate issuance, payment processing, signing, or another HSM-native use case; or +- the organization already has the people and procedures to operate the hardware safely. + +## When to choose ZeroKMS + +ZeroKMS is usually appropriate when: + +- the workload is field-level application encryption; +- one service or operator must not hold unilateral decryption capability; +- each value needs an independent key and audit boundary; +- high-volume batches must not require data-key reuse; or +- encrypted PostgreSQL data still needs to be queried. + +## Use them together + +The strongest design may use both. An HSM or HSM-backed cloud KMS can protect root key material, while ZeroKMS supplies split control and per-value derivation above it. The HSM remains the hardened foundation; ZeroKMS defines the application-facing trust model. + +## Related + +- [Cryptography and key derivation](/security/cryptography) +- [ZeroKMS vs AWS KMS](/concepts/compare/aws-kms) diff --git a/content/docs/concepts/index.mdx b/content/docs/concepts/index.mdx new file mode 100644 index 0000000..922af97 --- /dev/null +++ b/content/docs/concepts/index.mdx @@ -0,0 +1,11 @@ +--- +title: Concepts +description: "How CipherStash keeps encrypted data useful and controls access to plaintext." +type: concept +--- + +{/* + Fumadocs requires an index document when generating a metadata-backed folder. + meta.json excludes this file from the page tree, and Next redirects its route + to the first visible Concepts page. +*/} diff --git a/content/docs/concepts/key-management.mdx b/content/docs/concepts/key-management.mdx new file mode 100644 index 0000000..2216f64 --- /dev/null +++ b/content/docs/concepts/key-management.mdx @@ -0,0 +1,176 @@ +--- +title: Key management +description: "How ZeroKMS makes unique per-value keys practical: split control, client-side derivation, bounded compromise, immediate revocation, and batch performance without data-key caching." +type: concept +components: [encryption, platform] +audience: [developer, cto, ciso] +verifiedAgainst: + stack: "1.0.0" +--- + +Encryption is only as strong as the system that controls its keys. If an application keeps one long-lived key beside the data it protects, encrypting the database changes its appearance without meaningfully changing who can unlock it. + +ZeroKMS takes a different approach. Every encrypted value gets a unique data key. No data key is stored, transmitted, or reused, and neither CipherStash nor your application holds enough long-lived material to derive one alone. + +This gives CipherStash three properties that are difficult to combine with conventional key management: + +- **Fine-grained protection:** compromising one derived data key exposes one value, not a table or batch. +- **Split control:** CipherStash holds one side of the key relationship and your application holds the other. Neither side is sufficient by itself. +- **Production performance:** bulk operations derive unique keys locally after one ZeroKMS interaction instead of making one network request per value. + +## The problem with conventional envelope encryption + +Cloud key-management services commonly use envelope encryption. The KMS generates a data encryption key, returns the plaintext key to the application, and returns a second copy wrapped by a root key. The application encrypts the data, discards the plaintext key, and stores the wrapped key beside the ciphertext. + +On read, the application sends the wrapped key back to the KMS and receives the plaintext data key again. + +That works well for individual objects. It becomes difficult at database scale. A result containing 100 rows with two independently encrypted fields needs 200 data keys. Using one unique key per value means hundreds of KMS requests; reusing and caching a key makes the application faster but expands the amount of data that key can decrypt. + +Caching also weakens observability. Once one cached key unlocks thousands of values, the key service can record that the key was used but cannot tell which individual value the application accessed. + +The usual design therefore trades among: + +- how much data one key can decrypt; +- how many network calls an operation can tolerate; and +- how precisely key use can be audited. + +ZeroKMS is designed to remove that tradeoff. + +## The ZeroKMS mental model + +A usable data key is never handed from one system to another. It comes into existence only inside your application, when material from ZeroKMS is processed with material that never leaves your environment. + +Four pieces have distinct jobs: + +| Material | Where it exists | What it can do alone | +|---|---|---| +| **Authority key** | ZeroKMS, encrypted at rest under an AWS KMS root key | Produce a key seed; it cannot derive your data keys without the client key | +| **Client key** | Your application environment | Process a key seed; it cannot derive a data key without ZeroKMS | +| **Key seed** | Returned by ZeroKMS for a key ID and held for the operation | Nothing useful without the client key | +| **Data key** | Your application's memory for one operation | Encrypt or decrypt one value; then it is discarded | + +The authority key and client key form a split. They are never brought together. ZeroKMS never receives the client key, and your application never receives the authority key. + +```mermaid +flowchart LR + keyid["Key ID"] --> zerokms["ZeroKMS
authority key"] + zerokms --> seed["Key seed"] + + subgraph app["Your application"] + client["Client key
never leaves"] + combine["Process seed + derive"] + datakey["Unique data key
one value, one operation"] + value["Encrypt or decrypt value"] + discard["Discard data key"] + + client --> combine + combine --> datakey --> value --> discard + end + + seed --> combine +``` + +CipherStash uses proxy symmetric re-encryption to produce the seed on the ZeroKMS side. The application then processes that seed with its client key and uses an HMAC-based key derivation function to produce the data key. [Cryptography](/security/cryptography) is the canonical technical description of both layers. + +## A unique key for every value + +Each encrypted value carries a key ID. That ID is not secret and is not key material; it tells ZeroKMS which reproducible seed the client needs for that value. + +### Encrypt + +1. The client creates a new key ID. +2. ZeroKMS authorizes the request and produces the corresponding key seed. +3. The application processes the seed with its client key and derives a unique data key. +4. The data key encrypts one value, then is discarded. +5. The key ID travels with the ciphertext. The data key does not. + +### Decrypt + +1. The client reads the key ID from the encrypted payload. +2. ZeroKMS authorizes a request for that ID and reproduces the same seed. +3. The application processes it with the client key and derives the same data key. +4. The value is decrypted and the data key is discarded again. + +There is no database of per-value data keys to protect or restore. The persistent state is the much smaller authority/client relationship from which a data key can be reproduced only when both sides participate. + +## Why split control changes application compromise + +A compromised application is serious, but it is not equivalent to losing a permanent master key. + +**The client key is insufficient on its own.** Stealing it does not let an attacker derive historical data keys offline. The encrypted payload contains a key ID, not a seed or wrapped data key. ZeroKMS must still authorize and participate in each derivation. + +**There is no cache of reusable data keys to steal.** A data key exists only while one value is being processed. Capturing it does not unlock the next row. + +**Access has a server-side off switch.** Revoking a client's access key or its grant to a keyset prevents future seed requests. Because the decision is made during derivation, revocation does not depend on a compromised process voluntarily honoring a new policy. + +**The blast radius is scoped.** A client can derive keys only for the keysets it has been granted. Separate keysets create cryptographic boundaries between tenants, environments, regions, or workloads. + +**Identity can narrow it further.** A lock context can bind derivation to a claim from an authenticated end user. Possessing the service's client key is then not enough to decrypt a value locked to a different identity. See [Provable access control](/solutions/provable-access). + +**Derivation is observed outside the application.** ZeroKMS records the operation before returning the material required for decryption. An attacker operating only inside the application cannot edit or suppress that service-side record in the way they could alter application logs. + +An attacker controlling a live, authorized process may still request the operations available to that process while access remains valid. The important difference is that the capability is **scoped, observable, and revocable**—not a reusable master key that can be copied once and used forever offline. + +## Keysets define the isolation boundary + +A keyset is an independent authority-key scope. The same key ID under two keysets produces unrelated data keys, so ciphertext created under one keyset cannot be decrypted through another. + +Common boundaries include: + +- one keyset per customer in a multi-tenant application; +- separate keysets for development, staging, and production; +- regional keysets for data-residency requirements; and +- separate keysets for business units or workloads with different operators. + +Client access is granted per keyset. Revoking that grant stops the client from deriving any more keys in that boundary without changing database permissions or rewriting the ciphertext. + +Keyset design also controls intentional correlation. Encrypted equality and joins work only across compatible values under the same cryptographic scope; separating keysets prevents both decryption and cross-boundary encrypted comparison. + +The default workspace keyset is sufficient for many applications. Add more when your security boundary is narrower than the workspace. + +## Bulk performance without data-key caching + +The data-key-per-value model would be impractical if every value needed an independent network and HSM round trip. + +ZeroKMS batches the server-side work. A bulk operation can obtain the seed material it needs in one interaction, then derive each value's unique data key locally. Increasing a batch from ten values to a thousand increases local cryptographic work, but does not turn into a thousand sequential requests to the key service. + +This is why the Stack SDK's bulk operations are both the performance path and the strongest key-granularity path. They do not improve throughput by reusing a data key. + + +No **data key** is cached. CipherStash Proxy does cache bounded, keyset-scoped cipher objects so it does not reinitialize them for every statement. Those objects still require the client side of the split, and every value still receives its own derived data key. See [what is cached](/security/cryptography#what-is-cached-and-what-is-not). + + +## Authorization and audit are part of derivation + +With a policy check in application code, authorization happens first and decryption happens later. A bug or compromised process can create a gap between the decision and the use of the key. + +ZeroKMS evaluates authorization while producing the key seed. The authorization decision and the cryptographic operation are therefore part of the same event: if ZeroKMS does not authorize the request, the application does not receive the material required to derive the data key. + +That event also creates an independent audit record. Depending on how the client authenticated, it can identify the service or federated end user, the keyset, the operation, and the time. Lock contexts add the identity claim bound to the value. + +This is more useful than a log that says a SQL query ran. It records the operation that made plaintext recovery possible, at the system that had to participate in it. + +## What is stored + +The design deliberately keeps the durable pieces small and separated: + +| Location | Stored | +|---|---| +| Your database | Ciphertext, key ID, and any searchable index terms | +| Your application environment | Client key and credentials used to authenticate to ZeroKMS | +| ZeroKMS | Authority keys and access-control state | +| Nowhere | Plaintext data keys | + +Your encrypted data stays in your database. ZeroKMS handles key material, not application data. This separation also shapes recovery: restoring ZeroKMS restores the ability to reproduce keys; it does not require CipherStash to restore or move your encrypted database. Multi-region application patterns are covered in [Data residency](/solutions/data-residency#multi-region-with-regional-key-isolation). + +## ZeroKMS and searchable encryption + +ZeroKMS protects the keys used by the [Stack SDK](/reference/stack) and [CipherStash Proxy](/reference/proxy). Searchable encryption adds purpose-built terms so PostgreSQL can locate ciphertext without those keys. + +The responsibilities remain separate: + +- ZeroKMS participates in encryption and decryption key derivation. +- The client creates ciphertext and searchable terms in your environment. +- PostgreSQL compares terms but cannot derive a data key or decrypt a result. + +Start with [Searchable encryption](/concepts/searchable-encryption) for the query model. Continue to [Cryptography](/security/cryptography) for algorithms, trust boundaries, and the precise data flow. diff --git a/content/docs/concepts/meta.json b/content/docs/concepts/meta.json new file mode 100644 index 0000000..a2add10 --- /dev/null +++ b/content/docs/concepts/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Concepts", + "icon": "Lightbulb", + "pages": [ + "!index", + "searchable-encryption", + "key-management", + "...", + "compare" + ] +} diff --git a/content/docs/concepts/searchable-encryption.mdx b/content/docs/concepts/searchable-encryption.mdx new file mode 100644 index 0000000..ed6bee3 --- /dev/null +++ b/content/docs/concepts/searchable-encryption.mdx @@ -0,0 +1,207 @@ +--- +title: Searchable encryption +description: "How CipherStash keeps encrypted data useful: query capabilities, the write and query flow, PostgreSQL indexes, and the security tradeoffs." +type: concept +components: [encryption, eql] +audience: [developer, cto, ciso] +verifiedAgainst: + stack: "1.0.0" + eql: "3.0.4" +--- + +Searchable encryption lets you encrypt sensitive data before it reaches PostgreSQL **without giving up the queries your application depends on**. + +Your application can still look up a customer by email, order transactions by date, filter records by a range, match text, and query structured JSON. PostgreSQL answers those queries using encrypted indexes. It never receives the plaintext or the keys that decrypt it. + +That changes field-level encryption from an archival control into something you can use on live application data. + +## What it makes possible + +**A database breach yields ciphertext.** Backups, replicas, database consoles, and direct SQL access no longer expose the protected fields. Encryption happens in your application, outside the database trust boundary. + +**Your product keeps working.** `WHERE`, ranges, ordering, grouping, joins, fuzzy text matching, and JSON containment remain available. Existing PostgreSQL indexes continue to do the heavy lifting. + +**Search does not grant decryption.** PostgreSQL can find matching rows but returns ciphertext. Decryption is a separate operation performed by an authorized application, and can be bound to an end-user identity with [provable access control](/solutions/provable-access). + +**You decrypt less data.** The database narrows millions of encrypted rows to the small result set the application asked for. Only those returned values need to be decrypted. + +**Sensitive access becomes auditable.** Every decryption requires key derivation through ZeroKMS. When the client authenticates as the end user, the audit trail records whose identity authorized that operation—not merely which SQL statement ran. + +These properties are especially useful for applications and AI agents that need to locate sensitive records without receiving blanket access to every value in the table. + +## Why ordinary encryption breaks queries + +Modern authenticated encryption is randomized. Encrypting `ava@example.com` twice produces different ciphertexts, even with the same key. This prevents an observer from recognizing repeated plaintext, but it also means ordinary database operations stop working: + +```sql +WHERE email = 'ava@example.com' +ORDER BY date_of_birth +WHERE name LIKE '%martin%' +``` + +Two ciphertexts cannot be compared for plaintext equality, and sorting random-looking bytes does not sort the underlying values. Decrypting every row inside PostgreSQL would expose the plaintext and turn an indexed lookup into a full-table scan. + +Searchable encryption solves this by separating **the value** from **the information needed to query it**. + +## How it works + +CipherStash stores a randomized ciphertext together with one or more encrypted index terms. Each term has one job: equality, ordering, text matching, or structured JSON search. + +For an email column that supports equality, the stored value is conceptually: + +```json +{ + "ciphertext": "randomized encryption of ava@example.com", + "equalityTerm": "keyed deterministic term" +} +``` + +The real [EQL payload](/reference/eql/core-concepts#anatomy-of-an-encrypted-value) also carries the metadata needed to validate and decrypt it. PostgreSQL never compares the randomized ciphertext. It extracts and compares the purpose-built term. + +```mermaid +flowchart LR + subgraph app["Your application"] + value["Plaintext value"] --> encrypt["Encrypt"] + needle["Plaintext query"] --> queryterm["Create query term"] + decrypt["Decrypt returned values"] + end + + subgraph db["Your PostgreSQL"] + payload["Ciphertext + index terms"] + index["Normal PostgreSQL index"] + match["Matching ciphertexts"] + payload --> index --> match + end + + encrypt --> payload + queryterm --> index + match --> decrypt +``` + +### Write path + +1. The Stack SDK or CipherStash Proxy receives a plaintext value in your application environment. +2. It encrypts the value with a unique data key and creates the terms declared for that column. +3. Your application sends the encrypted payload to PostgreSQL. +4. PostgreSQL stores the payload and indexes its terms. It never sees the plaintext or data key. + +### Query path + +1. The application turns the plaintext query operand into a term using the same cryptographic scope as the column. +2. It sends that term as a bound query parameter. +3. [EQL](/reference/eql) routes the comparison to the matching term, and PostgreSQL uses the appropriate functional index. +4. PostgreSQL returns only the matching ciphertexts. + +### Read path + +1. The application passes a returned payload to the SDK or Proxy. +2. ZeroKMS authorizes the key derivation and returns the material the client needs. +3. The client derives the data key locally, decrypts the value, and discards the key. + +Search and decryption are deliberately separate. PostgreSQL can find records, but it cannot decrypt them. + +## A concrete equality lookup + +The column's EQL type declares its capability. An equality-searchable email uses `public.eql_v3_text_eq`: + +```sql filename="schema.sql" +CREATE TABLE users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq +); + +CREATE INDEX users_email_eq + ON users USING hash (eql_v3.eq_term(email)); +``` + +The application encrypts both stored values and query operands: + +```typescript filename="lookup.ts" +const term = await encryptionClient.encryptQuery("ava@example.com", { + column: users.email, + table: users, +}) + +if (term.failure) { + throw new Error(term.failure.message) +} + +const rows = await db.query( + "SELECT id, email FROM users WHERE email = $1::jsonb::eql_v3.query_text_eq", + [term.data], +) +``` + +PostgreSQL uses `users_email_eq` to locate the matching term. The result still contains an encrypted email; the application calls `decrypt` only if it is authorized to reveal it. The [Quickstart](/get-started/quickstart) walks through the complete setup. + +## Query capabilities + +You select capabilities per column, so a lookup field does not need to carry the machinery for range or text search. + +| Capability | What it enables | Typical uses | EQL term | +|---|---|---|---| +| Storage only | Store and decrypt | Secrets or fields never filtered in SQL | None | +| Equality | `=`, `<>`, `IN`, grouping, uniqueness, equijoins | Email lookup, customer IDs, external references | Keyed HMAC (`hm`) | +| Ordering and range | Equality, `<`, `>`, `BETWEEN`, `ORDER BY`, `MIN`, `MAX` | Dates, amounts, scores, measurements | OPE (`op`) or block ORE (`ob`) | +| Free-text match | Probabilistic n-gram matching with `@@` | Names, descriptions, addresses | Encrypted Bloom filter (`bf`) | +| Searchable JSON | Path access, exact containment, ordered leaf comparisons | Flexible metadata and nested application records | Deterministic selectors and ordered leaf terms | + +EQL exposes these as concrete domain variants such as `text_eq`, `integer_ord`, and `text_match`. Your ORM integration maps its encrypted column types to the same model. See [EQL core concepts](/reference/eql/core-concepts#variants-declare-capability) for the full matrix. + +## It still uses PostgreSQL indexes + +Searchable encryption is practical because the encrypted terms fit PostgreSQL's existing index machinery: + +- hash or B-tree indexes serve exact matching; +- B-tree indexes serve ranges and ordering; +- GIN indexes serve encrypted text matching and JSON containment. + +PostgreSQL does not scan and decrypt the table. Once the query operand has been converted to a term, the query follows the same broad execution shape as an indexed plaintext query. + +In CipherStash's reference benchmarks at one million rows, exact matching and OPE range queries both run at about **0.12 ms**, within **1.2–1.4×** of equivalent plaintext PostgreSQL queries. Free-text matching is **100–400× faster** with its GIN index than with a sequential scan. Hardware and workloads vary; [Benchmarks](/reference/benchmarks) contains the environment, query plans, and reproducible suite. + +## Search and access control reinforce each other + +Database access and plaintext access are different permissions: + +| Operation | Where it happens | What is required | +|---|---|---| +| Find matching rows | PostgreSQL | Encrypted query term and the matching EQL operator | +| Read stored values | PostgreSQL | Database permission; the result is ciphertext | +| Reveal plaintext | Your application | Client key plus an authorized ZeroKMS derivation | + +With [provable access control](/solutions/provable-access), decryption can also require the signed-in user's identity. An application or agent can query a shared encrypted dataset, receive candidate rows, and decrypt only the values that identity is permitted to access. ZeroKMS records the derivation that made each decryption possible. + +Searchable encryption is what makes that model usable: applications can locate the right protected records before asking to reveal them. + +## Choosing capabilities + +Start from the behavior your product needs: + +- Use equality for identifiers and lookup fields. +- Use ordering for values that must support ranges or sorting. +- Use free-text match for human-entered text where token matching is useful. +- Use searchable JSON when flexible document structure is part of the data model. +- Use storage-only encryption when the application reads a field by some other key and never filters on it. + +The narrower type is often also the clearest schema. A `text_eq` column tells future maintainers that it is a lookup field; a `text_match` column says it supports token matching but not sorting. Unsupported operations fail rather than silently returning an incorrect result. + +## The security tradeoff + +The database needs some information to answer a query. Searchable terms expose defined relationships between encrypted values while keeping the values themselves secret. The capability you select determines that relationship: + +| Capability | What a database observer can infer | +|---|---| +| Storage only | Table shape, row count, nullness, update timing, and approximate value size—but no equality or ordering term | +| Equality | Which comparable values are equal, and therefore their frequency | +| Ordering | Equality, frequency, and the relative order of values | +| Free-text match | Probabilistic token overlap, repeated token sets, and approximate token-set size | +| Searchable JSON | Repeated paths and structures, equality of path/value pairs, document shape, and ordering of searchable leaves | + +The terms are keyed. Someone who steals an equality term cannot hash a plaintext dictionary and compare it without the key. However, frequency distributions, known records, and other auxiliary information can still make likely values easier to infer—particularly in small or predictable domains. + +Queries add their own observable patterns. A database operator can see repeated searches, which rows match, and where range boundaries fall. This is known as search-pattern and access-pattern leakage. The database still does not receive the plaintext query, but logs, endpoint behavior, or known test records can sometimes label an otherwise encrypted term. + +This is why capabilities are selected per column instead of enabled globally. If relative order is sensitive but exact lookup is acceptable, choose equality rather than ordering. If even equality groups are sensitive, use storage-only encryption and filter a smaller candidate set after decryption in the application. + +For the application and key-management trust boundary, see [Cryptography](/security/cryptography). For the exact payload fields and EQL operators behind each capability, see [EQL core concepts](/reference/eql/core-concepts). diff --git a/content/docs/get-started/choose-your-stack.mdx b/content/docs/get-started/choose-your-stack.mdx new file mode 100644 index 0000000..d083f18 --- /dev/null +++ b/content/docs/get-started/choose-your-stack.mdx @@ -0,0 +1,102 @@ +--- +title: Choose your stack +description: "Pick an integration path: SDK or Proxy, your Postgres platform, your ORM, and your identity provider." +type: guide +components: [encryption, proxy, eql, platform] +audience: [developer, cto] +--- + +Four decisions, in order. The first one matters most; the rest usually follow from what you already run. + +## 1. SDK or Proxy + +| | [Stack SDK](/reference/stack) | [Proxy](/reference/proxy) | +|---|---|---| +| Where encryption happens | In your application process | In the proxy, which you run | +| Application changes | Encrypt and decrypt calls, or an ORM wrapper that adds them for you | Point your connection string at the proxy | +| Best when | You own the code and want fine-grained control | You cannot change the application, or it isn't TypeScript | +| Language | TypeScript / JavaScript | Any. It speaks the Postgres wire protocol | +| Runs where | Your app | A container or sidecar in your infrastructure | + +Both produce the same ciphertext and both speak [EQL](/reference/eql), so this is not a one-way door. You can move a table from one to the other without re-encrypting it. + +Start with the SDK unless you already know you can't change the application. + +## 2. Your Postgres + +EQL installs into any Postgres you can connect to. It needs no extension, no superuser, and no `postgresql.conf` changes. + +| Platform | Works | Notes | +|---|---|---| +| Supabase | Yes | [Supabase integration](/integrations/supabase). Expose the `eql_v3` schema in the dashboard. | +| Prisma Postgres | Yes | [Prisma Postgres integration](/integrations/prisma/prisma-postgres). EQL is installed through the Prisma ORM 8 migration graph. | +| Neon, RDS, Aurora, Cloud SQL | Yes | `stash eql install` detects a non-superuser role and adapts. | +| Self-hosted Postgres | Yes | Full operator-class support, so the ORE ordering mechanism is available. | +| Postgres behind PgBouncer | Yes | Transaction pooling is fine. Proxy has its own pooling. | +| DynamoDB | Yes, without EQL | [DynamoDB integration](/integrations/aws/dynamodb). Encrypted attributes and HMAC key lookups; no EQL, because there's no Postgres. | +| MySQL, MongoDB, others | Not yet | The SDK still encrypts values for you, but nothing is queryable server-side. | + + +Managed platforms usually block custom operator class creation. That's why the default ordering mechanism is moving to OPE, which indexes under Postgres's default btree operator class. See [SEM specifiers](/reference/eql/core-concepts#sem-specifiers). + + +## 3. Your ORM + +| Query layer | Path | What it looks like | +|---|---|---| +| Drizzle | [Drizzle integration](/integrations/drizzle) | Encrypted column types and typed query operators. Your Drizzle code keeps its shape. | +| Supabase JS | [Supabase integration](/integrations/supabase) | `encryptedSupabase` wraps the client. `.eq()`, `.gt()`, `.order()` keep working. | +| Prisma ORM 8 RC | [Prisma integration](/integrations/prisma) | Encrypted fields and query operators are generated from `schema.prisma`. | +| Raw SQL | [Quickstart](/get-started/quickstart) | Encrypt the value, insert it, cast the query operand. | +| An ORM we don't list | The SDK | Encrypt before write, decrypt after read. Every ORM supports that. | + +If you use no ORM, nothing is missing. The SDK's `encrypt` and `encryptQuery` are the whole surface. + +## 4. Identity binding + +Reach for this when a value should be decryptable only by the user it belongs to, rather than by anything holding the application's credentials. It is also what shrinks the blast radius of a compromised application process. + +It requires an OIDC provider, registered on the [OIDC providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) page in the Dashboard. + +| Provider | Supported | +|---|---| +| Supabase Auth | Yes | +| Clerk | Yes | +| Auth0 | Yes | +| Okta | Yes | +| Any OIDC issuer | Yes, if it publishes a discovery endpoint | + +See [provable access control](/solutions/provable-access) for what this buys you, and what it does not. + +## Where CipherStash sits among the encryption you already have + +Encryption at rest and in transit are not alternatives to this. They protect different things, and you should keep both. + +| | At rest | In transit | In use | +|---|---|---|---| +| Protects the disk | Yes | No | Yes | +| Protects the wire | No | Yes | Yes | +| Protects during a query | No | No | Yes | +| Plaintext visible to the database | Yes | Yes | **No** | +| Queryable without decrypting | n/a | n/a | Yes | + +A database breach defeats encryption at rest, because the database decrypts on read. That is the gap this fills. + +## From development to production + + + +Device auth gives each developer their own identity, so local decryptions are attributable. There is no interactive device profile in CI, hence the switch to explicit credentials. See [Deployment](/guides/deployment) for build-time availability, environment separation, and rollout gates. + +## Still not sure + +A [discovery session](https://cipherstash.com/contact) is a 60-minute conversation with our engineering team. No slides. Bring whoever owns the data layer, and a compliance lead if that's someone else. + +Worth thinking through beforehand: + +- Which fields are sensitive, and which regulations cover them. +- Whether you need those fields **searchable**, or only stored and retrieved. Encrypt-only columns carry no index terms and leak nothing. +- Whether you need per-user decryption, or per-tenant isolation, or both. +- Your database, ORM, and deployment target, plus any restriction on native Node modules. + +You'll leave with a recommended path and answers to whatever is blocking you. diff --git a/content/docs/get-started/index.mdx b/content/docs/get-started/index.mdx new file mode 100644 index 0000000..fd8bc9b --- /dev/null +++ b/content/docs/get-started/index.mdx @@ -0,0 +1,24 @@ +--- +title: Get started +navTitle: Overview +description: "Encrypt your first field in ten minutes, then pick the integration path that matches your stack." +type: tutorial +audience: [developer] +--- + +CipherStash encrypts your data at the field level, in your application, and keeps the ciphertext queryable in Postgres. The database never sees plaintext, and neither do we. + +This section gets you from nothing to an encrypted, queryable field. It owns no mechanics: each page links to the reference that does. + +## Start here + +- **[What is CipherStash?](/get-started/what-is-cipherstash)** is the mental model. What the pieces are, what the trade is, and which door to walk through. +- **[Quickstart](/get-started/quickstart)** is ten minutes of typing. Encrypt a field, store it, query it without decrypting it, and read it back. It works against any Postgres: Supabase, Neon, RDS, or a container. +- **[Choose your stack](/get-started/choose-your-stack)** picks your integration path: SDK or Proxy, your platform, your ORM, and your identity provider. + +## Then pick your path + +- **Adopting CipherStash into an existing app?** [Integrations](/integrations) covers the platforms, ORMs, frameworks, and auth providers that wrap your existing client, so your queries keep the shape they already have. +- **Can't change the application?** [CipherStash Proxy](/reference/proxy) sits in front of Postgres and encrypts on the wire. +- **Want to understand it before you install it?** [Searchable encryption](/concepts/searchable-encryption) explains how encrypted queries work and what each query capability enables. +- **Evaluating for a security review?** [Architecture & security](/security) is written to be read end to end, starting with [Cryptography](/security/cryptography). diff --git a/content/docs/get-started/meta.json b/content/docs/get-started/meta.json new file mode 100644 index 0000000..a1ffe31 --- /dev/null +++ b/content/docs/get-started/meta.json @@ -0,0 +1,11 @@ +{ + "title": "Get started", + "icon": "Rocket", + "pages": [ + "index", + "what-is-cipherstash", + "quickstart", + "choose-your-stack", + "..." + ] +} diff --git a/content/docs/get-started/quickstart.mdx b/content/docs/get-started/quickstart.mdx new file mode 100644 index 0000000..c1175c0 --- /dev/null +++ b/content/docs/get-started/quickstart.mdx @@ -0,0 +1,185 @@ +--- +title: Quickstart +description: "Encrypt, store, query, and decrypt your first field in Postgres, using the stash CLI and @cipherstash/stack." +type: tutorial +components: [encryption, eql, cli, platform] +audience: [developer] +verifiedAgainst: + stack: "1.0.0" + cli: "1.0.0" + eql: "3.0.4" +--- + +CipherStash encrypts your data at the field level. Every value gets its own encryption key, and the database never sees plaintext. A breach, a compromised agent, or a curious insider sees ciphertext. + +By the end of this page you will have encrypted a field, stored it in Postgres, queried it **without decrypting it**, and read it back. It takes about ten minutes and works with any Postgres: Supabase, Neon, RDS, or a Docker container. + +## Before you start + +- Node.js 22 or later. +- A Postgres database you can connect to. +- A [CipherStash account](https://dashboard.cipherstash.com/sign-up). The free plan is enough. + + + + + +## Initialize your project + +```bash cta cta-type="quickstart" example-id="quickstart-stash-init" +npx stash init +``` + +This opens a browser for device-based authentication, so local development needs no environment variables and no shared secrets. `init` then: + +1. Connects to your workspace. +2. Resolves your database connection and runs `stash eql install`, which installs [EQL](/reference/eql) v3, the Postgres surface that makes encrypted columns queryable. +3. Installs `@cipherstash/stack` if it isn't already present. +4. Scaffolds an encryption module at `src/encryption/index.ts`. +5. Writes `.cipherstash/context.json` with what it detected about your project. + +Integration-specific initialization flags include `--supabase`, `--drizzle`, and `--prisma`. + +EQL v3 is the default, so `init` installs it directly against the database. Confirm what you ended up with: + +```bash example-id="quickstart-eql-status" +npx stash eql status +``` + +This guide, and the rest of the EQL reference, targets EQL v3. + + + + + +## Define what to encrypt + +`init` scaffolds `src/encryption/index.ts`. It declares which columns are encrypted, and exports a ready-to-use client. Edit it to match your table: + +```typescript filename="src/encryption/index.ts" +import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack" + +export const users = encryptedTable("users", { + email: encryptedColumn("email").equality(), +}) + +export const encryptionClient = await Encryption({ schemas: [users] }) +``` + +`.equality()` declares a **capability**: it is what makes `WHERE email = ?` work later. Add `.orderAndRange()` for `ORDER BY` and comparisons, or `.freeTextSearch()` for token containment. Declare only the capabilities you query on, because each one stores an extra index term alongside the ciphertext. + + +Prefer to have an agent do this? `npx stash plan` inspects your project and drafts `.cipherstash/plan.md` listing the columns worth encrypting. Review it, then `npx stash impl` applies it. This page does it by hand so you can see each moving part. + + + + + + +## Create the encrypted column + +An encrypted column is typed with an EQL domain. The domain you pick has to match the capability you declared: `.equality()` on a text column means `public.eql_v3_text_eq`. + +```sql filename="schema.sql" +CREATE TABLE users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq +); +``` + +The type is what fixes the column's searchable surface. There is no separate configuration table. See [core concepts](/reference/eql/core-concepts) for the full variant model. + + + + + +## Encrypt a value and store it + +```typescript filename="src/encrypt.ts" +import { encryptionClient, users } from "./encryption" + +const encrypted = await encryptionClient.encrypt("alice@example.com", { + column: users.email, + table: users, +}) + +if (encrypted.failure) { + throw new Error(`Encryption failed: ${encrypted.failure.message}`) +} + +await db.query("INSERT INTO users (email) VALUES ($1)", [encrypted.data]) +``` + +Every operation returns a `Result`: either `data` or `failure`, never both. Encryption happens in your process, so the plaintext never leaves it. + + + + + +## Query without decrypting + +Encrypt the search term, then use it in an ordinary `WHERE` clause. + +```typescript filename="src/query.ts" +import { encryptionClient, users } from "./encryption" + +const term = await encryptionClient.encryptQuery("alice@example.com", { + column: users.email, + table: users, +}) + +if (term.failure) { + throw new Error(`Query encryption failed: ${term.failure.message}`) +} + +const rows = await db.query( + "SELECT id, email FROM users WHERE email = $1::public.eql_v3_text_eq", + [term.data], +) +``` + +The cast matters. An encrypted operator only resolves against a **typed operand**, so `$1::public.eql_v3_text_eq` is what tells Postgres to compare encrypted terms rather than fall back to raw `jsonb` semantics. See [typed operands](/reference/eql/core-concepts). + +Postgres compares ciphertext against ciphertext. It never sees either plaintext. + + +`LIKE` and `ILIKE` do not work on encrypted columns, in any variant. Free-text matching uses bloom-filter token containment (`@>`) on a `text_match` or `text_search` column instead. See [text](/reference/eql/text). + + + + + + +## Decrypt + +```typescript filename="src/decrypt.ts" +const decrypted = await encryptionClient.decrypt(row.email) + +if (decrypted.failure) { + throw new Error(`Decryption failed: ${decrypted.failure.message}`) +} + +console.log(decrypted.data) // "alice@example.com" +``` + +`decrypt` takes the encrypted payload and nothing else. The payload carries the key ID it needs. + + + + + +## What you just built + +You encrypted a field, stored it, queried it without decrypting, and read it back. Underneath: + +- **ZeroKMS** returned a key seed, which your application processed with its client key to derive a **unique data key for that value**. The data key existed in your memory for one operation and was then discarded. See [cryptography](/security/cryptography). +- **Your client key** never left your infrastructure. Without it, nothing decrypts, including CipherStash. +- **Your keyset** is the isolation boundary. Data encrypted under one keyset cannot be decrypted with another, which is how you separate tenants or environments. +- **The index term** stored alongside the ciphertext is what let Postgres answer the query. Each capability you declare has a bounded, published leakage profile: an equality term lets an observer see which rows share a value, and nothing more. Declare only the capabilities you query on, so that what a column reveals stays within what your threat model already tolerates. See [searchable encryption](/concepts/searchable-encryption). + +## Next steps + +- **Using an ORM or Supabase?** The [Drizzle](/integrations/drizzle) and [Supabase](/integrations/supabase) integrations wrap your existing client so queries look normal. +- **No app changes possible?** [CipherStash Proxy](/reference/proxy) sits in front of Postgres and does this transparently. +- **Bind decryption to a user.** [Provable access control](/solutions/provable-access) ties a value to the identity that encrypted it. +- **Ready to deploy?** [Deployment](/guides/deployment) covers credential choices, environment promotion, and production rollout gates. diff --git a/content/docs/get-started/what-is-cipherstash.mdx b/content/docs/get-started/what-is-cipherstash.mdx new file mode 100644 index 0000000..d93efb1 --- /dev/null +++ b/content/docs/get-started/what-is-cipherstash.mdx @@ -0,0 +1,113 @@ +--- +title: What is CipherStash? +description: "Field-level encryption that stays queryable in Postgres: the mental model, the pieces, and which door to walk through." +type: concept +components: [encryption, platform, eql, proxy] +audience: [developer, cto, ciso] +--- + +CipherStash encrypts individual fields in your application, before they reach the database, and keeps them queryable in Postgres. The database stores ciphertext and can still answer `WHERE`, `ORDER BY`, and containment queries against it. + +A breach yields ciphertext. So does a compromised agent, an over-permissioned service, and a curious insider. + +## The problem + +Encrypting a column has always meant losing the query. So teams encrypt at rest, which protects the disk and nothing else: the moment a query runs, the database has plaintext, and so does anyone who reached the database. + +That was tolerable when the thing reading your data was a person. It is less so now that it is an agent running on application credentials, at machine speed, one prompt injection away from exfiltration. + +## The mental model + +Three ideas carry the whole product. + +**Encryption happens in your process.** Not in the database, not on our infrastructure. Plaintext never leaves your application. + +**Every value gets its own key.** Not a table key or a column key. A per-value key, derived for one operation and discarded. Compromising one value tells an attacker nothing about the next. + +**Ciphertext stays queryable.** Alongside each encrypted value, the client stores index terms that let Postgres compare ciphertext without decrypting it. Which terms a column carries is something you choose, and it decides what the database could infer: see [choosing what each column reveals](#choosing-what-each-column-reveals). + +## The pieces + +```mermaid +flowchart TB + subgraph app["Your application"] + sdk["Stack SDK
encrypt / decrypt"] + end + + subgraph db["Your Postgres"] + eql["EQL
encrypted column types
and operators"] + end + + subgraph platform["CipherStash platform"] + zerokms["ZeroKMS
key seeds"] + cts["CTS
identity tokens"] + end + + proxy["Proxy
speaks EQL for you"] + + sdk -- ciphertext + index terms --> eql + proxy -- ciphertext + index terms --> eql + sdk <-- key seed --> zerokms + proxy <-- key seed --> zerokms + cts -. binds decryption
to an identity .-> sdk +``` + +| Piece | What it does | When you need it | +|---|---|---| +| [Stack SDK](/reference/stack) | Encrypts and decrypts values in your application | The default path. Start here. | +| [EQL](/reference/eql) | The Postgres surface: encrypted column types, operators, and term extractors | Always, if the data lives in Postgres. Installed once per database. | +| [Proxy](/reference/proxy) | Sits in front of Postgres and speaks EQL for you | When you cannot change the application | +| ZeroKMS | Returns a key seed per value; never sees your client key or a data key | Always. See [cryptography](/security/cryptography). | +| CTS | Federates your identity provider so decryption can be bound to a user | Only for [identity-aware encryption](/solutions/provable-access) | + +You rarely need all of them at once. The Stack SDK plus EQL is the common case. Proxy is the alternative to the SDK, not an addition to it. + +Note that **EQL is only for Postgres**. The SDK also encrypts values that never touch a database, and non-Postgres stores like [DynamoDB](/integrations/aws/dynamodb), with no EQL involved. + +## Choosing what each column reveals + +Querying ciphertext works because each value carries index terms derived from its plaintext. Those terms are not the plaintext, and what each one reveals is **bounded and published** rather than incidental: each scheme has a formal leakage profile. + +That turns leakage into a design choice rather than a surprise. You decide, per column, which capabilities to declare, and that decides what an observer of the database could infer: + +| You declare | So you can | An observer could infer | +|---|---|---| +| Nothing | Store and decrypt | Nothing from the ciphertext | +| Equality | `WHERE email = ?`, `GROUP BY`, joins | Which rows share a value | +| Ordering | `ORDER BY`, ranges, `MIN` / `MAX` | The relative order of values | +| Free-text | Token containment | Probabilistic token overlap | + +The engineering is in matching those choices to your threat model. For most columns, knowing that two rows share some unknown value is not an exposure. For a column whose frequency distribution is itself the secret, it might be, and then you encrypt without that capability and filter after decryption. + +So the goal is not to eliminate leakage. It is to choose capabilities whose leakage your threat model already tolerates, and to know exactly what you chose. Declare only what you query on. The per-scheme detail is in [searchable encryption](/concepts/searchable-encryption). + +## Which door + +**You are building.** Start with the [Quickstart](/get-started/quickstart), then [choose your stack](/get-started/choose-your-stack) to find the integration that matches your platform and ORM. + +**You are evaluating.** [Architecture & security](/security) is self-contained for a vendor review, starting with [cryptography](/security/cryptography). [Solutions](/solutions) covers the problems teams bring us. + +**You are deciding whether this is the right tool.** Start with [searchable encryption](/concepts/searchable-encryption) to understand how encrypted queries work. The [comparisons](/concepts/compare) are written to be honest about what CipherStash is not. + +## Performance + +Encryption in use is usually assumed to be slow. It is, if you do it with fully homomorphic encryption. Searchable encryption is a different trade: bounded, published leakage in exchange for the database doing ordinary index work on ciphertext. + +| | | +|---|---| +| Query overhead | Sub-millisecond. The encrypted operators inline into functional indexes, so Postgres does a normal index scan. | +| vs fully homomorphic encryption | [410,000x faster](https://github.com/cipherstash/tfhe-ore-bench) on the per-row primitives a database actually executes. | +| vs AWS KMS | Up to 14x the throughput, because ZeroKMS derives keys in bulk rather than one call per value. | + +The FHE comparison is an open benchmark harness you can run yourself. See [CipherStash vs FHE](/concepts/compare/fhe) for the methodology, and for the workloads where FHE is genuinely the right tool. + +## What it protects against + +| Threat | What happens | +|---|---| +| Database breach | Ciphertext. The keys were never in the database. | +| Compromised application credentials | Ciphertext, unless the attacker also holds the client key. | +| AI agent exfiltration | The agent reaches the database and decrypts nothing, because its credentials are not the user's keys. | +| Curious insider | Every decryption is a key derivation, and ZeroKMS records who performed it. | + +What it does **not** protect against: an attacker who has compromised your running application process, holding the client key and able to call `decrypt`. Encryption in the application means the application can decrypt. See [cryptography](/security/cryptography) for the full trust model. diff --git a/content/docs/guides/deployment.mdx b/content/docs/guides/deployment.mdx new file mode 100644 index 0000000..49778ca --- /dev/null +++ b/content/docs/guides/deployment.mdx @@ -0,0 +1,184 @@ +--- +title: Deployment +description: "Deploy CipherStash changes safely across environments, with explicit credential, migration, rollout, monitoring, and rollback gates." +type: guide +components: [encryption, eql, cli] +audience: [developer] +--- + +CipherStash deployments coordinate application code, database schema, and credentials. Treat each independently deployable stage as a release with its own verification and rollback gate. + +For the detailed dual-write, backfill, and cutover procedure for populated columns, follow [Data migration](/guides/migration). This page covers the production rollout around that migration. + +## Before the first deployment + +- Create a separate CipherStash workspace or machine credential set for each environment. +- Store `CS_*` values in the environment's secret manager rather than copying a developer's local profile. +- Confirm the production database connection used by migrations and backfills. +- Test encryption, decryption, and the queries you rely on against a non-production database with the same EQL version. +- Inventory all application versions and background workers that may remain live during a rolling deployment. +- Decide who can approve backfill, read cutover, and plaintext removal. + +## Credentials at build and runtime + + + +An encryption client constructed at module load may authenticate when a build tool imports that module. In that case, the four `CS_*` variables shown above must be available during both the build and runtime phases. + +Do not let a local `stash auth login` session mask missing deployment credentials. Reproduce the production build in a clean environment that has only the secrets explicitly supplied by the deployment platform. + +Backfills must use credentials for the same workspace and keyset as the deployed application. `CS_*` environment variables take precedence over a local CipherStash profile. + +## Environment order + +Promote each stage through environments in this order: + +1. Local or ephemeral development database +2. Shared development or test +3. Staging with production-like data volume and connection behavior +4. Production + +Run schema compatibility and application tests after each promotion. Do not use a successful local backfill as evidence that production credentials, row counts, locks, or query plans are safe. + +## Production rollout + +| Stage | Change | Verification gate | Rollback | +| --- | --- | --- | --- | +| Install EQL | Database-only, additive | EQL status and application connectivity | Leave installed | +| Add encrypted mirrors | Nullable columns plus dual-writes | Every writer populates both columns | Revert writers; keep columns | +| Backfill | Out-of-band data job | Zero uncovered rows and sampled decryptions match | Stop and resume later | +| Build indexes | Database-only | Expected indexes engage under `EXPLAIN` | Drop or rebuild the index | +| Cut reads over | Application-only | Known filters, ordering, nulls, and RLS behavior are correct | Revert reads to plaintext | +| Remove plaintext | Application and destructive schema change | Soak complete; no old application versions remain | Restore or migrate from backup | + +Keep schema changes backward-compatible with both the old and new application version during rolling deployments. In particular, encrypted mirror columns must remain nullable until every historical row is covered. + +## Release gates + +### Before backfill + +- Dual-writes are deployed in every process that can mutate the table. +- Known inserts, updates, upserts, imports, and background jobs populate both columns. +- The backfill is using production's CipherStash credentials. +- The primary key and chunk size are suitable for paging through the table. + +### Before read cutover + +- Coverage queries return zero missing encrypted values. +- Encrypted indexes have been created and verified using [EQL indexes](/reference/eql/indexes). +- Equality, range, ordering, null, and authorization tests pass against representative data. +- The old plaintext read path remains deployable as a rollback. + +### Before dropping plaintext + +- The encrypted read path has soaked under real traffic. +- No supported application version reads or writes the plaintext column. +- Coverage has been checked again. +- Required backups and restore procedures have been tested. +- The destructive migration has a named approver and maintenance window where required. + +## Monitor after cutover + +Monitor ordinary database and application signals alongside encryption-specific failures: + +- Encryption and decryption errors +- ZeroKMS request latency and availability +- Query latency, sequential scans, and index use +- Missing or unexpectedly null values +- Authorization and Row Level Security regressions +- Backfill progress, retry rate, and uncovered-row count + +Test the correctness of returned values, not only request success rates. An incorrectly scoped filter or ordering query may return plausible but incomplete data without raising an error. + +## Rollback principles + +Prefer application rollbacks while both plaintext and encrypted columns are being maintained. Before plaintext removal, a read cutover can be reversed without changing data: point reads back to the original columns and leave dual-writes running. + +After plaintext is dropped, rollback becomes a data-recovery operation. Avoid reaching that stage until operational evidence shows the encrypted path is stable and every supported application version has moved forward. + +## Serverless and bundled applications + +`@cipherstash/stack` must remain a runtime dependency when a build tool creates +a server bundle. Configure the bundler to leave the package external, then make +sure the deployment artifact installs it for the target platform. + +For Next.js, use `serverExternalPackages`: + +```typescript filename="next.config.ts" +const nextConfig = { + serverExternalPackages: ["@cipherstash/stack"], +} + +export default nextConfig +``` + +For esbuild, pass `--external:@cipherstash/stack`; for webpack, declare it as a +CommonJS external. An SST function needs both the esbuild exclusion and a +runtime install: + +```typescript filename="sst.config.ts" +{ + nodejs: { + esbuild: { external: ["@cipherstash/stack"] }, + install: ["@cipherstash/stack"], + }, +} +``` + +Build and start the artifact in a Linux container before release when local +development uses macOS or Windows. This catches a lockfile or deployment bundle +that omitted the target platform's runtime package. + +## Testing and CI + +Use a separate workspace and database for integration tests. Supply the test +workspace's four `CS_*` credentials only to the CI job, install the same EQL +release used by production, and test: + +- encryption followed by decryption returns the original typed value; +- every required equality, range, ordering, token, or JSON query returns the + expected records; +- a client without the required keyset grant cannot decrypt the data; +- schema migrations and application code work in both rolling-deployment + orders. + +Mocks are useful for business logic that merely passes encrypted values +through, but they do not test ciphertext compatibility, key access, query +terms, or EQL behavior. Keep at least one real end-to-end test for every +encrypted query capability used in production. + +## Team onboarding + +Invite each developer to the organization and workspace, then have them run: + +```bash +npx stash init +``` + +Each device receives its own developer identity and client key. Do not share +production credentials with developers or copy one developer's local profile +into CI. Remove a departing developer from the workspace and revoke their +device client keys without changing application credentials. + +See [workspace members](/reference/workspace/members) and +[client keys](/reference/auth/clients) for the complete access model. + +## Deploying Proxy + +Run CipherStash Proxy as a container in the same private network as PostgreSQL. +Supply database credentials and CipherStash application credentials from the +platform's secret manager, expose the PostgreSQL listener only to application +clients, and expose health or metrics endpoints only to the monitoring plane. + +For ECS, Kubernetes, or another orchestrator, keep these concerns separate: + +1. Pull the published Proxy image from the approved registry. +2. Grant the task or pod permission to read only its deployment secrets. +3. Configure security groups or network policies for application → Proxy and + Proxy → database traffic. +4. Roll out at least two instances where connection availability requires it. +5. Verify a plaintext write, encrypted database value, encrypted query, and + decrypted read through the deployed endpoint. + +Use the [Proxy configuration reference](/reference/proxy/configuration) for +environment variables, TOML settings, ports, logging, and metrics. diff --git a/content/docs/guides/index.mdx b/content/docs/guides/index.mdx new file mode 100644 index 0000000..3f85df0 --- /dev/null +++ b/content/docs/guides/index.mdx @@ -0,0 +1,11 @@ +--- +title: Guides +description: "Deployment and data migration guidance for CipherStash." +type: guide +--- + +{/* + Fumadocs requires an index document when generating a metadata-backed folder. + meta.json excludes this file from the page tree, and Next redirects its route + to Deployment. +*/} diff --git a/content/docs/guides/meta.json b/content/docs/guides/meta.json new file mode 100644 index 0000000..1f31180 --- /dev/null +++ b/content/docs/guides/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Guides", + "icon": "Wrench", + "pages": ["!index", "deployment", "migration", "..."] +} diff --git a/content/docs/guides/migration.mdx b/content/docs/guides/migration.mdx new file mode 100644 index 0000000..7265075 --- /dev/null +++ b/content/docs/guides/migration.mdx @@ -0,0 +1,217 @@ +--- +title: Data migration +navTitle: Data migration +description: "Move populated plaintext columns to searchable encryption with dual-writes, a resumable backfill, and a reversible read cutover." +type: guide +components: [encryption, eql, cli] +audience: [developer] +--- + +Encrypting a populated column is a staged application and database migration. Ciphertext must be produced in your application, so a database migration cannot convert existing plaintext by itself. + +The safe pattern is to add an encrypted mirror column, dual-write new changes, backfill historical rows, and then move reads to the mirror. The plaintext column remains authoritative until the final cleanup, making every earlier stage reversible. + +This guide uses `encryptedSupabase` for its examples. The same sequence applies when the application uses [Drizzle](/integrations/drizzle), [Prisma ORM 8](/integrations/prisma), or the [Stack SDK](/reference/stack): change the adapter-specific code, not the rollout order. + +## Migration sequence + +```text +DEPLOY 1 OUT OF BAND DEPLOY 2 DEPLOY 3 +Add encrypted mirror Backfill existing rows Read encrypted mirror Drop plaintext +Begin dual-writes Verify coverage Keep dual-writes Stop dual-writes +Reads stay plaintext Build indexes Soak and verify +``` + +Do not start the backfill until dual-writes are running in every deployed writer. Otherwise, a row created after the backfill passes it can remain permanently uncovered. + +## Before you start + +- Install EQL and configure your encryption client. For Supabase, complete the [Quickstart](/integrations/supabase/quickstart) against a development table first. +- Inventory every writer of the columns: services, jobs, Edge Functions, imports, seeds, RPC functions, webhooks, and administrative tools. +- Make the same CipherStash deployment credentials available to the application and backfill job; see [Credentials at build and runtime](/guides/deployment#credentials-at-build-and-runtime). +- Decide which encrypted capability each column needs by following [EQL core concepts](/reference/eql/core-concepts). + +The examples migrate `patients.name` and `patients.date_of_birth`: + +```sql +CREATE TABLE patients ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email text NOT NULL, + name text, + date_of_birth date, + plan text +); +``` + + + + +### Add nullable encrypted mirrors + +Add one encrypted column beside each plaintext column: + +```sql +ALTER TABLE patients + ADD COLUMN name_encrypted public.eql_v3_text_eq, + ADD COLUMN date_of_birth_encrypted public.eql_v3_date_ord; +``` + +The new columns must be nullable because existing rows do not have ciphertext yet. The `_encrypted` suffix is the convention used by `stash encrypt backfill`; use `--encrypted-column` if your names differ. + +Ship this schema change together with the dual-write code in the next step. Do not rename or remove the plaintext columns. + + + + +### Dual-write every mutation + +Update every insert, update, and upsert path to write the plaintext column and its encrypted mirror in the same statement: + +```typescript title="lib/patients.ts" +import { db } from "./db" + +export async function createPatient(input: { + email: string + name: string + dateOfBirth: string + plan: string +}) { + return db.from("patients").insert({ + email: input.email, + name: input.name, + name_encrypted: input.name, + date_of_birth: input.dateOfBirth, + date_of_birth_encrypted: input.dateOfBirth, + plan: input.plan, + }) +} + +export async function updatePatientName(id: number, name: string) { + return db + .from("patients") + .update({ name, name_encrypted: name }) + .eq("id", id) +} +``` + +The encrypted client encrypts the mirror values while passing the original values through unchanged. Keeping both values in one database statement prevents one half of the write from succeeding without the other. + +Search the codebase for every writer before deploying. A missed importer, background job, conflict handler, or administrative path can create uncovered rows. + + + + +### Deploy and verify dual-writes + +Deploy the schema and application changes to the environment that owns the database. The backfill is not safe while dual-writes exist only locally or in CI. + +After the deployment, create and update known rows through each important write path. Confirm that both the plaintext and encrypted columns are populated, then inspect the migration state: + +```bash +npx stash encrypt status +``` + +The application is now in a safe steady state: new writes are mirrored, old rows remain plaintext-only, and reads still use the original columns. + + + + +### Backfill historical rows + +Run one backfill per plaintext column: + +```bash +npx stash encrypt backfill --table patients --column name +npx stash encrypt backfill --table patients --column date_of_birth +``` + +The command pages by primary key, encrypts rows in chunks, and records checkpoints. It is resumable and idempotent. For a non-interactive job, acknowledge the deployment gate with `--confirm-dual-writes-deployed`. + + +Run the backfill with the same CipherStash workspace and keyset as the deployed application. A local developer profile may point to a different workspace, producing values the production application cannot decrypt. Use the deployed environment's explicit `CS_*` values for the job. + + + + + +### Verify complete coverage + +Check that every populated plaintext value has an encrypted mirror: + +```sql +SELECT + count(*) FILTER ( + WHERE name IS NOT NULL AND name_encrypted IS NULL + ) AS name_uncovered, + count(*) FILTER ( + WHERE date_of_birth IS NOT NULL + AND date_of_birth_encrypted IS NULL + ) AS dob_uncovered +FROM patients; +``` + +Both counts must be zero. A non-zero result usually means a writer is not dual-writing. Fix and deploy that path, then run the backfill again before continuing. + +Also sample known records through the encrypted application client and verify their decrypted values against the plaintext originals. + + + + +### Build encrypted indexes + +Build the functional indexes required by the queries you will move to the encrypted columns. Do this after the bulk backfill and before changing reads. + +Index definitions, supported query shapes, `EXPLAIN` verification, and guidance for large tables are maintained in [EQL indexes](/reference/eql/indexes). Use concurrent index creation where that guide recommends it for a live table. + + + + +### Cut reads over + +Deploy an application-only change that reads and filters on the encrypted mirror columns: + +```typescript +const { data } = await db + .from("patients") + .select("id, email, name_encrypted, date_of_birth_encrypted") + .eq("name_encrypted", "Alice Chen") + .gt("date_of_birth_encrypted", "1980-01-01") + .order("date_of_birth_encrypted", { ascending: false }) +``` + +Keep dual-writes enabled. If the new read path misbehaves, reverting this deployment restores plaintext reads while both sets of columns remain current. + +Verify correctness rather than checking only for non-empty results. Test known equality matches, range boundaries, ordering, nulls, and authorization behavior. + + + + +### Soak, then remove plaintext + +Let production traffic exercise the encrypted read path. Monitor query errors, latency, decryption failures, and uncovered-row counts for an appropriate period. + +Once you no longer need a plaintext rollback, remove the dual-write code and generate the destructive migration: + +```bash +npx stash encrypt drop --table patients --column name +``` + +Review and apply the generated migration through your normal deployment process. Repeat per column rather than combining unrelated migrations into one irreversible change. + + +Dropping the plaintext column is the first irreversible stage. Take any required backup, verify coverage again at apply time, and confirm that no old application version still reads or writes the column. + + + + + +## Rollback by stage + +| Current stage | Safe rollback | +| --- | --- | +| Mirrors added, before backfill | Revert application writes; leave or remove the empty mirrors | +| Backfill in progress | Stop it and resume later; keep plaintext reads and dual-writes | +| Reads cut over | Revert reads to plaintext; keep dual-writes | +| Plaintext dropped | Restore from an approved backup or perform a new decrypting migration | + +For environment ordering, credential handling, release gates, and production monitoring, continue with the [deployment guide](/guides/deployment). diff --git a/content/docs/index.mdx b/content/docs/index.mdx new file mode 100644 index 0000000..8f3f6f7 --- /dev/null +++ b/content/docs/index.mdx @@ -0,0 +1,37 @@ +--- +title: CipherStash Docs +seoTitle: "CipherStash Docs: searchable encryption for Postgres" +description: "Searchable field-level encryption, identity-bound keys, and cryptographic audit trails, built into your existing Postgres stack." +type: concept +audience: [developer, cto, ciso] +--- + +CipherStash encrypts your data at the field level. Every value gets its own +key, and the ciphertext stays queryable in Postgres. A breach, a compromised +agent, a curious insider: they all see ciphertext with no key. + +## Start here + + + + + + + + +## Browse the docs + + + + + + + + + + +## AI-ready documentation + +Every page is available as clean markdown: append `.mdx` to any page URL, or +fetch the whole corpus via [llms.txt](/llms.txt) and +[llms-full.txt](/llms-full.txt). diff --git a/content/docs/integrations/aws/dynamodb.mdx b/content/docs/integrations/aws/dynamodb.mdx new file mode 100644 index 0000000..4a9bb1a --- /dev/null +++ b/content/docs/integrations/aws/dynamodb.mdx @@ -0,0 +1,143 @@ +--- +title: DynamoDB +description: "Encrypt DynamoDB attributes with @cipherstash/stack, including bulk operations and equality lookups over HMAC attributes." +type: guide +components: [encryption, platform] +audience: [developer] +integration: + category: platform + setup: code-only +verifiedAgainst: + stack: "1.0.0" +--- + +CipherStash's DynamoDB helper encrypts and decrypts items without wrapping the +AWS SDK. Your application keeps control of every `PutCommand`, `GetCommand`, +and `QueryCommand`; `encryptedDynamoDB` transforms the item immediately before +a write and after a read. + +DynamoDB does not use EQL. Equality-enabled attributes store an HMAC search +term alongside the ciphertext so they can participate in key conditions. + +## Install + +```bash cta cta-type="install" example-id="install-dynamodb" +npm install @cipherstash/stack @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb +``` + +## Define the encrypted attributes + +Use the v3 schema builder. `TextEq` produces an equality term; `Text` is +storage-only. + +```typescript filename="encryption.ts" +import { Encryption } from "@cipherstash/stack" +import { encryptedDynamoDB } from "@cipherstash/stack/dynamodb" +import { encryptedTable, types } from "@cipherstash/stack/v3" + +export const users = encryptedTable("users", { + email: types.TextEq("email"), + name: types.Text("name"), +}) + +export const encryptionClient = await Encryption({ schemas: [users] }) +export const dynamo = encryptedDynamoDB({ encryptionClient }) +``` + +For an equality-enabled `email`, the encrypted item contains: + +| Attribute | Purpose | +| --- | --- | +| `email__source` | Ciphertext used for decryption | +| `email__hmac` | Equality term used in a partition key, sort key, or GSI | + +Non-encrypted attributes pass through unchanged. Ordering and free-text terms +have no DynamoDB query surface and are not stored. + +## Encrypt and write an item + +```typescript filename="write-user.ts" +import { PutCommand } from "@aws-sdk/lib-dynamodb" +import { docClient } from "./aws" +import { dynamo, users } from "./encryption" + +const encrypted = await dynamo.encryptModel( + { pk: "user#1", email: "alice@example.com", name: "Alice", role: "admin" }, + users, +) + +if (encrypted.failure) throw encrypted.failure + +await docClient.send(new PutCommand({ + TableName: "Users", + Item: encrypted.data, +})) +``` + +The result retains `pk` and `role`, replaces `email` with `email__source` and +`email__hmac`, and replaces `name` with `name__source`. + +## Read and decrypt + +```typescript filename="read-user.ts" +import { GetCommand } from "@aws-sdk/lib-dynamodb" + +const stored = await docClient.send(new GetCommand({ + TableName: "Users", + Key: { pk: "user#1" }, +})) + +const decrypted = await dynamo.decryptModel(stored.Item ?? {}, users) +if (decrypted.failure) throw decrypted.failure + +console.log(decrypted.data.email) +``` + +Use `bulkEncryptModels` and `bulkDecryptModels` before `BatchWriteCommand` and +after `BatchGetCommand` to process many items in one ZeroKMS round trip. + +## Query an equality-enabled attribute + +Encrypt the operand with the same table and column definition, then use its +HMAC as the DynamoDB key value: + +```typescript filename="find-by-email.ts" +import { QueryCommand } from "@aws-sdk/lib-dynamodb" + +const term = await encryptionClient.encryptQuery("alice@example.com", { + table: users, + column: users.email, +}) + +if (term.failure) throw term.failure + +const result = await docClient.send(new QueryCommand({ + TableName: "Users", + IndexName: "EmailIndex", + KeyConditionExpression: "email__hmac = :email", + ExpressionAttributeValues: { ":email": term.data.hm }, +})) + +const decrypted = await dynamo.bulkDecryptModels(result.Items ?? [], users) +if (decrypted.failure) throw decrypted.failure +``` + +An encrypted HMAC attribute can back a table key or GSI. Ciphertext source +attributes do not support ranges, `begins_with`, or `contains`. + +## Read items written by EQL v2 + +Keep the current v3 table declaration and opt into legacy decoding while old +items remain: + +```typescript +const decrypted = await dynamo.decryptModel(storedItem, users, { + storedEqlVersion: 2, +}) +``` + +New writes always use the current format. Remove the option after every legacy +item has been rewritten. + +For every method, option, and return type, see the generated +[DynamoDB API reference](/reference/stack/api-reference/dynamodb). diff --git a/content/docs/integrations/aws/meta.json b/content/docs/integrations/aws/meta.json new file mode 100644 index 0000000..12b54e1 --- /dev/null +++ b/content/docs/integrations/aws/meta.json @@ -0,0 +1,4 @@ +{ + "title": "AWS", + "pages": ["dynamodb"] +} diff --git a/content/docs/integrations/drizzle/api-reference/meta.json b/content/docs/integrations/drizzle/api-reference/meta.json new file mode 100644 index 0000000..dd99951 --- /dev/null +++ b/content/docs/integrations/drizzle/api-reference/meta.json @@ -0,0 +1,4 @@ +{ + "title": "API reference", + "pages": ["index"] +} diff --git a/content/docs/integrations/drizzle/index.mdx b/content/docs/integrations/drizzle/index.mdx new file mode 100644 index 0000000..9e28935 --- /dev/null +++ b/content/docs/integrations/drizzle/index.mdx @@ -0,0 +1,187 @@ +--- +title: Overview +description: "Encrypted columns with Drizzle ORM: pick a concrete EQL column type, and capability-checked operators encrypt your query operands for you." +type: tutorial +components: [encryption, eql] +audience: [developer] +integration: + category: orm + setup: code-only + pairsWith: [supabase, prisma] +verifiedAgainst: + stack: "1.0.0" + eql: "3.0.4" +--- + +Drizzle keeps its shape. You declare each encrypted column with a concrete EQL type, and a set of query operators encrypt their operands before Drizzle builds the SQL. Your `select`, `where`, and `orderBy` read as they always did, and the database only ever compares ciphertext. + +## Install + +```bash cta cta-type="install" example-id="install-drizzle" +npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm +npm install --save-dev drizzle-kit +``` + +Add EQL to your Drizzle migration history: + +```bash +npx stash eql migration --drizzle +npx drizzle-kit migrate +``` + + +`stash eql migration --drizzle` uses your project-local `drizzle-kit` to create a custom migration, then fills it with the EQL v3 install SQL. Pass `--out` if your migration directory is not `drizzle/`. For a direct, out-of-band install instead, run `npx stash eql install`. + + +## Credentials + + + +The native Stack client used below discovers the four `CS_*` variables first, then falls back to the developer profile. Use separate credentials for each deployed environment. + +## Declare the table + +The column type *is* the capability. There are no flags to configure. `TextEq` answers equality and nothing else, `IntegerOrd` answers ranges and ordering, `TextMatch` answers free-text matching, and a bare `Text` or `Bigint` is storage-only. + +```typescript filename="src/schema.ts" +import { pgTable, integer } from "drizzle-orm/pg-core" +import { encryptedIndexes, types } from "@cipherstash/stack-drizzle" + +export const users = pgTable( + "users", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + email: types.TextEq("email"), // equality + age: types.IntegerOrd("age"), // ranges, ordering, and equality + bio: types.TextMatch("bio"), // free-text token matching + balance: types.Bigint("balance"), // storage only + }, + (table) => [...encryptedIndexes(table)], +) +``` + +Each factory maps to the matching EQL domain, so `types.TextEq("email")` types the column as `public.eql_v3_text_eq`. The variant model behind those names is in [core concepts](/reference/eql/core-concepts). + +| Factory | Capability | EQL domain | +| --- | --- | --- | +| `types.Text(name)` | Store and decrypt only | `public.eql_v3_text` | +| `types.TextEq(name)` | `eq`, `ne`, `inArray`, `notInArray` | `public.eql_v3_text_eq` | +| `types.TextOrd(name)` | Ranges, ordering, plus equality | `public.eql_v3_text_ord` | +| `types.TextMatch(name)` | `matches` | `public.eql_v3_text_match` | +| `types.TextSearch(name)` | All of the above, on text | `public.eql_v3_text_search` | +| `types.IntegerOrd(name)` | Ranges, ordering, plus equality | `public.eql_v3_integer_ord` | + +The same bare / `Eq` / `Ord` pattern exists for `Smallint`, `Bigint`, `Real`, `Double`, `Numeric`, `Date`, and `Timestamp`. `Boolean` is storage-only by design, because a two-value column has too little cardinality for any searchable index to be safe. + +Declare only the capability you query on. Each one stores an extra index term alongside the ciphertext, with a bounded, published leakage profile: see [searchable encryption](/concepts/searchable-encryption). + +## Wire up the client + +Derive the encryption schema from the Drizzle table, build a typed client, and create the operators: + +```typescript filename="src/db.ts" +import { drizzle } from "drizzle-orm/postgres-js" +import { + createEncryptionOperators, + extractEncryptionSchema, +} from "@cipherstash/stack-drizzle" +import { Encryption } from "@cipherstash/stack" +import { users } from "./schema" + +export const usersSchema = extractEncryptionSchema(users) + +export const client = await Encryption({ schemas: [usersSchema] }) +export const ops = createEncryptionOperators(client) + +export const db = drizzle({ client: sqlClient }) +``` + +`extractEncryptionSchema` reads the encryption config straight off the column types, so there is no second schema to keep in sync with the first. + +## Query + +Every where-clause operator is **async**, because it encrypts its operand before Drizzle sees it. `await` each one. `ops.asc` and `ops.desc` are synchronous. + +```typescript filename="src/queries.ts" +import { db, ops } from "./db" +import { users } from "./schema" + +// Equality — email is TextEq +const exact = await db.select().from(users) + .where(await ops.eq(users.email, "alice@example.com")) + +// Ranges and ordering — age is IntegerOrd +const adults = await db.select().from(users) + .where(await ops.gte(users.age, 18)) + .orderBy(ops.asc(users.age)) + +const midBand = await db.select().from(users) + .where(await ops.between(users.age, 25, 40)) + +// Set membership, built on equality +const listed = await db.select().from(users) + .where(await ops.inArray(users.email, ["alice@example.com", "bob@example.com"])) + +// Free-text token containment — bio is TextMatch +const coffee = await db.select().from(users) + .where(await ops.matches(users.bio, "coffee")) +``` + +The full operator set is `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `matches`, `contains`, `selector`, `inArray`, `notInArray`, `exists`, `notExists`, `and`, `or`, `not`, `isNull`, `isNotNull`, `asc`, and `desc`. Null checks need no encryption, because a SQL `NULL` is never encrypted. See the [API reference](/integrations/drizzle/api-reference#createencryptionoperators) for signatures and per-operator capability requirements. + +Applying an operator the column's type cannot answer throws `EncryptionOperatorError` rather than silently returning wrong rows. Ordering a `TextEq` column, for instance, has no ordering term to compare. + + +There is no `like` or `ilike`. SQL pattern matching is meaningless on ciphertext, so free-text search is `ops.matches`, which compares encrypted token sets. It matches whole indexed tokens rather than arbitrary substrings. `ops.contains` is reserved for encrypted JSON containment. + + +## Insert and read + +Rows are encrypted **before** they reach Drizzle, so Drizzle never sees plaintext. Encrypt a batch in one call, which is also one ZeroKMS round trip: + +```typescript filename="src/insert.ts" +import { client, db, usersSchema } from "./db" +import { users } from "./schema" + +const rows = await client.bulkEncryptModels( + [ + { email: "alice@example.com", age: 30, bio: "climbing and coffee", balance: 100_000n }, + { email: "bob@example.com", age: 41, bio: "cycling and coffee", balance: 250_000n }, + ], + usersSchema, +) + +if (rows.failure) { + throw new Error(rows.failure.message) +} + +await db.insert(users).values(rows.data) +``` + +`Bigint` columns take a native JS `bigint`. Decrypt what comes back with `client.bulkDecryptModels(rows)`, which is likewise one round trip for the batch. + +## Indexes + +`encryptedIndexes(table)` derives the recommended functional indexes from each encrypted column's capabilities. In the schema above it produces equality, ordering, and free-text indexes while ignoring the storage-only `balance` column. Generate and apply the result through your normal `drizzle-kit` migration workflow, then run `ANALYZE users`. + +See [`encryptedIndexes`](/integrations/drizzle/api-reference#encryptedindexes) for the emitted index forms. Index selection, `EXPLAIN` verification, and large-table build guidance are in [EQL indexes](/reference/eql/indexes). + +## Where to next + + + + Exported column types, schema helpers, codecs, indexes, and encrypted query operators. + + + Drizzle over a Supabase Postgres connection. + + + The variant model behind each column type. + + + What each capability reveals, and how to choose. + + + The functional-index recipes behind every query on this page. + + diff --git a/content/docs/integrations/drizzle/meta.json b/content/docs/integrations/drizzle/meta.json new file mode 100644 index 0000000..d185819 --- /dev/null +++ b/content/docs/integrations/drizzle/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Drizzle", + "icon": "Drizzle", + "pages": ["index", "api-reference"] +} diff --git a/content/docs/integrations/index.mdx b/content/docs/integrations/index.mdx new file mode 100644 index 0000000..346f52b --- /dev/null +++ b/content/docs/integrations/index.mdx @@ -0,0 +1,47 @@ +--- +title: Integrations +navTitle: Overview +description: "Every CipherStash integration, by category: the Postgres platforms encrypted data lives in, and the query layers that read and write it." +type: concept +components: [encryption, eql, platform] +audience: [developer] +--- + +Every integration below writes the same ciphertext into the same [EQL](/reference/eql) columns, so none of them is a one-way door. You can move a table from one to another without re-encrypting it, and use more than one at once — an ORM for application queries, raw SQL for migrations. + +## Database + +Where the encrypted data lives. EQL installs into any Postgres you can connect to, with no extension and no superuser. + + + + Install EQL with the `stash` CLI, then query encrypted columns through PostgREST. Row Level Security, Supabase Auth, and Edge Functions all apply unchanged. + + + Prisma's managed Postgres, with EQL installed through the Prisma ORM 8 migration graph. + + + +## ORM + +How your application queries it. + + + + `encryptedSupabase` wraps your existing client so `.eq()`, `.in()`, `.gt()`, and `.order()` keep working on encrypted columns. + + + Encrypted EQL v3 columns declared in `schema.prisma`, with typed encrypted query operators. + + + Concrete encrypted column types and capability-checked operators. Targets EQL v3. + + + + +Not on this list? Any ORM works without an integration — encrypt before a write and decrypt after a read with the [Stack SDK](/reference/stack), which is what the integrations do for you. [CipherStash Proxy](/reference/proxy) covers the case where you cannot change the application at all, and the SDK also encrypts values that never reach Postgres, including non-Postgres stores like [DynamoDB](/integrations/aws/dynamodb). + + +## Still deciding + +[Choose your stack](/get-started/choose-your-stack) walks the four decisions in order: SDK or Proxy, your Postgres, your ORM, and your identity provider — including which identity providers can bind decryption to the signed-in user. diff --git a/content/docs/integrations/meta.json b/content/docs/integrations/meta.json new file mode 100644 index 0000000..78d30fa --- /dev/null +++ b/content/docs/integrations/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Integrations", + "icon": "Blocks", + "pages": ["index", "supabase", "prisma", "drizzle", "..."] +} diff --git a/content/docs/integrations/prisma/api-reference/meta.json b/content/docs/integrations/prisma/api-reference/meta.json new file mode 100644 index 0000000..10d114f --- /dev/null +++ b/content/docs/integrations/prisma/api-reference/meta.json @@ -0,0 +1,14 @@ +{ + "title": "API reference", + "pages": [ + "index", + "codec-types", + "column-types", + "control", + "operation-types", + "pack", + "runtime", + "stack", + "v3" + ] +} diff --git a/content/docs/integrations/prisma/deployment.mdx b/content/docs/integrations/prisma/deployment.mdx new file mode 100644 index 0000000..f5d24a9 --- /dev/null +++ b/content/docs/integrations/prisma/deployment.mdx @@ -0,0 +1,65 @@ +--- +title: Deployment +description: "Deploy CipherStash schema changes, backfills, indexes, and credentials safely with Prisma ORM 8." +type: guide +components: [encryption, eql] +audience: [developer, cto] +integration: + category: orm + setup: code-only +verifiedAgainst: + prismaNext: "0.16.0" + eql: "3.0.4" + stack: "1.0.0" +--- + +Prisma owns the complete database migration graph for this integration, including the EQL bundle, encrypted columns, and their functional indexes. Application releases still need to be staged carefully when a plaintext column already contains data. + +For the reasoning, rollback rules, and adapter-independent sequence, read [Data migration](/guides/migration) and the [deployment guide](/guides/deployment). This page maps that process to Prisma ORM 8. + +## New tables and empty columns + +For a new table or an empty column: + +```bash +npx prisma-next contract emit +npx prisma-next migration plan --name add-encrypted-fields +npx prisma-next migrate +``` + +The CipherStash extension pack contributes EQL to the same graph. Do not run `stash eql install`. + +## Populated columns + +Move a populated plaintext column to encryption across separate application releases: + +| Release | Prisma and application change | Gate | +| --- | --- | --- | +| 1. Add and dual-write | Add a nullable encrypted twin; write plaintext and ciphertext on every path; keep plaintext reads | Confirm dual-writes are live | +| Backfill | Run `stash encrypt backfill` against the target database with the deployed app's keyset | Confirm zero uncovered rows | +| Index | Add EQL functional indexes as a Prisma raw-SQL migration operation and run `ANALYZE` | Verify plans with `EXPLAIN` | +| 2. Cut over reads | Query and decrypt the encrypted twin; keep dual-writes | Soak under real traffic | +| 3. Stop dual-writing | Write only the encrypted field; retain the plaintext column | Confirm every instance is updated | +| 4. Remove plaintext | Apply a guarded destructive migration, then remove the old field from the contract | Irreversible | + +Do not combine these boundaries. In particular, a backfill is safe only after every production write path dual-writes, and the plaintext column must remain until encrypted reads have soaked successfully. + +## Index migrations + +Prisma schemas cannot express functional indexes. Keep their DDL as `rawSql` operations from `@prisma-next/postgres/migration` in the Prisma migration history; never apply them ad hoc to production. + +Use the recipes in [EQL indexes](/reference/eql/indexes). Build indexes after the bulk backfill and before encrypted reads go live, then run `ANALYZE` and verify the actual query plan. + +## Credentials + + + +Create separate credentials for each deployed environment. A backfill must use credentials bound to the same keyset as the deployed application; otherwise decryption or encrypted search can fail after a seemingly successful write. + +The encryption client is commonly constructed during module import, so make the four `CS_*` variables available at build time as well as runtime. + +## Runtime packaging + +The Prisma adapter uses the native Stack client. Keep `@cipherstash/stack`, `@cipherstash/protect-ffi`, and `@cipherstash/auth` external to server bundles, and deploy to a runtime that can load native packages. This adapter is not supported on edge runtimes. + +For Prisma's hosted deployment behavior, including additive-only push-to-deploy and preview databases, continue with [Prisma Compute](/integrations/prisma/prisma-compute). diff --git a/content/docs/integrations/prisma/index.mdx b/content/docs/integrations/prisma/index.mdx new file mode 100644 index 0000000..3fa9652 --- /dev/null +++ b/content/docs/integrations/prisma/index.mdx @@ -0,0 +1,60 @@ +--- +title: Overview +description: "Searchable field-level encryption for Prisma ORM 8: declare encrypted columns in schema.prisma and query them with the Prisma ORM." +type: concept +components: [encryption, eql] +audience: [developer] +integration: + category: orm + setup: code-only + pairsWith: [supabase, drizzle] +verifiedAgainst: + prismaNext: "0.16.0" + eql: "3.0.4" + stack: "1.0.0" +--- + +CipherStash adds searchable, field-level encryption to Prisma ORM 8. Encrypted columns are declared in `schema.prisma`, EQL is installed through Prisma's migration graph, and the generated query API encrypts operands and values before they reach Postgres. + + +This integration targets the **Prisma ORM 8 release candidate**, formerly known as **Prisma Next**. The prerelease CLI, package names, and configuration file still use `prisma-next` or `@prisma-next/*`; the examples preserve those names exactly. CipherStash setup uses `stash init --prisma`. + +It does not extend the classic `@prisma/client` API. For earlier Prisma releases, use the [Stack SDK](/reference/stack) around your existing reads and writes. + + +## Choose your path + +| What you want to do | Start here | +| --- | --- | +| Encrypt and query your first Prisma field | [Prisma ORM Quickstart](/integrations/prisma/quickstart) | +| Choose column types or look up query operators | [Schema and queries](/integrations/prisma/schema-and-queries) | +| Look up every exported function and type | [API reference](/integrations/prisma/api-reference) | +| Encrypt data already in production | [Prisma deployment guide](/integrations/prisma/deployment) | +| Use Prisma's managed database | [Prisma Postgres](/integrations/prisma/prisma-postgres) | +| Deploy the application with Prisma | [Prisma Compute](/integrations/prisma/prisma-compute) | +| Design and verify encrypted indexes | [EQL indexes](/reference/eql/indexes) | +| Look up the Prisma 8 prerelease itself | [Prisma ORM 8 documentation](https://www.prisma.io/docs/orm/next) | + +## How it works + +The [`@cipherstash/stack-prisma`](https://www.npmjs.com/package/@cipherstash/stack-prisma) extension has three integration points: + +1. Domain-named constructors such as `cipherstash.TextSearch()` declare encrypted EQL v3 columns in `schema.prisma`. +2. The CipherStash extension pack adds EQL and those columns to Prisma's contract and migration graph. +3. `cipherstashFromStack` connects the generated Prisma runtime to the Stack encryption client. + +The column type fixes which encrypted queries are possible. For example, `TextEq()` supports equality, while `TextSearch()` also supports ranges, ordering, and token matching. Choosing the narrowest useful type minimizes the information disclosed by searchable encryption. [Schema and queries](/integrations/prisma/schema-and-queries) contains the complete type and operator tables. + +## EQL and migration ownership + +This integration targets EQL v3 only. Prisma owns the EQL installation: `npx prisma-next migrate` installs the EQL bundle alongside your application schema. Do not run `stash eql install` in a Prisma ORM 8 project. + +Encrypted functional indexes also belong in the Prisma migration graph. `schema.prisma` cannot express expression indexes, so add their SQL as a Prisma raw-SQL migration operation. The canonical recipes and `EXPLAIN` checks live in [EQL indexes](/reference/eql/indexes). + +## Runtime and authentication + +Local development uses the CipherStash developer profile created by `stash init`. CI and deployed applications use credentials created with the CLI or Dashboard. [Credentials](/integrations/prisma/deployment#credentials) lists all three options and the four deployment environment variables. + +The Prisma adapter currently uses the native Stack client, so deploy it to a server runtime that can load native packages; it is not an edge-runtime adapter. + +For a new project, continue with the [Quickstart](/integrations/prisma/quickstart). diff --git a/content/docs/integrations/prisma/meta.json b/content/docs/integrations/prisma/meta.json new file mode 100644 index 0000000..3f6502e --- /dev/null +++ b/content/docs/integrations/prisma/meta.json @@ -0,0 +1,13 @@ +{ + "title": "Prisma ORM", + "icon": "Prisma", + "pages": [ + "index", + "quickstart", + "schema-and-queries", + "deployment", + "prisma-postgres", + "prisma-compute", + "api-reference" + ] +} diff --git a/content/docs/integrations/prisma/prisma-compute.mdx b/content/docs/integrations/prisma/prisma-compute.mdx new file mode 100644 index 0000000..b21848f --- /dev/null +++ b/content/docs/integrations/prisma/prisma-compute.mdx @@ -0,0 +1,88 @@ +--- +title: Prisma Compute +description: "Deploy a CipherStash-enabled Prisma ORM 8 application on Prisma Compute." +type: guide +components: [encryption, eql, platform] +audience: [developer, cto] +integration: + category: platform + setup: code-only +verifiedAgainst: + prismaNext: "0.16.0" + eql: "3.0.4" + stack: "1.0.0" +--- + +[Prisma Compute](https://www.prisma.io/docs/compute) deploys TypeScript applications alongside Prisma Postgres. CipherStash has been exercised with Prisma Compute's July 2026 public beta; verify fast-moving CLI and platform details against the current Prisma documentation. + +## Before the first deploy + + + +The local developer profile is not available on Prisma Compute. Create deployment credentials with `stash env` or the Dashboard, then add all four variables to both the preview and production roles. + +These values are needed during the build as well as at runtime because a database module may construct `cipherstashFromStack` when it is imported. Prisma Compute resolves environment variables when it creates a deployment, so redeploy after changing them. + +Use separate credentials for preview and production, bound to the keyset intended for each environment. + +{/* Screenshot candidate: Prisma Compute environment-variable screen showing the + four CS_* key names, preview/production scopes, and hidden values. */} + +## Keep native packages external + +The Prisma adapter uses CipherStash's native client. For Next.js, exclude its packages from the server bundle: + +```typescript title="next.config.ts" +import type { NextConfig } from "next" + +const nextConfig: NextConfig = { + serverExternalPackages: [ + "@cipherstash/stack", + "@cipherstash/protect-ffi", + "@cipherstash/auth", + ], +} + +export default nextConfig +``` + +Do not select an edge runtime for routes that use this Prisma adapter. + +## Understand push-to-deploy + +A Prisma Compute deployment runs contract emission and `prisma-next db init` against the target branch database. + +- Additive changes, including the EQL bundle and new encrypted columns, are reconciled during deployment. +- Destructive changes such as dropping the old plaintext column or setting `NOT NULL` are rejected by the additive-only deployment policy. +- Preview branches receive fresh databases, so a green preview does not prove that a destructive production update will succeed. + +Use separate pull requests for each stage in the [Prisma deployment guide](/integrations/prisma/deployment). + +## Apply destructive migrations deliberately + +After the release that stops writing the plaintext column is live, apply the reviewed destructive migration directly to the production database before merging the contract change: + +```bash +npx @prisma/cli database list +npx @prisma/cli database show --json +npx @prisma/cli database connection create + +npx prisma-next db update --db "" + +npx @prisma/cli database connection remove +``` + +Confirm the production database by name and with `database show`; do not rely on list order or branch metadata. A migration applied to the wrong preview database can succeed while the production deployment continues to fail. + +The destructive migration must include an apply-time coverage guard so the plaintext column is not dropped while any row lacks ciphertext. + +## Operational notes + +- Keep authorization checks in the page, server action, or route handler rather than relying only on Next.js middleware. +- Runtime output and build output use different Prisma commands: `app logs` and `build logs `. +- A first request after scale-to-zero can cold-start; retry before treating it as an application failure. + +{/* Screenshot candidate: Prisma Compute branch page showing an application, + its linked Prisma Postgres database, and separate production/preview state. */} + +Prisma Compute is in public beta. Reconfirm platform limitations before using it for a mission-critical workload. diff --git a/content/docs/integrations/prisma/prisma-postgres.mdx b/content/docs/integrations/prisma/prisma-postgres.mdx new file mode 100644 index 0000000..9dd0d37 --- /dev/null +++ b/content/docs/integrations/prisma/prisma-postgres.mdx @@ -0,0 +1,68 @@ +--- +title: Prisma Postgres +description: "Connect the CipherStash Prisma ORM 8 integration to Prisma's managed Postgres service." +type: guide +components: [encryption, eql, platform] +audience: [developer, cto] +integration: + category: platform + setup: code-only +verifiedAgainst: + prismaNext: "0.16.0" + eql: "3.0.4" + stack: "1.0.0" +--- + +[Prisma Postgres](https://www.prisma.io/docs/postgres) is Prisma's managed Postgres service. CipherStash uses it like any other Postgres database: encryption happens in the application, and EQL plus the encrypted domains live in the database. + +## Choose the connection URL + +Prisma Postgres provides pooled and direct connections: + +| Use | Connection | +| --- | --- | +| Application runtime | Pooled URL on `pooled.db.prisma.io` | +| Migrations, administrative work, and one-off backfills | Direct URL on `db.prisma.io` | + +Both connections require TLS. Copy the URLs from the Prisma Console and keep them in the relevant environment's secret store. + +```dotenv title=".env" +DATABASE_URL=postgresql://...@pooled.db.prisma.io:5432/postgres?sslmode=require +DIRECT_URL=postgresql://...@db.prisma.io:5432/postgres?sslmode=require +``` + +Use the direct URL when applying the Prisma migration graph or running a long-lived backfill. Use the pooled URL when connecting the deployed application. + +{/* Screenshot candidate: Prisma Console connection dialog with pooled and direct + connection choices visible, project names retained, and credentials redacted. */} + +## Initialize the database + +Set the migration process to the direct connection, then run the normal Prisma flow: + +```bash +DATABASE_URL="$DIRECT_URL" npx prisma-next contract emit +DATABASE_URL="$DIRECT_URL" npx prisma-next migration plan --name initial +DATABASE_URL="$DIRECT_URL" npx prisma-next migrate +``` + +This installs EQL through Prisma's migration graph. Do not install EQL separately with the `stash` CLI. + +For a populated column, use the direct connection for the out-of-band backfill and confirm that it targets the intended production or preview database. Follow the [Prisma deployment guide](/integrations/prisma/deployment) before changing live data. + +## What appears in Prisma tools + +Prisma Studio and the Prisma Console see EQL ciphertext payloads, not plaintext. That is expected: encryption keys remain in the application, outside Prisma Postgres. Read and update encrypted values through the CipherStash-enabled Prisma runtime. + +{/* Screenshot candidate: Prisma Studio showing one encrypted field as an EQL + ciphertext payload beside a non-sensitive plaintext field. */} + +## Branch databases + +Prisma platform branches can have isolated databases. Treat each branch as a separate CipherStash environment: + +- Confirm the database identity before migrations or backfills. +- Use separate `CS_*` credentials for preview and production. +- Keep every writer and one-off job on the keyset intended for that environment. + +If the application is also hosted by Prisma, [Prisma Compute](/integrations/prisma/prisma-compute) explains its build, environment-variable, and migration behavior. diff --git a/content/docs/integrations/prisma/quickstart.mdx b/content/docs/integrations/prisma/quickstart.mdx new file mode 100644 index 0000000..6f23700 --- /dev/null +++ b/content/docs/integrations/prisma/quickstart.mdx @@ -0,0 +1,182 @@ +--- +title: Quickstart +description: "Declare, migrate, write, and query your first encrypted field with CipherStash and Prisma ORM 8." +type: tutorial +components: [encryption, eql] +audience: [developer] +integration: + category: orm + setup: code-only + pairsWith: [supabase, drizzle] +verifiedAgainst: + prismaNext: "0.16.0" + eql: "3.0.4" + stack: "1.0.0" +--- + +This tutorial adds an encrypted, searchable email field to an existing Prisma ORM 8 release candidate project. + + +Prisma ORM 8 was formerly called Prisma Next. Its prerelease command remains `prisma-next`, while CipherStash initialization uses `--prisma`. + + +It is designed for a new table or empty column. To encrypt a populated column safely, use the [Prisma deployment guide](/integrations/prisma/deployment). + +## Before you start + +You need: + +- A Prisma ORM 8 release candidate project with `prisma-next.config.ts` +- A Postgres database and `DATABASE_URL` +- A CipherStash account; initialization signs you in + + + + +### Initialize CipherStash + +From the Prisma project, run: + +```bash cta cta-type="install" example-id="prisma-quickstart-init" +npx stash init --prisma +``` + +`stash init` opens the CipherStash device login when necessary, creates the local developer profile, and installs compatible pinned versions of `@cipherstash/stack`, `@cipherstash/stack-prisma`, and the `stash` CLI. + +It intentionally does not install EQL directly. The Prisma migration graph owns that step. + + +Agents can run the same setup. If no developer profile exists, the agent should start `npx stash auth login --json --region --prisma`, present the returned verification URL, and wait for you to complete device login. It can then run `npx stash init --prisma --region `. + +Initialization records project context and can install the CipherStash Prisma, authentication, indexing, deployment, and CLI skills for supported coding agents. + + + + + +### Declare an encrypted field + +Add the CipherStash type to your schema: + +```prisma title="prisma/schema.prisma" +model User { + id String @id + email cipherstash.TextSearch() +} +``` + +`TextSearch()` supports encrypted equality, range, ordering, and token matching. If you only need equality, prefer `TextEq()`. See [Schema and queries](/integrations/prisma/schema-and-queries) for every column type. + + + + +### Register the extension pack + +Add CipherStash to your existing Prisma configuration: + +```typescript title="prisma-next.config.ts" +import cipherstash from "@cipherstash/stack-prisma/control" +import { defineConfig } from "@prisma-next/cli/config-types" +import { prismaContract } from "@prisma-next/sql-contract-psl/provider" +import postgresPack from "@prisma-next/target-postgres/pack" +import { postgresCreateNamespace } from "@prisma-next/target-postgres/types" + +export default defineConfig({ + // Keep your existing family, target, driver, adapter, and database settings. + extensionPacks: [cipherstash], + contract: prismaContract("./prisma/schema.prisma", { + output: "src/prisma/contract.json", + target: postgresPack, + createNamespace: postgresCreateNamespace, + }), + migrations: { dir: "migrations" }, +}) +``` + +If you already use extension packs, include `cipherstash` in the existing array. `createNamespace` is required by Prisma's 0.15 and later prereleases. + + + + +### Emit and migrate + +Generate the contract, plan the migration, and apply it: + +```bash +npx prisma-next contract emit +npx prisma-next migration plan --name add-encrypted-user +npx prisma-next migrate +``` + +The final command installs EQL v3 and applies your application migration in one graph. Do not run `stash eql install` for this integration. + + + + +### Connect the encrypted runtime + +Create the database module from the emitted contract: + +```typescript title="src/db.ts" +import "dotenv/config" +import { cipherstashFromStack } from "@cipherstash/stack-prisma/v3" +import postgres from "@prisma-next/postgres/runtime" +import type { Contract } from "./prisma/contract.d" +import contractJson from "./prisma/contract.json" with { type: "json" } + +const cipherstash = await cipherstashFromStack({ contractJson }) + +export const db = postgres({ + contractJson, + extensions: cipherstash.extensions, + middleware: cipherstash.middleware, +}) +``` + +`cipherstashFromStack` uses the local developer profile automatically. For CI or a deployed application, see [Credentials](/integrations/prisma/deployment#credentials) for the CLI and Dashboard options. + + + + +### Write and query encrypted data + +Wrap plaintext values at the write boundary, then use the generated `eql*` operators: + +```typescript +import { + decryptAll, + EncryptedString, +} from "@cipherstash/stack-prisma/runtime" +import { db } from "./db" + +const runtime = await db.connect({ url: process.env.DATABASE_URL! }) + +try { + await db.orm.public.User.create({ + id: "user-0", + email: EncryptedString.from("alice@example.com"), + }) + + const rows = await db.orm.public.User + .where((user) => user.email.eqlEq("alice@example.com")) + .all() + + await decryptAll(rows) + console.log(await rows[0]?.email.decrypt()) + // alice@example.com +} finally { + await runtime.close() +} +``` + +The write value and query operand are encrypted before reaching Postgres. `decryptAll` batches result decryption; `.decrypt()` returns the plaintext value. + + + + +## Next steps + +- Choose other encrypted field types and operators in [Schema and queries](/integrations/prisma/schema-and-queries). +- Add functional indexes using [EQL indexes](/reference/eql/indexes); keep their raw SQL operations in the Prisma migration graph. +- Follow the [Prisma deployment guide](/integrations/prisma/deployment) before changing a populated production column. +- Continue with [Prisma Postgres](/integrations/prisma/prisma-postgres) or [Prisma Compute](/integrations/prisma/prisma-compute) if you use Prisma's hosted platform. diff --git a/content/docs/integrations/prisma/schema-and-queries.mdx b/content/docs/integrations/prisma/schema-and-queries.mdx new file mode 100644 index 0000000..de26d2f --- /dev/null +++ b/content/docs/integrations/prisma/schema-and-queries.mdx @@ -0,0 +1,130 @@ +--- +title: Schema and queries +description: "CipherStash column constructors, runtime envelopes, and encrypted query operators for Prisma ORM 8." +type: reference +components: [encryption, eql] +audience: [developer] +integration: + category: orm + setup: code-only +verifiedAgainst: + prismaNext: "0.16.0" + eql: "3.0.4" + stack: "1.0.0" +--- + +The CipherStash Prisma extension separates the encrypted **column type** from the runtime **value envelope**: + +- In `schema.prisma`, a domain-named constructor such as `TextEq()` fixes the Postgres plaintext family and searchable capabilities. +- In application code, a primitive-named envelope such as `EncryptedString` wraps a plaintext value for encryption. + +## Column constructors + +Choose the plaintext type first, then the narrowest query capability your application needs. + +| Plaintext | Storage only | Equality | Order and range | Free-text only | All text queries | +| --- | --- | --- | --- | --- | --- | +| `string` | `Text()` | `TextEq()` | `TextOrd()` | `TextMatch()` | `TextSearch()` | +| `number` (`int4`) | `Integer()` | `IntegerEq()` | `IntegerOrd()` | — | — | +| `number` (`int2`) | `Smallint()` | `SmallintEq()` | `SmallintOrd()` | — | — | +| `bigint` (`int8`) | `BigInt()` | `BigIntEq()` | `BigIntOrd()` | — | — | +| `number` (`numeric`) | `Numeric()` | `NumericEq()` | `NumericOrd()` | — | — | +| `number` (`float4`) | `Real()` | `RealEq()` | `RealOrd()` | — | — | +| `number` (`float8`) | `Double()` | `DoubleEq()` | `DoubleOrd()` | — | — | +| `Date` (`date`) | `Date()` | `DateEq()` | `DateOrd()` | — | — | +| `Date` (`timestamp`) | `Timestamp()` | `TimestampEq()` | `TimestampOrd()` | — | — | +| `boolean` | `Boolean()` | — | — | — | — | +| JSON document | `Json()` supports containment and JSONPath queries | — | — | — | — | + +Every `*Ord()` constructor includes equality. `TextMatch()` provides token matching without equality; `TextSearch()` combines equality, range, ordering, and token matching. Bare constructors such as `Text()` are storage-only. + +Each constructor maps directly to an EQL v3 Postgres domain. For example, `cipherstash.DoubleOrd()` creates an `eql_v3_double_ord` column. The type cannot be broadened at query time. + +## Runtime envelopes + +| Schema family | Write envelope | +| --- | --- | +| `Text*` | `EncryptedString.from(value)` | +| `Integer*`, `Smallint*`, `Numeric*`, `Real*`, `Double*` | `EncryptedNumber.from(value)` | +| `BigInt*` | `EncryptedBigInt.from(value)` | +| `Date*`, `Timestamp*` | `EncryptedDate.from(value)` | +| `Boolean` | `EncryptedBoolean.from(value)` | +| `Json` | `EncryptedJson.from(value)` | + +Import envelopes and `decryptAll` from `@cipherstash/stack-prisma/runtime`. + +```typescript +import { + decryptAll, + EncryptedDate, + EncryptedNumber, + EncryptedString, +} from "@cipherstash/stack-prisma/runtime" + +await db.orm.public.User.create({ + id: "user-0", + email: EncryptedString.from("alice@example.com"), + salary: EncryptedNumber.from(100_000), + birthday: EncryptedDate.from(new Date("1990-01-01")), +}) + +const rows = await db.orm.public.User.all() +await decryptAll(rows) +``` + +`decryptAll` groups a result set by table and column to avoid one encryption-service round trip per value. Call `.decrypt()` on an envelope after the batch completes. + +## Query operators + +Operators are generated only where the declared domain supports them. + +| Operator | Meaning | +| --- | --- | +| `eqlEq(value)`, `eqlNeq(value)` | Equality and inequality | +| `eqlIn(values)`, `eqlNotIn(values)` | Set membership | +| `eqlMatch(term)` | Free-text token match | +| `eqlGt/Gte/Lt/Lte(value)` | Range comparison | +| `eqlBetween(low, high)`, `eqlNotBetween(low, high)` | Bounded range | +| `eqlAsc(column)`, `eqlDesc(column)` | Encrypted ordering | +| `eqlJsonContains(value)` | JSON containment | +| `eqlJsonPathEq/Neq(path, value)` | Equality at a JSONPath | +| `eqlJsonPathGt/Gte/Lt/Lte(path, value)` | Range comparison at a JSONPath | +| `eqlJsonPathAsc/Desc(column, path)` | Ordering by a JSONPath leaf | + +```typescript +import { + eqlAsc, + eqlJsonPathDesc, +} from "@cipherstash/stack-prisma/runtime" + +const matches = await db.orm.public.User + .where((user) => user.email.eqlMatch("example.com")) + .all() + +const earners = await db.orm.public.User + .where((user) => user.salary.eqlBetween(100_000, 150_000)) + .orderBy((user) => eqlAsc(user.salary)) + .all() + +const preferences = await db.orm.public.User + .where((user) => user.profile.eqlJsonContains({ theme: "dark" })) + .orderBy((user) => eqlJsonPathDesc(user.profile, "$.score")) + .all() +``` + +Free-text search is token matching, not SQL `ILIKE`. Pass a term such as `example.com`, not a wildcard pattern such as `%example.com%`. + +## Indexes + +Encrypted predicates require functional Postgres indexes at meaningful scale. `schema.prisma` cannot express expression indexes, so put the index DDL in a Prisma raw-SQL migration operation and apply it with `prisma-next migrate`. + +The domain-to-index matrix, SQL recipes, rollout timing, `ANALYZE`, and `EXPLAIN` verification are maintained in [EQL indexes](/reference/eql/indexes). + +## Subpath exports + +| Import | Purpose | +| --- | --- | +| `@cipherstash/stack-prisma/control` | Prisma extension pack | +| `@cipherstash/stack-prisma/v3` | `cipherstashFromStack` and the EQL v3 adapter | +| `@cipherstash/stack-prisma/runtime` | Envelopes, `decryptAll`, and query helpers | +| `@cipherstash/stack-prisma/column-types` | TypeScript-authored contract factories | diff --git a/content/docs/integrations/supabase/api-reference/meta.json b/content/docs/integrations/supabase/api-reference/meta.json new file mode 100644 index 0000000..dd99951 --- /dev/null +++ b/content/docs/integrations/supabase/api-reference/meta.json @@ -0,0 +1,4 @@ +{ + "title": "API reference", + "pages": ["index"] +} diff --git a/content/docs/integrations/supabase/auth.mdx b/content/docs/integrations/supabase/auth.mdx new file mode 100644 index 0000000..7aa9f86 --- /dev/null +++ b/content/docs/integrations/supabase/auth.mdx @@ -0,0 +1,142 @@ +--- +title: Supabase Auth +description: "Federate the Supabase Auth session into CipherStash so encryption authenticates as the signed-in user — then, optionally, lock decryption to that user's identity." +type: guide +components: [encryption, platform] +audience: [developer] +verifiedAgainst: + stack: "1.0.0" + auth: "0.42.0" +--- + +Supabase Auth already knows who your user is. This page connects that identity to CipherStash in two layers you can adopt independently: + +1. **Federation** — exchange the user's Supabase session for a CipherStash token, so every encryption and decryption request authenticates *as that user* instead of as a shared service credential. This is the foundation. +2. **Identity-bound encryption (lock context)** — an optional layer on top of federation that binds a value to the user's identity claim, so only that user can decrypt it — enforced by ZeroKMS, not by your application code. + +Federation is useful on its own. Lock context requires it. Start with federation; add lock context where per-user secrecy matters. + +## Register Supabase as an OIDC provider + +CipherStash needs to trust your Supabase project as an OIDC issuer before it will accept a Supabase session token. Add the provider once, in the CipherStash dashboard at [dashboard.cipherstash.com/workspaces/_/oidc-providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) (the `_` resolves to whichever workspace you select). A Supabase project's issuer is: + +``` +https://.supabase.co/auth/v1 +``` + +> **Good to know**: the [CipherStash dashboard](https://dashboard.cipherstash.com) can register this for you in one click while it is connected to your Supabase project over OAuth. Manual OIDC configuration is covered in the [auth reference](/reference/auth). + +## Federate the Supabase session + +Authenticate the `Encryption` client with an `OidcFederationStrategy`. It takes your workspace CRN and a `getJwt` callback that returns the user's **current** Supabase access token — the strategy calls it on first use and again on every re-federation, so return a fresh token each time rather than capturing one: + +```typescript title="lib/db.ts" +import { createClient } from "@supabase/supabase-js" +import { OidcFederationStrategy } from "@cipherstash/stack" +import { encryptedSupabase } from "@cipherstash/stack-supabase" + +const supabaseClient = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) + +// Return the current Supabase session token — re-invoked on every re-federation. +const getJwt = async () => { + const { data: { session } } = await supabaseClient.auth.getSession() + if (!session) throw new Error("No active Supabase session") + return session.access_token +} + +const federation = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + getJwt, +) +if (federation.failure) throw new Error(federation.failure.error.message) + +export const db = await encryptedSupabase(supabaseClient, { + config: { + authStrategy: federation.data, + }, +}) +``` + +That's the whole integration. Every `db.from(...)` query now encrypts and decrypts under the signed-in user's identity, and your Supabase Auth session and RLS policies apply to the underlying request exactly as before. + + +Run the federation strategy **server-side only**: a Next.js Route Handler or Server Action, or an Edge Function. Never in the browser, where it would expose the session token. For the Edge runtime, see [Edge Functions](/integrations/supabase/edge-functions). + +The federation endpoint issues no refresh token. When the CipherStash token expires, the strategy re-federates by calling `getJwt` again, and because `getJwt` reads from `supabaseClient.auth.getSession()`, supabase-js's own token refresh keeps it valid. + + +## Identity-bound encryption (lock context) + +Federation authenticates *as* the user. **Lock context** goes further: it bakes a claim from the user's JWT (by default `sub`, which for Supabase Auth is the user's UUID) into the data key, so a value can only be decrypted by presenting the same identity. Even your own backend — holding valid workspace credentials — cannot decrypt another user's locked values. + + +Lock context requires the client to be authenticated with `OidcFederationStrategy` (above). It cannot be used with `AccessKeyStrategy`, which authenticates a *service*, not a user — there is no user `sub` claim to bind to. Lock context also requires a Business or Enterprise workspace plan. + + +Attach a lock context to any [`encryptedSupabase`](/integrations/supabase/api-reference) query with `.withLockContext()`. `sub` is the Supabase user's UUID: + +```typescript +import { db } from "./lib/db" + +const lockContext = { identityClaim: ["sub"] } + +// Insert — the value is bound to the signed-in user's `sub` claim +await db.from("patients") + .insert({ email: "alice@example.com", name: "Alice Chen" }) + .withLockContext(lockContext) + +// Read — the same user must be authenticated +const { data } = await db.from("patients") + .select("id, email, name") + .eq("email", "alice@example.com") + .withLockContext(lockContext) +``` + + +The query builder accepts either the plain `{ identityClaim: ["sub"] }` shape shown above or a `LockContext` instance. The plain shape is sufficient for new code. + + +ZeroKMS resolves the claim's *value* from the token that authenticated the request — the federated Supabase identity — so no separate identify step or per-operation token is needed. Reading a locked value without a matching context, or as a different user, fails with an encryption error regardless of what RLS allows. + + +Earlier releases used `new LockContext().identify(jwt)` to fetch a per-operation token. `identify()` and `getLockContext()` are **deprecated** — per-operation CTS tokens were removed in protect-ffi 0.25. The `LockContext` constructor itself is not deprecated. Authenticate the client with `OidcFederationStrategy` and pass the context straight to `.withLockContext()`, as above. + + +## Where it fits + +- **Per-user data** (a user's own medical history, messages, documents): lock to `sub`. The row is useless to anyone but its owner — including operators with database access and your own service role. +- **Shared / tenant data** (a support queue, org-wide records): don't lock it, or you'll be unable to decrypt it outside a user session. Federation alone still authenticates access as the user; RLS scopes which rows they see. +- **Server-side jobs** that must read locked data need the owning user's session — by design. If a background job must read a field, that field shouldn't be identity-locked. + +## Errors + +Both federation and lock context surface failures as values, not throws. + +| Scenario | What you see | +| --- | --- | +| Provider not registered with the workspace | Federation fails — register the Supabase OIDC issuer first | +| Expired or invalid Supabase session | `getJwt` throws / returns no session; federation cannot proceed | +| CipherStash token expired mid-request | Strategy re-federates automatically via `getJwt` | +| Decrypting another user's locked value | `encryptionError` on the [query response](/integrations/supabase/supabase-js#responses-and-errors) | + +The strategy's own factory and token errors follow the `@cipherstash/auth` `Result` shape (`failure.type`); see the [auth reference](/reference/auth). + +## Where to next + + + + The concept: federation, lock contexts, and what identity binding proves. + + + Run the federation strategy server-side in Supabase Edge Functions. + + + `.withLockContext()` and `.audit()` on the query builder. + + + OIDC providers, federation strategies, and access keys. + + diff --git a/content/docs/integrations/supabase/edge-functions.mdx b/content/docs/integrations/supabase/edge-functions.mdx new file mode 100644 index 0000000..079f141 --- /dev/null +++ b/content/docs/integrations/supabase/edge-functions.mdx @@ -0,0 +1,132 @@ +--- +title: Edge Functions +description: "Run CipherStash encryption inside Supabase Edge Functions using the @cipherstash/stack WebAssembly build, with the CipherStash token cached across invocations in an HTTP-only cookie." +type: guide +components: [encryption, platform] +audience: [developer] +verifiedAgainst: + stack: "1.0.0" + auth: "0.42.0" +--- + +Encryption and decryption must run somewhere your keys and credentials are safe — never in the browser. Supabase Edge Functions are that place: server-side compute next to your database. CipherStash ships a **WebAssembly build** of the SDK for exactly this runtime, so the same encrypt/decrypt code runs in Deno with no native bindings. + +## Why the wasm build + +Supabase Edge Functions run on Deno, which loads npm packages through the `node` compatibility layer. The SDK's default entry resolves to native NAPI bindings that don't exist in that runtime, so the edge uses a dedicated entry: `@cipherstash/stack/wasm-inline`. It embeds the WebAssembly module as base64 inside the JS bundle — no separate `.wasm` fetch, no bundler plugins, no `static_files` config. The same applies to the auth package (`@cipherstash/auth/wasm-inline`). + +## Set up the function + +Map the `wasm-inline` entries in your function's `deno.json`: + +```jsonc title="supabase/functions/encrypt/deno.json" +{ + "imports": { + "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@^1.0.0/wasm-inline", + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.42/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.42/cookies" + } +} +``` + + +**The developer profile is not supported in Edge Functions.** `stash auth login` creates a profile for the native Node.js SDK on your development machine. An Edge Function runs the WebAssembly client inside an isolated Deno runtime, where that profile and automatic credential discovery are unavailable. + +Provide the four `CS_*` values explicitly through an env file when serving locally and through Supabase secrets when deployed. + + + + +For an Edge Function, use the CLI or Dashboard option; the developer profile cannot cross into the Deno runtime. To create a dedicated local credential set and write it directly to the function's env file: + +```bash example-id="supabase-edge-stash-env" +npx stash env --name edge-dev --write ./supabase/functions/.env.local +``` + +Never commit this file. Each successful `stash env` run creates a new credential with a unique name, and the access key cannot be shown again. + +Then serve the function against that file: + +```bash +supabase functions serve --env-file ./supabase/functions/.env.local +``` + +For deployed functions, add the same four values as [Edge Function secrets](https://supabase.com/dashboard/project/_/settings/functions). Use a separate credential set for production rather than copying the local values. + +## Encrypt and decrypt in a function + +Each invocation builds a fresh client, but the CipherStash service token is cached in an HTTP-only cookie via `cookieStore`, so only the first request per session pays the full round-trip to CipherStash: + +```typescript title="supabase/functions/encrypt/index.ts" +import { Encryption, AccessKeyStrategy, encryptedTable, encryptedColumn } from "@cipherstash/stack/wasm-inline" +import { cookieStore } from "@cipherstash/auth/cookies" + +const patients = encryptedTable("patients", { + email: encryptedColumn("email").equality(), +}) + +Deno.serve(async (req) => { + const responseHeaders = new Headers({ "content-type": "application/json" }) + + // Cache the CipherStash token across invocations in an HTTP-only cookie + const authStrategy = AccessKeyStrategy.create( + Deno.env.get("CS_WORKSPACE_CRN")!, + Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + { store: cookieStore({ request: req, responseHeaders }) }, + ) + if (authStrategy.failure) { + return Response.json({ error: authStrategy.failure.type }, { status: 500, headers: responseHeaders }) + } + + const client = await Encryption({ + schemas: [patients], + config: { + authStrategy: authStrategy.data, + clientId: Deno.env.get("CS_CLIENT_ID")!, + clientKey: Deno.env.get("CS_CLIENT_KEY")!, + }, + }) + + const encrypted = await client.encrypt("alice@example.com", { column: patients.email, table: patients }) + const decrypted = await client.decrypt(encrypted.data) + + return Response.json({ ok: decrypted.data === "alice@example.com" }, { headers: responseHeaders }) +}) +``` + +Note the `.failure` / `.data` result shape: on the wasm entry, `AccessKeyStrategy.create` returns a `Result` you unwrap (unlike the Node build, where it returns the strategy directly). + +## Per-user identity on the edge + +To encrypt as the signed-in Supabase user rather than a service credential, swap `AccessKeyStrategy` for `OidcFederationStrategy` — its `getJwt` callback returns the current Supabase session token. The mechanics and the identity-lock layer are covered in [Supabase Auth](/integrations/supabase/auth); the only edge-specific difference is the `wasm-inline` import and the `Result` unwrap: + +```typescript +import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline" +import { cookieStore } from "@cipherstash/auth/cookies" + +const authStrategy = OidcFederationStrategy.create( + Deno.env.get("CS_WORKSPACE_CRN")!, + () => getSupabaseSessionToken(req), // return the current session JWT + { store: cookieStore({ request: req, responseHeaders }) }, +) +``` + +## Caveats + +- **Cold start.** The first request pays the full path: WebAssembly compile, auth round-trip to CipherStash, and ZeroKMS dataset-key initialization. Subsequent requests reuse the cookie-cached token and only pay for the encrypt/decrypt work. +- **Bundle size.** The inline wasm build is larger than a native binding (the module ships as base64 in the JS). That's the trade for zero runtime config — acceptable for an edge function that boots once per worker. +- **Server-side only.** These credentials and the session token must never reach the browser. Keep this code in the Edge Function. + +## Where to next + + + + Federation and identity-locked encryption — the full identity story. + + + The standard (Node) setup with the encryptedSupabase wrapper. + + + The core Encryption client API used here. + + diff --git a/content/docs/integrations/supabase/index.mdx b/content/docs/integrations/supabase/index.mdx new file mode 100644 index 0000000..89289a5 --- /dev/null +++ b/content/docs/integrations/supabase/index.mdx @@ -0,0 +1,57 @@ +--- +title: Overview +description: "Add searchable application-level encryption to Supabase while keeping the Supabase client, Auth, Row Level Security, and your choice of Postgres ORM." +type: concept +components: [encryption, eql, platform] +audience: [developer, ciso] +integration: + category: platform + setup: code-only + pairsWith: [drizzle, prisma, clerk] +verifiedAgainst: + eql: "3.0.4" + stack: "1.0.0" +--- + +CipherStash adds searchable, field-level encryption to Supabase. Your application encrypts sensitive values before they reach Postgres, so the database, backups, replication streams, and Supabase dashboard contain ciphertext rather than plaintext. + +Unlike ordinary application-level encryption, protected columns remain queryable. Equality filters, range queries, and ordering use the same Supabase query-builder methods as plaintext columns, while CipherStash encrypts query values and decrypts results around each request. + +## How it works + +CipherStash has two parts: + +1. [Encrypt Query Language (EQL)](/reference/eql) adds encrypted column types, operators, and functions to your Supabase Postgres database. +2. [`encryptedSupabase`](/integrations/supabase/api-reference) wraps the Supabase JavaScript client and performs encryption and decryption in your application. + +The EQL type assigned to a column declares what can be queried. For example, an equality column supports `.eq()` and `.in()`, while an ordered column also supports ranges and `.order()`. Choose only the capabilities you need, because searchable encryption has defined leakage characteristics. See [EQL core concepts](/reference/eql/core-concepts) for the complete model. + +With EQL 3.0.4, the Supabase wrapper supports encrypted equality, range, and ordering queries. Encrypted free-text and JSON queries require an adapter such as Drizzle or Prisma ORM 8 because PostgREST cannot express the typed operands those operations require. The [supabase-js guide](/integrations/supabase/supabase-js#free-text-and-encrypted-json-queries) documents the limitation. + +## Choose your path + +| What you want to do | Start here | +| --- | --- | +| Encrypt and query your first Supabase column | [Supabase Quickstart](/integrations/supabase/quickstart) | +| Use the Supabase JavaScript client | [supabase-js guide](/integrations/supabase/supabase-js) | +| Look up the complete wrapper API | [`encryptedSupabase` API reference](/integrations/supabase/api-reference) | +| Bind decryption to the signed-in user | [Supabase Auth](/integrations/supabase/auth) | +| Encrypt inside Supabase Edge Functions | [Edge Functions](/integrations/supabase/edge-functions) | +| Choose local or deployment credentials | [Workspace credentials](/reference/workspace/configuration#credentials) | +| Use an ORM against Supabase Postgres | [Drizzle](/integrations/drizzle) or [Prisma ORM 8](/integrations/prisma) | +| Encrypt columns that already contain data | [Data migration](/guides/migration) | +| Plan a production rollout or rollback | [Deployment guide](/guides/deployment) | + +## Supabase, Drizzle, and Prisma + +Supabase is Postgres, so you are not limited to PostgREST or `supabase-js`. CipherStash's [Drizzle](/integrations/drizzle) and [Prisma ORM 8](/integrations/prisma) integrations can read and write the same EQL columns through a direct database connection. They produce the same ciphertext, so an application can use more than one adapter without re-encrypting its data. + +Use `encryptedSupabase` when you want Supabase's query builder and automatic propagation of the caller's JWT. Use an ORM when it better fits your application or when you need encrypted free-text or JSON queries. A privileged direct database connection may bypass Row Level Security, so preserve tenant or user scoping in the connection role or ORM query. + +## Row Level Security and indexes + +Encryption complements Supabase Row Level Security rather than replacing it. RLS controls which rows a caller may query; encryption controls whether the values in those rows can be read. Keep RLS predicates on plaintext ownership columns such as `user_id`, and encrypt the sensitive contents of the row. [Supabase Auth](/integrations/supabase/auth) can additionally bind decryption to the authenticated user's identity. + +Encrypted columns use ordinary functional Postgres indexes over EQL term-extractor functions. The recipes, query shapes, `EXPLAIN` checks, and large-table guidance are maintained in [EQL indexes](/reference/eql/indexes). + +In the Supabase Table Editor, encrypted columns appear as ciphertext payloads. Read and edit them through an application using CipherStash; the dashboard does not hold the keys required to decrypt them. diff --git a/content/docs/integrations/supabase/meta.json b/content/docs/integrations/supabase/meta.json new file mode 100644 index 0000000..a94eedf --- /dev/null +++ b/content/docs/integrations/supabase/meta.json @@ -0,0 +1,12 @@ +{ + "title": "Supabase", + "icon": "Supabase", + "pages": [ + "index", + "quickstart", + "supabase-js", + "auth", + "edge-functions", + "api-reference" + ] +} diff --git a/content/docs/integrations/supabase/quickstart.mdx b/content/docs/integrations/supabase/quickstart.mdx new file mode 100644 index 0000000..c85343d --- /dev/null +++ b/content/docs/integrations/supabase/quickstart.mdx @@ -0,0 +1,165 @@ +--- +title: Quickstart +description: "Encrypt, store, and query your first field in Supabase with EQL and the encrypted Supabase JavaScript client." +type: tutorial +components: [encryption, eql, platform] +audience: [developer] +integration: + category: platform + setup: code-only + pairsWith: [drizzle, prisma, clerk] +verifiedAgainst: + eql: "3.0.4" + stack: "1.0.0" +--- + +This tutorial creates a Supabase table with one encrypted column, writes a value through the Supabase JavaScript client, and queries it without sending plaintext to Postgres. + +It is designed for a new table or an empty column. If the column already contains production data, use [Data migration](/guides/migration) instead. + +## Before you start + +You need: + +- A Supabase project and its database connection string +- A Node.js project +- A CipherStash account; the setup flow signs you in + + + + +### Initialize CipherStash + +Add your Supabase settings to the project's environment: + +```sh title=".env" +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY=... +DATABASE_URL=postgresql://postgres:...@db..supabase.co:5432/postgres +``` + +Then run the Supabase setup flow: + +```bash cta cta-type="install" example-id="supabase-quickstart-eql-install" +npx stash init --supabase +``` + +`stash init`: + +- Opens the CipherStash device login if you do not already have a developer profile +- Resolves the database configuration +- Installs compatible, pinned versions of `@cipherstash/stack`, `@cipherstash/stack-supabase`, and the `stash` CLI +- Installs EQL v3 with the grants required by Supabase's `anon`, `authenticated`, and `service_role` roles +- Scaffolds the encryption client and records context for coding agents + +If the command generates a Supabase migration instead of applying EQL directly, run the apply command it prints before continuing. Confirm that EQL is available with: + +```bash +npx stash eql status +``` + +The native Stack client automatically uses the developer profile created by this login. You do not need the four deployment environment variables for local development. When you deploy, [choose a deployment credential source](/guides/deployment#credentials-at-build-and-runtime). + + +**Using an agent?** An agent can drive the same setup. If no developer profile exists, it should start `npx stash auth login --json --region --supabase`, show you the returned verification URL, and leave the command running until authorization completes. It can then run `npx stash init --supabase --region `. + +At the end of interactive initialization, you can continue into `stash plan`. Choosing Codex or Claude Code installs the Supabase-specific CipherStash skills into the agent's project directory; editor agents receive the same guidance through `AGENTS.md`. The skills cover Supabase, authentication, EQL indexes, deployment, and the CLI, giving the agent the context needed to plan and implement encryption changes safely. + + + + + +### Create an encrypted column + +Run this in the Supabase SQL Editor or add it to a new migration: + +```sql +CREATE TABLE contacts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq NOT NULL, + display_name text +); +``` + +`email` is an EQL equality column, so it can be stored, decrypted, and queried with methods such as `.eq()` and `.in()`. `display_name` is ordinary plaintext and passes through unchanged. + +The column type is the encryption schema. There is no separate application declaration to keep synchronized. For other data types and query capabilities, see [EQL core concepts](/reference/eql/core-concepts). + + + + +### Create the encrypted Supabase client + +`stash init` installed the CipherStash packages. If this project does not already use the Supabase JavaScript client, install it: + +```bash +npm install @supabase/supabase-js +``` + +Create a client: + +```typescript title="lib/db.ts" +import { encryptedSupabase } from "@cipherstash/stack-supabase" + +export const db = await encryptedSupabase( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) +``` + +At startup, `encryptedSupabase` uses `DATABASE_URL` to inspect the database and recognize `contacts.email` as encrypted. Plaintext tables and columns continue to behave normally. + + + + +### Insert and query a value + +Write plaintext application values as usual: + +```typescript +import { db } from "./lib/db" + +const { error: insertError } = await db.from("contacts").insert({ + email: "alice@example.com", + display_name: "Alice", +}) + +if (insertError) throw insertError +``` + +The wrapper encrypts `email` before it leaves the application. It then encrypts the filter value when you query the column and decrypts the matching result: + +```typescript +const { data, error } = await db + .from("contacts") + .select("id, email, display_name") + .eq("email", "alice@example.com") + .single() + +if (error) throw error + +console.log(data) +// { id: 1, email: "alice@example.com", display_name: "Alice" } +``` + +Supabase Row Level Security still applies because the wrapper sends the request through the Supabase client. Add policies just as you would for an unencrypted table. + + + + +### Confirm that Supabase sees ciphertext + +Open `contacts` in the Supabase Table Editor. The `email` cell contains an EQL ciphertext payload, while `display_name` remains readable. + +The Table Editor cannot decrypt or meaningfully filter the encrypted value because its keys remain outside the database. Read and update encrypted fields through a CipherStash-enabled application. + + + + +## Next steps + +- Learn the supported mutations, filters, ordering, response types, and errors in the [supabase-js guide](/integrations/supabase/supabase-js). +- Use the complete [`encryptedSupabase` API reference](/integrations/supabase/api-reference) when you need method-level detail. +- Add functional indexes when the table reaches meaningful row counts by following [EQL indexes](/reference/eql/indexes). +- For a populated table, follow the safe dual-write and backfill process in [Data migration](/guides/migration). +- To use a direct Postgres ORM connection, continue with [Drizzle](/integrations/drizzle) or [Prisma ORM 8](/integrations/prisma). diff --git a/content/docs/integrations/supabase/supabase-js.mdx b/content/docs/integrations/supabase/supabase-js.mdx new file mode 100644 index 0000000..b4408c2 --- /dev/null +++ b/content/docs/integrations/supabase/supabase-js.mdx @@ -0,0 +1,235 @@ +--- +title: supabase-js +description: "Use the encryptedSupabase wrapper for transparent encryption, decryption, filtering, and ordering through the Supabase JavaScript client." +type: guide +components: [encryption, eql, platform] +audience: [developer] +integration: + category: orm + setup: code-only + pairsWith: [supabase] +verifiedAgainst: + stack: "1.0.0" + eql: "3.0.4" +--- + +`encryptedSupabase` wraps a [`@supabase/supabase-js`](https://supabase.com/docs/reference/javascript/introduction) client. It encrypts values before mutations leave your application, encrypts query operands for searchable columns, and decrypts selected rows before returning them. Plaintext columns pass through unchanged. + +The result is the familiar supabase-js query shape: + +```typescript +const { data, error } = await db + .from("patients") + .select("id, name, date_of_birth") + .eq("name", "Alice Chen") +``` + +The `name` operand and returned `name` value are plaintext in your code. Supabase and Postgres only receive their encrypted representations. + + +This guide assumes EQL is installed and your encrypted columns use `public.eql_v3_*` domain types. Complete the [Quickstart](/integrations/supabase/quickstart) first if your database is not ready. + + +## Install + +Install the Stack SDK, its Supabase adapter, supabase-js, and the Postgres driver used for schema introspection: + +```bash cta cta-type="install" example-id="install-supabase-js" +npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js pg +``` + +Your server needs these environment variables: + +```bash +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY=... +DATABASE_URL=postgresql://... +``` + +## Credentials + + + +The [Quickstart setup](/integrations/supabase/quickstart#initialize-cipherstash) creates and uses the developer profile. In CI or a deployed application, add the four `CS_*` variables to the same server environment as the Supabase settings above. + +## Create the client + +Pass your Supabase URL and key to `encryptedSupabase`. The async factory uses `DATABASE_URL` once at startup to inspect the `public` schema and identify its EQL columns. Application queries still go through the Supabase URL and PostgREST. + +```typescript title="lib/db.ts" +import { encryptedSupabase } from "@cipherstash/stack-supabase" + +export const db = await encryptedSupabase( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) +``` + +Pass `databaseUrl` when your direct connection uses another environment variable: + +```typescript +export const db = await encryptedSupabase(supabaseUrl, supabaseKey, { + databaseUrl: process.env.SUPABASE_DATABASE_URL, +}) +``` + +If your application already creates a Supabase client, wrap that instance instead. Its Auth session, custom headers, and Row Level Security context are preserved: + +```typescript title="lib/db.ts" +import { createClient } from "@supabase/supabase-js" +import { encryptedSupabase } from "@cipherstash/stack-supabase" + +const supabase = createClient(supabaseUrl, supabaseAnonKey) + +export const db = await encryptedSupabase(supabase, { + databaseUrl: process.env.DATABASE_URL, +}) +``` + + +Create this client on the server. It needs a direct database connection and CipherStash credentials, so it cannot run in a browser or Worker. For Deno, use the [Edge Functions guide](/integrations/supabase/edge-functions). + + +`encryptedSupabaseV3` remains as a deprecated, type-identical alias for existing applications. Use `encryptedSupabase` in new code. + +## Add optional TypeScript schemas + +Database introspection is enough at runtime. An optional declared schema adds compile-time row and filter types, and verifies at startup that the declaration matches the live database. + +```typescript title="lib/db.ts" +import { encryptedTable, types } from "@cipherstash/stack/eql/v3" +import { encryptedSupabase } from "@cipherstash/stack-supabase" + +const patients = encryptedTable("patients", { + name: types.TextEq("name"), + dateOfBirth: types.DateOrd("date_of_birth"), +}) + +export const typedDb = await encryptedSupabase(supabaseUrl, supabaseAnonKey, { + schemas: { patients }, +}) +``` + +The record key must match the database table name. A property may map to a different database column, as `dateOfBirth` does above; the wrapper applies that mapping to selects, mutations, filters, and returned rows. + +## Insert, update, and upsert + +Write plaintext values. The wrapper encrypts only the columns backed by EQL domains and sends every other column through unchanged. + +```typescript +const { data, error } = await db + .from("patients") + .insert({ + email: "alice@example.com", // plaintext column + name: "Alice Chen", // encrypted column + date_of_birth: "1988-06-15", // encrypted column + plan: "standard", // plaintext column + }) + .select("id, email, name, date_of_birth, plan") + .single() + +await db + .from("patients") + .update({ name: "Alice Jones" }) + .eq("id", data!.id) + +await db.from("patients").upsert( + { email: "alice@example.com", name: "Alice Jones" }, + { onConflict: "email" }, +) +``` + +`insert` and `upsert` accept one row or an array. The wrapper batches a mutation's encryption work into one ZeroKMS round trip. + +## Select and filter + +Encrypted values are decrypted before they appear in `data`. Bare `.select()` and `.select("*")` work because the wrapper expands the column list discovered during introspection. + +```typescript +const { data } = await db + .from("patients") + .select("*") + .eq("name", "Alice Chen") +``` + +The EQL domain on each encrypted column determines which filters it accepts: + +| supabase-js method | Required encrypted domain | Behaviour | +| --- | --- | --- | +| `.eq()`, `.neq()`, `.in()` | `_eq`, `_ord`, or `text_search` | Compares encrypted equality terms | +| `.gt()`, `.gte()`, `.lt()`, `.lte()` | `_ord` or `text_search` | Compares encrypted ordering terms | +| `.is(column, null)` | Any column | Passes SQL `NULL` through without encryption | +| `.match()`, `.or()`, `.not()`, `.filter()` | Depends on the operator | Encrypts operands for encrypted columns | +| `.contains()` | Plaintext JSON or array | Uses native PostgREST containment | + +Encrypted and plaintext predicates compose in the same query: + +```typescript +const { data } = await db + .from("patients") + .select("id, name, date_of_birth, plan") + .gte("date_of_birth", "1980-01-01") // encrypted range + .lt("date_of_birth", "1990-01-01") // encrypted range + .eq("plan", "standard") // plaintext equality +``` + +Use `.is(column, null)` for null checks. Passing `null` to a comparison such as `.eq()` cannot create an encrypted query term and is forwarded to PostgREST; it is almost never the intended SQL operation. + +## Order and paginate + +`.order()` works on plaintext columns and OPE-backed encrypted ordering domains such as `date_ord`, `integer_ord`, and `text_search`. The wrapper orders an encrypted column by the `op` term inside its payload, reproducing plaintext order without decrypting in Postgres. + +```typescript +const { data } = await db + .from("patients") + .select("id, name, date_of_birth") + .order("date_of_birth", { ascending: false }) + .range(0, 19) +``` + +Storage-only, equality-only, and match-only encrypted columns cannot be ordered. ORE-backed `_ord_ore` domains also cannot be ordered through PostgREST; Supabase installations disable those domains because their operator class requires superuser privileges. + +`.limit()`, `.range()`, `.single()`, `.maybeSingle()`, `.abortSignal()`, `.throwOnError()`, and `.returns()` otherwise mirror supabase-js. `.csv()` throws because PostgREST serializes ciphertext before the wrapper has a chance to decrypt it; select rows normally and serialize the plaintext result in your application. + +## Free-text and encrypted JSON queries + + +**The current EQL 3.0.4 release cannot run encrypted free-text or encrypted JSON queries through supabase-js.** `.matches()`, encrypted `.contains()`, `.selectorEq()`, and `.selectorNe()` fail before a request is sent. Their SQL operators require an `eql_v3.query_*` cast that PostgREST cannot express. This PostgREST boundary was introduced in EQL 3.0.2 and remains in 3.0.4, which is why the runtime error describes it as `3.0.2+`. + +Inserts, selects, decryption, equality, ranges, and ordering are unaffected. Use [Drizzle](/integrations/drizzle), [Prisma ORM 8](/integrations/prisma), or a carefully scoped SQL/RPC function for free-text and encrypted JSON queries. + + +Scalar encrypted filters currently use a full encrypted storage envelope as the PostgREST operand because PostgREST cannot cast a term-only query envelope. PostgREST sends filters in GET query strings, so configure application and proxy logs not to retain query strings. The operand is encrypted, but it can contain ciphertext and every index term for the queried value. + +## Responses and errors + +Awaiting the builder returns the normal supabase-js response fields. Encryption failures use the same response path and add `error.encryptionError`: + +```typescript +const { data, error, count, status, statusText } = await db + .from("patients") + .select("id, name") + .eq("name", "Alice Chen") + +if (error?.encryptionError) { + // CipherStash authentication, encryption, or decryption failed +} else if (error) { + // Supabase or Postgres rejected the request +} +``` + +Chain `.throwOnError()` when the surrounding code prefers exceptions. Chain `.audit({ metadata })` to attach context to ZeroKMS audit events, or `.withLockContext({ identityClaim: ["sub"] })` to bind encryption to a federated user. See [Supabase Auth](/integrations/supabase/auth) before using lock contexts. + +## Where to next + + + + Preserve the signed-in Supabase user and optionally lock decryption to their identity. + + + Run encryption in Deno with the WebAssembly build. + + + The complete query-builder API, response types, and implementation details. + + diff --git a/content/docs/meta.json b/content/docs/meta.json new file mode 100644 index 0000000..5d7ad44 --- /dev/null +++ b/content/docs/meta.json @@ -0,0 +1,12 @@ +{ + "pages": [ + "index", + "get-started", + "integrations", + "concepts", + "guides", + "security", + "solutions", + "reference" + ] +} diff --git a/content/docs/reference/agent-skills.mdx b/content/docs/reference/agent-skills.mdx new file mode 100644 index 0000000..887f6ba --- /dev/null +++ b/content/docs/reference/agent-skills.mdx @@ -0,0 +1,74 @@ +--- +title: Agent skills +description: "Install CipherStash agent skills to give your AI coding assistant accurate knowledge of encryption setup, schema building, deployment, and integrations." +type: reference +components: [encryption, cli] +verifiedAgainst: + stack: "1.0.0" + cli: "1.0.0" +--- + +CipherStash Stack 1.0 publishes 13 [agent skills](https://skills.sh/) covering the stable SDK, CLI, database surfaces, deployment workflow, and integrations. The skills give an AI coding assistant the exact APIs, commands, constraints, and rollout sequence it needs without making your application code part of the prompt. + +Skills work with AI coding tools that support the skills protocol, including Claude Code, Codex, Cursor, GitHub Copilot, Windsurf, Cline, Gemini, AMP, Goose, Roo, and Trae. + +## Install every skill + +Install the complete collection in your project root: + +```bash cta cta-type="install" example-id="install-agent-skills" +npx skills add cipherstash/stack +``` + +This installs all 13 skills. Your agent activates the relevant ones from the task and code it is working on. + +## Install through the Stash workflow + +The `stash` 1.0 CLI bundles the skills and selects a smaller set for the integration detected by `stash init`. When `stash impl` hands the reviewed plan to an agent, it copies the selected skills into the tool's skill directory or includes their guidance in the generated agent instructions. + +| Integration | Skills selected | +| --- | --- | +| Drizzle | `stash-encryption`, `stash-drizzle`, `stash-indexing`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-cli` | +| Supabase | `stash-encryption`, `stash-supabase`, `stash-indexing`, `stash-postgres`, `stash-edge`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-cli` | +| Prisma ORM 8 | `stash-encryption`, `stash-prisma`, `stash-indexing`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-cli` | +| PostgreSQL | `stash-encryption`, `stash-indexing`, `stash-postgres`, `stash-edge`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-cli` | + +Claude Code receives skills under `.claude/skills/`; Codex receives them under `.codex/skills/`. Handoffs to tools that do not load a skill directory receive the same guidance through the generated agent instructions. + +## Available skills + +| Skill | What it covers | +| --- | --- | +| `stash-encryption` | The stable `@cipherstash/stack` EQL v3 typed schema, `EncryptionClient`, encryption and model operations, searchable fields, encrypted JSON, bulk operations, and migration lifecycle. | +| `stash-auth` | Credentials, `AutoStrategy`, `AccessKeyStrategy`, `OidcFederationStrategy`, `DeviceSessionStrategy`, the four `CS_*` variables, lock contexts, and authentication failures. | +| `stash-zerokms` | Keysets, clients, client keys, grants, revocation, key hierarchy, multi-tenant isolation, and diagnosing keyset mismatches. | +| `stash-indexing` | Functional indexes over EQL v3 term extractors, managed-Postgres constraints, `ORDER BY` and `GROUP BY` shapes, and `EXPLAIN` verification. | +| `stash-deployment` | The safe multi-deploy encryption rollout, credentials, backfill, read cutover, rollback, Prisma Compute, and preview environments. | +| `stash-cli` | `init`, `plan`, `impl`, `status`, authentication, `eql install/migration/repair/upgrade/status`, validation, backfill, drop, schema generation, and the agent interface. | +| `stash-drizzle` | `@cipherstash/stack-drizzle` 1.0 column factories, schema extraction, indexes, EQL v3 migration generation, and encrypted query operators such as `eq`, `matches`, `contains`, and `selector`. | +| `stash-supabase` | The `encryptedSupabase` wrapper, encrypted mutations and reads, filters and ordering, identity-aware encryption, and PostgREST limitations. | +| `stash-prisma` | `@cipherstash/stack-prisma` 1.0, encrypted Prisma column types, runtime envelopes, `decryptAll`, `eql*` query operators, and Prisma-owned EQL migrations. | +| `stash-postgres` | Hand-written EQL v3 SQL with `pg` or `postgres`, typed bind parameters, operator selection, `encryptQuery`, and double-encoding failures. | +| `stash-edge` | The `@cipherstash/stack/wasm-inline` entry for Deno, Supabase Edge Functions, Cloudflare Workers, and Bun, including explicit credentials and WASM-specific client behavior. | +| `stash-dynamodb` | The `@cipherstash/stack/dynamodb` surface, encrypted attributes, HMAC query attributes, nested values, bulk operations, and DynamoDB table design. | +| `stash-supply-chain-security` | Dependency admission, install cooldowns, lockfile integrity, frozen CI installs, registry pinning, Dependabot, `CODEOWNERS`, and npm provenance. | + +## How skills work + +An installed skill activates when the task matches its description. Integration skills link back to the cross-cutting skills instead of duplicating their rules: authentication questions route to `stash-auth`, index design to `stash-indexing`, production rollout to `stash-deployment`, and key access to `stash-zerokms`. + +That separation keeps the guidance consistent. A Drizzle, Supabase, Prisma, or raw PostgreSQL task uses the same released encryption model and deployment safety rules while adding only the query and schema behavior specific to that integration. + +## Typical workflow + +1. **Initialize the project.** Run `npx stash init` with `--drizzle`, `--supabase`, or `--prisma` when appropriate. The CLI authenticates, inspects the project, installs compatible packages, and records context under `.cipherstash/`. +2. **Draft a plan.** Run `npx stash plan`. The selected skills help the agent produce a reviewable `.cipherstash/plan.md` without changing the application. +3. **Review the plan.** Confirm the selected columns, capabilities, indexes, migration sequence, and deployment gates. +4. **Implement it.** Run `npx stash impl`. The CLI hands the plan and integration-specific skill set to your chosen agent. +5. **Track the rollout.** Run `npx stash status` as schema changes, dual writes, backfill, read cutover, and plaintext removal progress. + +## Requirements + +- Node.js 22 or later +- An AI coding tool that supports skills or generated agent instructions +- A CipherStash account ([sign up](https://dashboard.cipherstash.com/sign-up)) diff --git a/content/docs/reference/auth/access-keys.mdx b/content/docs/reference/auth/access-keys.mdx new file mode 100644 index 0000000..90cf000 --- /dev/null +++ b/content/docs/reference/auth/access-keys.mdx @@ -0,0 +1,63 @@ +--- +title: Access keys +description: "Create and manage access keys for production applications, CI, and workspace automation." +type: reference +components: [platform] +audience: [developer] +--- + +Access keys authenticate non-interactive requests to CipherStash services such +as ZeroKMS and CTS. A deployed Stack SDK or Proxy client uses an access key +alongside its [client key](/reference/auth/clients). + +Local development normally does not need one. `stash init` creates a +device-backed session and client key for the current developer. Use access +keys for production, CI, or workspace automation where an interactive login is +not available. + +## Create an access key + +Open **Access Keys** for the workspace in the +[CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/access-keys), +select **Create access key**, and choose the least-privileged role that covers +the workload. + +The key value is shown when it is created. Store it in the deployment's secret +manager as `CS_CLIENT_ACCESS_KEY`; do not commit it or share a developer key +with production. + +## Roles + +### Member + +Use `member` for applications performing cryptographic operations. It can list +keysets and generate or retrieve data-key material: + +```text +keyset:list +data_key:generate +data_key:retrieve +``` + +### Control + +Use `control` for workspace automation that manages keysets and client access. +It can create, list, enable, disable, grant, modify, and revoke keysets, and +list clients. It cannot derive data keys. + +### Admin + +`admin` combines service administration and cryptographic permissions. Avoid +it in production applications: use `member` for runtime operations and +`control` for automation so a compromised credential has the smallest useful +scope. + +## Rotate or revoke a key + +Create the replacement first, update every consumer, verify that the new key +is in use, and then delete the old key from the Dashboard. Rotating an access +key changes authentication; it does not re-encrypt stored data. + +See [workspace configuration](/reference/workspace/configuration) for the full +production credential set and [Deployment](/guides/deployment) for rollout +gates. diff --git a/content/docs/reference/auth/clients.mdx b/content/docs/reference/auth/clients.mdx new file mode 100644 index 0000000..0cc78aa --- /dev/null +++ b/content/docs/reference/auth/clients.mdx @@ -0,0 +1,48 @@ +--- +title: Client keys +description: "Understand device-backed and application client keys, keyset grants, revocation, and deployment use." +type: reference +components: [platform, encryption] +audience: [developer] +--- + +A client key is the application-held half of CipherStash's split key +architecture. It identifies a developer device or deployed application and is +granted access to one or more keysets. ZeroKMS never receives the client key. + +## Client key types + +### Device-backed client keys + +`stash init` creates a device-backed client key for the current developer and +grants it access to the workspace's default keyset. + +- Use it for local development only. +- Each developer and device gets a distinct key. +- Revoking one device does not affect other developers or production. +- No `CS_*` environment variables are required while the device session is + active. + +### Application client keys + +Create an application client key for production, CI, background jobs, or +Proxy—any environment without an interactive device login. + +Open **Clients** in the +[CipherStash Dashboard](https://dashboard.cipherstash.com/workspaces/_/clients), +select **Create a new client**, and grant only the keysets that workload needs. +Store its ID and key value as `CS_CLIENT_ID` and `CS_CLIENT_KEY`. + +## Keyset access + +A client can use only the keysets it has been granted. Separate keysets are a +cryptographic isolation boundary: a client without the grant cannot derive the +keys required to decrypt that data. + +Grant access before deploying a workload. To revoke it, remove the client from +the keyset or delete the client. Revocation prevents future key derivation but +does not modify ciphertext already stored in your database. + +See [key management](/concepts/key-management#keysets-define-the-isolation-boundary) +for the isolation model and [workspace configuration](/reference/workspace/configuration) +for production environment variables. diff --git a/content/docs/reference/auth/index.mdx b/content/docs/reference/auth/index.mdx new file mode 100644 index 0000000..afd6298 --- /dev/null +++ b/content/docs/reference/auth/index.mdx @@ -0,0 +1,31 @@ +--- +title: Auth +navTitle: Overview +description: "Authentication and credential reference for local development, production services, and identity-bound encryption." +type: reference +components: [platform] +--- + +CipherStash separates the credentials used by developers, deployed services, +and signed-in end users. Choose the mechanism that matches who is performing +the cryptographic operation. + +| Context | Authentication | Use it for | +| --- | --- | --- | +| Local development | Device session created by `stash init` | Per-developer access without shared environment variables | +| Production and CI | Application client key plus access key | Non-interactive SDK and Proxy deployments | +| Signed-in end user | OIDC federation | Identity-bound encryption and user-attributed key derivation | + +## In this section + +- [Access keys](/reference/auth/access-keys) authenticate programmatic requests + to CipherStash services and define their service-level permissions. +- [Client keys](/reference/auth/clients) supply the client-held half of the + ZeroKMS key split and scope access to keysets. +- [OIDC providers](/reference/auth/oidc-configuration) register an identity + provider for end-user federation. + +For the four environment variables used by deployed applications, see +[workspace configuration](/reference/workspace/configuration). For the +cryptographic effect of binding a value to an identity claim, see +[provable access control](/solutions/provable-access). diff --git a/content/docs/reference/auth/meta.json b/content/docs/reference/auth/meta.json new file mode 100644 index 0000000..96450d9 --- /dev/null +++ b/content/docs/reference/auth/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Auth", + "pages": ["index", "access-keys", "clients", "oidc-configuration"] +} diff --git a/content/docs/reference/auth/oidc-configuration.mdx b/content/docs/reference/auth/oidc-configuration.mdx new file mode 100644 index 0000000..79a3462 --- /dev/null +++ b/content/docs/reference/auth/oidc-configuration.mdx @@ -0,0 +1,73 @@ +--- +title: OIDC providers +description: "Register an identity provider so cryptographic operations can be authenticated as the signed-in end user." +type: reference +components: [platform, encryption] +audience: [developer] +verifiedAgainst: + auth: "0.42.0" +--- + +Register an OpenID Connect provider when encryption or decryption must be +performed as the signed-in user. CipherStash federates the provider's JWT into +a short-lived service token; the application does not send an OIDC client +secret to CipherStash. + +OIDC is optional. Applications that authenticate only as a deployed service +use an [application client key](/reference/auth/clients) and +[access key](/reference/auth/access-keys) instead. + +## Supported providers + +- Auth0 +- Clerk +- Okta +- Supabase Auth + +## Register a provider + +1. Open **Authentication → OIDC Providers** for the workspace in the + [Dashboard](https://dashboard.cipherstash.com/workspaces/_/oidc-providers). +2. Select **Add OIDC Provider**. +3. Choose the vendor and enter the issuer URL from the provider's OIDC + discovery document. + +| Provider | Example issuer | +| --- | --- | +| Auth0 | `https://your-tenant.auth0.com/` | +| Clerk | `https://your-app.clerk.accounts.dev/` | +| Okta | `https://your-org.okta.com/` | +| Supabase | `https://project-ref.supabase.co/auth/v1` | + +For Supabase, the [Dashboard integration](/integrations/supabase) can register +the project issuer for you. + +CipherStash stores the provider vendor and issuer. Token signatures are +validated using the issuer's published JWKS; no provider client secret is +stored. + +## Use the provider from Stack + +Configure `OidcFederationStrategy` with a callback that returns the current +user's JWT: + +```typescript filename="encryption.ts" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" + +const encryption = await Encryption({ + schemas: [patients], + config: { + authStrategy: OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN, + () => getCurrentUserJwt(), + ), + }, +}) +``` + +The callback must return the current token rather than a token captured once +at startup, because the strategy invokes it again when federation needs to be +renewed. + +Continue with [provable access control](/solutions/provable-access) to bind +encryption to an identity claim using a lock context. diff --git a/content/docs/reference/benchmarks.mdx b/content/docs/reference/benchmarks.mdx new file mode 100644 index 0000000..be8ae7c --- /dev/null +++ b/content/docs/reference/benchmarks.mdx @@ -0,0 +1,73 @@ +--- +title: Benchmarks +description: "Query latency on encrypted columns versus plaintext PostgreSQL. Exact match and range run within 1.2-1.4x of plaintext and stay flat from 10k to 10M rows." +type: reference +components: [eql] +--- + +CipherStash encrypts data in your application and queries it in PostgreSQL through [EQL](/reference/eql). These benchmarks measure what that costs: the same query shapes run against encrypted columns and against plaintext tables with equivalent indexes, so every figure is an honest encrypted-versus-plaintext ratio rather than a number in isolation. + +All results are medians on **Apple M1 Max, PostgreSQL 17, EQL v3**, across datasets from 10,000 to 10,000,000 rows. The full suite (per-scenario SQL, `EXPLAIN` plans, charts, and the v2-to-v3 regression comparison) lives in [cipherstash/benches](https://github.com/cipherstash/benches). + + +On the fast paths, querying encrypted data costs almost nothing. Exact match and OPE range queries run at about **0.12 ms, within 1.2-1.4x of plaintext PostgreSQL, and stay flat from 10k to 10M rows**. Latency tracks the index, not the row count. + + +## Query latency + +Median latency at 1,000,000 rows, with the ratio to the same query on a plaintext table with an equivalent index. + +| Operation | EQL index | Encrypted median | vs plaintext | +| --- | --- | --- | --- | +| Exact match (`=`) | B-tree (equality term) | 0.12 ms | 1.3x | +| Range / `ORDER BY` (OPE) | B-tree (OPE order term) | 0.12 ms | 1.2x | +| Range / `ORDER BY` (ORE) | B-tree (ORE order term) | 0.52 ms | 5.2x | +| JSON containment (`@>`) | GIN (SteVec) | 0.40 ms | 1.4x | +| `GROUP BY` (low cardinality) | B-tree (equality term) | 83 ms | 2.2x | + +**Range and ordering have two index options.** The `_ord` variants use order-preserving encryption (OPE), which is EQL v3's default: near-plaintext latency, and an index that builds in about 1 s at 1M rows. The `_ord_ore` variants use order-revealing encryption (ORE): roughly 5x plaintext, and the index takes about 44 s to build at 1M. They have different leakage profiles, so the right choice is per column rather than universal, see [Searchable encryption](/concepts/searchable-encryption) for the tradeoff and [Numbers](/reference/eql/numbers) for the variants. + +**Free-text match** uses a bloom-filter term (there is no `LIKE` on encrypted text) and runs at about 15.7 ms at 1M rows. What matters most is that the index engages at all: the bloom GIN is **100-400x faster than a sequential scan**. See [Text](/reference/eql/text). + +## Scaling + +Latency on an indexed encrypted column tracks the index, not the table, so the encrypted-versus-plaintext ratio barely moves as the dataset grows 1000x: + +| Operation | 10k | 100k | 1M | 10M | +| --- | --- | --- | --- | --- | +| Exact match (`=`) | 0.13 ms (1.4x) | 0.11 ms (1.2x) | 0.12 ms (1.3x) | 0.13 ms (1.4x) | +| Range (OPE) | 0.12 ms (1.2x) | 0.12 ms (1.2x) | 0.12 ms (1.2x) | 0.12 ms (1.2x) | + +Aggregations that scan every matched row, like `GROUP BY`, grow with the number of rows in the group exactly as they do on plaintext: about 2x plaintext throughout (2.1 ms at 10k, 83 ms at 1M). + +## Ingest + +Encryption happens in your application on write, so insert throughput reflects client-side work rather than database load. On the reference machine, encrypted insert throughput ranges from about **11k rows/s** for exact-match and OPE columns down to about **1.3k rows/s** for the free-text `text_search` domain, which generates a bloom and an order term per value. Batching and parallel writers scale this well past the single-threaded figures here. The per-domain breakdown is in the [ingest report](https://github.com/cipherstash/benches/blob/main/report/BENCHMARK_REPORT.md). + +## Methodology + +- **Environment:** Apple M1 Max, PostgreSQL 17, EQL v3. Datasets of 10k, 100k, 1M, and 10M rows. +- **Baseline:** every encrypted scenario is compared to the same query shape on a plaintext table with an equivalent index (`benches/plaintext_v3.rs`). Ratios are encrypted median divided by plaintext median. +- **Indexes:** queries use functional indexes over EQL's extractor terms, exactly as documented in [Indexes](/reference/eql/indexes). A query that cannot use its index falls back to a sequential scan, as it would on plaintext. +- **Caveats:** medians are single-machine and single-connection, so absolute numbers on your hardware and under concurrency will differ. One outlier is called out in the report: JSON `contains` at 10M rows uses a query recipe that materializes the full GIN bitmap before `LIMIT` (a documented plan artifact, not the operator's real cost). + +## Reproduce + +```bash +git clone https://github.com/cipherstash/benches +cd benches +mise run setup-db-v3 +mise run bench:v3:query:all 1000000 +mise run report:v3-compare +``` + +## Related + + + + The leakage model behind each index type: what exact, range, and match terms reveal. + + + The functional-index recipes these numbers depend on, and what it takes for an index to engage. + + diff --git a/content/docs/reference/cli/auth.mdx b/content/docs/reference/cli/auth.mdx new file mode 100644 index 0000000..c0963c0 --- /dev/null +++ b/content/docs/reference/cli/auth.mdx @@ -0,0 +1,83 @@ +--- +title: stash auth +description: "Reference for the `stash auth` commands." +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +The `stash auth` command group. + +### `auth login` + +Runs the OAuth 2.0 device authorization flow: +1. Pick a region for your workspace. +2. Approve in the browser — the URL is printed, so it works over SSH/headless. +3. The CLI polls until you approve, then stores a short-lived token. +4. Your device is bound to the workspace's default keyset, so later + commands authenticate without a fresh login. + +```bash +npx stash auth login [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--region ` | Region to authenticate against (e.g. us-east-1). Skips the interactive region picker. (env: `STASH_REGION`) | +| `--json` | Emit newline-delimited JSON events instead of prose. The first event (authorization_required) carries the device verification URL for a human to open. Implies no prompt and no browser auto-open. | +| `--no-open` | Don't auto-open the verification URL in a browser (already implied by --json). | +| `--supabase` | Track Supabase as the referrer. | +| `--drizzle` | Track Drizzle as the referrer. | +| `--prisma` | Track Prisma as the referrer. | + + +#### Examples + +```bash +npx stash auth login +npx stash auth login --region us-east-1 +npx stash auth login --supabase +npx stash auth login --region us-east-1 --json +``` + +### `auth regions` + +List the regions you can authenticate against + +```bash +npx stash auth regions [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--json` | Emit machine-readable [\{ slug, label \}] instead of a text list. | + + +#### Examples + +```bash +npx stash auth regions +npx stash auth regions --json +``` + +## Switching workspaces + +`stash` 1.0.0 keeps one active device-session profile at +`~/.cipherstash/auth.json`. To switch workspaces, run `npx stash auth login` +again and complete authorization for the target workspace. The new login +replaces the active session. + +`stash init` reuses the active session when it is still valid, so switch +workspaces before running `init`. There is no separate profile manager or +workspace-switch command in `stash` 1.0.0. diff --git a/content/docs/reference/cli/db.mdx b/content/docs/reference/cli/db.mdx new file mode 100644 index 0000000..0d0e6ca --- /dev/null +++ b/content/docs/reference/cli/db.mdx @@ -0,0 +1,55 @@ +--- +title: stash db +description: "Reference for the `stash db` commands." +type: reference +components: [cli, eql] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +The `stash db` command group. + +### `db validate` + +Validate encryption schema + +```bash +npx stash db validate [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--supabase` | Use Supabase-compatible mode. | +| `--exclude-operator-family` | Skip operator family creation. | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | + + +### `db migrate` + +Run pending encrypt config migrations (not yet implemented) + +```bash +npx stash db migrate +``` + +### `db test-connection` + +Test database connectivity + +```bash +npx stash db test-connection [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | diff --git a/content/docs/reference/cli/doctor.mdx b/content/docs/reference/cli/doctor.mdx new file mode 100644 index 0000000..176e425 --- /dev/null +++ b/content/docs/reference/cli/doctor.mdx @@ -0,0 +1,20 @@ +--- +title: stash doctor +description: "Diagnose install problems (native binaries, runtime)" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Diagnose install problems (native binaries, runtime) + +```bash +npx stash doctor +``` diff --git a/content/docs/reference/cli/encrypt.mdx b/content/docs/reference/cli/encrypt.mdx new file mode 100644 index 0000000..126a6c8 --- /dev/null +++ b/content/docs/reference/cli/encrypt.mdx @@ -0,0 +1,70 @@ +--- +title: stash encrypt +description: "Reference for the `stash encrypt` commands." +type: reference +components: [cli, eql] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +The `stash encrypt` command group. + +### `encrypt status` + +Show per-column migration status (phase, progress, drift) + +```bash +npx stash encrypt status +``` + +### `encrypt plan` + +Diff intent (.cipherstash/migrations.json) vs observed state + +```bash +npx stash encrypt plan +``` + +### `encrypt backfill` + +Resumably encrypt plaintext into the encrypted column + +```bash +npx stash encrypt backfill [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--table ` | Target table. | +| `--column ` | Target column. | +| `--pk-column ` | Primary-key column used to page through rows. | +| `--chunk-size ` | Rows encrypted per batch. | +| `--encrypted-column ` | Destination encrypted column. | +| `--schema-column-key ` | Schema key identifying the column config. | +| `--confirm-dual-writes-deployed` | Assert the app is dual-writing before backfilling (safety gate). | +| `--force` | Proceed past non-fatal safety checks. | + + +### `encrypt drop` + +Generate a migration to drop the plaintext column + +```bash +npx stash encrypt drop [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--table ` | Target table. | +| `--column ` | Target column. | +| `--migrations-dir ` | Directory to write the drop migration into. | diff --git a/content/docs/reference/cli/env.mdx b/content/docs/reference/cli/env.mdx new file mode 100644 index 0000000..4cf0a15 --- /dev/null +++ b/content/docs/reference/cli/env.mdx @@ -0,0 +1,50 @@ +--- +title: stash env +description: "Mint deployment credentials and print them as env vars" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Mints a fresh ZeroKMS client and a CipherStash access key from your +device-code session (`stash auth login`), then prints the four env +vars a deployed app needs: CS_WORKSPACE_CRN, CS_CLIENT_ID, +CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY. + +The access key is created with the member role (the CLI never mints +admin keys) and is shown exactly once — pipe the output into your +deployment secret store. Creating access keys requires your user to +have the admin role in the workspace. + +Stdout carries only the dotenv block (or the --json events); +progress UI goes to stderr, so `stash env --name x > prod.env` +and pipes into secret stores are safe. + +```bash +npx stash env [flags] +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--name ` | Name for the minted access key and ZeroKMS client. Prompted for interactively; required in non-interactive runs. | +| `--write [path]` | Write the vars to a file (default .env.production.local, mode 0600) instead of printing them. An existing file prompts before overwriting — and is refused non-interactively — before anything is minted. | +| `--json` | Emit machine-readable NDJSON (a \{ status: "minted" \} object, or \{ status: "written" \} with --write — deliberately secret-free since the secrets are in the file; failures are \{ status: "error" \}). Implies no prompts. | + + +## Examples + +```bash +npx stash env --name my-app-prod +npx stash env --name my-app-prod --write +npx stash env --name staging --write .env.staging.local +npx stash env --name edge-dev --json +``` diff --git a/content/docs/reference/cli/eql.mdx b/content/docs/reference/cli/eql.mdx new file mode 100644 index 0000000..813e1bb --- /dev/null +++ b/content/docs/reference/cli/eql.mdx @@ -0,0 +1,134 @@ +--- +title: stash eql +description: "Reference for the `stash eql` commands." +type: reference +components: [cli, eql] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +The `stash eql` command group. + +### `eql install` + +Scaffold stash.config.ts (if missing) and install EQL extensions + +```bash +npx stash eql install [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--force` | Reinstall / overwrite even if already installed. | +| `--dry-run` | Show what would happen without making changes. | +| `--supabase` | Use Supabase-compatible mode (auto-detected from DATABASE_URL). | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | + + +### `eql migration` + +Generate an EQL v3 install migration for your ORM (Drizzle; Prisma Next installs EQL through its own migrations) + +```bash +npx stash eql migration [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--drizzle` | Emit a Drizzle custom migration containing the EQL v3 install SQL. | +| `--prisma` | Not needed: Prisma Next installs EQL through its own migration framework — run `prisma-next migrate` instead. | +| `--supabase` | Append the Supabase role grants (eql_v3 + eql_v3_internal for anon/authenticated/service_role). | +| `--name ` | Name for the generated migration (Drizzle). Letters, numbers, dashes, underscores only. Defaults to `install-eql`. | +| `--out ` | Directory drizzle-kit writes the migration into (passed to `drizzle-kit generate --out`). Defaults to `drizzle`; set it to match your drizzle.config.ts. | +| `--dry-run` | Show what would happen without making changes. | + + +#### Examples + +```bash +npx stash eql migration --drizzle +npx stash eql migration --drizzle --supabase +``` + +### `eql repair` + +Sweep an existing Drizzle output directory for in-place +`ALTER COLUMN ... SET DATA TYPE ` statements — which cannot run, +because Postgres has no cast from text/numeric to an EQL domain — and rewrite +each into an additive encrypted column that preserves the source column. + +This is the same sweep `eql migration --drizzle` runs, without having to +generate an EQL install migration you do not want just to trigger it. + +Migrations the database has already applied are reported and left alone: +rewriting one would leave its .sql describing a shape that database never got +from it, so a fresh CI or staging database replaying the file would silently +diverge. Pass --database-url so that check can run; without it the repair +proceeds and warns that applied state could not be verified. If your +drizzle.config.ts overrides `migrations.table` / `migrations.schema`, name +the ledger with --migrations-table — otherwise the check queries the default +relation, finds nothing, and reports applied state as unverified. + +```bash +npx stash eql repair [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--drizzle` | Repair a Drizzle migration directory. | +| `--out ` | Directory holding the migrations to sweep. Defaults to `drizzle`; set it to match your drizzle.config.ts. | +| `--migrations-table <[schema.]table>` | Drizzle's migration ledger, when drizzle.config.ts overrides `migrations.table` / `migrations.schema`. Defaults to `drizzle.__drizzle_migrations`. Only read with --database-url. | +| `--dry-run` | Show what would happen without making changes. | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | + + +#### Examples + +```bash +npx stash eql repair --drizzle +npx stash eql repair --drizzle --dry-run +npx stash eql repair --drizzle --out db/migrations --database-url postgres://… +``` + +### `eql upgrade` + +Upgrade EQL extensions to the latest version + +```bash +npx stash eql upgrade [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--dry-run` | Show what would happen without making changes. | +| `--supabase` | Use Supabase-compatible mode. | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | + + +### `eql status` + +Show EQL installation status + +```bash +npx stash eql status [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | diff --git a/content/docs/reference/cli/impl.mdx b/content/docs/reference/cli/impl.mdx new file mode 100644 index 0000000..af1fbd0 --- /dev/null +++ b/content/docs/reference/cli/impl.mdx @@ -0,0 +1,36 @@ +--- +title: stash impl +description: "Execute the plan with a local agent" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Execute the plan with a local agent + +```bash +npx stash impl [flags] +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--continue-without-plan` | Skip planning and go straight to implementation (interactively confirms before proceeding). | +| `--target ` | Skip the agent-target picker and hand off directly to one of claude-code \| codex \| agents-md \| wizard. Safe in non-TTY contexts. | + + +## Examples + +```bash +npx stash impl +npx stash impl --continue-without-plan +npx stash impl --target claude-code +``` diff --git a/content/docs/reference/cli/index.mdx b/content/docs/reference/cli/index.mdx new file mode 100644 index 0000000..eadd67e --- /dev/null +++ b/content/docs/reference/cli/index.mdx @@ -0,0 +1,76 @@ +--- +title: CLI +navTitle: Overview +description: "Command reference for the stash CLI, generated from v1.0.0." +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +The `stash` CLI. Install with `npx stash@1.0.0`. Every command accepts `--help` and `--version`. + +### Setup & workflow + +| Command | Description | +| --- | --- | +| [`init`](/reference/cli/init) | Initialize CipherStash for your project | +| [`plan`](/reference/cli/plan) | Draft a reviewable encryption plan at .cipherstash/plan.md | +| [`impl`](/reference/cli/impl) | Execute the plan with a local agent | +| [`status`](/reference/cli/status) | Displays implementation status | +| [`wizard`](/reference/cli/wizard) | AI-guided encryption setup (reads your codebase) | +| [`doctor`](/reference/cli/doctor) | Diagnose install problems (native binaries, runtime) | +| [`manifest`](/reference/cli/manifest) | Print the structured, versioned command surface | +| [`telemetry`](/reference/cli/telemetry) | Manage anonymous usage analytics | + +### Auth + +| Command | Description | +| --- | --- | +| [`auth login`](/reference/cli/auth#auth-login) | Authenticate with CipherStash | +| [`auth regions`](/reference/cli/auth#auth-regions) | List the regions you can authenticate against | + +### EQL + +| Command | Description | +| --- | --- | +| [`eql install`](/reference/cli/eql#eql-install) | Scaffold stash.config.ts (if missing) and install EQL extensions | +| [`eql migration`](/reference/cli/eql#eql-migration) | Generate an EQL v3 install migration for your ORM (Drizzle; Prisma Next installs EQL through its own migrations) | +| [`eql repair`](/reference/cli/eql#eql-repair) | Repair migrations drizzle-kit generated with an un-runnable ALTER COLUMN to an encrypted type | +| [`eql upgrade`](/reference/cli/eql#eql-upgrade) | Upgrade EQL extensions to the latest version | +| [`eql status`](/reference/cli/eql#eql-status) | Show EQL installation status | + +### Database + +| Command | Description | +| --- | --- | +| [`db validate`](/reference/cli/db#db-validate) | Validate encryption schema | +| [`db migrate`](/reference/cli/db#db-migrate) | Run pending encrypt config migrations (not yet implemented) | +| [`db test-connection`](/reference/cli/db#db-test-connection) | Test database connectivity | + +### Schema + +| Command | Description | +| --- | --- | +| [`schema build`](/reference/cli/schema#schema-build) | Build an encryption schema from your database | + +### Encrypt + +| Command | Description | +| --- | --- | +| [`encrypt status`](/reference/cli/encrypt#encrypt-status) | Show per-column migration status (phase, progress, drift) | +| [`encrypt plan`](/reference/cli/encrypt#encrypt-plan) | Diff intent (.cipherstash/migrations.json) vs observed state | +| [`encrypt backfill`](/reference/cli/encrypt#encrypt-backfill) | Resumably encrypt plaintext into the encrypted column | +| [`encrypt drop`](/reference/cli/encrypt#encrypt-drop) | Generate a migration to drop the plaintext column | + +### Deployment + +| Command | Description | +| --- | --- | +| [`env`](/reference/cli/env) | Mint deployment credentials and print them as env vars | diff --git a/content/docs/reference/cli/init.mdx b/content/docs/reference/cli/init.mdx new file mode 100644 index 0000000..e7fc9ef --- /dev/null +++ b/content/docs/reference/cli/init.mdx @@ -0,0 +1,42 @@ +--- +title: stash init +description: "Initialize CipherStash for your project" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Set up CipherStash end-to-end: authenticate, introspect your database, +install dependencies, install EQL, and hand off the rest to your local +coding agent. Every prompt has a non-interactive escape hatch, so init +never blocks waiting on a TTY (CI, agents, pipes). + +```bash +npx stash init [flags] +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--supabase` | Use Supabase-specific setup flow. | +| `--drizzle` | Use Drizzle-specific setup flow. | +| `--prisma` | Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migrate). | +| `--region ` | Region to authenticate against (e.g. us-east-1). Skips the interactive region picker. Required for non-interactive init when not already logged in. (env: `STASH_REGION`) | + + +## Examples + +```bash +npx stash init +npx stash init --supabase +npx stash init --prisma +npx stash init --region us-east-1 +``` diff --git a/content/docs/reference/cli/manifest.mdx b/content/docs/reference/cli/manifest.mdx new file mode 100644 index 0000000..0ccb5aa --- /dev/null +++ b/content/docs/reference/cli/manifest.mdx @@ -0,0 +1,37 @@ +--- +title: stash manifest +description: "Print the structured, versioned command surface" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Emit the CLI command surface as data. `--json` produces the machine- +readable manifest the docs generator and agents consume; without it a +grouped command list is printed. The manifest is stamped with the CLI +version, so a page generated from it always names the version it describes. + +```bash +npx stash manifest [flags] +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--json` | Emit the structured JSON manifest instead of a text list. | + + +## Examples + +```bash +npx stash manifest --json +npx stash manifest +``` diff --git a/content/docs/reference/cli/meta.json b/content/docs/reference/cli/meta.json new file mode 100644 index 0000000..93463e4 --- /dev/null +++ b/content/docs/reference/cli/meta.json @@ -0,0 +1,27 @@ +{ + "title": "CLI", + "pages": [ + "index", + "---Setup & workflow---", + "init", + "plan", + "impl", + "status", + "wizard", + "doctor", + "manifest", + "telemetry", + "---Auth---", + "auth", + "---EQL---", + "eql", + "---Database---", + "db", + "---Schema---", + "schema", + "---Encrypt---", + "encrypt", + "---Deployment---", + "env" + ] +} diff --git a/content/docs/reference/cli/plan.mdx b/content/docs/reference/cli/plan.mdx new file mode 100644 index 0000000..082ec50 --- /dev/null +++ b/content/docs/reference/cli/plan.mdx @@ -0,0 +1,37 @@ +--- +title: stash plan +description: "Draft a reviewable encryption plan at .cipherstash/plan.md" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Draft a reviewable encryption plan at .cipherstash/plan.md + +```bash +npx stash plan [flags] +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--complete-rollout` | Plan the entire encryption lifecycle (schema-add through drop) in one document. Skips the production-deploy gate; only safe when this database is not backing a deployed application. Needs confirmation — an interactive prompt, or --yes non-interactively (else it exits non-zero without drafting). | +| `--yes` | Confirm --complete-rollout's gate-skip without a prompt (for automation / CI). No effect without --complete-rollout. | +| `--target ` | Skip the agent-target picker and hand off directly to one of claude-code \| codex \| agents-md \| wizard. Safe in non-TTY contexts. | + + +## Examples + +```bash +npx stash plan +npx stash plan --target claude-code +npx stash plan --complete-rollout --yes --target claude-code +``` diff --git a/content/docs/reference/cli/schema.mdx b/content/docs/reference/cli/schema.mdx new file mode 100644 index 0000000..ada1471 --- /dev/null +++ b/content/docs/reference/cli/schema.mdx @@ -0,0 +1,31 @@ +--- +title: stash schema +description: "Reference for the `stash schema` commands." +type: reference +components: [cli, eql] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +The `stash schema` command group. + +### `schema build` + +Build an encryption schema from your database + +```bash +npx stash schema build [flags] +``` + +#### Flags + +| Flag | Description | +| --- | --- | +| `--supabase` | Use Supabase-compatible mode. | +| `--database-url ` | Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these. (env: `DATABASE_URL`) | diff --git a/content/docs/reference/cli/status.mdx b/content/docs/reference/cli/status.mdx new file mode 100644 index 0000000..9fcd239 --- /dev/null +++ b/content/docs/reference/cli/status.mdx @@ -0,0 +1,28 @@ +--- +title: stash status +description: "Displays implementation status" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Displays implementation status + +```bash +npx stash status [flags] +``` + +### Flags + +| Flag | Description | +| --- | --- | +| `--quest` | Force the quest-log output (emoji + progress bars) even in non-TTY contexts. | +| `--plain` | Force the plain-text output even in TTY contexts. | +| `--json` | Emit a structured JSON document instead. | diff --git a/content/docs/reference/cli/telemetry.mdx b/content/docs/reference/cli/telemetry.mdx new file mode 100644 index 0000000..3eaebce --- /dev/null +++ b/content/docs/reference/cli/telemetry.mdx @@ -0,0 +1,34 @@ +--- +title: stash telemetry +description: "Manage anonymous usage analytics" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +Manage the anonymous, opt-out usage analytics the CLI collects to +improve the tool. `status` (the default) reports whether telemetry is +on and which setting governs it; `enable` / `disable` write your saved +preference. Telemetry is also disabled by the DO_NOT_TRACK or +STASH_TELEMETRY_DISABLED environment variables and automatically in CI. +No plaintext, schema, table/column names, or connection details are +ever collected. See https://cipherstash.com/docs/reference/cli. + +```bash +npx stash telemetry +``` + +## Examples + +```bash +npx stash telemetry status +npx stash telemetry disable +npx stash telemetry enable +``` diff --git a/content/docs/reference/cli/wizard.mdx b/content/docs/reference/cli/wizard.mdx new file mode 100644 index 0000000..f3e6956 --- /dev/null +++ b/content/docs/reference/cli/wizard.mdx @@ -0,0 +1,20 @@ +--- +title: stash wizard +description: "AI-guided encryption setup (reads your codebase)" +type: reference +components: [cli] +verifiedAgainst: + cli: "1.0.0" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from `stash manifest --json` (v1.0.0). Re-run `bun run generate-docs:cli` to refresh from the latest published CLI. */} + + +Generated from **`stash` v1.0.0** via `npx stash@1.0.0 manifest --json`. Run `npx stash@1.0.0 --help` to see the live command surface. + + +AI-guided encryption setup (reads your codebase) + +```bash +npx stash wizard +``` diff --git a/content/docs/reference/eql/booleans.mdx b/content/docs/reference/eql/booleans.mdx new file mode 100644 index 0000000..22f0794 --- /dev/null +++ b/content/docs/reference/eql/booleans.mdx @@ -0,0 +1,64 @@ +--- +title: Booleans +description: "Encrypted booleans are storage-only by design: public.eql_v3_boolean stores and decrypts, carries no index terms, and blocks every comparison." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.0" +--- + + + +Every scalar type has a storage-only variant — for `bool` it's the only one. EQL ships `public.eql_v3_boolean` and nothing else: there is no `bool_eq` and no `bool_ord`. An encrypted boolean column can be stored, decrypted, and null-checked; it cannot be filtered, sorted, grouped, or joined on. + +## Why there are no query variants + +A two-value column has too little cardinality for any searchable index to be safe. An equality term over `true` / `false` would partition the table into two visible buckets — leaking the value distribution (and, with any outside knowledge, the values themselves) outright. Rather than ship an index term that can't keep its promise, EQL omits the query variants entirely. See [Searchable encryption](/concepts/searchable-encryption) for the general analysis of what index terms reveal. + +## What works, what raises + +`public.eql_v3_boolean` follows the bare-variant contract described in [Core concepts](/reference/eql/core-concepts#variants-declare-capability): it carries no index terms, so `IS NULL` / `IS NOT NULL` are the only predicates that work. Every comparison operator routes to a blocker and raises — the [fail-loud behavior](/reference/eql/core-concepts#unsupported-operations-fail-loudly) shared by all encrypted variants: + +```sql +-- ❌ Raises: operator = is not supported for public.eql_v3_boolean +SELECT * FROM users WHERE is_active = $1::public.eql_v3_boolean; + +-- ✅ Works: NULL columns are not encrypted +SELECT * FROM users WHERE is_active IS NOT NULL; +``` + +## Filter client-side + +Query on other columns, decrypt the boolean in your application, and filter there: + +```sql +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq, -- exact lookup + created_at public.eql_v3_timestamp_ord, -- range queries, ORDER BY + is_active public.eql_v3_boolean -- storage only (by design) +); +``` + +```sql +-- Narrow the result set with the columns that do carry index terms… +SELECT id, email, is_active FROM users +WHERE created_at >= $1::public.eql_v3_timestamp_ord; +-- …then decrypt is_active in the client and filter on the plaintext. +``` + +The [Stack SDK](/reference/stack) and [CipherStash Proxy](/reference/proxy) decrypt the payload back to a plain boolean on read, so the client-side filter is an ordinary `if`. + +If a boolean genuinely needs to be a server-side predicate, that is a data-modelling signal: consider whether the flag is actually sensitive. A non-sensitive flag can stay a plain PostgreSQL `boolean` column alongside your encrypted columns. + +## Storing without querying + +`bool` is the forced case of a pattern available to every scalar type: the bare variant `public.eql_v3_` (for example `public.eql_v3_integer`, `public.eql_v3_text`, `public.eql_v3_timestamp`) is storage-and-decryption only. It carries no index terms, and every comparison operator raises — use it for columns you only ever store and decrypt, so the database holds no searchable material for them at all. + +For every type other than `bool`, storage-only is a choice you can walk back. If you later need to query, retype the column as a query variant — or, if the payloads already carry the needed term (the client decides which terms travel in the payload), cast at the call site: + +```sql +SELECT * FROM readings WHERE value::public.eql_v3_integer_ord > $1::public.eql_v3_integer_ord; +``` + +The variant families and what each one enables are covered in [Core concepts](/reference/eql/core-concepts); the per-type specifics live in [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), and [Text](/reference/eql/text). diff --git a/content/docs/reference/eql/core-concepts.mdx b/content/docs/reference/eql/core-concepts.mdx new file mode 100644 index 0000000..fb6a239 --- /dev/null +++ b/content/docs/reference/eql/core-concepts.mdx @@ -0,0 +1,194 @@ +--- +title: Core concepts +description: "The model behind every EQL page: domain variants that declare capability, the encrypted payload envelope, the typed-operand rule, and fail-loud blockers." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.3" +--- + + + +Everything in the EQL reference builds on four ideas: columns are typed as **domain variants** that declare what they can do, every value is a **`jsonb` payload** carrying encrypted index terms, **operands must be typed** for the encrypted operators to resolve, and anything a column can't do **fails loudly** instead of returning wrong rows. This page is the canonical home for all four — the per-type and per-query pages link back here rather than restating them. + +## Variants declare capability + +EQL ships its searchable-encryption surface as PostgreSQL **domains in the `public` schema**, all backed by `jsonb` (the functions behind them live in `eql_v3` — [The three schemas](#the-three-schemas) explains the split). Each scalar type generates a *family* of domain variants, and the variant you type a column as fixes its query capability. Each domain carries a `CHECK` constraint that validates the encrypted payload on insert, so a malformed or wrong-version value is rejected at write time rather than surfacing at query time. + +There is no database-side configuration table. Earlier EQL versions tracked encryption config in the database (`config_add_table`, `config_add_column`, and friends) — those are gone in v3. The searchable surface of a column is fixed by the domain variant you type it as, and which index terms travel in a value's payload is decided by the encryption client (the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy)). The domain makes the matching operators resolve; the term in the payload is what makes them answer. + +For any scalar type ``, the family looks like this: + +| Domain variant | Capability | +| --- | --- | +| `public.eql_v3_` | Storage and decryption only. | +| `public.eql_v3__eq` | Equality: `=`, `<>`, `IN`, `GROUP BY`, `DISTINCT`, equijoins. | +| `public.eql_v3__ord` | Comparisons (`<` … `>=`), `BETWEEN`, `ORDER BY`, `MIN` / `MAX` — plus equality. | +| `public.eql_v3__ord_ope` | The byte-identical twin of `_ord`, with OPE pinned. See [SEM specifiers](#sem-specifiers). | +| `public.eql_v3__ord_ore` | As `_ord`, with block-ORE pinned. | +| `public.eql_v3_text_match` (text only) | Free-text fuzzy match: `@@`. | +| `public.eql_v3_text_search` (text only) | Equality + ordering + fuzzy match. | +| `public.eql_v3_text_search_ore` (text only) | As `text_search`, with block-ORE pinned. | + +Every public domain name carries the `eql_v3_` prefix. It keeps EQL's types from shadowing built-in Postgres type names such as `text` and `json`, and gives each EQL version its own column-type namespace so two versions can coexist. Query-operand domains live in the versioned `eql_v3` schema already, so they are unprefixed: `eql_v3.query_text_eq`, `eql_v3.query_json`. + +Two things worth calling out: + +- **The bare variant blocks everything.** `public.eql_v3_` carries no index term. Querying it with any comparison operator raises an "operator not supported" exception. Use it for columns you only ever store and decrypt — [Booleans](/reference/eql/booleans) covers this pattern in full. +- **Which index term backs each capability** is an implementation detail of the payload — covered in [Anatomy of an encrypted value](#anatomy-of-an-encrypted-value) below. + +### SEM specifiers + +A trailing mechanism suffix — the `_ope` in `_ord_ope` — is a **SEM specifier**: it pins *which* searchable-encryption mechanism implements the capability, rather than just declaring the capability itself. + +| Variant | Mechanism | Term | Extractor | +| --- | --- | --- | --- | +| `_ord` | CLLW OPE (the default) | `op` | `eql_v3.ord_term(col)` | +| `_ord_ope` | CLLW OPE, pinned. Byte-identical to `_ord` | `op` | `eql_v3.ord_term(col)` | +| `_ord_ore` | Block-ORE, pinned | `ob` | `eql_v3.ord_term_ore(col)` | +| `text_search` | CLLW OPE for its ordering term | `hm`, `op`, `bf` | `eq_term` / `ord_term` / `match_term` | +| `text_search_ore` | Block-ORE for its ordering term | `hm`, `ob`, `bf` | `eq_term` / `ord_term_ore` / `match_term` | + +The two mechanisms differ in what they demand of the database, not in the capability they declare. `eql_v3_internal.ope_cllw` is a domain over `bytea`, so an ordered functional index on `eql_v3.ord_term(col)` binds `bytea_ops`, the base type's **default** operator class. It works anywhere you can `CREATE INDEX`, with no superuser. + +Block-ORE's operator class is hand-written for a composite type and needs superuser to create. + + +On a database where EQL cannot create that operator class (cloud Supabase and most managed Postgres), the installer **disables every ORE-backed domain**. The types still exist, but a `CHECK` constraint rejects the first value written to one, raising `feature_not_supported` and naming the alternative to use. That is deliberate: installing them anyway leaves `<` and `>` running as unindexable sequential scans while `CREATE INDEX ... (eql_v3.ord_term_ore(col))` fails with an opaque Postgres error. Failing loudly on the first write beats degrading silently. + + +Use `_ord` unless you have a specific reason to pin block-ORE, and pin `_ord_ope` when you want a column's mechanism frozen against a future default change. Each type page lists its available specifiers under an "SEM specifiers" heading. + +Declaring a table is just typing each column as the variant it needs: + +```sql +CREATE TABLE users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq, -- equality only + salary public.eql_v3_integer_ord, -- equality + range + ORDER BY + created_at public.eql_v3_timestamp_ord +); +``` + +Every scalar type — `int2`, `int4`, `int8`, `numeric`, `float4`, `float8`, `date`, `timestamp`, `text`, and `bool` in EQL 3.0.3 — ships some subset of this family. The per-category pages list exactly which variants each type has and how to choose between them: [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), [Text](/reference/eql/text), and [Booleans](/reference/eql/booleans). Encrypted JSON documents use separate domains — `public.eql_v3_json_search` for searchable documents, `public.eql_v3_json` for storage-only ones — with their own operator surface; see [JSON](/reference/eql/json). + +## The three schemas + +EQL spreads its surface across three PostgreSQL schemas, and the split is what makes EQL **upgradable in place**: + +| Schema | Holds | Do you call it? | +| --- | --- | --- | +| `public` | The encrypted **domain types** — every `public.eql_v3_` variant you type a column as. | Referenced in your table DDL. | +| `eql_v3` | All **user-callable functions and operators** — the searchable-encryption API (`eql_v3.eq_term`, `eql_v3.jsonb_path_query`, the encrypted `=` / `<` / `@>` operators, `eql_v3.version()`). | Yes — this is the API. | +| `eql_v3_internal` | The **implementation functions** the domains and operators are built from. | No — never call these directly. | + +**Why the types live in `public`, and carry a version prefix.** Your columns are typed as `public.eql_v3_integer_ord`, `public.eql_v3_text_eq`, and so on — never `eql_v3.*`. Types stay in `public` so a column's type resolves without `search_path` games. The `eql_v3_` prefix does two jobs: it stops EQL's types shadowing Postgres built-ins, since `text`, `json`, and `integer` already resolve in `public`, and it gives each EQL generation its own column-type namespace, so `eql_v3_text_eq` and a future `eql_v4_text_eq` can sit side by side in one database while you migrate table by table. + +**Why `eql_v3` is versioned.** The schema name encodes the major API version and is itself part of the public contract — a breaking change introduces a new `eql_vN` schema *beside* the old one rather than mutating it, so you migrate on your own timeline. Everything in `eql_v3` is fair game to call. + +**Why `eql_v3_internal` is off-limits.** These are the building blocks — term comparators, unsupported-operator blockers, cast helpers — that the operators and domain `CHECK` constraints wire together. They carry no stability guarantee and can change or disappear between releases. If you're reaching for `eql_v3_internal.*`, there's a `public` type or an `eql_v3` function that does what you want. + +## Anatomy of an encrypted value + +Every EQL encrypted value is a `jsonb` payload with a shared envelope plus the index terms that make it queryable. Earlier CipherStash docs called this format the **CipherCell** — this section is the current definition of the same structure. + +Payloads are **produced** by the encryption clients — the [Stack SDK](/reference/stack) and [CipherStash Proxy](/reference/proxy) — and **consumed** by EQL's operators and functions inside Postgres. EQL never sees plaintext: it validates, stores, and compares these payloads; it cannot produce or decrypt them. The division is strict: the clients never rely on the database for key material. + +### The envelope + +Every payload carries three envelope keys. Each `eql_v3` domain's `CHECK` constraint requires them, so a value missing any of these is rejected at write time: + +| Key | Contents | Notes | +| --- | --- | --- | +| `v` | The EQL version | `3` — the payload version matches the EQL major version. The domain `CHECK`s assert it and raise on any other value. | +| `i` | Ident: `{"t": "", "c": ""}` | Binds the ciphertext to the table and column it was encrypted for. Both keys required. | +| `c` | Ciphertext | The opaque, non-deterministic encrypted blob (mp_base85-encoded). Never used in comparisons. | + + +Payloads produced by EQL v2 clients carried `v: 2`; from 3.0.0 the payload version and the EQL version move together. + + +A `k` discriminator (`"ct"` for a scalar ciphertext, `"sv"` for a JSON document) also appears on payloads emitted by the clients, distinguishing the two top-level shapes. + +### Index-term keys + +Alongside the envelope, a payload carries the index terms for its column's capability. Each key is backed by a SEM (searchable encrypted metadata) type in the `eql_v3_internal` schema: + +| Key | SEM type | Wire shape | Enables | Reveals | +| --- | --- | --- | --- | --- | +| `hm` | `eql_v3_internal.hmac_256` (domain over `text`) | Hex string (HMAC-SHA-256) | `=`, `<>` on `_eq` and `text_search` domains | Whether two values are equal — nothing else | +| `op` | `eql_v3_internal.ope_cllw` (domain over `bytea`) | Hex-encoded CLLW OPE ciphertext | `<`, `<=`, `>`, `>=`, `ORDER BY` on `_ord` / `_ord_ope` domains and `text_search`, and on String / Number leaves of `public.eql_v3_json_search` | The relative order of two values | +| `ob` | `eql_v3_internal.ore_block_256` (composite: array of `bytea` block terms) | Array of hex-encoded ORE blocks (block count varies by scalar width) | The same comparisons on the pinned `_ord_ore` and `text_search_ore` domains — and `=` / `<>`, since ORE comparison collapses to equality | The relative order of two values | +| `bf` | `eql_v3_internal.bloom_filter` (domain over `smallint[]`) | Array of set bit positions (**signed** 16-bit — large filters emit negative positions) | `@@` fuzzy match on `_match` and `text_search` domains | Probabilistic token overlap between values | + +The capability is encoded as **required keys**: the payload for a `public.eql_v3_text_eq` column must carry `hm`; a `public.eql_v3_integer_ord` payload must carry `op` (and only `op`); a `text_match` payload must carry `bf`; a `text_search` payload carries `hm`, `op`, and `bf`. A payload missing its term key fails the domain `CHECK` — and fails to deserialize in the client bindings. + +A scalar payload for a `public.eql_v3_text_search` column (lookup + ordering + free-text match, so all three terms are required): + +```json +{ + "v": 3, + "i": { "t": "users", "c": "email" }, + "c": "mBbKmsMM%bK#QQOx1yLDBHyD...", + "hm": "9c8ec1d2f9932b979b1bf3f09f8a4e2f6a41f8de2f0c8b7a52e1f5c3d4b6a790", + "ob": ["7a1fd0c2...", "d24c9be1...", "03fa66b8..."], + "bf": [42, 1290, -8113, 30201] +} +``` + +- `v`, `i`, `c` — the envelope +- `hm` — equality term: `WHERE email = $1` compares this +- `ob` — ordering term: `ORDER BY` and range comparisons walk these blocks +- `bf` — bloom-filter term: `@@` fuzzy match tests these bit positions + +Encrypted JSON documents use a different payload shape — a document-level key header `h` and an `sv` array with one encrypted entry per path, instead of a root ciphertext — defined in [JSON](/reference/eql/json). + +### Machine-readable schemas + +The [EQL repository](https://github.com/cipherstash/encrypt-query-language) publishes the format as JSON Schema in two places: + +- **`crates/eql-bindings/schema/`** — one schema per scalar domain (`$id`s under `https://schemas.cipherstash.com/eql/v3/`), generated from the canonical Rust wire types in the `eql-bindings` crate. TypeScript bindings are generated from the same definitions, so every producer and consumer shares one source of truth. +- **`docs/reference/schema/`** — full-payload schemas covering both the scalar and `sv` document shapes. These files are still named for the v2.x payload releases (`eql-payload-v2.2.schema.json`, `eql-payload-v2.3.schema.json`); the v2.3 schema describes the document shape, with the payload version field moving to `3` alongside the EQL 3.0.0 release. + +## The typed-operand rule + +The `eql_v3` domains are backed by `jsonb`. When an operand has no known type — a bare string literal, an untyped parameter — PostgreSQL reduces the domain to its `jsonb` base type and resolves the **native `jsonb` operator** instead of the encrypted one. The query doesn't fail; it silently returns native `jsonb` semantics, which are meaningless for encrypted payloads. + + + +```sql +SELECT * FROM users WHERE email = $1; +``` + + + +```sql +-- Typed operand — the encrypted `=` resolves. +SELECT * FROM users WHERE email = $1::public.eql_v3_text_eq; +``` + +Always type the operand: a typed parameter (`$1::public.eql_v3_text_eq`) or an explicit cast (`'…'::public.eql_v3_integer_ord`). The [Stack SDK](/reference/stack) and [CipherStash Proxy](/reference/proxy) type bound parameters automatically — raw SQL must do it by hand. + +This is the one place where a mistake is *silent*. Everything else fails loudly: + +## Unsupported operations fail loudly + +Unsupported operators are not silent no-ops. Every operator that a variant doesn't support is still *defined* — it routes to a blocker function that raises an `operator … is not supported` exception. A mis-typed query fails loudly instead of silently returning wrong results: + +```sql +-- salary is public.eql_v3_bigint_eq (equality only) +SELECT * FROM users WHERE salary > $1::public.eql_v3_bigint_eq; +-- ERROR: operator > is not supported for public.eql_v3_bigint_eq +``` + +A `NULL` operand still raises — the blockers are deliberately not `STRICT`, so PostgreSQL can't skip the check. (A SQL `NULL` column value is not encrypted, so `IS NULL` / `IS NOT NULL` themselves always work, on every variant.) + +`LIKE` and `ILIKE` are blocked on **every** encrypted variant — pattern matching is meaningless on ciphertext. Encrypted text matching is bloom-filter fuzzy match (`@@`) instead; [Text](/reference/eql/text) covers it. + +One equality subtlety follows from the term table above, and it splits on whether the column is text. + +On the non-text scalars, ordering is equality-lossless: `=` and `<>` on an `_ord` column compare the **ordering term** (`op`, or `ob` on `_ord_ore`), so those payloads carry no `hm` term at all and get equality for free. On text, ordering is *not* equality-lossless, so every orderable text variant carries `hm` alongside its ordering term and resolves `=` and `<>` against it. `_eq` columns always compare `hm`. + +## What the terms reveal + +Every index term a value carries is extra material stored in the database, and each term class reveals defined structure to an observer who can read the stored payloads: equality terms reveal *value repetition* (which rows share a value), ordering terms reveal *ordering* (which of two values is larger), and bloom terms reveal *probabilistic token overlap*. None of them reveal the plaintext — but you should only carry the terms you actually query on. The full analysis of what each term does and doesn't leak is in [Searchable encryption](/concepts/searchable-encryption). diff --git a/content/docs/reference/eql/dates-and-times.mdx b/content/docs/reference/eql/dates-and-times.mdx new file mode 100644 index 0000000..c8ffebf --- /dev/null +++ b/content/docs/reference/eql/dates-and-times.mdx @@ -0,0 +1,115 @@ +--- +title: Dates & times +description: "The complete reference for encrypted date and timestamp columns: the domain variants, the payload they carry, and time-window, newest-first, and MIN/MAX queries." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.0" +--- + + + +`date` and `timestamp` columns carry the same capabilities as [encrypted numbers](/reference/eql/numbers) — equality, ranges, ordering, `MIN` / `MAX` — but the queries they serve are temporal: time windows, newest-first listings, retention cutoffs, "when did this last happen". + +## Variants + +Both types generate the same `jsonb`-backed domain variants. The generic form: + +| Domain variant | Capability | +| --- | --- | +| `public.eql_v3_` | Storage and decryption only. | +| `public.eql_v3__eq` | Equality: `=`, `<>`, `IN`, `GROUP BY`, `DISTINCT`, equijoins. | +| `public.eql_v3__ord` | Comparisons, `BETWEEN`, `ORDER BY`, `MIN` / `MAX` — plus equality. | +| `public.eql_v3__ord_ope` | The byte-identical twin of `_ord`, with OPE pinned. See [SEM specifiers](#sem-specifiers). | +| `public.eql_v3__ord_ore` | As `_ord`, with the ORE mechanism pinned. | + +`` is the encrypted type name from the table below, so `public.eql_v3__ord` is `public.eql_v3_timestamp_ord` for `timestamp`. + +And every concrete domain this page covers: + +| Type | Variants | +| --- | --- | +| `date` | `public.eql_v3_date` · `public.eql_v3_date_eq` · `public.eql_v3_date_ord` · `public.eql_v3_date_ord_ope` · `public.eql_v3_date_ord_ore` | +| `timestamp` | `public.eql_v3_timestamp` · `public.eql_v3_timestamp_eq` · `public.eql_v3_timestamp_ord` · `public.eql_v3_timestamp_ord_ope` · `public.eql_v3_timestamp_ord_ore` | + +Time columns are nearly always ranged and sorted, so `_ord` is the usual choice. Declare only the capability you query on — each capability stores extra searchable material with defined leakage (see [Searchable encryption](/concepts/searchable-encryption)), and the variant model itself is covered in [Core concepts](/reference/eql/core-concepts). + +### Example + +An audit-events table where the timestamps drive time-window queries and sorting: + +```sql +CREATE TABLE audit_events ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + occurred_at public.eql_v3_timestamp_ord, -- time windows, newest-first, MIN/MAX + review_due public.eql_v3_date_ord, -- range filters + sealed_on public.eql_v3_date -- store and decrypt only +); +``` + +### SEM specifiers + +Both types take the same mechanism specifiers on their orderable variant (the concept is defined in [Core concepts](/reference/eql/core-concepts#sem-specifiers)): + +| Specifier | Mechanism | Ordering term | Extractor | +| --- | --- | --- | --- | +| `_ord` | The default, currently CLLW OPE | `op` | `eql_v3.ord_term` | +| `_ord_ope` | CLLW OPE, pinned explicitly | `op` | `eql_v3.ord_term` | +| `_ord_ore` | Block-ORE, pinned explicitly | `ob` | `eql_v3.ord_term_ore` | + +`_ord` and `_ord_ope` are byte-identical today. Pin `_ord_ope` when you want a column's mechanism frozen against a future change of the default. + + +Block-ORE terms sort only under a custom btree operator class, and creating one requires superuser. Where the EQL installer runs as a non-superuser, which is the case on most managed Postgres including cloud Supabase, it cannot create the class, so it **disables every ORE-backed domain** rather than let them install half-working. `_ord_ore` then raises `feature_not_supported` on the first value written to it. Use `_ord` there. + + +## Payload + +A value for an `_ord` column carries the shared envelope keys (`v`, `i`, `c` — see [Core concepts](/reference/eql/core-concepts)) plus the `op` ordering term. Here is a payload for the `public.eql_v3_timestamp_ord` `occurred_at` column: + +```json +{ + "v": 3, + "i": { "t": "audit_events", "c": "occurred_at" }, + "c": "mBbKmsMM%bK#QQOx1yLDBHyD...", + "op": "5f2b1a9e4c07d38b6ea15c92..." +} +``` + +- **`op` is the only index term.** It is a hex-encoded CLLW OPE ciphertext, which Postgres sorts by native `bytea` comparison. An `_ord` payload carries no `hm`, because ordering over a date or timestamp is equality-lossless: `=` and `<>` resolve against the same term. +- **An `_ord_ore` payload carries `ob` in place of `op`**: an array of block-ORE ciphertexts, 12 blocks for a `timestamp`. + +## Operators + +| SQL operator | `public.eql_v3_` | `_eq` | `_ord` variants | +| --- | :---: | :---: | :---: | +| `=` / `<>` | ❌ | ✅ | ✅ | +| `<` `<=` `>` `>=` | ❌ | ❌ | ✅ | +| `BETWEEN` (desugars to `>=` and `<=`) | ❌ | ❌ | ✅ | +| `IN` (desugars to `=`) | ❌ | ✅ | ✅ | +| `GROUP BY` / `DISTINCT` | ❌ | ✅ | ✅ | +| `ORDER BY` | ❌ | ❌ | ✅ | +| `IS NULL` / `IS NOT NULL` | ✅ | ✅ | ✅ | + +Blocked *operator* cells raise an `operator … is not supported` exception — they never silently return wrong rows. `ORDER BY` is the one blocked cell that doesn't raise: it isn't an operator, so sorting a variant without an ordering term runs — but the order is meaningless (see [Sorting](/reference/eql/sorting)). Operands must be typed (`$1::public.eql_v3_timestamp_ord`), or PostgreSQL resolves the native `jsonb` operator instead of the encrypted one. Both rules are covered in [Core concepts](/reference/eql/core-concepts). + +## Functions + +content/partials/eql/functions-dates-and-times.mdx + +## Where to next + + + + The same capabilities on int, float, and numeric columns. + + + Btree recipes on `eql_v3.ord_term` for range, ORDER BY, and MIN/MAX. + + + Why the extractor-form sort key matters, and how to verify with EXPLAIN. + + + WHERE-clause patterns across all encrypted types. + + diff --git a/content/docs/reference/eql/filtering.mdx b/content/docs/reference/eql/filtering.mdx new file mode 100644 index 0000000..b7f282b --- /dev/null +++ b/content/docs/reference/eql/filtering.mdx @@ -0,0 +1,125 @@ +--- +title: Filtering +description: "WHERE-clause patterns on encrypted columns: equality, IN lists, ranges and BETWEEN, text token matching, JSON containment, and combining encrypted and plaintext predicates." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.3" +--- + + + +Every filter below is ordinary SQL — the encrypted operators resolve from the column's domain variant, and a functional index on the matching term extractor serves the predicate. One rule applies throughout: **operands must be typed** (`$1::public.eql_v3_text_eq`, not a bare literal), or PostgreSQL falls through to native `jsonb` semantics. See [Core concepts](/reference/eql/core-concepts) for the typed-operand rule and how unsupported operators fail loudly instead of returning wrong rows. + +## Equality: `=` and `<>` + +Works on `_eq` and every `_ord` variant of every scalar, and on `text_search`: + +```sql +SELECT * FROM users WHERE email = $1::public.eql_v3_text_eq; +SELECT * FROM users WHERE tax_id <> $1::public.eql_v3_text_eq; +``` + +On `_eq` columns, and on every text variant, equality compares the HMAC (`hm`) term. On the `_ord` variants of the non-text scalars there is no `hm`: equality compares the ordering term instead, which is lossless over those domains, so those columns get `=` and `<>` for free. See [Core concepts](/reference/eql/core-concepts) for the mechanism. + +```sql +-- salary is public.eql_v3_bigint_ord: equality works without an hm term +SELECT * FROM users WHERE salary = $1::public.eql_v3_bigint_ord; +``` + +Bare storage-only variants (`public.eql_v3_text`, `public.eql_v3_integer`, …) block every comparison — see the type pages for what each variant supports: [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), [Text](/reference/eql/text), [Booleans](/reference/eql/booleans). + +## `IN` lists + +`IN` desugars to `=`, so it needs the same equality-capable variants. Each list element is a separately encrypted, typed operand: + +```sql +SELECT * FROM users +WHERE email IN ($1::public.eql_v3_text_eq, $2::public.eql_v3_text_eq, $3::public.eql_v3_text_eq); +``` + +There is no way to encrypt a list as one value — the client encrypts each element and binds it as its own parameter. `IN (subquery)` also works, subject to the same-keyset rule covered in [Joins](/reference/eql/joins). + +## Ranges and `BETWEEN` + +`<`, `<=`, `>`, `>=` work on `_ord` / `_ord_ope` variants and `text_search`, which carry an OPE (`op`) term, and on the pinned `_ord_ore` / `text_search_ore` variants, which carry a block-ORE (`ob`) term: + +```sql +SELECT * FROM users WHERE salary >= $1::public.eql_v3_bigint_ord; + +-- BETWEEN desugars to >= and <= +SELECT * FROM users +WHERE created_at BETWEEN $1::public.eql_v3_timestamp_ord AND $2::public.eql_v3_timestamp_ord; +``` + +Half-open ranges compose the same way: + +```sql +SELECT * FROM events +WHERE occurred_at >= $1::public.eql_v3_timestamp_ord + AND occurred_at < $2::public.eql_v3_timestamp_ord; +``` + +## Text fuzzy matching: `@@` + +There is no `LIKE` on encrypted columns — encrypted free-text matching is bloom-filter fuzzy match via `@@` on a `text_match` or `text_search` column: + +```sql +SELECT * FROM users WHERE name @@ $1::public.eql_v3_text_match; +``` + +The client encrypts the search term into a bloom-filter query value; matching is probabilistic (false positives possible, false negatives not). `@>` / `<@` raise on these domains — they were the old spelling of this match and are now blockers. For the full no-`LIKE` story, short-term behaviour, and match-term tuning, see [Text](/reference/eql/text). + +## JSON containment and path filters + +Encrypted JSON documents (`public.eql_v3_json_search`) filter by containment and path existence: + +```sql +-- Does the document contain this (encrypted) structure? +SELECT * FROM orders WHERE metadata @> $1::eql_v3.query_json; + +-- Does this path exist in the document? +SELECT * FROM orders WHERE eql_v3.jsonb_path_exists(metadata, 'region_selector'); + +-- Exact match on a field: containment on a value selector, built by the client +SELECT * FROM orders WHERE metadata @> $1::eql_v3.query_json; +``` + +Field access is by selector hash, not plaintext path — and exact field matching is containment on a **value selector**, not `=` on an extracted leaf, which raises. The full JSON surface — containment, field access, path queries, and range filters on extracted leaves — is in [JSON](/reference/eql/json). + +## Combining predicates + +Encrypted predicates compose with `AND`, `OR`, `NOT`, and parentheses like any other predicate — and plaintext columns filter normally alongside encrypted ones in the same `WHERE` clause: + +```sql +SELECT * FROM users +WHERE status = 'active' -- plaintext column, native operator + AND created_at >= $1::public.eql_v3_timestamp_ord -- encrypted range + AND (email = $2::public.eql_v3_text_eq -- encrypted equality + OR name @@ $3::public.eql_v3_text_match); -- encrypted fuzzy match +``` + +The planner treats each encrypted predicate independently, so it can combine an index on a plaintext column with a functional index on an encrypted one (bitmap-AND, or whichever plan is cheapest). + +## `IS NULL` and `IS NOT NULL` + +A SQL `NULL` column value is never encrypted — there is no payload to encrypt — so null checks work on **every** variant, including storage-only ones: + +```sql +SELECT * FROM users WHERE tax_id IS NULL; +SELECT * FROM users WHERE tax_id IS NOT NULL; +``` + +Don't confuse this with a JSON `null` *inside* an encrypted document, which is an encrypted value like any other — see [JSON](/reference/eql/json). + +## Shape summary + +| Filter shape | Operators | Works on | Index | +| --- | --- | --- | --- | +| Equality | `=` `<>` `IN` | `_eq`, all `_ord` variants, `text_search` variants | hash (or btree) on `eql_v3.eq_term` — btree on `eql_v3.ord_term` for the non-text `_ord` variants | +| Range | `<` `<=` `>` `>=` `BETWEEN` | all `_ord` variants, `text_search` variants | btree on `eql_v3.ord_term` (`eql_v3.ord_term_ore` on `_ord_ore`) | +| Text fuzzy match | `@@` | `text_match`, `text_search` variants | GIN on `eql_v3.match_term` | +| JSON containment | `@>` `<@` | `public.eql_v3_json_search` | GIN on `eql_v3.to_ste_vec_query(col)::jsonb` | +| Null check | `IS NULL` / `IS NOT NULL` | every variant | — | + +Every one of these has a full index recipe — which method, which extractor, and how to confirm the index engages with `EXPLAIN` — in [Indexes](/reference/eql/indexes). diff --git a/content/docs/reference/eql/functions.mdx b/content/docs/reference/eql/functions.mdx new file mode 100644 index 0000000..22cf3c0 --- /dev/null +++ b/content/docs/reference/eql/functions.mdx @@ -0,0 +1,72 @@ +--- +title: Functions +description: "Generated catalog of EQL SQL functions and operators (EQL 3.0.0-sample)." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.0-sample" +--- + +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest (v3.0.0-sample). */} + + + + +Generated from the **EQL 3.0.0-sample** manifest (the Doxygen'd SQL is the source of truth). For the model behind these — variants, terms, typed operands — see [Core concepts](/reference/eql/core-concepts). + + +The EQL SQL surface — encrypted domains (in the `public` schema) and the `eql_v3` functions behind them. The type and query pages explain *when* to use these; this page is the exhaustive reference they link to. + +## Encrypted domains + +A column's capability is declared by its **domain variant**. This matrix comes straight from the Rust catalog (`eql-codegen dump-catalog`) — the source of truth the SQL is generated from. See [Core concepts](/reference/eql/core-concepts) for the model. + +| Domain | Type | Variant | Capabilities | Operators | +| --- | --- | --- | --- | --- | +| `public.integer` | integer | _(storage only)_ | storage | — | +| `public.integer_eq` | integer | `_eq` | equality | `=` `<>` | +| `public.integer_ord` | integer | `_ord` | equality, order | `=` `<>` `<` `<=` `>` `>=` | +| `public.integer_ord_ope` | integer | `_ord_ope` | equality, order | `=` `<>` `<` `<=` `>` `>=` | +| `public.text_match` | text | `_match` | match | `@>` `<@` | +| `public.text_search` | text | `_search` | equality, order, match | `=` `<>` `<` `<=` `>` `>=` `@>` `<@` | +| `public.json` | jsonb | _(storage only)_ | json | — | +| `public.jsonb_entry` | jsonb | _(storage only)_ | json | — | +| `public.jsonb_query` | jsonb | _(storage only)_ | json | — | + +## Functions + +The public `eql_v3` API — 2 functions (2 overloads). Internal `eql_v3_internal` functions (3) are implementation detail and omitted. + +### `jsonb_path_query` + +Query encrypted JSONB for sv elements matching a selector. + +Returns one jsonb_entry row per matching encrypted element. + +**Signature:** + +```sql +jsonb_path_query(jsonb, text) +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `val` | `jsonb` | encrypted EQL payload with sv | +| `selector` | `text` | selector hash | + + +**Returns:** `public.jsonb_entry` — matching encrypted entries + +### `version` + +Get the installed EQL version string. + +Returns the version string for the installed EQL library. + +**Signature:** + +```sql +version() +``` + +**Returns:** `text` — the version string diff --git a/content/docs/reference/eql/grouping-and-aggregates.mdx b/content/docs/reference/eql/grouping-and-aggregates.mdx new file mode 100644 index 0000000..809a689 --- /dev/null +++ b/content/docs/reference/eql/grouping-and-aggregates.mdx @@ -0,0 +1,155 @@ +--- +title: Grouping & aggregates +description: "GROUP BY on the equality term, eql_v3.grouped_value, DISTINCT ON, COUNT, and eql_v3.min/max on encrypted columns — and why SUM and AVG stay client-side." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.3" +--- + + + +Grouping and deduplication key on the **equality term**, never on the stored ciphertext, so they work on the same variants as `=`: `_eq`, every `_ord` variant, and the `text_search` variants. `MIN` / `MAX` need an ordering term (any `_ord` variant, or `text_search`). Arithmetic aggregates don't work at all — that's the last section. As everywhere, operands and call-site casts must be typed; see [Core concepts](/reference/eql/core-concepts). + +## `GROUP BY` and `DISTINCT` + +Encryption is non-deterministic: the same plaintext encrypts to a different ciphertext every time. An encrypted column is a domain over `jsonb`, and Postgres compares a domain using the *base type's* comparison — so the natural forms compare whole encrypted payloads, never the plaintext behind them. They raise no error; they just answer wrongly. + + + +```sql +SELECT email, COUNT(*) FROM logins GROUP BY email; -- one group per row +SELECT DISTINCT email FROM logins; -- every row comes back +``` + + + +Key on the equality term instead. It's a deterministic keyed hash, identical for equal plaintexts: + +```sql +SELECT COUNT(*) + FROM logins + GROUP BY eql_v3.eq_term(email); + +SELECT DISTINCT ON (eql_v3.eq_term(email)) email + FROM logins + ORDER BY eql_v3.eq_term(email); +``` + +Plain `DISTINCT` has nowhere to put the term, so deduplication is `DISTINCT ON`. Postgres requires the leading `ORDER BY` expression to match the `DISTINCT ON` expression, and the row that survives each group is the first one in that order — add further sort keys after it to choose which. + +The term is an HMAC — opaque and one-way — so there's rarely a reason to select it. `DISTINCT ON` puts no restriction on the projection, so the encrypted column comes back alongside it for the client to decrypt; under `GROUP BY` that takes [`eql_v3.grouped_value`](#grouped-value). + +Keying on the extractor is also what makes the query fast. `GROUP BY email` would use the entire encrypted payload — 1–2 KB per row — as the hash key: Postgres estimates a hash table far larger than the default `work_mem` and falls back to a disk-spilling `GroupAggregate`. The extractor key is small, so the hash table fits in `work_mem` and the planner picks `HashAggregate` reliably. The same reasoning, from the index-tuning angle, is in [Indexes](/reference/eql/indexes). + +## `eql_v3.grouped_value` [#grouped-value] + +Grouping by the term costs you the column: Postgres rejects a bare reference to it, because it can't prove the column is constant within each group. + + + +```sql +SELECT email, COUNT(*) + FROM logins + GROUP BY eql_v3.eq_term(email); +``` + + + +Every row in a group encrypts the same plaintext, so any one of them represents the group. `eql_v3.grouped_value` is the aggregate that hands one back: + +```sql +SELECT eql_v3.grouped_value(email) AS email, COUNT(*) + FROM logins + GROUP BY eql_v3.eq_term(email); +``` + +What comes back is a real encrypted payload, which the client decrypts as usual — unlike the group key itself, which is a term and can never be decrypted. + +```sql +eql_v3.grouped_value(jsonb) RETURNS jsonb +``` + +One aggregate covers every encrypted type: all the domains are jsonb-backed, and `grouped_value` returns its input unchanged — it never decrypts, inspects, or compares the value. Three details worth knowing: + +- It returns the **first non-`NULL`** value in each group. Which value is "first" is arbitrary without an intra-aggregate `ORDER BY`, and not deterministic under parallel aggregation — that's fine here, because every member of the group is an encryption of the same plaintext. +- A group of all-`NULL` values aggregates to `NULL`, matching native aggregate semantics. +- It's `PARALLEL SAFE` with a combine function, so it survives partial aggregation on large `GROUP BY` workloads. + +Deduplicating without a per-group aggregate needs none of this — that's `DISTINCT ON`, above. + +## `COUNT` and `COUNT(DISTINCT)` + +Plain `COUNT(col)` counts non-`NULL` rows — it never compares values, so it works on **any** variant, including storage-only ones: + +```sql +SELECT COUNT(tax_id) FROM users; -- works even on bare public.eql_v3_text +``` + +`COUNT(DISTINCT col)` deduplicates, so it has the ciphertext problem too: on the raw column it counts distinct ciphertexts, which is just the row count. Count distinct terms instead: + +```sql +SELECT COUNT(DISTINCT eql_v3.eq_term(email)) FROM logins; +``` + +## `MIN` and `MAX`: `eql_v3.min` / `eql_v3.max` + +EQL ships `min` / `max` aggregates per ord-capable variant of every scalar type. The input type selects the aggregate, and the return type matches the input: + +```sql +eql_v3.min(public.eql_v3__ord) RETURNS public.eql_v3__ord +eql_v3.max(public.eql_v3__ord) RETURNS public.eql_v3__ord +eql_v3.min(public.eql_v3__ord_ope) RETURNS public.eql_v3__ord_ope +eql_v3.max(public.eql_v3__ord_ope) RETURNS public.eql_v3__ord_ope +eql_v3.min(public.eql_v3__ord_ore) RETURNS public.eql_v3__ord_ore +eql_v3.max(public.eql_v3__ord_ore) RETURNS public.eql_v3__ord_ore +``` + +Comparison routes through the variant's `<` / `>` operator on the ordering term — no decryption happens in the database, and the result is an encrypted value the client decrypts. `NULL` inputs are skipped; an all-`NULL` input set returns `NULL`, matching native aggregate semantics. + +```sql +SELECT eql_v3.min(salary) FROM users; +SELECT eql_v3.max(salary) FROM users WHERE department = 'engineering'; + +-- Combined with grouping: grouped_value returns the department code encrypted, +-- so the client can decrypt it to label the row. +SELECT eql_v3.grouped_value(department_code) AS department_code, + eql_v3.max(salary) + FROM users + GROUP BY eql_v3.eq_term(department_code); +``` + +If the column is generic `jsonb` rather than a domain, cast to the right variant at the call site so overload resolution can pick the aggregate: + +```sql +SELECT eql_v3.min(salary_jsonb::public.eql_v3_bigint_ord) FROM users; +``` + +A btree on `eql_v3.ord_term(col)` serves `MIN` / `MAX` — the [Indexes](/reference/eql/indexes) page has the recipe. + +## No `SUM`, no `AVG` + + +**`SUM`, `AVG`, and every other arithmetic aggregate are unsupported** on encrypted columns — they would require homomorphic encryption, which EQL does not do. `MIN` / `MAX` work because they only need *comparison*, which the ordering term provides. For sums and averages, select the rows (or `MIN`/`MAX`/`COUNT` server-side to narrow them) and aggregate client-side after decryption. + + +## Leaves inside encrypted JSON + +Leaves extracted from an encrypted JSON document (`metadata -> 'region_selector'::text`, a `public.eql_v3_json_entry`) **cannot be grouped by equality**. Entries stopped carrying an equality term in EQL 3.0.1: exact matching on a JSON field became presence of a value selector, which containment answers as a filter — there is no per-field key to hash rows into groups with. + +What still works on an extracted leaf is ordering: `eql_v3.min` / `eql_v3.max` on String and Number leaves, via their CLLW OPE term. + +```sql +SELECT eql_v3.min(metadata -> 'total_selector'::text) FROM orders; +``` + +To group by a field, promote it to its own encrypted column — a `text_eq` (or any `_ord`) column alongside the document — and group on that column's equality term as above. Selectors, value selectors, and node capabilities are in [JSON](/reference/eql/json). + +## Where to go next + +- [Indexes](/reference/eql/indexes) — the hash/btree recipes that back these shapes, and the full `work_mem` / `HashAggregate` story. +- [Joins](/reference/eql/joins) — equality terms across tables, and the same-keyset rule. +- [Filtering](/reference/eql/filtering) — the `WHERE` shapes that feed these aggregates. diff --git a/content/docs/reference/eql/index.mdx b/content/docs/reference/eql/index.mdx new file mode 100644 index 0000000..0b1bebe --- /dev/null +++ b/content/docs/reference/eql/index.mdx @@ -0,0 +1,201 @@ +--- +title: EQL +navTitle: Overview +description: "Encrypt Query Language (EQL) installs encrypted column types and operators into Postgres as plain SQL — encryption itself happens in your client." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.4" +--- + + + +Encrypt Query Language (EQL) is a set of types, operators, and functions for storing and querying encrypted data in PostgreSQL. The [Stash CLI](/reference/cli) installs it over an ordinary database connection — no extension packaging, no superuser, no operator classes — so it runs on Supabase, RDS, Cloud SQL, and self-hosted Postgres alike. + +EQL itself never encrypts anything. Encryption and decryption happen in the client, using the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy). EQL provides the database-side surface those clients query against: encrypted column types, the operators that compare them, and the term-extractor functions that make indexes work. + +Every encrypted column is a `jsonb`-backed domain type in the `public` schema (the functions and operators behind them live in `eql_v3`), and the domain variant you choose declares what the column can do — the full model is in [Core concepts](/reference/eql/core-concepts). + +## Install + + + + +### Run the installer + +Point the [Stash CLI](/reference/cli) at your database and install the `eql_v3` schema: + +```bash +npx stash eql install +``` + +`stash eql install` scaffolds a [`stash.config.ts`](#configuration) if your project doesn't have one, then installs the `eql_v3` schema — all domain types, operators, functions, and aggregates — along with the `cs_migrations` tracking schema that the `stash encrypt` commands depend on. It is idempotent: re-running reports that EQL is already installed and changes nothing. Pass `--force` to reinstall in place. + + +EQL v3 is the only installation target in `stash` 1.0.0, so no version flag is required. The [EQL v2 reference](/reference/eql/v2) remains available for applications maintaining an existing v2 deployment. + + + + + +### Point it at each database + +The CLI reads the connection string from `DATABASE_URL` (your environment or a `.env` file) and prompts if it can't find one. Override it for a single run — handy when installing across several databases — with `--database-url`, which is used for that run only and never written to disk: + +```bash +npx stash eql install \ + --database-url postgres://user:pass@host:5432/mydb +``` + +Run the installer against every database that stores encrypted columns. + + + + +### Verify + +```bash +npx stash eql status +``` + +Or check directly from SQL: + +```sql +SELECT eql_v3.version(); +-- '3.0.4' +``` + + + + +To pull in a newer EQL release later, run `npx stash eql upgrade`. + + +To uninstall, drop both EQL schemas: `DROP SCHEMA eql_v3, eql_v3_internal CASCADE`. This removes the encrypted operators and functions, so queries against encrypted columns stop resolving — but your data survives: the `public.*` domain types (and the columns typed as them) live in the `public` schema and are left untouched. Remove those separately only if you intend to drop or re-type the columns. + + +### Configuration + +`stash eql install` reads a `stash.config.ts` at your project root, scaffolding one on first run if it's missing. The same file is used by the `stash db` and `stash encrypt` commands. It exports a `defineConfig` object with two fields: + +| Field | Required | Description | +| --- | --- | --- | +| `databaseUrl` | Yes | PostgreSQL connection string. The scaffold sets it to `await resolveDatabaseUrl()`, which resolves from the `--database-url` flag, then `DATABASE_URL` (environment or `.env`), then `supabase status`, then an interactive prompt. The resolved connection string is never written to disk — only the declarative call is. | +| `client` | No | Path to your encryption client file. Defaults to `./src/encryption/index.ts`. | + +```ts filename="stash.config.ts" +import { defineConfig, resolveDatabaseUrl } from "stash"; + +export default defineConfig({ + databaseUrl: await resolveDatabaseUrl(), + client: "./src/encryption/index.ts", +}); +``` + +### Drizzle migration history + +For a Drizzle project, generate a custom migration instead of installing EQL directly: + +```bash +npx stash eql migration --drizzle +npx drizzle-kit migrate +``` + +The command runs your project-local `drizzle-kit`, creates a custom migration, and writes the same EQL v3 SQL that `stash eql install` applies directly. Add `--supabase` when the database is also reached through Supabase PostgREST, and use `--out` when your Drizzle migration directory is not `drizzle/`. + +If `drizzle-kit generate` has already emitted an in-place `ALTER COLUMN ... SET DATA TYPE` for an encrypted domain, repair the unapplied migration before running it: + +```bash +npx stash eql repair --drizzle --database-url "$DATABASE_URL" +``` + +See the [`eql migration`](/reference/cli/eql#eql-migration) and [`eql repair`](/reference/cli/eql#eql-repair) references for all options. Prisma ORM 8 installs EQL through its own migration graph and does not use either command. + +### dbdev + +EQL is also published to [dbdev](https://database.dev/cipherstash/eql). The dbdev release can lag behind GitHub releases, so prefer `stash eql install` when you need the latest version. + +### Docker for local development + +Run a Postgres image with EQL pre-installed: + +```sh +docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=postgres \ + ghcr.io/cipherstash/postgres-eql:17 +``` + +EQL installs automatically on first boot. Images are available for PostgreSQL 14–17 (`:14` through `:17`), and you can pin a specific EQL version with a suffixed tag (for example `:17-3.0.4`). + +## Permissions + +Installing EQL and running queries against it need different privileges. A common production pattern splits them across two users. + +**Migration user** — installs EQL and adds encrypted columns during migrations: + +```sql +GRANT CREATE ON DATABASE your_database TO your_migration_user; +GRANT CREATE ON SCHEMA public TO your_migration_user; +GRANT ALTER ON ALL TABLES IN SCHEMA public TO your_migration_user; +``` + +`CREATE ON DATABASE` lets EQL create its schemas (`eql_v3`, `eql_v3_internal`) and the `public.*` domain types; `CREATE ON SCHEMA` and `ALTER` are needed to add encrypted columns (typed as `public.*` domains, with their `CHECK` constraints) to your tables. + +**Runtime user** — the application's day-to-day access: + +```sql +-- EQL schema usage (resolves the encrypted operators / extractors) +GRANT USAGE ON SCHEMA eql_v3 TO your_app_user; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO your_app_user; + +-- User table access (normal application permissions) +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE your_tables TO your_app_user; +``` + +Schema changes — adding or removing encrypted columns — always go through the migration user. + +## Managed Postgres and Supabase + +EQL v3 is designed to install without superuser. There are no custom operator classes (which managed platforms typically block), no `postgresql.conf` changes, and no separate Supabase build — the same schema installs everywhere. When the connected role lacks superuser, `stash eql install` detects it and automatically falls back to the no-operator-family install variant, so it works on Supabase, Neon, and RDS without extra flags. Indexing works through ordinary functional indexes over EQL's term-extractor functions, which any user who can `CREATE INDEX` can build. See the [Supabase integration](/integrations/supabase) for platform-specific setup. + +## Understand + + + + Domain variants, the encrypted payload, typed operands, and fail-loud blockers — the model every other page assumes. + + + Encrypted integers, floats, and numerics. + + + Encrypted dates and timestamps: time windows, newest-first, retention cutoffs. + + + Encrypted text: equality, ordering, and free-text token matching — and why there is no `LIKE`. + + + Encrypted JSON documents: containment, field access, and GIN indexing. + + + Storage-only by design: why encrypted booleans carry no index terms. + + + Functional-index recipes over the term extractors, and what it takes for an index to engage. + + + +## Use + + + + `WHERE` clauses on encrypted columns: equality, ranges, and text containment. + + + `ORDER BY` on encrypted columns, and how to keep the sort in the index. + + + `GROUP BY` on the equality term, `DISTINCT ON`, `COUNT`, and the `MIN` / `MAX` aggregates. + + + Equijoins on encrypted columns and the same-keyset rule. + + diff --git a/content/docs/reference/eql/indexes.mdx b/content/docs/reference/eql/indexes.mdx new file mode 100644 index 0000000..f0fab93 --- /dev/null +++ b/content/docs/reference/eql/indexes.mdx @@ -0,0 +1,207 @@ +--- +title: Indexes +description: "Create Postgres indexes on encrypted columns using functional indexes over EQL's term-extractor functions." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.3" +--- + + + +EQL indexes are ordinary PostgreSQL functional indexes over **term-extractor functions** — never an index or operator class on the column itself. Each extractor returns a small per-row index term whose return type already carries a default operator class: + +| Extractor | Index method | Term | Capability | +| --- | --- | --- | --- | +| `eql_v3.eq_term(col)` | `hash` (or `btree`) | `hm` (HMAC-256) | equality | +| `eql_v3.ord_term(col)` | `btree` | `op` (CLLW OPE) | range, `ORDER BY`, `MIN` / `MAX` | +| `eql_v3.ord_term_ore(col)` | `btree` | `ob` (ORE block) | the same, on pinned `_ord_ore` / `text_search_ore` columns | +| `eql_v3.match_term(col)` | `gin` | `bf` (bloom filter) | text fuzzy match (`@@`) | + +The extractors are inlinable SQL functions, so the planner rewrites a bare-form predicate into the same expression the index was built on. You don't rewrite queries to use the index: + +```sql +SELECT * FROM users WHERE email = $1::public.eql_v3_text_eq; +-- planner inlines `=` to: eql_v3.eq_term(email) = eql_v3.eq_term($1) +-- Index Cond on USING hash (eql_v3.eq_term(email)) +``` + + +EQL v3 deliberately ships no operator class for encrypted columns. Operators resolve against the domain's `jsonb` base type, so an opclass on the column would bypass the encrypted surface. Always index through the extractor. + + +## Index recipes + +Type the column as the domain variant that carries the term (see [Core concepts](/reference/eql/core-concepts) for the variant model, and the per-type pages for specifics), then index the matching extractor: + +```sql +-- Equality: hash index on eq_term +-- (columns typed public.eql_v3__eq, any text variant, or text_search; equality on +-- the non-text _ord columns compares ordering terms, so the btree below serves it) +CREATE INDEX users_email_eq + ON users USING hash (eql_v3.eq_term(email)); + +-- Range / ordering: btree index on ord_term +-- (columns typed public.eql_v3__ord, _ord_ope, or text_search) +CREATE INDEX users_created_at_ord + ON users USING btree (eql_v3.ord_term(created_at)); + +-- Range / ordering on a pinned block-ORE column: btree on ord_term_ore +-- (columns typed public.eql_v3__ord_ore or text_search_ore) +CREATE INDEX users_created_at_ord_ore + ON users USING btree (eql_v3.ord_term_ore(created_at_ore)); + +-- Text fuzzy match (`@@`): GIN index on match_term +-- (columns typed public.eql_v3_text_match or text_search) +CREATE INDEX users_name_match + ON users USING gin (eql_v3.match_term(name)); + +ANALYZE users; +``` + +Run `ANALYZE` after every index build. `CREATE INDEX` on an expression gathers no statistics for that expression — without `ANALYZE`, the planner has no histogram for `eql_v3.eq_term(email)` and can misjudge the index it just built. + +Create indexes when the table has a significant number of rows (typically more than 1,000) and you query the column with the matching operator. Drop indexes for capabilities you no longer query — duplicate indexes compete for cache and slow writes. + +## Requirements for an index to engage + +All three must hold: + +1. **The value carries the required term.** Range needs `op` (or `ob` on the pinned block-ORE variants), fuzzy match needs `bf`, and equality needs `hm`, except on the non-text `_ord` variants, where equality resolves against the ordering term instead. Which terms travel in a value's payload is decided by the encryption client — a value with only a bloom term will not drive an equality index. +2. **The index was built after the data carried the term.** If you change which terms a column's values carry, recreate the index. +3. **The query operand is typed.** A typed parameter (`$1`, which CipherStash Proxy supplies) or an explicit cast resolves the encrypted operator; a bare `jsonb` literal falls through to native `jsonb` semantics and skips the index entirely: + +```sql +-- ✓ resolves the encrypted operator → uses the index +WHERE email = $1::public.eql_v3_text_eq; +WHERE email = $1; -- only when the client (Stack SDK / Proxy) binds $1 typed + +-- ✗ falls through to native jsonb semantics +WHERE email = '{"hm":"abc"}'::jsonb; +``` + +## Query shapes + +### Equality + +```sql +SELECT * FROM users WHERE email = $1::public.eql_v3_text_eq; +-- Index Scan using users_email_eq +-- Index Cond: (eql_v3.eq_term(email) = eql_v3.eq_term($1)) +``` + +### Fuzzy match + +`col @@ $1` inlines to bloom array containment over `eql_v3.match_term(col)`, so the GIN index engages: + +```sql +SELECT * FROM users WHERE name @@ $1::public.eql_v3_text_match; +-- Bitmap Index Scan on users_name_match +``` + + +**Supply the needle as a bind parameter or a literal.** Since EQL 3.0.3 the match carries an empty-needle guard (see [Text](/reference/eql/text)), which makes `eql_v3.matches` non-`STRICT` and references the needle twice. A needle supplied as an *uncorrelated subquery* — `WHERE name @@ (SELECT …)` — therefore no longer inlines, and the query falls back to a sequential scan. The result is still correct; only the index acceleration is lost. + + +### Range and ORDER BY + +The `<`, `<=`, `>`, `>=` operators inline to comparisons on `eql_v3.ord_term`, so natural-form range predicates match the btree: + +```sql +SELECT * FROM users WHERE created_at < $1::public.eql_v3_timestamp_ord; +``` + +`ORDER BY` needs care. The planner inlines operators in *predicates* but does not rewrite *sort keys*: `ORDER BY created_at` uses the index for the `WHERE` clause but still adds a `Sort` node, which scales linearly with the rows passing the filter. To stream rows out of the btree already ordered, write the sort key in extractor form: + +```sql +SELECT * FROM users + WHERE created_at < $1::public.eql_v3_timestamp_ord + ORDER BY eql_v3.ord_term(created_at) DESC + LIMIT 10; +``` + +ORE terms are order-preserving, so this sorts identically to the natural form — it just lets the index do the ordering. At large row counts this is the difference between seconds and milliseconds. + + +If you `SELECT col::jsonb ... ORDER BY col`, Postgres folds the cast into the scan and uses `(col)::jsonb` as the sort key — which matches no index. Project the column raw, or write the sort key as `eql_v3.ord_term(col)`, which sidesteps this entirely. + + +### GROUP BY and DISTINCT + +Group and deduplicate on the extractor, not the raw column: + +```sql +SELECT eql_v3.grouped_value(email) AS email, count(*) + FROM users + GROUP BY eql_v3.eq_term(email); + +SELECT DISTINCT ON (eql_v3.eq_term(email)) email + FROM users + ORDER BY eql_v3.eq_term(email); +``` + +The extractor form is a correctness requirement before it's a tuning one: encryption is non-deterministic and the column is a domain over `jsonb`, so `GROUP BY email` and `SELECT DISTINCT email` compare raw ciphertext and never collapse two encryptions of the same value. It's also the faster shape — `GROUP BY email` uses the entire encrypted payload (1–2 KB per row) as the hash key, so Postgres estimates a hash table far larger than the default `work_mem` and falls back to a disk-spilling `GroupAggregate`, while the small deterministic term fits in `work_mem` and gets a `HashAggregate`. Full treatment in [Grouping and aggregates](/reference/eql/grouping-and-aggregates). + +## Encrypted JSON + +Containment (`@>` / `<@`) on `public.eql_v3_json_search` document columns uses a GIN index over `eql_v3.to_ste_vec_query(col)::jsonb` — the same index that serves exact field matching, which is containment on a value selector. Field-level ordering has its own extractor recipe. See [JSON](/reference/eql/json). + +## Verify with EXPLAIN + +The first move on a slow query is `EXPLAIN (COSTS OFF)`: + +- **`Index Scan using `** — the functional index is engaged. +- **`Index Cond:` referencing the extractor** (`eql_v3.eq_term(...)`, `eql_v3.ord_term(...)`) — the inlined predicate matched the index. +- **`Seq Scan`** — no index used. Check the three requirements above. +- **`Filter:` showing the raw operator** — inlining did not happen. Usual causes: a pinned `search_path` on a customised function, or the planner judging another plan cheaper. +- **`Sort` node above an Index Scan** — natural-form `ORDER BY`. Switch the sort key to `eql_v3.ord_term(col)` to eliminate it. + +Once the plan looks right, repeat with `EXPLAIN ANALYZE` to measure actual timings. Compare estimated and actual row counts as well as the chosen scan; a large mismatch usually means the planner needs fresh statistics before the index definition itself is changed. + +## Building indexes on large tables + +Index *build* time is a separate axis from query time — a functional index that queries in a millisecond can take hours to `CREATE` on a large table. + +**Raise `maintenance_work_mem`.** `CREATE INDEX` draws on `maintenance_work_mem` (default 64 MB — far too small for a multi-million-row build). It's the single highest-leverage knob: + +```sql +SET maintenance_work_mem = '2GB'; +CREATE INDEX users_email_eq ON users USING btree (eql_v3.eq_term(email)); +``` + +**Prefer `btree` over `hash` for equality on large tables.** A btree build sorts then bulk-loads with sequential writes and can parallelise; a hash build scatters rows to random buckets and degrades to random I/O once the index outgrows cache — it cannot parallelise. A btree on `eql_v3.eq_term(col)` serves `=` exactly as well as a hash index, with no query-side cost. Hash is fine up to mid-six-figure row counts. + +**Expect a de-TOAST floor.** A functional index over a large encrypted column de-TOASTs the whole stored value once per row to evaluate the extractor. This cost is identical across access methods and sets the build's floor rate. Index builds are also I/O-heavy in a way queries are not — containerised Postgres on a virtualised filesystem (Docker Desktop on macOS, notably) pays a steep penalty, so run large builds on native storage. + +**Watch the build.** From a second session while `CREATE INDEX` runs: + +```sql +SELECT phase, tuples_done, tuples_total, + round(100.0 * tuples_done / nullif(tuples_total, 0), 1) AS pct +FROM pg_stat_progress_create_index; +``` + +A steady `tuples_done` rate is healthy. A rate that decays over time is the cache/memory wall — raise `maintenance_work_mem`, and if it's a hash index, rebuild it as a btree. + +## Why this works on managed Postgres + +Everything above is a functional index over an `IMMUTABLE` SQL function — no operator class on a column, no superuser, no `postgresql.conf` changes. Managed platforms that block custom operator classes (Supabase among them) run these recipes unchanged, so the indexing model is identical on Supabase, RDS, Cloud SQL, and self-hosted Postgres. See the [Supabase integration](/integrations/supabase). + +## Troubleshooting + +**Index not being used:** + +1. Verify the value carries the term: + + ```sql + SELECT email::jsonb ? 'hm' AS has_hmac, + email::jsonb ? 'ob' AS has_ore_block, + email::jsonb ? 'bf' AS has_bloom + FROM users LIMIT 1; + ``` + +2. Verify the operand is typed (`$1::public.eql_v3_text_eq`, not `$1::jsonb`). +3. Recreate the index if the column's terms changed after it was built. +4. Run `ANALYZE`. Very small tables may still choose a sequential scan — that's correct. + +**`=` returns zero rows on a populated column:** equality requires the term its variant compares — `hm` on `_eq` / `text_search`, `ob` on `_ord` variants. Type the column as an equality-capable variant and confirm the encryption client is emitting that term. diff --git a/content/docs/reference/eql/joins.mdx b/content/docs/reference/eql/joins.mdx new file mode 100644 index 0000000..583c4b4 --- /dev/null +++ b/content/docs/reference/eql/joins.mdx @@ -0,0 +1,114 @@ +--- +title: Joins +description: "Equijoins on encrypted columns: the same-keyset and matching-variant constraint, IN (subquery) and set operations, a worked example, and how to diagnose a join that returns nothing." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.0" +--- + + + +Equijoins work on equality-capable variants (`_eq`, every `_ord` variant, `text_search` variants) — the join condition is just encrypted equality. But there is one constraint that has no plaintext equivalent, and it is the single thing to internalize on this page: + + +**Both sides of the join must be encrypted with the same keyset and typed as a matching variant.** Encrypted equality compares deterministic index terms, and those terms are derived from the encryption keys. Two columns encrypted under different keysets produce different terms for the *same plaintext* — their terms can **never** match, and the join returns no rows. This is not an error the database can detect: the query is valid, the plan is fine, the result is simply empty. + + +"Matching variant" means both sides are the same domain, so the equality operator resolves and both sides compare the same term kind: `_eq` columns compare HMAC terms, and so does every text variant; the `_ord` variants of the non-text scalars compare ordering terms. An `_eq` column can't join an `_ord` column, because EQL defines its equality operators per domain and none exists between the two. See [Core concepts](/reference/eql/core-concepts) for the term model. + +## Equijoin + +```sql +SELECT u.*, o.total +FROM users u +JOIN orders o ON u.email = o.customer_email; +-- both columns public.eql_v3_text_eq, encrypted with the same keyset +``` + +No typed-operand cast is needed here — both operands are encrypted columns, so their domain types resolve the encrypted operator directly. All join types (`INNER`, `LEFT`, `RIGHT`, `FULL`) work; `LEFT JOIN` null-extension behaves normally because SQL `NULL`s are not encrypted. + +Index both sides for anything beyond small tables — a hash (or btree) index on `eql_v3.eq_term(col)` on each column. Recipes are in [Indexes](/reference/eql/indexes). + +## `IN (subquery)` and set operations + +Both follow the same rule, because both compare equality terms across two column sources: + +```sql +-- IN (subquery): users.email and orders.customer_email must share a keyset +SELECT * FROM users +WHERE email IN (SELECT customer_email FROM orders WHERE flagged); + +-- Set-operation dedup: UNION / INTERSECT / EXCEPT dedupe by equality term +SELECT email FROM users +UNION +SELECT customer_email FROM orders; +``` + +If the two columns are under different keysets, `IN (subquery)` matches nothing, `INTERSECT` is empty, `EXCEPT` returns everything, and `UNION` never merges duplicates — all silently. + +## Worked example + +Two tables sharing an encrypted customer identifier, both columns typed `public.eql_v3_text_eq` and encrypted by the same client configuration (same keyset): + +```sql +CREATE TABLE users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq +); + +CREATE TABLE orders ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_email public.eql_v3_text_eq, + total BIGINT NOT NULL +); + +CREATE INDEX users_email_eq ON users USING hash (eql_v3.eq_term(email)); +CREATE INDEX orders_cust_eq ON orders USING hash (eql_v3.eq_term(customer_email)); +ANALYZE users; ANALYZE orders; +``` + +Orders per user, filtered by an encrypted lookup on one side: + +```sql +SELECT u.id, COUNT(o.id) AS order_count +FROM users u +LEFT JOIN orders o ON u.email = o.customer_email +WHERE u.email = $1::public.eql_v3_text_eq +GROUP BY u.id; +``` + +The `WHERE` engages the hash index on `users`; the join condition engages the one on `orders`. The grouping key here is a plaintext `id`, so no extractor is needed — grouping on encrypted columns is covered in [Grouping & aggregates](/reference/eql/grouping-and-aggregates). + +## Anti-pattern: joining across keysets + +The failure mode is quiet. A join across keysets doesn't raise, doesn't warn, and produces a plan that looks healthy — the terms just never match, so it behaves exactly like a join where no rows happen to correlate: + +```sql +-- users encrypted by service A's keyset, partners by service B's: +SELECT * FROM users u JOIN partners p ON u.email = p.contact_email; +-- 0 rows. Always. Even when the plaintext emails overlap. +``` + +To diagnose a join that returns fewer rows than expected (or none): + +1. **Check the variants.** Both columns must be equality-capable and compare the same term kind. A blocked operator raises loudly, so if the query *runs*, the variants at least resolve — but confirm they compare the same term (`hm` vs `ob`). +2. **Compare terms for a known-matching pair.** Take one row from each table that you know holds the same plaintext and compare their equality terms: + + ```sql + SELECT eql_v3.eq_term(u.email) = eql_v3.eq_term(p.contact_email) AS terms_match + FROM users u, partners p + WHERE u.id = 42 AND p.id = 7; -- rows known to share a plaintext value + ``` + + `false` for plaintext-identical values means the terms were derived under different keysets (or different client configurations) — no SQL will make them join. +3. **Fix it at the encryption layer.** Configure both columns under the same keyset in the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy) and re-encrypt one side. Cross-keyset correlation otherwise has to happen in the client, after decryption. + +Treat shared keysets as part of your schema design: columns you intend to join are a unit, the same way a foreign key pair is. + +## Where to go next + +- [Filtering](/reference/eql/filtering) — the equality and `IN` shapes joins are built from. +- [Grouping & aggregates](/reference/eql/grouping-and-aggregates) — grouping joined results on encrypted keys. +- [Indexes](/reference/eql/indexes) — equality index recipes for both sides of a join. +- [Core concepts](/reference/eql/core-concepts) — index terms, variants, and why determinism makes joins possible at all. diff --git a/content/docs/reference/eql/json.mdx b/content/docs/reference/eql/json.mdx new file mode 100644 index 0000000..0db66b0 --- /dev/null +++ b/content/docs/reference/eql/json.mdx @@ -0,0 +1,329 @@ +--- +title: JSON +description: "The complete reference for encrypted JSON documents with public.eql_v3_json_search — the ste_vec payload shape, value-selector containment, field access, and path queries over ciphertext, with the native jsonb operators that don't apply blocked outright." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.3" +--- + + + +`public.eql_v3_json_search` is EQL's searchable encrypted JSON document type, built on structured encryption (**ste_vec**). The document is encrypted as a vector of encrypted entries — one entry per path inside the document — and every path is queryable without decryption: containment, field and array access, exact field matching, and range comparisons on extracted leaves. + +Like every EQL type, it holds ciphertext the database can't read. Encryption, decryption, and selector generation happen in the client — the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy). See [Searchable encryption](/concepts/searchable-encryption) for how querying ciphertext works at all. + +## The types + +Four `jsonb`-backed domains make up the encrypted JSON surface. The family follows the same convention as the scalars: the bare name is storage-only, and a suffix adds capability. + +| Type | What it is | +| --- | --- | +| `public.eql_v3_json_search` | The searchable column type. An encrypted document envelope carrying an `sv` array — one encrypted entry per path in the document. | +| `public.eql_v3_json` | Storage-only encrypted JSON: one opaque ciphertext (`{v, i, c}`), no index terms, no server-side searchability. Every comparison and containment operator is blocked on it. | +| `public.eql_v3_json_entry` | A single entry from the vector: a selector, a ciphertext, and an optional ordering term. This is what `->` returns. | +| `eql_v3.query_json` | A containment needle: entries with selectors but **no ciphertext**. This is what you cast a `@>` operand to. | + + +These domains were renamed in EQL 3.0.1: the searchable document was `public.eql_v3_json`, the entry was `public.eql_v3_jsonb_entry`, and the needle was `eql_v3.query_jsonb`. The bare `public.eql_v3_json` name now means the storage-only domain, which rejects a ste_vec document, so retype searchable columns: `ALTER TABLE t ALTER COLUMN doc TYPE public.eql_v3_json_search`. + + +## Payload shape + +An encrypted JSON document uses a different payload shape from the scalar types: the standard envelope keys are present (`v`, `i`, plus the `k: "sv"` discriminator — envelope anatomy is covered in [Core concepts](/reference/eql/core-concepts)), but there is no root ciphertext — the root document's ciphertext is the root `sv` entry. Two document-level keys plus the vector: + +| Key | Contents | +| --- | --- | +| `h` | Key header — the key-retrieval material (IV, tag, descriptor, keyset) for the whole document. Required. Hoisted here once rather than repeated in every entry, which shrinks a document by roughly 30%. | +| `sv` | The vector: one encrypted entry per path in the document. | + +Each entry has: + +| Key | Contents | +| --- | --- | +| `s` | Selector — a deterministic hash. On a **path entry** it covers the JSON path; on a **value entry** it covers the path *and* the canonical value, which is what makes exact matching work. Required; entry matching compares selectors first. | +| `c` | Ciphertext for the node at that path — raw AEAD output, with the nonce derived from the entry's own selector rather than a shared per-document IV. A value entry encrypts a fixed versioned sentinel, so it stays distinguishable from a genuine `""` leaf. | +| `op` | Optional ordering term (CLLW OPE, backed by `eql_v3_internal.ope_cllw`) on String and Number path entries. | +| `a` | Optional array marker — `true` when the selector points at an array context. | + +The decoded `op` value starts with a domain-tag byte (`0x00` numeric, `0x01` string) followed by the CLLW ciphertext, so numeric and string values in one column keep a consistent total order. Pre-release payloads named this key `oc` and backed it with CLLW ORE, and older ones split it further, into `ocf` (fixed-width, numeric) and `ocv` (variable-width, string). + + +**Entries no longer carry `hm`.** The per-value HMAC equality term was retired in EQL 3.0.1 in favour of value-inclusive selectors, and an `hm`-bearing entry now fails the domain `CHECK`. Along with the key header and the selector-derived nonces, that makes this a wire-format change: stored documents need re-encrypting by a current client, and there is no mechanical conversion. + + +A document payload for a `public.eql_v3_json_search` column: + +```json +{ + "v": 3, + "k": "sv", + "i": { "t": "orders", "c": "metadata" }, + "h": "a4f1c2...", + "sv": [ + { "s": "2517068c0d1f9d4d41d2c666211f785e", "c": "mBbKmM..." }, + { "s": "f510853a4ab9d4f75f51a533ac264c5d", "c": "mBbKmQ...", "op": "01a3f2..." }, + { "s": "33743aed3ae636f6bf05cff11ac4b519", "c": "mBbKmR...", "op": "004e19..." } + ] +} +``` + +- First entry: an object root — no ordering term +- Second entry: a string leaf — `op` starting with tag `01` +- Third entry: a numeric leaf — `op` starting with tag `00` + +A containment **query** payload (`eql_v3.query_json`) has the same `sv` shape but its entries carry no `c` — containment matches selectors, never ciphertexts. This is the needle the client builds for a `@>` query: + +```json +{ + "sv": [ + { "s": "f510853a4ab9d4f75f51a533ac264c5d", "op": "01a3f2..." } + ] +} +``` + +## Storing encrypted JSON + +Type the column as `public.eql_v3_json_search`: + +```sql +CREATE TABLE orders ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + metadata public.eql_v3_json_search +); +``` + +There is no database-side configuration step. Which selectors and terms a document carries is decided by the encryption client; typing the column as `public.eql_v3_json_search` is what makes the encrypted operators and functions resolve. The domain's `CHECK` constraint validates the payload shape on insert, so malformed values are rejected at write time. + +For a document you never search, type the column as the storage-only `public.eql_v3_json` instead: it takes the plain `{v, i, c}` envelope, rejects a ste_vec document, and blocks every comparison operator — the JSON analogue of `public.eql_v3_boolean`. + +Insert and read through the Stack SDK or Proxy, which encrypt the document into the ste_vec payload on write and decrypt it on read. + +## What each node type supports + +During encryption, the client flattens the document: each unique path gets a deterministic **selector** hash. Each node then emits a **path entry** (selector over the path, carrying the ordering term where the type has one) and a **value entry**, whose selector covers the path *and* the canonical value: + +| JSON node type | Ordering term | Exact match (`@>` on the value selector) | Ordering (`<` … `>=`, `MIN`/`MAX`) | +| --- | --- | :---: | :---: | +| Object | — | Yes | No | +| Array | — | Yes | No | +| Boolean / JSON `null` | — | Yes | No | +| String | `op` (CLLW OPE, string domain) | Yes | Yes | +| Number | `op` (CLLW OPE, numeric domain) | Yes | Yes | + +Exact matching is the *presence* of a value selector in the stored document, which makes it injective and loss-free for every type — including `text`, `bigint` and `numeric`, where the old ordering-term comparison collided (`"café"` with `"cafe"`, `9007199254740993` with `…992`). It is expressed as document containment, not as `=` on an extracted leaf: + +```sql +SELECT * FROM orders WHERE metadata @> $1::eql_v3.query_json; +``` + +JSON `null` here means a `null` literal *inside* the document. A SQL `NULL` column value is not encrypted at all. + +## Blocked native jsonb operators + +These native PostgreSQL `jsonb` operators are **blocked** on `public.eql_v3_json_search`. They raise an error rather than silently running plaintext-jsonb semantics against the encrypted payload: + +- Key/path existence: `?`, `?|`, `?&`, `@?`, `@@` +- Path extraction: `#>`, `#>>` +- Mutation: `-`, `#-`, `||` +- Root-document comparison: `=`, `<>`, `<`, `<=`, `>`, `>=` + +Equality on the **extract surface** raises too: `col -> 'sel'::text = $1::…` is a blocker for every family, because an extracted path entry carries no value selector and so cannot answer an exact match. Use value-selector containment for that. + +Use containment (`@>` / `<@`), field access (`->` / `->>`), or the `eql_v3.jsonb_path_*` functions instead. There is no server-side mutation of an encrypted document — updates re-encrypt in the client. + + +**Operands must be typed** (`doc -> 'email'::text`, not `doc -> 'email'`) — an untyped operand resolves the native `jsonb` operator, bypassing both the encrypted operator and the blockers. See [Core concepts](/reference/eql/core-concepts). + + +## Functions + +Every JSON query addresses paths by **selector hash** — the deterministic identifier the client emits for a JSON path during encryption, not a plaintext path like `$.customer.tier`. Operands must be typed, or PostgreSQL resolves the native `jsonb` operator instead of the encrypted one. + +The examples below all query one encrypted `metadata` document. In plaintext: + +```json +{ + "customer": { + "tier": "premium", + "region": "apac" + }, + "total": 149.95, + "items": ["sku-1042", "sku-2210"] +} +``` + +The `*_selector` placeholders stand for the selector hash of each path: `region_selector` for `$.customer.region`, `total_selector` for `$.total`, and `items_selector` for `$.items`. + +### Containment and exact matching [#fn-contains] + +`@>` tests whether the encrypted document contains a structure; `<@` is the reverse. Build the needle in the client and cast it to `eql_v3.query_json`; `eql_v3.ste_vec_contains(a, b)` is the function form. This is both the structural-containment operator and the way to match a field exactly — the client emits a value-selector needle for the field and value you're matching. + + + +```sql +SELECT * FROM orders +WHERE metadata @> $1::eql_v3.query_json; +``` + + + +For large tables, back containment with a GIN index. The typed `@>` inlines to a native `jsonb @>` over `eql_v3.to_ste_vec_query(col)::jsonb`, so a GIN index on that same expression engages: + +```sql +CREATE INDEX orders_metadata_gin + ON orders USING gin (eql_v3.to_ste_vec_query(metadata)::jsonb jsonb_path_ops); +``` + +### Field access [#fn-field-access] + +`->` returns a `public.eql_v3_json_entry`; `->>` serializes that entry as ciphertext text. Fields are addressed by selector hash, and array elements by 0-based index. + + + +```sql +SELECT metadata -> 'selector_hash'::text FROM orders; -- entry +SELECT metadata ->> 'selector_hash'::text FROM orders; -- entry as ciphertext text +SELECT metadata -> 0 FROM orders; -- array element by index +``` + + + +An extracted `public.eql_v3_json_entry` is ordered-comparable, but not equality-comparable: `=` / `<>` on it raise. + + +`eql_v3.eq_term` has a `public.eql_v3_json_entry` overload, but it is **deprecated** — it is an alias for `eql_v3.ope_term`, returning raw OPE bytes rather than an exact equality term, so it inherits the ordering term's encoding collisions (float rounding on large integers, collation on text). Exact field matching is value-selector containment, above. + + +### eql_v3.ord_term [#fn-ord-term] + +Range comparisons and `ORDER BY` on an extracted **String or Number** leaf, via `eql_v3.ord_term`. + + + +```sql +SELECT * FROM orders +WHERE (metadata -> 'total_selector'::text) > $1::public.eql_v3_json_entry; +``` + + + +A btree on `eql_v3.ord_term(col -> ''::text)` engages range and `ORDER BY`; the GIN index on `eql_v3.to_ste_vec_query(col)::jsonb` engages exact matching. See [Indexes](/reference/eql/indexes). + +### eql_v3.min / max [#fn-min-max] + +`MIN` / `MAX` over an extracted ordered leaf. + + + +```sql +SELECT eql_v3.min(metadata -> 'total_selector'::text) FROM orders; +``` + + + +### eql_v3.jsonb_path_query [#fn-path-query] + +Path queries take the same selector hashes. `jsonb_path_query` returns every matching entry, `jsonb_path_query_first` the first, and `jsonb_path_exists` a boolean. + + + +```sql +SELECT eql_v3.jsonb_path_query(metadata, 'selector_hash') FROM orders; -- all matches +SELECT eql_v3.jsonb_path_query_first(metadata, 'selector_hash') FROM orders; -- first match +SELECT eql_v3.jsonb_path_exists(metadata, 'selector_hash') FROM orders; -- boolean +``` + + + +### eql_v3.jsonb_array_* [#fn-array] + +Helpers over an encrypted array node. `jsonb_array_elements` yields encrypted entries — each carrying the grafted key header, which makes it the decryptable unit. + + + +```sql +SELECT eql_v3.jsonb_array_length(metadata -> 'items_selector'::text) FROM orders; +SELECT eql_v3.jsonb_array_elements(metadata -> 'items_selector'::text) FROM orders; +``` + + + +`eql_v3.jsonb_array_elements_text`, which streamed each element as bare ciphertext text, was **removed** in EQL 3.0.1: an entry's ciphertext is no longer independently decryptable, so the rows it produced were useless. Use `eql_v3.jsonb_array_elements`. + +## Worked example + +An `orders` table with an encrypted `metadata` document. The plaintext your application works with: + +```json +{ + "customer": { + "tier": "premium", + "region": "apac" + }, + "items": ["sku-1042", "sku-2210"] +} +``` + +The client encrypts this into a ste_vec payload with selectors for `$`, `$.customer`, `$.customer.tier`, `$.customer.region`, `$.items`, and each array element — every path becomes queryable. + + + + +### Create the table and insert + +```sql +CREATE TABLE orders ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + metadata public.eql_v3_json_search +); + +INSERT INTO orders (metadata) VALUES ($1); +-- $1 is the encrypted ste_vec payload produced by the Stack SDK or Proxy +``` + + + + +### Query by containment + +Find premium orders. The client encrypts the needle `{"customer": {"tier": "premium"}}` into a `ste_vec_query` — a value-selector needle, so this is an exact match on the field, not a fuzzy one: + +```sql +SELECT id FROM orders +WHERE metadata @> $1::eql_v3.query_json; +``` + +Add the GIN index from above once the table grows. + + + + +### Query by path + +Path existence and range comparisons work on the extract surface — the database never sees `"apac"` or `149.95`: + +```sql +SELECT id FROM orders +WHERE eql_v3.jsonb_path_exists(metadata, 'region_selector') + AND (metadata -> 'total_selector'::text) > $1::public.eql_v3_json_entry; +``` + +The rows come back as ciphertext; decrypt them in the client. + + + + +## Where to next + + + + The envelope anatomy, typed-operand rule, and fail-loud behavior shared by every EQL type. + + + GIN containment and field-level functional index recipes. + + + WHERE-clause patterns across all encrypted types. + + diff --git a/content/docs/reference/eql/meta.json b/content/docs/reference/eql/meta.json new file mode 100644 index 0000000..d432349 --- /dev/null +++ b/content/docs/reference/eql/meta.json @@ -0,0 +1,24 @@ +{ + "title": "EQL", + "pages": [ + "index", + "core-concepts", + "---Types---", + "numbers", + "dates-and-times", + "text", + "json", + "booleans", + "---Indexes---", + "indexes", + "---Queries---", + "filtering", + "sorting", + "grouping-and-aggregates", + "joins", + "---Reference---", + "functions", + "---Previous versions---", + "v2" + ] +} diff --git a/content/docs/reference/eql/numbers.mdx b/content/docs/reference/eql/numbers.mdx new file mode 100644 index 0000000..b2f1cac --- /dev/null +++ b/content/docs/reference/eql/numbers.mdx @@ -0,0 +1,123 @@ +--- +title: Numbers +description: "The complete reference for encrypted numeric columns: the int, float, and numeric domain variants, the ordering term they carry, and range, ORDER BY, and MIN/MAX queries." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.0" +--- + + + +Six numeric types share one identical query surface: `int2`, `int4`, `int8`, `float4`, `float8`, and `numeric`. These are the columns you filter by range, sort, and take a `MIN` / `MAX` over — salaries, totals, rates, quantities. + +Date and time columns have the same capabilities but their own semantics — see [Dates & times](/reference/eql/dates-and-times). There is no free-text matching for numeric types — `_match` and `_search` are [text-only variants](/reference/eql/text). + +## Variants + +Each numeric type generates the same `jsonb`-backed domain variants. The generic form: + +| Domain variant | Capability | +| --- | --- | +| `public.eql_v3_` | Storage and decryption only. | +| `public.eql_v3__eq` | Equality: `=`, `<>`, `IN`, `GROUP BY`, `DISTINCT`, equijoins. | +| `public.eql_v3__ord` | Comparisons, `BETWEEN`, `ORDER BY`, `MIN` / `MAX` — plus equality. | +| `public.eql_v3__ord_ope` | The byte-identical twin of `_ord`, with OPE pinned. See [SEM specifiers](#sem-specifiers). | +| `public.eql_v3__ord_ore` | As `_ord`, with the ORE mechanism pinned. | + +`` is the encrypted type name from the table below, so `public.eql_v3__ord` is `public.eql_v3_bigint_ord` for `int8`. + +And every concrete domain this page covers: + +| Type | Variants | +| --- | --- | +| `int2` | `public.eql_v3_smallint` · `public.eql_v3_smallint_eq` · `public.eql_v3_smallint_ord` · `public.eql_v3_smallint_ord_ope` · `public.eql_v3_smallint_ord_ore` | +| `int4` | `public.eql_v3_integer` · `public.eql_v3_integer_eq` · `public.eql_v3_integer_ord` · `public.eql_v3_integer_ord_ope` · `public.eql_v3_integer_ord_ore` | +| `int8` | `public.eql_v3_bigint` · `public.eql_v3_bigint_eq` · `public.eql_v3_bigint_ord` · `public.eql_v3_bigint_ord_ope` · `public.eql_v3_bigint_ord_ore` | +| `float4` | `public.eql_v3_real` · `public.eql_v3_real_eq` · `public.eql_v3_real_ord` · `public.eql_v3_real_ord_ope` · `public.eql_v3_real_ord_ore` | +| `float8` | `public.eql_v3_double` · `public.eql_v3_double_eq` · `public.eql_v3_double_ord` · `public.eql_v3_double_ord_ope` · `public.eql_v3_double_ord_ore` | +| `numeric` | `public.eql_v3_numeric` · `public.eql_v3_numeric_eq` · `public.eql_v3_numeric_ord` · `public.eql_v3_numeric_ord_ope` · `public.eql_v3_numeric_ord_ore` | + +Declare only the capability you query on — each capability stores extra searchable material with defined leakage (see [Searchable encryption](/concepts/searchable-encryption)), and the variant model itself is covered in [Core concepts](/reference/eql/core-concepts). + +### Example + +A payroll table mixing the variants by how each column is queried: + +```sql +CREATE TABLE employees ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + salary public.eql_v3_bigint_ord, -- range queries, ORDER BY, MIN/MAX + tax_rate public.eql_v3_numeric_eq, -- exact lookup only + net_worth public.eql_v3_numeric -- store and decrypt only, never queried +); +``` + +### SEM specifiers + +All six types take the same mechanism specifiers on their orderable variant (the concept is defined in [Core concepts](/reference/eql/core-concepts#sem-specifiers)): + +| Specifier | Mechanism | Ordering term | Extractor | +| --- | --- | --- | --- | +| `_ord` | The default, currently CLLW OPE | `op` | `eql_v3.ord_term` | +| `_ord_ope` | CLLW OPE, pinned explicitly | `op` | `eql_v3.ord_term` | +| `_ord_ore` | Block-ORE, pinned explicitly | `ob` | `eql_v3.ord_term_ore` | + +`_ord` and `_ord_ope` are byte-identical today. Pin `_ord_ope` when you want a column's mechanism frozen against a future change of the default. + + +Block-ORE terms sort only under a custom btree operator class, and creating one requires superuser. Where the EQL installer runs as a non-superuser, which is the case on most managed Postgres including cloud Supabase, it cannot create the class, so it **disables every ORE-backed domain** rather than let them install half-working. `_ord_ore` then raises `feature_not_supported` on the first value written to it. Use `_ord` there. + + +## Payload + +A value for an `_ord` column carries the shared envelope keys (`v`, `i`, `c` — see [Core concepts](/reference/eql/core-concepts)) plus the `op` ordering term. Here is a payload for the `public.eql_v3_bigint_ord` `salary` column: + +```json +{ + "v": 3, + "i": { "t": "employees", "c": "salary" }, + "c": "mBbKmsMM%bK#QQOx1yLDBHyD...", + "op": "5f2b1a9e4c07d38b6ea15c92..." +} +``` + +- **`op` is the only index term.** It is a hex-encoded CLLW OPE ciphertext, which Postgres sorts by native `bytea` comparison. An `_ord` payload carries no `hm`, because ordering over a numeric scalar is equality-lossless: `=` and `<>` resolve against the same term. Only `_eq` payloads carry `hm` (a single hex HMAC-SHA-256 string) instead. +- **An `_ord_ore` payload carries `ob` in place of `op`**: an array of block-ORE ciphertexts, 8 blocks for the int types and 14 for `numeric`. + +## Operators + +| SQL operator | `public.eql_v3_` | `_eq` | `_ord` variants | +| --- | :---: | :---: | :---: | +| `=` / `<>` | ❌ | ✅ | ✅ | +| `<` `<=` `>` `>=` | ❌ | ❌ | ✅ | +| `BETWEEN` (desugars to `>=` and `<=`) | ❌ | ❌ | ✅ | +| `IN` (desugars to `=`) | ❌ | ✅ | ✅ | +| `GROUP BY` / `DISTINCT` | ❌ | ✅ | ✅ | +| `ORDER BY` | ❌ | ❌ | ✅ | +| `IS NULL` / `IS NOT NULL` | ✅ | ✅ | ✅ | + +Blocked *operator* cells raise an `operator … is not supported` exception — they never silently return wrong rows. `ORDER BY` is the one blocked cell that doesn't raise: it isn't an operator, so sorting a variant without an ordering term runs — but the order is meaningless (see [Sorting](/reference/eql/sorting)). Operands must be typed (`$1::public.eql_v3_bigint_ord`), or PostgreSQL resolves the native `jsonb` operator instead of the encrypted one. Both rules are covered in [Core concepts](/reference/eql/core-concepts). + +## Functions + +content/partials/eql/functions-numbers.mdx + +**`SUM`, `AVG`, and other arithmetic aggregates are not supported** on encrypted columns — they would require homomorphic encryption. `MIN` / `MAX` work because they only need comparison; for sums and averages, decrypt at the application boundary and aggregate client-side. + +## Where to next + + + + The same capabilities on date and timestamp columns. + + + Btree recipes on `eql_v3.ord_term` for range, ORDER BY, and MIN/MAX. + + + WHERE-clause patterns across all encrypted types. + + + GROUP BY, DISTINCT, and the aggregate surface on encrypted columns. + + diff --git a/content/docs/reference/eql/sorting.mdx b/content/docs/reference/eql/sorting.mdx new file mode 100644 index 0000000..840faea --- /dev/null +++ b/content/docs/reference/eql/sorting.mdx @@ -0,0 +1,100 @@ +--- +title: Sorting +description: "ORDER BY on encrypted columns: which variants sort, when to write the sort key in extractor form, keyset pagination, and the ::jsonb projection trap." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.0" +--- + + + +`ORDER BY` on an encrypted column needs an ordering term: it works on the `_ord`, `_ord_ope`, and `_ord_ore` variants of every scalar, and on `text_search` / `text_search_ore`. Ordering terms are order-preserving, so the database sorts ciphertext in exactly the order the plaintext would sort — without decrypting anything. Which variants carry the term is covered in [Numbers](/reference/eql/numbers), [Dates & times](/reference/eql/dates-and-times), and [Text](/reference/eql/text); the variant model itself is in [Core concepts](/reference/eql/core-concepts). + +Sorting a variant *without* an ordering term (`_eq`, `text_match`, bare storage variants) won't raise — but the order is meaningless. Type the column as an `_ord` variant when ordering matters. + +The mechanism behind the term, CLLW OPE (`op`) or block-ORE (`ob`), changes nothing about how you write the query. `eql_v3.ord_term` is the extractor for `_ord`, `_ord_ope`, and `text_search`; `eql_v3.ord_term_ore` is the extractor for `_ord_ore` and `text_search_ore`. Everything on this page applies to both, with one exception: only the OPE term indexes under Postgres's default btree operator class, so on managed Postgres it is the only mechanism available. See [SEM specifiers](/reference/eql/core-concepts#sem-specifiers). + +## Bare form vs extractor form + +Both of these sort correctly: + +```sql +-- Bare form +SELECT * FROM users ORDER BY created_at DESC; + +-- Extractor form +SELECT * FROM users ORDER BY eql_v3.ord_term(created_at) DESC; +``` + +The difference is the plan. The planner inlines encrypted operators in *predicates*, so a `WHERE created_at < $1` matches a btree on `eql_v3.ord_term(created_at)` without rewriting — but it does **not** rewrite *sort keys*. Bare `ORDER BY created_at` therefore adds a `Sort` node above the scan, and that sort's cost scales linearly with the rows passing the filter. + +Writing the sort key in extractor form makes it textually match the index expression, so rows stream out of the btree already ordered — no `Sort` node at all: + +```sql +CREATE INDEX users_created_at_ord + ON users USING btree (eql_v3.ord_term(created_at)); +ANALYZE users; + +SELECT * FROM users + WHERE created_at < $1::public.eql_v3_timestamp_ord + ORDER BY eql_v3.ord_term(created_at) DESC + LIMIT 10; +-- Index Scan Backward using users_created_at_ord — no Sort node +``` + +At large row counts this is the difference between seconds and milliseconds, and it matters most for `LIMIT` queries: with a `Sort` node, Postgres must sort *every* matching row before it can return the top 10; streaming from the index, it stops after 10. + +Rule of thumb: bare form is fine for small result sets or when no ordering index exists; any hot query with `ORDER BY ... LIMIT` should use the extractor form. Confirm with `EXPLAIN (COSTS OFF)` — a `Sort` node above an `Index Scan` means the sort key didn't match the index. Full plan-reading guidance is in [Indexes](/reference/eql/indexes). + +## `ASC`, `DESC`, and `NULLS` + +`ASC` / `DESC` behave normally — a btree serves both directions (backward scans handle `DESC`). SQL `NULL` column values are not encrypted, so `NULLS FIRST` / `NULLS LAST` also behave normally: + +```sql +SELECT * FROM users +ORDER BY eql_v3.ord_term(last_login) DESC NULLS LAST; +``` + +## Keyset pagination + +`OFFSET` pagination degrades on encrypted columns the same way it does on plaintext ones — every page re-sorts and discards the rows before the offset. Keyset (cursor) pagination composes an encrypted range filter with an extractor-form sort: + +```sql +-- Page 1 +SELECT id, email, created_at FROM users + ORDER BY eql_v3.ord_term(created_at) DESC + LIMIT 20; + +-- Next page: pass the last row's created_at back, re-encrypted as the cursor +SELECT id, email, created_at FROM users + WHERE created_at < $1::public.eql_v3_timestamp_ord + ORDER BY eql_v3.ord_term(created_at) DESC + LIMIT 20; +``` + +Both the filter and the sort ride the same btree on `eql_v3.ord_term(created_at)`, so every page is an index scan that stops after 20 rows. The client re-encrypts the cursor value for the next request — the database only ever sees ciphertext. + +## The `::jsonb` projection trap + + +If you project the column with a cast and sort on it — `SELECT col::jsonb ... ORDER BY col` — Postgres folds the cast into the scan and uses `(col)::jsonb` as the sort key, which matches no index. Project the column raw and let the client decode it, or write the sort key as `eql_v3.ord_term(col)`, which sidesteps the problem entirely. + + +## Sorting extracted JSON leaves + +String and Number leaves inside an encrypted JSON document carry a CLLW OPE term, so they sort too — the extractor is `eql_v3.ord_term` on the extracted entry: + +```sql +SELECT * FROM orders +ORDER BY eql_v3.ord_term(metadata -> 'total_selector'::text) DESC +LIMIT 10; +``` + +A btree on the same `eql_v3.ord_term(...)` expression streams this ordered, exactly like `ord_term` on a scalar column. Selectors, node types, and which leaves are orderable are covered in [JSON](/reference/eql/json). + +## Where to go next + +- [Indexes](/reference/eql/indexes) — the btree recipe behind every sort on this page, plus `EXPLAIN` verification and large-table build guidance. +- [Filtering](/reference/eql/filtering) — the range predicates that pair with these sorts. +- [Grouping & aggregates](/reference/eql/grouping-and-aggregates) — `MIN` / `MAX`, which use the same ordering term. diff --git a/content/docs/reference/eql/text.mdx b/content/docs/reference/eql/text.mdx new file mode 100644 index 0000000..c82edb9 --- /dev/null +++ b/content/docs/reference/eql/text.mdx @@ -0,0 +1,163 @@ +--- +title: Text +description: "The complete reference for encrypted text columns: every text domain variant, the multi-term payload, why LIKE is gone everywhere, and @@ bloom-filter fuzzy match as the encrypted free-text search." +type: reference +components: [eql] +verifiedAgainst: + eql: "3.0.3" +--- + + + +Text is the richest encrypted scalar. Beyond the variants every scalar type gets, `text` adds its own: `text_match` for encrypted free-text matching, and `text_search` for columns you need to look up, sort, *and* search. Emails, names, tax IDs, addresses — this page is the full surface for all of them. + +## Variants + +All of these are `jsonb`-backed domains. Which one you declare fixes the column's query capability — the variant model itself is covered in [Core concepts](/reference/eql/core-concepts): + +| Domain variant | Capability | +| --- | --- | +| `public.eql_v3_text` | Storage and decryption only. | +| `public.eql_v3_text_eq` | Equality: `=`, `<>`, `IN`, `GROUP BY`, `DISTINCT`, equijoins. | +| `public.eql_v3_text_ord` | Comparisons, `BETWEEN`, `ORDER BY`, `MIN` / `MAX` — plus equality. | +| `public.eql_v3_text_ord_ope` | The byte-identical twin of `text_ord`, with OPE pinned. See [SEM specifiers](#sem-specifiers). | +| `public.eql_v3_text_ord_ore` | As `text_ord`, with the ORE mechanism pinned. | +| `public.eql_v3_text_match` | Free-text fuzzy match: `@@`. | +| `public.eql_v3_text_search` | Equality + ordering + fuzzy match. | +| `public.eql_v3_text_search_ore` | As `text_search`, with the ORE mechanism pinned. | + +Declare only the capabilities you query on — each capability stores extra searchable material with defined leakage (see [Searchable encryption](/concepts/searchable-encryption)). + +### Example + +A users table mixing the variants by how each column is queried: + +```sql +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_search, -- lookup, sort, and free-text match + name public.eql_v3_text_match, -- free-text match only + tax_id public.eql_v3_text_eq, -- exact lookup only + notes public.eql_v3_text -- store and decrypt only +); +``` + +### SEM specifiers + +Text takes the same mechanism specifiers as the other orderable types (the concept is defined in [Core concepts](/reference/eql/core-concepts#sem-specifiers)): + +| Specifier | Mechanism | Ordering term | Extractor | +| --- | --- | --- | --- | +| `_ord` | The default, currently CLLW OPE | `op` | `eql_v3.ord_term` | +| `_ord_ope` | CLLW OPE, pinned explicitly | `op` | `eql_v3.ord_term` | +| `_ord_ore` | Block-ORE, pinned explicitly | `ob` | `eql_v3.ord_term_ore` | +| `text_search` | The default, currently CLLW OPE | `op` | `eql_v3.ord_term` | +| `text_search_ore` | Block-ORE, pinned explicitly | `ob` | `eql_v3.ord_term_ore` | + +`text_ord` and `text_ord_ope` are byte-identical today. Pin `_ord_ope` when you want a column's mechanism frozen against a future change of the default. + + +Block-ORE terms sort only under a custom btree operator class, and creating one requires superuser. Where the EQL installer runs as a non-superuser, which is the case on most managed Postgres including cloud Supabase, it cannot create the class, so it **disables every ORE-backed domain** rather than let them install half-working. `text_ord_ore` and `text_search_ore` then raise `feature_not_supported` on the first value written to them. Use `text_ord` or `text_search` there. + + +## Payload + +A value for a `text_search` column carries the shared envelope keys (`v`, `i`, `c` — see [Core concepts](/reference/eql/core-concepts)) plus all three index terms: + +```json +{ + "v": 3, + "i": { "t": "users", "c": "email" }, + "c": "mBbKmsMM%bK#QQOx1yLDBHyD...", + "hm": "9c8ec1d2f9932b979b1bf3f09f8a4e2f6a41f8de2f0c8b7a52e1f5c3d4b6a790", + "op": "5f2b1a9e4c07d38b6ea15c92...", + "bf": [42, 1290, -8113, 30201] +} +``` + +- `hm` — equality term: `WHERE email = $1` compares this +- `op` — ordering term: a hex-encoded CLLW OPE ciphertext, which `ORDER BY` and range comparisons sort by native `bytea` comparison +- `bf` — bloom-filter term: `@@` fuzzy match tests these bit positions + +A `text_search_ore` payload carries `ob` in place of `op`: an array of block-ORE ciphertexts rather than a single string. + +The narrower variants carry only their own terms. A `text_eq` payload carries `hm` alone, `text_match` carries `bf` alone, and the orderable variants carry `hm` plus their ordering term (`op` for `text_ord` / `text_ord_ope`, `ob` for `text_ord_ore`). Text keeps `hm` on every orderable variant, because ordering over text is not equality-lossless the way it is over the numeric scalars: `=` and `<>` resolve against the HMAC term rather than the ordering term. A payload missing its variant's required term fails the domain `CHECK` at write time. + +**`bf` positions are signed**: EQL stores the filter as PostgreSQL `smallint[]`, and filters sized above 32768 emit upper-half bit positions as *negative* signed values. Consumers must use a signed 16-bit integer type. + +## Operators + +| SQL operator | `public.eql_v3_text` | `text_eq` | `text_ord` variants | `text_match` | `text_search` variants | +| --- | :---: | :---: | :---: | :---: | :---: | +| `=` / `<>` | ❌ | ✅ | ✅ | ❌ | ✅ | +| `<` `<=` `>` `>=` | ❌ | ❌ | ✅ | ❌ | ✅ | +| `@@` (fuzzy match) | ❌ | ❌ | ❌ | ✅ | ✅ | +| `@>` / `<@` | ❌ | ❌ | ❌ | ❌ | ❌ | +| `LIKE` / `ILIKE` (`~~` / `~~*`) | ❌ | ❌ | ❌ | ❌ | ❌ | +| `IN` / `GROUP BY` / `DISTINCT` | ❌ | ✅ | ✅ | ❌ | ✅ | +| `ORDER BY` | ❌ | ❌ | ✅ | ❌ | ✅ | +| `IS NULL` / `IS NOT NULL` | ✅ | ✅ | ✅ | ✅ | ✅ | + +Blocked *operator* cells raise an `operator … is not supported` exception — they never silently return wrong rows. `ORDER BY` is the one blocked cell that doesn't raise: it isn't an operator, so sorting a variant without an ordering term runs — but the order is meaningless (see [Sorting](/reference/eql/sorting)). Operands must be typed (`$1::public.eql_v3_text_eq`), or PostgreSQL resolves the native `jsonb` operator instead of the encrypted one. Both rules are covered in [Core concepts](/reference/eql/core-concepts). + +## Functions + +content/partials/eql/functions-text.mdx + +There are no `like` / `ilike` function forms — encrypted text matching is `eql_v3.matches` on a `text_match` value. + +## There is no `LIKE` + +`LIKE` and `ILIKE` (`~~` / `~~*`) raise on **every** encrypted-domain variant — including `text_match` and `text_search`. SQL pattern matching is meaningless on ciphertext. Encrypted text matching is bloom-filter fuzzy match — `@@` on a `text_match` or `text_search` column: + + + +```sql +SELECT * FROM users WHERE email LIKE '%alice%'; +``` + + + +```sql +-- Encrypted free-text match +SELECT * FROM users WHERE email @@ $1::public.eql_v3_text_match; + +-- Function form +SELECT * FROM users WHERE eql_v3.matches(email, $1::public.eql_v3_text_match); +``` + +`@@` is **probabilistic ngram-bloom matching** — it reduces to array containment over two bloom filters built from the downcased 3-gram token sets, so a `true` may be a false positive and a `false` never is. It is not `LIKE`, and it is not containment: it is order- and multiplicity-insensitive. The client encrypts the search term into a bloom-filter query value. + + +**`@>` / `<@` raise on the match variants.** The fuzzy match was renamed in EQL 3.0.1: it used to be spelled `@>` / `<@`, backed by `eql_v3.contains` / `eql_v3.contained_by`, which are no longer public functions on these domains. That naming promised containment semantics it never had, so the operator became the directional `@@` and the old spellings are now blockers that raise `operator … is not supported`. Genuine containment on encrypted JSON is unaffected and keeps `@>` / `<@`; see [JSON](/reference/eql/json). Repoint any query, view, or adapter still emitting the old form, and coordinate with the client: an SDK emitting `eql_v3.contains(` for text match must move to `eql_v3.matches(` in the same release. + + +### Search terms too short to tokenise + +A search string below the tokeniser's floor — a 2-character term, say — produces no 3-grams and encrypts to an empty bloom (`bf: []`). Because an empty array is contained by every array, such a needle once matched **every row in the table**, silently. Since EQL 3.0.3 the match is guarded, giving `LIKE ''`-shaped semantics: + +| Stored value's bloom | Needle's bloom | `LIKE` analogue | Result | +| --- | --- | --- | :---: | +| empty | empty | `'' LIKE ''` | ✅ | +| non-empty | empty | `'catty' LIKE ''` | ❌ (was ✅) | +| empty | non-empty | `'' LIKE 'cat'` | ❌ | +| non-empty | non-empty | — | bloom match | + +Only the second row changed: an empty needle now matches only a value whose own bloom is also empty. Nothing to rewrite, and values legitimately stored with an empty bloom are unaffected. On EQL 3.0.2 and earlier, reject sub-floor search terms client-side — the SDK already does. + +## Where to next + + + + Hash on `eq_term`, btree on `ord_term`, GIN on `match_term`. + + + WHERE-clause patterns across all encrypted types. + + + Extractor-form sort keys and index-backed ordering. + + + Equijoins on encrypted text columns, and the same-keyset rule. + + diff --git a/content/docs/reference/eql/v2/index.mdx b/content/docs/reference/eql/v2/index.mdx new file mode 100644 index 0000000..f406579 --- /dev/null +++ b/content/docs/reference/eql/v2/index.mdx @@ -0,0 +1,123 @@ +--- +title: EQL v2 +navTitle: Overview +description: PostgreSQL types, operators, and functions for querying encrypted data with EQL v2 (v2.2) — the eql_v2_encrypted type and searchable index types. +type: reference +components: [eql] +verifiedAgainst: + eql: "2.2" +--- + + +This section documents **EQL v2** (v2.2), the release existing CipherStash deployments run. Starting a new project? Use **EQL v3** — see the [EQL reference](/reference/eql). + + +**Encrypt Query Language (EQL)** is a set of PostgreSQL types, operators, and functions that enable queries on encrypted data without decryption. EQL works seamlessly with the [CipherCell](/reference/eql/v2/payload) format to provide searchable encryption capabilities directly in PostgreSQL. + +## What is EQL? + +EQL provides the database-side components needed to query encrypted data. Unlike traditional PostgreSQL extensions, EQL is implemented as a collection of types, operators, and functions, making it compatible with managed database providers like AWS RDS that restrict extension installation. + +When combined with the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy), EQL enables: + +- **Exact match queries** using encrypted equality operators +- **Range queries** with order-preserving encryption +- **Pattern matching** using encrypted Bloom filters +- **Unique constraints** on encrypted columns +- **JSON/JSONB operations** on encrypted structured data + +## Core components + +### The `eql_v2_encrypted` type + +The foundation of EQL is the `eql_v2_encrypted` data type, which stores [CipherCells](/reference/eql/v2/payload) containing encrypted data and searchable encrypted metadata. + +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email eql_v2_encrypted, + name eql_v2_encrypted +); +``` + + +The `eql_v2_encrypted` type is required for searchable encryption in PostgreSQL. Regular `JSON` or `JSONB` types can store CipherCells but do not support encrypted queries. + + +### Operators + +EQL provides PostgreSQL operators that work directly with encrypted data: + +```sql +-- Exact match +SELECT * FROM users WHERE email = 'encrypted_search_value'::eql_v2_encrypted; + +-- Range queries +SELECT * FROM products WHERE price > 'encrypted_value'::eql_v2_encrypted; + +-- Pattern matching +SELECT * FROM documents WHERE content LIKE '%encrypted_pattern%'; +``` + +### Functions + +EQL ships in the `eql_v2` schema, with functions in three groups: + +- **Configuration** — register tables, columns, and searchable indexes in the EQL configuration. +- **Index-term extraction** — `eql_v2.hmac_256()` (exact match), `eql_v2.bloom_filter()` (pattern matching), and `eql_v2.ore_block_u64_8_256()` (range) extract a searchable term from an encrypted value. These back the functional indexes — see [Setting up indexes](/reference/eql/v2/indexes). +- **Comparison** — operators (`=`, `<`, `LIKE`, `@>`, …) are the query surface over encrypted columns. + +For the complete function reference — every signature, parameter, and return type — see [EQL functions](/reference/eql/functions). + +## Index types + +EQL supports multiple searchable encryption index types. Each index type enables different query patterns: + +### `unique`: Exact match + +Enables exact equality queries and unique constraints using HMAC-SHA256. + +[Learn more about equality queries](/concepts/searchable-encryption#query-capabilities) + +### `ore`: Range queries + +Enables range comparisons (`<`, `>`, `BETWEEN`) and ordering (`ORDER BY`) using Order Revealing Encryption. + +[Learn more about range queries](/concepts/searchable-encryption#query-capabilities) + +### `match`: Pattern matching + +Enables substring and full-text search (`LIKE`, `ILIKE`) using encrypted Bloom filters with trigrams. + +[Learn more about token matching](/concepts/searchable-encryption#query-capabilities) + +### `ste_vec`: Structured data + +Enables containment queries and JSON-style operations on encrypted arrays and JSONB data. + +## How it works + +EQL leverages PostgreSQL's native indexing capabilities to enable efficient queries on encrypted data. The searchable encrypted metadata within [CipherCells](/reference/eql/v2/payload) is indexed using standard PostgreSQL index types (B-tree for exact/range, GIN for pattern matching). + +When a query is executed: + +1. **Client-side**: The application encrypts the search value using the same encryption scheme, producing a CipherCell with the appropriate searchable encrypted metadata +2. **Database-side**: EQL operators extract and compare the searchable encrypted metadata from both the stored CipherCells and the search CipherCell +3. **Result**: Matching rows are returned without ever decrypting the data in the database + +## Compatibility + +EQL is designed to work with: + +- **PostgreSQL 14+**: Full support for all EQL features +- **Managed databases**: Works with AWS RDS, Azure Database, Google Cloud SQL, and other managed PostgreSQL providers +- **CipherStash SDKs**: Integrates with all CipherStash SDKs as well as CipherStash Proxy + + +Unlike PostgreSQL extensions that require `CREATE EXTENSION`, EQL types and functions are installed directly into your database schema, making it compatible with managed database environments that restrict extension installation. + + +## Related documentation + +- [CipherCell format](/reference/eql/v2/payload): The data structure used by EQL +- [Searchable encryption](/concepts/searchable-encryption): Available encrypted query capabilities diff --git a/content/docs/reference/eql/v2/indexes.mdx b/content/docs/reference/eql/v2/indexes.mdx new file mode 100644 index 0000000..0bbccab --- /dev/null +++ b/content/docs/reference/eql/v2/indexes.mdx @@ -0,0 +1,154 @@ +--- +title: Setting up indexes +description: Create PostgreSQL indexes for encrypted columns. Index syntax differs between self-hosted PostgreSQL and managed databases like Supabase. +type: reference +components: [eql] +verifiedAgainst: + eql: "2.2" +--- + + +EQL v2 reference, for existing v2.2 deployments. New projects use [EQL v3](/reference/eql). + + +Encrypted columns need PostgreSQL indexes for fast queries. Without an index, the database performs a sequential scan: correct but slow at scale. + +Index syntax differs between deployment types. Self-hosted PostgreSQL with full EQL installed supports custom operator classes and can use B-tree indexes directly on `eql_v2_encrypted` columns. Managed databases like Supabase cannot install operator families (they require superuser), so indexes must use extraction functions instead. + +## Deployment matrix + +| Query type | Self-hosted (full EQL) | Supabase | +|---|---|---| +| Equality | `USING btree (col)` with opclass, or `USING hash (eql_v2.hmac_256(col))` | `USING hash (eql_v2.hmac_256(col))` only | +| Range / ORDER BY | `USING btree (col)` with opclass | None (OPE-index work in progress) | +| Pattern match | `USING gin (eql_v2.bloom_filter(col))` | Same | +| JSONB containment | `USING gin (eql_v2.ste_vec(col))` | Same | + + + Range filters (`>`, `>=`, `<`, `<=`) work on Supabase without a range index (they use a sequential scan). `ORDER BY` on encrypted columns is not supported on Supabase at all. Sort application-side after decrypting results. Operator family support for Supabase is in development. + + +--- + +## Equality + +Equality indexes speed up `WHERE col = $1` queries and `IN` lists. + +**Self-hosted (B-tree with operator class):** + +```sql +CREATE INDEX ON users USING btree (email); +``` + +This works because the full EQL install registers a B-tree operator class for `eql_v2_encrypted` that compares HMAC terms. + +**Self-hosted or Supabase (hash on extraction function):** + +```sql +CREATE INDEX ON users USING hash (eql_v2.hmac_256(email)); +``` + +This form works on both deployment types. Use it when you want one index that works everywhere, or when you are on Supabase. + +See queries: [Equality queries](/reference/eql/v2/queries#equality) + +--- + +## Match + +Match indexes speed up `WHERE col LIKE $1` and `ILIKE` queries. They use a GIN index on the Bloom filter extracted from each encrypted value. + +```sql +CREATE INDEX ON users USING gin (eql_v2.bloom_filter(name)); +``` + +This form is identical for self-hosted and Supabase. + +See queries: [Match queries](/reference/eql/v2/queries#match-free-text) + +--- + +## Range and order + +Range indexes support `>`, `>=`, `<`, `<=`, `BETWEEN`, and `ORDER BY` on encrypted columns. + +**Self-hosted (B-tree with operator class):** + +```sql +CREATE INDEX ON users USING btree (age); +``` + +Requires the EQL operator family (`CREATE OPERATOR FAMILY`) to be installed. The full EQL install includes this. The `--exclude-operator-family` install flag omits it. + +**Supabase:** + +Functional range indexes for Supabase are not yet available. Range _filters_ work without an index (sequential scan). `ORDER BY` on encrypted columns is not supported on Supabase. + +See queries: [Range queries](/reference/eql/v2/queries#range-and-ordering) + +--- + +## JSONB + +JSONB indexes support path existence and containment queries on encrypted JSON columns. + +```sql +CREATE INDEX ON documents USING gin (eql_v2.ste_vec(metadata)); +``` + +This form is identical for self-hosted and Supabase. + +See queries: [JSONB queries](/reference/eql/v2/queries#jsonb-queries) + +--- + +## Supabase query forms + +This is the most common source of silent performance problems with encrypted columns on Supabase. + +A functional index on `eql_v2.hmac_256(email)` is only engaged when the query uses the same extraction function. A bare `WHERE email = $1` query does not use the index, even if the index exists. The database falls back to a sequential scan: your query returns correct results, but it scans every row. + +**Wrong (does not use functional index):** + +```sql +SELECT * FROM users WHERE email = $1::eql_v2_encrypted; +``` + +**Right (engages the functional index):** + +```sql +SELECT * FROM users WHERE eql_v2.hmac_256(email) = eql_v2.hmac_256($1::eql_v2_encrypted); +``` + + + SDK wrappers (Drizzle adapter, Supabase wrapper) generate the correct query form automatically. This only matters when you write raw SQL queries against Supabase encrypted columns. If you are using the Drizzle adapter or Supabase wrapper, no action is needed. + + +The same principle applies to `eql_v2.bloom_filter` and `eql_v2.ste_vec` indexes: the extraction function must appear in both the index definition and the query predicate. + +--- + +## Complete example + +```sql filename="migrations/add_encrypted_indexes.sql" +-- Equality index (Supabase-compatible form) +CREATE INDEX users_email_eq_idx ON users USING hash (eql_v2.hmac_256(email)); + +-- Match index +CREATE INDEX users_name_match_idx ON users USING gin (eql_v2.bloom_filter(name)); + +-- JSONB index +CREATE INDEX documents_metadata_ste_idx ON documents USING gin (eql_v2.ste_vec(metadata)); + +-- Range index (self-hosted only — requires operator family) +CREATE INDEX users_age_range_idx ON users USING btree (age); +``` + +--- + +## Related + +- [Searchable encryption queries](/reference/eql/v2/queries): Query patterns for each index type +- [Searchable encryption overview](/concepts/searchable-encryption): How searchable indexes work +- [Supabase integration](/integrations/supabase): Supabase-specific setup and limitations +- [EQL guide](/reference/eql/v2): Full reference for EQL types and functions diff --git a/content/docs/reference/eql/v2/meta.json b/content/docs/reference/eql/v2/meta.json new file mode 100644 index 0000000..c86899c --- /dev/null +++ b/content/docs/reference/eql/v2/meta.json @@ -0,0 +1,4 @@ +{ + "title": "EQL v2", + "pages": ["index", "indexes", "queries", "payload"] +} diff --git a/content/docs/reference/eql/v2/payload.mdx b/content/docs/reference/eql/v2/payload.mdx new file mode 100644 index 0000000..a086ec0 --- /dev/null +++ b/content/docs/reference/eql/v2/payload.mdx @@ -0,0 +1,309 @@ +--- +title: The CipherCell +description: Understand the CipherCell, the CipherStash JSON format that stores ciphertext, searchable encrypted metadata, and fields for querying encrypted data via EQL. +type: reference +components: [eql] +verifiedAgainst: + eql: "2.2" +--- + + +EQL v2 reference, for existing v2.2 deployments. New projects use [EQL v3](/reference/eql). + + +The **CipherCell** is CipherStash's standard format for storing encrypted data in a database. It is a JSON-based structure that combines **encrypted values**, **searchable encrypted metadata**, and **non-sensitive metadata** into a single, self-contained record. +CipherCells are designed to make encrypted data practical to work with in real applications. They can be stored in existing databases (such as PostgreSQL jsonb columns), indexed, queried, and audited without exposing plaintext. + +## What a CipherCell contains + +A CipherCell typically includes: + +- **Encrypted data** + - The ciphertext for one or more sensitive values. + - Each value is encrypted independently using strong authenticated encryption and unique per-value keys. +- **Searchable Encrypted Metadata (SEM)** + - Additional cryptographic material derived from the plaintext that enables secure querying using searchable encryption. + - This metadata allows operations such as equality checks, range queries, or text search to be performed **without decrypting the data**. + - The database can evaluate queries over this metadata, but cannot recover the original values. +- **Non-sensitive metadata** + - Plaintext fields that are safe to expose, such as schema identifiers, versioning information, timestamps, or application-level IDs. + - Keeping this metadata unencrypted allows efficient filtering, indexing, and integration with existing tooling. + +## How CipherCells are used + +CipherCells are stored directly in the database, usually in a JSON-compatible column. +[Encrypt Query Language (EQL)](/reference/eql/v2) understands this structure and provides database functions and operators that work over CipherCells, enabling encrypted search and filtering while preserving strong security guarantees. + +From an application's perspective, a CipherCell behaves like a regular database value: + +- Applications write encrypted data as JSON +- Databases store and index it +- Queries operate on Searchable Encrypted Metadata (SEM) +- Decryption happens only in trusted application code with the right keys and claims + +## Why CipherCells exist + +The CipherCell format solves a common problem with encryption at rest: traditional encryption makes data opaque and hard to query. CipherCells retain the benefits of encryption while enabling: + +- Fine-grained, **per-value** protection +- Searchable encryption over structured data +- Compatibility with existing databases and ORMs +- Clear separation between sensitive and non-sensitive information + +A CipherCell is the **unit of encrypted storage** in CipherStash: a portable, self-describing JSON record that makes encrypted data usable, searchable, and auditable by default. + +## Structure + + +**Required fields**: Only `i` (identifier) and `v` (version) are required. + +**Payload requirement**: Either `c` (ciphertext) or `sv` (structured encryption vector) must be present, but never both. + +**Optional fields**: All searchable encrypted metadata (SEM) fields are optional and only included when the corresponding index types are configured. + + +A CipherCell is stored as a JSON object with the following top-level structure: + +```json +{ + "i": { + "t": "table_name", + "c": "column_name" + }, + "v": 2, + "c": "encrypted_data_in_messagepack_base85", + "hm": "2e182f0c444d1d51f5f70f32d778b2eaa854f5921a4a2acaa4446c44055cb777", + "ob": ["ore_block_1", "ore_block_2"], + "bf": [1234, 5678, 9012] +} +``` + +## Top-level fields + +### `i` - Identifier + +The table and column identifier for this encrypted data. + +**Type**: Object with `t` (table) and `c` (column) properties + +**Required**: Yes + +```json +{ + "i": { + "t": "users", + "c": "email" + } +} +``` + +This field identifies which table and column the encrypted data belongs to, enabling proper decryption and index usage. + +### `v` - Version + +The encryption version used for this CipherCell. + +**Type**: Integer + +**Required**: Yes + +```json +{ + "v": 2 +} +``` + +The version field allows for cryptographic algorithm upgrades over time while maintaining backward compatibility. + +### `c` - Ciphertext (required unless `sv` is present) + +The encrypted record containing the actual plaintext data. + +**Type**: String (MessagePack encoded and Base85 encoded) + +**Required**: Yes (unless `sv` field is present) + +```json +{ + "c": "Xk}0>Z*pVbW@%*8a%F0@" +} +``` + + +Either `c` or `sv` must be present in every CipherCell, but never both. Use `c` for standard encrypted values and `sv` for structured encryption vectors (arrays or JSON structures). + + +### `a` - Array item flag + +Indicates whether this CipherCell represents an item within an array. + +**Type**: Boolean + +**Required**: No + +```json +{ + "a": true +} +``` + +## Searchable Encrypted Metadata (SEM) + +The CipherCell can contain various types of searchable encrypted metadata, each enabling different query capabilities. All SEM fields are optional and only included when the corresponding index type is configured. + +### `hm` - HMAC-SHA256 + +Enables exact match queries using HMAC-SHA256. + +**Type**: Hex-encoded string (64 characters) + +**Index Type**: [Equality](/concepts/searchable-encryption#query-capabilities) + +```json +{ + "hm": "2e182f0c444d1d51f5f70f32d778b2eaa854f5921a4a2acaa4446c44055cb777" +} +``` + +### `ob` - ORE Block + +Enables range queries and ordering using Order Revealing Encryption. + +**Type**: Array of strings + +**Index Type**: [Order / Range](/concepts/searchable-encryption#query-capabilities) + +```json +{ + "ob": [ + "01a2b3c4d5e6f7g8h9i0", + "j1k2l3m4n5o6p7q8r9s0" + ] +} +``` + +### `bf` - Bloom Filter + +Enables substring and pattern matching queries using encrypted Bloom filters with trigrams. + +**Type**: Array of integers + +**Index Type**: [Match](/concepts/searchable-encryption#query-capabilities) + +```json +{ + "bf": [1234, 5678, 9012, 3456, 7890] +} +``` + +### `b3` - Blake3 + +Blake3 hash for exact matches in structured encryption vectors. + +**Type**: Hex-encoded string + +**Used in**: SteVec (Structured Encryption Vector) subfield + +### `s` - Selector + +Selector value for field selection in structured encryption vectors. + +**Type**: String + +**Used in**: SteVec (Structured Encryption Vector) subfield + +### `ocf` - ORE CLWW Fixed-Width + +ORE CLWW (Chenette-Lewi-Weis-Wu) fixed-width scheme for 64-bit integer values in structured encryption vectors. + +**Type**: String + +**Used in**: SteVec (Structured Encryption Vector) subfield + +### `ocv` - ORE CLWW Variable-Width + +ORE CLWW variable-width scheme for string comparison in structured encryption vectors. + +**Type**: String + +**Used in**: SteVec (Structured Encryption Vector) subfield + +### `sv` - Structured Encryption Vector (SteVec) (required unless `c` is present) + +Nested array of CipherCells for supporting containment queries and JSON-style operations. + +**Type**: Array of CipherCell objects + +**Required**: Yes (unless `c` attribute is present) + +```json +{ + "sv": [ + { + "c": "Xk}0>Z*pVbW@%*8a%F0@", + "hm": "hash1...", + "s": "selector1" + }, + { + "c": "Yl~1?A+qWcX#&+9b&G1#", + "hm": "hash2...", + "s": "selector2" + } + ] +} +``` + +SteVec enables queries on array elements and JSON document structures while maintaining encryption. Each element in the `sv` array is itself a CipherCell that can contain SEM fields like `b3`, `s`, `ocf`, and `ocv`. + +## Complete example + +Here's a complete CipherCell with multiple index types enabled: + +```json +{ + "i": { + "t": "products", + "c": "price" + }, + "v": 2, + "c": "Xk}0>Z*pVbW@%*8a%F0@Yl~1?A+qWcX#&+9b&G1#", + "hm": "2e182f0c444d1d51f5f70f32d778b2eaa854f5921a4a2acaa4446c44055cb777", + "ob": [ + "01a2b3c4d5e6f7g8h9i0", + "j1k2l3m4n5o6p7q8r9s0", + "t1u2v3w4x5y6z7a8b9c0" + ], + "bf": [1234, 5678, 9012, 3456, 7890, 2345, 6789] +} +``` + +This CipherCell: +- Belongs to the `price` column of the `products` table +- Uses encryption version 2 +- Contains the encrypted plaintext value +- Supports exact match queries via `hm` +- Supports range queries and ordering via `ob` +- Supports pattern matching via `bf` + +## Design principles + +### Minimal storage + +Only the index types configured for a column are included in the CipherCell. This minimizes storage overhead and ensures optimal performance. + +### Composable indexes + +Multiple index types can be combined on a single column, enabling both exact matches and range queries, or exact matches and pattern matching, depending on application needs. + +### Forward compatibility + +The version field (`v`) enables cryptographic algorithm upgrades without requiring full database re-encryption. Older versions can coexist with newer versions during migration. + +### Standardized format + +The CipherCell format is consistent across all CipherStash SDKs and tools, ensuring interoperability and portability of encrypted data. + +## Database storage + +CipherCells can be stored as JSON in any database that supports JSON data types. +However, for search to be supported using the [Stack SDK](/reference/stack) or [CipherStash Proxy](/reference/proxy), the `eql_v2.encrypted` database type must be used which is available when the Encrypt Query Language (EQL) helpers have been installed. diff --git a/content/docs/reference/eql/v2/queries.mdx b/content/docs/reference/eql/v2/queries.mdx new file mode 100644 index 0000000..8bc39d8 --- /dev/null +++ b/content/docs/reference/eql/v2/queries.mdx @@ -0,0 +1,277 @@ +--- +title: Searchable encryption queries +description: Equality, match, and range query patterns for encrypted PostgreSQL columns, with SDK predicates and raw SQL forms. +type: reference +components: [eql] +verifiedAgainst: + eql: "2.2" +--- + + +EQL v2 reference, for existing v2.2 deployments. New projects use [EQL v3](/reference/eql). + + +This page covers the three query families available for encrypted columns: equality, match (free-text), and range/order. Each section shows the SDK predicate, the raw SQL form, the underlying EQL index, and links to the corresponding index setup. + +For index creation (the `CREATE INDEX` statements your database needs), see [Setting up indexes](/reference/eql/v2/indexes). + +For a conceptual overview of how searchable encryption works, see [Searchable encryption](/concepts/searchable-encryption). + +## Equality + +Exact match on an encrypted column. Uses the `unique` (HMAC-SHA256) index. + +**Schema:** + +```typescript filename="src/schema.ts" +import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema" + +const users = encryptedTable("users", { + email: encryptedColumn("email").equality(), +}) +``` + +**SDK (single value):** + +```typescript filename="src/queries.ts" +const term = await client.encryptQuery("alice@example.com", { + column: users.email, + table: users, + queryType: "equality", +}) + +const result = await pgClient.query( + "SELECT * FROM users WHERE email = $1", + [term.data], +) +``` + +**SDK (IN list):** + +```typescript filename="src/queries.ts" +const terms = await client.encryptQuery([ + { value: "alice@example.com", column: users.email, table: users, queryType: "equality" as const }, + { value: "bob@example.com", column: users.email, table: users, queryType: "equality" as const }, +]) + +// Use each term.data as a separate parameter, or build an ANY($1) query. +``` + +**Drizzle:** + +```typescript filename="src/queries.ts" +const results = await db + .select() + .from(usersTable) + .where(await encryptionOps.eq(usersTable.email, "alice@example.com")) +``` + +**Supabase wrapper:** + +```typescript filename="src/queries.ts" +const { data } = await eSupabase + .from("users", users) + .select("id, email") + .eq("email", "alice@example.com") +``` + +**Raw SQL (self-hosted with EQL operator classes):** + +```sql +SELECT * FROM users WHERE email = $1::eql_v2_encrypted; +``` + +**Raw SQL (Supabase / functional index form):** + +```sql +SELECT * FROM users WHERE eql_v2.hmac_256(email) = eql_v2.hmac_256($1::eql_v2_encrypted); +``` + + + On Supabase, bare `WHERE email = $1` does not use the functional index. Wrap both sides with `eql_v2.hmac_256()` to engage the hash index. The SDK wrappers (Drizzle, Supabase wrapper) handle this automatically. See [Index setup: Supabase callout](/reference/eql/v2/indexes#supabase-query-forms). + + +**Underlying index:** [Equality index setup](/reference/eql/v2/indexes#equality) + +--- + +## Match (free-text) + +Substring and full-text search on an encrypted column. Uses the `match` (Bloom filter) index. Corresponds to `LIKE` / `ILIKE` semantics. + +**Schema:** + +```typescript filename="src/schema.ts" +const users = encryptedTable("users", { + name: encryptedColumn("name").freeTextSearch(), +}) +``` + +**SDK:** + +```typescript filename="src/queries.ts" +const term = await client.encryptQuery("alice", { + column: users.name, + table: users, + queryType: "freeTextSearch", +}) + +const result = await pgClient.query( + "SELECT * FROM users WHERE name LIKE $1", + [term.data], +) +``` + +**Drizzle:** + +```typescript filename="src/queries.ts" +const results = await db + .select() + .from(usersTable) + .where(await encryptionOps.ilike(usersTable.name, "%alice%")) +``` + +**Supabase wrapper:** + +```typescript filename="src/queries.ts" +const { data } = await eSupabase + .from("users", users) + .select("id, name") + .ilike("name", "%alice%") +``` + +**Raw SQL:** + +```sql +SELECT * FROM users WHERE name LIKE $1; +``` + +The Bloom filter index uses a GIN index on the extracted filter term. See [Match index setup](/reference/eql/v2/indexes#match). + +**Underlying index:** [Match index setup](/reference/eql/v2/indexes#match) + +--- + +## Range and ordering + +Comparison (`>`, `>=`, `<`, `<=`, `BETWEEN`) and `ORDER BY` on an encrypted column. Uses the `ore` (Order Revealing Encryption) index. + +**Schema:** + +```typescript filename="src/schema.ts" +const users = encryptedTable("users", { + age: encryptedColumn("age").dataType("number").orderAndRange(), +}) +``` + +**SDK (range filter):** + +```typescript filename="src/queries.ts" +const term = await client.encryptQuery(21, { + column: users.age, + table: users, + queryType: "orderAndRange", +}) + +const result = await pgClient.query( + "SELECT * FROM users WHERE age > $1", + [term.data], +) +``` + +**SDK, ORDER BY (self-hosted only):** + +```typescript filename="src/queries.ts" +// Self-hosted PostgreSQL with EQL operator families installed: +const result = await pgClient.query( + "SELECT * FROM users ORDER BY age ASC", +) + +// Without operator family support (Supabase, or --exclude-operator-family): +const result = await pgClient.query( + "SELECT * FROM users ORDER BY eql_v2.ore_block_u64_8_256(age) ASC", +) +``` + +**Drizzle:** + +```typescript filename="src/queries.ts" +// Range +const results = await db + .select() + .from(usersTable) + .where(await encryptionOps.gte(usersTable.age, 18)) + +// Sort (requires operator family support; not available on Supabase) +const results = await db + .select() + .from(usersTable) + .orderBy(encryptionOps.asc(usersTable.age)) +``` + +**Supabase wrapper:** + +```typescript filename="src/queries.ts" +// Range filter works +const { data } = await eSupabase + .from("users", users) + .select("id, age") + .gte("age", 18) + +// ORDER BY on encrypted columns is not supported on Supabase. +// Sort application-side after decrypting. +``` + + + `ORDER BY` on encrypted columns requires EQL operator families, which need superuser access to install. Supabase does not grant superuser. Range _filters_ (`>`, `>=`, `<`, `<=`) work on both self-hosted and Supabase. Sorting on encrypted columns is not currently supported on Supabase. Sort application-side after decrypting results. Operator family support for Supabase is being developed in collaboration with the Supabase and CipherStash teams. + + +**Underlying index:** [Range index setup](/reference/eql/v2/indexes#range-and-order) + +--- + +## JSONB queries + +Query encrypted JSON columns using path existence or containment. Uses the `ste_vec` index. + +**Schema:** + +```typescript filename="src/schema.ts" +const documents = encryptedTable("documents", { + metadata: encryptedColumn("metadata").searchableJson(), +}) +``` + +**SDK (path existence):** + +```typescript filename="src/queries.ts" +const term = await client.encryptQuery("$.user.role", { + column: documents.metadata, + table: documents, +}) + +const result = await pgClient.query( + "SELECT * FROM documents WHERE eql_v2.ste_vec(metadata) @> $1", + [term.data], +) +``` + +**SDK (containment):** + +```typescript filename="src/queries.ts" +const term = await client.encryptQuery({ role: "admin" }, { + column: documents.metadata, + table: documents, +}) +``` + +**Drizzle:** + +```typescript filename="src/queries.ts" +const results = await db + .select() + .from(documentsTable) + .where(await encryptionOps.jsonbPathExists(documentsTable.metadata, "$.user.role")) +``` + +**Underlying index:** [JSONB index setup](/reference/eql/v2/indexes#jsonb) diff --git a/content/docs/reference/glossary.mdx b/content/docs/reference/glossary.mdx new file mode 100644 index 0000000..616dbf3 --- /dev/null +++ b/content/docs/reference/glossary.mdx @@ -0,0 +1,156 @@ +--- +title: Glossary +description: "Definitions of key CipherStash concepts and terms, from ZeroKMS, keysets, and client keys to EQL, HMAC, and searchable encryption." +type: reference +--- + +## A + +### ABAC (Attribute Based Access Control) + +A dynamic security model that makes access decisions based on a variety of attributes, including user characteristics, resource details, and environmental factors. Unlike [RBAC](#rbac-role-based-access-control), which relies solely on a user's assigned role, ABAC offers more granular and context-aware policies that adapt to changing conditions. + +### Access key + +A persistent authentication credential used for communication with [ZeroKMS](#zerokms) or [CTS](#cts-cipherstash-token-service). See [access keys](/reference/auth/access-keys). + +### Account management + +Activities to administer your CipherStash account, such as billing and adding or removing users. See [billing](/reference/workspace/billing) and [members](/reference/workspace/members). + +## C + +### Ciphertext + +An encrypted version of plaintext, produced by applying an encryption algorithm (a cipher). It is unreadable without a cipher to decrypt it. + +See also: [plaintext](#plaintext). + +### CipherStash CLI + +The command line tool (`stash`) for interacting with CipherStash services. See the [CLI reference](/reference/cli). + +### CipherStash Proxy + +A database proxy that sits between an application and a database, adding encryption in use. Proxy works alongside your existing infrastructure and is fully contained within your environment. See the [Proxy reference](/reference/proxy). + +### Client key + +The cryptographic credential assigned to a programmatic access point for a [keyset](#keyset). A client key can have access to many keysets, and a keyset can be shared by multiple client keys. + +There are two types: + +- **Device-backed client keys** are created automatically by `stash init` and tied to a developer's [device](#device). Used for local development. +- **Application client keys** are created in the Dashboard for production and CI/CD. Identified by a [client ID](#client-id) and client key value, set via environment variables. + +See [client keys](/reference/auth/clients). + +### Client ID + +A unique identifier for a [client key](#client-key). Each client key and client ID is unique to your app. + +### Client key value + +The secret credential set via `CS_CLIENT_KEY`. Together with the [client ID](#client-id), it forms the credential pair for a [client key](#client-key). The client key value is sensitive and must be kept secret. + +### CTS (CipherStash Token Service) + +CTS manages the trust relationships between a workspace and third-party or customer identity providers. It brokers secure access to CipherStash services such as ZeroKMS, ensuring that only authenticated and authorized users gain entry. See [CTS](/security/cts). + +## D + +### Dashboard + +The web interface for managing CipherStash Cloud primitives: workspaces, client keys, keysets, access keys, devices, and members. Available at [dashboard.cipherstash.com](https://dashboard.cipherstash.com/). + +The Dashboard manages cloud infrastructure. Product-specific configuration (encryption schemas, secrets, proxy rules) is handled by the SDKs and CLI. + +### Data access event + +An event triggered by execution of SQL statements by CipherStash Proxy. Includes metadata of statements executed and records accessed. + +### Device + +A unique identity tied to a developer's device and user account, created by `stash init`. Each device has an associated [client key](#client-key) that is automatically granted access to the default [keyset](#keyset). + +Devices are used for local development authentication, with no environment variables required. Production environments use application [client keys](#client-key) instead. See the [quickstart](/get-started/quickstart). + +## E + +### EQL (Encrypt Query Language) + +Our [open-source library](https://github.com/cipherstash/encrypt-query-language) for PostgreSQL users. EQL provides the database-side surface for querying encrypted data: encrypted column types, the operators that compare them, and the term-extractor functions that make indexes work. EQL itself never encrypts anything. See the [EQL reference](/reference/eql). + +## H + +### HMAC (Hash-based Message Authentication Code) + +A cryptographic technique that combines a hash function with a secret key to verify both the integrity and authenticity of a message. Unlike raw hash functions such as SHA-256, HMAC requires a secret key, so only parties with the key can generate valid HMACs. This prevents attackers from pre-computing hash tables (rainbow tables) or guessing values. + +In searchable encryption, HMACs create encrypted search tokens. The key stays on the application side, so the server can match encrypted search tokens without ever learning the plaintext or being able to generate new tokens. See [searchable encryption](/concepts/searchable-encryption) for what an HMAC term does and does not reveal. + +## I + +### IdP (Identity Provider) + +A third-party identity provider, such as Auth0, Okta, or Clerk. + +## J + +### JWT (JSON Web Token) + +A compact, URL-safe means of representing claims between two parties as a JSON object, typically used for authentication and authorization. JWTs are digitally signed to ensure the integrity and authenticity of the information, allowing systems to verify user identity without maintaining server-side sessions. + +## K + +### Keyset + +A core CipherStash Cloud primitive for cryptographic isolation, managed by [ZeroKMS](#zerokms). Each keyset maintains its own set of data encryption keys, and data encrypted under one keyset cannot be decrypted with another. + +Use keysets for tenant isolation, environment separation, regional compliance, or any cryptographic boundary your architecture requires. A [client key](#client-key) can access many keysets, and a keyset can be shared by multiple client keys. In the Secrets SDK, environments map directly to keysets. + +See [key management](/concepts/key-management#keysets-define-the-isolation-boundary). + +## O + +### OIDC (OpenID Connect) + +An identity layer built on top of the OAuth 2.0 protocol that enables applications to verify user identity through an [identity provider](#idp-identity-provider). It facilitates secure single sign-on and standardizes how the identity provider shares identity information, using RESTful APIs and [JSON Web Tokens](#jwt-json-web-token). + +### ORE (Order Revealing Encryption) + +A searchable encryption technique allowing comparison and sorting of encrypted data without decryption. ORE terms reveal the relative order of the values they encode. See [sorting](/reference/eql/sorting) and [searchable encryption](/concepts/searchable-encryption). + +## P + +### Plaintext + +Unencrypted information, readable by humans and computers. + +## R + +### RBAC (Role Based Access Control) + +A security model that assigns access permissions based on a user's role within an organization, streamlining the management of access rights by grouping permissions into predefined roles. Unlike [ABAC](#abac-attribute-based-access-control), which evaluates policies based on a range of attributes, RBAC relies solely on roles. + +## S + +### Searchable encrypted metadata + +An encrypted data structure for finding records in encrypted columns, carried in the value's payload alongside the ciphertext. It replaces the need for full table scans, and supports range, exact, and match queries. + +See [core concepts](/reference/eql/core-concepts) for payload anatomy, and [searchable encryption](/concepts/searchable-encryption) for what each index term reveals. + +## W + +### Workspace + +A workspace contains [keysets](#keyset), client keys, and configuration. A workspace can separate environments (such as dev and prod), be shared with other users, and be associated with a custom identity provider. + +See [workspace and account](/reference/workspace). + +## Z + +### ZeroKMS + +CipherStash's key management service. ZeroKMS provides high-performance batch encryption and decryption, enabling a unique encryption key per field. See [key management](/concepts/key-management). diff --git a/content/docs/reference/index.mdx b/content/docs/reference/index.mdx new file mode 100644 index 0000000..f734fde --- /dev/null +++ b/content/docs/reference/index.mdx @@ -0,0 +1,11 @@ +--- +title: Reference +description: "Precise API documentation for EQL, the Stack SDK, Auth, the CLI, and Proxy." +type: reference +--- + +{/* + Fumadocs requires an index document when generating a metadata-backed folder. + meta.json excludes this file from the page tree, and Next redirects its route + to the first visible Reference page. +*/} diff --git a/content/docs/reference/meta.json b/content/docs/reference/meta.json new file mode 100644 index 0000000..fc7918c --- /dev/null +++ b/content/docs/reference/meta.json @@ -0,0 +1,16 @@ +{ + "title": "Reference", + "icon": "Library", + "pages": [ + "!index", + "eql", + "stack", + "auth", + "cli", + "proxy", + "workspace", + "...", + "agent-skills", + "glossary" + ] +} diff --git a/content/docs/reference/proxy/configuration.mdx b/content/docs/reference/proxy/configuration.mdx new file mode 100644 index 0000000..d49e9e2 --- /dev/null +++ b/content/docs/reference/proxy/configuration.mdx @@ -0,0 +1,360 @@ +--- +title: Configuration +description: "Install and configure CipherStash Proxy: Docker, TOML and environment variables, logging, Prometheus metrics, and the CLI." +type: reference +components: [proxy, eql] +--- + +CipherStash Proxy is available as a [container image](https://hub.docker.com/r/cipherstash/proxy) on Docker Hub. Deploy it locally, as a cloud sidecar, or as a standalone binary. It speaks the PostgreSQL protocol, so your application connects to Proxy exactly the same way it connects to PostgreSQL. + +## Installing Proxy + +The quickest way to run Proxy alongside your application is to add a container to your `docker-compose.yml`: + +```yaml filename="docker-compose.yml" +services: + app: + # Your app container config + db: + # Your Postgres container config + proxy: + image: cipherstash/proxy:latest + container_name: proxy + ports: + - 6432:6432 + - 9930:9930 + environment: + - CS_DATABASE__HOST=${CS_DATABASE__HOST} + - CS_DATABASE__PORT=${CS_DATABASE__PORT} + - CS_DATABASE__USERNAME=${CS_DATABASE__USERNAME} + - CS_DATABASE__PASSWORD=${CS_DATABASE__PASSWORD} + - CS_DATABASE__NAME=${CS_DATABASE__NAME} + - CS_WORKSPACE_CRN=${CS_WORKSPACE_CRN} + - CS_CLIENT_ACCESS_KEY=${CS_CLIENT_ACCESS_KEY} + - CS_DEFAULT_KEYSET_ID=${CS_DEFAULT_KEYSET_ID} + - CS_CLIENT_ID=${CS_CLIENT_ID} + - CS_CLIENT_KEY=${CS_CLIENT_KEY} + - CS_PROMETHEUS__ENABLED=${CS_PROMETHEUS__ENABLED:-true} +``` + +For a fully working example, see the [docker-compose.yml](https://github.com/cipherstash/proxy/blob/main/docker-compose.yml) in the Proxy repository. + +Start the container, then connect your PostgreSQL client to Proxy on TCP 6432: + +```bash cta cta-type="install" example-id="proxy-docker-compose-up" +docker compose up +``` + + +Unlike the Stack SDK, Proxy **always requires credentials to be supplied explicitly**, because it runs as a separate process or container and cannot use the host's device-based authentication. + +Create the four variables with `stash env` or the Dashboard, then supply them to the Proxy container. [Workspace credentials](/reference/workspace/configuration#credentials) shows both options and the complete environment block. + + +## Setting up the database schema + +Proxy queries encrypted data through [EQL](/reference/eql), which must be installed in the target database. Encrypted columns are typed with an EQL domain such as `public.eql_v3_text_eq`, and that type is what fixes the column's searchable capability. There is no database-side configuration table. + +- [Install EQL](/reference/eql) covers the install itself, including the permissions split and Supabase. +- [Core concepts](/reference/eql/core-concepts) covers the variant model and which domain type to choose. +- [Indexes](/reference/eql/indexes) covers adding functional indexes over the term extractors. + +In development, the Proxy container can install EQL for you on startup: + +```bash +CS_DATABASE__INSTALL_EQL=true +``` + +Check the installed version: + +```sql +SELECT eql_v3.version(); +``` + + +`CS_DATABASE__INSTALL_EQL` is for development only. In production, install EQL as a database migration. + + +## Configuration loading order + +Proxy accepts configuration from a TOML file, environment variables, or both. + +1. If `cipherstash-proxy.toml` exists in the current working directory, Proxy reads it first. +2. Proxy then reads any environment variables that are set. +3. When both are present, environment variables override values from the TOML file. + +## Config options + +### `[server]` + +| Option | Env variable | Default | Description | +|---|---|---|---| +| `host` | `CS_SERVER__HOST` | `0.0.0.0` | Proxy listen address | +| `port` | `CS_SERVER__PORT` | `6432` | Proxy listen port | +| `require_tls` | `CS_SERVER__REQUIRE_TLS` | `false` | Enforce TLS for client-to-proxy connections | +| `shutdown_timeout` | `CS_SERVER__SHUTDOWN_TIMEOUT` | `2000` | Milliseconds to wait for connections to drain on shutdown | +| `worker_threads` | `CS_SERVER__WORKER_THREADS` | `NUMBER_OF_CORES/2` or `4` | Number of server worker threads | +| `thread_stack_size` | `CS_SERVER__THREAD_STACK_SIZE` | `2097152` (2 MiB) | Thread stack size in bytes. Automatically set to 4 MiB when log level is `debug` or `trace` | +| `cipher_cache_size` | `CS_SERVER__CIPHER_CACHE_SIZE` | `64` | Maximum number of encryption/decryption operations to cache | +| `cipher_cache_ttl_seconds` | `CS_SERVER__CIPHER_CACHE_TTL_SECONDS` | `3600` | Seconds before cached cipher operations expire | + +### `[database]` + +| Option | Env variable | Default | Description | +|---|---|---|---| +| `host` | `CS_DATABASE__HOST` | `0.0.0.0` | Database host address | +| `port` | `CS_DATABASE__PORT` | `5432` | Database port | +| `name` | `CS_DATABASE__NAME` | (required) | Database name | +| `username` | `CS_DATABASE__USERNAME` | (required) | Database username | +| `password` | `CS_DATABASE__PASSWORD` | (required) | Database password | +| `connection_timeout` | `CS_DATABASE__CONNECTION_TIMEOUT` | none | Milliseconds to hold an idle connection open. In production, set this higher than your application's connection pool idle timeout | +| `with_tls_verification` | `CS_DATABASE__WITH_TLS_VERIFICATION` | `false` | Enable TLS verification between Proxy and the database | +| `config_reload_interval` | `CS_DATABASE__CONFIG_RELOAD_INTERVAL` | `60` | Seconds between encrypted index configuration reloads | +| `schema_reload_interval` | `CS_DATABASE__SCHEMA_RELOAD_INTERVAL` | `60` | Seconds between database schema reloads | + +### `[tls]` + +Configure TLS for client-to-proxy connections. Provide either file paths or inline PEM strings. + +| Option | Env variable | Description | +|---|---|---| +| `certificate_path` | `CS_TLS__CERTIFICATE_PATH` | Path to the public certificate `.crt` file | +| `private_key_path` | `CS_TLS__PRIVATE_KEY_PATH` | Path to the private key file | +| `certificate_pem` | `CS_TLS__CERTIFICATE_PEM` | Public certificate as a PEM string | +| `private_key_pem` | `CS_TLS__PRIVATE_KEY_PEM` | Private key as a PEM string | + +### `[auth]` + +| Option | Env variable | Description | +|---|---|---| +| `workspace_crn` | `CS_WORKSPACE_CRN` | CipherStash workspace CRN | +| `client_access_key` | `CS_CLIENT_ACCESS_KEY` | CipherStash client access key | + +### `[encrypt]` + +| Option | Env variable | Description | +|---|---|---| +| `default_keyset_id` | `CS_DEFAULT_KEYSET_ID` | Default keyset. Omit to enable [multitenant operation](/reference/proxy/multitenant) | +| `client_id` | `CS_CLIENT_ID` | CipherStash client ID | +| `client_key` | `CS_CLIENT_KEY` | CipherStash client key | + +### `[log]` + +| Option | Env variable | Default | Description | +|---|---|---|---| +| `level` | `CS_LOG__LEVEL` | `info` | Global log level. Valid values: `error`, `warn`, `info`, `debug`, `trace` | +| `format` | `CS_LOG__FORMAT` | `pretty` (terminal), `structured` (non-terminal) | Log format. Valid values: `pretty`, `text`, `structured` (JSON) | +| `output` | `CS_LOG__OUTPUT` | `stdout` | Log output destination. Valid values: `stdout`, `stderr` | +| `ansi_enabled` | `CS_LOG__ANSI_ENABLED` | `true` (terminal), `false` (non-terminal) | Enable colored output | +| `slow_statements` | `CS_LOG__SLOW_STATEMENTS` | `false` | Enable slow statement logging | +| `slow_statement_min_duration_ms` | `CS_LOG__SLOW_STATEMENT_MIN_DURATION_MS` | `2000` | Minimum duration in milliseconds for a statement to be considered slow | +| `slow_db_response_min_duration_ms` | `CS_LOG__SLOW_DB_RESPONSE_MIN_DURATION_MS` | `100` | Minimum duration in milliseconds for a database response to be considered slow | + +### `[prometheus]` + +| Option | Env variable | Default | Description | +|---|---|---|---| +| `enabled` | `CS_PROMETHEUS__ENABLED` | `false` | Enable the Prometheus metrics exporter | +| `port` | `CS_PROMETHEUS__PORT` | `9930` | Port for the Prometheus exporter | + +## Full TOML example + +```toml filename="cipherstash-proxy.toml" +[server] +host = "0.0.0.0" +port = "6432" +require_tls = "false" +shutdown_timeout = "2000" +worker_threads = "4" +thread_stack_size = "2097152" +cipher_cache_size = "64" +cipher_cache_ttl_seconds = "3600" + +[database] +host = "0.0.0.0" +port = "5432" +name = "database" +username = "username" +password = "password" +connection_timeout = "300000" +with_tls_verification = "false" +config_reload_interval = "60" +schema_reload_interval = "60" + +[tls] +certificate_path = "./server.crt" +private_key_path = "./server.key" + +[auth] +workspace_crn = "cipherstash-workspace-crn" +client_access_key = "cipherstash-client-access-key" + +[encrypt] +default_keyset_id = "cipherstash-keyset-id" +client_id = "cipherstash-client-id" +client_key = "cipherstash-client-key" + +[log] +level = "info" +format = "pretty" +output = "stdout" +ansi_enabled = "true" +slow_statements = "false" +slow_statement_min_duration_ms = "2000" +slow_db_response_min_duration_ms = "100" + +[prometheus] +enabled = "false" +port = "9930" +``` + +## Development settings + +Defaults are tuned for production. When running Proxy locally, override these for a better development experience. None of these settings are appropriate for production. + +Enable colored, human-readable logs: + +```bash +CS_LOG__FORMAT="pretty" +CS_LOG__ANSI_ENABLED="true" +``` + +Reduce reload intervals when iterating on your schema: + +```bash +CS_DATABASE__CONFIG_RELOAD_INTERVAL="10" +CS_DATABASE__SCHEMA_RELOAD_INTERVAL="10" +``` + +### Slow query logging + +Slow query logging identifies statements that take longer than expected: + +```bash +CS_LOG__SLOW_STATEMENTS="true" +``` + +By default, any statement taking longer than 2000 ms is flagged. Tune the thresholds to match your latency expectations: + +```bash +CS_LOG__SLOW_STATEMENT_MIN_DURATION_MS="500" # statement total time +CS_LOG__SLOW_DB_RESPONSE_MIN_DURATION_MS="200" # database round-trip only +``` + +When a slow statement is detected, Proxy emits a structured log line at the `SLOW_STATEMENTS` target. With `format = "pretty"`, it looks like: + +``` +WARN slow_statement duration_ms=620 query="SELECT * FROM users WHERE ..." +``` + +Use these lines to find queries that need indexes. A query that is unexpectedly slow is usually missing a functional index over the term extractor, or is passing an untyped operand. See [query performance](/reference/eql/indexes). + +To isolate slow-statement output in production, set `CS_LOG__SLOW_STATEMENTS_LEVEL=warn` while keeping the global level at `error`. + +## Docker-specific options + +These environment variables are only available in the Docker container image, not in the binary. + +| Env variable | Description | +|---|---| +| `CS_DATABASE__INSTALL_EQL` | Install EQL on container startup | +| `CS_DATABASE__INSTALL_EXAMPLE_SCHEMA` | Load the example schema on container startup | +| `CS_DATABASE__INSTALL_AWS_RDS_CERT_BUNDLE` | Add the AWS RDS certificate bundle for TLS verification | + + +`CS_DATABASE__INSTALL_EQL` and `CS_DATABASE__INSTALL_EXAMPLE_SCHEMA` are for development use only. Install EQL as a database migration in production. + + +## Per-target log levels + +Override the log level for specific internal components using the pattern `CS_LOG__{TARGET}_LEVEL`. These accept the same values as `CS_LOG__LEVEL`. + +| Env variable | Target | Description | +|---|---|---| +| `CS_LOG__DEVELOPMENT_LEVEL` | `DEVELOPMENT` | General development logging | +| `CS_LOG__AUTHENTICATION_LEVEL` | `AUTHENTICATION` | Client authentication and SASL | +| `CS_LOG__CONFIG_LEVEL` | `CONFIG` | Configuration loading | +| `CS_LOG__CONTEXT_LEVEL` | `CONTEXT` | Connection context | +| `CS_LOG__ENCODING_LEVEL` | `ENCODING` | Data encoding and decoding | +| `CS_LOG__ENCRYPT_LEVEL` | `ENCRYPT` | Encryption operations | +| `CS_LOG__DECRYPT_LEVEL` | `DECRYPT` | Decryption operations | +| `CS_LOG__ENCRYPT_CONFIG_LEVEL` | `ENCRYPT_CONFIG` | Encryption configuration loading | +| `CS_LOG__ZEROKMS_LEVEL` | `ZEROKMS` | ZeroKMS cipher initialization, cache, and connectivity | +| `CS_LOG__MIGRATE_LEVEL` | `MIGRATE` | Schema migration | +| `CS_LOG__PROTOCOL_LEVEL` | `PROTOCOL` | PostgreSQL wire protocol | +| `CS_LOG__MAPPER_LEVEL` | `MAPPER` | SQL statement mapping and transformation | +| `CS_LOG__SCHEMA_LEVEL` | `SCHEMA` | Database schema analysis | +| `CS_LOG__SLOW_STATEMENTS_LEVEL` | `SLOW_STATEMENTS` | Slow statement detection | + +## CLI reference + +```bash +cipherstash-proxy [OPTIONS] [DBNAME] [COMMAND] +``` + +### Commands + +| Command | Description | +|---|---| +| `encrypt` | Encrypt existing plaintext data in the database | +| `help` | Print help information | + +The `encrypt` command encrypts existing plaintext data in place. Follow the safeguards in [Data migration](/guides/migration) before running it against populated production data. + +### Arguments and options + +| Flag | Default | Description | +|---|---|---| +| `DBNAME` | | Database name (positional, optional) | +| `-H, --db-host ` | `127.0.0.1` | Database host | +| `-u, --db-user ` | `postgres` | Database user | +| `-p, --config-file-path ` | `cipherstash-proxy.toml` | Path to the TOML config file | +| `-l, --log-level ` | `info` | Log level | +| `-f, --log-format ` | `pretty` (terminal), `structured` (non-terminal) | Log format | + +## Prometheus metrics + +Enable the exporter with `CS_PROMETHEUS__ENABLED=true`. Metrics are exposed on port `9930` by default. + +| Metric | Type | Description | +|---|---|---| +| `cipherstash_proxy_keyset_cipher_cache_hits_total` | Counter | Keyset-scoped cipher cache hits | +| `cipherstash_proxy_keyset_cipher_cache_miss_total` | Counter | Cipher cache misses requiring initialization | +| `cipherstash_proxy_keyset_cipher_init_total` | Counter | New keyset-scoped cipher initializations | +| `cipherstash_proxy_keyset_cipher_init_duration_seconds` | Histogram | Duration of cipher initialization, including ZeroKMS network call | +| `cipherstash_proxy_clients_active_connections` | Gauge | Current active client connections | +| `cipherstash_proxy_clients_bytes_received_total` | Counter | Bytes received from clients | +| `cipherstash_proxy_clients_bytes_sent_total` | Counter | Bytes sent to clients | +| `cipherstash_proxy_decrypted_values_total` | Counter | Individual values decrypted | +| `cipherstash_proxy_decryption_duration_seconds` | Histogram | Time spent performing decryption | +| `cipherstash_proxy_decryption_duration_seconds_count` | Counter | Number of decryption timing observations | +| `cipherstash_proxy_decryption_duration_seconds_sum` | Counter | Total cumulative decryption time | +| `cipherstash_proxy_decryption_error_total` | Counter | Unsuccessful decryption operations | +| `cipherstash_proxy_decryption_requests_total` | Counter | ZeroKMS decrypt requests | +| `cipherstash_proxy_encrypted_values_total` | Counter | Individual values encrypted | +| `cipherstash_proxy_encryption_duration_seconds` | Histogram | Time spent performing encryption | +| `cipherstash_proxy_encryption_duration_seconds_count` | Counter | Number of encryption timing observations | +| `cipherstash_proxy_encryption_duration_seconds_sum` | Counter | Total cumulative encryption time | +| `cipherstash_proxy_encryption_error_total` | Counter | Unsuccessful encryption operations | +| `cipherstash_proxy_encryption_requests_total` | Counter | ZeroKMS encrypt requests | +| `cipherstash_proxy_rows_encrypted_total` | Counter | Encrypted rows returned to clients | +| `cipherstash_proxy_rows_passthrough_total` | Counter | Non-encrypted rows returned to clients | +| `cipherstash_proxy_rows_total` | Counter | Total rows returned to clients | +| `cipherstash_proxy_server_bytes_received_total` | Counter | Bytes received from the PostgreSQL server | +| `cipherstash_proxy_server_bytes_sent_total` | Counter | Bytes sent to the PostgreSQL server | +| `cipherstash_proxy_statements_execution_duration_seconds` | Histogram | Database SQL execution time | +| `cipherstash_proxy_statements_session_duration_seconds` | Histogram | Total statement processing time (encrypt, execute, decrypt) | +| `cipherstash_proxy_statements_encrypted_total` | Counter | Statements requiring encryption | +| `cipherstash_proxy_statements_passthrough_total` | Counter | Statements not requiring encryption | +| `cipherstash_proxy_statements_total` | Counter | Total statements processed | +| `cipherstash_proxy_statements_unmappable_total` | Counter | Statements that could not be mapped | + +## Reloading configuration + +When Proxy receives a `SIGHUP`, it reloads application-level configuration (`database`, `auth`, `encrypt`, `log`, `prometheus`, `development`) without disrupting active connections. + +Network settings require a full restart: `server.host`, `server.port`, `server.require_tls`, `server.worker_threads`, and any `tls` configuration. + +## Supported architectures + +CipherStash Proxy is available as a Docker container image for `linux/arm64`. diff --git a/content/docs/reference/proxy/errors.mdx b/content/docs/reference/proxy/errors.mdx new file mode 100644 index 0000000..c6dd510 --- /dev/null +++ b/content/docs/reference/proxy/errors.mdx @@ -0,0 +1,217 @@ +--- +title: Errors +description: "Errors returned by CipherStash Proxy, grouped by category, with likely causes and steps to diagnose and resolve each one." +type: reference +components: [proxy] +--- + +Errors CipherStash Proxy can return, grouped by category, with steps to diagnose and resolve each one. + +## Authentication errors + +### Database authentication failed + +**Error:** `Database authentication failed. Check username and password` + +Proxy could not authenticate with the upstream PostgreSQL database. + +1. Check that the configured username and password are correct and can connect to the database. +2. Check that the database uses a supported authentication method: `password`, `md5`, or `scram-sha-256`. See [PostgreSQL password authentication](https://www.postgresql.org/docs/17/auth-password.html). + +### Client authentication failed + +**Error:** `Client authentication failed. Check username and password.` + +Proxy rejected the client connection because the credentials were incorrect. Check that the username and password your application sends match the values configured in Proxy. + +### ZeroKMS authentication failed + +**Error:** `ZeroKMS authentication failed. Check the configured credentials.` + +Proxy could not authenticate with ZeroKMS. + +1. Check that the configured `client` credentials are correct. +2. Check that the active `keyset_name` or `keyset_id` is associated with a keyset in the configured workspace. + +## Mapping errors + +### Invalid parameter + +**Error:** `Invalid parameter for column 'column_name' of type 'cast' in table 'table_name'. (OID 'oid')` + +Each encrypted column has a target type. When encrypting a parameter or literal, Proxy decodes and casts the data into that type. This error means the data cannot be decoded and cast to the expected type. + +Some parameter types are converted automatically. For example, PostgreSQL `INT2`, `INT4`, and `INT8` are converted to the corresponding encrypted integer types. + +Check that the parameter or literal matches the type of the encrypted column. See [core concepts](/reference/eql/core-concepts) for the variant model. + +### Invalid SQL statement + +**Error:** `sql parser error: Expected: SELECT, VALUES, or a subquery in the query body` (varies) + +Proxy could not parse the SQL statement. Check that the statement is valid PostgreSQL. If the statement is correct and the parser is wrong, contact [CipherStash support](https://cipherstash.com/support). + +### Unsupported parameter type + +**Error:** `Encryption of PostgreSQL {name} (OID {oid}) types is not currently supported.` + +The parameter's data type is not supported for encryption. See the [EQL type reference](/reference/eql/core-concepts) for the supported types. + +### Statement could not be type checked + +**Error:** `Statement could not be type checked: '{type-check-error-message}'` + +Proxy checks SQL statements against the database schema in order to transparently encrypt and decrypt data. The behaviour when a type check fails depends on the `mapping_errors_enabled` configuration: + +- When `mapping_errors_enabled` is `false` (default), the error is logged and the statement passes through to the database. +- When `mapping_errors_enabled` is `true`, the error is raised and statement execution halts. + +The default passes statements through because most production systems have a small number of encrypted columns, and blocking on false negatives causes more harm. A statement that passes through unmapped cannot silently write plaintext: the encrypted column's domain `CHECK` rejects any payload missing its required index terms, so the write fails with a PostgreSQL type error instead. See [core concepts](/reference/eql/core-concepts). + +Check that you are running the latest version of Proxy, and contact [CipherStash support](https://cipherstash.com/support) if the error persists. + +### Internal mapper error + +**Error:** `Statement encountered an internal error. This may be a bug in the statement mapping module of CipherStash Proxy.` + +An unexpected error occurred in the statement mapping module. Update to the latest version, and contact [CipherStash support](https://cipherstash.com/support) if the error persists. + +## Encrypt errors + +### Column could not be encrypted + +**Error:** `Column 'column_name' in table 'table_name' could not be encrypted.` + +Proxy uses ZeroKMS for encryption operations. The most likely cause is a network issue reaching the service. + +1. Check that ZeroKMS is available on the [status page](https://status.cipherstash.com/). +2. Check that Proxy has network access to ZeroKMS in the appropriate region. +3. Check that the column's encrypted type matches the data being written. + +### KeysetId could not be parsed + +**Error:** `KeysetId '{id}' could not be parsed using 'SET CIPHERSTASH.KEYSET_ID'. KeysetId should be a valid UUID.` + +The value passed to `SET CIPHERSTASH.KEYSET_ID` is not a valid UUID. The correct syntax is: + +```sql +SET [SESSION] CIPHERSTASH.KEYSET_ID { TO | = } '{KeysetId}' +``` + +### KeysetId could not be set + +**Error:** `Keyset Id could not be set using 'SET CIPHERSTASH.KEYSET_ID'` + +1. Check the syntax: the `keyset_id` value must be in single quotes. +2. Check that the `keyset_id` is a valid UUID. +3. Check that the value is a literal. PostgreSQL `SET` does not support parameterised queries. + +### KeysetName could not be set + +**Error:** `Keyset Name could not be set using 'SET CIPHERSTASH.KEYSET_NAME'` + +1. Check the syntax: the `keyset_name` value must be in single quotes. +2. Check that the value is a literal. PostgreSQL `SET` does not support parameterised queries. + +### Unknown keyset identifier + +**Error:** `Unknown keyset name or id '{keyset}'` + +Proxy could not find a keyset matching the name or ID. + +1. Check that the active `keyset_name` or `keyset_id` is associated with a keyset in the configured workspace. +2. Check that the configured `client` has been granted access to the keyset in the Dashboard. +3. Keyset names are case-sensitive. Check for an exact match. +4. Check that the configured `client` credentials are correct. + +### Could not decrypt data for keyset + +**Error:** `Could not decrypt data for keyset '{keyset_id}'` + +The active `keyset_id` does not match the `keyset_id` of the data being decrypted. Each encrypted record belongs to a keyset with a unique identifier, and Proxy encrypts data using the currently active keyset. If the record's keyset does not match the active one, the data cannot be decrypted. + +1. Check that the `keyset_id` in the configuration matches the `keyset_id` of the encrypted records. +2. If using `SET CIPHERSTASH.KEYSET_ID`, check that the value matches the encrypted records. +3. Check that the configured `client` has been granted access to the `keyset_id`. + +See [multitenant operation](/reference/proxy/multitenant) for per-connection keyset scoping. + +### Plaintext could not be encoded + +**Error:** `Decrypted column could not be encoded as the expected type.` + +When Proxy decrypts data, it casts and encodes the result as the PostgreSQL representation of the column's type. Changing an encrypted column's type after data has been written can cause this error. + +1. Check that the column has the correct encrypted type. +2. Check that the column type has not changed since the data was written. + +### Unknown column + +**Error:** `Column 'column_name' in table 'table_name' has no Encrypt configuration` + +Proxy has no encrypted-column mapping for this column. In EQL v3 a column's searchable capability is fixed by the [domain variant](/reference/eql/core-concepts) it is typed as, so a column Proxy cannot map is usually one that was never given an EQL type: + +```sql +ALTER TABLE users ALTER COLUMN email TYPE public.eql_v3_text_eq; +``` + +Proxy reloads the database schema on the interval set by `CS_DATABASE__SCHEMA_RELOAD_INTERVAL` (60 seconds by default), so a newly typed column may take up to that long to become mappable. + +### Unknown table + +**Error:** `Table 'table_name' has no Encrypt configuration` + +The table has no columns Proxy recognises as encrypted. Type at least one column with an EQL domain variant, as above. + +### Unknown index term + +**Error:** `Unknown Index Term for column '{column_name}' in table '{table_name}'.` + +The encrypted column carries an index term Proxy does not recognise. EQL validates payloads against the column's domain `CHECK`, but direct database writes can bypass the encryption client and store a malformed payload. + +Check the column's type and the payload's term keys against [core concepts](/reference/eql/core-concepts). + +### Column configuration mismatch + +**Error:** `Column configuration for column '{column_name}' in table '{table_name}' does not match the encrypted column.` + +Proxy validates that encrypted columns match their expected type before decrypting. This check prevents confused deputy attacks and should not appear during normal operation. Contact [CipherStash support](https://cipherstash.com/support) if it persists. + +## Decrypt errors + +### Column could not be deserialised + +**Error:** `Column 'column_name' in table 'table_name' could not be deserialised.` + +Proxy stores encrypted data and search terms as `jsonb`. This error indicates an internal issue deserialising and extracting the ciphertext for decryption. It can occur if the encrypted data has been altered by another process. + +Check that the data in the encrypted column is a well-formed EQL payload. See [payload anatomy](/reference/eql/core-concepts). Contact [CipherStash support](https://cipherstash.com/support) if the error persists. + +## Configuration errors + +### Missing or invalid TLS configuration + +**Errors:** + +``` +Invalid Transport Layer Security (TLS) certificate. +Invalid Transport Layer Security (TLS) private key. +Missing Transport Layer Security (TLS) certificate at path: {path}. +Missing Transport Layer Security (TLS) private key at path: {path}. +``` + +For path-based configuration, check that the certificate and key exist at the specified paths and are valid. For PEM-based configuration, check that the certificate and key values are valid. See [TLS configuration](/reference/proxy/configuration). + +### Network configuration change requires restart + +**Error:** `Network configuration change requires restart` + +When Proxy receives a `SIGHUP`, it reloads application-level configuration without disrupting active connections. These network settings require a full restart: + +- `server.host` +- `server.port` +- `server.require_tls` +- `server.worker_threads` +- `tls` (any TLS configuration) + +Stop Proxy, update the configuration, then restart. diff --git a/content/docs/reference/proxy/index.mdx b/content/docs/reference/proxy/index.mdx new file mode 100644 index 0000000..446c501 --- /dev/null +++ b/content/docs/reference/proxy/index.mdx @@ -0,0 +1,23 @@ +--- +title: Proxy +navTitle: Overview +description: "Configuration, message flow, multitenant operation, and error reference for CipherStash Proxy." +type: reference +components: [proxy] +--- + +CipherStash Proxy sits between your application and PostgreSQL, encrypting query parameters on the way in and decrypting results on the way out. Your application connects to Proxy exactly as it connects to PostgreSQL, so adopting encryption needs no application changes. + +Proxy speaks the PostgreSQL wire protocol (it is based on [pgcat](https://github.com/pgcat/pgcat)) and is distributed as a [container image](https://hub.docker.com/r/cipherstash/proxy) on Docker Hub. It is an [EQL](/reference/eql) client: it produces the encrypted payloads EQL stores, and types the bound parameters EQL's operators resolve against. + +## In this section + +- [Configuration](/reference/proxy/configuration) covers installation, the TOML and environment-variable options, logging, Prometheus metrics, and the CLI. +- [Message flow](/reference/proxy/message-flow) explains how Proxy intercepts the extended query protocol, for debugging unmappable statements. +- [Multitenant operation](/reference/proxy/multitenant) covers per-connection keyset scoping and encrypted mapping. +- [Errors](/reference/proxy/errors) lists the errors Proxy returns, with causes and remediation. + +## Related + +- [EQL reference](/reference/eql) for the database-side surface Proxy queries against. +- [Stack SDK](/reference/stack) for the application-level alternative to Proxy. diff --git a/content/docs/reference/proxy/message-flow.mdx b/content/docs/reference/proxy/message-flow.mdx new file mode 100644 index 0000000..896dc44 --- /dev/null +++ b/content/docs/reference/proxy/message-flow.mdx @@ -0,0 +1,83 @@ +--- +title: Message flow +description: "How CipherStash Proxy handles PostgreSQL Parse and Bind messages to transparently encrypt and decrypt query parameters." +type: reference +components: [proxy] +--- + +This page explains the internal message handling flow, for advanced users debugging unmappable statements or unexpected Proxy behaviour. CipherStash Proxy sits between your application and PostgreSQL and intercepts the PostgreSQL extended query protocol. It encrypts parameters before they reach the database and decrypts values before they reach your application. + +## Extended query protocol overview + +The PostgreSQL extended query protocol uses a sequence of messages to execute parameterised queries. The two most relevant messages for encryption are: + +- **Parse**: the client sends a SQL statement with parameter placeholders (`$1`, `$2`, and so on). Proxy inspects the statement and maps column references to their encrypted column types. +- **Bind**: the client sends parameter values to bind to a prepared statement. If Proxy recognised the statement during Parse, it encrypts the parameters here before forwarding them. + +Binding a parameter is also what types it. EQL's encrypted operators only resolve against a typed operand, so the Bind step is what makes an index-backed query possible. See [typed operands](/reference/eql/core-concepts) for why. + +## Parse flow + +When Proxy receives a Parse message, it determines whether the SQL statement references encrypted columns. + +![Parse message flow diagram](/images/proxy/parse.svg) + +1. Proxy checks whether the statement is encryptable, that is, whether it references at least one encrypted column. +2. If it is encryptable, Proxy maps the column references to their encrypted column types. +3. If the statement includes literal parameter values, Proxy rewrites them as encrypted values immediately. +4. Proxy adds the statement and its column mapping to the connection context for use during Bind. +5. Proxy forwards the (possibly rewritten) Parse message to PostgreSQL. + +If the statement is not encryptable, Proxy forwards it unchanged. + +## Bind flow + +When Proxy receives a Bind message, it looks up the corresponding statement in the connection context. + +![Bind message flow diagram](/images/proxy/bind.svg) + +1. Proxy checks whether the statement that this Bind message references is in the context, that is, whether it was processed during Parse. +2. If it is, Proxy encrypts each parameter value according to the column mapping recorded during Parse. +3. Proxy rewrites the parameter values in the Bind message with the encrypted payloads. +4. Proxy creates a portal for the bound statement and adds it to the context. +5. Proxy forwards the rewritten Bind message to PostgreSQL. + +If the statement is not in context, Proxy creates a portal without encryption and forwards it unchanged. + +## Pipelining + +PostgreSQL supports pipelining: the client can send multiple messages without waiting for responses. Proxy must track Describe and Execute messages to correlate server responses with the right statements or portals, since responses arrive in order but may interleave. + +``` + Sequential Pipelined +| Client | Server | | Client | Server | +|----------------|-----------------| |----------------|-----------------| +| send query 1 | | | send query 1 | | +| | process query 1 | | send query 2 | process query 1 | +| receive rows 1 | | | send query 3 | process query 2 | +| send query 2 | | | receive rows 1 | process query 3 | +| | process query 2 | | receive rows 2 | | +| receive rows 2 | | | receive rows 3 | | +``` + +The PostgreSQL server always processes queries in sequential order, even when pipelined. Proxy preserves this ordering when encrypting parameters and decrypting results. + +## Unmappable statements + +Some statements cannot be mapped to an encrypted column. This happens when: + +- The statement uses a function or expression that obscures the column reference, for example `CAST(email AS text)`. +- The column is referenced through a view, subquery, or CTE that Proxy cannot resolve. +- The statement uses a SQL feature Proxy does not yet parse, for example certain `COPY` forms. + +When a statement is unmappable, Proxy forwards it to PostgreSQL unmodified. No encryption or decryption occurs. The `cipherstash_proxy_statements_unmappable_total` Prometheus metric tracks how often this happens. Set `CS_LOG__MAPPER_LEVEL=debug` to see which statements are unmappable and why. + + +An unmappable `SELECT` returns the raw encrypted payload rather than plaintext. An unmappable `INSERT` or `UPDATE` fails the encrypted column's domain `CHECK` rather than writing plaintext, so a mapping failure never silently stores unencrypted data. + + +## Related + +- [Proxy configuration](/reference/proxy/configuration) for `CS_LOG__MAPPER_LEVEL` and the Prometheus metrics. +- [Proxy errors](/reference/proxy/errors) for the errors raised when mapping or type checking fails. +- [EQL core concepts](/reference/eql/core-concepts) for the typed-operand rule and the domain `CHECK`. diff --git a/content/docs/reference/proxy/meta.json b/content/docs/reference/proxy/meta.json new file mode 100644 index 0000000..7d70335 --- /dev/null +++ b/content/docs/reference/proxy/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Proxy", + "pages": ["index", "configuration", "message-flow", "multitenant", "errors"] +} diff --git a/content/docs/reference/proxy/multitenant.mdx b/content/docs/reference/proxy/multitenant.mdx new file mode 100644 index 0000000..9bc91c4 --- /dev/null +++ b/content/docs/reference/proxy/multitenant.mdx @@ -0,0 +1,77 @@ +--- +title: Multitenant operation +description: "Scope CipherStash Proxy connections to tenant-specific keysets and manage encrypted mapping at runtime." +type: reference +components: [proxy, platform] +--- + +CipherStash Proxy supports multitenant applications using ZeroKMS keysets, giving strong cryptographic separation between tenants. + +Each tenant is associated with a keyset, and data is protected by separate encryption keys. Scope a Proxy connection to a specific keyset at runtime with the `SET CIPHERSTASH.KEYSET` commands. Once a keyset is set for a connection, all subsequent operations are scoped to it, and data can only be decrypted by the keyset that encrypted it. + +A keyset `name` is unique to a workspace and functions as an alias, so you can associate a keyset with an arbitrary identifier such as an internal `TenantId`. + + +Proxy must be configured **without** a `CS_DEFAULT_KEYSET_ID` to enable multitenant operation. If a default keyset is configured, the `SET CIPHERSTASH.KEYSET` commands return an error. + + +## Keyset commands + +### SET CIPHERSTASH.KEYSET_ID + +Sets the active keyset for the current connection using a keyset UUID. + +```sql +SET CIPHERSTASH.KEYSET_ID = '2cace9db-3a2a-4b46-a184-ba412b3e0730'; +``` + +### SET CIPHERSTASH.KEYSET_NAME + +Sets the active keyset for the current connection using a keyset name. + +```sql +SET CIPHERSTASH.KEYSET_NAME = 'tenant-1'; +``` + +### Usage notes + +- Execute `SET CIPHERSTASH.KEYSET` before performing any encryption operations. +- The keyset remains active for the duration of the connection, or until a subsequent `SET CIPHERSTASH.KEYSET` command. +- The active keyset is connection-scoped and does not affect other connections. +- Both commands take a literal value in single quotes. PostgreSQL `SET` does not support parameterised queries. + +Joining encrypted columns across two keysets does not work, because equality is only meaningful within a keyset. See [joins](/reference/eql/joins) for the same-keyset constraint. + +## Disabling encrypted mapping + +CipherStash Proxy transforms the plaintext SQL statements your application issues into statements on encrypted columns. This process is called encrypted mapping. + +In some circumstances you may need to disable encrypted mapping for one or more SQL statements, for example when performing a data transformation with complex logic directly in the database using `plpgsql`. + +Use `SET` to change the `CIPHERSTASH.UNSAFE_DISABLE_MAPPING` configuration parameter. It is always scoped to the connection session. + +```sql +SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true; -- disable +SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = false; -- re-enable +``` + + +If mapping is disabled, sensitive data may not be encrypted and may appear in logs. + + +Proxy and EQL together give some protection against reading or writing plaintext in encrypted columns. An unmapped `SELECT` returns the encrypted payload rather than plaintext. An unmapped `INSERT` or `UPDATE` fails the encrypted column's domain `CHECK` with a PostgreSQL type error, because every EQL domain variant requires its index terms to be present in the payload. See [core concepts](/reference/eql/core-concepts) for the variant model. + +## Prepared statements and mapping + +CipherStash Proxy only decrypts data from SQL statements it has explicitly checked and mapped. + +If mapping is disabled, any subsequent `PREPARE` skips the mapping process. If mapping is re-enabled later, data returned from those prepared statements is still not decrypted. + +To restore mapping, encryption, and decryption of prepared statements, either open a new connection or prepare the statement again after re-enabling mapping. + +This behaviour is expected. When a client prepares a statement, it sends the SQL in a `parse` message. Once prepared, the client refers to the statement by name and skips the `parse` step. If mapping was disabled during `parse`, Proxy never mapped the statement, so data returned from subsequent executions is never decrypted. See [message flow](/reference/proxy/message-flow). + +## Related + +- [Key management](/concepts/key-management#keysets-define-the-isolation-boundary) for the cryptographic isolation model. +- [Proxy configuration](/reference/proxy/configuration) for `CS_DEFAULT_KEYSET_ID`. diff --git a/content/docs/reference/stack/api-reference/meta.json b/content/docs/reference/stack/api-reference/meta.json new file mode 100644 index 0000000..45717c6 --- /dev/null +++ b/content/docs/reference/stack/api-reference/meta.json @@ -0,0 +1,17 @@ +{ + "title": "API reference", + "pages": [ + "index", + "Package-exports", + "adapter-kit", + "dynamodb", + "encryption", + "encryption.v3", + "eql.v3", + "errors", + "identity", + "schema", + "types-public", + "wasm-inline" + ] +} diff --git a/content/docs/reference/stack/index.mdx b/content/docs/reference/stack/index.mdx new file mode 100644 index 0000000..4b60194 --- /dev/null +++ b/content/docs/reference/stack/index.mdx @@ -0,0 +1,33 @@ +--- +title: Overview +description: "The core @cipherstash/stack package for client-side encryption, searchable schemas, identity controls, and encrypted query operands." +type: reference +components: [encryption, eql] +audience: [developer] +--- + +`@cipherstash/stack` is the core TypeScript SDK for CipherStash. It encrypts and decrypts values in your application, defines the searchable capabilities of encrypted columns, and creates encrypted operands for queries against [EQL](/reference/eql). + +Database and framework wrappers are separate packages. Use the [Supabase](/integrations/supabase), [Prisma ORM 8](/integrations/prisma), or [Drizzle](/integrations/drizzle) integration when you want encryption built into that library's query flow. + +## What the package provides + +| Area | Purpose | +| --- | --- | +| Encryption client | Encrypt and decrypt individual values, batches, and models. | +| Schema builders | Describe tables, columns, value types, and searchable capabilities. | +| Query encryption | Produce typed encrypted operands for equality, range, and search queries. | +| Identity controls | Bind encryption and decryption to federated identity claims. | +| DynamoDB helpers | Encrypt and decrypt DynamoDB attributes and expressions. | +| Error types | Inspect operation failures without parsing error messages. | + +## Start here + + + + Install the package, define an encrypted schema, and encrypt, query, and decrypt a value. + + + Browse the generated functions, classes, interfaces, and type aliases exported by the package. + + diff --git a/content/docs/reference/stack/meta.json b/content/docs/reference/stack/meta.json new file mode 100644 index 0000000..1f08609 --- /dev/null +++ b/content/docs/reference/stack/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Stack SDK", + "pages": ["index", "usage", "api-reference"] +} diff --git a/content/docs/reference/stack/usage.mdx b/content/docs/reference/stack/usage.mdx new file mode 100644 index 0000000..51ff988 --- /dev/null +++ b/content/docs/reference/stack/usage.mdx @@ -0,0 +1,108 @@ +--- +title: Usage +description: "Install @cipherstash/stack and use its core encryption client with an EQL v3 encrypted column." +type: reference +components: [encryption, eql] +audience: [developer] +--- + +## Install + +```bash cta cta-type="install" example-id="install-stack" +npm install @cipherstash/stack +``` + +Authenticate locally with `stash auth login`, or configure the `CS_*` machine credentials described in [workspace configuration](/reference/workspace/configuration). + +## Define a schema and client + +The application schema tells Stack which columns are encrypted and which query capabilities they need. It must match the [EQL domain](/reference/eql/core-concepts) used by the database column. + +```typescript filename="src/encryption.ts" +import { Encryption, encryptedColumn, encryptedTable } from "@cipherstash/stack" + +export const users = encryptedTable("users", { + email: encryptedColumn("email").equality(), +}) + +export const encryption = await Encryption({ schemas: [users] }) +``` + +An equality-enabled text column uses the corresponding EQL type: + +```sql +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq +); +``` + +## Encrypt and store + +Stack operations return a `Result` with either `data` or `failure`: + +```typescript +const encrypted = await encryption.encrypt("alice@example.com", { + table: users, + column: users.email, +}) + +if (encrypted.failure) throw encrypted.failure + +await db.query("INSERT INTO users (email) VALUES ($1)", [encrypted.data]) +``` + +Encryption happens before the value leaves the application process. + +## Query an encrypted column + +Encrypt the operand with the same table and column definition, then cast it to the column's EQL domain in SQL: + +```typescript +const term = await encryption.encryptQuery("alice@example.com", { + table: users, + column: users.email, +}) + +if (term.failure) throw term.failure + +const result = await db.query( + "SELECT id, email FROM users WHERE email = $1::public.eql_v3_text_eq", + [term.data], +) +``` + +The database compares encrypted terms and never receives the plaintext operand. See [EQL filtering](/reference/eql/filtering) for the available query forms. + +## Decrypt a result + +```typescript +const decrypted = await encryption.decrypt(result.rows[0].email) + +if (decrypted.failure) throw decrypted.failure + +console.log(decrypted.data) +``` + +For every exported signature and option, use the [generated API reference](/reference/stack/api-reference). For an end-to-end project setup, follow the [quickstart](/get-started/quickstart). + +## Upgrading from `@cipherstash/protect` + +The current package is `@cipherstash/stack`. Replace the retired package and +rename its schema builders: + +| Retired API | Stack API | +| --- | --- | +| `protect(config)` | `Encryption(config)` | +| `csTable(name, columns)` | `encryptedTable(name, columns)` | +| `csColumn(name)` | A typed column from `types`, such as `types.TextEq(name)` | + +```bash +npm uninstall @cipherstash/protect +npm install @cipherstash/stack +``` + +Use the v3 exports shown above for new schemas. Data already written in an +older format needs an explicit migration plan; do not change the package and +database column format in a single irreversible deployment. Follow +[Data migration](/guides/migration) for the staged rollout. diff --git a/content/docs/reference/workspace/billing.mdx b/content/docs/reference/workspace/billing.mdx new file mode 100644 index 0000000..2ad28ca --- /dev/null +++ b/content/docs/reference/workspace/billing.mdx @@ -0,0 +1,36 @@ +--- +title: Billing +description: "How CipherStash per-workspace billing works, including plans, upgrades, downgrades, and Stripe." +type: reference +components: [platform] +audience: [cto] +reviewBy: "2027-01-09" +--- + +CipherStash uses a **per-workspace billing model**. Each workspace has its own plan and usage limits. Organizations and team members are always free and unlimited. + +## Plans + +Each workspace is on a plan. Plans differ in their usage limits and in which features they unlock, such as keyset isolation and [lock contexts](/solutions/provable-access). See [pricing](https://cipherstash.com/pricing) for the current plans, their prices, and their limits. + +Enterprise is an organization-level plan with custom pricing and dedicated support. Enterprise organizations bypass per-workspace billing entirely. Contact [sales@cipherstash.com](mailto:sales@cipherstash.com). + +## Managing workspace billing + +Each workspace has a billing page reachable from the workspace sidebar in the [Dashboard](https://dashboard.cipherstash.com). From there you can view your current plan and usage metrics, upgrade or downgrade, cancel your subscription, and open the Stripe billing portal for payment methods and invoices. + +## How billing works + +- **One Stripe customer per organization.** All workspaces in your organization share the same Stripe customer. +- **One subscription per workspace.** Each workspace has its own Stripe subscription tied to its plan. +- **Proration on upgrades.** Upgrading mid-cycle charges a prorated amount for the remainder of the billing period. +- **Downgrades at period end.** Downgrading takes effect at the end of the current billing period. +- **Cancellation.** Canceling keeps the plan active until the end of the billing period, then reverts to Free. + +## Enterprise and AWS Marketplace + +Organizations on Enterprise plans or subscribed through AWS Marketplace have billing managed at the organization level, and all their workspaces have unlimited access to all features. Manage enterprise billing from the Organization Profile. + +## Need help? + +Contact [support@cipherstash.com](mailto:support@cipherstash.com) for billing questions. diff --git a/content/docs/reference/workspace/configuration.mdx b/content/docs/reference/workspace/configuration.mdx new file mode 100644 index 0000000..e7701b7 --- /dev/null +++ b/content/docs/reference/workspace/configuration.mdx @@ -0,0 +1,45 @@ +--- +title: Configuration +description: "Configure a CipherStash workspace for local development and production: the workspace CRN, client and access keys, and keyset selection." +type: reference +components: [platform, encryption] +--- + +A workspace is identified by its **CRN** and accessed with a **client key** and an **access key**. How those credentials reach your application differs between local development and production. + +## Credentials + + + +## What the deployment variables mean + +| Credential | Description | +|---|---| +| Workspace CRN | Identifies your workspace and its region, for example `crn:ap-southeast-2.aws:your-workspace-id` | +| Client ID | Identifies your application client key | +| Client key | Your half of the dual-party key split | +| Access key | Authenticates API calls to CipherStash | + +These are the same credentials the [Stack SDK](/reference/stack) and [Proxy](/reference/proxy/configuration) consume. For build-time availability, environment separation, and production rollout gates, see [Deployment](/guides/deployment). + +## Keysets + +A workspace can hold many keysets. Data encrypted under one keyset cannot be decrypted with another, which is what makes keysets the boundary for tenant isolation, environment separation, and regional compliance. + +To use a specific keyset, pass the `keyset` option when constructing the client: + +```typescript filename="keyset-config.ts" +const client = await Encryption({ + schemas: [users], + config: { + keyset: { name: "tenant-a" }, + }, +}) +``` + +Proxy scopes keysets per connection instead. See [multitenant operation](/reference/proxy/multitenant). + +## Related + +- [Key management](/concepts/key-management#keysets-define-the-isolation-boundary) for the isolation model. +- [Billing](/reference/workspace/billing) for the plans and how per-workspace billing works. diff --git a/content/docs/reference/workspace/index.mdx b/content/docs/reference/workspace/index.mdx new file mode 100644 index 0000000..1f484f8 --- /dev/null +++ b/content/docs/reference/workspace/index.mdx @@ -0,0 +1,22 @@ +--- +title: Workspace & account +navTitle: Overview +description: "Workspace configuration, organization and workspace members, and per-workspace billing." +type: reference +components: [platform] +--- + +A **workspace** is the unit of isolation and billing in CipherStash. It holds your keysets, clients, access keys, and configuration. Workspaces belong to an **organization**, which owns the member list and the billing relationship. + +Use workspaces to separate environments (development from production), to scope access for a team, or to associate a custom identity provider. + +## In this section + +- [Configuration](/reference/workspace/configuration) covers workspace credentials, the workspace CRN, and keyset selection. +- [Members](/reference/workspace/members) covers organization and workspace membership, and developer device onboarding. +- [Billing](/reference/workspace/billing) covers the per-workspace billing model, and how upgrades and downgrades work. + +## Related + +- [Access keys](/reference/auth/access-keys) for the credentials that authenticate to ZeroKMS and CTS. +- [Glossary](/reference/glossary) for the difference between a workspace, a keyset, a client key, and a device. diff --git a/content/docs/reference/workspace/members.mdx b/content/docs/reference/workspace/members.mdx new file mode 100644 index 0000000..afa3a4e --- /dev/null +++ b/content/docs/reference/workspace/members.mdx @@ -0,0 +1,32 @@ +--- +title: Members +description: "Manage CipherStash organization members and workspace memberships, and onboard developer devices for local development." +type: reference +components: [platform] +--- + +There are two types of membership in CipherStash: organization memberships and workspace memberships. Every user belongs to at least one organization, and can be invited to any number of workspaces inside that organization. + +## Organization memberships + +Organization memberships control which users have access to CipherStash in general. Once a user is added to an organization, they can be granted access to any number of workspaces inside it. + +Add a member from **Members** in the Dashboard's organization sidebar. + +## Workspace memberships + +Workspace memberships control which users have access to a specific workspace. Once a user is added to a workspace, they have full access to managing it, including creating and managing keysets, clients, and access keys. + +Add a member from the [workspace settings](https://dashboard.cipherstash.com/workspaces/_/settings) page in the Dashboard. + +## Developer device onboarding + +After a member is added to an organization and granted workspace access, they initialize their local development environment: + +```bash cta cta-type="quickstart" example-id="stash-init-member" +npx stash init +``` + +This creates a unique [device](/reference/glossary#device) and [client key](/reference/glossary#client-key) for that developer, with automatic access to the default [keyset](/reference/glossary#keyset). No environment variables are needed for local development. + +See [team onboarding](/guides/deployment#team-onboarding) for the full workflow. diff --git a/content/docs/reference/workspace/meta.json b/content/docs/reference/workspace/meta.json new file mode 100644 index 0000000..caec042 --- /dev/null +++ b/content/docs/reference/workspace/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Workspace & account", + "pages": ["index", "configuration", "members", "billing"] +} diff --git a/content/docs/security/audit-logging.mdx b/content/docs/security/audit-logging.mdx new file mode 100644 index 0000000..5a8a406 --- /dev/null +++ b/content/docs/security/audit-logging.mdx @@ -0,0 +1,54 @@ +--- +title: Audit logging +description: "How CipherStash Proxy identifies query patterns, redacts SQL, and associates access events with records and identities." +type: concept +components: [proxy, platform] +audience: [ciso, developer] +--- + +CipherStash Proxy can produce data-access events without requiring application +queries to be instrumented. Its audit pipeline identifies the query pattern, +removes literal values, determines which records were accessed, and associates +the event with the authenticated client or identity context. + +This page describes what Proxy contributes to an event. Retention, export, and +SIEM integration depend on the audit service and deployment configuration. + +## Statement fingerprinting + +Proxy fingerprints the parsed SQL statement so semantically equivalent query +shapes can be grouped across parameter values and environments. The fingerprint +identifies a query pattern without using the literal values supplied by the +application. + +## SQL redaction + +Literal values are stripped before SQL is included in an event. Table, column, +and function names remain so the event is useful for investigation. If Proxy +cannot safely parse and redact a statement, it does not transmit the statement +text. + +Parameterized queries sent through PostgreSQL's extended protocol already +keep bound values separate from the SQL text; redaction also covers literals +embedded directly in a statement. + +## Record identification + +Proxy can add missing primary-key columns to an internal form of the query, +then reconcile returned identifiers with their source tables. Injected columns +are removed before the result is returned to the application, so the result +shape remains unchanged. + +## Event pipeline + +1. **Fingerprint** the parsed query shape. +2. **Redact** static values from the SQL representation. +3. **Identify** primary keys for records touched by the query. +4. **Reconcile** identifiers with tables and remove injected fields from the + application result. +5. **Attribute** the event to the client and, when OIDC federation is used, the + authenticated identity. + +For cryptographic identity binding and SDK audit metadata, see +[provable access control](/solutions/provable-access#audit-logging). For Proxy +operation and configuration, see the [Proxy reference](/reference/proxy). diff --git a/content/docs/security/compliance/index.mdx b/content/docs/security/compliance/index.mdx new file mode 100644 index 0000000..98b0846 --- /dev/null +++ b/content/docs/security/compliance/index.mdx @@ -0,0 +1,49 @@ +--- +title: Compliance +navTitle: Overview +description: "How CipherStash capabilities support a compliance control design, and which responsibilities remain with the customer." +type: concept +components: [encryption, platform, proxy] +audience: [ciso, cto] +reviewBy: "2027-01-31" +--- + +CipherStash supplies technical controls that can support a compliance program: +field-level encryption, regional key management, cryptographic isolation, +identity-bound decryption, and data-access evidence. A framework outcome still +depends on how those controls are configured and on the surrounding application, +cloud, operational, and organizational controls. + +## Capability map + +| Requirement area | Relevant CipherStash capability | Customer responsibility | +| --- | --- | --- | +| Protect sensitive stored data | Application-level field encryption | Classify fields and ensure every write path encrypts them | +| Restrict key access | Client keys, access-key roles, and keyset grants | Apply least privilege, rotate credentials, and control the deployment secret store | +| Isolate tenants or environments | Separate keysets or workspaces | Define and enforce the boundary consistently in application and database access | +| Attribute access to a user | OIDC federation and lock contexts | Configure the identity provider and choose stable, appropriate claims | +| Produce data-access evidence | ZeroKMS derivation context and Proxy audit events | Configure retention, review, alerting, and SIEM ingestion | +| Keep key material regional | Region-bound workspaces | Deploy applications and databases in the intended region and govern cross-border access | +| Remove access to encrypted data | Revoke client grants or destroy the relevant key boundary | Confirm scope, backups, legal holds, and recovery requirements before destructive action | + +## Common frameworks + +These capabilities can contribute evidence or control implementation for +frameworks such as SOC 2, HIPAA, GDPR, PCI DSS, ISO 27001, and regional privacy +laws. They do not by themselves make a workload compliant, and the exact +mapping depends on the organization's scope and assessor interpretation. + +Use the following pages when building a control narrative: + +- [Cryptography](/security/cryptography) for algorithms, key hierarchy, data + flow, and trust boundaries. +- [Key management](/concepts/key-management) for per-value keys, split control, + keysets, and revocation. +- [Data residency](/solutions/data-residency) for regional deployment patterns. +- [Provable access control](/solutions/provable-access) and + [audit logging](/security/audit-logging) for identity and evidence. +- [Access keys](/reference/auth/access-keys) and + [client keys](/reference/auth/clients) for least-privilege credentials. + +Contact [support@cipherstash.com](mailto:support@cipherstash.com) for current +assurance reports, contractual documents, or framework-specific questions. diff --git a/content/docs/security/compliance/meta.json b/content/docs/security/compliance/meta.json new file mode 100644 index 0000000..346ed94 --- /dev/null +++ b/content/docs/security/compliance/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Compliance", + "pages": ["index", "..."] +} diff --git a/content/docs/security/cryptography.mdx b/content/docs/security/cryptography.mdx new file mode 100644 index 0000000..35c4474 --- /dev/null +++ b/content/docs/security/cryptography.mdx @@ -0,0 +1,181 @@ +--- +title: Cryptography +description: "The canonical account of CipherStash's cryptographic design: the key hierarchy, what ZeroKMS does and does not learn, and what is cached." +type: concept +components: [platform] +audience: [ciso, cto] +reviewBy: "2027-01-09" +--- + +This page is the single reference for CipherStash's cryptographic design. Every other page that touches key management links here rather than restating it. + +## Cryptographic primitives + +| Purpose | Algorithm | Details | +|---|---|---| +| Data encryption | AES-256-GCM-SIV | Authenticated encryption with nonce-misuse resistance | +| Equality search terms | HMAC-SHA-256 | Deterministic terms for exact-match lookups | +| Range and sorting terms | CLLW OPE or block ORE | EQL defaults to a directly sortable CLLW order-preserving term; block ORE is available as a pinned alternative ([Lewi-Wu 2016](https://eprint.iacr.org/2016/612), [Bogatov et al. 2018](https://arxiv.org/abs/1810.05135)) | +| Free-text search terms | Encrypted Bloom filters | Trigram tokenization ([Nojima/Kadobayashi 2009](https://link.springer.com/chapter/10.1007/978-3-642-04474-8_17), [Chum/Zhang 2017](https://link.springer.com/chapter/10.1007/978-3-319-66399-9_15)) | +| Payload integrity | BLAKE3 | Structure validation of the encrypted payload | +| Payload encoding | MessagePack + Base85 | Compact binary serialization, stored as `jsonb` in PostgreSQL | + +Encryption and decryption happen in your application process, in the native Rust module. CipherStash infrastructure never sees plaintext. + +## The key hierarchy + +Three distinct things are often all called "the key". Keeping them apart is what makes the rest of this page readable. + +| Key | Where it lives | What protects it | +|---|---|---| +| **Authority key** | ZeroKMS, server-side | Encrypted at rest under an AWS KMS root key | +| **Client key** | Your application or workload | You do (`CS_CLIENT_KEY`) | +| **Data key** | Your application's memory, briefly | Nothing. It is derived per value, identified by a key ID, and discarded | + +The authority key and the client key are two halves of a split. Neither alone derives a data key, and the two are never brought together in one place. The key seed travels from ZeroKMS to you. The client key never travels at all. + +## How a data key is produced + +The mechanism operates at two layers, and descriptions that name only one of them are incomplete. + +### Layer 1: the key seed, produced server-side + +Your application requests a key seed by sending a **key ID**. ZeroKMS uses **proxy symmetric re-encryption** (patent pending) on the authority key to produce the **key seed** for that ID, and returns it. + +The ID is what gives every data key a unique identity. It travels with the encrypted value, so the same seed can be requested again to decrypt it. + +ZeroKMS does all of this without the client key. It never receives it, and the request carries no key material of yours, only the ID. That is what makes the architecture zero-knowledge: ZeroKMS produces a seed it cannot itself turn into a data key. + +### Layer 2: the data key, derived client-side + +Your application **processes the key seed with the client key**, then expands the result into a **unique data key per value** using an **HMAC-based key derivation function**. The data key encrypts the value with AES-256-GCM-SIV, then is discarded from memory. + +The client key never leaves your infrastructure, and neither does anything it processes. Only the seed crosses the boundary, and it crosses inward. + +```mermaid +flowchart TD + subgraph zerokms["ZeroKMS (CipherStash)"] + root["AWS KMS root key"] + auth["Authority key"] + seed["Key seed"] + root -. encrypts at rest .-> auth + auth -- "layer 1:
proxy symmetric re-encryption" --> seed + end + + subgraph you["Your infrastructure"] + keyid["Key ID
unique per data key"] + client["Client key
never leaves"] + processed["Processed seed"] + datakey["Data key
one per value
discarded after use"] + ciphertext["Ciphertext"] + client -- processes the seed --> processed + processed -- "layer 2:
HMAC-based KDF" --> datakey + datakey -- AES-256-GCM-SIV --> ciphertext + end + + keyid -- "requests a seed
(no key material)" --> auth + seed -- returned to your app --> processed +``` + +Re-encryption describes how the seed is produced. HMAC key derivation describes how the processed seed becomes per-value keys. Both are true, at different layers. + +Note what crosses the boundary. Outbound: a key ID, which is not key material. Inbound: the key seed. Nothing else leaves your infrastructure, not the client key, not the processed seed, not the data key. + +## What is cached, and what is not + + +This distinction matters for a security review, and earlier documentation stated it too broadly. "Nothing is cached" is not accurate. What is true is that **no data key is ever cached, stored, or transmitted.** + + +| Thing | Cached? | Detail | +|---|---|---| +| Data keys | Never | Derived per value, held in memory for the duration of one operation, then discarded | +| Key seeds | Not persisted | Held only for the operation that requested them | +| Keyset-scoped ciphers | **Yes, in Proxy** | [CipherStash Proxy](/reference/proxy/configuration) caches keyset-scoped cipher objects so it does not re-initialize per statement | +| Authority keys | At rest, in ZeroKMS | Encrypted under an AWS KMS root key | + +Proxy's cipher cache is bounded by `cipher_cache_size` (64 entries) and `cipher_cache_ttl_seconds` (3600 seconds), and its hit rate is exposed as `cipherstash_proxy_keyset_cipher_cache_hits_total`. Caching a keyset-scoped cipher is not the same as caching a data key: the cipher still requires the client key to derive anything, and per-value keys are still derived per value. + +The Stack SDK derives per operation and caches nothing. + +## Trust model + +An attacker must compromise **both** the ZeroKMS authority key and your client key to derive data keys. Compromising either alone is insufficient. + +- CipherStash never sees plaintext. Encryption and decryption run in your process. +- CipherStash never sees your client key, nor anything the client key has processed. +- CipherStash never sees a data key. Data keys are derived in your memory. +- Ciphertext never has to leave your infrastructure. ZeroKMS handles key material, not data. + +### Shared responsibility + +| You | CipherStash | +|---|---| +| Protect the client key (`CS_CLIENT_KEY`) | Protect authority keys, encrypted at rest under AWS KMS | +| Secure your application and database | Operate ZeroKMS with high availability | +| Manage access keys and keysets | Enforce access-control policy on keysets | +| Register identity providers for lock contexts | Operate the CipherStash Token Service (CTS) | +| Store encrypted data in your database | Never store, access, or log plaintext | + +### Blast radius + +[Keysets](/concepts/key-management#keysets-define-the-isolation-boundary) scope keys. Each keyset is a full cryptographic boundary: one tenant's keyset cannot decrypt another's data. Compromising a client key affects only the keysets that application can reach, and revoking its access key stops further key derivation immediately. + +## Data flow + +### Write path + +1. The application calls `client.encrypt(plaintext, { column, table })`. +2. The SDK requests a key seed from ZeroKMS over TLS, sending a unique key ID. The request carries no key material. +3. ZeroKMS re-encrypts under the authority key and returns the key seed for that ID. +4. The SDK processes the seed with the client key, then derives the data key from the result, locally. +5. The SDK encrypts the plaintext with AES-256-GCM-SIV. +6. If the column declares searchable capability, the SDK generates the index terms (HMAC, CLLW OPE or block ORE, Bloom filter). +7. The SDK packs ciphertext and index terms into an [EQL payload](/reference/eql/core-concepts). +8. The data key is discarded. +9. The application stores the payload in PostgreSQL. + +### Read path + +1. The application reads the payload and calls `client.decrypt(...)`. +2. The SDK requests a key seed using the key ID stored with the value, processes it with the client key, and derives the data key locally. +3. The SDK decrypts the ciphertext, discards the data key, and returns plaintext. + +### Query path + +1. The application encrypts a search term. +2. The SDK generates the appropriate index term (HMAC for equality, CLLW OPE or block ORE for range and ordering, Bloom filter for free text). +3. PostgreSQL compares encrypted terms using [EQL](/reference/eql) operators. The database never sees plaintext. + +## What querying encrypted data reveals + +Searchable encryption is a trade. Each index term reveals bounded information to whoever can read the database: HMAC terms reveal equality and therefore frequency, OPE and ORE terms reveal relative order, and Bloom filter terms reveal probabilistic token membership. + +That leakage model is documented once, in [Searchable encryption](/concepts/searchable-encryption), with per-term detail and guidance on when the trade is not worth making. Assess it as part of your threat model. If the ordering or frequency of a column's values is itself sensitive, encrypt that column without a searchable index and filter after decryption. + +## Network security + +All communication between the SDK and CipherStash services uses TLS 1.2 or later: + +- SDK to ZeroKMS, for key seeds. +- SDK to CTS, for token exchange during identity-aware encryption. +- SDK to your database: your existing connection. CipherStash does not proxy it. + +Key material does not leave the region your workspace is configured in. See [data residency](/solutions/data-residency#regional-zerokms-deployment). + +## Open-source components + +| Component | Repository | +|---|---| +| EQL | [cipherstash/encrypt-query-language](https://github.com/cipherstash/encrypt-query-language) | +| ORE implementation | [cipherstash/ore.rs](https://github.com/cipherstash/ore.rs) | +| CipherStash Proxy | [cipherstash/proxy](https://github.com/cipherstash/proxy) | + +The core cryptographic implementations are open source and independently auditable. ZeroKMS is a managed service operated by CipherStash. + +## Related + +- [Searchable encryption](/concepts/searchable-encryption) for the canonical index-term leakage model. +- [Key management](/concepts/key-management) for the isolation boundary. +- [Proxy configuration](/reference/proxy/configuration) for the cipher cache settings. +- [Provable access control](/solutions/provable-access) for binding decryption to an authenticated identity. diff --git a/content/docs/security/cts.mdx b/content/docs/security/cts.mdx new file mode 100644 index 0000000..bd5074a --- /dev/null +++ b/content/docs/security/cts.mdx @@ -0,0 +1,61 @@ +--- +title: CipherStash Token Service +navTitle: CTS +description: "How CTS authenticates client keys and federated identities, then issues short-lived tokens for ZeroKMS." +type: concept +components: [platform] +audience: [developer, ciso] +--- + +CipherStash Token Service (CTS) is the authentication and identity-federation +layer in front of ZeroKMS. It converts a persistent application credential or +a trusted identity-provider token into a short-lived token scoped to +cryptographic service access. + +CTS plays a role similar to AWS STS: the long-lived credential proves who the +caller is, while the issued token is temporary and is what the caller presents +to ZeroKMS. + +## Request flow + +```mermaid +sequenceDiagram + participant App as Stack SDK or Proxy + participant IdP as Identity provider + participant CTS + participant ZK as ZeroKMS + + alt Application authentication + App->>CTS: Client ID + access key + else End-user federation + App->>IdP: Obtain user JWT + IdP-->>App: Signed JWT + App->>CTS: Federate JWT + end + CTS-->>App: Short-lived CTS token + App->>ZK: Key-seed request + CTS token + ZK-->>App: Key seed +``` + +The Stack SDK refreshes service tokens as required. Application code normally +selects an auth strategy rather than calling CTS directly. + +## Authentication modes + +### Application credentials + +A production service or Proxy instance uses a +[client key](/reference/auth/clients) and +[access key](/reference/auth/access-keys). This authenticates the workload as +an application rather than as a particular end user. + +### OIDC federation + +Register a trusted issuer and use `OidcFederationStrategy` when each request +should carry the signed-in user's identity. CTS validates the provider JWT and +issues the temporary service token. See +[OIDC providers](/reference/auth/oidc-configuration). + +Federated identity can also be used with a lock context so key derivation +depends on a JWT claim. That cryptographic binding is described in +[provable access control](/solutions/provable-access). diff --git a/content/docs/security/index.mdx b/content/docs/security/index.mdx new file mode 100644 index 0000000..9a8e230 --- /dev/null +++ b/content/docs/security/index.mdx @@ -0,0 +1,21 @@ +--- +title: Architecture & security +navTitle: Overview +description: "Trust model, cryptographic design, availability, audit, and compliance, written to be read end to end during a vendor security review." +type: concept +audience: [ciso, cto] +--- + +This section is written to be self-contained. A security team should be able to assess CipherStash from these pages without piecing the story together from reference material elsewhere. + +Start with [Cryptography](/security/cryptography). It is the canonical account of the cryptographic design: the key hierarchy, what ZeroKMS learns and does not learn, and what is cached. Every other page that touches key management links to it rather than restating it. + +## In this section + +- [Cryptography](/security/cryptography) covers the primitives, the key hierarchy, the trust model, and the data flow. +- [Compliance](/security/compliance) maps CipherStash onto specific frameworks. + +## Related + +- [Searchable encryption](/concepts/searchable-encryption) is the canonical account of what each index term reveals to the database. +- [Provable access control](/solutions/provable-access) covers binding decryption to an authenticated identity. diff --git a/content/docs/security/meta.json b/content/docs/security/meta.json new file mode 100644 index 0000000..30977ec --- /dev/null +++ b/content/docs/security/meta.json @@ -0,0 +1,12 @@ +{ + "title": "Architecture & security", + "icon": "Shield", + "pages": [ + "index", + "cryptography", + "!audit-logging", + "cts", + "!compliance", + "..." + ] +} diff --git a/content/docs/solutions/ai-and-rag.mdx b/content/docs/solutions/ai-and-rag.mdx new file mode 100644 index 0000000..745612e --- /dev/null +++ b/content/docs/solutions/ai-and-rag.mdx @@ -0,0 +1,84 @@ +--- +title: AI and RAG +description: "Protect sensitive source text and metadata in AI and retrieval-augmented generation pipelines without giving the database plaintext." +type: concept +components: [encryption, platform] +audience: [developer, cto, ciso] +verifiedAgainst: + stack: "1.0.0" +--- + +Retrieval-augmented generation pipelines often store document chunks, +customer metadata, access labels, and vector embeddings in the same database. +The source text may contain PII, financial records, health information, or +confidential business material that should not become plaintext merely because +an application needs semantic retrieval. + +CipherStash encrypts sensitive fields in the application before they reach the +database. The vector store can continue retrieving candidate rows while the +application decrypts only the records selected for the prompt. + +## What to encrypt + +Treat each part of the RAG record according to how it is used: + +| Field | Typical treatment | +| --- | --- | +| Source chunk | Encrypt; decrypt only after retrieval and authorization | +| Document title or filename | Encrypt if it contains customer or case information | +| Tenant, owner, or policy ID | Encrypt with equality when the database must filter on it | +| Access labels | Encrypt with the narrowest query capability the filter needs | +| Embedding | Keep separate from source text; assess whether the embedding itself is sensitive | + +Embeddings are derived data, not ciphertext. They may reveal properties of the +source and should be covered by the application's data classification and +threat model. Encrypting the source text does not make an exposed embedding +harmless. + +## Retrieval flow + +```mermaid +sequenceDiagram + participant App as Application + participant Vector as Vector store + participant DB as Encrypted record store + participant ZK as ZeroKMS + participant LLM as Model + + App->>Vector: Search with query embedding + Vector-->>App: Candidate record IDs + App->>DB: Fetch authorized encrypted records + DB-->>App: Ciphertext + App->>ZK: Request key seeds for selected values + ZK-->>App: Key seeds + Note over App: Derive keys and decrypt locally + App->>LLM: Minimum required plaintext context +``` + +The database and vector service do not receive the source plaintext. The model +still receives whichever context the application sends, so model-provider +retention, logging, prompt injection, and authorization remain separate design +decisions. + +## Tenant and user isolation + +Use a separate [keyset](/concepts/key-management#keysets-define-the-isolation-boundary) +when tenants or environments require a cryptographic boundary. Use +[identity-bound encryption](/solutions/provable-access) when decryption should +require claims from the signed-in user. + +Apply authorization before decryption and again before assembling the prompt. +Vector similarity alone is not an authorization decision: a candidate from +another tenant must be discarded before its ciphertext is decrypted. + +## Searchable metadata + +If the metadata lives in Postgres, EQL can filter equality, ranges, token +matches, and JSON containment without decrypting every row. Declare only the +capabilities the retrieval pipeline uses; each capability has a documented +leakage profile. See [searchable encryption](/concepts/searchable-encryption) +and [EQL filtering](/reference/eql/filtering). + +For implementation, begin with the [Stack SDK](/reference/stack), then follow +the [Data migration](/guides/migration) rollout when existing records must be +encrypted without downtime. diff --git a/content/docs/solutions/data-residency.mdx b/content/docs/solutions/data-residency.mdx new file mode 100644 index 0000000..497328a --- /dev/null +++ b/content/docs/solutions/data-residency.mdx @@ -0,0 +1,133 @@ +--- +title: Data residency +description: "Meet data residency rules with regional ZeroKMS deployment, a dual-party key split, and multi-region patterns for cross-border access." +type: concept +components: [platform, encryption] +audience: [cto, ciso] +verifiedAgainst: + stack: "1.0.0" +--- + +CipherStash provides data residency guarantees through regional key management, encryption that happens entirely in your infrastructure, and cryptographic key splitting. This page covers the deployment patterns for organizations with cross-border data requirements. + +## Why the architecture gives you residency + +Three properties combine to keep data and key material inside a region: + +- **Plaintext never leaves your systems.** Encryption and decryption happen in your infrastructure, in the Stack SDK or Proxy. The cloud service never sees plaintext. +- **Encrypted values never leave your systems either.** Ciphertext lives in your database. +- **Data keys are never transmitted.** They are derived locally, not fetched over the network. + +## Regional ZeroKMS deployment + +ZeroKMS is deployed on AWS only. A workspace is tied to one region, and its region identifier forms part of the workspace CRN (`crn:ap-southeast-2.aws:your-workspace-id`). + + + +Selecting a ZeroKMS region controls where authority keys are managed. Combined with your application's deployment region, that determines where all key material exists. + +Need a region that is not listed? Contact [support@cipherstash.com](mailto:support@cipherstash.com). + +## Dual-party key split + +CipherStash splits key material between two parties: + +1. **Authority key**, managed by ZeroKMS in your chosen region. +2. **Client key**, managed by your application in your infrastructure. + +Neither key alone derives a data key. ZeroKMS alone cannot access your data, because it holds only half the key material. Your application alone cannot either, because it needs a key seed from ZeroKMS. + +The two halves are never brought together in one place. ZeroKMS returns a key seed; your application processes that seed with the client key and derives the data key locally. The client key never leaves your infrastructure, and neither does anything it processes. See [cryptography](/security/cryptography). + +## Deployment patterns + +### Single region + +Deploy your application and ZeroKMS in the same region. + +```mermaid +flowchart TB + subgraph region["Region: eu-central-1.aws"] + app["Your app
+ client key"] + zerokms["ZeroKMS
+ authority key"] + db[("PostgreSQL
encrypted")] + app <-- key seed --> zerokms + app -- ciphertext --> db + end +``` + +All key material and data remain in one region. This satisfies most data residency requirements, including GDPR and regional data protection laws. + +### Multi-region with regional key isolation + +For organizations operating across regions with differing requirements, deploy a separate workspace per region. + +```mermaid +flowchart LR + subgraph eu["Region: eu-central-1.aws"] + euApp["App
+ client key"] + euKms["ZeroKMS (EU)"] + euDb[("PostgreSQL (EU)")] + euApp <-- key seed --> euKms + euApp -- ciphertext --> euDb + end + + subgraph apac["Region: ap-southeast-2.aws"] + apacApp["App
+ client key"] + apacKms["ZeroKMS (APAC)"] + apacDb[("PostgreSQL (APAC)")] + apacApp <-- key seed --> apacKms + apacApp -- ciphertext --> apacDb + end + + euDb -. "cannot be decrypted by" .-x apacApp +``` + +Each region gets its own ZeroKMS workspace with independent authority keys, its own client key (which, like every client key, never leaves your infrastructure), and its own database. Data encrypted in one region cannot be decrypted in another, which enforces the residency boundary cryptographically rather than by policy. + +### Cross-border access + +When a global team needs to reach encrypted data across regions, construct a client per region with that region's credentials. + +```typescript filename="regional-access.ts" +import { Encryption } from "@cipherstash/stack" +import { customers } from "./schema" + +// A client scoped to the EU workspace +const euClient = await Encryption({ + schemas: [customers], + config: { + workspaceCrn: process.env.CS_EU_WORKSPACE_CRN, + clientId: process.env.CS_EU_CLIENT_ID, + clientKey: process.env.CS_EU_CLIENT_KEY, + accessKey: process.env.CS_EU_ACCESS_KEY, + }, +}) + +// Decrypting EU customer data requires EU credentials +const result = await euClient.decrypt(encryptedEuRecord) + +if (result.failure) { + throw new Error(`Decryption failed: ${result.failure.message}`) +} +``` + +Access to each region's data requires that region's credentials, which gives you an auditable, revocable access model. To revoke a team member's access to a region, delete their client credentials in that region's workspace. + +## Compliance alignment + +| Requirement | How CipherStash addresses it | +|---|---| +| Data must not leave the region | Encryption and decryption happen locally; plaintext never leaves your infrastructure | +| Key material must stay in region | ZeroKMS authority keys are region-bound; client keys deploy with your app | +| Audit trail for cross-border access | ZeroKMS logs key derivation requests with identity context | +| Ability to revoke access | Delete client credentials, or revoke [lock context](/solutions/provable-access) identities | +| Cryptographic enforcement | The dual-party key split means a single party cannot decrypt alone | + +This architecture supports compliance with GDPR, regional data protection laws, and industry-specific requirements in healthcare and financial services. See [compliance](/security/compliance) for framework-by-framework detail. + +## Next steps + +- Review [regional ZeroKMS deployment](#regional-zerokms-deployment) for your workspaces. +- [Set up provable access control](/solutions/provable-access) to bind decryption to an identity. +- Review [key management](/concepts/key-management) when designing recovery and revocation procedures. diff --git a/content/docs/solutions/index.mdx b/content/docs/solutions/index.mdx new file mode 100644 index 0000000..df8c976 --- /dev/null +++ b/content/docs/solutions/index.mdx @@ -0,0 +1,47 @@ +--- +title: Solutions +navTitle: Overview +description: "What CipherStash solves: protecting sensitive data, AI and RAG pipelines, data residency, and provable access." +type: concept +audience: [cto, ciso] +--- + +CipherStash encrypts sensitive data at the field level, in your infrastructure, while keeping it queryable. This section covers the problems that solves, and what an implementation looks like for each. + +## In this section + +- [Data residency](/solutions/data-residency) covers regional key management and the deployment patterns for cross-border requirements. +- [Provable access control](/solutions/provable-access) binds decryption to an authenticated identity, so access is cryptographically demonstrable rather than merely logged. + +## What data should I protect? + +When improving your data security practices, it can be difficult to know what data is important enough to protect. These are the common categories of sensitive data worth encrypting. + +### Personally identifiable information (PII) + +Any data that could identify a specific individual: names, phone numbers, dates of birth, addresses, emails, IP addresses, social security numbers, license numbers, passport numbers. + +**Applicable regulations:** GDPR, CCPA, HIPAA, APP (Australia), PCI-DSS, SOC 2, ISO 27001. + +### Protected health information (PHI) + +Any information about health status, provision of health care, or payment for health care that can be linked to an individual: medical record numbers, health insurance beneficiary numbers, biometric identifiers, medical conditions, medication histories, payment histories. + +**Applicable regulations:** HIPAA, CMIA, GDPR, APP. + +### Financial information + +Data about money, accounts, and transactions: account numbers and balances, transaction data, TFNs and ITINs, saved payees. + +**Applicable regulations:** GDPR, CCPA, PCI-DSS, CDR (Australia), SOC 2, ISO 27001. + +### Authentication information + +Credentials used to gain access to accounts and services: usernames, passwords (both plaintext and hashed), OAuth tokens, session cookies. + +**Applicable regulations:** SOC 2, ISO 27001, PCI-DSS. + +## Related + +- [Searchable encryption](/concepts/searchable-encryption) for how querying encrypted data works, and what each index term reveals. +- [Compliance](/security/compliance) for how CipherStash maps onto specific frameworks. diff --git a/content/docs/solutions/meta.json b/content/docs/solutions/meta.json new file mode 100644 index 0000000..fedb529 --- /dev/null +++ b/content/docs/solutions/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Solutions", + "icon": "Target", + "pages": ["index", "ai-and-rag", "data-residency", "provable-access", "..."] +} diff --git a/content/docs/solutions/provable-access.mdx b/content/docs/solutions/provable-access.mdx new file mode 100644 index 0000000..16f0c6b --- /dev/null +++ b/content/docs/solutions/provable-access.mdx @@ -0,0 +1,160 @@ +--- +title: Provable access control +description: "Bind decryption to an authenticated identity with lock contexts, so access to data is cryptographically demonstrable rather than merely logged." +type: concept +components: [platform, encryption] +audience: [ciso, cto] +verifiedAgainst: + stack: "1.0.0" + auth: "0.42.0" +--- + +Traditional access control relies on application logic. If a bug or misconfiguration exposes data, there is no way to prove afterwards whether access was unauthorized. CipherStash binds decryption to an authenticated identity, so access leaves cryptographic evidence rather than only a log entry. + +## How it works + +Two pieces combine. + +**An auth strategy** decides who the client is when it talks to ZeroKMS. `OidcFederationStrategy` federates a signed-in user's OIDC JWT (from Supabase, Clerk, Auth0, or Okta) into a CipherStash token, so every ZeroKMS request is made *as that user* rather than as your service. + +**A lock context** binds a value to a claim from that user's JWT, typically `sub`. ZeroKMS bakes the claim's value into the data key's tag, so only the user who encrypted a value can decrypt it. + +The two are layered, not alternatives: lock context requires `OidcFederationStrategy`, but you can use the strategy without lock context. Authenticating as the user gives you an audit trail; adding lock context gives you the cryptographic binding. + +That creates a provable access boundary: + +- If data was decrypted, the user's identity must have been present. +- ZeroKMS records the identity associated with every key derivation. +- The record cannot be falsified by tampering with application logs, because the evidence is the derivation itself, not the log line describing it. + + +A lock context restricts **who can decrypt**. It does not hide the ciphertext's existence, nor does it prevent a signed-in user from decrypting the values their own claim unlocks. It moves the enforcement point out of your application and into key derivation. + + +## Identity-aware encryption + +Add your identity provider to the workspace first, on the [OIDC providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) page in the Dashboard. The `_` in that URL resolves to whichever workspace you have selected. + +### Authenticate as the end user + +Construct the client with `OidcFederationStrategy`. Pass a function that returns the *current* provider JWT: the strategy re-invokes it when it needs to re-federate, so do not capture a token once. + +```typescript filename="client.ts" +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" +import { patients } from "./schema" + +// Every ZeroKMS request is authenticated as the signed-in user +export const client = await Encryption({ + schemas: [patients], + config: { + authStrategy: OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN, + () => getProviderJwt(), + ), + }, +}) +``` + +`OidcFederationStrategy` is re-exported by `@cipherstash/stack`, so you do not need a separate import from [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth). + + +`OidcFederationStrategy` is the only strategy that supports lock contexts. `AccessKeyStrategy` authenticates as your service rather than as an end user, so there is no user identity for ZeroKMS to resolve the claim against. + + +### Bind encryption to the user's identity + +Every example below uses the `client` from above. Without its `authStrategy`, `.withLockContext()` has no end-user identity to bind to. + +```typescript filename="identity-encrypt.ts" +// `client` is configured with OidcFederationStrategy, as above +import { client } from "./client" +import { patients } from "./schema" + +// The data key is tagged with the authenticated provider's `sub` claim +async function encryptDiagnosis(diagnosis: string) { + const result = await client + .encrypt(diagnosis, { column: patients.diagnosis, table: patients }) + .withLockContext({ identityClaim: ["sub"] }) + + if (result.failure) { + throw new Error(`Encryption failed: ${result.failure.message}`) + } + + return result.data +} +``` + +### Decrypt with the same identity claim + +Decryption requires the same lock context, resolved against whichever user the client is currently authenticated as. + +```typescript filename="identity-decrypt.ts" +// The same `client`, authenticated as the same user +async function decryptDiagnosis(encryptedDiagnosis: unknown) { + const result = await client + .decrypt(encryptedDiagnosis) + .withLockContext({ identityClaim: ["sub"] }) + + if (result.failure) { + // Key derivation fails when the identity does not match + throw new Error(`Access denied: ${result.failure.message}`) + } + + return result.data +} +``` + +Lock contexts work with every operation: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, and `bulkDecryptModels`. + + +`lockContext.identify(jwt)` is **deprecated**. Per-operation CTS tokens were removed in `protect-ffi` 0.25, so `identify()` no longer affects encryption: code that still calls it compiles and logs a deprecation warning, but the token it fetches is unused. Authenticate the client with `OidcFederationStrategy` and pass the claim to `.withLockContext()` instead. + +Constructing a `LockContext` is not deprecated, it is simply optional here. `.withLockContext()` accepts either a `LockContext` or a plain `{ identityClaim }`. + + +## Audit logging + +Every encryption and decryption through ZeroKMS produces an audit event: + +| Field | Description | +|---|---| +| Identity | The authenticated user or service | +| Operation | Encrypt or decrypt | +| Timestamp | When the operation occurred | +| Keyset | Which keyset was used | +| Application | Which application performed the operation | + +### Combining with Proxy audit + +[CipherStash Proxy](/reference/proxy) adds statement-level audit on top: statement fingerprinting to identify unique query patterns against encrypted data, SQL redaction to strip sensitive values from logged queries, primary key injection to track which records were touched, and record reconciliation to map access events back to rows. See [Audit logging](/security/audit-logging). + +Together these answer who accessed what data, when, and using which query. + +## Use cases + +### Healthcare: HIPAA audit requirements + +HIPAA requires audit controls recording who accessed protected health information. Authenticating each request as the signed-in provider means the ZeroKMS audit log records the identity, the operation, the keyset, and the timestamp for each derivation, and a lock context ties each patient record to the provider who encrypted it. + +### Financial services: segregation of duties + +Encrypt audit records under a compliance-team member's identity. Someone on the trading desk, authenticated as themselves, resolves a different claim value, so key derivation fails and the records do not decrypt. The separation is enforced at derivation, not by a role check in the application. + +### Multi-tenant SaaS: tenant isolation + +Bind encryption to a per-user claim so only that user's identity can decrypt their data. For coarser isolation that does not depend on a signed-in end user, separate [keysets](/concepts/key-management#keysets-define-the-isolation-boundary) usually fit better: a keyset boundary is per-client or per-connection, where a lock context is per-identity. + +## Compared to application-level access control + +| Aspect | Application logic | Lock contexts | +|---|---|---| +| Enforcement | A software check, which can be bypassed | Key derivation, which cannot be skipped | +| Audit trail | Application logs, which can be edited | ZeroKMS derivation records | +| Proof of access | Circumstantial, from log entries | Direct: decryption required the identity | +| Blast radius of a bug | Data exposed when the check is bypassed | Data stays encrypted when application logic fails | + +## Next steps + +- [Data residency](/solutions/data-residency) for revoking access per region. +- [Audit logging](/security/audit-logging) for statement-level access evidence. +- [Compliance](/security/compliance) for GDPR, HIPAA, and PCI-DSS mapping. diff --git a/content/partials/eql/functions-dates-and-times.mdx b/content/partials/eql/functions-dates-and-times.mdx new file mode 100644 index 0000000..581efbf --- /dev/null +++ b/content/partials/eql/functions-dates-and-times.mdx @@ -0,0 +1,59 @@ +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */} + +Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions. + +### eql_v3.eq(a, b) [#fn-eq] + + + +```sql +SELECT * FROM events +WHERE eql_v3.eq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + +### eql_v3.neq(a, b) [#fn-neq] + + + +```sql +SELECT * FROM events +WHERE eql_v3.neq(occurred_at, $1::public.eql_v3_timestamp_eq); +``` + + + +### eql_v3.lt / lte / gt / gte [#fn-comparison] + + + +```sql +-- a range uses two of the four +SELECT * FROM events +WHERE eql_v3.gte(occurred_at, $1::public.eql_v3_timestamp_ord) + AND eql_v3.lt(occurred_at, $2::public.eql_v3_timestamp_ord); +``` + + + +### eql_v3.min(col) [#fn-min] + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(occurred_at) FROM events; +``` + + + +### eql_v3.max(col) [#fn-max] + + + +```sql +SELECT eql_v3.max(occurred_at) FROM events; +``` + + diff --git a/content/partials/eql/functions-numbers.mdx b/content/partials/eql/functions-numbers.mdx new file mode 100644 index 0000000..357c8fa --- /dev/null +++ b/content/partials/eql/functions-numbers.mdx @@ -0,0 +1,59 @@ +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */} + +Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions. + +### eql_v3.eq(a, b) [#fn-eq] + + + +```sql +SELECT * FROM payments +WHERE eql_v3.eq(amount, $1::public.eql_v3_bigint_eq); +``` + + + +### eql_v3.neq(a, b) [#fn-neq] + + + +```sql +SELECT * FROM payments +WHERE eql_v3.neq(amount, $1::public.eql_v3_bigint_eq); +``` + + + +### eql_v3.lt / lte / gt / gte [#fn-comparison] + + + +```sql +-- a range uses two of the four +SELECT * FROM payments +WHERE eql_v3.gte(amount, $1::public.eql_v3_bigint_ord) + AND eql_v3.lt(amount, $2::public.eql_v3_bigint_ord); +``` + + + +### eql_v3.min(col) [#fn-min] + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(amount) FROM payments; +``` + + + +### eql_v3.max(col) [#fn-max] + + + +```sql +SELECT eql_v3.max(amount) FROM payments; +``` + + diff --git a/content/partials/eql/functions-text.mdx b/content/partials/eql/functions-text.mdx new file mode 100644 index 0000000..ff415c7 --- /dev/null +++ b/content/partials/eql/functions-text.mdx @@ -0,0 +1,82 @@ +{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */} + +Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions. + +### eql_v3.eq(a, b) [#fn-eq] + + + +```sql +SELECT * FROM users +WHERE eql_v3.eq(email, $1::public.eql_v3_text_eq); +``` + + + +### eql_v3.neq(a, b) [#fn-neq] + + + +```sql +SELECT * FROM users +WHERE eql_v3.neq(email, $1::public.eql_v3_text_eq); +``` + + + +### eql_v3.lt / lte / gt / gte [#fn-comparison] + + + +```sql +-- any of the four; ordering is the usual reason to index text +SELECT id, email FROM users +WHERE eql_v3.gt(email, $1::public.eql_v3_text_ord) +ORDER BY eql_v3.ord_term(email); +``` + + + +### eql_v3.contains(a, b) [#fn-contains] + + + +```sql +-- token containment on the bloom-filter term +SELECT * FROM users +WHERE eql_v3.contains(email, $1::public.eql_v3_text_match); +``` + + + +### eql_v3.contained_by(a, b) [#fn-contained_by] + + + +```sql +SELECT * FROM users +WHERE eql_v3.contained_by(email, $1::public.eql_v3_text_match); +``` + + + +### eql_v3.min(col) [#fn-min] + + + +```sql +-- compares ordering terms; result decrypts client-side +SELECT eql_v3.min(email) FROM users; +``` + + + +### eql_v3.max(col) [#fn-max] + + + +```sql +SELECT eql_v3.max(email) FROM users; +``` + + diff --git a/content/stack/cipherstash/encryption/bulk-operations.mdx b/content/stack/cipherstash/encryption/bulk-operations.mdx index 5a6d823..550ce58 100644 --- a/content/stack/cipherstash/encryption/bulk-operations.mdx +++ b/content/stack/cipherstash/encryption/bulk-operations.mdx @@ -9,7 +9,7 @@ description: Encrypt and decrypt arrays of raw values in a single ZeroKMS round- This page covers the raw-value variants. If you want to encrypt whole objects (records with multiple fields), see [Model operations](/stack/cipherstash/encryption/models) instead. -For full method signatures, see the [`EncryptionClient` API reference](/stack/reference/stack/latest/packages/stack/src/encryption/classes/EncryptionClient). +For full method signatures, see the [`EncryptionClient` API reference](/reference/stack/api-reference). ## Why bulk matters diff --git a/content/stack/cipherstash/encryption/dynamodb.mdx b/content/stack/cipherstash/encryption/dynamodb.mdx index 76e2f44..5b672bc 100644 --- a/content/stack/cipherstash/encryption/dynamodb.mdx +++ b/content/stack/cipherstash/encryption/dynamodb.mdx @@ -3,6 +3,8 @@ title: DynamoDB description: Encrypt and decrypt DynamoDB items with the encryptedDynamoDB helper from @cipherstash/stack, including bulk operations and HMAC equality queries. --- +import { faqPrivacy, faqKms, faqFreeTier } from "@/components/faq/shared"; + CipherStash provides a DynamoDB integration through `@cipherstash/stack/dynamodb`. The `encryptedDynamoDB` helper encrypts items before writing to DynamoDB and decrypts them after reading — it does not wrap the AWS SDK, so you keep full control of your DynamoDB operations. ## Installation @@ -469,3 +471,44 @@ const queryResult = await docClient.send(new QueryCommand({ const decrypted = await dynamo.bulkDecryptModels(queryResult.Items ?? [], users) ``` + +## FAQ + + + Yes, for exact equality. Each encrypted attribute stores an{" "} + __hmac term you match against, so you can look up by an + encrypted partition key, sort key, or GSI. Range, comparison, and + substring queries on encrypted attributes are not supported. + + ), + }, + { + title: "Do I have to change how I write DynamoDB operations?", + answer: ( + <> + No. encryptedDynamoDB encrypts items before writing and + decrypts them after reading; it does not wrap the AWS SDK, so you keep + full control of your PutItem, GetItem, and Query calls. + + ), + }, + { + title: "What does adopting it look like?", + answer: ( + <> + Install @cipherstash/stack, define an encrypted schema for + the attributes you want to protect, initialize the client, and encrypt + values before writing them. You can adopt it one table at a time. + + ), + }, + faqKms, + faqFreeTier, + ]} +/> diff --git a/content/stack/cipherstash/encryption/models.mdx b/content/stack/cipherstash/encryption/models.mdx index 54d30f7..b7287ad 100644 --- a/content/stack/cipherstash/encryption/models.mdx +++ b/content/stack/cipherstash/encryption/models.mdx @@ -9,7 +9,7 @@ Model methods encrypt or decrypt an entire object in one call. The SDK inspects This is the recommended approach when working with database records: pass the object in, get the encrypted (or decrypted) version back, and write it to the database. -For full method signatures, see the [`EncryptionClient` API reference](/stack/reference/stack/latest/packages/stack/src/encryption/classes/EncryptionClient). +For full method signatures, see the [`EncryptionClient` API reference](/reference/stack/api-reference). ## How schema-driven selection works diff --git a/content/stack/cipherstash/kms/oidc.mdx b/content/stack/cipherstash/kms/oidc.mdx index f4ffefc..581aaae 100644 --- a/content/stack/cipherstash/kms/oidc.mdx +++ b/content/stack/cipherstash/kms/oidc.mdx @@ -6,7 +6,7 @@ description: Register an identity provider so end users can authenticate cryptog OIDC Providers let you federate your existing identity provider (IdP) with CipherStash. Once registered, end users can authenticate cryptographic operations using the identity tokens your IdP issues, not just application credentials. -This pairs with [identity-aware encryption](/stack/reference/stack/latest/packages/stack/src/identity/classes/LockContext) via `LockContext`, which binds encryption operations to a specific user identity. +This pairs with [identity-aware encryption](/reference/stack/api-reference) via `LockContext`, which binds encryption operations to a specific user identity. If you are only encrypting data at rest with application credentials, you do not need an OIDC provider. @@ -61,4 +61,4 @@ No client secret or shared key is stored. CipherStash validates tokens by fetchi ## Using it from code Once a provider is registered, use `LockContext` in the Encryption SDK to bind encryption operations to a user's identity token. -See the [`LockContext` API reference](/stack/reference/stack/latest/packages/stack/src/identity/classes/LockContext) for the full API. +See the [Stack API reference](/reference/stack/api-reference) for the full `LockContext` API. diff --git a/content/stack/cipherstash/kms/regions.mdx b/content/stack/cipherstash/kms/regions.mdx index bffd4f8..d101365 100644 --- a/content/stack/cipherstash/kms/regions.mdx +++ b/content/stack/cipherstash/kms/regions.mdx @@ -7,13 +7,9 @@ Each workspace is tied to a specific region and is deployed in that region's [Ze ## Supported ZeroKMS regions -- Asia Pacific (Sydney) -- Europe (Frankfurt) -- Europe (Ireland) -- US East (N. Virginia) -- US East (Ohio) -- US West (N. California) -- US West (Oregon) +ZeroKMS is deployed on AWS only. A region identifier forms part of your workspace CRN, for example `crn:ap-southeast-2.aws:your-workspace-id`. + + ## Requesting a new region diff --git a/content/stack/meta.json b/content/stack/meta.json index 340df56..34e13fc 100644 --- a/content/stack/meta.json +++ b/content/stack/meta.json @@ -1,8 +1,3 @@ { - "pages": [ - "quickstart", - "cipherstash", - "deploy", - "reference" - ] + "pages": ["quickstart", "cipherstash", "deploy", "reference"] } diff --git a/content/stack/reference/cipher-cell.mdx b/content/stack/reference/cipher-cell.mdx index 078c4ea..a9f60e5 100644 --- a/content/stack/reference/cipher-cell.mdx +++ b/content/stack/reference/cipher-cell.mdx @@ -149,7 +149,7 @@ Enables exact match queries using HMAC-SHA256. **Type**: Hex-encoded string (64 characters) -**Index Type**: [Exact](/stack/cipherstash/encryption/searchable-encryption#exact-match) +**Index Type**: [Exact](/stack/cipherstash/encryption/searchable-encryption#exact-matching) ```json { @@ -163,7 +163,7 @@ Enables range queries and ordering using Order Revealing Encryption. **Type**: Array of strings -**Index Type**: [Order / Range](/stack/cipherstash/encryption/searchable-encryption#range--order) +**Index Type**: [Order / Range](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) ```json { @@ -180,7 +180,7 @@ Enables substring and pattern matching queries using encrypted Bloom filters wit **Type**: Array of integers -**Index Type**: [Match](/stack/cipherstash/encryption/searchable-encryption#match-pattern) +**Index Type**: [Match](/stack/cipherstash/encryption/searchable-encryption#free-text-search) ```json { diff --git a/content/stack/reference/drizzle.mdx b/content/stack/reference/drizzle.mdx index c7d67bd..e50e296 100644 --- a/content/stack/reference/drizzle.mdx +++ b/content/stack/reference/drizzle.mdx @@ -3,18 +3,18 @@ title: Drizzle adapter reference description: Encrypted query operators, schema extraction, EQL migration generation, and API surface for @cipherstash/stack/drizzle. --- -`@cipherstash/stack/drizzle` integrates CipherStash field-level encryption with Drizzle ORM. It provides a custom column type for encrypted fields and drop-in query operators that encrypt search values before they reach PostgreSQL. This page covers the operators, batching patterns, and migration generation. The step-by-step integration guide is at [Drizzle integration guide](/stack/cipherstash/encryption/drizzle). Full type signatures live in the [auto-generated API reference](/stack/reference/stack/latest/packages/stack/src/drizzle). +`@cipherstash/stack/drizzle` integrates CipherStash field-level encryption with Drizzle ORM. It provides a custom column type for encrypted fields and drop-in query operators that encrypt search values before they reach PostgreSQL. This page covers the operators, batching patterns, and migration generation. The step-by-step integration guide is at [Drizzle integration guide](/stack/cipherstash/encryption/drizzle). Full type signatures live in the auto-generated API reference. ## Public entry points | Export | Purpose | |---|---| -| [`encryptedType`](/stack/reference/stack/latest/packages/stack/src/drizzle/functions/encryptedType) | Custom Drizzle column type for an encrypted field. Accepts a `dataType` and index config. | -| [`extractEncryptionSchema`](/stack/reference/stack/latest/packages/stack/src/drizzle/functions/extractEncryptionSchema) | Converts a Drizzle `pgTable` definition into a CipherStash `EncryptedTable` schema for the SDK. | -| [`createEncryptionOperators`](/stack/reference/stack/latest/packages/stack/src/drizzle/functions/createEncryptionOperators) | Returns an object with all Drizzle query operators wrapped for encrypted columns. | -| [`EncryptedColumnConfig`](/stack/reference/stack/latest/packages/stack/src/drizzle/type-aliases/EncryptedColumnConfig) | Type alias for the column configuration object (`dataType`, `equality`, `freeTextSearch`, `orderAndRange`, `searchableJson`). | -| [`EncryptionConfigError`](/stack/reference/stack/latest/packages/stack/src/drizzle/classes/EncryptionConfigError) | Thrown when a column lacks the index required by an operator. | -| [`EncryptionOperatorError`](/stack/reference/stack/latest/packages/stack/src/drizzle/classes/EncryptionOperatorError) | Thrown for operator-level failures (invalid arguments, unsupported operations). | +| `encryptedType` | Custom Drizzle column type for an encrypted field. Accepts a `dataType` and index config. | +| `extractEncryptionSchema` | Converts a Drizzle `pgTable` definition into a CipherStash `EncryptedTable` schema for the SDK. | +| `createEncryptionOperators` | Returns an object with all Drizzle query operators wrapped for encrypted columns. | +| `EncryptedColumnConfig` | Type alias for the column configuration object (`dataType`, `equality`, `freeTextSearch`, `orderAndRange`, `searchableJson`). | +| `EncryptionConfigError` | Thrown when a column lacks the index required by an operator. | +| `EncryptionOperatorError` | Thrown for operator-level failures (invalid arguments, unsupported operations). | ## Encrypted query operators @@ -131,10 +131,9 @@ See the [CipherStash CLI reference](/stack/cipherstash/cli) for all `db install` ## Full API surface -Everything else is in the auto-generated TypeDoc reference: +The Drizzle adapter now ships as its own package, `@cipherstash/stack-drizzle`, with the EQL v3 dialect on `@cipherstash/stack-drizzle/v3`. This page documents the older `@cipherstash/stack/drizzle` entry point; the [Drizzle integration](/integrations/drizzle) is the current, EQL v3 surface: -- [Drizzle module](/stack/reference/stack/latest/packages/stack/src/drizzle) — all exports -- [`encryptedType`](/stack/reference/stack/latest/packages/stack/src/drizzle/functions/encryptedType) — column builder -- [`extractEncryptionSchema`](/stack/reference/stack/latest/packages/stack/src/drizzle/functions/extractEncryptionSchema) — schema conversion -- [`createEncryptionOperators`](/stack/reference/stack/latest/packages/stack/src/drizzle/functions/createEncryptionOperators) — operator factory -- [`EncryptedColumnConfig`](/stack/reference/stack/latest/packages/stack/src/drizzle/type-aliases/EncryptedColumnConfig) — column config type +- `encryptedType` — column builder +- `extractEncryptionSchema` — schema conversion +- `createEncryptionOperators` — operator factory +- `EncryptedColumnConfig` — column config type diff --git a/content/stack/reference/encryption-sdk.mdx b/content/stack/reference/encryption-sdk.mdx index 1abfb63..ef036e0 100644 --- a/content/stack/reference/encryption-sdk.mdx +++ b/content/stack/reference/encryption-sdk.mdx @@ -3,18 +3,18 @@ title: Encryption SDK reference description: Public entry points, supported data types, and configuration highlights for @cipherstash/stack field-level encryption. --- -`@cipherstash/stack` is CipherStash's field-level encryption SDK for TypeScript. It encrypts individual column values client-side using per-value keys derived from ZeroKMS (backed by AWS KMS), before data leaves the application. This page summarises the public surface, data type rules, and configuration options. Full type signatures live in the [auto-generated API reference](/stack/reference/stack/latest/packages/stack/src/encryption). +`@cipherstash/stack` is CipherStash's field-level encryption SDK for TypeScript. It encrypts individual column values client-side using per-value keys derived from ZeroKMS (backed by AWS KMS), before data leaves the application. This page summarises the public surface, data type rules, and configuration options. Full type signatures live in the [auto-generated API reference](/reference/stack/api-reference). ## Public entry points | Export | Import path | Purpose | |---|---|---| -| `Encryption(config)` | `@cipherstash/stack` | Factory function. Returns a `Promise`. [Reference](/stack/reference/stack/latest/packages/stack/src/encryption/functions/Encryption) | -| `EncryptionClient` | `@cipherstash/stack/encryption` | Class with all encrypt/decrypt methods. Obtain via `Encryption()`, not `new`. [Reference](/stack/reference/stack/latest/packages/stack/src/encryption/classes/EncryptionClient) | -| `encryptedTable` / `encryptedColumn` / `encryptedField` | `@cipherstash/stack/schema` | Schema builders. Define which tables and columns to encrypt, and which search indexes to create. [Reference](/stack/reference/stack/latest/packages/stack/src/schema) | -| `LockContext` | `@cipherstash/stack/identity` | Identity-aware encryption. Ties an encrypted value to a specific JWT identity. [Reference](/stack/reference/stack/latest/packages/stack/src/identity) | -| `Secrets` | `@cipherstash/stack/secrets` | End-to-end encrypted secret storage. Separate from field-level encryption. [Reference](/stack/reference/stack/latest/packages/stack/src/types-public) | -| Error types | `@cipherstash/stack/errors` | `StackError`, `EncryptionErrorTypes`, `getErrorMessage`. [Reference](/stack/reference/stack/latest/packages/stack/src/types-public) | +| `Encryption(config)` | `@cipherstash/stack` | Factory function. Returns a `Promise`. [Reference](/reference/stack/api-reference) | +| `EncryptionClient` | `@cipherstash/stack/encryption` | Class with all encrypt/decrypt methods. Obtain via `Encryption()`, not `new`. [Reference](/reference/stack/api-reference) | +| `encryptedTable` / `encryptedColumn` / `encryptedField` | `@cipherstash/stack/schema` | Schema builders. Define which tables and columns to encrypt, and which search indexes to create. [Reference](/reference/stack/api-reference) | +| `LockContext` | `@cipherstash/stack/identity` | Identity-aware encryption. Ties an encrypted value to a specific JWT identity. [Reference](/reference/stack/api-reference) | +| `Secrets` | `@cipherstash/stack/secrets` | End-to-end encrypted secret storage. Separate from field-level encryption. [Reference](/reference/stack/api-reference) | +| Error types | `@cipherstash/stack/errors` | `StackError`, `EncryptionErrorTypes`, `getErrorMessage`. [Reference](/reference/stack/api-reference) | ## Adapter packages @@ -75,7 +75,7 @@ Some numeric inputs are invalid for encryption. The following table covers the b | `clientKey` | `CS_CLIENT_KEY` | Client key material used for ZeroKMS encryption operations | | `keyset` | (none) | Multi-tenant isolation. Specify `{ name: "tenant-a" }` or `{ id: "" }` | -See the full type at [EncryptionClientConfig](/stack/reference/stack/latest/packages/stack/src/types-public/type-aliases/EncryptionClientConfig) and [ClientConfig](/stack/reference/stack/latest/packages/stack/src/types-public/type-aliases/ClientConfig). +See [the generated API reference](/reference/stack/api-reference) for the complete `EncryptionClientConfig` and `ClientConfig` types. ```typescript filename="init.ts" import { Encryption } from "@cipherstash/stack" @@ -110,7 +110,4 @@ Set `STASH_STACK_LOG` to control log verbosity. The SDK never logs plaintext dat Everything else is in the auto-generated TypeDoc reference: -- [Encryption module](/stack/reference/stack/latest/packages/stack/src/encryption) — `Encryption()`, `EncryptionClient` class, all methods -- [Schema module](/stack/reference/stack/latest/packages/stack/src/schema) — `encryptedTable`, `encryptedColumn`, `encryptedField`, type inference helpers -- [Identity module](/stack/reference/stack/latest/packages/stack/src/identity) — `LockContext` -- [Types](/stack/reference/stack/latest/packages/stack/src/types-public) — `EncryptionClientConfig`, `ClientConfig`, `EncryptOptions`, `BulkEncryptPayload`, and more +- [Generated API reference](/reference/stack/api-reference) — encryption, schema, identity, configuration, and error exports diff --git a/content/stack/reference/eql-guide.mdx b/content/stack/reference/eql-guide.mdx index 9dfa917..3649d17 100644 --- a/content/stack/reference/eql-guide.mdx +++ b/content/stack/reference/eql-guide.mdx @@ -87,19 +87,19 @@ EQL supports multiple searchable encryption index types. Each index type enables Enables exact equality queries and unique constraints using HMAC-SHA256. -[Learn more about exact indexes](/stack/cipherstash/encryption/searchable-encryption#exact-match) +[Learn more about exact indexes](/stack/cipherstash/encryption/searchable-encryption#exact-matching) ### `ore`: Range queries Enables range comparisons (`<`, `>`, `BETWEEN`) and ordering (`ORDER BY`) using Order Revealing Encryption. -[Learn more about range indexes](/stack/cipherstash/encryption/searchable-encryption#range--order) +[Learn more about range indexes](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) ### `match`: Pattern matching Enables substring and full-text search (`LIKE`, `ILIKE`) using encrypted Bloom filters with trigrams. -[Learn more about match indexes](/stack/cipherstash/encryption/searchable-encryption#match-pattern) +[Learn more about match indexes](/stack/cipherstash/encryption/searchable-encryption#free-text-search) ### `ste_vec`: Structured data diff --git a/content/stack/reference/eql/meta.json b/content/stack/reference/eql/meta.json index 7568e54..d20d140 100644 --- a/content/stack/reference/eql/meta.json +++ b/content/stack/reference/eql/meta.json @@ -1,5 +1,3 @@ { - "pages": [ - "index" - ] -} \ No newline at end of file + "pages": ["index"] +} diff --git a/content/stack/reference/glossary.mdx b/content/stack/reference/glossary.mdx index d52d0c6..559e5b0 100644 --- a/content/stack/reference/glossary.mdx +++ b/content/stack/reference/glossary.mdx @@ -131,7 +131,7 @@ It facilitates secure single sign-on (SSO) and simplifies the authentication pro ### ORE (Order Revealing Encryption) A searchable encryption technique allowing for search, comparison, and sorting of encrypted data without decryption. -See [Range queries](/stack/cipherstash/encryption/searchable-encryption#range--order) for details. +See [Range queries](/stack/cipherstash/encryption/searchable-encryption#sorting-and-range-queries) for details. ## P diff --git a/content/stack/reference/index.mdx b/content/stack/reference/index.mdx index 5acc9a0..75fd1a7 100644 --- a/content/stack/reference/index.mdx +++ b/content/stack/reference/index.mdx @@ -11,7 +11,7 @@ Auto-generated TypeDoc reference for CipherStash packages. For hand-written guid The core CipherStash SDK. Includes Encryption (`Encryption()`, `EncryptionClient`, schema builders, type inference, Lock Context), Secrets API, and integrations (Drizzle, Supabase, DynamoDB, and more). -[Browse @cipherstash/stack reference](/stack/reference/stack/latest) +[Browse @cipherstash/stack reference](/reference/stack/api-reference) ### EQL API diff --git a/content/stack/reference/stack/meta.json b/content/stack/reference/stack/meta.json index 6f40473..dbb256d 100644 --- a/content/stack/reference/stack/meta.json +++ b/content/stack/reference/stack/meta.json @@ -1,5 +1,3 @@ { - "pages": [ - "latest" - ] -} \ No newline at end of file + "pages": ["latest"] +} diff --git a/next.config.mjs b/next.config.mjs index 2c59456..a0e4cfc 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,13 +1,88 @@ import { createMDX } from "fumadocs-mdx/next"; +import { v2Redirects } from "./v2-redirects.mjs"; const withMDX = createMDX(); +// V2 IA migration (CIP-3325): the full legacy→v2 redirect map is gated so the +// preview site serves BOTH trees while sections migrate (legacy at /stack, v2 +// at the root). Flip on at merge; once content/stack is deleted the map +// becomes unconditional (CIP-3335). Coverage is enforced by +// `bun run validate-redirects` regardless of the flag. +const enableV2Redirects = process.env.ENABLE_V2_REDIRECTS === "1"; + +// Migrated legacy sections redirect as soon as their replacements are +// complete, without waiting for the root-level v2 IA cutover. The two legacy +// landing pages remain available until ENABLE_V2_REDIRECTS is flipped. +const enabledV2RedirectPrefixes = [ + "/stack/quickstart", + "/stack/cipherstash/postgres", + "/stack/cipherstash/supabase", + "/stack/cipherstash/encryption", + "/stack/cipherstash/kms", + "/stack/cipherstash/proxy", + "/stack/cipherstash/cli", + "/stack/deploy", + "/stack/reference", +]; + +function isEnabledV2Redirect(source) { + return enabledV2RedirectPrefixes.some( + (prefix) => source === prefix || source.startsWith(`${prefix}/`), + ); +} + /** @type {import('next').NextConfig} */ const config = { basePath: "/docs", reactStrictMode: true, async redirects() { return [ + // The app lives under the /docs basePath, so the bare domain root + // (e.g. on Vercel preview URLs) would otherwise 404. In production + // "/" never reaches this app — cipherstash.com routes only /docs/* + // here — so this only affects previews. + { + source: "/", + destination: "/docs", + basePath: false, + permanent: false, + }, + // Vanity URL for the new IA (safe to ship ungated: the path has no + // legacy traffic). Temporary until the v2 quickstart is canonical. + { + source: "/quickstart", + destination: "/get-started/quickstart", + permanent: false, + }, + // Concepts is a non-clickable sidebar group with no landing page. + // Keep its direct URL useful without rendering a synthetic Overview. + { + source: "/concepts", + destination: "/concepts/searchable-encryption", + permanent: false, + }, + // Guides is a non-clickable sidebar group with no landing page. + // Keep its direct URL useful without rendering a synthetic Overview. + { + source: "/guides", + destination: "/guides/deployment", + permanent: false, + }, + // Reference is a non-clickable sidebar group with no landing page. + // Keep its direct URL useful without rendering a synthetic Overview. + { + source: "/reference", + destination: "/reference/eql", + permanent: false, + }, + { + source: "/integrations/prisma-next", + destination: "/integrations/prisma", + permanent: true, + }, + ...v2Redirects.filter( + ({ source }) => enableV2Redirects || isEnabledV2Redirect(source), + ), // === 4-section consolidation: product sections under /cipherstash/ === { source: "/stack/encryption/:path*", @@ -231,10 +306,10 @@ const config = { destination: "/stack/reference/proxy-reference", permanent: true, }, - // Reference section index → latest + // Legacy generated Stack reference → the V2 API reference { source: "/stack/reference/stack", - destination: "/stack/reference/stack/latest", + destination: "/reference/stack/api-reference", permanent: false, }, // === AI-cited URLs orphaned by the restructure === @@ -287,11 +362,10 @@ const config = { destination: "/stack/deploy/aws-ecs", permanent: true, }, - { - source: "/reference/eql", - destination: "/stack/reference/eql", - permanent: false, - }, + // NOTE(v2): the AI-citation redirect "/reference/eql" → + // "/stack/reference/eql" was removed here — its source collides with + // the v2 IA's /reference/eql page, which now serves that traffic + // directly (CIP-3325). { source: "/platform/workspaces/key-sets", destination: "/stack/cipherstash/kms/keysets", @@ -308,16 +382,6 @@ const config = { destination: "/stack/cipherstash/kms", permanent: false, }, - // === Pre-publish placeholder for the v2 docs restructure === - // The v2 branch will host the Supabase docs at /docs/integrations/supabase, - // but that branch isn't published yet. Until it ships, temporarily (307) - // point the future URL at the current Supabase overview page so external - // links to it resolve. Remove this once v2 owns /integrations/supabase. - { - source: "/integrations/supabase", - destination: "/stack/cipherstash/supabase", - permanent: false, - }, ]; }, async rewrites() { @@ -327,6 +391,13 @@ const config = { source: "/stack/:path*.mdx", destination: "/llms.mdx/stack/:path*", }, + // Raw-markdown mirror for the v2 tree (Cloudflare/agents fetch + // .mdx). Listed after the /stack rule so legacy paths keep + // resolving to the legacy collection. + { + source: "/:path*.mdx", + destination: "/llms.mdx/v2/:path*", + }, ], afterFiles: [ { diff --git a/package.json b/package.json index 77b4f3c..f851fd1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "prebuild": "bun run generate-docs && bun run generate-docs:eql && bun run validate-links", + "prebuild": "bun run generate-docs && bun run generate-docs:supabase && bun run generate-docs:drizzle && bun run generate-docs:prisma && bun run generate-docs:eql && bun run generate-docs:eql-api && bun run generate-docs:cli && bun run validate-content && bun run validate-mermaid && bun run validate-links && bun run validate-redirects", "build": "next build", "dev": "next dev -p 3001", "start": "next start", @@ -12,8 +12,17 @@ "lint": "biome check", "format": "biome format --write", "generate-docs": "tsx scripts/generate-docs.ts", + "generate-docs:supabase": "tsx scripts/generate-supabase-docs.ts", + "generate-docs:drizzle": "tsx scripts/generate-drizzle-docs.ts", + "generate-docs:prisma": "tsx scripts/generate-prisma-docs.ts", "generate-docs:eql": "tsx scripts/generate-eql-docs.ts", - "validate-links": "tsx scripts/validate-links.ts" + "generate-docs:eql-api": "tsx scripts/generate-eql-api-docs.ts", + "generate-docs:cli": "tsx scripts/generate-cli-docs.ts", + "check-eql-pin": "tsx scripts/check-eql-pin.ts", + "validate-links": "tsx scripts/validate-links.ts", + "validate-redirects": "tsx scripts/validate-v2-redirects.ts", + "validate-content": "tsx scripts/validate-content-api.ts", + "validate-mermaid": "tsx scripts/validate-mermaid.ts" }, "dependencies": { "fumadocs-core": "16.6.0", @@ -21,6 +30,7 @@ "fumadocs-ui": "16.6.0", "github-slugger": "^2.0.0", "lucide-react": "^0.563.0", + "mermaid": "^11.16.0", "next": "16.2.6", "posthog-js": "^1.354.0", "posthog-node": "^5.26.0", @@ -31,10 +41,12 @@ "devDependencies": { "@biomejs/biome": "^2.3.14", "@tailwindcss/postcss": "^4.1.18", + "@types/jsdom": "^28.0.3", "@types/mdx": "^2.0.13", "@types/node": "^25.2.1", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", + "jsdom": "^29.1.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", "tsx": "^4.0.0", diff --git a/public/images/hsm.png b/public/images/hsm.png new file mode 100644 index 0000000..ccb6eda Binary files /dev/null and b/public/images/hsm.png differ diff --git a/scripts/check-eql-pin.ts b/scripts/check-eql-pin.ts new file mode 100644 index 0000000..bb05235 --- /dev/null +++ b/scripts/check-eql-pin.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env tsx +/** + * Resolves whether a newer EQL release is available for the pinned minor. + * + * The EQL release is PINNED in `generate-eql-docs.ts` on purpose — see the + * header there. This script does not change that: it only reports (and with + * `--apply`, stages) the bump, so the upgrade still lands as a reviewable + * commit whose drift check has already run. + * + * Scope is deliberately the pinned MINOR. Patch releases in this project are + * not additive-only — 3.0.1 removed `eql_v3.contains`, added `eql_v3.matches`, + * and renamed the whole encrypted-JSON domain surface — so even a patch bump + * has to go through review. A newer minor or major is reported but never + * proposed, because those carry migration work beyond a pin change. + * + * Usage: + * tsx scripts/check-eql-pin.ts # report only, exit 0 + * tsx scripts/check-eql-pin.ts --apply # also rewrite the pin + */ +import fs from "node:fs"; +import path from "node:path"; + +const PIN_FILE = path.join(process.cwd(), "scripts/generate-eql-docs.ts"); +// Matches the pin's fallback literal, leaving the env-var override intact. +const PIN_RE = + /(EQL_RELEASE_TAG\s*=\s*process\.env\.EQL_RELEASE_TAG\s*\?\?\s*")(eql-[^"]+)(")/; +const RELEASES_API = + "https://api.github.com/repos/cipherstash/encrypt-query-language/releases"; +// Stable EQL SQL releases only. Excludes the sibling tags cut by the same +// release (`eql-bindings-v3.0.3`, `@cipherstash/eql@3.0.3`) and prereleases +// like `eql-3.0.0-alpha.4`, which must never be proposed automatically. +const TAG_RE = /^eql-(\d+)\.(\d+)\.(\d+)$/; + +interface Version { + tag: string; + major: number; + minor: number; + patch: number; +} + +function parseTag(tag: string): Version | null { + const m = TAG_RE.exec(tag); + if (!m) return null; + return { + tag, + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + }; +} + +function compare(a: Version, b: Version): number { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +function readPin(): Version { + const source = fs.readFileSync(PIN_FILE, "utf8"); + const m = PIN_RE.exec(source); + if (!m) { + throw new Error( + `Could not find the EQL_RELEASE_TAG pin in ${PIN_FILE}. ` + + "If the declaration was reformatted, update PIN_RE in this script.", + ); + } + const version = parseTag(m[2]); + if (!version) { + throw new Error( + `Pinned tag "${m[2]}" is not a stable eql-X.Y.Z release; refusing to compare.`, + ); + } + return version; +} + +async function fetchReleases(): Promise { + const headers: Record = { + accept: "application/vnd.github+json", + }; + // Authenticated in CI purely for the rate limit; the repo is public. + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (token) headers.authorization = `Bearer ${token}`; + + const found: Version[] = []; + // Each EQL release cuts three tags, so paginate rather than assume one page + // covers enough history. + for (let page = 1; page <= 3; page++) { + const res = await fetch(`${RELEASES_API}?per_page=100&page=${page}`, { + headers, + }); + if (!res.ok) { + throw new Error( + `GitHub releases API returned ${res.status} ${res.statusText}`, + ); + } + const batch = (await res.json()) as { + tag_name: string; + draft: boolean; + prerelease: boolean; + }[]; + if (!batch.length) break; + for (const r of batch) { + if (r.draft || r.prerelease) continue; + const v = parseTag(r.tag_name); + if (v) found.push(v); + } + if (batch.length < 100) break; + } + return found.sort(compare); +} + +function emit(name: string, value: string): void { + const out = process.env.GITHUB_OUTPUT; + if (out) fs.appendFileSync(out, `${name}=${value}\n`); +} + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + const pinned = readPin(); + const releases = await fetchReleases(); + + if (!releases.length) { + throw new Error( + "No stable eql-X.Y.Z releases found — refusing to proceed.", + ); + } + + const sameMinor = releases.filter( + (r) => r.major === pinned.major && r.minor === pinned.minor, + ); + const latestPatch = sameMinor[sameMinor.length - 1]; + const latestOverall = releases[releases.length - 1]; + + console.log(`Pinned: ${pinned.tag}`); + console.log( + `Latest in ${pinned.major}.${pinned.minor}.x: ${latestPatch?.tag ?? "(none)"}`, + ); + console.log(`Latest overall: ${latestOverall.tag}`); + + // A newer minor/major is surfaced but never proposed: it means migration + // work, not a pin bump, and deciding when to take it is a human call. + const newerLine = + compare(latestOverall, latestPatch ?? pinned) > 0 + ? `${latestOverall.tag} is available but is a minor/major bump — not proposed automatically.` + : ""; + if (newerLine) console.log(`\nNote: ${newerLine}`); + emit("newer_minor", newerLine); + + if (!latestPatch || compare(latestPatch, pinned) <= 0) { + console.log("\n✓ Pin is current for this minor. Nothing to do."); + emit("bump", "false"); + return; + } + + console.log(`\n→ Patch bump available: ${pinned.tag} → ${latestPatch.tag}`); + emit("bump", "true"); + emit("from", pinned.tag); + emit("to", latestPatch.tag); + + if (apply) { + const source = fs.readFileSync(PIN_FILE, "utf8"); + fs.writeFileSync( + PIN_FILE, + source.replace(PIN_RE, `$1${latestPatch.tag}$3`), + ); + console.log(` Applied to ${path.relative(process.cwd(), PIN_FILE)}`); + } +} + +main().catch((err) => { + console.error(`✗ ${err.message}`); + process.exit(1); +}); diff --git a/scripts/cli-supplements/auth.md b/scripts/cli-supplements/auth.md new file mode 100644 index 0000000..aa87664 --- /dev/null +++ b/scripts/cli-supplements/auth.md @@ -0,0 +1,10 @@ +## Switching workspaces + +`stash` 1.0.0 keeps one active device-session profile at +`~/.cipherstash/auth.json`. To switch workspaces, run `npx stash auth login` +again and complete authorization for the target workspace. The new login +replaces the active session. + +`stash init` reuses the active session when it is still valid, so switch +workspaces before running `init`. There is no separate profile manager or +workspace-switch command in `stash` 1.0.0. diff --git a/scripts/fixtures/eql-manifest.sample.json b/scripts/fixtures/eql-manifest.sample.json new file mode 100644 index 0000000..25a5246 --- /dev/null +++ b/scripts/fixtures/eql-manifest.sample.json @@ -0,0 +1,192 @@ +{ + "_note": "ILLUSTRATIVE SAMPLE — shape only. Replaced at build time by the real eql-manifest.json from the eql-docs release asset (cipherstash/encrypt-query-language). Do not treat these signatures/domains as authoritative. Mirrors the real schema: domains live in `public.`, public functions in `eql_v3.`, internal ctors in `eql_v3_internal.` (visibility: private).", + "$schema": "https://schemas.cipherstash.com/eql/manifest/v1.json", + "name": "eql", + "version": "3.0.0-sample", + "generatedFrom": "doxygen-xml + catalog", + "counts": { "functions": 5, "public": 2, "private": 3, "domains": 9 }, + "functions": [ + { + "name": "version", + "signature": "version()", + "visibility": "public", + "brief": "Get the installed EQL version string.", + "description": "Returns the version string for the installed EQL library.", + "params": [], + "returns": { "type": "text", "description": "the version string" }, + "throws": [], + "notes": "", + "warnings": "", + "seeAlso": "", + "source": { "file": "src/v3/version.sql", "line": 28 } + }, + { + "name": "jsonb_path_query", + "signature": "jsonb_path_query(jsonb, text)", + "visibility": "public", + "brief": "Query encrypted JSONB for sv elements matching a selector.", + "description": "Returns one jsonb_entry row per matching encrypted element.", + "params": [ + { + "name": "val", + "type": "jsonb", + "description": "encrypted EQL payload with sv" + }, + { "name": "selector", "type": "text", "description": "selector hash" } + ], + "returns": { + "type": "public.jsonb_entry", + "description": "matching encrypted entries" + }, + "throws": [], + "notes": "", + "warnings": "", + "seeAlso": "", + "source": { "file": "src/v3/jsonb/functions.sql", "line": 358 } + }, + { + "name": "hmac_256", + "signature": "hmac_256(jsonb)", + "visibility": "private", + "brief": "Extract the HMAC-SHA-256 equality term from an encrypted value.", + "description": "Backs equality (`=`, `IN`, joins) on `_eq` and `text_search` columns.", + "params": [ + { "name": "val", "type": "jsonb", "description": "the encrypted value" } + ], + "returns": { "type": "text", "description": "the HMAC term" }, + "throws": [], + "notes": "", + "warnings": "", + "seeAlso": "", + "source": { "file": "src/v3/sem/hmac_256/functions.sql", "line": 12 } + }, + { + "name": "bloom_filter", + "signature": "bloom_filter(jsonb)", + "visibility": "private", + "brief": "Extract the bloom-filter match term from an encrypted value.", + "description": "Backs token containment (`@>`) on `text_match` / `text_search` columns.", + "params": [ + { "name": "val", "type": "jsonb", "description": "the encrypted value" } + ], + "returns": { + "type": "smallint[]", + "description": "the set bit positions" + }, + "throws": [], + "notes": "", + "warnings": "", + "seeAlso": "", + "source": { "file": "src/v3/sem/bloom_filter/functions.sql", "line": 20 } + }, + { + "name": "ore_block_256", + "signature": "ore_block_256(jsonb)", + "visibility": "private", + "brief": "Extract the ORE ordering term from an encrypted value.", + "description": "Backs range and ordering (`<`, `>`, `ORDER BY`) on `_ord` columns.", + "params": [ + { "name": "val", "type": "jsonb", "description": "the encrypted value" } + ], + "returns": { + "type": "eql_v3_internal.ore_block_256", + "description": "the ORE term" + }, + "throws": [], + "notes": "", + "warnings": "", + "seeAlso": "", + "source": { "file": "src/v3/sem/ore_block_256/functions.sql", "line": 8 } + } + ], + "domains": [ + { + "name": "public.integer", + "type": "integer", + "variant": "", + "base": "jsonb", + "capabilities": ["storage"], + "supportedOperators": [], + "termFunctions": [] + }, + { + "name": "public.integer_eq", + "type": "integer", + "variant": "eq", + "base": "jsonb", + "capabilities": ["equality"], + "supportedOperators": ["=", "<>"], + "termFunctions": ["eql_v3.eq_term"] + }, + { + "name": "public.integer_ord", + "type": "integer", + "variant": "ord", + "base": "jsonb", + "capabilities": ["equality", "order"], + "supportedOperators": ["=", "<>", "<", "<=", ">", ">="], + "termFunctions": ["eql_v3.ord_term"] + }, + { + "name": "public.integer_ord_ope", + "type": "integer", + "variant": "ord_ope", + "base": "jsonb", + "capabilities": ["equality", "order"], + "supportedOperators": ["=", "<>", "<", "<=", ">", ">="], + "termFunctions": ["eql_v3.ord_ope_term"] + }, + { + "name": "public.text_match", + "type": "text", + "variant": "match", + "base": "jsonb", + "capabilities": ["match"], + "supportedOperators": ["@>", "<@"], + "termFunctions": ["eql_v3.match_term"] + }, + { + "name": "public.text_search", + "type": "text", + "variant": "search", + "base": "jsonb", + "capabilities": ["equality", "order", "match"], + "supportedOperators": ["=", "<>", "<", "<=", ">", ">=", "@>", "<@"], + "termFunctions": [ + "eql_v3.eq_term", + "eql_v3.ord_term", + "eql_v3.match_term" + ] + }, + { + "name": "public.json", + "type": "jsonb", + "variant": "", + "base": "jsonb", + "shape": "stevec", + "capabilities": ["json"], + "supportedOperators": [], + "termFunctions": [] + }, + { + "name": "public.jsonb_entry", + "type": "jsonb", + "variant": "", + "base": "jsonb", + "shape": "stevec", + "capabilities": ["json"], + "supportedOperators": [], + "termFunctions": ["eql_v3.eq_term", "eql_v3.ore_cllw"] + }, + { + "name": "public.jsonb_query", + "type": "jsonb", + "variant": "", + "base": "jsonb", + "shape": "stevec", + "capabilities": ["json"], + "supportedOperators": [], + "termFunctions": [] + } + ] +} diff --git a/scripts/fixtures/stash-manifest.json b/scripts/fixtures/stash-manifest.json new file mode 100644 index 0000000..9c54546 --- /dev/null +++ b/scripts/fixtures/stash-manifest.json @@ -0,0 +1,494 @@ +{ + "name": "stash", + "version": "1.0.0", + "groups": [ + { + "title": "Setup & workflow", + "commands": [ + { + "name": "init", + "summary": "Initialize CipherStash for your project", + "long": "Set up CipherStash end-to-end: authenticate, introspect your database,\ninstall dependencies, install EQL, and hand off the rest to your local\ncoding agent. Every prompt has a non-interactive escape hatch, so init\nnever blocks waiting on a TTY (CI, agents, pipes).", + "examples": [ + "init", + "init --supabase", + "init --prisma", + "init --region us-east-1" + ], + "flags": [ + { + "name": "--supabase", + "description": "Use Supabase-specific setup flow." + }, + { + "name": "--drizzle", + "description": "Use Drizzle-specific setup flow." + }, + { + "name": "--prisma", + "description": "Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migrate)." + }, + { + "name": "--region", + "value": "", + "description": "Region to authenticate against (e.g. us-east-1). Skips the interactive region picker. Required for non-interactive init when not already logged in.", + "env": "STASH_REGION" + } + ] + }, + { + "name": "plan", + "summary": "Draft a reviewable encryption plan at .cipherstash/plan.md", + "examples": [ + "plan", + "plan --target claude-code", + "plan --complete-rollout --yes --target claude-code" + ], + "flags": [ + { + "name": "--complete-rollout", + "description": "Plan the entire encryption lifecycle (schema-add through drop) in one document. Skips the production-deploy gate; only safe when this database is not backing a deployed application. Needs confirmation — an interactive prompt, or --yes non-interactively (else it exits non-zero without drafting)." + }, + { + "name": "--yes", + "description": "Confirm --complete-rollout's gate-skip without a prompt (for automation / CI). No effect without --complete-rollout." + }, + { + "name": "--target", + "value": "", + "description": "Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts." + } + ] + }, + { + "name": "impl", + "summary": "Execute the plan with a local agent", + "examples": [ + "impl", + "impl --continue-without-plan", + "impl --target claude-code" + ], + "flags": [ + { + "name": "--continue-without-plan", + "description": "Skip planning and go straight to implementation (interactively confirms before proceeding)." + }, + { + "name": "--target", + "value": "", + "description": "Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts." + } + ] + }, + { + "name": "status", + "summary": "Displays implementation status", + "flags": [ + { + "name": "--quest", + "description": "Force the quest-log output (emoji + progress bars) even in non-TTY contexts." + }, + { + "name": "--plain", + "description": "Force the plain-text output even in TTY contexts." + }, + { + "name": "--json", + "description": "Emit a structured JSON document instead." + } + ] + }, + { + "name": "wizard", + "summary": "AI-guided encryption setup (reads your codebase)" + }, + { + "name": "doctor", + "summary": "Diagnose install problems (native binaries, runtime)" + }, + { + "name": "manifest", + "summary": "Print the structured, versioned command surface", + "long": "Emit the CLI command surface as data. `--json` produces the machine-\nreadable manifest the docs generator and agents consume; without it a\ngrouped command list is printed. The manifest is stamped with the CLI\nversion, so a page generated from it always names the version it describes.", + "examples": ["manifest --json", "manifest"], + "flags": [ + { + "name": "--json", + "description": "Emit the structured JSON manifest instead of a text list." + } + ] + }, + { + "name": "telemetry", + "summary": "Manage anonymous usage analytics", + "long": "Manage the anonymous, opt-out usage analytics the CLI collects to\nimprove the tool. `status` (the default) reports whether telemetry is\non and which setting governs it; `enable` / `disable` write your saved\npreference. Telemetry is also disabled by the DO_NOT_TRACK or\nSTASH_TELEMETRY_DISABLED environment variables and automatically in CI.\nNo plaintext, schema, table/column names, or connection details are\never collected. See https://cipherstash.com/docs/reference/cli.", + "examples": [ + "telemetry status", + "telemetry disable", + "telemetry enable" + ] + } + ] + }, + { + "title": "Auth", + "commands": [ + { + "name": "auth login", + "summary": "Authenticate with CipherStash", + "long": "Runs the OAuth 2.0 device authorization flow:\n1. Pick a region for your workspace.\n2. Approve in the browser — the URL is printed, so it works over SSH/headless.\n3. The CLI polls until you approve, then stores a short-lived token.\n4. Your device is bound to the workspace's default keyset, so later\n commands authenticate without a fresh login.", + "examples": [ + "auth login", + "auth login --region us-east-1", + "auth login --supabase", + "auth login --region us-east-1 --json" + ], + "flags": [ + { + "name": "--region", + "value": "", + "description": "Region to authenticate against (e.g. us-east-1). Skips the interactive region picker.", + "env": "STASH_REGION" + }, + { + "name": "--json", + "description": "Emit newline-delimited JSON events instead of prose. The first event (authorization_required) carries the device verification URL for a human to open. Implies no prompt and no browser auto-open." + }, + { + "name": "--no-open", + "description": "Don't auto-open the verification URL in a browser (already implied by --json)." + }, + { + "name": "--supabase", + "description": "Track Supabase as the referrer." + }, + { + "name": "--drizzle", + "description": "Track Drizzle as the referrer." + }, + { + "name": "--prisma", + "description": "Track Prisma as the referrer." + } + ] + }, + { + "name": "auth regions", + "summary": "List the regions you can authenticate against", + "examples": ["auth regions", "auth regions --json"], + "flags": [ + { + "name": "--json", + "description": "Emit machine-readable [{ slug, label }] instead of a text list." + } + ] + } + ] + }, + { + "title": "EQL", + "commands": [ + { + "name": "eql install", + "summary": "Scaffold stash.config.ts (if missing) and install EQL extensions", + "flags": [ + { + "name": "--force", + "description": "Reinstall / overwrite even if already installed." + }, + { + "name": "--dry-run", + "description": "Show what would happen without making changes." + }, + { + "name": "--supabase", + "description": "Use Supabase-compatible mode (auto-detected from DATABASE_URL)." + }, + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + }, + { + "name": "eql migration", + "summary": "Generate an EQL v3 install migration for your ORM (Drizzle; Prisma Next installs EQL through its own migrations)", + "examples": [ + "eql migration --drizzle", + "eql migration --drizzle --supabase" + ], + "flags": [ + { + "name": "--drizzle", + "description": "Emit a Drizzle custom migration containing the EQL v3 install SQL." + }, + { + "name": "--prisma", + "description": "Not needed: Prisma Next installs EQL through its own migration framework — run `prisma-next migrate` instead." + }, + { + "name": "--supabase", + "description": "Append the Supabase role grants (eql_v3 + eql_v3_internal for anon/authenticated/service_role)." + }, + { + "name": "--name", + "value": "", + "description": "Name for the generated migration (Drizzle). Letters, numbers, dashes, underscores only. Defaults to `install-eql`." + }, + { + "name": "--out", + "value": "", + "description": "Directory drizzle-kit writes the migration into (passed to `drizzle-kit generate --out`). Defaults to `drizzle`; set it to match your drizzle.config.ts." + }, + { + "name": "--dry-run", + "description": "Show what would happen without making changes." + } + ] + }, + { + "name": "eql repair", + "summary": "Repair migrations drizzle-kit generated with an un-runnable ALTER COLUMN to an encrypted type", + "long": "Sweep an existing Drizzle output directory for in-place\n`ALTER COLUMN ... SET DATA TYPE ` statements — which cannot run,\nbecause Postgres has no cast from text/numeric to an EQL domain — and rewrite\neach into an additive encrypted column that preserves the source column.\n\nThis is the same sweep `eql migration --drizzle` runs, without having to\ngenerate an EQL install migration you do not want just to trigger it.\n\nMigrations the database has already applied are reported and left alone:\nrewriting one would leave its .sql describing a shape that database never got\nfrom it, so a fresh CI or staging database replaying the file would silently\ndiverge. Pass --database-url so that check can run; without it the repair\nproceeds and warns that applied state could not be verified. If your\ndrizzle.config.ts overrides `migrations.table` / `migrations.schema`, name\nthe ledger with --migrations-table — otherwise the check queries the default\nrelation, finds nothing, and reports applied state as unverified.", + "examples": [ + "eql repair --drizzle", + "eql repair --drizzle --dry-run", + "eql repair --drizzle --out db/migrations --database-url postgres://…" + ], + "flags": [ + { + "name": "--drizzle", + "description": "Repair a Drizzle migration directory." + }, + { + "name": "--out", + "value": "", + "description": "Directory holding the migrations to sweep. Defaults to `drizzle`; set it to match your drizzle.config.ts." + }, + { + "name": "--migrations-table", + "value": "<[schema.]table>", + "description": "Drizzle's migration ledger, when drizzle.config.ts overrides `migrations.table` / `migrations.schema`. Defaults to `drizzle.__drizzle_migrations`. Only read with --database-url." + }, + { + "name": "--dry-run", + "description": "Show what would happen without making changes." + }, + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + }, + { + "name": "eql upgrade", + "summary": "Upgrade EQL extensions to the latest version", + "flags": [ + { + "name": "--dry-run", + "description": "Show what would happen without making changes." + }, + { + "name": "--supabase", + "description": "Use Supabase-compatible mode." + }, + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + }, + { + "name": "eql status", + "summary": "Show EQL installation status", + "flags": [ + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + } + ] + }, + { + "title": "Database", + "commands": [ + { + "name": "db validate", + "summary": "Validate encryption schema", + "flags": [ + { + "name": "--supabase", + "description": "Use Supabase-compatible mode." + }, + { + "name": "--exclude-operator-family", + "description": "Skip operator family creation." + }, + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + }, + { + "name": "db migrate", + "summary": "Run pending encrypt config migrations (not yet implemented)" + }, + { + "name": "db test-connection", + "summary": "Test database connectivity", + "flags": [ + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + } + ] + }, + { + "title": "Schema", + "commands": [ + { + "name": "schema build", + "summary": "Build an encryption schema from your database", + "flags": [ + { + "name": "--supabase", + "description": "Use Supabase-compatible mode." + }, + { + "name": "--database-url", + "value": "", + "description": "Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.", + "env": "DATABASE_URL" + } + ] + } + ] + }, + { + "title": "Encrypt", + "commands": [ + { + "name": "encrypt status", + "summary": "Show per-column migration status (phase, progress, drift)" + }, + { + "name": "encrypt plan", + "summary": "Diff intent (.cipherstash/migrations.json) vs observed state" + }, + { + "name": "encrypt backfill", + "summary": "Resumably encrypt plaintext into the encrypted column", + "flags": [ + { + "name": "--table", + "value": "", + "description": "Target table." + }, + { + "name": "--column", + "value": "", + "description": "Target column." + }, + { + "name": "--pk-column", + "value": "", + "description": "Primary-key column used to page through rows." + }, + { + "name": "--chunk-size", + "value": "", + "description": "Rows encrypted per batch." + }, + { + "name": "--encrypted-column", + "value": "", + "description": "Destination encrypted column." + }, + { + "name": "--schema-column-key", + "value": "", + "description": "Schema key identifying the column config." + }, + { + "name": "--confirm-dual-writes-deployed", + "description": "Assert the app is dual-writing before backfilling (safety gate)." + }, + { + "name": "--force", + "description": "Proceed past non-fatal safety checks." + } + ] + }, + { + "name": "encrypt drop", + "summary": "Generate a migration to drop the plaintext column", + "flags": [ + { + "name": "--table", + "value": "", + "description": "Target table." + }, + { + "name": "--column", + "value": "", + "description": "Target column." + }, + { + "name": "--migrations-dir", + "value": "", + "description": "Directory to write the drop migration into." + } + ] + } + ] + }, + { + "title": "Deployment", + "commands": [ + { + "name": "env", + "summary": "Mint deployment credentials and print them as env vars", + "long": "Mints a fresh ZeroKMS client and a CipherStash access key from your\ndevice-code session (`stash auth login`), then prints the four env\nvars a deployed app needs: CS_WORKSPACE_CRN, CS_CLIENT_ID,\nCS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY.\n\nThe access key is created with the member role (the CLI never mints\nadmin keys) and is shown exactly once — pipe the output into your\ndeployment secret store. Creating access keys requires your user to\nhave the admin role in the workspace.\n\nStdout carries only the dotenv block (or the --json events);\nprogress UI goes to stderr, so `stash env --name x > prod.env`\nand pipes into secret stores are safe.", + "examples": [ + "env --name my-app-prod", + "env --name my-app-prod --write", + "env --name staging --write .env.staging.local", + "env --name edge-dev --json" + ], + "flags": [ + { + "name": "--name", + "value": "", + "description": "Name for the minted access key and ZeroKMS client. Prompted for interactively; required in non-interactive runs." + }, + { + "name": "--write", + "value": "[path]", + "description": "Write the vars to a file (default .env.production.local, mode 0600) instead of printing them. An existing file prompts before overwriting — and is refused non-interactively — before anything is minted." + }, + { + "name": "--json", + "description": "Emit machine-readable NDJSON (a { status: \"minted\" } object, or { status: \"written\" } with --write — deliberately secret-free since the secrets are in the file; failures are { status: \"error\" }). Implies no prompts." + } + ] + } + ] + } + ] +} diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts new file mode 100644 index 0000000..3a017fc --- /dev/null +++ b/scripts/generate-cli-docs.ts @@ -0,0 +1,432 @@ +#!/usr/bin/env tsx +/** + * CLI reference generator. + * + * Generates the `/reference/cli` pages from the `stash` CLI itself, so the + * reference can never drift from the shipped command surface. Every page is + * stamped with the CLI version it was generated from. + * + * ── Data source ─────────────────────────────────────────────────────────── + * We consume `stash manifest --json` (shipped in stash CLI 0.17) — the + * structured, versioned command surface the CLI builds from its own command + * registry. Groups, summaries, per-command flags (with defaults + env vars), + * and curated examples all come straight from the CLI, so the docs are a + * projection of the real command set rather than a scrape of `--help`. + * + * ── Versioning ──────────────────────────────────────────────────────────── + * Always generated from the LATEST published `stash` on npm (resolved via + * `npm view stash version`), so a new release plus a run of this script — it + * runs in `prebuild` — refreshes the docs automatically. Every page carries + * `verifiedAgainst.cli` and a visible banner, so readers and agents always + * know which version the docs describe. Offline, it falls back to the cached + * `scripts/fixtures/stash-manifest.json`. + */ +import { execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const CLI_NAME = "stash"; +let CLI_VERSION = ""; // resolved to the latest published npm version at run time +const RUNNER = "npx"; // normalized invocation shown in docs +const FIXTURE = path.join( + process.cwd(), + "scripts/fixtures", + "stash-manifest.json", +); +const OUT_DIR = path.join(process.cwd(), "content/docs/reference/cli"); +// Hand-authored per-command prose merged into the generated page (hybrid model): +// the generated skeleton (synopsis + flags + examples) stays drift-free; a +// supplement adds rich narrative the manifest doesn't carry. Lives outside +// content/ so it's never treated as a page or wiped by the clean step. Where +// the CLI grows per-command long-help, that prose can migrate into the CLI and +// this hook retires. +const SUPPLEMENTS_DIR = path.join(process.cwd(), "scripts/cli-supplements"); + +// ── The `stash manifest --json` contract ──────────────────────────────────── +// Mirrors packages/cli/src/cli/manifest.ts in the stack repo. Command `name` +// is the full path ("eql install"); flags are already resolved per-command. +interface CliFlag { + name: string; // "--supabase" + value?: string; // "" + description: string; + default?: string; // surfaced default, when worth showing + env?: string; // env var that also sets this, e.g. DATABASE_URL +} +interface CliCommand { + name: string; + summary: string; + long?: string; + examples?: string[]; + flags?: CliFlag[]; +} +interface CliGroup { + title: string; + commands: CliCommand[]; +} +interface CliManifest { + name: string; + version: string; + groups: CliGroup[]; +} + +// ── Internal model (what the renderer consumes) ───────────────────────────── +interface Flag { + name: string; + value?: string; + description: string; +} +interface Command { + path: string; // "eql install" + base: string; // "eql" + sub?: string; // "install" + group: string; // nav group title, from the manifest + summary: string; + long?: string; + flags: Flag[]; + examples: string[]; +} +interface Manifest { + name: string; + version: string; + commands: Command[]; + groupOrder: string[]; // nav group order, as the CLI declares it +} + +// EQL/Postgres command groups get the `eql` component facet too (content-model +// rule: tag `eql` for queryable-in-Postgres ciphertext). +const componentsFor = (base: string): string[] => + ["eql", "db", "schema", "encrypt"].includes(base) ? ["cli", "eql"] : ["cli"]; + +// ── Source ────────────────────────────────────────────────────────────────── +// Resolve the latest published version so the docs track releases automatically. +function latestVersion(): string { + try { + return execSync(`npm view ${CLI_NAME} version`, { + encoding: "utf8", + }).trim(); + } catch { + const cached = fs.existsSync(FIXTURE) + ? (JSON.parse(fs.readFileSync(FIXTURE, "utf8")) as CliManifest).version + : undefined; + if (cached) { + console.warn(`⚠ npm unreachable; using cached stash v${cached}.`); + return cached; + } + throw new Error( + "Cannot resolve latest stash version (offline, no fixture).", + ); + } +} + +// Run the resolved CLI and read its `manifest --json`, caching to a fixture for +// offline builds. dotenvx (the CLI's launcher) may print tips before the JSON, +// so slice from the first `{` to the last `}` defensively. +function loadRawManifest(version: string): CliManifest { + try { + const out = execSync(`npx --yes ${CLI_NAME}@${version} manifest --json`, { + encoding: "utf8", + cwd: os.tmpdir(), + stdio: ["ignore", "pipe", "ignore"], + }); + const start = out.indexOf("{"); + const end = out.lastIndexOf("}"); + if (start === -1 || end < start) { + throw new Error( + `\`${CLI_NAME}@${version} manifest --json\` did not emit a JSON object (got: ${out.trim().slice(0, 120)}…)`, + ); + } + const manifest = JSON.parse(out.slice(start, end + 1)) as CliManifest; + fs.mkdirSync(path.dirname(FIXTURE), { recursive: true }); + fs.writeFileSync(FIXTURE, `${JSON.stringify(manifest, null, 2)}\n`); + return manifest; + } catch { + if (fs.existsSync(FIXTURE)) { + console.warn(`⚠ Could not run stash@${version}; using cached fixture.`); + return JSON.parse(fs.readFileSync(FIXTURE, "utf8")) as CliManifest; + } + throw new Error( + `Could not run stash@${version} manifest --json and no cached fixture exists.`, + ); + } +} + +// Fold the manifest's richer flag metadata (default + env) into the description +// column so the page format (Flag | Description) stays a single table. +function mapFlag(f: CliFlag): Flag { + const notes: string[] = []; + if (f.default !== undefined) notes.push(`default: \`${f.default}\``); + if (f.env) notes.push(`env: \`${f.env}\``); + const description = notes.length + ? `${f.description} (${notes.join("; ")})` + : f.description; + return { name: f.name, value: f.value, description }; +} + +// Project the CLI manifest onto the internal model the renderer consumes. +function toManifest(m: CliManifest): Manifest { + const commands: Command[] = []; + const groupOrder: string[] = []; + for (const group of m.groups) { + if (!group.commands.length) continue; + if (!groupOrder.includes(group.title)) groupOrder.push(group.title); + for (const c of group.commands) { + const [base, ...rest] = c.name.split(/\s+/); + commands.push({ + path: c.name, + base, + sub: rest.length ? rest.join(" ") : undefined, + group: group.title, + summary: c.summary, + long: c.long, + flags: (c.flags ?? []).map(mapFlag), + examples: (c.examples ?? []).map((e) => `${RUNNER} ${CLI_NAME} ${e}`), + }); + } + } + return { name: m.name, version: m.version, commands, groupOrder }; +} + +// ── Render ─────────────────────────────────────────────────────────────────── +const generatedMarker = (): string => + `{/* GENERATED — do not edit. Produced by scripts/generate-cli-docs.ts from \`${CLI_NAME} manifest --json\` (v${CLI_VERSION}). Re-run \`bun run generate-docs:cli\` to refresh from the latest published CLI. */}`; + +function banner(): string { + return ` +Generated from **\`${CLI_NAME}\` v${CLI_VERSION}** via \`${RUNNER} ${CLI_NAME}@${CLI_VERSION} manifest --json\`. Run \`${RUNNER} ${CLI_NAME}@${CLI_VERSION} --help\` to see the live command surface. +`; +} + +// Escape characters MDX parses as JSX inside prose: `{`/`}` (expression braces — +// e.g. the `auth regions` flag description "[{ slug, label }]" would otherwise +// evaluate `slug` and crash the prerender) and stray `<` (tags). Flag names and +// values render inside code spans, which are literal, so this only applies to +// manifest-derived prose (descriptions, summaries, and long help). +const escapeMdxText = (s: string): string => + s + .split(/(`[^`\n]*`)/g) + .map((part) => + part.startsWith("`") ? part : part.replace(/([{}<])/g, "\\$1"), + ) + .join(""); + +function flagsTable(flags: Flag[]): string { + if (!flags.length) return ""; + const rows = flags + .map((f) => { + // Escape pipes in option values so they don't read as table-column + // separators, even inside the code span. + const opt = `\`${f.name}${f.value ? ` ${f.value}` : ""}\``.replace( + /\|/g, + "\\|", + ); + const description = escapeMdxText(f.description).replace(/\|/g, "\\|"); + return `| ${opt} | ${description} |`; + }) + .join("\n"); + return `\n### Flags\n\n| Flag | Description |\n| --- | --- |\n${rows}\n`; +} + +function commandSection(cmd: Command, level: "##" | "###"): string { + const synopsis = `${RUNNER} ${CLI_NAME} ${cmd.path}${cmd.flags.length ? " [flags]" : ""}`; + const parts = [ + `${level} \`${cmd.path}\``, + "", + escapeMdxText(cmd.long ?? cmd.summary), + "", + "```bash", + synopsis, + "```", + ]; + if (cmd.flags.length) + parts.push(flagsTable(cmd.flags).replace("### Flags", `${level}# Flags`)); + if (cmd.examples.length) { + parts.push( + `\n${level}# Examples\n`, + "```bash", + cmd.examples.join("\n"), + "```", + ); + } + return parts.join("\n"); +} + +function renderPage( + base: string, + cmds: Command[], +): { slug: string; body: string } { + const isGroup = cmds.some((c) => c.sub) || cmds.length > 1; + const title = base; + const components = componentsFor(base); + const description = isGroup + ? `Reference for the \`${CLI_NAME} ${base}\` commands.` + : cmds[0].summary; + + const frontmatter = [ + "---", + `title: ${CLI_NAME} ${title}`, + `description: ${JSON.stringify(description)}`, + "type: reference", + `components: [${components.join(", ")}]`, + "verifiedAgainst:", + ` cli: "${CLI_VERSION}"`, + "---", + ].join("\n"); + + const parts = [frontmatter, "", generatedMarker(), "", banner(), ""]; + + if (isGroup) { + parts.push( + `The \`${CLI_NAME} ${base}\` command group.`, + "", + cmds.map((c) => commandSection(c, "###")).join("\n\n"), + ); + } else { + const c = cmds[0]; + parts.push( + escapeMdxText(c.long ?? c.summary), + "", + "```bash", + `${RUNNER} ${CLI_NAME} ${c.path}${c.flags.length ? " [flags]" : ""}`, + "```", + ); + if (c.flags.length) parts.push(flagsTable(c.flags)); + if (c.examples.length) + parts.push("\n## Examples\n", "```bash", c.examples.join("\n"), "```"); + } + + const supplement = readSupplement(base); + const body = `${parts.join("\n").trimEnd()}${supplement ? `\n\n${supplement}` : ""}\n`; + return { slug: base, body }; +} + +// Optional hand-authored prose for a command, merged after its generated +// reference. Returns "" when no supplement exists. +function readSupplement(slug: string): string { + const file = path.join(SUPPLEMENTS_DIR, `${slug}.md`); + return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() : ""; +} + +function renderIndex( + manifest: Manifest, + groups: Map, +): string { + const frontmatter = [ + "---", + "title: CLI", + "navTitle: Overview", + `description: "Command reference for the ${CLI_NAME} CLI, generated from v${CLI_VERSION}."`, + "type: reference", + "components: [cli]", + "verifiedAgainst:", + ` cli: "${CLI_VERSION}"`, + "---", + ].join("\n"); + + const sections = manifest.groupOrder + .filter((g) => groups.has(g)) + .map((g) => { + const rows = (groups.get(g) ?? []) + .flatMap((base) => + manifest.commands + .filter((c) => c.base === base) + .map((c) => { + const anchor = c.sub ? `#${c.path.replace(/\s+/g, "-")}` : ""; + const summary = escapeMdxText(c.summary).replace(/\|/g, "\\|"); + return `| [\`${c.path}\`](/reference/cli/${base}${anchor}) | ${summary} |`; + }), + ) + .join("\n"); + return `### ${g}\n\n| Command | Description |\n| --- | --- |\n${rows}`; + }) + .join("\n\n"); + + return `${frontmatter} + +${generatedMarker()} + +${banner()} + +The \`${CLI_NAME}\` CLI. Install with \`${RUNNER} ${CLI_NAME}@${CLI_VERSION}\`. Every command accepts \`--help\` and \`--version\`. + +${sections} +`; +} + +function renderMeta(manifest: Manifest, groups: Map): string { + const pages: string[] = ["index"]; + for (const g of manifest.groupOrder) { + const groupPages = groups.get(g); + if (!groupPages) continue; + pages.push(`---${g}---`); + pages.push(...groupPages); + } + return `${JSON.stringify({ title: "CLI", pages }, null, 2)}\n`; +} + +// ── Main ───────────────────────────────────────────────────────────────────── +function loadManifest(): Manifest { + return toManifest(loadRawManifest(CLI_VERSION)); +} + +function main() { + // latestVersion() picks which published CLI to invoke; the manifest we get + // back is authoritative for what to stamp. Reconcile CLI_VERSION to it so + // pages never claim a version different from the data they were built from + // (e.g. when the live run fails and we fall back to an older cached fixture). + CLI_VERSION = latestVersion(); + const manifest = loadManifest(); + CLI_VERSION = manifest.version; + + // Group top-level commands by base, preserving discovery order. + const bases: string[] = []; + for (const c of manifest.commands) + if (!bases.includes(c.base)) bases.push(c.base); + + // Nav groups, in the order the CLI declares them; a base inherits the group + // of its commands. + const groups = new Map(); + for (const g of manifest.groupOrder) groups.set(g, []); + for (const base of bases) { + const command = manifest.commands.find((c) => c.base === base); + if (!command) throw new Error(`No command found for base "${base}".`); + + const group = groups.get(command.group); + if (!group) { + throw new Error( + `Command "${command.path}" uses undeclared group "${command.group}".`, + ); + } + group.push(base); + } + for (const [g, list] of groups) if (!list.length) groups.delete(g); + + // Clean previously generated pages, then write fresh. + fs.mkdirSync(OUT_DIR, { recursive: true }); + for (const f of fs.readdirSync(OUT_DIR)) { + if (f.endsWith(".mdx") || f === "meta.json") + fs.rmSync(path.join(OUT_DIR, f)); + } + + let count = 0; + for (const base of bases) { + const cmds = manifest.commands.filter((c) => c.base === base); + const { slug, body } = renderPage(base, cmds); + fs.writeFileSync(path.join(OUT_DIR, `${slug}.mdx`), body); + count++; + } + fs.writeFileSync( + path.join(OUT_DIR, "index.mdx"), + renderIndex(manifest, groups), + ); + fs.writeFileSync( + path.join(OUT_DIR, "meta.json"), + renderMeta(manifest, groups), + ); + + console.log( + `✓ Generated ${count} CLI reference page(s) for ${CLI_NAME} v${manifest.version} → ${path.relative(process.cwd(), OUT_DIR)}`, + ); +} + +main(); diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index e254677..fb0d205 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -2,7 +2,7 @@ /** * Main orchestrator for generating TypeDoc API reference documentation. * - * Generates docs for @cipherstash/stack (which includes all integrations). + * Generates docs for the core @cipherstash/stack package. * * Set PROTECT_WORKSPACE_PATH to point to a local protectjs checkout * for development (e.g., /Users/cj/Documents/CipherStash/Github/protectjs). @@ -14,25 +14,45 @@ import { type DocsConfig, generateDocs } from "./lib/docs-generator.js"; const stackConfig: DocsConfig = { packageName: "@cipherstash/stack", + projectName: "@cipherstash/stack", repoUrl: "https://github.com/cipherstash/stack.git", + sourceRef: "@cipherstash/stack@1.0.0", tempDirName: ".tmp-stack", - baseOutputDir: path.join(process.cwd(), "content/stack/reference/stack"), + baseOutputDir: path.join( + process.cwd(), + "content/docs/reference/stack/api-reference", + ), + publicPath: "/reference/stack/api-reference", + metaTitle: "API reference", + versionedOutput: false, + entryPointBasePath: "packages/stack/src", + router: "module", + flattenOutputFiles: true, + generatedSources: { + "packages/stack/src/package-exports.ts": + '/** Exports available from the `@cipherstash/stack` package root.\n * @module Package exports\n */\nexport * from "./index.js"\n', + }, entryPoints: [ + "./packages/stack/src/package-exports.ts", "./packages/stack/src/encryption/index.ts", - "./packages/stack/src/secrets/index.ts", "./packages/stack/src/schema/index.ts", - "./packages/stack/src/drizzle/index.ts", + "./packages/stack/src/eql/v3/index.ts", + "./packages/stack/src/encryption/v3.ts", "./packages/stack/src/dynamodb/index.ts", - "./packages/stack/src/supabase/index.ts", "./packages/stack/src/identity/index.ts", "./packages/stack/src/types-public.ts", - "./packages/stack/src/client.ts", "./packages/stack/src/errors/index.ts", + "./packages/stack/src/adapter-kit.ts", + "./packages/stack/src/wasm-inline.ts", ], tsconfigInclude: ["packages/stack/src/**/*"], - tagFilter: (tag: string) => - tag.includes("@cipherstash/stack@") && !tag.includes("stack-"), + tagFilter: () => false, referencePathSegment: "stack", + frontmatterGlobals: { + type: "reference", + components: ["encryption", "eql"], + audience: ["developer"], + }, }; async function main() { diff --git a/scripts/generate-drizzle-docs.ts b/scripts/generate-drizzle-docs.ts new file mode 100644 index 0000000..37498e1 --- /dev/null +++ b/scripts/generate-drizzle-docs.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env tsx +/** Generate the Drizzle integration's API reference from its Stack 1.0 tag. */ +import path from "node:path"; +import { type DocsConfig, generateDocs } from "./lib/docs-generator.js"; + +const drizzleConfig: DocsConfig = { + packageName: "@cipherstash/stack-drizzle", + projectName: "@cipherstash/stack-drizzle", + repoUrl: "https://github.com/cipherstash/stack.git", + sourceRef: "@cipherstash/stack-drizzle@1.0.0", + tempDirName: ".tmp-stack-drizzle", + baseOutputDir: path.join( + process.cwd(), + "content/docs/integrations/drizzle/api-reference", + ), + publicPath: "/integrations/drizzle/api-reference", + metaTitle: "API reference", + versionedOutput: false, + entryPointBasePath: "packages/stack-drizzle/src", + entryModule: "index", + router: "module", + flattenOutputFiles: true, + entryPoints: ["./packages/stack-drizzle/src/index.ts"], + tsconfigInclude: ["packages/stack-drizzle/src/**/*"], + tagFilter: () => false, + referencePathSegment: "drizzle", + frontmatterGlobals: { + type: "reference", + components: ["encryption", "eql"], + audience: ["developer"], + }, +}; + +generateDocs(drizzleConfig).catch((error) => { + console.error("Failed to generate the Drizzle API reference:", error); + process.exit(1); +}); diff --git a/scripts/generate-eql-api-docs.ts b/scripts/generate-eql-api-docs.ts new file mode 100644 index 0000000..5bcc536 --- /dev/null +++ b/scripts/generate-eql-api-docs.ts @@ -0,0 +1,554 @@ +#!/usr/bin/env tsx +/** + * EQL API reference generator + drift guard (docs V2). + * + * Consumes the structured `eql-manifest.json` that the EQL repo emits from its + * Doxygen'd SQL (cipherstash/encrypt-query-language#364) and: + * + * 1. Generates a version-stamped function catalog at + * content/docs/reference/eql/functions.mdx — the exhaustive, drift-proof + * low-level reference the hand-written pedagogical pages link to. + * 2. Drift-lints the hand-written pages: every schema-qualified reference + * (`public.`, `eql_v3.`, `eql_v3_internal.`) must match the + * manifest by schema AND name. This catches fabricated names, stale + * payloads, and right-name/wrong-schema refs (e.g. `eql_v3.text_eq` for a + * domain that lives in `public`) — the classes of drift that slipped into + * the v2 docs. + * + * ── Manifest source ──────────────────────────────────────────────────────── + * Reads the committed illustrative sample by default. Set EQL_MANIFEST_PATH to + * a real manifest (the `eql-docs-` release asset extracted by + * `generate-eql-docs.ts`, or a locally-generated one) — the renderer and lint + * are identical either way. + * + * The drift-lint is REPORT-ONLY against the sample (tiny, so most real symbols + * read as "unknown"); it becomes a failing gate against a real manifest + * (auto-strict when EQL_MANIFEST_PATH is set). See STRICT below. + */ +import fs from "node:fs"; +import path from "node:path"; + +// Manifest source, in priority order: +// 1. EQL_MANIFEST_PATH — explicit override (local testing / CI). +// 2. .eql-manifest.release.json — extracted from the eql-docs release asset +// by generate-eql-docs.ts (present once a release ships the manifest). +// 3. the committed illustrative sample — offline fallback. +const SAMPLE_MANIFEST = path.join( + process.cwd(), + "scripts/fixtures/eql-manifest.sample.json", +); +const RELEASE_MANIFEST = path.join(process.cwd(), ".eql-manifest.release.json"); +const MANIFEST_PATH = + process.env.EQL_MANIFEST_PATH ?? + (fs.existsSync(RELEASE_MANIFEST) ? RELEASE_MANIFEST : SAMPLE_MANIFEST); +const EQL_DIR = path.join(process.cwd(), "content/docs/reference/eql"); +const OUT_FILE = path.join(EQL_DIR, "functions.mdx"); +// Per-type function fragments embedded into the hand-written type pages via +// Fumadocs' `` directive. They live OUTSIDE the two content +// collections (content/docs, content/stack) so they never become routes, and +// are included cwd-relative (`content/partials/…`). Generated, so +// the per-type "which functions apply" tables can't drift from the manifest. +const FRAGMENT_DIR = path.join(process.cwd(), "content/partials/eql"); +// Single source for the EQL version the whole reference is built against: the +// release manifest's own `version`. Written here so the banner on +// every EQL page reads the same release-derived value (no hardcoded constant). +const VERSION_FILE = path.join(process.cwd(), "src/lib/eql-version.ts"); +// Report-only against the illustrative sample; a failing gate against any real +// manifest (the release asset or an explicit override). +const STRICT = MANIFEST_PATH !== SAMPLE_MANIFEST; + +interface Param { + name: string; + type?: string; + description?: string; +} +interface Fn { + name: string; + signature: string; + visibility: "public" | "private"; + brief: string; + description?: string; + params: Param[]; + returns?: { type?: string; description?: string }; + source?: { file?: string; line?: number }; +} +interface Domain { + name: string; + type: string; + variant: string; + base?: string; + capabilities: string[]; + supportedOperators?: string[]; + termFunctions?: string[]; + shape?: string; +} +interface Manifest { + version: string; + functions: Fn[]; + domains?: Domain[]; +} + +function loadManifest(): Manifest { + return JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")); +} + +// ── Render the generated catalog ───────────────────────────────────────────── +/** + * Make a manifest prose string safe to drop into MDX. + * + * Doxygen comments are written for a plain-text renderer, so they contain bare + * `<` and `{` — SQL operators especially (`<>`, `<=`). MDX reads those as JSX + * and the build dies on the *content* file, far from the cause: EQL 3.0.3's + * `ope_term` brief says "= / <> are blocked", which parses as an unclosed + * fragment. Escape both, but only outside inline-code spans, so the many + * descriptions that already write `` `<>` `` keep rendering as code. + */ +function mdxProse(s: string): string { + return s + .split(/(`+[^`]*`+)/g) + .map((seg, i) => + i % 2 === 1 ? seg : seg.replace(/ + `| \`${p.name}\` | ${p.type ? `\`${p.type}\`` : ""} | ${mdxProse(p.description ?? "").replace(/\|/g, "\\|")} |`, + ) + .join("\n"); + return `\n| Parameter | Type | Description |\n| --- | --- | --- |\n${rows}\n`; +} + +// Public functions are heavily overloaded — one name per operation with a +// per-encrypted-type variant, all sharing the same doc. Group by name so the +// page is one entry per function (its distinct signatures listed) instead of +// ~hundreds of near-identical overload blocks. +function renderPublicFunctions(fns: Fn[]): string { + const byName = new Map(); + for (const f of fns) { + const g = byName.get(f.name) ?? []; + g.push(f); + byName.set(f.name, g); + } + const sections: string[] = []; + const entries = [...byName.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ); + for (const [name, overloads] of entries) { + const rep = + overloads.find((o) => o.params.length || o.brief) ?? overloads[0]; + const sigs = [...new Set(overloads.map((o) => o.signature))].sort(); + const parts = [`### \`${name}\``, "", mdxProse(rep.brief)]; + if (rep.description && rep.description !== rep.brief) + parts.push("", mdxProse(rep.description)); + parts.push( + "", + sigs.length > 1 + ? `**${sigs.length} overloads** (one per encrypted type):` + : "**Signature:**", + "", + "```sql", + ...sigs, + "```", + ); + if (rep.params.length) parts.push(paramsTable(rep.params)); + if (rep.returns?.type || rep.returns?.description) { + const t = rep.returns.type ? `\`${rep.returns.type}\`` : ""; + parts.push( + "", + `**Returns:** ${t}${rep.returns.description ? ` — ${mdxProse(rep.returns.description)}` : ""}`, + ); + } + sections.push(parts.join("\n")); + } + return sections.join("\n\n"); +} + +function renderDomains(domains: Domain[]): string { + if (!domains.length) return ""; + const rows = domains + .map((d) => { + const variant = d.variant ? `\`_${d.variant}\`` : "_(storage only)_"; + const ops = d.supportedOperators?.length + ? d.supportedOperators.map((o) => `\`${o}\``).join(" ") + : "—"; + return `| \`${d.name}\` | ${d.type} | ${variant} | ${d.capabilities.join(", ")} | ${ops} |`; + }) + .join("\n"); + return [ + "## Encrypted domains", + "", + "A column's capability is declared by its **domain variant**. This matrix comes straight from the Rust catalog (`eql-codegen dump-catalog`) — the source of truth the SQL is generated from. See [Core concepts](/reference/eql/core-concepts) for the model.", + "", + "| Domain | Type | Variant | Capabilities | Operators |", + "| --- | --- | --- | --- | --- |", + rows, + "", + ].join("\n"); +} + +function render(manifest: Manifest): string { + const version = manifest.version; + // Operators (`->`, `>`) reach the manifest as pseudo-functions via Doxygen's + // operator-name remapping; they render poorly and are already covered by the + // domain matrix's Operators column, so keep only real named functions here. + const isNamed = (f: Fn) => /^[a-z_][a-z0-9_]*$/.test(f.name); + const publicFns = manifest.functions.filter( + (f) => f.visibility === "public" && isNamed(f), + ); + const privateFns = manifest.functions.filter( + (f) => f.visibility === "private", + ); + + const frontmatter = [ + "---", + "title: Functions", + `description: "Generated catalog of EQL SQL functions and operators (EQL ${version})."`, + "type: reference", + "components: [eql]", + "verifiedAgainst:", + ` eql: "${version}"`, + "---", + ].join("\n"); + + const body = [ + frontmatter, + "", + `{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest (v${version}). */}`, + "", + "", + "", + ``, + `Generated from the **EQL ${version}** manifest (the Doxygen'd SQL is the source of truth). For the model behind these — variants, terms, typed operands — see [Core concepts](/reference/eql/core-concepts).`, + ``, + "", + "The EQL SQL surface — encrypted domains (in the `public` schema) and the `eql_v3` functions behind them. The type and query pages explain *when* to use these; this page is the exhaustive reference they link to.", + "", + renderDomains(manifest.domains ?? []), + "## Functions", + "", + `The public \`eql_v3\` API — ${new Set(publicFns.map((f) => f.name)).size} functions (${publicFns.length} overloads). Internal \`eql_v3_internal\` functions (${privateFns.length}) are implementation detail and omitted.`, + "", + renderPublicFunctions(publicFns), + ]; + + return `${body.join("\n").trimEnd()}\n`; +} + +// ── Per-type function fragments ────────────────────────────────────────────── +// Each hand-written type page (numbers, text, dates-and-times) carries a +// per-function reference: one card per EQL function, listing its operator +// equivalents, the domains it applies to, and a worked example. These are +// generated from the manifest and ``d into the page (via the `` +// component) rather than hand-maintained, where the domain lists silently +// drifted. The example is the one authored part — templated by capability, not +// pulled from the manifest — and the domain list, the drift-prone part, is not. +// +// json and booleans are intentionally NOT here: json's surface is containment / +// path functions (a bespoke story, not the eq/ord/min-max set), and booleans +// are storage-only with no query functions. +interface FragmentSpec { + page: string; + // Which manifest domain `type`s belong on this page. + match: (type: string) => boolean; + // Illustrative context for the generated examples. + table: string; + col: string; + // Representative concrete type the example casts to, e.g. `bigint` → + // `public.eql_v3_bigint_ord`. One of the page's family, for a realistic cast. + castType: string; + // On text, ranges are unusual and sorting is the point, so the comparison + // example demonstrates ORDER BY. Elsewhere a range filter reads best. + orderByExample: boolean; +} + +const FRAGMENT_SPECS: FragmentSpec[] = [ + { + page: "numbers", + match: (t) => + /(^|\b)(small|big)?int|integer|numeric|decimal|real|double|float/.test(t), + table: "payments", + col: "amount", + castType: "bigint", + orderByExample: false, + }, + { + page: "text", + match: (t) => t === "text", + table: "users", + col: "email", + castType: "text", + orderByExample: true, + }, + { + page: "dates-and-times", + match: (t) => /date|time/.test(t), + table: "events", + col: "occurred_at", + castType: "timestamp", + orderByExample: false, + }, +]; + +// The EQL function set, in reading order. `cap` is the domain capability that +// exposes the function, so a function only renders when the page has a domain +// with that capability. `lt`/`lte`/`gt`/`gte` are one card: same capability, +// same domains. `id` is the deep-link anchor. +interface FuncDef { + id: string; + name: string; + ops: string[]; + /** Domain capability that exposes the function. */ + cap?: string; + /** + * Operator that exposes the function, for surfaces the catalog does not model + * as a capability. Fuzzy match is the case: 3.0.1 dropped the `match` + * capability from the catalog, and `public.eql_v3_text_match` now reports + * `capabilities: ["storage"]` with `supportedOperators: ["@@"]`, so the + * operator list is the only reliable signal that a domain can be matched on. + */ + op?: string; + agg?: boolean; +} +const FUNCS: FuncDef[] = [ + { id: "fn-eq", name: "eql_v3.eq(a, b)", ops: ["="], cap: "equality" }, + { id: "fn-neq", name: "eql_v3.neq(a, b)", ops: ["<>"], cap: "equality" }, + { + id: "fn-comparison", + name: "eql_v3.lt / lte / gt / gte", + ops: ["<", "<=", ">", ">="], + cap: "order", + }, + { + id: "fn-matches", + name: "eql_v3.matches(a, b)", + ops: ["@@"], + op: "@@", + }, + { + id: "fn-min", + name: "eql_v3.min(col)", + ops: ["MIN"], + cap: "order", + agg: true, + }, + { + id: "fn-max", + name: "eql_v3.max(col)", + ops: ["MAX"], + cap: "order", + agg: true, + }, +]; + +// `public.eql_v3_text_eq` → `text_eq`. +const shortDomain = (name: string) => name.replace(/^public\.(eql_v3_)?/, ""); + +// The example for one function, keyed by its anchor id and templated from the +// page's illustrative context. The `::public.eql_v3__` casts use +// real domain names, so they stay correct; the table and column are illustrative. +function exampleFor(id: string, spec: FragmentSpec): string { + const { table, col, castType } = spec; + const dom = (variant: string) => `public.eql_v3_${castType}_${variant}`; + switch (id) { + case "fn-eq": + return `SELECT * FROM ${table}\nWHERE eql_v3.eq(${col}, $1::${dom("eq")});`; + case "fn-neq": + return `SELECT * FROM ${table}\nWHERE eql_v3.neq(${col}, $1::${dom("eq")});`; + case "fn-comparison": + return spec.orderByExample + ? `-- any of the four; ordering is the usual reason to index text\nSELECT id, ${col} FROM ${table}\nWHERE eql_v3.gt(${col}, $1::${dom("ord")})\nORDER BY eql_v3.ord_term(${col});` + : `-- a range uses two of the four\nSELECT * FROM ${table}\nWHERE eql_v3.gte(${col}, $1::${dom("ord")})\n AND eql_v3.lt(${col}, $2::${dom("ord")});`; + case "fn-matches": + return `-- probabilistic fuzzy match on the bloom-filter term\nSELECT * FROM ${table}\nWHERE eql_v3.matches(${col}, $1::${dom("match")});`; + case "fn-min": + return `-- compares ordering terms; result decrypts client-side\nSELECT eql_v3.min(${col}) FROM ${table};`; + case "fn-max": + return `SELECT eql_v3.max(${col}) FROM ${table};`; + default: + return ""; + } +} + +function renderFragment(domains: Domain[], spec: FragmentSpec): string { + const scoped = domains.filter((d) => spec.match(d.type)); + const header = `{/* GENERATED — do not edit. Produced by scripts/generate-eql-api-docs.ts from the EQL manifest. Edit the generator, not this file. */}`; + const intro = + "Every operator has a function form, for managed platforms that disallow custom operators — same typed arguments, identical resolution. Each lists the encrypted domains it applies to; the `MIN` / `MAX` aggregates only exist as functions."; + if (!scoped.length) { + return `${header}\n\n${intro}\n\n_No matching encrypted domains in this EQL manifest._\n`; + } + + const blocks: string[] = []; + for (const fn of FUNCS) { + const applies = scoped + .filter((d) => { + if (!d.variant) return false; + return fn.op + ? (d.supportedOperators ?? []).includes(fn.op) + : !!fn.cap && d.capabilities.includes(fn.cap); + }) + .map((d) => shortDomain(d.name)); + if (!applies.length) continue; + const attrs = [ + `ops="${fn.ops.join(",")}"`, + fn.agg ? "agg" : "", + `domains="${applies.join(",")}"`, + ] + .filter(Boolean) + .join(" "); + const example = exampleFor(fn.id, spec); + // The name is a real `###` heading with an explicit id, so each function + // gets a table-of-contents entry (nested under "Functions") and a stable + // deep-link anchor. The `` card renders everything below it. + blocks.push( + `### ${fn.name} [#${fn.id}]\n\n\n\n\`\`\`sql\n${example}\n\`\`\`\n\n`, + ); + } + + return `${header}\n\n${intro}\n\n${blocks.join("\n\n")}\n`; +} + +function writeFragments(manifest: Manifest): void { + fs.mkdirSync(FRAGMENT_DIR, { recursive: true }); + for (const spec of FRAGMENT_SPECS) { + const out = path.join(FRAGMENT_DIR, `functions-${spec.page}.mdx`); + fs.writeFileSync(out, renderFragment(manifest.domains ?? [], spec)); + console.log(`✓ Generated ${path.relative(process.cwd(), out)}`); + } +} + +// ── Drift guard ────────────────────────────────────────────────────────────── +// The known surface is fully schema-qualified: domains live in `public.`, +// functions in `eql_v3.` (public) or `eql_v3_internal.` (private), and the +// per-domain extractor term-functions come pre-qualified from the catalog. +// Matching schema-AND-name means a right-name/wrong-schema reference — e.g. +// `eql_v3.text_eq`, whose domain is actually `public.text_eq` — is flagged too, +// not just fabricated names. +function knownSymbols(manifest: Manifest): Set { + const known = new Set(); + for (const d of manifest.domains ?? []) { + known.add(d.name); // public. + for (const fn of d.termFunctions ?? []) known.add(fn); // eql_v3. + } + for (const f of manifest.functions) { + const schema = f.visibility === "private" ? "eql_v3_internal" : "eql_v3"; + known.add(`${schema}.${f.name}`); + } + return known; +} + +// Prose that names a symbol in order to say it no longer exists (or no longer +// means what it used to). Kept narrow — these are the words the upgrade notes +// and rename callouts actually use, not a general escape hatch. +const RETIRED_MARKER = /\b(removed|renamed|retired|deprecated|no longer)\b/i; + +function driftCheck(manifest: Manifest): string[] { + const known = knownSymbols(manifest); + const referenced = new Map>(); // fqn -> pages + + for (const file of fs.readdirSync(EQL_DIR)) { + if (!file.endsWith(".mdx") || file === "functions.mdx") continue; + const text = fs.readFileSync(path.join(EQL_DIR, file), "utf8"); + for (const line of text.split("\n")) { + // A line that names a symbol in order to say it is gone is documenting + // history, not teaching an API — an upgrade note has to be able to write + // "`eql_v3.jsonb_array_elements_text` was removed". Same line-local + // exemption validate-content-api.ts makes, and the same trade: naming a + // dead symbol WITHOUT saying it is dead is the bug being caught. + if (RETIRED_MARKER.test(line)) continue; + // Any schema-qualified reference — function call, domain cast, or type. + // A trailing `*` marks a prose family (e.g. `eql_v3.jsonb_path_*`), which + // names a set rather than one symbol, so it's skipped. So is a trailing + // `` placeholder (e.g. `public.eql_v3__ord`). + for (const m of line.matchAll( + /\b(public|eql_v3_internal|eql_v3)\.([a-z0-9_]+)(\*?)/g, + )) { + if (m[3] === "*") continue; + if (line[(m.index ?? 0) + m[0].length] === "<") continue; + const fqn = `${m[1]}.${m[2]}`; + const pages = referenced.get(fqn) ?? new Set(); + pages.add(file); + referenced.set(fqn, pages); + } + } + } + + const unknown: string[] = []; + for (const [fqn, pages] of referenced) { + if (known.has(fqn) || MANIFEST_BLIND_SPOTS.has(fqn)) continue; + unknown.push(`${fqn} (in ${[...pages].join(", ")})`); + } + return unknown.sort(); +} + +// Symbols that EQL really ships but the manifest's catalog does not list, so +// the drift check would reject a correct reference. +// +// The scalar query-operand domains (`eql_v3.query_text_eq`, and 39 siblings) +// are created inside a `DO ... EXECUTE` block in the install SQL, which the +// catalog generator does not walk — `eql_v3.query_jsonb`, created at the top +// level, IS in the manifest. Verified present in the 3.0.0 install SQL: +// grep -c "CREATE DOMAIN eql_v3.query_" cipherstash-encrypt.sql # => 40 +// +// Keep this list minimal, and delete entries as the manifest grows to cover +// them. +// +// `eql_v3.grouped_value` and `public.eql_v3_json` used to be listed here too. +// Both were fixed upstream in EQL 3.0.4 +// (cipherstash/encrypt-query-language#427): the aggregate was lost because +// Doxygen misread `CREATE AGGREGATE`'s definition body and named the member +// after its argument type, and the storage-only JSON domain was filtered out of +// the catalog dump as scalar while the scalar path skipped its mixed family +// wholesale. Both now resolve from the manifest, so the exemptions are gone — +// and if either regresses, the drift check says so instead of staying quiet. +const MANIFEST_BLIND_SPOTS = new Set(["eql_v3.query_text_eq"]); + +// ── Main ───────────────────────────────────────────────────────────────────── +function main() { + const manifest = loadManifest(); + + fs.mkdirSync(EQL_DIR, { recursive: true }); + fs.writeFileSync(OUT_FILE, render(manifest)); + + // Per-type function fragments included into the hand-written type pages. + writeFragments(manifest); + + // Emit the release version for the banner (shared by every EQL + // reference page, hand-written and generated alike). + fs.mkdirSync(path.dirname(VERSION_FILE), { recursive: true }); + fs.writeFileSync( + VERSION_FILE, + `// GENERATED by scripts/generate-eql-api-docs.ts from the EQL release manifest.\n// Do not edit; the prebuild step overwrites it with the version of the EQL\n// release the docs are built against.\nexport const EQL_VERSION = ${JSON.stringify(manifest.version)};\n`, + ); + console.log( + `✓ Generated ${path.relative(process.cwd(), OUT_FILE)} from EQL ${manifest.version} (${manifest.functions.length} functions)`, + ); + + const unknown = driftCheck(manifest); + if (unknown.length) { + const header = `⚠ ${unknown.length} schema-qualified symbol(s) referenced in hand-written pages are not in the manifest (domains or functions):`; + console.warn(`\n${header}`); + for (const u of unknown) console.warn(` - ${u}`); + if (STRICT) { + console.error( + "\nDrift check failed (STRICT). Fix the reference or update the pinned EQL version.", + ); + process.exit(1); + } else { + console.warn( + "\n(Report-only: using the illustrative sample manifest, which covers only a few symbols. A real manifest — the release asset or EQL_MANIFEST_PATH — makes this a failing gate.)", + ); + } + } else { + console.log( + "✓ Drift check: all schema-qualified symbols resolve against the manifest.", + ); + } +} + +main(); diff --git a/scripts/generate-eql-docs.ts b/scripts/generate-eql-docs.ts index 2ad8994..6f9b171 100644 --- a/scripts/generate-eql-docs.ts +++ b/scripts/generate-eql-docs.ts @@ -2,19 +2,46 @@ /** * Generates EQL (Encrypt Query Language) API reference documentation. * - * Fetches the latest release from the encrypt-query-language repository, - * downloads the docs tarball, and converts API.md to an MDX page under - * content/stack/reference/eql/. + * Downloads the docs tarball for the pinned EQL release and converts API.md to + * an MDX page under content/stack/reference/eql/. + * + * The release is PINNED. `generate-eql-api-docs.ts` then runs a STRICT drift + * check of the hand-written reference against this release's manifest, so an + * unpinned "latest release" meant any upstream EQL publish could turn the docs + * build red with no commit in this repo — which is exactly what + * eql-3.0.0-alpha.4 did when it replaced `eql_v3.ore_cllw`. + * + * To upgrade: bump EQL_RELEASE_TAG, run `bun run build`, and fix whatever the + * drift check reports. That makes an EQL upgrade a deliberate, reviewable + * commit rather than an ambush on whoever has a PR open. + * + * `EQL_RELEASE_TAG=eql-3.0.0 bun run generate-docs:eql` overrides it for a + * one-off check against a different release. */ import { execSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import GithubSlugger from "github-slugger"; -const GITHUB_API_URL = - "https://api.github.com/repos/cipherstash/encrypt-query-language/releases"; +/** + * The EQL release the docs are written against. + * + * 3.0.x is still moving: 3.0.1 renamed the encrypted-JSON domains and replaced + * the text fuzzy-match operator, 3.0.3 guarded the empty-bloom needle, and + * 3.0.4 fixed the manifest extraction that had been dropping + * `eql_v3.grouped_value` and `public.eql_v3_json` from the catalog entirely + * (cipherstash/encrypt-query-language#427). Each bump is a deliberate commit + * that fixes whatever the drift check reports. + */ +const EQL_RELEASE_TAG = process.env.EQL_RELEASE_TAG ?? "eql-3.0.4"; + +const GITHUB_RELEASE_DOWNLOAD = + "https://github.com/cipherstash/encrypt-query-language/releases/download"; const TEMP_DIR = ".tmp-eql"; const OUTPUT_DIR = path.join(process.cwd(), "content/stack/reference/eql"); +// Where the machine-readable manifest is surfaced for the v2 API-reference +// generator (scripts/generate-eql-api-docs.ts). Gitignored; best-effort. +const RELEASE_MANIFEST = path.join(process.cwd(), ".eql-manifest.release.json"); /** * Check if a tarball URL exists (returns HTTP 200) @@ -32,37 +59,26 @@ function checkTarballExists(url: string): boolean { } /** - * Get the latest release that has a docs tarball available + * Resolve the docs tarball for the pinned release. */ -async function getLatestRelease(): Promise<{ +async function getPinnedRelease(): Promise<{ tag: string; tarballUrl: string; }> { - console.log("Fetching releases from GitHub..."); - const response = execSync(`curl -sL ${GITHUB_API_URL}`, { - encoding: "utf8", - }); - - const releases = JSON.parse(response); - - if (!Array.isArray(releases) || releases.length === 0) { - throw new Error("No releases found in GitHub API response"); - } - - for (const release of releases) { - const tag = release.tag_name; - if (!tag || !tag.startsWith("eql-")) continue; - - const tarballUrl = `https://github.com/cipherstash/encrypt-query-language/releases/download/${tag}/eql-docs-${tag}.tar.gz`; - - console.log(`Checking ${tag}...`); - if (checkTarballExists(tarballUrl)) { - console.log(`Found docs tarball for ${tag}`); - return { tag, tarballUrl }; - } + const tag = EQL_RELEASE_TAG; + const tarballUrl = `${GITHUB_RELEASE_DOWNLOAD}/${tag}/eql-docs-${tag}.tar.gz`; + + console.log(`Using pinned EQL release: ${tag}`); + if (!checkTarballExists(tarballUrl)) { + throw new Error( + `No docs tarball for pinned EQL release "${tag}".\n` + + ` Expected: ${tarballUrl}\n` + + " Update EQL_RELEASE_TAG in scripts/generate-eql-docs.ts, or set the\n" + + " EQL_RELEASE_TAG environment variable to a release that ships one.", + ); } - throw new Error("No release with documentation tarball found"); + return { tag, tarballUrl }; } /** @@ -116,20 +132,22 @@ function escapeMdxSpecials(content: string): string { const escaped = parts .map((part, i) => { if (i % 2 === 1) return part; - return part - .replace(/\{/g, "\\{") - .replace(/\}/g, "\\}") - // Strip Doxygen's `` teletype tags. They're a lowercase-led - // "tag" so the `<` rule below leaves them intact, but MDX requires - // every tag to be balanced — and mangled SQL-comment source can - // emit a stray, unclosed `` (e.g. eql-3.0.0-alpha.3's API.md), - // which fails the whole build. MDX doesn't need the tag, so drop it. - .replace(/<\/?tt\b[^>]*>/gi, "") - // Escape `<` unless it begins a real JSX/HTML tag, a closing - // tag, or an autolink (followed by a lowercase letter, `_`, `$`, - // or `/`). Uppercase-led tokens like `` are type placeholders - // in the API reference, not JSX, so they must be escaped too. - .replace(/<(?![a-z_$/])/g, "\\<"); + return ( + part + .replace(/\{/g, "\\{") + .replace(/\}/g, "\\}") + // Strip Doxygen's `` teletype tags. They're a lowercase-led + // "tag" so the `<` rule below leaves them intact, but MDX requires + // every tag to be balanced — and mangled SQL-comment source can + // emit a stray, unclosed `` (e.g. eql-3.0.0-alpha.3's API.md), + // which fails the whole build. MDX doesn't need the tag, so drop it. + .replace(/<\/?tt\b[^>]*>/gi, "") + // Escape `<` unless it begins a real JSX/HTML tag, a closing + // tag, or an autolink (followed by a lowercase letter, `_`, `$`, + // or `/`). Uppercase-led tokens like `` are type placeholders + // in the API reference, not JSX, so they must be escaped too. + .replace(/<(?![a-z_$/])/g, "\\<") + ); }) .join(""); result.push(escaped); @@ -231,8 +249,7 @@ async function main() { console.log("EQL API Reference Documentation Generator"); console.log("=".repeat(60)); - const { tag, tarballUrl } = await getLatestRelease(); - console.log(`Latest release: ${tag}`); + const { tag, tarballUrl } = await getPinnedRelease(); const extractPath = await downloadAndExtractDocs(tarballUrl, tag); @@ -259,6 +276,40 @@ async function main() { "utf8", ); + // Surface the machine-readable manifest for the v2 API-reference generator. + // Only releases packaged with the manifest carry it; older ones don't, so + // this is best-effort and the generator falls back to the committed sample. + const manifestSrc = path.join(extractPath, "json", "eql-manifest.json"); + try { + // The pinned tag is the authoritative version, so always stamp it rather + // than trusting the manifest's own field. EQL's release workflow derives + // that field from a tag input that can arrive empty, in which case + // `json.sh` silently falls back to `"DEV"` — which eql-3.0.4 shipped, and + // which would otherwise surface as "EQL DEV" in the banner on every + // reference page. Report a disagreement rather than papering over it: a + // manifest stamped with a DIFFERENT release is a packaging mix-up worth + // seeing, not a cosmetic default. + const manifest = JSON.parse(await fs.readFile(manifestSrc, "utf8")); + const expected = tag.replace(/^eql-/, ""); + if (manifest.version && manifest.version !== "DEV") { + if (manifest.version !== expected) { + console.warn( + `⚠ ${tag} ships a manifest stamped "${manifest.version}", not "${expected}". Using the pinned tag; check the release.`, + ); + } + } + manifest.version = expected; + await fs.writeFile(RELEASE_MANIFEST, JSON.stringify(manifest, null, 2)); + console.log( + `✓ Extracted eql-manifest.json → ${path.basename(RELEASE_MANIFEST)} (version: ${manifest.version})`, + ); + } catch { + await fs.rm(RELEASE_MANIFEST, { force: true }); // clear any stale cache + console.log( + "• No eql-manifest.json in this release; API reference uses the sample.", + ); + } + // Clean up console.log("Cleaning up..."); await fs.rm(extractPath, { recursive: true, force: true }); diff --git a/scripts/generate-prisma-docs.ts b/scripts/generate-prisma-docs.ts new file mode 100644 index 0000000..f9f8618 --- /dev/null +++ b/scripts/generate-prisma-docs.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env tsx +/** Generate the Prisma integration's API reference from its Stack 1.0 tag. */ +import path from "node:path"; +import { type DocsConfig, generateDocs } from "./lib/docs-generator.js"; + +const prismaConfig: DocsConfig = { + packageName: "@cipherstash/stack-prisma", + projectName: "@cipherstash/stack-prisma", + repoUrl: "https://github.com/cipherstash/stack.git", + sourceRef: "@cipherstash/stack-prisma@1.0.0", + tempDirName: ".tmp-stack-prisma", + baseOutputDir: path.join( + process.cwd(), + "content/docs/integrations/prisma/api-reference", + ), + publicPath: "/integrations/prisma/api-reference", + metaTitle: "API reference", + versionedOutput: false, + entryPointBasePath: "packages/stack-prisma/src/exports", + router: "module", + flattenOutputFiles: true, + entryPoints: [ + "./packages/stack-prisma/src/exports/codec-types.ts", + "./packages/stack-prisma/src/exports/column-types.ts", + "./packages/stack-prisma/src/exports/control.ts", + "./packages/stack-prisma/src/exports/operation-types.ts", + "./packages/stack-prisma/src/exports/pack.ts", + "./packages/stack-prisma/src/exports/runtime.ts", + "./packages/stack-prisma/src/exports/stack.ts", + "./packages/stack-prisma/src/exports/v3.ts", + ], + tsconfigInclude: ["packages/stack-prisma/src/**/*"], + tagFilter: () => false, + referencePathSegment: "prisma", + frontmatterGlobals: { + type: "reference", + components: ["encryption", "eql"], + audience: ["developer"], + }, +}; + +generateDocs(prismaConfig).catch((error) => { + console.error("Failed to generate the Prisma API reference:", error); + process.exit(1); +}); diff --git a/scripts/generate-supabase-docs.ts b/scripts/generate-supabase-docs.ts new file mode 100644 index 0000000..175bcd9 --- /dev/null +++ b/scripts/generate-supabase-docs.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env tsx +/** Generate the Supabase integration's API reference from its Stack 1.0 tag. */ +import path from "node:path"; +import { type DocsConfig, generateDocs } from "./lib/docs-generator.js"; + +const supabaseConfig: DocsConfig = { + packageName: "@cipherstash/stack-supabase", + projectName: "@cipherstash/stack-supabase", + repoUrl: "https://github.com/cipherstash/stack.git", + sourceRef: "@cipherstash/stack-supabase@1.0.0", + tempDirName: ".tmp-stack-supabase", + baseOutputDir: path.join( + process.cwd(), + "content/docs/integrations/supabase/api-reference", + ), + publicPath: "/integrations/supabase/api-reference", + metaTitle: "API reference", + versionedOutput: false, + entryPointBasePath: "packages/stack-supabase/src", + entryModule: "index", + router: "module", + flattenOutputFiles: true, + entryPoints: ["./packages/stack-supabase/src/index.ts"], + tsconfigInclude: ["packages/stack-supabase/src/**/*"], + tagFilter: () => false, + referencePathSegment: "supabase", + frontmatterGlobals: { + type: "reference", + components: ["encryption", "eql", "platform"], + audience: ["developer"], + }, +}; + +generateDocs(supabaseConfig).catch((error) => { + console.error("Failed to generate the Supabase API reference:", error); + process.exit(1); +}); diff --git a/scripts/lib/docs-generator.ts b/scripts/lib/docs-generator.ts index d787979..ce45804 100644 --- a/scripts/lib/docs-generator.ts +++ b/scripts/lib/docs-generator.ts @@ -22,6 +22,28 @@ export interface DocsConfig { tagFilter: (tag: string) => boolean; /** Reference URL path segment (e.g., 'stack' or 'drizzle') */ referencePathSegment: string; + /** Generate from a branch or commit instead of selecting a release tag. */ + sourceRef?: string; + /** Public route before the generated version directory. */ + publicPath?: string; + /** Navigation title written to the package-level meta.json. */ + metaTitle?: string; + /** TypeDoc project name. */ + projectName?: string; + /** Additional frontmatter added to every generated page. */ + frontmatterGlobals?: Record; + /** Generate directly into baseOutputDir instead of a latest/version folder. */ + versionedOutput?: boolean; + /** Base path TypeDoc uses to name entry-point modules. */ + entryPointBasePath?: string; + /** TypeDoc Markdown output router. */ + router?: "member" | "module"; + /** Write generated module pages into one directory. */ + flattenOutputFiles?: boolean; + /** Promote one TypeDoc entry module into the generated root page. */ + entryModule?: string; + /** Synthetic source entry points written into the temporary checkout. */ + generatedSources?: Record; } /** @@ -33,6 +55,95 @@ export interface VersionInfo { patch: number; } +/** + * Keep inline code spans on one physical line. + * + * TSDoc comments are commonly wrapped at a fixed width, including in the + * middle of a backtick span. Markdown permits that, but MDX can reinterpret a + * `{ ... }` on the continuation line as an expression inside its surrounding + * list or blockquote container. Fenced code blocks are deliberately excluded. + */ +function collapseMultilineInlineCode(content: string): string { + // TypeDoc's frontmatter plugin can truncate a summary midway through an + // inline-code span. Never pair that unmatched backtick with one in the MDX + // body: frontmatter is YAML and does not need Markdown normalization. + const frontmatter = content.match(/^---\n[\s\S]*?\n---\n?/)?.[0] ?? ""; + const lines = content.slice(frontmatter.length).split("\n"); + const output: string[] = []; + let prose: string[] = []; + let fence: { character: string; length: number } | undefined; + + const collapseProse = () => { + if (prose.length === 0) return; + const block = prose.join("\n"); + let result = ""; + let cursor = 0; + + while (cursor < block.length) { + const start = block.indexOf("`", cursor); + if (start === -1) { + result += block.slice(cursor); + break; + } + + let openingLength = 1; + while (block[start + openingLength] === "`") openingLength += 1; + result += block.slice(cursor, start + openingLength); + cursor = start + openingLength; + + let closing = cursor; + while (closing < block.length) { + closing = block.indexOf("`", closing); + if (closing === -1) break; + let closingLength = 1; + while (block[closing + closingLength] === "`") closingLength += 1; + if (closingLength === openingLength) break; + closing += closingLength; + } + + if (closing === -1) { + result += block.slice(cursor); + cursor = block.length; + break; + } + + const body = block.slice(cursor, closing); + result += body.includes("\n") + ? body.replace(/[ \t]*\n[ \t]*/g, " ") + : body; + result += "`".repeat(openingLength); + cursor = closing + openingLength; + } + + output.push(...result.split("\n")); + prose = []; + }; + + for (const line of lines) { + const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + const marker = match?.[1]; + if (!fence && marker) { + collapseProse(); + output.push(line); + fence = { character: marker[0] ?? "", length: marker.length }; + } else if (fence) { + output.push(line); + if ( + marker?.[0] === fence.character && + marker.length >= fence.length && + match?.[2]?.trim() === "" + ) { + fence = undefined; + } + } else { + prose.push(line); + } + } + collapseProse(); + + return `${frontmatter}${output.join("\n")}`; +} + /** * Cleans up markdown links in generated .mdx files: * 1. Strips .mdx extensions from link targets @@ -57,6 +168,7 @@ export async function stripMdxExtensions(dir: string): Promise { await stripMdxExtensions(fullPath); } else if (entry.isFile() && entry.name.endsWith(".mdx")) { let content = await fs.readFile(fullPath, "utf8"); + content = collapseMultilineInlineCode(content); content = content.replace(/\]\(([^)#]+)\.mdx([#)])/g, "]($1$2"); content = content.replace( /\]\(\/docs\/reference\//g, @@ -66,6 +178,9 @@ export async function stripMdxExtensions(dir: string): Promise { // TypeDoc generates links to index pages (e.g., ../index or /stack/.../index) // but Fumadocs serves index.mdx at the directory URL without /index. content = content.replace(/\]\(([^)#]*)\/index([#)])/g, "]($1$2"); + // A flattened module sits beside the root index and links back to it as + // `(index)`, without a preceding slash for the rule above to match. + content = content.replace(/\]\(index([#)])/g, "](./$1"); // Strip temp directory prefix from source link text (e.g., .tmp-stack/) // Matches: [.tmp-stack/packages/...](url) → [packages/...](url) content = content.replace(/\[\.tmp-[^/]+\//g, "["); @@ -102,7 +217,16 @@ export async function generateMetaJson(dir: string): Promise { } const metaPath = path.join(dir, "meta.json"); - await fs.writeFile(metaPath, JSON.stringify({ pages }, null, 2), "utf8"); + await fs.writeFile(metaPath, serializeMetaJson({ pages }), "utf8"); +} + +/** Keep generated metadata aligned with Biome's compact single-item arrays. */ +function serializeMetaJson(meta: { title?: string; pages: string[] }): string { + const json = JSON.stringify(meta, null, 2).replace( + /"pages": \[\n {4}"([^"]+)"\n {2}\]/, + '"pages": ["$1"]', + ); + return `${json}\n`; } /** @@ -142,9 +266,14 @@ export function getVersionsToGenerate( } } + // Only the latest major. The module layout diverged across majors — Stack 1.0 + // moved the Drizzle/Supabase adapters to their own packages and dropped + // secrets — so a single `entryPoints` config (which must match the layout it + // documents) can only correctly generate one line. The launch documents 1.0; + // older-major reference lives on the previous docs site. const majorVersions = Array.from(versionMap.keys()) .sort((a, b) => b - a) - .slice(0, 3); + .slice(0, 1); return majorVersions.map((major, index) => { const tag = versionMap.get(major); @@ -155,6 +284,55 @@ export function getVersionsToGenerate( }); } +/** + * TypeScript `paths` that resolve a workspace package to its SOURCE. + * + * An adapter package (`stack-supabase`) imports its sibling by package name — + * `@cipherstash/stack`, `@cipherstash/stack/schema`, `.../adapter-kit` — which + * resolves through that package's `exports` map to `./dist/*`. The clone is + * never built, so there is no `dist` and every such import is TS2307. TypeDoc + * then documents the adapter with all its cross-package types missing. + * + * Derive the mapping from the sibling's own `exports` map rather than hardcoding + * it: rewrite each subpath's `./dist/x.js` target to `./packages//src/x.ts`. + * A hand-written list would silently rot the next time Stack adds a subpath, and + * the failure mode is exactly the one this is fixing — a missing type quietly + * becoming `any` in the published reference. + */ +async function workspaceSourcePaths( + workingDir: string, + packageName: string, +): Promise> { + const dir = packageName.split("/").pop(); + const manifest = path.join(workingDir, "packages", dir ?? "", "package.json"); + + let exportsMap: Record; + try { + const pkg = JSON.parse(await fs.readFile(manifest, "utf8")); + exportsMap = pkg.exports ?? {}; + } catch { + // The layout moved. Better to emit nothing and let TS2307 name the missing + // module than to invent paths that point at files which do not exist. + console.warn(` ! no exports map at ${manifest}; skipping source paths`); + return {}; + } + + const paths: Record = {}; + for (const [subpath, target] of Object.entries(exportsMap)) { + if (subpath.endsWith("package.json")) continue; + // Any condition will do — every branch points at the same module, and only + // the path shape matters here. + const dist = JSON.stringify(target).match(/\.\/dist\/[^"]+\.js/)?.[0]; + if (!dist) continue; + + const stem = dist.replace(/^\.\/dist\//, "").replace(/\.js$/, ""); + const specifier = + subpath === "." ? packageName : `${packageName}/${subpath.slice(2)}`; + paths[specifier] = [`./packages/${dir}/src/${stem}.ts`]; + } + return paths; +} + /** * Generate documentation for a specific tag */ @@ -169,18 +347,26 @@ export async function generateDocsForTag( const versionString = version ? `v${version.major}.${version.minor}.${version.patch}` : tag; - const dirName = isLatest ? "latest" : versionString; - const outputDir = path.join(config.baseOutputDir, dirName); + const dirName = + config.versionedOutput === false ? "" : isLatest ? "latest" : versionString; + const outputDir = dirName + ? path.join(config.baseOutputDir, dirName) + : config.baseOutputDir; + const displayDirName = dirName || "unversioned API reference"; console.log(`\n${"=".repeat(60)}`); console.log( - `Generating docs for ${dirName}${isLatest ? ` (${versionString})` : ""}`, + `Generating docs for ${displayDirName}${isLatest ? ` (${versionString})` : ""}`, ); console.log("=".repeat(60)); // Checkout the tag (skip if using local path) if (!localPath) { + // A prior tag's `bun install` rewrites the tracked package.json, which would + // block switching tags. Discard any working-tree changes first so the + // checkout is always clean (harmless on the first/only tag). console.log(`Checking out ${tag}...`); + execSync("git checkout -- .", { cwd: workingDir, stdio: "inherit" }); execSync(`git checkout ${tag}`, { cwd: workingDir, stdio: "inherit" }); console.log("Cleaning node_modules..."); @@ -198,16 +384,40 @@ export async function generateDocsForTag( // Create output directory await fs.mkdir(outputDir, { recursive: true }); + // A package root named `index.ts` collides with TypeDoc's own `index.mdx`. + // Callers can add a synthetic, clearly named re-export module so the root + // package surface is documented without changing the source repository. + for (const [relativePath, source] of Object.entries( + config.generatedSources ?? {}, + )) { + const generatedPath = path.join(workingDir, relativePath); + await fs.mkdir(path.dirname(generatedPath), { recursive: true }); + await fs.writeFile(generatedPath, source, "utf8"); + } + // Create a custom tsconfig for TypeDoc const typedocTsConfig = { extends: "./tsconfig.json", include: config.tsconfigInclude, exclude: ["node_modules", "examples", "dist", "__tests__"], compilerOptions: { + // The stack repo's ROOT tsconfig (which this extends) sets + // `moduleResolution: "bundler"` but no `customConditions`; + // `packages/stack/tsconfig.json` — the config the stack team actually + // builds with — adds `customConditions: ["node"]`. That matters here: + // @cipherstash/protect-ffi's `exports` map routes the `node` condition to + // `lib/index.d.cts` (the real type surface) and everything else to + // `default: ./dist/wasm/protect_ffi.js`, which ships NO `types`. Without + // the condition, TypeScript falls through to the sibling + // `dist/wasm/protect_ffi.d.ts` — raw wasm-bindgen output — and every + // hand-written type resolves to nothing ("has no exported member + // 'ProtectError'. Did you mean 'encryptQuery'?"). + customConditions: ["node"], paths: { "@/*": ["./packages/stack/src/*"], "@cipherstash/schema": ["./packages/schema/src/index.ts"], "@cipherstash/schema/*": ["./packages/schema/src/*"], + ...(await workspaceSourcePaths(workingDir, "@cipherstash/stack")), }, }, }; @@ -225,9 +435,12 @@ export async function generateDocsForTag( // Create TypeDoc configuration const typedocConfig = { + name: config.projectName, entryPoints: config.entryPoints, tsconfig: "./typedoc.tsconfig.json", - basePath: workingDir, + basePath: config.entryPointBasePath + ? path.join(workingDir, config.entryPointBasePath) + : workingDir, plugin: [ "typedoc-plugin-markdown", "typedoc-plugin-frontmatter", @@ -237,6 +450,7 @@ export async function generateDocsForTag( readme: "none", frontmatterGlobals: { packageVersion: versionString, + ...config.frontmatterGlobals, }, frontmatterCommentTags: ["author", "description"], githubPages: false, @@ -314,6 +528,17 @@ export async function generateDocsForTag( sanitizeComments: true, fileExtension: ".mdx", entryFileName: "index", + router: config.router, + flattenOutputFiles: config.flattenOutputFiles, + entryModule: config.entryModule, + // Type errors fail the build, deliberately. This was `true` to tolerate + // "cross-package type references unresolved even though the source is + // correct" — but that diagnosis was wrong, and tolerating it was not free: + // an unresolved import does not just warn, it makes TypeDoc emit `any`. + // The cause was the missing `customConditions` above, and with that fixed + // the whole surface typechecks cleanly. Leaving this off means the next + // resolution break surfaces as a failed build instead of a reference page + // that quietly documents `any`. skipErrorChecking: false, sort: ["source-order"], kindSortOrder: [ @@ -324,7 +549,7 @@ export async function generateDocsForTag( "Variable", "Enum", ], - publicPath: `/stack/reference/${config.referencePathSegment}/${dirName}/`, + publicPath: `${(config.publicPath ?? `/stack/reference/${config.referencePathSegment}`).replace(/\/$/, "")}${dirName ? `/${dirName}` : ""}/`, }; const configPath = path.join(workingDir, "typedoc.json"); @@ -347,7 +572,7 @@ export async function generateDocsForTag( console.log("Generating meta.json files..."); await generateMetaJson(outputDir); - console.log(`Docs for ${dirName} generated successfully!`); + console.log(`Docs for ${displayDirName} generated successfully!`); console.log(`Output directory: ${outputDir}`); return { dirName, versionString, isLatest }; @@ -396,27 +621,34 @@ export async function generateDocs(config: DocsConfig): Promise { }); workingDir = tempDir; - console.log("Fetching all tags..."); - execSync("git fetch --tags", { cwd: workingDir, stdio: "inherit" }); - - allTags = execSync("git tag --sort=-v:refname", { - cwd: workingDir, - encoding: "utf8", - }) - .trim() - .split("\n"); - - if (allTags.length === 0 || allTags[0] === "") { - throw new Error(`No tags found in ${config.packageName} repository`); + if (config.sourceRef) { + allTags = [config.sourceRef]; + console.log(`Using source ref ${config.sourceRef}`); + } else { + console.log("Fetching all tags..."); + execSync("git fetch --tags", { cwd: workingDir, stdio: "inherit" }); + + allTags = execSync("git tag --sort=-v:refname", { + cwd: workingDir, + encoding: "utf8", + }) + .trim() + .split("\n"); + + if (allTags.length === 0 || allTags[0] === "") { + throw new Error(`No tags found in ${config.packageName} repository`); + } + + console.log(`Found ${allTags.length} tags`); } - - console.log(`Found ${allTags.length} tags`); } // Determine which versions to generate const versionsToGenerate = localPath ? [{ tag: "local-dev", isLatest: true }] - : getVersionsToGenerate(allTags, config.tagFilter); + : config.sourceRef + ? [{ tag: config.sourceRef, isLatest: true }] + : getVersionsToGenerate(allTags, config.tagFilter); if (!localPath && versionsToGenerate.length === 0) { throw new Error( @@ -432,12 +664,17 @@ export async function generateDocs(config: DocsConfig): Promise { // Clean existing generated output (preserve hand-authored files) for (const { tag, isLatest } of versionsToGenerate) { const version = parseVersion(tag); - const dirName = isLatest - ? "latest" - : version - ? `v${version.major}.${version.minor}.${version.patch}` - : tag; - const versionDir = path.join(config.baseOutputDir, dirName); + const dirName = + config.versionedOutput === false + ? "" + : isLatest + ? "latest" + : version + ? `v${version.major}.${version.minor}.${version.patch}` + : tag; + const versionDir = dirName + ? path.join(config.baseOutputDir, dirName) + : config.baseOutputDir; await fs.rm(versionDir, { recursive: true, force: true }); } @@ -454,16 +691,20 @@ export async function generateDocs(config: DocsConfig): Promise { generatedVersions.push(versionInfo); } - // Generate a meta.json for the package directory listing versions + // Generate package-level navigation. An unversioned reference already has + // a complete meta.json from generateMetaJson; preserve its page list. const packageMetaPath = path.join(config.baseOutputDir, "meta.json"); - const packageMeta = { - pages: generatedVersions.map(({ dirName }) => dirName), - }; - await fs.writeFile( - packageMetaPath, - JSON.stringify(packageMeta, null, 2), - "utf8", - ); + const packageMeta = + config.versionedOutput === false + ? { + ...(config.metaTitle ? { title: config.metaTitle } : {}), + ...JSON.parse(await fs.readFile(packageMetaPath, "utf8")), + } + : { + ...(config.metaTitle ? { title: config.metaTitle } : {}), + pages: generatedVersions.map(({ dirName }) => dirName), + }; + await fs.writeFile(packageMetaPath, serializeMetaJson(packageMeta), "utf8"); // Clean up temp directory console.log("\nCleaning up..."); diff --git a/scripts/plugins/fumadocs-frontmatter.mjs b/scripts/plugins/fumadocs-frontmatter.mjs index 30dbc46..730c3ab 100644 --- a/scripts/plugins/fumadocs-frontmatter.mjs +++ b/scripts/plugins/fumadocs-frontmatter.mjs @@ -113,6 +113,7 @@ export function load(app) { page.frontmatter = { ...meta, + ...(isRoot ? { navTitle: "Overview" } : {}), // Author-provided frontmatter (e.g. @description comment tags, // frontmatterGlobals) still wins. ...page.frontmatter, diff --git a/scripts/validate-content-api.ts b/scripts/validate-content-api.ts new file mode 100644 index 0000000..4490f1b --- /dev/null +++ b/scripts/validate-content-api.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env tsx +/** + * Content API gate. + * + * Fails the build when documentation teaches one of the specific APIs listed in + * RULES below. Two long-lived content branches plus a fast-moving SDK means a + * page fixed on one branch can be silently reintroduced by a port from the + * other — which is exactly how the deprecated `LockContext.identify()` flow + * survived a fix and came back in a comparison-page port. + * + * ── What this does NOT do ────────────────────────────────────────────────── + * This is a DENYLIST, not verification. It catches only what someone thought to + * add, and cannot see an API that changed MEANING rather than name. Stack's + * `contains()` → `matches()` rename is the worked example: `contains()` still + * exists, so nothing here (or in any symbol-presence diff) fires, yet calling it + * on an encrypted text column now raises. That shipped in the docs. + * + * Catching that class needs the examples themselves typechecked against the + * installed SDK, the way Rust doctests are compiled. Until then, treat a pass + * as "none of the known-bad patterns appeared", which is what it prints. + * + * Run via `bun run validate-content`; wired into prebuild. + * + * A line is exempt if it also says the API is retired ("deprecated", "removed", + * "no longer", "are gone"), so that callouts and migration notes are free to + * name the old API in order to warn readers off it. The check is deliberately + * line-local: naming a dead symbol without saying it is dead is the bug. + * + * Generated output is skipped: it mirrors the SDK's own doc comments, which we + * fix upstream rather than here. + */ +import fs from "node:fs"; +import path from "node:path"; + +/** Generated by scripts/*, or a version reference that documents old syntax on purpose. */ +const SKIP_PATHS = [ + "content/stack/reference/stack/", // TypeDoc output + "content/docs/reference/stack/api-reference/", // TypeDoc output + "content/docs/integrations/supabase/api-reference/", // TypeDoc output + "content/docs/integrations/drizzle/api-reference/", // TypeDoc output + "content/docs/integrations/prisma/api-reference/", // TypeDoc output + "content/stack/reference/eql/index.mdx", // generated EQL API reference + "content/docs/reference/eql/functions.mdx", // generated + "content/docs/reference/cli/", // generated from `stash manifest --json` + "content/docs/reference/eql/v2/", // the retained EQL v2 reference +]; + +/** + * Prose that names a retired API in order to warn readers off it. Kept narrow: + * these are the words we actually use in deprecation callouts and migration + * notes, not a general escape hatch. + */ +const RETIRED_MARKER = + /\b(deprecat\w*|removed|no longer|are gone|was gone|never existed)\b/i; + +type Rule = { + id: string; + pattern: RegExp; + message: string; + fix: string; + /** Only apply the rule to files under these prefixes. */ + scope: string[]; + /** + * Files that legitimately name the API, and are exempt from this rule. Use + * only when a page documents a component that really does still ship it. + */ + exempt?: string[]; +}; + +const IDENTITY_SCOPE = ["content/docs/", "content/stack/"]; +/** The legacy tree documents EQL v2 throughout; only the v2 IA must be v3-only. */ +const V3_SCOPE = ["content/docs/"]; + +const RULES: Rule[] = [ + { + id: "lockcontext-identify", + pattern: /\.identify\s*\(/, + message: + "`LockContext.identify()` is deprecated. Per-operation CTS tokens were removed in protect-ffi 0.25; the token it fetches no longer affects encryption.", + fix: 'Authenticate the client with `OidcFederationStrategy` via `config.authStrategy`, then pass the claim to `.withLockContext({ identityClaim: ["sub"] })`.', + scope: IDENTITY_SCOPE, + }, + { + id: "identity-token-option", + pattern: /identityToken\s*:/, + message: + "`withLockContext({ identityToken })` has never existed in any released version of @cipherstash/stack.", + fix: 'Use `.withLockContext({ identityClaim: ["sub"] })` on the operation, not on the client.', + scope: IDENTITY_SCOPE, + }, + { + id: "bare-lockcontext-constructor", + pattern: /new\s+LockContext\s*\(\s*\)/, + message: + "A bare `new LockContext()` is only useful with the deprecated `identify()` flow.", + fix: 'Pass a plain `{ identityClaim: ["sub"] }` to `.withLockContext()`. The Supabase query builder still requires an instance: `new LockContext({ context: { identityClaim: ["sub"] } })`.', + scope: IDENTITY_SCOPE, + }, + { + id: "eql-v2-encrypted-type", + pattern: /\beql_v2_encrypted\b/, + message: "The `eql_v2_encrypted` column type was removed in EQL 3.0.0.", + fix: "Type the column with an EQL v3 domain variant, e.g. `public.eql_v3_text_eq`. See /reference/eql/core-concepts.", + scope: V3_SCOPE, + }, + { + id: "eql-v2-schema-functions", + pattern: /\beql_v2\.\w+\s*\(/, + message: "The `eql_v2` schema was removed in EQL 3.0.0.", + fix: "Use the `eql_v3` equivalents, or the encrypted operators directly with a typed operand.", + scope: V3_SCOPE, + }, + { + id: "eql-v2-config-table", + pattern: + /\b(cs_add_column_v1|cs_match_v1|cs_ste_vec_v2|cs_check_encrypted_v1|add_search_config|config_add_column|config_add_table)\b/, + message: + "EQL v3 has no database-side configuration table; these functions are gone.", + fix: "A column's searchable capability is fixed by the domain variant it is typed as. See /reference/eql/core-concepts.", + scope: V3_SCOPE, + }, + { + id: "protect-branding", + pattern: /\b(ProtectClientConfig|@cipherstash\/cli)\b/, + message: + "Stale naming from before the `protect` -> `stack` rename, or the `@cipherstash/cli` -> `stash` rename.", + fix: "Use `EncryptionClientConfig` and `stash`.", + scope: V3_SCOPE, + }, +]; + +type Finding = { + file: string; + line: number; + rule: Rule; + text: string; +}; + +function collectFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const files: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...collectFiles(full)); + else if (/\.mdx?$/.test(entry.name)) files.push(full); + } + return files; +} + +function isSkipped(relative: string): boolean { + return SKIP_PATHS.some((prefix) => relative.startsWith(prefix)); +} + +function inScope(relative: string, rule: Rule): boolean { + return rule.scope.some((prefix) => relative.startsWith(prefix)); +} + +const root = process.cwd(); +const findings: Finding[] = []; +let filesScanned = 0; +let filesSkipped = 0; + +for (const dir of ["content/docs", "content/stack"]) { + for (const file of collectFiles(path.join(root, dir))) { + const relative = path.relative(root, file).split(path.sep).join("/"); + if (isSkipped(relative)) { + filesSkipped++; + continue; + } + filesScanned++; + + const lines = fs.readFileSync(file, "utf8").split("\n"); + lines.forEach((text, i) => { + // A line that names a dead API *and says it is dead* is doing its job. + if (RETIRED_MARKER.test(text)) return; + + for (const rule of RULES) { + if (!inScope(relative, rule)) continue; + if (rule.exempt?.includes(relative)) continue; + if (rule.pattern.test(text)) { + findings.push({ + file: relative, + line: i + 1, + rule, + text: text.trim(), + }); + } + } + }); + } +} + +if (findings.length > 0) { + const byRule = new Map(); + for (const f of findings) { + const list = byRule.get(f.rule.id) ?? []; + list.push(f); + byRule.set(f.rule.id, list); + } + + console.error( + `✗ ${findings.length} occurrence(s) of deprecated or non-existent API in documentation:\n`, + ); + for (const [id, group] of byRule) { + const { message, fix } = group[0].rule; + console.error(` [${id}] ${message}`); + console.error(` Fix: ${fix}\n`); + for (const f of group) { + console.error(` ${f.file}:${f.line}`); + console.error(` ${f.text.slice(0, 100)}`); + } + console.error(""); + } + console.error( + 'A line that also says the API is retired ("deprecated", "removed", "no longer",\n' + + '"are gone", "never existed") is exempt, so deprecation callouts still pass.', + ); + process.exit(1); +} + +// Say what was actually checked. This gate is a DENYLIST: it can only catch the +// APIs listed in RULES, so an unqualified "no non-existent API found" overstates +// it — and did, while the Supabase wrapper reference taught +// `.contains()` for encrypted free-text, which raises. Reporting the rule count +// makes the coverage legible at a glance, so the next reader can tell the +// difference between "verified" and "matched nothing on a short list". +console.log( + `✓ content API gate: no occurrences of ${RULES.length} known-retired API pattern(s) ` + + `in ${filesScanned} file(s) (${filesSkipped} generated/pinned file(s) skipped).`, +); +console.log( + " Denylist only: it does not verify that the APIs the docs DO name still exist.", +); diff --git a/scripts/validate-links.ts b/scripts/validate-links.ts index 6e38477..b4c34e9 100644 --- a/scripts/validate-links.ts +++ b/scripts/validate-links.ts @@ -1,210 +1,393 @@ +/** + * Internal-link validation across every MDX page the site serves. + * + * Both content collections are covered: `content/docs` (the v2 IA, served from + * the site root) and `content/stack` (the legacy tree, served under /stack). + * This script used to know only about content/stack and only about markdown + * link syntax, which left the entire v2 tree — and every `` in + * both trees — unchecked. A page could be renamed or merged away and every + * link to it would 404 against a green build. + * + * For markdown links (`[text](/url)`) and JSX href attributes (`href="/url"`) + * alike, it checks that: + * + * - the target page exists, in either collection; + * - a `#fragment` matches a heading on that page; + * - the link carries no `/docs` prefix (Next's basePath prepends it), no + * `/index` suffix, and no `.mdx` extension — each of which 404s. + * + * Non-page targets are resolved rather than waved through: static files under + * `public/`, and static route handlers under `src/app` (`/llms.txt` and + * friends). Anything genuinely unresolvable is an error, so the exceptions + * stay honest. + * + * Anchors are slugged with the same `github-slugger` Fumadocs uses, one + * slugger per page so repeated headings get the `-1`/`-2` suffixes the + * renderer produces. Hand-rolled slugification gets this wrong in ways that + * matter: "Range & order" is `range--order`, not `range-order`. + * + * Every line carries its true origin (see `SourceLine`), so a broken link + * inside an ``d partial is reported against the partial's own path + * and line rather than an offset position in whichever page pulled it in. + */ import fs from "node:fs"; import path from "node:path"; +import GithubSlugger from "github-slugger"; + +const ROOT = process.cwd(); + +/** + * Content collections and the URL prefix each is served under. Mirrors the + * `loader({ baseUrl })` calls in src/lib/source.ts — keep them in step. + */ +const COLLECTIONS = [ + { dir: "content/docs", baseUrl: "" }, + { dir: "content/stack", baseUrl: "/stack" }, +]; -const CONTENT_DIR = path.join(process.cwd(), "content/stack"); +/** Shared MDX fragments, pulled into pages by ``. Not routable. */ +const PARTIALS_DIR = "content/partials"; /** - * Recursively collect all .mdx files in a directory. + * A line of MDX plus where it actually came from. Inlining a partial would + * otherwise renumber every line below the `` directive, silently + * shifting reported positions by the length of the partial. */ +interface SourceLine { + text: string; + /** Repo-relative path of the file this line is really in. */ + file: string; + /** 1-indexed line number within that file. */ + line: number; +} + +interface Page { + /** Repo-relative path of the .mdx file, for error messages. */ + file: string; + /** Heading ids usable as #fragments on this page. */ + anchors: Set; +} + +interface BrokenLink { + file: string; + line: number; + url: string; + reason: string; +} + function collectMdxFiles(dir: string): string[] { const results: string[] = []; + if (!fs.existsSync(dir)) return results; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...collectMdxFiles(fullPath)); - } else if (entry.isFile() && entry.name.endsWith(".mdx")) { + if (entry.isDirectory()) results.push(...collectMdxFiles(fullPath)); + else if (entry.isFile() && entry.name.endsWith(".mdx")) results.push(fullPath); - } } return results; } /** - * Convert a file path to its corresponding URL. - * content/stack/cipherstash/encryption/getting-started.mdx → /stack/cipherstash/encryption/getting-started - * content/stack/reference/stack/latest/schema/index.mdx → /stack/reference/stack/latest/schema + * Read a file into `SourceLine`s with fenced code blocks blanked out. Blanking + * rather than dropping keeps every line's index aligned with the file on disk. + * Headings and links inside a code sample are illustrative, not real. */ -function filePathToUrl(filePath: string): string { - let rel = path.relative(CONTENT_DIR, filePath); - // Remove .mdx extension - rel = rel.replace(/\.mdx$/, ""); - // Remove trailing /index - rel = rel.replace(/\/index$/, ""); - return `/stack/${rel}`; +function readLines(file: string): SourceLine[] { + const rel = path.relative(ROOT, file); + let fence: string | null = null; + return fs + .readFileSync(file, "utf8") + .split("\n") + .map((text, i) => { + const open = /^\s*(`{3,}|~{3,})/.exec(text); + let out = text; + if (fence) { + if (open && open[1][0] === fence[0] && open[1].length >= fence.length) + fence = null; + out = ""; + } else if (open) { + fence = open[1]; + out = ""; + } + return { text: out, file: rel, line: i + 1 }; + }); } /** - * Check if a URL has a /index suffix that would 404 at runtime. - * Fumadocs serves index.mdx at the directory URL, so /path/index is not a valid route. + * Fumadocs' `path` inlines a partial at build time, so + * the partial's headings become anchors on the including page and its links + * are rendered by it. One level deep — partials don't nest today. + * + * The inlined lines keep the partial's own file and line numbers, so positions + * below the directive stay correct in the including page. */ -function hasIndexSuffix(url: string): boolean { - return /\/index$/.test(url); +function inlineIncludes(lines: SourceLine[]): SourceLine[] { + const out: SourceLine[] = []; + for (const line of lines) { + out.push(line); + const match = /([^<]+)<\/include>/.exec(line.text); + if (!match) continue; + const partial = path.join(ROOT, match[1].trim()); + if (fs.existsSync(partial)) out.push(...readLines(partial)); + } + return out; } -/** - * Check if a URL has a .mdx extension that should have been stripped. - */ -function hasMdxExtension(url: string): boolean { - return /\.mdx$/.test(url); +/** Reduce inline markdown to the plain text the slugger would see. */ +function headingText(raw: string): string { + return raw + .replace(/\[#[^\]]+\]\s*$/, "") // explicit anchor suffix + .replace(/`([^`]*)`/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\*\*([^*]*)\*\*/g, "$1") + .replace(/\*([^*]*)\*/g, "$1") + .replace(/<[^>]+>/g, "") + .trim(); } -/** - * Build set of all valid internal URLs from MDX files. - */ -function buildValidUrls(mdxFiles: string[]): Set { - const urls = new Set(); - for (const file of mdxFiles) { - urls.add(filePathToUrl(file)); +function collectAnchors(lines: SourceLine[]): Set { + const slugger = new GithubSlugger(); + const anchors = new Set(); + for (const { text } of lines) { + const match = /^#{1,6}\s+(.+?)\s*$/.exec(text); + if (!match) continue; + const explicit = /\[#([^\]]+)\]\s*$/.exec(match[1]); + if (explicit) { + anchors.add(explicit[1]); + continue; + } + anchors.add(slugger.slug(headingText(match[1]))); } - return urls; + return anchors; } -interface BrokenLink { - file: string; - line: number; - url: string; - reason: string; +/** content/docs/a/b.mdx → /a/b ; content/stack/a/index.mdx → /stack/a */ +function fileToUrl(file: string, dir: string, baseUrl: string): string { + const rel = path + .relative(path.join(ROOT, dir), file) + .replace(/\.mdx$/, "") + .replace(/(^|\/)index$/, ""); + return `${baseUrl}/${rel}`.replace(/\/+$/, "") || "/"; } +/** True when `child` is `parent` itself or sits underneath it. */ +function isInside(child: string, parent: string): boolean { + return child === parent || child.startsWith(parent + path.sep); +} + +type Resolution = + | { url: string } + | { error: string } + // Not a link we can or should resolve (external, anchor-only, expression). + | null; + /** - * Resolve a relative link to an absolute URL path using the file's actual location. - * Does NOT strip /index or .mdx — the caller should detect and report those as errors. + * Resolve a relative link against the collection directory of the file it + * appears in. Relative links that escape the collection, or that live in a + * shared partial where there is no single base to resolve against, are + * reported rather than skipped — a silent skip is how a typo gets through. */ -function resolveRelativeLink(link: string, currentFilePath: string): string { - const currentDir = path.dirname(currentFilePath); - const resolvedPath = path.join(currentDir, link); - const rel = path.relative(CONTENT_DIR, resolvedPath); - return `/stack/${rel}`; +function resolveRelative(file: string, link: string): Resolution { + const abs = path.join(ROOT, file); + if (isInside(abs, path.join(ROOT, PARTIALS_DIR))) { + return { + error: + "Relative link inside a shared partial — it would resolve differently " + + "per including page. Use an absolute path.", + }; + } + for (const { dir, baseUrl } of COLLECTIONS) { + const base = path.join(ROOT, dir); + if (!isInside(abs, base)) continue; + const resolved = path.resolve(path.dirname(abs), link); + if (!isInside(resolved, base)) + return { error: `Relative link resolves outside ${dir}.` }; + return { + url: `${baseUrl}/${path.relative(base, resolved)}`.replace(/\/+$/, ""), + }; + } + return { error: "Relative link in a file outside every content collection." }; +} + +// --- Build the set of things a link may legitimately point at --------------- + +const pages = new Map(); +/** Per-page line arrays, so each file is read and de-fenced exactly once. */ +const fileLines = new Map(); + +for (const { dir, baseUrl } of COLLECTIONS) { + for (const file of collectMdxFiles(path.join(ROOT, dir))) { + const lines = inlineIncludes(readLines(file)); + fileLines.set(file, lines); + pages.set(fileToUrl(file, dir, baseUrl), { + file: path.relative(ROOT, file), + anchors: collectAnchors(lines), + }); + } } +/** Static files under public/ are served at the site root. */ +const assets = new Set(); +const walkAssets = (dir: string, prefix = "") => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) + walkAssets(path.join(dir, entry.name), `${prefix}/${entry.name}`); + else assets.add(`${prefix}/${entry.name}`); + } +}; +walkAssets(path.join(ROOT, "public")); + /** - * Scan an MDX file for broken internal links. + * Static route handlers (src/app/llms.txt/route.ts → /llms.txt). Dynamic and + * group segments are skipped: their paths can't be enumerated statically, and + * no documentation link targets one. */ -function scanFile(filePath: string, validUrls: Set): BrokenLink[] { - const broken: BrokenLink[] = []; - const content = fs.readFileSync(filePath, "utf8"); - const lines = content.split("\n"); +const routes = new Set(); +const walkRoutes = (dir: string, prefix = "") => { + if (!fs.existsSync(dir)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name.startsWith("[") || entry.name.startsWith("(")) continue; + walkRoutes(path.join(dir, entry.name), `${prefix}/${entry.name}`); + } else if (/^route\.tsx?$/.test(entry.name) && prefix) { + routes.add(prefix); + } + } +}; +walkRoutes(path.join(ROOT, "src/app")); - // Match markdown links: [text](url) - const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; +// --- Scan ------------------------------------------------------------------ - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; +const LINK_PATTERNS = [ + /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, // [text](/url "title") + /href="([^"]+)"/g, // +]; - for (const match of line.matchAll(linkRegex)) { - const url = match[2]; +function scanLines(lines: SourceLine[]): BrokenLink[] { + const broken: BrokenLink[] = []; - // Skip external links - if (url.startsWith("http://") || url.startsWith("https://")) continue; + for (const { text, file, line } of lines) { + const report = (url: string, reason: string) => + broken.push({ file, line, url, reason }); - // Skip anchor-only links - if (url.startsWith("#")) continue; + for (const pattern of LINK_PATTERNS) { + for (const match of text.matchAll(pattern)) { + const url = match[1]; + // External, protocol-relative, anchor-only, or a JSX expression. + if (/^(https?:|mailto:|tel:|\/\/|#|\{)/.test(url)) continue; - // Skip mailto links - if (url.startsWith("mailto:")) continue; + const [targetRaw, fragment] = url.split("#"); + if (!targetRaw) continue; - // Strip anchor from URL for validation - const urlWithoutAnchor = url.split("#")[0]; + let target: string; + if (targetRaw.startsWith("/")) { + target = targetRaw; + } else { + const resolved = resolveRelative(file, targetRaw); + if (resolved === null) continue; + if ("error" in resolved) { + report(url, resolved.error); + continue; + } + target = resolved.url; + } - // Check for wrong /docs/ prefix - if (urlWithoutAnchor.startsWith("/docs/")) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: - "Uses /docs/ prefix — Next.js basePath prepends this automatically. Use /stack/ instead.", - }); - continue; - } + // Normalise a trailing slash before the shape checks, so `/a/index/` + // and `/a.mdx/` get their precise diagnosis rather than falling + // through to a generic "no such page". + const normalized = target.replace(/\/+$/, "") || "/"; - // Internal absolute links (start with /stack/) - if (urlWithoutAnchor.startsWith("/stack/")) { - if (hasIndexSuffix(urlWithoutAnchor)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, + if (normalized === "/docs" || normalized.startsWith("/docs/")) { + report( url, - reason: - "Link ends with /index which will 404. Remove /index — Fumadocs serves index.mdx at the directory URL.", - }); - } else if (hasMdxExtension(urlWithoutAnchor)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: - "Link has .mdx extension which will 404. Remove the .mdx extension.", - }); - } else if (!validUrls.has(urlWithoutAnchor)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, + "Uses a /docs prefix — Next's basePath prepends it automatically, so this resolves to /docs/docs/… and 404s.", + ); + continue; + } + if (/\/index$/.test(normalized)) { + report( url, - reason: "Page not found", - }); + "Ends with /index, which 404s — Fumadocs serves index.mdx at the directory URL.", + ); + continue; + } + if (/\.mdx$/.test(normalized)) { + report(url, "Has a .mdx extension, which 404s as a page link."); + continue; } - continue; - } - - // Skip other absolute links (e.g., /api/, external paths) - if (urlWithoutAnchor.startsWith("/")) continue; - // Relative links — resolve against current file's directory - const resolved = resolveRelativeLink(urlWithoutAnchor, filePath); - if (resolved.startsWith("/stack/")) { - if (hasIndexSuffix(resolved)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: `Link resolves to ${resolved} which ends with /index and will 404. Remove /index from the link target.`, - }); - } else if (hasMdxExtension(resolved)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, - url, - reason: `Link resolves to ${resolved} which has .mdx extension and will 404. Remove the .mdx extension.`, - }); - } else if (!validUrls.has(resolved)) { - broken.push({ - file: path.relative(process.cwd(), filePath), - line: i + 1, + const page = pages.get(normalized); + if (!page) { + if (assets.has(normalized) || routes.has(normalized)) continue; + report(url, "No such page, static asset, or route."); + continue; + } + if (fragment && !page.anchors.has(fragment)) { + report( url, - reason: `Page not found (resolved to ${resolved})`, - }); + `No heading on ${normalized} (${page.file}) produces the anchor #${fragment}.`, + ); } } } } - return broken; } -// Main -const mdxFiles = collectMdxFiles(CONTENT_DIR); -console.log(`Found ${mdxFiles.length} MDX files in content/stack/`); +/** + * A partial included by several pages is scanned once per including page, so + * de-duplicate before reporting — otherwise one typo in a shared fragment + * shows up three times. + */ +const seen = new Set(); +const allBroken: BrokenLink[] = []; +for (const lines of fileLines.values()) { + for (const b of scanLines(lines)) { + const key = `${b.file}:${b.line}:${b.url}`; + if (seen.has(key)) continue; + seen.add(key); + allBroken.push(b); + } +} +allBroken.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); -const validUrls = buildValidUrls(mdxFiles); -console.log(`Built ${validUrls.size} valid URLs\n`); +/** + * TypeDoc output is gitignored and produced by `generate-docs`, which + * `prebuild` runs before this script. Run standalone on a fresh checkout, + * every link into those pages looks broken — say so rather than let a hundred + * spurious errors imply the docs are falling apart. + */ +const GENERATED = [ + "content/docs/reference/stack/api-reference/index.mdx", + "content/docs/integrations/supabase/api-reference/index.mdx", + "content/docs/integrations/drizzle/api-reference/index.mdx", + "content/docs/integrations/prisma/api-reference/index.mdx", +]; +const missing = GENERATED.filter((d) => !fs.existsSync(path.join(ROOT, d))); -const allBroken: BrokenLink[] = []; +console.log( + `Checked ${fileLines.size} MDX file(s) across ${COLLECTIONS.map((c) => c.dir).join(" + ")} ` + + `→ ${pages.size} page(s), ${assets.size} static asset(s), ${routes.size} route(s).`, +); -for (const file of mdxFiles) { - const broken = scanFile(file, validUrls); - allBroken.push(...broken); +if (missing.length > 0) { + console.log( + `\n! Generated API pages are absent (${missing.join(", ")}), so links into\n` + + " them will be reported as missing. Run the matching `generate-docs:*`\n" + + " task first; `prebuild` and the links workflow do this automatically.", + ); } if (allBroken.length === 0) { - console.log("No broken links found!"); + console.log("✓ every internal link and anchor resolves."); process.exit(0); -} else { - console.log(`Found ${allBroken.length} broken link(s):\n`); - for (const { file, line, url, reason } of allBroken) { - console.log(` ${file}:${line}`); - console.log(` Link: ${url}`); - console.log(` ${reason}\n`); - } - process.exit(1); } + +console.log(`\n✗ ${allBroken.length} broken link(s):\n`); +for (const { file, line, url, reason } of allBroken) { + console.log(` ${file}:${line}`); + console.log(` Link: ${url}`); + console.log(` ${reason}\n`); +} +process.exit(1); diff --git a/scripts/validate-mermaid.ts b/scripts/validate-mermaid.ts new file mode 100644 index 0000000..e9de692 --- /dev/null +++ b/scripts/validate-mermaid.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env tsx +/** + * Mermaid diagram gate. + * + * A malformed diagram does not fail the build: Mermaid parses in the browser, + * and `src/components/mermaid.tsx` catches the error so a bad chart can't blank + * the page. The failure mode is therefore silent, and a diagram that renders as + * nothing looks identical to a diagram nobody noticed was missing. + * + * So parse every ```mermaid fence at build time instead. Mermaid needs a DOM to + * initialize, which jsdom provides. + * + * Run via `bun run validate-mermaid`; wired into prebuild. + */ +import fs from "node:fs"; +import path from "node:path"; +import { JSDOM } from "jsdom"; + +const CONTENT_DIRS = ["content/docs", "content/stack"]; + +function collectFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const files: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) files.push(...collectFiles(full)); + else if (/\.mdx?$/.test(entry.name)) files.push(full); + } + return files; +} + +type Block = { file: string; line: number; chart: string }; + +function collectBlocks(file: string): Block[] { + const source = fs.readFileSync(file, "utf8"); + const blocks: Block[] = []; + const pattern = /^```mermaid[^\n]*\n([\s\S]*?)^```/gm; + + for (const match of source.matchAll(pattern)) { + const line = source.slice(0, match.index).split("\n").length; + blocks.push({ file, line, chart: match[1] }); + } + return blocks; +} + +async function main() { + const root = process.cwd(); + const blocks = CONTENT_DIRS.flatMap((dir) => + collectFiles(path.join(root, dir)).flatMap(collectBlocks), + ); + + if (blocks.length === 0) { + console.log("✓ no mermaid diagrams to validate"); + return; + } + + // Mermaid reaches for browser globals at import time and during parse. + // `navigator` is a getter-only property on Node's globalThis, so assign via + // defineProperty rather than `=`. + const dom = new JSDOM(""); + for (const name of ["window", "document", "navigator"] as const) { + Object.defineProperty(globalThis, name, { + value: name === "window" ? dom.window : dom.window[name], + configurable: true, + writable: true, + }); + } + + const mermaid = (await import("mermaid")).default; + mermaid.initialize({ startOnLoad: false }); + + const failures: { block: Block; message: string }[] = []; + + for (const block of blocks) { + try { + await mermaid.parse(block.chart); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push({ block, message: message.split("\n")[0] }); + } + } + + if (failures.length > 0) { + console.error(`✗ ${failures.length} invalid mermaid diagram(s):\n`); + for (const { block, message } of failures) { + const relative = path.relative(root, block.file); + console.error(` ${relative}:${block.line}`); + console.error(` ${message}\n`); + } + process.exit(1); + } + + console.log(`✓ all ${blocks.length} mermaid diagram(s) parse`); +} + +main().catch((error) => { + console.error("✗ mermaid validation failed to run:", error); + process.exit(1); +}); diff --git a/scripts/validate-v2-redirects.ts b/scripts/validate-v2-redirects.ts new file mode 100644 index 0000000..da28ad1 --- /dev/null +++ b/scripts/validate-v2-redirects.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env tsx +/** + * V2 redirect gate (CIP-3325 / CIP-3337 item 7). + * + * Every page in the legacy tree (content/stack) must be covered by an entry + * in v2-redirects.mjs — exact match or `:path*` wildcard — and every resolved + * destination must be a real v2 page. Run via `bun run validate-redirects`; + * wired into prebuild so an orphaned source or a redirect-to-404 fails CI. + */ +import fs from "node:fs"; +import path from "node:path"; +// eslint-disable-next-line -- .mjs import is intentional; the map is shared with next.config.mjs +import { v2Redirects } from "../v2-redirects.mjs"; + +const LEGACY_DIR = path.join(process.cwd(), "content/stack"); +const V2_DIR = path.join(process.cwd(), "content/docs"); + +// These indexes are generated during prebuild and intentionally ignored by +// git. Their tracked meta files establish the output roots so the redirect +// validator can also run before generation in a clean checkout. +const GENERATED_PAGE_ROOTS = new Set([ + "/integrations/supabase/api-reference", + "/reference/stack/api-reference", +]); + +function collectSlugs(dir: string, prefix: string[] = []): string[] { + const slugs: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + slugs.push( + ...collectSlugs(path.join(dir, entry.name), [...prefix, entry.name]), + ); + } else if (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) { + const base = entry.name.replace(/\.mdx?$/, ""); + const parts = base === "index" ? prefix : [...prefix, base]; + slugs.push(`/stack${parts.length ? `/${parts.join("/")}` : ""}`); + } + } + return slugs; +} + +function matches(url: string, source: string): boolean { + if (source.endsWith("/:path*")) { + const base = source.slice(0, -"/:path*".length); + return url === base || url.startsWith(`${base}/`); + } + return url === source; +} + +function resolveDestination( + url: string, + redirect: { source: string; destination: string }, +): string { + if (!redirect.source.endsWith("/:path*")) return redirect.destination; + + const base = redirect.source.slice(0, -"/:path*".length); + const suffix = url === base ? "" : url.slice(base.length + 1); + return redirect.destination.replace(":path*", suffix); +} + +function destinationExists(destination: string): boolean { + const route = destination.split(/[?#]/, 1)[0]; + if (route === "/") return fs.existsSync(path.join(V2_DIR, "index.mdx")); + + const relative = route.replace(/^\//, ""); + const page = path.join(V2_DIR, `${relative}.mdx`); + const index = path.join(V2_DIR, relative, "index.mdx"); + if (fs.existsSync(page) || fs.existsSync(index)) return true; + + return ( + GENERATED_PAGE_ROOTS.has(route) && + fs.existsSync(path.join(V2_DIR, relative, "meta.json")) + ); +} + +const urls = collectSlugs(LEGACY_DIR); +const unmatched = urls.filter( + (url) => !v2Redirects.some((r: { source: string }) => matches(url, r.source)), +); + +if (unmatched.length > 0) { + console.error( + `✗ ${unmatched.length} legacy page(s) have no v2 redirect mapping:\n`, + ); + for (const url of unmatched.sort()) { + console.error(` ${url}`); + } + console.error("\nAdd entries to v2-redirects.mjs (see IA.md migration map)."); + process.exit(1); +} + +const broken = urls.flatMap((url) => { + const redirect = v2Redirects.find((candidate: { source: string }) => + matches(url, candidate.source), + ); + if (!redirect) return []; + + const destination = resolveDestination(url, redirect); + return destinationExists(destination) ? [] : [{ url, destination }]; +}); + +if (broken.length > 0) { + console.error( + `✗ ${broken.length} legacy redirect(s) resolve to a missing v2 page:\n`, + ); + for (const { url, destination } of broken) { + console.error(` ${url} → ${destination}`); + } + console.error( + "\nCreate the destination or map the legacy URL to an existing canonical page.", + ); + process.exit(1); +} + +console.log( + `✓ all ${urls.length} legacy pages map to existing v2 destinations`, +); diff --git a/source.config.ts b/source.config.ts index 7769cf3..38e98e6 100644 --- a/source.config.ts +++ b/source.config.ts @@ -3,6 +3,7 @@ import { rehypeCodeDefaultOptions } from "fumadocs-core/mdx-plugins"; import { metaSchema, pageSchema } from "fumadocs-core/source/schema"; import { defineConfig, defineDocs } from "fumadocs-mdx/config"; import { z } from "zod"; +import { cipherstashDark, cipherstashLight } from "./src/lib/shiki-themes"; // You can customise Zod schemas for frontmatter and `meta.json` here // see https://fumadocs.dev/docs/mdx/collections @@ -23,6 +24,60 @@ export const docs = defineDocs({ }, }); +// V2 information architecture (CIP-3325). New content lives in content/docs +// and is served from the site root (e.g. /docs/get-started/...). The legacy +// `docs` collection above (content/stack) is served alongside it during the +// migration and is deleted once the last section moves. See IA.md. +export const v2docs = defineDocs({ + dir: "content/docs", + docs: { + schema: pageSchema.extend({ + seoTitle: z.string().optional(), + // Sidebar label, when it should differ from the page's `title` (which is + // also the H1). Mainly for section index pages: the folder already names + // the section, so repeating it on the index item is noise. Applied to the + // page tree in `src/lib/source.ts`; never affects the URL or the H1. + navTitle: z.string().optional(), + // Diátaxis page type. Every page should declare one; enforced by the + // docs lint (CIP-3337) rather than the schema so stubs can land first. + type: z.enum(["tutorial", "guide", "concept", "reference"]).optional(), + // Facets powering index pages, filtered views, and the future + // tailored-quickstart picker (CIP-3339). Nav position never depends on + // these — the sidebar tree comes from meta.json alone. + components: z + .array(z.enum(["encryption", "platform", "eql", "proxy", "cli"])) + .optional(), + audience: z.array(z.enum(["developer", "cto", "ciso"])).optional(), + integration: z + .object({ + category: z.enum([ + "platform", + "orm", + "framework", + "auth-provider", + "language", + "runtime", + ]), + setup: z.enum(["code-only", "dashboard-required"]), + pairsWith: z.array(z.string()).optional(), + }) + .optional(), + // Review tracking (CIP-3337): API pages pin the releases they were + // verified against (e.g. { stack: "1.2.0", eql: "3.0.0" }); claims pages + // (compliance, pricing, comparisons) carry a review-by date instead. + verifiedAgainst: z.record(z.string(), z.string()).optional(), + reviewBy: z.string().optional(), + }), + postprocess: { + includeProcessedMarkdown: true, + }, + }, + meta: { + schema: metaSchema, + }, +}); + +// ── Syntax highlighting: the CipherStash code theme ────────────────────────── // Parse the leftover code-fence meta string (what remains after Fumadocs // extracts `title`, `tab`, and line-number directives) for the analytics // attributes documented for authors: `example-id`, `cta`, and `cta-type`. @@ -49,6 +104,14 @@ const codeCopyTrackingTransformer: ShikiTransformer = { pre(node) { node.properties["data-language"] = this.options.lang ?? "plaintext"; + // A ```mermaid fence stays a code fence in the mdast, so the processed + // markdown we serve at `.mdx` and in llms.txt keeps the diagram as + // readable source. Carry the raw source through to the client, where + // `TrackedCodeBlock` swaps the highlighted block for a rendered diagram. + if (this.options.lang === "mermaid") { + node.properties["data-mermaid"] = this.source; + } + const raw = typeof this.options.meta?.__raw === "string" ? this.options.meta.__raw @@ -91,11 +154,13 @@ const codeCopyTrackingTransformer: ShikiTransformer = { export default defineConfig({ mdxOptions: { rehypeCodeOptions: { - // Preserve Fumadocs' default Shiki config (themes, parseMetaString) and - // its default transformers (notation highlight, diff, focus, word - // highlight) — passing `transformers` alone would replace them entirely — - // then append our copy-tracking transformer. + // Preserve Fumadocs' default Shiki config (dual-theme mode, + // parseMetaString) and its default transformers (notation highlight, diff, + // focus, word highlight) — passing `transformers` alone would replace them + // entirely — then append our copy-tracking transformer. ...rehypeCodeDefaultOptions, + // Swap GitHub's themes for the CipherStash palette (defined above). + themes: { light: cipherstashLight, dark: cipherstashDark }, transformers: [ ...(rehypeCodeDefaultOptions.transformers ?? []), codeCopyTrackingTransformer, diff --git a/src/app/(home)/layout.tsx b/src/app/(home)/layout.tsx deleted file mode 100644 index c16b056..0000000 --- a/src/app/(home)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { HomeLayout } from "fumadocs-ui/layouts/home"; -import { baseOptions } from "@/lib/layout.shared"; - -export default function Layout({ children }: LayoutProps<"/">) { - return {children}; -} diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx deleted file mode 100644 index 5311eab..0000000 --- a/src/app/(home)/page.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { - ArrowRight, - BookOpen, - Code, - Database, - ExternalLinkIcon, - FileText, - KeyRound, - Lock, - Search, - ShieldCheck, - Zap, -} from "lucide-react"; -import Link from "next/link"; -import type { ComponentType } from "react"; -import { - DrizzleLogo, - DynamoDBLogo, - PrismaLogo, - SupabaseLogo, -} from "@/components/integration-logos"; -import type { Metadata } from "next"; - -// The /docs landing page had no metadata (no ). `absolute` bypasses the -// root layout's "%s | CipherStash Docs" template so the title isn't doubled. -export const metadata: Metadata = { - title: { - absolute: "CipherStash Docs — Searchable encryption for Postgres", - }, - description: - "Data Level Access Control for Postgres. Searchable field-level encryption, identity-bound keys, and cryptographic audit trails.", - alternates: { canonical: "https://cipherstash.com/docs" }, -}; - -const monoClass = "font-[family-name:var(--font-fira-code)] tracking-[-0.02em]"; -const eyebrowClass = - "font-[family-name:var(--font-fira-code)] text-[10px] font-medium tracking-[0.16em] uppercase text-fd-primary"; - -const products = [ - { - title: "Encryption", - description: - "Searchable field-level encryption. Range queries, exact match, and free-text search over ciphertext. Sub-millisecond overhead.", - href: "/stack/cipherstash/encryption", - icon: Lock, - }, - { - title: "ZeroKMS", - description: - "The key management layer. Unique key per value, derived on demand, never stored. 100x faster than AWS KMS.", - href: "/stack/cipherstash/kms", - icon: KeyRound, - }, - { - title: "Proxy", - description: - "Transparent searchable encryption for existing PostgreSQL databases. Zero application code changes.", - href: "/stack/cipherstash/proxy", - icon: Database, - }, -]; - -const integrations: { - title: string; - description: string; - href: string; - logo: ComponentType<{ className?: string }>; -}[] = [ - { - title: "Supabase", - description: "Field-level encryption for your Supabase project.", - href: "/stack/cipherstash/supabase", - logo: SupabaseLogo, - }, - { - title: "Drizzle ORM", - description: "Encrypted column types and query operators for Drizzle.", - href: "/stack/cipherstash/encryption/drizzle", - logo: DrizzleLogo, - }, - { - title: "Prisma Next", - description: - "Searchable field-level encryption for Postgres with Prisma Next.", - href: "/stack/cipherstash/encryption/prisma-next", - logo: PrismaLogo, - }, - { - title: "DynamoDB", - description: - "Encrypted DynamoDB attributes with searchable equality lookups.", - href: "/stack/cipherstash/encryption/dynamodb", - logo: DynamoDBLogo, - }, -]; - -const resources = [ - { - title: "What is CipherStash?", - description: "DLAC, threat model, how it works", - href: "/stack/reference/what-is-cipherstash", - icon: ShieldCheck, - }, - { - title: "API Reference", - description: "SDK and API reference docs", - href: "/stack/reference", - icon: Code, - }, - { - title: "Agent Skills", - description: "CipherStash knowledge for your AI coding agent", - href: "/stack/reference/agent-skills", - icon: Zap, - }, - { - title: "Use Cases", - description: "AI/RAG, compliance, data residency", - href: "/stack/reference/use-cases", - icon: BookOpen, - }, -]; - -export default function HomePage() { - return ( - <main className="flex flex-col"> - {/* Hero */} - <section className="border-b border-fd-border"> - <div className="mx-auto w-full max-w-[1200px] px-6 pt-24 pb-16 md:px-12 md:pt-32 md:pb-20"> - <p className={eyebrowClass}>DLAC / DATA LEVEL ACCESS CONTROL</p> - <h1 - className={`mt-4 text-3xl font-medium text-fd-foreground md:text-5xl ${monoClass}`} - > - CipherStash Docs - </h1> - <p className="mt-4 max-w-2xl text-[17px] leading-relaxed text-fd-muted-foreground"> - Searchable field-level encryption. Identity-bound keys. - Cryptographic audit trails. Built into your existing Postgres stack. - </p> - - {/* Getting started cards */} - <div className="mt-10 grid gap-px bg-fd-border sm:grid-cols-2 border border-fd-border rounded-[2px] overflow-hidden"> - {[ - { - href: "/stack/quickstart", - icon: Zap, - title: "Quickstart", - desc: "Encrypt your first fields in 15 minutes.", - }, - { - href: "/stack/cipherstash/supabase", - icon: Database, - title: "Supabase", - desc: "Field-level encryption for Supabase.", - }, - { - href: "/stack/cipherstash/encryption/searchable-encryption", - icon: Search, - title: "Searchable encryption", - desc: "Equality, free text, range, ordering, and JSON queries over ciphertext.", - }, - { - href: "/stack/reference/agent-skills", - icon: Zap, - title: "Agent Skills", - desc: "CipherStash knowledge for Cursor, Copilot, Claude Code.", - }, - ].map((card) => ( - <Link - key={card.href} - href={card.href} - className="group flex items-center gap-4 bg-fd-background p-5 transition-colors hover:bg-fd-accent/50" - > - <div className="flex size-10 shrink-0 items-center justify-center rounded-[2px] bg-fd-primary/10 text-fd-primary"> - <card.icon className="size-5" /> - </div> - <div className="min-w-0"> - <p - className={`font-medium text-fd-foreground text-[15px] ${monoClass}`} - > - {card.title} - </p> - <p className="text-sm text-fd-muted-foreground"> - {card.desc} - </p> - </div> - <ArrowRight className="ml-auto size-4 shrink-0 text-fd-muted-foreground transition-colors group-hover:text-fd-primary" /> - </Link> - ))} - </div> - </div> - </section> - - {/* Products */} - <section className="mx-auto w-full max-w-[1200px] px-6 py-16 md:px-12 md:py-24"> - <p className={eyebrowClass}>§ 01 / THE STACK</p> - <h2 - className={`mt-3 text-xl font-medium text-fd-foreground md:text-2xl ${monoClass}`} - > - The Stack - </h2> - <p className="mt-2 text-fd-muted-foreground"> - Encryption, key management, and proxy. - </p> - - <div className="mt-8 grid gap-px bg-fd-border sm:grid-cols-3 border border-fd-border rounded-[2px] overflow-hidden"> - {products.map((product) => ( - <Link - key={product.title} - href={product.href} - className="group relative flex flex-col overflow-hidden bg-fd-background transition-colors hover:bg-fd-accent/50" - > - <div className="flex h-32 items-center justify-center border-b border-fd-border bg-fd-muted/20"> - <product.icon className="size-10 text-fd-muted-foreground/30" /> - </div> - <div className="flex flex-1 flex-col p-5"> - <div className="flex items-center gap-2"> - <product.icon className="size-4 text-fd-primary" /> - <h3 className={`font-medium text-fd-foreground ${monoClass}`}> - {product.title} - </h3> - </div> - <p className="mt-2 flex-1 text-sm leading-relaxed text-fd-muted-foreground"> - {product.description} - </p> - </div> - </Link> - ))} - </div> - </section> - - {/* Integrations */} - <section className="border-t border-fd-border"> - <div className="mx-auto w-full max-w-[1200px] px-6 py-16 md:px-12 md:py-24"> - <p className={eyebrowClass}>§ 02 / INTEGRATIONS</p> - <h2 - className={`mt-3 text-xl font-medium text-fd-foreground md:text-2xl ${monoClass}`} - > - Integrations - </h2> - <p className="mt-2 text-fd-muted-foreground"> - Drop-in encryption for the databases and ORMs you already use. - </p> - - <div className="mt-8 grid gap-px bg-fd-border sm:grid-cols-2 lg:grid-cols-4 border border-fd-border rounded-[2px] overflow-hidden"> - {integrations.map((integration) => ( - <Link - key={integration.title} - href={integration.href} - className="group flex flex-col items-center bg-fd-background p-6 text-center transition-colors hover:bg-fd-accent/50" - > - <div className="flex size-24 items-center justify-center"> - <integration.logo className="h-12 w-auto" /> - </div> - <h3 - className={`mt-4 font-medium text-fd-foreground ${monoClass}`} - > - {integration.title} - </h3> - <p className="mt-1 text-sm text-fd-muted-foreground"> - {integration.description} - </p> - </Link> - ))} - </div> - </div> - </section> - - {/* Resources */} - <section className="border-t border-fd-border"> - <div className="mx-auto w-full max-w-[1200px] px-6 py-16 md:px-12 md:py-24"> - <p className={eyebrowClass}>§ 03 / RESOURCES</p> - <h2 - className={`mt-3 text-xl font-medium text-fd-foreground md:text-2xl ${monoClass}`} - > - Resources - </h2> - - <div className="mt-8 grid gap-px bg-fd-border sm:grid-cols-2 lg:grid-cols-4 border border-fd-border rounded-[2px] overflow-hidden"> - {resources.map((resource) => ( - <Link - key={resource.title} - href={resource.href} - className="group flex items-start gap-3 bg-fd-background p-4 transition-colors hover:bg-fd-accent/50" - > - <resource.icon className="mt-0.5 size-5 shrink-0 text-fd-muted-foreground group-hover:text-fd-primary" /> - <div> - <p - className={`font-medium text-fd-foreground text-[14px] ${monoClass}`} - > - {resource.title} - </p> - <p className="mt-0.5 text-sm text-fd-muted-foreground"> - {resource.description} - </p> - </div> - </Link> - ))} - </div> - </div> - </section> - - {/* AI/LLM + CTA footer */} - <section className="border-t border-fd-border bg-fd-card/50"> - <div className="mx-auto flex w-full max-w-[1200px] flex-col items-center px-6 py-16 text-center md:px-12 md:py-20"> - <div className="flex size-10 items-center justify-center rounded-[2px] bg-fd-primary/10 text-fd-primary"> - <FileText className="size-5" /> - </div> - <h2 - className={`mt-4 text-xl font-medium text-fd-foreground md:text-2xl ${monoClass}`} - > - AI-ready documentation - </h2> - <p className="mx-auto mt-2 max-w-lg text-sm text-fd-muted-foreground"> - Every page is clean markdown. Feed it to your LLM. - </p> - <div className="mt-6 flex flex-wrap justify-center gap-3"> - <Link - href="/llms.txt" - className="inline-flex items-center gap-2 rounded-[2px] border border-fd-border px-4 py-2 text-sm font-medium text-fd-foreground transition-colors hover:border-fd-primary/40 hover:bg-fd-accent/50" - > - <FileText className="size-4" /> - llms.txt - </Link> - <Link - href="/llms-full.txt" - className="inline-flex items-center gap-2 rounded-[2px] border border-fd-border px-4 py-2 text-sm font-medium text-fd-foreground transition-colors hover:border-fd-primary/40 hover:bg-fd-accent/50" - > - <FileText className="size-4" /> - llms-full.txt - </Link> - <a - href="https://github.com/cipherstash/stack" - target="_blank" - rel="noopener noreferrer" - className="inline-flex items-center gap-2 rounded-[2px] border border-fd-border px-4 py-2 text-sm font-medium text-fd-foreground transition-colors hover:border-fd-primary/40 hover:bg-fd-accent/50" - > - <ExternalLinkIcon className="size-4" /> - GitHub - </a> - </div> - </div> - </section> - </main> - ); -} diff --git a/src/app/[[...slug]]/layout.tsx b/src/app/[[...slug]]/layout.tsx new file mode 100644 index 0000000..d121273 --- /dev/null +++ b/src/app/[[...slug]]/layout.tsx @@ -0,0 +1,15 @@ +import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { baseOptions } from "@/lib/layout.shared"; +import { getV2PageTree } from "@/lib/source"; + +// Layout for the V2 IA tree (content/docs), served from the site root — +// including the /docs landing page (content/docs/index.mdx), which renders +// inside the same navigation shell as every other page. Static routes +// (/stack, /api, /og, …) take precedence over this segment as usual. +export default function Layout({ children }: LayoutProps<"/[[...slug]]">) { + return ( + <DocsLayout tree={getV2PageTree()} {...baseOptions()}> + {children} + </DocsLayout> + ); +} diff --git a/src/app/[[...slug]]/page.tsx b/src/app/[[...slug]]/page.tsx new file mode 100644 index 0000000..7f029e7 --- /dev/null +++ b/src/app/[[...slug]]/page.tsx @@ -0,0 +1,84 @@ +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from "fumadocs-ui/layouts/docs/page"; +import { createRelativeLink } from "fumadocs-ui/mdx"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { LLMCopyButton, ViewOptions } from "@/components/ai/page-actions"; +import { gitConfig } from "@/lib/layout.shared"; +import { v2source } from "@/lib/source"; +import { getMDXComponents } from "@/mdx-components"; + +// Page route for the V2 IA tree (content/docs), including the /docs landing +// page. Mirrors the legacy /stack/[[...slug]] route; the legacy route is +// deleted when the migration completes (see IA.md). + +// The landing page's URL is "/", which would produce "/docs/.mdx" — serve its +// raw-markdown mirror at /docs/index.mdx instead (normalized back to the root +// slug in the llms.mdx/v2 route). +function markdownUrl(pageUrl: string): string { + return `/docs${pageUrl === "/" ? "/index" : pageUrl}.mdx`; +} + +export default async function Page(props: PageProps<"/[[...slug]]">) { + const params = await props.params; + const page = v2source.getPage(params.slug); + if (!page) notFound(); + + const MDX = page.data.body; + + return ( + <DocsPage toc={page.data.toc} full={page.data.full}> + <DocsTitle>{page.data.title}</DocsTitle> + <DocsDescription className="mb-0 text-fd-foreground"> + {page.data.description} + </DocsDescription> + <div className="flex flex-row gap-2 items-center border-b pb-6"> + <LLMCopyButton markdownUrl={markdownUrl(page.url)} /> + <ViewOptions + markdownUrl={markdownUrl(page.url)} + githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/content/docs/${page.path}`} + /> + </div> + <DocsBody> + <MDX + components={getMDXComponents({ + a: createRelativeLink(v2source, page), + })} + /> + </DocsBody> + </DocsPage> + ); +} + +export async function generateStaticParams() { + return v2source.generateParams(); +} + +export async function generateMetadata( + props: PageProps<"/[[...slug]]">, +): Promise<Metadata> { + const params = await props.params; + const page = v2source.getPage(params.slug); + if (!page) notFound(); + + const title = page.data.seoTitle ?? page.data.title; + const url = `https://cipherstash.com/docs${page.url === "/" ? "" : page.url}`; + + return { + title, + description: page.data.description, + alternates: { canonical: url }, + openGraph: { + type: "article", + url, + title, + description: page.data.description, + // TODO(v2): OG images — the /og route only covers the legacy tree. + // Add a v2 OG route when the first real (non-stub) pages land. + }, + }; +} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index aa9d5cd..ec6bc8d 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,5 +1,5 @@ -import { source } from "@/lib/source"; import { createFromSource } from "fumadocs-core/search/server"; +import { source } from "@/lib/source"; export const { GET } = createFromSource(source, { // https://docs.orama.com/docs/orama-js/supported-languages diff --git a/src/app/global.css b/src/app/global.css index 2d7a215..3ebd230 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -47,19 +47,66 @@ } } -/* ── Typography: Fira Code for headings, labels, code ── */ +/* ── Typography: Fira Code for H1 + code, Geist Sans for H2+ ── */ @layer base { - h1, h2, h3, h4 { + /* H1 keeps the Fira Code monospace treatment. */ + h1 { font-family: var(--font-fira-code), ui-monospace, monospace !important; letter-spacing: -0.02em; } + /* H2 and below use Geist Sans, matching the marketing site. */ + h2, h3, h4, h5, h6 { + font-family: var(--font-geist-sans), var(--font-inter), system-ui, sans-serif !important; + letter-spacing: -0.02em; + } + + /* Relax the tracking at smaller sizes, where -0.02em reads too tight. */ + h4, h5, h6 { + letter-spacing: -0.01em; + } + code, pre, kbd { font-family: var(--font-fira-code), ui-monospace, monospace !important; } } +/* ── Body copy ── */ + +/* Fumadocs sets prose body text to `--color-fd-foreground` at 90%, and renders + the page description (DocsDescription, the standfirst under the H1) muted. + We want the reverse: the standfirst leads at full contrast, and the body + copy sits back. Only `--tw-prose-body` moves — headings, links, bold, code, + quotes and captions keep their own variables, so they stay prominent + against the softer paragraph text. + + Both values are theme tokens, so light and dark follow the same rule with + no per-mode override. The description's colour is set at the call site in + the two page routes. + + Deliberately unlayered. Fumadocs declares `.prose` in `@layer utilities`, + and layer order beats both specificity and source order — the same rule + inside `@layer components` below is silently ignored. Unlayered + declarations outrank every layer, so this wins without `!important`. */ +.prose { + --tw-prose-body: var(--color-fd-muted-foreground); +} + +/* Callout body sits one step above the muted paragraphs around it: the colour + prose body copy had before the rule above. A callout is already set apart by + its card background, border, accent bar and icon; matching the body copy + exactly made it read flat, and full foreground read as shouting. + + Fumadocs puts `text-fd-muted-foreground` directly on the description div, so + inheritance can't reach it — this has to select the element. `data-callout` + is ours (see mdx-components.tsx); `prose-no-margin` is what Fumadocs marks + the description's prose block with. Unlayered for the same reason as the + rule above: the utility class it overrides lives in `@layer utilities`. */ +[data-callout] .prose-no-margin { + color: color-mix(in oklab, var(--color-fd-foreground) 90%, transparent); +} + /* ── Fumadocs component overrides ── */ @layer components { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e6ff9a2..fd09c99 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,7 @@ import { RootProvider } from "fumadocs-ui/provider/next"; import { PostHogProvider } from "@/lib/posthog/provider"; import "./global.css"; import type { Metadata } from "next"; -import { Inter, Fira_Code } from "next/font/google"; +import { Fira_Code, Geist, Inter } from "next/font/google"; // Site-wide title template so every page gets a descriptive, branded // <title>. Per-page metadata returns a bare title (e.g. "Keysets") which @@ -27,11 +27,17 @@ const firaCode = Fira_Code({ variable: "--font-fira-code", }); +// Geist Sans for H2 and below, matching the marketing site. H1 stays Fira Code. +const geistSans = Geist({ + subsets: ["latin"], + variable: "--font-geist-sans", +}); + export default function Layout({ children }: LayoutProps<"/">) { return ( <html lang="en" - className={`${inter.variable} ${firaCode.variable} ${inter.className}`} + className={`${inter.variable} ${firaCode.variable} ${geistSans.variable} ${inter.className}`} suppressHydrationWarning > <body className="flex flex-col min-h-screen"> diff --git a/src/app/llms-full.txt/route.ts b/src/app/llms-full.txt/route.ts index 8e2efe8..9cd76be 100644 --- a/src/app/llms-full.txt/route.ts +++ b/src/app/llms-full.txt/route.ts @@ -1,5 +1,5 @@ import { getPostHogClient } from "@/lib/posthog/server"; -import { getLLMText, source } from "@/lib/source"; +import { getLLMText, source, v2source } from "@/lib/source"; export const revalidate = false; @@ -18,7 +18,7 @@ export async function GET(request: Request) { await posthog.flush(); } - const scan = source.getPages().map(getLLMText); + const scan = [...v2source.getPages(), ...source.getPages()].map(getLLMText); const scanned = await Promise.all(scan); return new Response(scanned.join("\n\n")); diff --git a/src/app/llms.mdx/v2/[[...slug]]/route.ts b/src/app/llms.mdx/v2/[[...slug]]/route.ts new file mode 100644 index 0000000..fc9f251 --- /dev/null +++ b/src/app/llms.mdx/v2/[[...slug]]/route.ts @@ -0,0 +1,48 @@ +import { notFound } from "next/navigation"; +import { getPostHogClient } from "@/lib/posthog/server"; +import { getLLMText, v2source } from "@/lib/source"; + +// Raw-markdown mirror for the V2 IA tree, reached via the +// `/:path*.mdx` rewrite in next.config.mjs (same pattern as the legacy +// /llms.mdx/stack route). +export const revalidate = false; + +export async function GET( + req: Request, + { params }: RouteContext<"/llms.mdx/v2/[[...slug]]">, +) { + const { slug } = await params; + // The landing page's markdown mirror is served at /docs/index.mdx (its URL + // is "/", which can't carry an .mdx suffix) — normalize back to the root. + const normalized = + !slug || (slug.length === 1 && slug[0] === "index") ? [] : slug; + const page = v2source.getPage(normalized); + if (!page) notFound(); + + const posthog = getPostHogClient(); + if (posthog) { + posthog.capture({ + distinctId: "llm-agent", + event: "llms_mdx_page_fetched", + properties: { + $current_url: req.url, + page_slug: normalized.join("/"), + page_title: page.data.title, + referer: req.headers.get("referer") ?? "", + user_agent: req.headers.get("user-agent") ?? "", + }, + }); + await posthog.flush(); + } + + return new Response(await getLLMText(page), { + headers: { + "Content-Type": "text/markdown", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +export function generateStaticParams() { + return v2source.generateParams(); +} diff --git a/src/app/llms.txt/route.ts b/src/app/llms.txt/route.ts index 5d6bcbb..2c6696e 100644 --- a/src/app/llms.txt/route.ts +++ b/src/app/llms.txt/route.ts @@ -1,5 +1,5 @@ import { getPostHogClient } from "@/lib/posthog/server"; -import { source } from "@/lib/source"; +import { source, v2source } from "@/lib/source"; export const revalidate = false; @@ -21,7 +21,8 @@ export async function GET(request: Request) { const lines: string[] = []; lines.push("# Documentation"); lines.push(""); - for (const page of source.getPages()) { + // V2 tree first: it's the canonical IA once the migration completes. + for (const page of [...v2source.getPages(), ...source.getPages()]) { lines.push(`- [${page.data.title}](${page.url}): ${page.data.description}`); } return new Response(lines.join("\n")); diff --git a/src/app/og/docs/[...slug]/route.tsx b/src/app/og/docs/[...slug]/route.tsx index 208fdfd..801a890 100644 --- a/src/app/og/docs/[...slug]/route.tsx +++ b/src/app/og/docs/[...slug]/route.tsx @@ -1,7 +1,7 @@ -import { getPageImage, source } from "@/lib/source"; +import { generate as DefaultImage } from "fumadocs-ui/og"; import { notFound } from "next/navigation"; import { ImageResponse } from "next/og"; -import { generate as DefaultImage } from "fumadocs-ui/og"; +import { getPageImage, source } from "@/lib/source"; export const revalidate = false; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 515283d..77fd867 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,10 +1,10 @@ import type { MetadataRoute } from "next"; -import { source } from "@/lib/source"; +import { source, v2source } from "@/lib/source"; const BASE_URL = "https://cipherstash.com/docs"; export default function sitemap(): MetadataRoute.Sitemap { - return source.getPages().map((page) => ({ + return [...v2source.getPages(), ...source.getPages()].map((page) => ({ url: `${BASE_URL}${page.url}`, lastModified: new Date(), changeFrequency: "weekly", diff --git a/src/app/stack/[[...slug]]/page.tsx b/src/app/stack/[[...slug]]/page.tsx index 6f5e79a..992bf75 100644 --- a/src/app/stack/[[...slug]]/page.tsx +++ b/src/app/stack/[[...slug]]/page.tsx @@ -1,16 +1,16 @@ -import { getPageImage, source } from "@/lib/source"; import { DocsBody, DocsDescription, DocsPage, DocsTitle, } from "fumadocs-ui/layouts/docs/page"; -import { notFound } from "next/navigation"; -import { getMDXComponents } from "@/mdx-components"; -import type { Metadata } from "next"; import { createRelativeLink } from "fumadocs-ui/mdx"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; import { LLMCopyButton, ViewOptions } from "@/components/ai/page-actions"; import { gitConfig } from "@/lib/layout.shared"; +import { getPageImage, source } from "@/lib/source"; +import { getMDXComponents } from "@/mdx-components"; export default async function Page(props: PageProps<"/stack/[[...slug]]">) { const params = await props.params; @@ -22,7 +22,7 @@ export default async function Page(props: PageProps<"/stack/[[...slug]]">) { return ( <DocsPage toc={page.data.toc} full={page.data.full}> <DocsTitle>{page.data.title}</DocsTitle> - <DocsDescription className="mb-0"> + <DocsDescription className="mb-0 text-fd-foreground"> {page.data.description} </DocsDescription> <div className="flex flex-row gap-2 items-center border-b pb-6"> diff --git a/src/app/stack/layout.tsx b/src/app/stack/layout.tsx index d5b93ec..78d2389 100644 --- a/src/app/stack/layout.tsx +++ b/src/app/stack/layout.tsx @@ -1,6 +1,6 @@ -import { source } from "@/lib/source"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; import { baseOptions } from "@/lib/layout.shared"; +import { source } from "@/lib/source"; export default function Layout({ children }: LayoutProps<"/stack">) { return ( diff --git a/src/components/bad-example.tsx b/src/components/bad-example.tsx new file mode 100644 index 0000000..4da2ca2 --- /dev/null +++ b/src/components/bad-example.tsx @@ -0,0 +1,48 @@ +import { CircleX } from "lucide-react"; + +interface BadExampleProps { + /** Short label for the header strip. */ + label?: string; + /** Verbatim error the example produces, rendered as a footer. */ + error?: string; + /** The example itself (a fenced code block). */ + children: React.ReactNode; +} + +/** + * A code example that does NOT work, styled so a reader skimming the page can + * never mistake it for one that does — the failure mode of showing broken SQL + * in the same grey box as the fix. + * + * Modelled on rustdoc's `compile_fail` blocks: red frame, an explicit label, + * and (optionally) the verbatim error underneath. The example keeps the site's + * syntax highlighting and copy button, because a reader reproducing the failure + * is a legitimate thing to want. + */ +export function BadExample({ + label = "Doesn't work", + error, + children, +}: BadExampleProps) { + return ( + <div className="my-4 overflow-hidden rounded-lg border border-red-500/40 bg-red-500/[0.04]"> + <div className="flex items-center gap-1.5 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5"> + <CircleX + className="size-3.5 shrink-0 text-red-700 dark:text-red-400" + aria-hidden="true" + /> + <span className="text-[11px] font-semibold uppercase tracking-wide text-red-700 dark:text-red-400"> + {label} + </span> + </div> + + <div className="p-3 [&>*]:!my-0">{children}</div> + + {error ? ( + <p className="border-t border-red-500/20 px-3 py-2 font-mono text-xs leading-relaxed text-red-700 dark:text-red-400"> + {error} + </p> + ) : null} + </div> + ); +} diff --git a/src/components/cipherstash-credentials.tsx b/src/components/cipherstash-credentials.tsx new file mode 100644 index 0000000..2665cb9 --- /dev/null +++ b/src/components/cipherstash-credentials.tsx @@ -0,0 +1,270 @@ +"use client"; + +import type { CodeBlockProps } from "fumadocs-ui/components/codeblock"; +import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock"; +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "fumadocs-ui/components/ui/popover"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "fumadocs-ui/components/ui/tabs"; +import { + Check, + ChevronDown, + ExternalLink, + Laptop, + LayoutDashboard, + Terminal, +} from "lucide-react"; +import { useState } from "react"; +import { TrackedCodeBlock } from "@/components/code-block"; +import { cipherstashDark, cipherstashLight } from "@/lib/shiki-themes"; + +const ENVIRONMENT_VARIABLES = `CS_WORKSPACE_CRN=crn:<region>.<provider>:<workspace-id> +CS_CLIENT_ID=<uuid> +CS_CLIENT_KEY=<hex> +CS_CLIENT_ACCESS_KEY=CSAK...`; + +const options = [ + { + value: "developer-profile", + title: "Developer profile", + useFor: "Local development", + icon: Laptop, + command: "npx stash auth login", + action: null, + description: + "The native Stack client uses the developer profile automatically.", + }, + { + value: "stash-env", + title: "stash env", + useFor: "CI and Deployment", + icon: Terminal, + command: "npx stash env --name <app-env>", + action: null, + description: + "Creates a client and prints the four variables below. The access key is shown once.", + }, + { + value: "dashboard", + title: "Dashboard", + useFor: "CI and Deployment", + icon: LayoutDashboard, + command: null, + action: ( + <> + Open your{" "} + <a + href="https://dashboard.cipherstash.com/workspaces/_" + target="_blank" + rel="noreferrer" + className="font-medium text-fd-foreground underline decoration-fd-primary decoration-[1.5px] underline-offset-[3.5px] transition-opacity hover:opacity-80" + > + CipherStash workspace + <ExternalLink + className="ms-1 inline size-3" + aria-label="Opens in a new tab" + /> + </a>{" "} + and create deployment credentials. + </> + ), + description: "Save the values in your platform’s secret store.", + }, +] as const; + +type CredentialOptionValue = (typeof options)[number]["value"]; + +function TrackedBashCodeBlock(props: CodeBlockProps) { + return ( + <TrackedCodeBlock + {...props} + data-language="bash" + className={`my-0 ${props.className ?? ""}`} + /> + ); +} + +function DeploymentEnvironmentVariables() { + return ( + <div className="mt-5 border-t pt-4"> + <p className="text-sm font-medium text-fd-foreground"> + Deployment environment variables + </p> + <p className="my-2 text-xs text-fd-muted-foreground"> + Both deployment methods produce the same values. + <br /> + Treat <code>CS_CLIENT_KEY</code> and <code>CS_CLIENT_ACCESS_KEY</code>{" "} + as secrets. + </p> + <DynamicCodeBlock + lang="dotenv" + code={ENVIRONMENT_VARIABLES} + codeblock={{ title: ".env", className: "mb-0" }} + options={{ + themes: { light: cipherstashLight, dark: cipherstashDark }, + }} + /> + </div> + ); +} + +/** + * The canonical credential-source chooser for integration and deployment + * guides. Keep the discovery order and environment-variable names aligned + * with the Stack authentication skill. + */ +export function CipherStashCredentials() { + const [selectedOption, setSelectedOption] = useState<CredentialOptionValue>( + options[0].value, + ); + const activeOption = + options.find(({ value }) => value === selectedOption) ?? options[0]; + const ActiveIcon = activeOption.icon; + + return ( + <div className="not-prose my-6 overflow-hidden rounded-xl border border-t-2 border-t-fd-primary bg-fd-card"> + <div className="border-b px-5 py-4"> + <h3 className="font-semibold text-fd-foreground"> + Choose a credential source + </h3> + <p className="mt-1 text-sm text-fd-muted-foreground"> + Use the developer profile locally. For CI or a deployed application, + create a separate credential set with the CLI or Dashboard. + </p> + </div> + + <Tabs + value={selectedOption} + onValueChange={(value) => + setSelectedOption(value as CredentialOptionValue) + } + className="my-0 gap-0 overflow-visible rounded-none border-0 bg-transparent" + > + <div className="border-b p-4 md:hidden"> + <Popover> + <PopoverTrigger asChild> + <button + type="button" + className="flex w-full items-center gap-3 rounded-lg border bg-fd-background px-3 py-2.5 text-left shadow-sm outline-none transition-colors hover:bg-fd-accent focus-visible:border-fd-primary focus-visible:ring-2 focus-visible:ring-fd-primary/20" + > + <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-fd-primary/10 text-fd-primary"> + <ActiveIcon className="size-4" aria-hidden="true" /> + </span> + <span className="min-w-0 flex-1"> + <span className="block text-sm font-medium text-fd-foreground"> + {activeOption.title} + </span> + <span className="block text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> + {activeOption.useFor} + </span> + </span> + <ChevronDown + className="size-4 shrink-0 text-fd-muted-foreground" + aria-hidden="true" + /> + </button> + </PopoverTrigger> + <PopoverContent + align="start" + collisionPadding={16} + className="w-[var(--radix-popover-trigger-width)] p-1" + > + {options.map(({ value, title, useFor, icon: Icon }) => ( + <PopoverClose key={value} asChild> + <button + type="button" + onClick={() => setSelectedOption(value)} + className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-fd-accent focus-visible:bg-fd-accent" + > + <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-fd-primary/10 text-fd-primary"> + <Icon className="size-4" aria-hidden="true" /> + </span> + <span className="min-w-0 flex-1"> + <span className="block text-sm font-medium text-fd-foreground"> + {title} + </span> + <span className="block text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> + {useFor} + </span> + </span> + {value === selectedOption && ( + <Check + className="size-4 shrink-0 text-fd-primary" + aria-hidden="true" + /> + )} + </button> + </PopoverClose> + ))} + </PopoverContent> + </Popover> + </div> + + <TabsList className="hidden grid-cols-3 border-b md:grid"> + {options.map(({ value, title, useFor, icon: Icon }, index) => ( + <TabsTrigger + key={value} + value={value} + className="group inline-flex w-full items-center justify-start gap-2 border-b-2 border-transparent px-5 py-4 text-left text-sm text-fd-muted-foreground transition-colors hover:text-fd-foreground data-[state=active]:border-fd-primary data-[state=active]:bg-fd-primary/5 data-[state=active]:text-fd-foreground" + > + <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-fd-primary/10 text-fd-primary"> + <Icon className="size-4" aria-hidden="true" /> + </span> + <span className="min-w-0"> + <span className="block font-medium"> + {index + 1}. {title} + </span> + <span className="mt-0.5 block text-xs font-medium uppercase tracking-wide text-fd-muted-foreground"> + {useFor} + </span> + </span> + </TabsTrigger> + ))} + </TabsList> + + {options.map(({ value, command, action, description }) => ( + <TabsContent + key={value} + value={value} + className="rounded-none bg-transparent p-5 outline-none" + > + {command ? ( + <> + <p className="mb-2 text-sm text-fd-foreground">Run:</p> + <DynamicCodeBlock + lang="bash" + code={command} + options={{ + themes: { + light: cipherstashLight, + dark: cipherstashDark, + }, + components: { pre: TrackedBashCodeBlock }, + }} + /> + </> + ) : ( + <p className="text-sm leading-relaxed text-fd-foreground"> + {action} + </p> + )} + <p className="mt-3 text-xs leading-relaxed text-fd-muted-foreground"> + {description} + </p> + {value !== "developer-profile" && ( + <DeploymentEnvironmentVariables /> + )} + </TabsContent> + ))} + </Tabs> + </div> + ); +} diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx index b0f9d18..b42044c 100644 --- a/src/components/code-block.tsx +++ b/src/components/code-block.tsx @@ -9,6 +9,7 @@ import { slug } from "github-slugger"; import { usePathname } from "next/navigation"; import posthog from "posthog-js"; import { type MouseEvent, useCallback, useEffect, useRef } from "react"; +import { Mermaid } from "@/components/mermaid"; // Build-time metadata is attached to the `<pre>` as `data-*` attributes by the // `cipherstash:code-copy-tracking` Shiki transformer (see `source.config.ts`). @@ -18,6 +19,8 @@ type TrackingProps = { "data-filename"?: string; "data-cta"?: string; "data-cta-type"?: string; + /** Raw diagram source, carried through for `mermaid` fences. */ + "data-mermaid"?: string; }; /** @@ -35,6 +38,25 @@ type TrackingProps = { export function TrackedCodeBlock(props: CodeBlockProps) { const attrs = props as CodeBlockProps & TrackingProps; const language = attrs["data-language"] ?? "plaintext"; + + // A ```mermaid fence is a code fence everywhere except on screen: the source + // survives in the mdast (so `.mdx` and llms.txt keep it readable), and the + // rendered page gets a diagram instead of an overflowing block of syntax. + const mermaidChart = attrs["data-mermaid"]; + if (language === "mermaid" && mermaidChart) { + return <Mermaid chart={mermaidChart} />; + } + + return <TrackedFumadocsCodeBlock {...props} />; +} + +/** + * Instrumented Fumadocs code block, split from the Mermaid dispatch above so + * every hook in this component is called unconditionally. + */ +function TrackedFumadocsCodeBlock(props: CodeBlockProps) { + const attrs = props as CodeBlockProps & TrackingProps; + const language = attrs["data-language"] ?? "plaintext"; const exampleId = attrs["data-example-id"]; const isCta = attrs["data-cta"] === "true"; const ctaType = attrs["data-cta-type"]; diff --git a/src/components/eql-fn.tsx b/src/components/eql-fn.tsx new file mode 100644 index 0000000..5af3af7 --- /dev/null +++ b/src/components/eql-fn.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; + +interface EqlFnProps { + /** + * Comma-separated operator equivalents, e.g. `<,<=,>,>=`. Omit for pure + * functions with no operator form (e.g. `eql_v3.jsonb_path_query`). + */ + ops?: string; + /** Comma-separated short domain names the function applies to. */ + domains?: string; + /** Aggregate function (MIN/MAX): labels the row "Aggregate". */ + agg?: boolean; + /** How many domains to show before the "Show all" toggle. */ + initial?: number; + /** The worked example (a fenced code block). */ + children: React.ReactNode; +} + +/** + * The body of one entry in a generated EQL function reference (see the + * fragments under content/partials/eql, produced by + * scripts/generate-eql-api-docs.ts). + * + * The function name is a real Markdown heading in the fragment (so it appears + * in the page's table of contents and is deep-linkable); this component renders + * everything below it — the operator equivalents, the domains it applies to, + * and the example. The domain list is the drift-prone part, so it comes from + * the manifest via the `domains` prop; only the first few show, with the rest + * behind a reader-controlled toggle. The example is passed as children so it + * keeps the site's syntax highlighting and copy button. + */ +export function EqlFn({ + ops, + domains, + agg, + initial = 2, + children, +}: EqlFnProps) { + const opList = ops ? ops.split(",").filter(Boolean) : []; + const domainList = domains ? domains.split(",").filter(Boolean) : []; + const [expanded, setExpanded] = useState(false); + const hidden = Math.max(0, domainList.length - initial); + const visible = expanded ? domainList : domainList.slice(0, initial); + const label = agg ? "Aggregate" : "Operators"; + + return ( + <div className="my-4 rounded-lg border border-fd-border bg-fd-card p-4"> + {opList.length > 0 ? ( + <div className="flex flex-wrap items-center gap-1.5"> + <span className="mr-1 text-[11px] font-semibold uppercase tracking-wide text-fd-muted-foreground"> + {label} + </span> + {opList.map((op) => ( + <span + key={op} + className="rounded-md border border-fd-primary/30 bg-fd-primary/10 px-2 py-0.5 font-mono text-sm font-semibold text-fd-primary" + > + {op} + </span> + ))} + </div> + ) : null} + + {domainList.length > 0 ? ( + <div className="mt-3 flex flex-wrap items-center gap-1.5"> + <span className="mr-1 text-[11px] font-semibold uppercase tracking-wide text-fd-muted-foreground"> + On + </span> + {visible.map((d) => ( + <span + key={d} + className="rounded-md border border-fd-border bg-fd-secondary px-2 py-0.5 font-mono text-xs" + > + {d} + </span> + ))} + {hidden > 0 ? ( + <button + type="button" + onClick={() => setExpanded((v) => !v)} + aria-expanded={expanded} + className="rounded-md px-1.5 py-0.5 text-xs font-medium text-fd-primary hover:bg-fd-primary/10 focus-visible:outline-2 focus-visible:outline-fd-primary" + > + {expanded + ? "Show fewer" + : `Show all ${domainList.length} variants`} + </button> + ) : null} + </div> + ) : null} + + <div className="mt-3 [&>*]:!my-0">{children}</div> + </div> + ); +} diff --git a/src/components/eql-version.tsx b/src/components/eql-version.tsx new file mode 100644 index 0000000..d3c43e0 --- /dev/null +++ b/src/components/eql-version.tsx @@ -0,0 +1,20 @@ +import { Callout } from "fumadocs-ui/components/callout"; +import Link from "next/link"; +import { EQL_VERSION } from "@/lib/eql-version"; + +/** + * Version banner for the EQL v3 reference. Shows the EQL release the docs were + * generated/validated against — sourced from the release manifest's own version + * (written to `@/lib/eql-version` by generate-eql-api-docs.ts at build time) — + * and links to the retained EQL v2 reference for readers on the older + * generation. + */ +export function EqlVersion() { + return ( + <Callout title="EQL version" type="info"> + This reference is generated and validated against{" "} + <strong>EQL {EQL_VERSION}</strong>. Running EQL 2.x? See the{" "} + <Link href="/reference/eql/v2">EQL v2 reference</Link>. + </Callout> + ); +} diff --git a/src/components/faq/index.tsx b/src/components/faq/index.tsx new file mode 100644 index 0000000..ffd9ac6 --- /dev/null +++ b/src/components/faq/index.tsx @@ -0,0 +1,63 @@ +import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; +import { isValidElement, type ReactNode } from "react"; + +export type FaqEntry = { + title: string; + answer: ReactNode; +}; + +/** Flatten a ReactNode to plain text for the FAQPage JSON-LD `text` field. */ +function nodeToText(node: ReactNode): string { + if (node === null || node === undefined || typeof node === "boolean") { + return ""; + } + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + if (Array.isArray(node)) { + return node.map(nodeToText).join(""); + } + if (isValidElement(node)) { + return nodeToText((node.props as { children?: ReactNode }).children); + } + return ""; +} + +/** + * Renders a list of FAQ entries as an accordion and emits FAQPage structured + * data (JSON-LD) so the questions can surface as rich results in search. + * + * Compose `items` from the shared, product-wide entries in `./shared` plus any + * integration-specific entries authored inline, keeping generic answers DRY. + */ +export function Faq({ items }: { items: FaqEntry[] }) { + const jsonLd = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: items.map((item) => ({ + "@type": "Question", + name: item.title, + acceptedAnswer: { + "@type": "Answer", + text: nodeToText(item.answer).replace(/\s+/g, " ").trim(), + }, + })), + }; + + return ( + <> + <script + type="application/ld+json" + // biome-ignore lint/security/noDangerouslySetInnerHtml: JSON-LD FAQPage structured data + dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} + /> + <Accordions type="single"> + {items.map((item) => ( + <Accordion key={item.title} title={item.title}> + {item.answer} + </Accordion> + ))} + </Accordions> + </> + ); +} diff --git a/src/components/faq/shared.tsx b/src/components/faq/shared.tsx new file mode 100644 index 0000000..5d5378a --- /dev/null +++ b/src/components/faq/shared.tsx @@ -0,0 +1,70 @@ +import type { FaqEntry } from "./index"; + +/** + * Product-wide FAQ answers that are not specific to any one integration. + * Import the entries you need into an integration overview page and mix them + * with integration-specific `FaqEntry` objects authored inline. + */ + +export const faqPrivacy: FaqEntry = { + title: "Can CipherStash ever see my data, or my encryption keys?", + answer: ( + <> + No, never. Encryption and decryption occur in your application, and keys + are derived in your environment. Plaintext and keys never leave your + control and never reach CipherStash. + </> + ), +}; + +export const faqScaling: FaqEntry = { + title: "How well does it scale?", + answer: ( + <> + Latency stays flat as data grows: exact-match lookups hold at ~0.1 ms and + range queries at ~0.5 ms, from 10k to 10M rows on the{" "} + <a + href="https://github.com/cipherstash/benches" + target="_blank" + rel="noreferrer" + > + cipherstash/benches + </a>{" "} + suite. + </> + ), +}; + +export const faqKms: FaqEntry = { + title: "Do I need to run a KMS or key vault?", + answer: ( + <> + No. Key management is built in through ZeroKMS. If you want to control the + root key, Bring Your Own Key (BYOK) lets you root it in your own KMS. + </> + ), +}; + +export const faqRlsVsEncryption: FaqEntry = { + title: "I already use Row Level Security. Do I need this?", + answer: ( + <> + RLS and CipherStash do different things, and they work best together. RLS + controls which rows someone can see, but the data itself is still + unencrypted, so if RLS is bypassed (a leaked key, a wrong policy, a + compromised database) the plaintext is exposed. CipherStash encrypts the + data and stores the keys separately, so even if someone gets around RLS, + the data stays safe. Use RLS to control access, and CipherStash to keep + the data protected. + </> + ), +}; + +export const faqFreeTier: FaqEntry = { + title: "Is there a free tier?", + answer: ( + <> + Yes, a free developer tier, so you can build encryption in from day one. + </> + ), +}; diff --git a/src/components/icons/drizzle.tsx b/src/components/icons/drizzle.tsx new file mode 100644 index 0000000..9c4ee5e --- /dev/null +++ b/src/components/icons/drizzle.tsx @@ -0,0 +1,37 @@ +export function DrizzleIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + viewBox="13 12.5 64 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + role="img" + aria-label="Drizzle" + {...props} + > + <rect + width="5.41766" + height="22.979" + transform="matrix(0.873028 0.48767 -0.497212 0.867629 24.459 24.3583)" + className="fill-black dark:fill-[#C5F74F]" + /> + <rect + width="5.41766" + height="22.979" + transform="matrix(0.873028 0.48767 -0.497212 0.867629 43.2793 12.6755)" + className="fill-black dark:fill-[#C5F74F]" + /> + <rect + width="5.41766" + height="22.979" + transform="matrix(0.873028 0.48767 -0.497212 0.867629 72.2383 12.676)" + className="fill-black dark:fill-[#C5F74F]" + /> + <rect + width="5.41766" + height="22.979" + transform="matrix(0.873028 0.48767 -0.497212 0.867629 53.4121 24.3583)" + className="fill-black dark:fill-[#C5F74F]" + /> + </svg> + ); +} diff --git a/src/components/icons/prisma.tsx b/src/components/icons/prisma.tsx new file mode 100644 index 0000000..f5e7f67 --- /dev/null +++ b/src/components/icons/prisma.tsx @@ -0,0 +1,19 @@ +export function PrismaIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + viewBox="0 0 58 72" + fill="none" + xmlns="http://www.w3.org/2000/svg" + role="img" + aria-label="Prisma" + {...props} + > + <path + fillRule="evenodd" + clipRule="evenodd" + d="M0.522473 45.0933C-0.184191 46.246 -0.173254 47.7004 0.550665 48.8423L13.6534 69.5114C14.5038 70.8529 16.1429 71.4646 17.6642 71.0082L55.4756 59.6648C57.539 59.0457 58.5772 56.7439 57.6753 54.7874L33.3684 2.06007C32.183 -0.511323 28.6095 -0.722394 27.1296 1.69157L0.522473 45.0933ZM32.7225 14.1141C32.2059 12.9187 30.4565 13.1028 30.2001 14.3796L20.842 60.9749C20.6447 61.9574 21.5646 62.7964 22.5248 62.5098L48.6494 54.7114C49.4119 54.4838 49.8047 53.6415 49.4891 52.9111L32.7225 14.1141Z" + className="fill-[#090A15] dark:fill-white" + /> + </svg> + ); +} diff --git a/src/components/icons/supabase.tsx b/src/components/icons/supabase.tsx index c6336f9..1b9b9bb 100644 --- a/src/components/icons/supabase.tsx +++ b/src/components/icons/supabase.tsx @@ -4,6 +4,8 @@ export function SupabaseIcon(props: React.SVGProps<SVGSVGElement>) { viewBox="0 0 109 113" fill="none" xmlns="http://www.w3.org/2000/svg" + role="img" + aria-label="Supabase" {...props} > <path @@ -44,5 +46,5 @@ export function SupabaseIcon(props: React.SVGProps<SVGSVGElement>) { </linearGradient> </defs> </svg> - ) + ); } diff --git a/src/components/integration-logos.tsx b/src/components/integration-logos.tsx index 2ea7c85..e679d23 100644 --- a/src/components/integration-logos.tsx +++ b/src/components/integration-logos.tsx @@ -5,6 +5,7 @@ export function PrismaLogo({ className }: { className?: string }) { fill="currentColor" xmlns="http://www.w3.org/2000/svg" className={className} + role="img" aria-label="Prisma" > <path d="M254.313 235.519 148.057 4.5a8.288 8.288 0 0 0-7.215-4.486 8.534 8.534 0 0 0-7.499 4.084L1.296 212.55a8.521 8.521 0 0 0 .1 9.282l54.915 82.731a8.518 8.518 0 0 0 9.687 3.396l181.79-59.7a8.518 8.518 0 0 0 5.282-4.79 8.524 8.524 0 0 0 1.243-7.95Zm-23.077 5.769-156.642 51.45c-2.871.943-5.704-1.689-4.876-4.573l54.45-189.751a3.453 3.453 0 0 1 6.541-.252l103.013 137.317a3.454 3.454 0 0 1-2.486 5.809Z" /> @@ -19,6 +20,8 @@ export function DrizzleLogo({ className }: { className?: string }) { fill="currentColor" xmlns="http://www.w3.org/2000/svg" className={className} + role="img" + aria-label="Drizzle" > <rect width="5.25365" @@ -65,6 +68,8 @@ export function SupabaseLogo({ className }: { className?: string }) { fill="none" xmlns="http://www.w3.org/2000/svg" className={className} + role="img" + aria-label="Supabase" > {/* Wordmark — uses currentColor */} <path @@ -121,6 +126,8 @@ export function DynamoDBLogo({ className }: { className?: string }) { height="446" viewBox="0 0 561 446" className={className} + role="img" + aria-label="DynamoDB" > <g fill="none" transform="translate(.311)"> {/* Wordmark — uses currentColor */} diff --git a/src/components/mermaid.tsx b/src/components/mermaid.tsx new file mode 100644 index 0000000..33d59d3 --- /dev/null +++ b/src/components/mermaid.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; + +/** + * Renders a Mermaid diagram authored as a ```mermaid code fence. + * + * The fence stays a code fence all the way through the markdown pipeline. The + * Shiki transformer in `source.config.ts` copies its source onto the `<pre>` as + * `data-mermaid`, and `TrackedCodeBlock` swaps in this component at render + * time. That ordering matters: `fumadocs-mdx` serializes the *same* mdast tree + * that it renders, so a remark plugin rewriting the fence into JSX would also + * rewrite the markdown we serve at `.mdx` and in llms.txt, where a diagram + * should degrade to readable source rather than an opaque component call. + * + * Mermaid is ~1MB, so it is imported dynamically inside the effect. Only pages + * that actually contain a diagram pay for it, and it never enters the server + * bundle. + */ +export function Mermaid({ chart }: { chart: string }) { + // `useId` yields colons, which are not valid in a DOM id passed to Mermaid. + const id = useId().replace(/[^a-zA-Z0-9]/g, ""); + const [svg, setSvg] = useState<string>(""); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function render() { + const mermaid = (await import("mermaid")).default; + const isDark = document.documentElement.classList.contains("dark"); + + mermaid.initialize({ + startOnLoad: false, + theme: isDark ? "dark" : "default", + // Diagram labels should match the surrounding prose, not Mermaid's + // default sans stack. + fontFamily: "inherit", + securityLevel: "strict", + }); + + try { + const { svg } = await mermaid.render(`mermaid-${id}`, chart); + if (!cancelled) setSvg(svg); + } catch { + // A malformed diagram falls back to its source rather than blanking the + // page. `scripts/validate-mermaid.ts` fails the build before this can + // reach production, so in practice this covers only the render step. + if (!cancelled) setFailed(true); + } + } + + render(); + + // Re-render on theme toggle: Mermaid bakes colours into the SVG. + const observer = new MutationObserver(render); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => { + cancelled = true; + observer.disconnect(); + }; + }, [chart, id]); + + if (failed) { + return ( + <pre className="my-6 overflow-x-auto rounded-lg border border-fd-border bg-fd-card p-4 text-sm"> + <code>{chart}</code> + </pre> + ); + } + + if (!svg) { + // Nothing to reserve: the diagram appears once Mermaid resolves, and a + // placeholder box would only add a layout jump. + return null; + } + + return ( + <div + className="my-6 flex justify-center overflow-x-auto rounded-lg border border-fd-border bg-fd-card p-4 [&_svg]:h-auto [&_svg]:max-w-full" + // Mermaid output, generated from our own content with securityLevel: strict. + // biome-ignore lint/security/noDangerouslySetInnerHtml: Mermaid returns an SVG string. + dangerouslySetInnerHTML={{ __html: svg }} + /> + ); +} diff --git a/src/components/zerokms-regions.tsx b/src/components/zerokms-regions.tsx new file mode 100644 index 0000000..1fd1617 --- /dev/null +++ b/src/components/zerokms-regions.tsx @@ -0,0 +1,30 @@ +import { ZEROKMS_REGIONS } from "@/lib/zerokms-regions"; + +/** + * Renders the supported ZeroKMS regions. Used by every page that lists regions + * so the list is maintained in exactly one place (`@/lib/zerokms-regions`). + */ +export function ZeroKmsRegions() { + return ( + <table> + <thead> + <tr> + <th>Area</th> + <th>Location</th> + <th>Region identifier</th> + </tr> + </thead> + <tbody> + {ZEROKMS_REGIONS.map((region) => ( + <tr key={region.id}> + <td>{region.area}</td> + <td>{region.location}</td> + <td> + <code>{region.id}</code> + </td> + </tr> + ))} + </tbody> + </table> + ); +} diff --git a/src/lib/eql-version.ts b/src/lib/eql-version.ts new file mode 100644 index 0000000..58f5f8a --- /dev/null +++ b/src/lib/eql-version.ts @@ -0,0 +1,4 @@ +// GENERATED by scripts/generate-eql-api-docs.ts from the EQL release manifest. +// Do not edit; the prebuild step overwrites it with the version of the EQL +// release the docs are built against. +export const EQL_VERSION = "3.0.0-sample"; diff --git a/src/lib/layout.shared.tsx b/src/lib/layout.shared.tsx index cba3da9..f0505ad 100644 --- a/src/lib/layout.shared.tsx +++ b/src/lib/layout.shared.tsx @@ -9,11 +9,13 @@ export const gitConfig = { function Logo() { return ( <div className="flex items-center gap-3"> + {/* biome-ignore lint/performance/noImgElement: static SVG logo; next/image gives no benefit for SVGs and would need extra config */} <img src="/docs/images/cipherstash-logo-dark.svg" alt="CipherStash" className="h-5 w-5 hidden dark:block" /> + {/* biome-ignore lint/performance/noImgElement: static SVG logo; next/image gives no benefit for SVGs and would need extra config */} <img src="/docs/images/cipherstash-logo-light.svg" alt="CipherStash" diff --git a/src/lib/posthog/provider.tsx b/src/lib/posthog/provider.tsx index 711982a..9d7a5cb 100644 --- a/src/lib/posthog/provider.tsx +++ b/src/lib/posthog/provider.tsx @@ -1,13 +1,13 @@ "use client"; -import posthog from "posthog-js"; import { usePathname, useSearchParams } from "next/navigation"; +import posthog from "posthog-js"; import { - Suspense, createContext, + type ReactNode, + Suspense, useContext, useEffect, - type ReactNode, } from "react"; const PostHogContext = createContext<typeof posthog | null>(null); diff --git a/src/lib/shiki-themes.ts b/src/lib/shiki-themes.ts new file mode 100644 index 0000000..bb60543 --- /dev/null +++ b/src/lib/shiki-themes.ts @@ -0,0 +1,132 @@ +interface CodePalette { + bg: string; + fg: string; + com: string; + kw: string; + fn: string; + typ: string; + str: string; + num: string; + pun: string; +} + +function cipherstashTheme( + name: string, + type: "light" | "dark", + c: CodePalette, +) { + return { + name, + type, + colors: { + "editor.background": c.bg, + "editor.foreground": c.fg, + }, + settings: [ + { settings: { background: c.bg, foreground: c.fg } }, + { + scope: ["comment", "punctuation.definition.comment", "string.comment"], + settings: { foreground: c.com, fontStyle: "italic" }, + }, + { + scope: [ + "keyword", + "keyword.control", + "keyword.other", + "keyword.operator.new", + "keyword.operator.expression", + "keyword.operator.logical", + "storage", + "storage.type", + "storage.modifier", + "variable.language", + "entity.name.tag", + "punctuation.definition.tag", + ], + settings: { foreground: c.kw }, + }, + { + scope: [ + "entity.name.function", + "support.function", + "meta.function-call", + "meta.function-call.generic", + "variable.function", + ], + settings: { foreground: c.fn }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.type", + "support.class", + "entity.other.inherited-class", + "entity.name.namespace", + "support.type.property-name", + "meta.object-literal.key", + "entity.other.attribute-name", + ], + settings: { foreground: c.typ }, + }, + { + scope: [ + "string", + "string.quoted", + "string.template", + "string.regexp", + "punctuation.definition.string", + ], + settings: { foreground: c.str }, + }, + { + scope: [ + "constant.numeric", + "constant.language", + "constant.character", + "constant.other", + "support.constant", + ], + settings: { foreground: c.num }, + }, + { + scope: [ + "punctuation", + "keyword.operator", + "meta.brace", + "punctuation.separator", + "punctuation.terminator", + ], + settings: { foreground: c.pun }, + }, + { + scope: ["variable", "variable.other", "variable.parameter"], + settings: { foreground: c.fg }, + }, + ], + }; +} + +export const cipherstashDark = cipherstashTheme("cipherstash-dark", "dark", { + bg: "#0b0b0a", + fg: "#eae8dd", + com: "#8f8f8f", + kw: "#d77595", + fn: "#d2a8ff", + typ: "#7fd0c4", + str: "#c8f031", + num: "#f4dd63", + pun: "#9a9486", +}); + +export const cipherstashLight = cipherstashTheme("cipherstash-light", "light", { + bg: "#faf9f4", + fg: "#2b2822", + com: "#7a7a7a", + kw: "#a63057", + fn: "#8250df", + typ: "#1c8577", + str: "#567d0d", + num: "#977c11", + pun: "#6b6559", +}); diff --git a/src/lib/source.ts b/src/lib/source.ts index 97ae84f..7c990b3 100644 --- a/src/lib/source.ts +++ b/src/lib/source.ts @@ -1,10 +1,15 @@ -import { docs } from "fumadocs-mdx:collections/server"; +import { docs, v2docs } from "fumadocs-mdx:collections/server"; +import type * as PageTree from "fumadocs-core/page-tree"; import { type InferPageType, loader } from "fumadocs-core/source"; -import { createElement } from "react"; import { icons } from "lucide-react"; +import { createElement } from "react"; +import { DrizzleIcon } from "@/components/icons/drizzle"; +import { PrismaIcon } from "@/components/icons/prisma"; import { SupabaseIcon } from "@/components/icons/supabase"; const customIcons: Record<string, () => React.ReactElement> = { + Drizzle: () => createElement(DrizzleIcon, { width: 16, height: 16 }), + Prisma: () => createElement(PrismaIcon, { width: 16, height: 16 }), Supabase: () => createElement(SupabaseIcon, { width: 16, height: 16 }), }; @@ -23,6 +28,68 @@ export const source = loader({ icon: resolveIcon, }); +// V2 IA tree (CIP-3325): content/docs served from the site root, e.g. +// /docs/get-started/quickstart. Lives alongside the legacy `source` during +// the migration; the legacy loader and /stack routes are deleted at the end. +export const v2source = loader({ + baseUrl: "/", + source: v2docs.toFumadocsSource(), + icon: resolveIcon, +}); + +// Sidebar folders whose only page is their index render with a collapse +// chevron pointing at nothing. Collapse such folders into plain page items; +// they become folders again automatically once real sub-pages land. +function flattenEmptyFolders(nodes: PageTree.Node[]): PageTree.Node[] { + return nodes.map((node) => { + if (node.type !== "folder") return node; + const children = flattenEmptyFolders(node.children); + if (children.length === 0 && node.index) { + return { ...node.index, icon: node.index.icon ?? node.icon }; + } + return { ...node, children }; + }); +} + +// The sidebar label comes from a page's `title`, which is also its H1. A +// section index wants a short nav label ("Overview") under a folder that +// already names the section, while keeping the descriptive H1. `navTitle` +// frontmatter overrides the label only; the URL and H1 are untouched. +function applyNavTitles(nodes: PageTree.Node[]): PageTree.Node[] { + const navTitles = new Map<string, string>(); + for (const page of v2source.getPages()) { + const navTitle = page.data.navTitle; + if (navTitle) navTitles.set(page.url, navTitle); + } + if (navTitles.size === 0) return nodes; + + const rename = (list: PageTree.Node[]): PageTree.Node[] => + list.map((node) => { + if (node.type === "folder") { + return { + ...node, + index: node.index + ? (rename([node.index])[0] as typeof node.index) + : undefined, + children: rename(node.children), + }; + } + if (node.type !== "page") return node; + const navTitle = navTitles.get(node.url); + return navTitle ? { ...node, name: navTitle } : node; + }); + + return rename(nodes); +} + +export function getV2PageTree(): PageTree.Root { + const tree = v2source.getPageTree(); + return { + ...tree, + children: applyNavTitles(flattenEmptyFolders(tree.children)), + }; +} + export function getPageImage(page: InferPageType<typeof source>) { const segments = [...page.slugs, "image.png"]; @@ -32,7 +99,9 @@ export function getPageImage(page: InferPageType<typeof source>) { }; } -export async function getLLMText(page: InferPageType<typeof source>) { +export async function getLLMText( + page: InferPageType<typeof source> | InferPageType<typeof v2source>, +) { const processed = await page.data.getText("processed"); return `# ${page.data.title} diff --git a/src/lib/zerokms-regions.ts b/src/lib/zerokms-regions.ts new file mode 100644 index 0000000..9efe432 --- /dev/null +++ b/src/lib/zerokms-regions.ts @@ -0,0 +1,26 @@ +/** + * The ZeroKMS regions a workspace can be deployed into. Single source of truth: + * rendered by the `<ZeroKmsRegions />` MDX component on every page that lists + * regions, so the list can't drift between the regions reference and the + * data-residency page. + * + * `id` is the region component of a workspace CRN + * (`crn:<id>:<workspace-id>`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`). + * The `.aws` suffix is part of the identifier: ZeroKMS is deployed only on AWS + * today, and the suffix leaves room for other cloud providers later. + */ +export type ZeroKmsRegion = { + area: string; + location: string; + id: string; +}; + +export const ZEROKMS_REGIONS: ZeroKmsRegion[] = [ + { area: "Asia Pacific", location: "Sydney", id: "ap-southeast-2.aws" }, + { area: "Europe", location: "Frankfurt", id: "eu-central-1.aws" }, + { area: "Europe", location: "Ireland", id: "eu-west-1.aws" }, + { area: "US East", location: "N. Virginia", id: "us-east-1.aws" }, + { area: "US East", location: "Ohio", id: "us-east-2.aws" }, + { area: "US West", location: "N. California", id: "us-west-1.aws" }, + { area: "US West", location: "Oregon", id: "us-west-2.aws" }, +]; diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx index ba31fd2..122f68c 100644 --- a/src/mdx-components.tsx +++ b/src/mdx-components.tsx @@ -1,8 +1,43 @@ -import { Callout } from "fumadocs-ui/components/callout"; +import { Callout as FumaCallout } from "fumadocs-ui/components/callout"; import { Step, Steps } from "fumadocs-ui/components/steps"; import defaultMdxComponents from "fumadocs-ui/mdx"; import type { MDXComponents } from "mdx/types"; +import type { ComponentProps } from "react"; +import { BadExample } from "@/components/bad-example"; +import { CipherStashCredentials } from "@/components/cipherstash-credentials"; import { TrackedCodeBlock } from "@/components/code-block"; +import { EqlFn } from "@/components/eql-fn"; +import { EqlVersion } from "@/components/eql-version"; +import { Faq } from "@/components/faq"; +import { ZeroKmsRegions } from "@/components/zerokms-regions"; + +/** + * Callouts keep Fumadocs' own body colour — `text-fd-muted-foreground`, the + * same token the sidebar's resting nav items use — so they sit at the same + * weight as the rest of the chrome rather than shouting. This wrapper only: + * + * - doubles the padding, `p-3 ps-1` → `p-6 ps-2`. `ps` stays proportionally + * small because the accent bar is the container's first child and sits + * near the edge; + * - adds a `data-callout` attribute, which Fumadocs does not emit. global.css + * has had a `[data-callout]` rule since the theme work that consequently + * matched nothing. + * + * Fumadocs' `Callout` still resolves the `warn`/`tip` aliases, picks the icon + * and renders the title, and it spreads unknown props onto the container, so + * there is nothing here to reimplement. + */ +function Callout({ className, ...props }: ComponentProps<typeof FumaCallout>) { + // CalloutContainer runs its own `cn`, so these merge against its defaults; + // a caller's className comes last and still wins. + return ( + <FumaCallout + data-callout + className={`p-6 ps-4 ${className ?? ""}`} + {...props} + /> + ); +} export function getMDXComponents(components?: MDXComponents): MDXComponents { return { @@ -13,6 +48,12 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { Callout, Steps, Step, + BadExample, + CipherStashCredentials, + EqlVersion, + EqlFn, + ZeroKmsRegions, + Faq, ...components, }; } diff --git a/src/proxy.ts b/src/proxy.ts index 028dd8f..5d45773 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,5 @@ -import { NextResponse } from "next/server"; import type { NextFetchEvent, NextRequest } from "next/server"; +import { NextResponse } from "next/server"; import { getPostHogClient } from "@/lib/posthog/server"; const SKIP_PATHS = ["/api", "/_next/static", "/_next/image", "/ingest"]; diff --git a/v2-redirects.mjs b/v2-redirects.mjs new file mode 100644 index 0000000..20dba75 --- /dev/null +++ b/v2-redirects.mjs @@ -0,0 +1,444 @@ +// V2 IA redirect map (CIP-3325): every legacy /stack/* URL → its new home. +// Derived from the migration map in IA.md; completeness is enforced by +// `scripts/validate-v2-redirects.ts` (every content/stack page must match an +// entry here, exact or wildcard). +// +// Gated behind ENABLE_V2_REDIRECTS=1 in next.config.mjs: during the migration +// the preview site serves BOTH trees (legacy at /stack, v2 at the root), so +// unmigrated content stays reachable. The flag flips on at merge; once +// content/stack is deleted these entries become unconditional (CIP-3335). +// +// Conventions (matching next.config.mjs): sources/destinations omit the +// "/docs" basePath. Order matters — specific entries before wildcards. +// +// All entries are `permanent: false` (307) while the IA settles — browsers +// and crawlers cache 308s aggressively, and a mis-cached destination is hard +// to walk back. Flip to permanent once the map has soaked post-merge +// (CIP-3335). +export const v2Redirects = [ + // === Roots === + { source: "/stack", destination: "/", permanent: false }, + { + source: "/stack/quickstart", + destination: "/get-started/quickstart", + permanent: false, + }, + { source: "/stack/cipherstash", destination: "/", permanent: false }, + { + source: "/stack/cipherstash/postgres", + destination: "/reference/eql", + permanent: false, + }, + { + source: "/stack/cipherstash/supabase", + destination: "/integrations/supabase", + permanent: false, + }, + + // === Encryption SDK section → Reference/stack + new homes === + { + source: "/stack/cipherstash/encryption", + destination: "/reference/stack", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/searchable-encryption", + destination: "/concepts/searchable-encryption", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/identity", + destination: "/solutions/provable-access", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/drizzle", + destination: "/integrations/drizzle", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/prisma-next", + destination: "/integrations/prisma", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/dynamodb", + destination: "/integrations/aws/dynamodb", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/supabase", + destination: "/integrations/supabase", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/indexes", + destination: "/reference/eql/indexes", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/queries", + destination: "/reference/eql/filtering", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/configuration", + destination: "/reference/workspace/configuration", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/encrypt-decrypt", + destination: "/reference/stack/usage", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/storing-data", + destination: "/reference/stack/usage", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/schema", + destination: "/reference/stack/api-reference/schema", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/models", + destination: "/reference/stack/api-reference/encryption", + permanent: false, + }, + { + source: "/stack/cipherstash/encryption/bulk-operations", + destination: "/reference/stack/api-reference/encryption", + permanent: false, + }, + // Any retired Encryption SDK leaf falls back to the current usage guide. + { + source: "/stack/cipherstash/encryption/:path*", + destination: "/reference/stack/usage", + permanent: false, + }, + + // === KMS section → Security + Reference/auth + Concepts === + { + source: "/stack/cipherstash/kms", + destination: "/concepts/key-management", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/cts", + destination: "/security/cts", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/oidc", + destination: "/reference/auth/oidc-configuration", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/access-keys", + destination: "/reference/auth/access-keys", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/clients", + destination: "/reference/auth/clients", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/disaster-recovery", + destination: "/concepts/key-management", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/keysets", + destination: "/concepts/key-management", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/regions", + destination: "/solutions/data-residency", + permanent: false, + }, + { + source: "/stack/cipherstash/kms/configuration", + destination: "/reference/workspace/configuration", + permanent: false, + }, + + // === Proxy section → Reference/proxy + new homes === + { + source: "/stack/cipherstash/proxy", + destination: "/reference/proxy", + permanent: false, + }, + { + source: "/stack/cipherstash/proxy/audit", + destination: "/security/audit-logging", + permanent: false, + }, + { + source: "/stack/cipherstash/proxy/getting-started", + destination: "/reference/proxy", + permanent: false, + }, + { + source: "/stack/cipherstash/proxy/encrypt-tool", + destination: "/guides/migration", + permanent: false, + }, + { + source: "/stack/cipherstash/proxy/searchable-json", + destination: "/reference/eql/json", + permanent: false, + }, + { + source: "/stack/cipherstash/proxy/troubleshooting", + destination: "/reference/proxy/errors", + permanent: false, + }, + // configuration, message-flow, multitenant + { + source: "/stack/cipherstash/proxy/:path*", + destination: "/reference/proxy/:path*", + permanent: false, + }, + + // === CLI section → Reference/cli === + { + source: "/stack/cipherstash/cli", + destination: "/reference/cli", + permanent: false, + }, + { + source: "/stack/cipherstash/cli/troubleshooting", + destination: "/reference/cli/doctor", + permanent: false, + }, + { + source: "/stack/cipherstash/cli/api", + destination: "/reference/cli", + permanent: false, + }, + { + source: "/stack/cipherstash/cli/install", + destination: "/reference/cli/eql", + permanent: false, + }, + { + source: "/stack/cipherstash/cli/push", + destination: "/reference/cli", + permanent: false, + }, + { + source: "/stack/cipherstash/cli/validate", + destination: "/reference/cli/db", + permanent: false, + }, + { + source: "/stack/cipherstash/cli/:path*", + destination: "/reference/cli/:path*", + permanent: false, + }, + + // === Deploy section → Guides === + { + source: "/stack/deploy", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/going-to-production", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/aws-ecs", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/bundling", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/sst", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/testing", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/team-onboarding", + destination: "/guides/deployment", + permanent: false, + }, + { + source: "/stack/deploy/troubleshooting", + destination: "/guides/deployment", + permanent: false, + }, + + // === Reference section === + { + source: "/stack/reference", + destination: "/reference/eql", + permanent: false, + }, + { + source: "/stack/reference/what-is-cipherstash", + destination: "/get-started/what-is-cipherstash", + permanent: false, + }, + { + source: "/stack/reference/security-architecture", + destination: "/security/cryptography", + permanent: false, + }, + { + source: "/stack/reference/compliance", + destination: "/security/compliance", + permanent: false, + }, + { + source: "/stack/reference/comparisons", + destination: "/concepts/compare", + permanent: false, + }, + { + source: "/stack/reference/comparisons/:path*", + destination: "/concepts/compare/:path*", + permanent: false, + }, + { + source: "/stack/reference/use-cases", + destination: "/solutions", + permanent: false, + }, + { + // The AI/RAG page is not part of the v2 tree yet (it needs a rewrite before + // it can be republished), so send its legacy URL to the Solutions index + // rather than a page that does not exist. + source: "/stack/reference/use-cases/ai-rag", + destination: "/solutions/ai-and-rag", + permanent: false, + }, + { + source: "/stack/reference/use-cases/compliance", + destination: "/security/compliance", + permanent: false, + }, + { + source: "/stack/reference/use-cases/:path*", + destination: "/solutions/:path*", + permanent: false, + }, + { + source: "/stack/reference/billing", + destination: "/reference/workspace/billing", + permanent: false, + }, + { + source: "/stack/reference/members", + destination: "/reference/workspace/members", + permanent: false, + }, + { + source: "/stack/reference/cipher-cell", + destination: "/reference/eql/core-concepts", + permanent: false, + }, + { + source: "/stack/reference/eql-guide", + destination: "/reference/eql", + permanent: false, + }, + { + source: "/stack/reference/eql", + destination: "/reference/eql", + permanent: false, + }, + { + source: "/stack/reference/eql/:path*", + destination: "/reference/eql/:path*", + permanent: false, + }, + { + source: "/stack/reference/encryption-sdk", + destination: "/reference/stack", + permanent: false, + }, + { + source: "/stack/reference/error-handling", + destination: "/reference/stack/api-reference/errors", + permanent: false, + }, + // NOTE: legacy "migration" page is the @cipherstash/protect→stack package + // rename guide, NOT data migration (see IA.md). + { + source: "/stack/reference/migration", + destination: "/reference/stack/usage", + permanent: false, + }, + { + source: "/stack/reference/proxy-errors", + destination: "/reference/proxy/errors", + permanent: false, + }, + { + source: "/stack/reference/proxy-reference", + destination: "/reference/proxy/configuration", + permanent: false, + }, + { + source: "/stack/reference/drizzle", + destination: "/integrations/drizzle", + permanent: false, + }, + { + source: "/stack/reference/dashboard-supabase-integration", + destination: "/integrations/supabase", + permanent: false, + }, + { + source: "/stack/reference/discovery-session", + destination: "/get-started/choose-your-stack", + permanent: false, + }, + { + source: "/stack/reference/planning-guide", + destination: "/get-started/choose-your-stack", + permanent: false, + }, + { + source: "/stack/reference/supported-solutions", + destination: "/integrations", + permanent: false, + }, + { + source: "/stack/reference/agent-skills", + destination: "/reference/agent-skills", + permanent: false, + }, + { + source: "/stack/reference/glossary", + destination: "/reference/glossary", + permanent: false, + }, + // Generated TypeDoc API reference (scripts/generate-docs.ts output) + // The legacy generator placed the Supabase wrapper beneath Stack. Its v2 + // reference is owned by the Supabase integration instead. + { + source: "/stack/reference/stack/latest/packages/stack-supabase/:path*", + destination: "/integrations/supabase/api-reference", + permanent: false, + }, + { + source: "/stack/reference/stack/:path*", + destination: "/reference/stack/api-reference", + permanent: false, + }, +];