Skip to content
Open
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
56 changes: 56 additions & 0 deletions .changeset/stash-sql-edge-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'stash': minor
'@cipherstash/wizard': minor
'@cipherstash/stack': patch
---

Two new bundled agent skills for the integrations that don't use an ORM —
`stash-sql` 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-sql`** — 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.

**`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, ESM-only), and
the auth-strategy `Result` that must be unwrapped before it reaches
`config.authStrategy`.

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 on lines +48 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add example coverage and a documentation-site follow-up.

These changes alter the public stash init / wizard handoff workflow and introduce raw-SQL/WASM usage guidance. Add or update repository examples and explicitly track corresponding updates for the separately maintained documentation site.

As per coding guidelines, “When public APIs or user-facing workflows change, update repository examples and flag that the separately maintained documentation site needs corresponding updates.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/stash-sql-edge-skills.md around lines 48 - 51, Update the change
description to include repository examples covering the revised stash init/impl
handoffs, wizard skill installation, and raw-SQL/WASM guidance, and explicitly
note that the separately maintained documentation site requires corresponding
updates. Preserve the existing integration-specific guidance and cross-links.

Source: Coding guidelines


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-sql`, `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-sql` |
| `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-sql')
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-sql')
})
})

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-sql',
'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-sql` (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-sql',
'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-sql':
'hand-written 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
15 changes: 11 additions & 4 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
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-sql` / `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-sql',
'stash-edge',
'stash-cli',
],
prisma: ['stash-encryption', 'stash-indexing', 'stash-cli'],
generic: ['stash-encryption', 'stash-indexing', 'stash-cli'],
generic: [
'stash-encryption',
'stash-indexing',
'stash-sql',
'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 @@ -455,6 +455,8 @@ Backfill **auto-detects the target column's EQL version** from its Postgres doma

**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 @@ -514,6 +516,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-sql` skill is the reference for both.

Comment on lines +420 to +421

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid nested backticks inside this inline code span.

Line 420 embeds a template literal inside an inline Markdown code span, triggering MD038 and risking incorrect rendering. Use a fenced TypeScript example or a placeholder such as db.execute(sqlTemplate).

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 420-420: Spaces inside code span elements

(MD038, no-space-in-code)

🪛 SkillSpector (2.3.11)

[warning] 27: [RP1] null: npx commands without a version suffix (e.g. @1.0.0) create a rug-pull risk if the upstream server is compromised and publishes a malicious update.

Remediation: Pin the version: npx @scope/server@1.2.3

(MCP Rug Pull (RP1))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/stash-drizzle/SKILL.md` around lines 420 - 421, Update the raw SQL
example in the surrounding documentation to remove the nested backticks from the
inline Markdown code span. Use a safe placeholder such as
db.execute(sqlTemplate), or convert the example to a fenced TypeScript block
while preserving the guidance about operand casts and driver-specific parameter
binding.

Source: Linters/SAST tools

## 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
Loading