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
72 changes: 72 additions & 0 deletions .changeset/decrypt-chaining-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
'stash': patch
'@cipherstash/stack': patch
---

Correct the shipped documentation for `decryptModel` / `bulkDecryptModels`.

Three places in `skills/stash-encryption` and four in `packages/stack/README.md`
said these return "a plain `Promise<Result<...>>` (not a chainable operation)"
and that there is therefore "no `.withLockContext()` to chain". They return an
`AuditableDecryptModelOperation`, which is thenable and carries both
`.withLockContext()` and `.audit()` — the same `.audit()` chain the
audit-on-decrypt work advertises. The skill contradicted itself: its own
reference table already listed the correct return type.

The skill ships inside the `stash` tarball and `installSkills()` copies it into
customer repos, so this was steering agents away from an API that exists. The
README ships in the `@cipherstash/stack` tarball.

The equivalent statement about the **WASM entry** is correct and unchanged —
`@cipherstash/stack/wasm-inline` really does return a plain promise from decrypt,
with no lock-context argument.

Also fixes the setup prompt `stash init` writes for coding agents, which
referenced `protectOps.eq` — an API that does not exist anywhere in the repo.
Every step naming an integration-specific API now branches on the project's
actual integration, instead of naming Drizzle's and Supabase's and leaving the
other two to guess:

- **Query paths.** `createEncryptionOperators(client)` (conventionally `ops`)
for Drizzle, the `encryptedSupabase` wrapper's own filters for Supabase, the
`eql*` column operators for Prisma Next, and `client.encryptQuery(...)` for a
plain Postgres project.
- **Schema authoring.** The `types.*` column factories for Drizzle, the
`eql_v3_encrypted` domain in migration SQL for Supabase, the `cipherstash.*`
field constructors in `schema.prisma` for Prisma Next, and `encryptedTable`
for plain Postgres. Prisma Next was previously sent at `types.*` /
`encryptedTable` — the client `stash schema build` explicitly refuses to
scaffold for that integration.
- **Read paths.** `decryptModel(row, usersSchema)` where that applies, and the
wrapper's transparent decryption where it does not.
- **Skill pointers.** A plain Postgres project installs no integration-specific
skill, so each "see the integration skill" was a pointer at a file that was
never written. Those now point at `stash-encryption`, which it does get.

`client.encryptQuery` is also shown taking the schema objects themselves
(`{ table: usersSchema, column: usersSchema.email }`) rather than an
object-shorthand that read as three required strings — `queryType` is inferred
from the column's configured indexes.

The cutover and complete-rollout **plan templates** now split EQL v3 from v2.
Both described the v2 rename swap (`<col>` → `<col>_plaintext`, twin → `<col>`)
as the only cutover path, so on the default v3 install `stash plan` drafted a
plan built around `stash encrypt cutover` — a command that refuses v3 columns
outright ("there is no rename step") and refuses entirely on a v3-only
database, where `eql_v2_configuration` does not exist. The implement prompt
already carried this split; the plan templates did not. The version is
per-column, so the templates tell the agent to establish it per column rather
than deciding once for the whole plan.

The "already encrypted" stop-and-ask now recognises `eql_v3_*` domains
alongside the legacy `eql_v2_encrypted` udt, so it can fire on the default
path at all.

**`stash init` now detects already-encrypted columns on EQL v3.** Database
introspection marked a column as CipherStash-managed only when its udt was
exactly `eql_v2_encrypted`. v3 columns carry per-domain types
(`eql_v3_text_search`, `eql_v3_integer_ord`, …), so on the default path every
encrypted column was reported as plaintext — shown with its `dataType` and left
unticked in the column picker, inviting a re-run to encrypt it a second time.
The picker also labelled any encrypted column with the literal string
`eql_v2_encrypted`; it now shows the column's real domain.
13 changes: 8 additions & 5 deletions .changeset/dynamodb-eql-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ Pass a table built with `encryptedTable` + the `types.*` domains from
the typed client from `EncryptionV3` and the nominal client from
`Encryption({ config: { eqlVersion: 3 } })` are accepted.

EQL v2 tables continue to work unchanged — this is additive, and no existing
caller needs to change. The table decides which wire format is used, so a
DynamoDB table populated under one version must keep being read with that
version.
EQL v2 tables continue to be **readable** — `decryptModel` /
`bulkDecryptModels` still accept one, so existing items stay accessible. Writing
through a v2 table is a separate matter: `encryptModel` / `bulkEncryptModels`
narrowed to EQL v3 tables in this same release, so a caller that still encrypts
through a v2 table does need to change. The table decides which wire format is
used, so a DynamoDB table populated under one version must keep being read with
that version.

This fixes a latent bug that made v3 unusable: the write path detected an
encrypted value by its `k: 'ct'` tag, but EQL v3 scalars carry no `k`
Expand Down Expand Up @@ -71,4 +74,4 @@ type, where a declared column `email` becomes `email__source` (plus
`email`. `decryptModel` / `bulkDecryptModels` invert it via `DecryptedAttributes`.
`AnyEncryptedTable`, `DynamoDBEncryptionClient` and `AuditConfig` are now
exported from `@cipherstash/stack/dynamodb` so these signatures can be named.
The EQL v2 overloads are unchanged.
The EQL v2 **decrypt** overloads are unchanged; the v2 encrypt overloads are removed in this release.
71 changes: 71 additions & 0 deletions packages/cli/src/commands/init/lib/__tests__/introspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { DataType } from '../../types.js'
import {
candidateDomains,
defaultDomain,
isEqlEncryptedDomain,
pgTypeToDataType,
selectTableColumns,
} from '../introspect.js'
Expand Down Expand Up @@ -103,6 +104,44 @@ describe('defaultDomain', () => {
})
})

// The marker used to be `udt_name === 'eql_v2_encrypted'` alone. v3 is the
// default generation and its columns carry `eql_v3_*` domains, so on the
// default path an already-encrypted column was reported as plaintext: shown
// with its `dataType` hint and left unticked. `packages/wizard` already made
// this call, and its comment names the consequence — misreporting encrypted
// columns as plaintext lets an agent clobber real ciphertext.
describe('isEqlEncryptedDomain', () => {
it('recognises every eql_v3_* domain', () => {
for (const udt of [
'eql_v3_encrypted',
'eql_v3_text_search',
'eql_v3_integer_ord',
'eql_v3_date_ord',
'eql_v3_json',
]) {
expect(isEqlEncryptedDomain(udt)).toBe(true)
}
})

it('still recognises the legacy v2 udt', () => {
expect(isEqlEncryptedDomain('eql_v2_encrypted')).toBe(true)
})

it('does not claim plaintext types', () => {
for (const udt of ['text', 'int4', 'jsonb', 'bool', 'timestamptz']) {
expect(isEqlEncryptedDomain(udt)).toBe(false)
}
})

// The trailing underscore is load-bearing: a bare `startsWith('eql_v3')`
// would also claim a hypothetical future `eql_v30_*` generation. Same
// reasoning as `classifyEqlDomain` in `@cipherstash/migrate`.
it('does not claim a future generation by prefix', () => {
expect(isEqlEncryptedDomain('eql_v30_text_search')).toBe(false)
expect(isEqlEncryptedDomain('eql_v3')).toBe(false)
})
})

describe('selectTableColumns', () => {
beforeEach(() => {
selectMock.mockReset()
Expand Down Expand Up @@ -214,6 +253,38 @@ describe('selectTableColumns', () => {
expect(schema?.columns).toEqual([{ name: 'ssn', domain: 'TextSearch' }])
})

// The per-column hint was the literal string 'eql_v2_encrypted' for any
// encrypted column, so a v3 column was labelled with a domain it does not
// have. Report the column's actual udt.
it('hints an encrypted column with its real domain, not a hardcoded v2 udt', async () => {
const withV3 = [
{
tableName: 'accounts',
columns: [
{
columnName: 'ssn',
dataType: 'jsonb',
udtName: 'eql_v3_text_search',
isEqlEncrypted: true,
},
],
},
]
selectMock
.mockResolvedValueOnce('accounts')
.mockResolvedValueOnce('TextSearch')
multiselectMock.mockResolvedValueOnce(['ssn'])

await selectTableColumns(withV3)

const opts = multiselectMock.mock.calls[0][0].options
expect(opts).toEqual([
expect.objectContaining({ value: 'ssn', hint: 'eql_v3_text_search' }),
])
// And it starts ticked, so a re-run does not offer to re-encrypt it.
expect(multiselectMock.mock.calls[0][0].initialValues).toEqual(['ssn'])
})

it('pre-selects the widest searchable domain as the per-column default', async () => {
// Asserts the ARGS to the domain prompt, not just its return: the picker
// must pass initialValue: defaultDomain(options) so the widest searchable
Expand Down
178 changes: 177 additions & 1 deletion packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,117 @@ describe('renderSetupPrompt — orient + route (implement mode)', () => {
expect(out).toContain('pnpm exec drizzle-kit migrate')
})

// The "wire the column through" step names the query API by hand. It used to
// name `protectOps.eq`, which exists nowhere; naming `createEncryptionOperators`
// unconditionally is the same mistake one step smaller — that symbol is
// exported only by `@cipherstash/stack-drizzle`, which a Supabase or Prisma
// project does not even have in its dependency tree. Each integration gets
// the API it can actually import.
describe('query-operator guidance is per-integration', () => {
const render = (integration: SetupPromptContext['integration']) =>
renderSetupPrompt({ ...baseCtx, integration })

it('names the Drizzle operators for drizzle', () => {
const out = render('drizzle')
expect(out).toContain('createEncryptionOperators(client)')
expect(out).toContain('ops.eq')
})

it('names the wrapper builder for supabase, not the Drizzle operators', () => {
const out = render('supabase')
expect(out).not.toContain('createEncryptionOperators')
expect(out).toContain('encryptedSupabase')
})

it('names the eql* column operators for prisma-next', () => {
const out = render('prisma-next')
expect(out).not.toContain('createEncryptionOperators')
expect(out).toContain('eqlEq')
})

// `postgresql` is the default when nothing is detected, and it gets no
// integration skill at all (install-skills.ts) — so "see the integration
// skill" is a dangling pointer for it too.
it('names the core encryptQuery path for plain postgresql', () => {
const out = render('postgresql')
expect(out).not.toContain('createEncryptionOperators')
expect(out).toContain('encryptQuery')
expect(out).toContain('stash-encryption')
})

// `EncryptQueryOptions` takes the schema OBJECTS — `usersSchema` and
// `usersSchema.email` — not their names as strings, and `queryType` is
// inferred from the column's indexes when omitted. The object-shorthand
// `{ table, column, queryType }` reads as three required string fields:
// the same species of plausible-but-wrong API as the `protectOps.eq` this
// PR exists to have removed.
it('shows encryptQuery taking schema objects, not string names', () => {
const out = render('postgresql')
expect(out).toContain('column: usersSchema.email')
expect(out).not.toContain('{ table, column, queryType }')
})

// `packages/cli` builds with tsup, which transpiles without type-checking,
// and has no `typecheck` script — so `Record<Integration, …>` buys nothing
// at build time here. An if-chain ending in a bare Drizzle `return` hands a
// future fifth integration the exact string this helper exists to stop it
// getting. Degrade to neutral guidance, as `skillsFor()` degrades to
// BASE_SKILLS.
it('degrades to neutral guidance for an unrecognised integration', () => {
const out = renderSetupPrompt({
...baseCtx,
integration: 'mystery-orm' as SetupPromptContext['integration'],
})
expect(out).not.toContain('createEncryptionOperators')
expect(out).not.toContain('encryptedSupabase')
expect(out).toContain('encryptQuery')
})
})

// Step 2 named the Drizzle and Supabase schema APIs unconditionally — the
// identical defect step 5 above was just fixed for. A prisma-next project was
// sent at `types.*` / `encryptedTable`, which `stash schema build` explicitly
// refuses to scaffold for that integration; a plain-Postgres project was
// given no named path at all.
describe('schema-authoring guidance is per-integration', () => {
const render = (integration: SetupPromptContext['integration']) =>
renderSetupPrompt({ ...baseCtx, integration })

it('names the Drizzle column factories for drizzle', () => {
const out = render('drizzle')
expect(out).toContain('`types.*` column factories')
expect(out).toContain('@cipherstash/stack-drizzle')
})

it('names the eql_v3 domain for supabase', () => {
expect(render('supabase')).toContain('eql_v3_encrypted')
})

it('names the cipherstash.* field constructors for prisma-next', () => {
const out = render('prisma-next')
expect(out).toContain('cipherstash.TextSearch()')
expect(out).toContain('prisma/schema.prisma')
// The `types.*` client is precisely what `stash schema build` refuses to
// emit for prisma-next.
expect(out).not.toContain('`types.*` column factories')
})

it('names encryptedTable for plain postgresql', () => {
const out = render('postgresql')
expect(out).toContain('encryptedTable')
expect(out).toContain('@cipherstash/stack/v3')
})
})

// `postgresql` installs no integration-specific skill (SKILL_MAP), so every
// unconditional "see the integration skill" is a pointer at a file that was
// never written. Step 5's was fixed; two more survived elsewhere.
it('never points plain postgresql at "the integration skill"', () => {
const out = renderSetupPrompt({ ...baseCtx, integration: 'postgresql' })
expect(out).not.toMatch(/the integration skill/i)
expect(out).toContain('stash-encryption')
})

it('emits supabase migration commands for supabase integration', () => {
const out = renderSetupPrompt({
...baseCtx,
Expand Down Expand Up @@ -438,7 +549,12 @@ describe('renderSetupPrompt — no db push recommendations', () => {
expect(out).toMatch(/1\.\s*\*\*Schema-add/)
expect(out).toMatch(/2\.\s*\*\*Dual-write/)
// Cutover is still covered, just without a db push workaround note.
const cutoverSection = out.substring(out.indexOf('#### Encryption cutover'))
// The heading has to be one that exists: `indexOf` returning -1 makes
// `substring(-1)` the whole document, so this scoped assertion was
// silently asserting nothing at all.
const heading = '#### Backfill and switch'
expect(out).toContain(heading)
const cutoverSection = out.substring(out.indexOf(heading))
expect(cutoverSection).toMatch(/encrypt cutover/)
})

Expand All @@ -450,6 +566,66 @@ describe('renderSetupPrompt — no db push recommendations', () => {
})
})

// The rename swap is an EQL v2 operation. `stash encrypt cutover` refuses a v3
// column outright ("Cut-over is not applicable to EQL v3 columns … there is no
// rename step", `encrypt/cutover.ts`) and refuses entirely on a v3-only
// install, where `eql_v2_configuration` does not exist. v3 is the default, so
// a template presenting the rename as the only path drafts a plan around a
// command that will decline to run. The implement prompt already splits the
// two; these templates did not.
describe('renderSetupPrompt — plan templates split EQL v3 from v2', () => {
const plan = (planStep: 'cutover' | 'complete') =>
renderSetupPrompt({ ...baseCtx, mode: 'plan', planStep })

for (const planStep of ['cutover', 'complete'] as const) {
describe(`${planStep} plan`, () => {
it('names both versions and states v3 has no rename', () => {
const out = plan(planStep)
expect(out).toContain('EQL v3')
expect(out).toContain('EQL v2')
expect(out).toMatch(/no rename/i)
})

it('does not present the rename swap unconditionally', () => {
const out = plan(planStep)
// Every mention of the rename swap has to sit next to the v2 label.
for (const line of out.split('\n')) {
if (/_plaintext/.test(line)) {
expect(line).toMatch(/v2|v3/)
}
}
})

it('tells the agent how to determine the version per column', () => {
// The version is per-column — a database can hold both — so this
// cannot be decided once for the whole plan.
expect(plan(planStep)).toMatch(/encrypt status|encrypt backfill/)
})
})
}

// The stop-and-ask trigger for "already encrypted" named the v2 UDT alone.
// v3 is the default and its columns carry `eql_v3_*` domains, so on the
// default path the check silently never fired. The agent reads the schema
// itself, so this half is fixable here; `introspect.ts`'s `isEqlEncrypted`
// has the same gap and is a separate behavioural change.
it('recognises v3 domains in the already-encrypted stop-and-ask', () => {
for (const mode of ['implement', 'plan'] as const) {
const out = renderSetupPrompt({ ...baseCtx, mode })
expect(out).toMatch(/eql_v3_/)
}
})

it('keeps the cutover invocation scoped to v2', () => {
const out = plan('cutover')
const cutoverLine = out
.split('\n')
.find((l) => l.includes('encrypt cutover --table'))
expect(cutoverLine).toBeDefined()
expect(cutoverLine).toMatch(/v2/)
})
})

describe('renderSetupPrompt — honours what the handoff actually wrote', () => {
for (const mode of ['implement', 'plan'] as const) {
it(`claude-code with no skills points at neither a skills dir nor AGENTS.md (${mode})`, () => {
Expand Down
Loading
Loading