Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .changeset/stash-postgres-edge-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
'stash': minor
'@cipherstash/wizard': minor
'@cipherstash/stack': patch
---

Two new bundled agent skills for the integrations that don't use an ORM —
`stash-postgres` and `stash-edge` (#754).

Everything a raw-SQL or edge integration needed was reachable only from
`dist/*.d.ts` JSDoc, the Postgres catalog, or experiment: grepping the skills
`stash init` installs for `postgres-js|::jsonb::eql|sql.json|query_text_search`
returned a single hit, in an unrelated code comment.

**`stash-postgres`** — hand-written SQL over `pg` / `postgres-js`, no ORM. The
column-domain-to-query-domain operator matrix (which of `=`, `<>`, `<`, `>=`,
`@@`, `@>` each encrypted domain accepts, and against which `eql_v3.query_*`
operand), the storage-vs-query payload distinction, per-driver parameter
binding, recipes for equality / free-text / range / `ORDER BY` / JSON
containment / JSON field selectors, and the `information_schema` drift check.
Two failure modes get their mechanism spelled out: pre-stringifying a payload
on postgres-js double-encodes it into a jsonb *string* scalar, tripping the
domain CHECK with a message naming neither JSON nor encoding; and leaving an
operand as bare `jsonb` silently selects a different operator overload — one
that coerces to the *storage* domain and so rejects the ciphertext-free query
term. It also scopes itself against the two things a hand-written-SQL reader
is otherwise left to infer: **CipherStash Proxy** (where you write plaintext
SQL and none of the skill applies — the `usesProxy` fork `stash init` already
asked about), and the provenance of the operator surface itself (the EQL
bundle from `cipherstash/encrypt-query-language`, version-checkable with
`SELECT eql_v3.version()`, and where operator gaps should be filed). Its
domain and operator tables are explicitly marked as a snapshot of a versioned
surface, with a ranked list of authorities to confirm current types against —
the EQL skill first, then the generated `@cipherstash/eql` types and install
SQL, both of which need only `node_modules` and no database.

**`stash-edge`** — the `@cipherstash/stack/wasm-inline` entry for Deno,
Supabase Edge Functions, Cloudflare Workers, and Bun. Import specifier per
runtime, the four mandatory `CS_*` variables and minting them with
`stash env`, how the WASM client surface differs from the native typed client
(no `.audit()`, no `.withLockContext()`, per-item bulk shape, a required
`table` argument on `decryptModel` / `bulkDecryptModels`, ESM-only), and the
auth-strategy `Result` that must be unwrapped before it reaches
`config.authStrategy`.

It also separates the two mechanisms behind identity-bound encryption, which
are routinely conflated — and which the source comment on the entry itself got
wrong. An auth strategy decides *who the client is*; a lock context decides
*which key the value is encrypted under*. Only the first exists on this entry,
so an `authStrategy` alone still writes values encrypted under the workspace
key, and the entry cannot read what the native one wrote under a lock context.
That is a silent read split between the two entries, and the skill says so
rather than leaving it to be discovered as a failed decrypt.

Both carry **the credential-identity rule**, a silent data-loss footgun now
also stated in `stash-cli` (under `env` and `encrypt backfill`) and
`stash-supabase`: EQL index terms derive from the ZeroKMS client key, so rows
written under one credential and queried under another decrypt correctly and
never match a query, with no error.

`stash-encryption` now states that the two entries' schema types **do not
interchange** — their column classes carry private fields, so TypeScript
compares them nominally and rejects a shared schema module in both directions.
It works at runtime, which makes a type assertion the tempting fix; the
guidance is to author the schema against exactly one entry instead.

`stash init` / `stash impl` handoffs and the `@cipherstash/wizard` skills
prompt install both skills for the `postgresql` and `supabase` integrations.
Drizzle and Prisma Next get cross-links from their own skills instead, since
those integrations emit correctly-typed operands themselves.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Also fixes the `@cipherstash/stack/wasm-inline` module JSDoc, which showed
`OidcFederationStrategy.create(...)`'s `Result` being passed straight to
`config.authStrategy` without unwrapping — the same JSDoc the raw-SQL surface
was being reverse-engineered from.
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ If these variables are missing, tests that require live encryption will fail or
- `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README)
- `examples/*`: Working apps (basic, prisma, supabase-worker)
- `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo).
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)
- `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma-next`, `stash-supply-chain-security`)

## Agent Skills — these ship to customers

Expand Down Expand Up @@ -113,6 +113,8 @@ nothing type-checks them, and the damage lands in a customer's repo, not ours.
| Drizzle / Supabase / Prisma Next / DynamoDB integrations | `skills/stash-drizzle`, `skills/stash-supabase`, `skills/stash-prisma-next`, `skills/stash-dynamodb` |
| The rollout/cutover lifecycle (`packages/migrate`, `stash encrypt *`) | `skills/stash-encryption` and `skills/stash-cli` |
| The `@cipherstash/eql` pin, `eql install`/`eql migration` behaviour, or index-related SQL guidance | `skills/stash-indexing` |
| The EQL operator/domain surface (`eql_v3.query_*` casts, predicate forms) | `skills/stash-postgres` |
| `packages/stack/src/wasm-inline.ts`, the WASM entry's exports, or `stash env` | `skills/stash-edge` |
| pnpm config, CI workflows, dependency policy | `skills/stash-supply-chain-security` |
| The durable agent rules themselves | `packages/cli/src/commands/init/doctrine/AGENTS-doctrine.md` |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ describe('SKILL_MAP', () => {
expect(SKILL_MAP.postgresql).not.toContain('stash-supabase')
expect(SKILL_MAP.postgresql).not.toContain('stash-prisma-next')
})

// #754: the no-ORM path had no source for the raw-SQL binding surface or
// the WASM entry — an integration on it had to reverse-engineer both from
// `dist/*.d.ts` and the Postgres catalog. `postgresql` is that path;
// Supabase shares it (Edge Functions are the flagship WASM-entry use, and
// its migrations/RPC are hand-written SQL).
it.each([
'postgresql',
'supabase',
] as const)('%s includes the raw-SQL and edge skills', (integration) => {
expect(SKILL_MAP[integration]).toContain('stash-postgres')
expect(SKILL_MAP[integration]).toContain('stash-edge')
})

// The ORM integrations emit correctly-typed operands themselves, so they
// get cross-links from their own skills rather than the full install.
it.each([
'drizzle',
'prisma-next',
] as const)('%s does not install the raw-SQL skill', (integration) => {
expect(SKILL_MAP[integration]).not.toContain('stash-postgres')
})
})

describe('skillsFor', () => {
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/commands/init/lib/install-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,16 @@ import { findBundledDir } from './bundled-paths.js'
*/
export const SKILL_MAP: Record<Integration, readonly string[]> = {
drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'],
// Supabase gets the raw-SQL and edge skills on top of its own: Edge
// Functions are the flagship use of the WASM entry, and Supabase projects
// write hand-written SQL in migrations and RPC even when the app itself
// goes through PostgREST (#754).
supabase: [
'stash-encryption',
'stash-supabase',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
'prisma-next': [
Expand All @@ -24,7 +30,16 @@ export const SKILL_MAP: Record<Integration, readonly string[]> = {
'stash-indexing',
'stash-cli',
],
postgresql: ['stash-encryption', 'stash-indexing', 'stash-cli'],
// The no-ORM path: `stash-postgres` (binding + predicate forms) and `stash-edge`
// (WASM entry, CS_* credentials) are the two skills this integration has no
// other source for — everything else assumes an ORM emits the operands.
postgresql: [
'stash-encryption',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
}

/** The skills every integration gets — the safe fallback for an unmapped one. */
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/init/lib/setup-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ const SKILL_PURPOSES: Record<string, string> = {
'Prisma Next-specific patterns: `cipherstash.*` field constructors, migration flow, encrypted query operators',
'stash-indexing':
'index recipes for encrypted columns — the `eql_v3` extractor functional indexes, Supabase/managed-Postgres constraints, EXPLAIN verification',
'stash-postgres':
'hand-written Postgres SQL over `pg` / `postgres-js`: the encrypted predicate matrix, `eql_v3.query_*` operand casts, per-driver parameter binding',
'stash-edge':
'the `@cipherstash/stack/wasm-inline` entry for Deno / Supabase Edge Functions / Workers: imports, `CS_*` credentials, the credential-identity rule',
'stash-dynamodb':
'DynamoDB encryption: per-item encrypt/decrypt, HMAC attribute keys, audit logging',
'stash-cli':
Expand Down
40 changes: 33 additions & 7 deletions packages/stack/src/wasm-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,24 @@
* import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline"
* import { cookieStore } from "@cipherstash/auth/cookies"
*
* const authStrategy = OidcFederationStrategy.create(
* // `create` returns a Result — UNWRAP it. `config.authStrategy` takes the
* // strategy itself; handing it the Result fails later and opaquely.
* const strategy = OidcFederationStrategy.create(
* "crn:ap-southeast-2.aws:my-workspace-id", () => getClerkSessionToken(req),
* { store: cookieStore({ request: req, responseHeaders }) },
* )
* const client = await Encryption({ schemas, config: { authStrategy, clientId, clientKey } })
* if (strategy.failure) throw new Error(strategy.failure.error.message)
*
* const client = await Encryption({
* schemas, config: { authStrategy: strategy.data, clientId, clientKey },
* })
* ```
*
* For service-to-service / CI use with a custom token store, build an
* `AccessKeyStrategy.create(workspaceCrn, accessKey, { store })` the same
* way (it derives the region from the CRN). Both strategies are
* re-exported from this entry.
* way — same Result-unwrapping, and it derives the region from the CRN. Both
* strategies are re-exported from this entry. An auth strategy and
* `config.accessKey` are mutually exclusive.
*/

import { withResult } from '@byteslice/result'
Expand Down Expand Up @@ -655,9 +662,28 @@ const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client')
* ZeroKMS round trip regardless of how many fields or models it covers. What
* still differs from the native surface is deliberate and local: failures come
* back as this entry's `{ failure }` Results (rather than a thenable operation
* with `.audit()`), and there is no `.withLockContext()` — identity-bound
* encryption on the edge is configured at client construction via
* `config.authStrategy` instead (#663 context).
* with `.audit()`).
*
* There is also no `.withLockContext()` here, and that one is a **gap, not a
* design decision** (#797). An earlier version of this comment said
* identity-bound encryption is "configured at client construction via
* `config.authStrategy` instead" (#663 context). That is wrong, and the
* conflation is worth naming because it is the one people arrive with:
* an auth strategy decides WHO THE CLIENT IS; a lock context decides WHICH
* KEY THE VALUE IS ENCRYPTED UNDER. They are orthogonal, and only the first
* exists on this entry.
*
* The consequences are silent, which is why they are stated here rather than
* left to a failed decrypt: values written from this entry are encrypted under
* the workspace key even when the client is authenticated as an end user, and
* this entry cannot read anything the native entry wrote under a lock context,
* since decrypt requires the same context. `skills/stash-edge` documents both.
*
* The binding accepts a lock context on both paths — `lockContext` is an
* option field on the single calls and per-payload-item on the bulk ones — so
* closing this is plumbing rather than a new capability. It is not plumbed
* here yet, and shipping it would want a live round-trip test first: a wrong
* or missing claim surfaces as a failed decrypt, not a key error.
*
* Construct via {@link Encryption} — the constructor is private to
* prevent callers from wrapping arbitrary objects in this type.
Expand Down
12 changes: 11 additions & 1 deletion packages/wizard/src/lib/install-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,24 @@ import type { Integration } from './types.js'
*/
const SKILL_MAP: Record<Integration, readonly string[]> = {
drizzle: ['stash-encryption', 'stash-drizzle', 'stash-indexing', 'stash-cli'],
// `stash-postgres` / `stash-edge` mirror the CLI's SKILL_MAP — see the comments
// there for why Supabase and the generic (no-ORM) path get them (#754).
supabase: [
'stash-encryption',
'stash-supabase',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
prisma: ['stash-encryption', 'stash-indexing', 'stash-cli'],
generic: ['stash-encryption', 'stash-indexing', 'stash-cli'],
generic: [
'stash-encryption',
'stash-indexing',
'stash-postgres',
'stash-edge',
'stash-cli',
],
}

/**
Expand Down
10 changes: 10 additions & 0 deletions skills/stash-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,8 @@ Backfill **detects a `public.eql_v3_*` target column as EQL v3** from its Postgr

**Dual-write precondition.** The application must already write both `<col>` and `<col>_encrypted` on every insert and update. Otherwise rows written *during* the backfill land in plaintext only, silently. The first run prompts (interactive) or requires `--confirm-dual-writes-deployed` (non-interactive), then records `dual_writing`. Resumes don't re-prompt.

**Credential precondition — run the backfill with the *application's* credentials.** Backfill encrypts through whichever `CS_*` credentials are in its environment, and EQL index terms derive from the ZeroKMS client key. Backfilling from a laptop on the local device profile, then querying from an app using credentials minted by `stash env`, produces rows that decrypt correctly and **never match a query** — with no error. Export the target environment's `CS_*` values in the shell running the backfill. See [`env`](#env) and `stash-edge` § The Credential-Identity Rule.

| Flag | Description |
|---|---|
| `--table` / `--column` | Required |
Expand Down Expand Up @@ -516,6 +518,14 @@ CS_CLIENT_KEY=<hex>
CS_CLIENT_ACCESS_KEY=CSAK…
```

> **Every writer of a searchable column must use these same credentials** —
> including `stash encrypt backfill`, seed scripts, and admin tools — or their
> rows decrypt but never match a query. EQL index terms derive from the ZeroKMS
> client key, so a row written under one credential and queried under another
> decrypts correctly and silently fails every search. Mint one credential per
> environment and export it for **every** process that writes that
> environment's data. See `stash-edge` § The Credential-Identity Rule.

Things to know:

- **The access key is shown exactly once** — CTS cannot re-reveal it. Pipe the
Expand Down
2 changes: 2 additions & 0 deletions skills/stash-drizzle/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ import { index } from "drizzle-orm/pg-core"

Run `ANALYZE <table>` after the migration applies — an expression index gathers no statistics at `CREATE INDEX` time. For when to create indexes during a rollout (after backfill, before switching reads), engagement rules, and `EXPLAIN` verification, see the `stash-indexing` skill.

> **Dropping to raw SQL?** ``db.execute(sql`…`)`` bypasses the operators this integration emits, so you own the operand casts yourself — an encrypted predicate needs its needle cast to the column's `eql_v3.query_*` domain, and the driver's parameter-binding rules differ between `pg` and `postgres-js`. The `stash-postgres` skill is the reference for both.

## Migrating an Existing Column to Encrypted

The hard case: a Drizzle table that already exists in production with live data in a plaintext column you want to encrypt. You can't just change the column type — that would drop the data and break NOT NULL constraints.
Expand Down
Loading