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
39 changes: 39 additions & 0 deletions .changeset/encrypt-lifecycle-mixed-table.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'stash': patch
---

`stash encrypt cutover` and `stash encrypt drop` no longer act on — or report
success for — an encrypted column they only guessed at.

On a table holding both a legacy EQL v2 pair (`ssn` / `ssn_encrypted`) and one
unrelated EQL v3 column, the v2 ciphertext column is not classified as an EQL
column at all, so column resolution fell through to the "this is the table's
only EQL column" rule and claimed the unrelated v3 column. Three consequences,
all now fixed:

- **`cutover` reported success for work it never did.** Its EQL v3 branch had no
guard on how the column was resolved, and returned without setting an exit
code — so it printed "point your application at `email_enc`" and exited 0
while the v2 rename never ran. A scripted rollout read that as complete. It
now refuses, and exits 1, exactly as `drop` already did.

- **The recorded pairing was discarded.** `encrypt backfill` writes the true
`encryptedColumn` to `.cipherstash/migrations.json`, so the answer was already
on disk — but a hint that failed to resolve was dropped entirely and the
re-resolution reached the guess. A hint naming a column that still exists but
is not an EQL v3 column is now reported as what it is (most often a legacy
`eql_v2_encrypted` counterpart) instead of being replaced by a guess. A
genuinely stale hint — one naming a column that is gone — still falls back to
the naming convention as before.

- **`drop`'s refusal message prescribed the guess.** It told the user to re-run
`backfill --encrypted-column <the guessed column>`. Following it recorded the
guess as fact, so the next run resolved "by hint", walked past the refusal,
and passed the coverage check vacuously — an unrelated but legitimately
backfilled column is non-NULL on every row — then generated a live
`DROP COLUMN` on the plaintext and exited 0. The message now asks for the
column that actually encrypts the named one, and says explicitly not to record
the guess.

Pure-v2 and pure-v3 tables are unaffected, as are tables with two or more EQL v3
columns (resolution already failed closed there).
24 changes: 24 additions & 0 deletions .changeset/init-scaffold-compiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'stash': patch
---

The client file `stash init` writes now compiles.

Both placeholder templates emitted `await Encryption({ schemas: [] })`, and
`Encryption` requires at least one table — an empty schema set is a deliberate
compile error, so it cannot be relaxed. Every `stash init` therefore left a
project whose first `tsc` or `next build` failed, in a file the CLI had just
told the user not to hand-edit. (The previous scaffold called `EncryptionV3`,
whose looser bound accepted `[]`; collapsing that into an alias of `Encryption`
tightened it.)

The scaffold now declares a single sentinel table, `__stash_placeholder__`, so
the file typechecks as written. `stash encrypt` commands refuse to run while
that table is still the only one declared, and say so — rather than failing
later with a confusing "table not found".

Nothing in the repo compiled this output before: `packages/cli` has no
typecheck step, the codegen tests only string-match fragments of the template,
and the step test stubs the generator out entirely. Both templates are now
committed as fixtures that CI typechecks, pinned byte-for-byte to the generator
so they cannot drift.
9 changes: 9 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,15 @@ jobs:
- name: Typecheck (examples/basic — guards the v3 stack/stack-drizzle importers)
run: pnpm exec turbo run typecheck --filter @cipherstash/basic-example

# `stash init` writes a client file into the user's project and tells them
# not to hand-edit it. Nothing compiled that file, so tightening
# `Encryption` to require a non-empty schema set left every `stash init`
# emitting a project that fails its first `tsc` — with CI green (#772
# review). The fixtures are pinned byte-for-byte to the generator by
# `placeholder-client-fixture.test.ts`.
- name: Typecheck (stash init's scaffolded client)
run: pnpm --filter stash run typecheck:scaffold

- name: Lint — no hardcoded package-manager runners
run: pnpm run lint:runners

Expand Down
65 changes: 65 additions & 0 deletions packages/cli/__fixtures__/scaffold/drizzle.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* CipherStash encryption client — placeholder.
*
* `stash init` wrote this file. It is intentionally NOT a real Drizzle
* schema. Your existing schema files (typically under `src/db/`) remain
* authoritative — your agent will edit those directly when you encrypt a
* column, then update the `Encryption({ schemas: [...] })` call below
* to reference the encrypted tables you declared there.
*
* Until that happens, the encryption client is initialised with a single
* placeholder table so that this file compiles, and `stash encrypt`
* commands refuse to run and point back here.
*
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
* built with the `types.*` factories from `@cipherstash/stack-drizzle`.
* Each domain's query capabilities are FIXED by the type you pick — there is
* no capability config object. Choose the factory whose capabilities you need:
* types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
* types.TextEq / types.IntegerEq equality (eq, inArray)
* types.IntegerOrd / types.DateOrd equality + order/range (gt/lt/between/sort)
* types.TextMatch free-text match only
* types.TextSearch equality + order/range + free-text
* types.Json encrypted-JSONB containment + selectors
*
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
*
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
*
* import { pgTable, integer, text } from 'drizzle-orm/pg-core'
* import { types } from '@cipherstash/stack-drizzle'
*
* export const users = pgTable('users', {
* id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
* email: text('email').notNull(), // existing plaintext, unchanged for now
* email_encrypted: types.TextSearch('email_encrypted'), // encrypted twin, NULLABLE — never .notNull()
* })
*
* Net-new encrypted column (path 1 — declare encrypted from the start):
*
* export const orders = pgTable('orders', {
* id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
* billing_address: types.TextEq('billing_address'),
* })
*
* Once you have encrypted tables declared, harvest them and pass to Encryption():
*
* import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'
* import { Encryption } from '@cipherstash/stack/v3'
* import { users, orders } from './db/schema'
*
* export const encryptionClient = await Encryption({
* schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)],
* })
*/
import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'

// REPLACE THIS. It exists only so this file compiles before you have declared
// any encrypted tables — `Encryption` requires at least one. Swap it for your
// real tables (see the patterns above); `stash encrypt` refuses to run while
// the placeholder is still here.
export const placeholderTable = encryptedTable('__stash_placeholder__', {
replace_me: types.Text('replace_me'),
})

export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
60 changes: 60 additions & 0 deletions packages/cli/__fixtures__/scaffold/generic.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* CipherStash encryption client — placeholder.
*
* `stash init` wrote this file. It is intentionally NOT a real schema
* definition. Your existing schema files remain authoritative — your
* agent will declare encrypted columns there and update the
* `Encryption({ schemas: [...] })` call below to reference them.
*
* Until that happens, the encryption client is initialised with a single
* placeholder table so that this file compiles, and `stash encrypt`
* commands refuse to run and point back here.
*
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
* built with the `types.*` factories from `@cipherstash/stack/eql/v3`
* (also re-exported from `@cipherstash/stack/v3`). Each domain's query
* capabilities are FIXED by the type you pick — there is no chainable
* capability tuner. Choose the factory whose capabilities you need:
* types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
* types.TextEq / types.IntegerEq equality
* types.IntegerOrd / types.DateOrd equality + order/range
* types.TextMatch free-text match only
* types.TextSearch equality + order/range + free-text
* types.Json encrypted-JSONB containment + selectors
*
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
*
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
*
* import { encryptedTable, types } from '@cipherstash/stack/v3'
*
* export const users = encryptedTable('users', {
* email_encrypted: types.TextSearch('email_encrypted'),
* })
*
* Net-new encrypted column (path 1 — declare encrypted from the start):
*
* export const orders = encryptedTable('orders', {
* billing_address: types.TextEq('billing_address'),
* })
*
* Once you have encrypted tables declared, pass them to Encryption():
*
* import { Encryption } from '@cipherstash/stack/v3'
* import { users, orders } from './db/schema'
*
* export const encryptionClient = await Encryption({
* schemas: [users, orders],
* })
*/
import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'

// REPLACE THIS. It exists only so this file compiles before you have declared
// any encrypted tables — `Encryption` requires at least one. Swap it for your
// real tables (see the patterns above); `stash encrypt` refuses to run while
// the placeholder is still here.
export const placeholderTable = encryptedTable('__stash_placeholder__', {
replace_me: types.Text('replace_me'),
})

export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
5 changes: 3 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stash",
"version": "1.0.0-rc.4",
"description": "CipherStash CLI the one stash command for auth, init, encryption schema, database setup, and secrets.",
"description": "CipherStash CLI \u2014 the one stash command for auth, init, encryption schema, database setup, and secrets.",
"repository": {
"type": "git",
"url": "git+https://github.com/cipherstash/stack.git",
Expand Down Expand Up @@ -41,6 +41,7 @@
"postbuild": "chmod +x ./dist/bin/stash.js",
"dev": "tsup --watch",
"test": "vitest run",
"typecheck:scaffold": "tsc -p tsconfig.scaffold.json",
"test:e2e": "vitest run --config vitest.integration.config.ts",
"lint": "biome check ."
},
Expand All @@ -56,7 +57,7 @@
"posthog-node": "^5.40.0",
"zod": "^3.25.76"
},
"//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.",
"//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies \u2014 pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.",
"optionalDependencies": {
"@cipherstash/auth-darwin-arm64": "catalog:repo",
"@cipherstash/auth-darwin-x64": "catalog:repo",
Expand Down
44 changes: 43 additions & 1 deletion packages/cli/src/commands/encrypt/__tests__/encrypt-v3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,36 @@ describe('encrypt cutover — EQL version awareness', () => {
)
})

// #772 review, finding 7. `drop` gated on `via === 'sole'`; `cutover` did
// not, and its v3 branch `return`s without setting exitCode — so a mixed
// table (a v2 pair the classifier no longer sees, plus one unrelated v3
// column) produced a success-shaped message and exit 0 while the v2 rename
// never ran. A scripted rollout read that as done.
it("refuses a by-elimination ('sole') match rather than reporting success", async () => {
lifecycleMock.mockResolvedValue(
resolved(
{ column: 'email_enc', domain: 'eql_v3_text_search', version: 3 },
'sole',
),
)
migrateMocks.progress.mockResolvedValue({ phase: 'backfilled' })
const exitSpy = spyExit()

await cutoverCommand({ table: 'users', column: 'ssn' })

expect(p.log.error).toHaveBeenCalledWith(
expect.stringContaining('nothing confirms it encrypts "ssn"'),
)
// The old message told the user to point their application at the guessed
// column, as though the lifecycle were complete.
expect(p.log.info).not.toHaveBeenCalledWith(
expect.stringContaining('point your application at email_enc'),
)
expect(migrateMocks.renameEncryptedColumns).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
exitSpy.mockRestore()
})

it('fails closed when EQL columns exist but none is identifiable', async () => {
lifecycleMock.mockResolvedValue({
info: null,
Expand Down Expand Up @@ -372,9 +402,21 @@ describe('encrypt drop — EQL version awareness', () => {
expect(p.log.error).toHaveBeenCalledWith(
expect.stringContaining('nothing confirms it encrypts "email"'),
)
expect(p.log.error).toHaveBeenCalledWith(
// The remedy must not prescribe the GUESS. Recording `secret_blob` makes
// the next resolution `via: 'hint'`, which walks past this very gate — and
// the coverage check then passes vacuously, because an unrelated but
// legitimately-backfilled column is non-NULL on every row. Following that
// advice generated a live DROP COLUMN on the plaintext at exit 0
// (#772 review, finding 7).
expect(p.log.error).not.toHaveBeenCalledWith(
expect.stringContaining('--encrypted-column secret_blob'),
)
expect(p.log.error).toHaveBeenCalledWith(
expect.stringContaining('--encrypted-column <name>'),
)
expect(p.log.error).toHaveBeenCalledWith(
expect.stringContaining('do not record secret_blob'),
)
expect(migrateMocks.countUnencrypted).not.toHaveBeenCalled()
expect(writeFileMock).not.toHaveBeenCalled()
expect(exitSpy).toHaveBeenCalledWith(1)
Expand Down
18 changes: 17 additions & 1 deletion packages/cli/src/commands/encrypt/cutover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function cutoverCommand(options: CutoverCommandOptions) {
// DOMAIN TYPES (manifest name as a hint; the `<col>_encrypted` naming is
// a convention, never relied upon) before any phase/config checks so v3
// users get the real answer, not a confusing precondition error.
const { info, candidates } = await resolveColumnLifecycle(
const { info, candidates, unresolvedHint } = await resolveColumnLifecycle(
client,
options.table,
options.column,
Expand All @@ -86,6 +86,7 @@ export async function cutoverCommand(options: CutoverCommandOptions) {
options.table,
options.column,
candidates,
unresolvedHint,
)
if (!info && unresolved) {
p.log.error(unresolved)
Expand All @@ -96,6 +97,21 @@ export async function cutoverCommand(options: CutoverCommandOptions) {

if (info?.version === 3) {
const encryptedColumn = info.column

// `via: 'sole'` means only that this is the table's ONE EQL v3 column —
// nothing ties it to the plaintext column the user named. On a mixed
// table (a v2 pair the classifier no longer sees, plus one unrelated v3
// column) that guess is simply wrong, and reporting "nothing to do for
// EQL v3" for it told a scripted rollout the cut-over had succeeded when
// the v2 rename never ran. `drop.ts` already refuses a `'sole'` match for
// the same reason (#772 review, finding 7).
if (info.via === 'sole') {
p.log.error(
`${options.table}.${encryptedColumn} (${info.domain}) is the table's only EQL v3 column, but nothing confirms it encrypts "${options.column}" — refusing to report a cut-over outcome on that guess. If "${options.column}" pairs with a legacy eql_v2_encrypted column, this release no longer manages that lifecycle. Otherwise record the pairing: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column <the column that actually encrypts ${options.column}>\`.`,
)
exitCode = 1
return
}
if (state?.phase === 'dropped') {
// Terminal phase — the lifecycle already finished. Not an error and
// not "finish the backfill": there is nothing left to backfill.
Expand Down
12 changes: 10 additions & 2 deletions packages/cli/src/commands/encrypt/drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export async function dropCommand(options: DropCommandOptions) {
// column, droppable straight after `backfilled`. The version and the
// encrypted column's name are resolved from the DOMAIN TYPES (manifest
// name as a hint) — the `<col>_encrypted` naming is a convention only.
const { info, candidates } = await resolveColumnLifecycle(
const { info, candidates, unresolvedHint } = await resolveColumnLifecycle(
client,
options.table,
options.column,
Expand All @@ -91,6 +91,7 @@ export async function dropCommand(options: DropCommandOptions) {
options.table,
options.column,
candidates,
unresolvedHint,
)
if (!info && unresolved) {
p.log.error(unresolved)
Expand All @@ -103,9 +104,16 @@ export async function dropCommand(options: DropCommandOptions) {
// that destroys the only copy of this data. Dropping is the single
// irreversible step in the lifecycle, so it demands a positively
// asserted pairing (manifest hint or the naming convention).
//
// The remedy must NOT name `info.column`. That is the guess itself, and
// recording it turns the next resolution into `via: 'hint'`, which walks
// straight past this gate — while the coverage check below passes
// vacuously, because a legitimately-backfilled unrelated column is
// non-NULL on every row. Following the old message verbatim generated a
// live `DROP COLUMN` on the plaintext at exit 0 (#772 review, finding 7).
if (info?.via === 'sole') {
p.log.error(
`${options.table}.${info.column} (${info.domain}) is the table's only encrypted column, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Record the pairing and retry: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column ${info.column}\` (which writes it to the manifest), or set "encryptedColumn": "${info.column}" for this column in .cipherstash/migrations.json.`,
`${options.table}.${info.column} (${info.domain}) is the table's only encrypted column, but nothing confirms it encrypts "${options.column}" — refusing to generate an irreversible drop on that guess. Identify the column that actually encrypts "${options.column}" and record that pairing: re-run \`stash encrypt backfill --table ${options.table} --column ${options.column} --encrypted-column <name>\` (which writes it to the manifest), or set "encryptedColumn" for this column in .cipherstash/migrations.json. If "${options.column}" pairs with a legacy eql_v2_encrypted column, this release no longer manages that lifecycle — do not record ${info.column}.`,
)
exitCode = 1
return
Expand Down
Loading