diff --git a/.changeset/proxy-only-db-push.md b/.changeset/proxy-only-db-push.md new file mode 100644 index 00000000..01a2a243 --- /dev/null +++ b/.changeset/proxy-only-db-push.md @@ -0,0 +1,10 @@ +--- +"stash": minor +"@cipherstash/wizard": minor +--- + +`stash db push` is no longer included by default in `stash plan` / `stash impl` agent prompts or the wizard's post-agent step. SDK users (Drizzle, Supabase, plain PostgreSQL) no longer see `stash db push` baked into their rollout/cutover walkthroughs — the encryption config lives in app code, so the database doesn't need a copy. + +Pass `--proxy` to `stash init` (or answer the new interactive prompt) if you query encrypted data via [CipherStash Proxy](https://github.com/cipherstash/proxy). The choice is persisted to `.cipherstash/context.json` as `usesProxy` and is honoured by `stash plan`, `stash impl`, and the wizard's post-agent step. Existing `.cipherstash/context.json` files without the field default to SDK-only. + +Known gap: `stash encrypt cutover` currently requires a pending EQL config registered via `stash db push`, so SDK-only users running the migrate-existing-column flow will hit a "No pending EQL configuration" error from cutover. Workaround: run `stash db push` once before `stash encrypt cutover`. Decoupling cutover from EQL config for SDK-only users is tracked as a follow-up to [#447](https://github.com/cipherstash/stack/issues/447). diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index e8169ca5..056edd94 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -109,6 +109,8 @@ Options: Init Flags: --supabase Use Supabase-specific setup flow --drizzle Use Drizzle-specific setup flow + --proxy Query encrypted data via CipherStash Proxy + --no-proxy Query encrypted data directly via the SDK (default) Plan Flags: --complete-rollout Plan the entire encryption lifecycle (schema-add through drop) diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts index bd2e7902..cc178662 100644 --- a/packages/cli/src/commands/impl/index.ts +++ b/packages/cli/src/commands/impl/index.ts @@ -37,6 +37,7 @@ function buildStateFromContext( eqlInstalled: true, agents, mode: 'implement', + usesProxy: ctx.usesProxy ?? false, } } diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index ee15b3cf..0482d705 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -9,6 +9,7 @@ import { gatherContextStep } from './steps/gather-context.js' import { installDepsStep } from './steps/install-deps.js' import { installEqlStep } from './steps/install-eql.js' import { resolveDatabaseStep } from './steps/resolve-database.js' +import { resolveProxyChoiceStep } from './steps/resolve-proxy-choice.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' import { detectPackageManager, runnerCommand } from './utils.js' @@ -32,6 +33,7 @@ const PROVIDER_MAP: Record InitProvider> = { const STEPS = [ authenticateStep, resolveDatabaseStep, + resolveProxyChoiceStep, buildSchemaStep, installDepsStep, installEqlStep, @@ -69,6 +71,13 @@ export async function initCommand(flags: Record) { let state: InitState = {} + // Parse --proxy and --no-proxy flags; --proxy wins if both are set + if (flags.proxy) { + state.usesProxy = true + } else if (flags['no-proxy']) { + state.usesProxy = false + } + try { for (const step of STEPS) { state = await step.run(state, provider) diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index 882853b2..b6da2b4d 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -12,6 +12,7 @@ const baseCtx: SetupPromptContext = { handoff: 'claude-code', mode: 'implement', installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'], + usesProxy: false, } describe('renderSetupPrompt — orient + route (implement mode)', () => { @@ -380,3 +381,177 @@ describe('renderSetupPrompt — plan mode default when planStep is unset', () => ) }) }) + +describe('renderSetupPrompt — usesProxy conditional', () => { + describe('implement mode with usesProxy: false (SDK-only)', () => { + it('drops db push step from add-new-column flow', () => { + const out = renderSetupPrompt({ ...baseCtx, usesProxy: false }) + expect(out).not.toMatch(/5\.\s*Register the encryption config/) + // Step 5 should now be the wire-the-column step, not db push + expect(out).toMatch(/5\.\s*Wire the column through/) + }) + + it('drops register-pending-config step from rollout path', () => { + const out = renderSetupPrompt({ ...baseCtx, usesProxy: false }) + // Should have only "Schema-add" as step 1, then "Dual-write" as step 2 + // (no "Register pending config" in between) + expect(out).toMatch(/1\.\s*\*\*Schema-add/) + expect(out).toMatch(/2\.\s*\*\*Dual-write/) + // Register pending config should not appear in the rollout section + const rolloutSection = out.substring( + out.indexOf('#### Encryption rollout'), + out.indexOf('⛔'), + ) + expect(rolloutSection).not.toMatch(/Register pending config/) + }) + + it('removes db push from cutover step but keeps encrypt cutover invocation', () => { + const out = renderSetupPrompt({ ...baseCtx, usesProxy: false }) + const cutoverSection = out.substring( + out.indexOf('#### Encryption cutover'), + ) + // Should mention encrypt cutover + expect(cutoverSection).toMatch(/encrypt cutover/) + // Should include the Proxy-conditional aside + expect(cutoverSection).toMatch(/If you use CipherStash Proxy/) + }) + }) + + describe('implement mode with usesProxy: true (Proxy)', () => { + it('includes db push step in add-new-column flow', () => { + const out = renderSetupPrompt({ ...baseCtx, usesProxy: true }) + expect(out).toMatch(/5\.\s*Register the encryption config.*db push/) + expect(out).toMatch(/6\.\s*\*\*If db push wrote pending\*\*.*db activate/) + }) + + it('includes register-pending-config step in rollout path', () => { + const out = renderSetupPrompt({ ...baseCtx, usesProxy: true }) + expect(out).toMatch(/2\.\s*\*\*Register pending config.*db push/) + expect(out).toMatch(/3\.\s*\*\*Dual-write/) + }) + + it('includes full db push in cutover step', () => { + const out = renderSetupPrompt({ ...baseCtx, usesProxy: true }) + const cutoverSection = out.substring( + out.indexOf('#### Encryption cutover'), + ) + expect(cutoverSection).toMatch(/5\.\s*\*\*Switch the schema and re-push/) + expect(cutoverSection).toMatch(/Run.*db push.*again/) + }) + }) + + describe('plan mode (rollout) with usesProxy conditional', () => { + it('mentions db push in rollout plan summary when usesProxy: true', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'rollout', + usesProxy: true, + }) + expect(out).toMatch( + /Encryption rollout.*dual-write code, and.*db push.*writes pending/, + ) + }) + + it('notes db push as Proxy-only in rollout plan summary when usesProxy: false', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'rollout', + usesProxy: false, + }) + expect(out).toMatch( + /Encryption rollout.*dual-write code.*plus.*db push.*Proxy users only/, + ) + }) + + it('includes db push in rollout PR contents when usesProxy: true', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'rollout', + usesProxy: true, + }) + expect(out).toMatch(/schema-add.*db push.*pending.*dual-write code/) + }) + + it('drops db push from rollout PR contents when usesProxy: false', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'rollout', + usesProxy: false, + }) + expect(out).toMatch(/schema-add.*dual-write code/) + // Should not mention "db push (pending)" in the rollout PR contents + const prSection = out.substring( + out.indexOf('migrate columns: what the rollout PR contains'), + ) + expect(prSection).not.toMatch(/db push.*pending/) + }) + }) + + describe('plan mode (cutover) with usesProxy conditional', () => { + it('includes db push in schema-rename when usesProxy: true', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'cutover', + usesProxy: true, + }) + expect(out).toMatch(/Schema rename and re-push/) + expect(out).toMatch(/db push.*registers the renamed/) + }) + + it('separates schema rename from db push when usesProxy: false', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'cutover', + usesProxy: false, + }) + expect(out).toMatch(/\*\*Schema rename\.\*\*.*original column/) + expect(out).toMatch(/Proxy users only.*db push/) + }) + + it('notes db push as Proxy-only in prose when usesProxy: false', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'cutover', + usesProxy: false, + }) + expect(out).toMatch(/schema-edit step.*exact rename pattern/) + expect(out).not.toMatch(/schema-edit.*db push.*step/i) + }) + }) + + describe('plan mode (complete) with usesProxy conditional', () => { + it('includes db push steps in full lifecycle when usesProxy: true', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'complete', + usesProxy: true, + }) + expect(out).toMatch(/db push.*backfill.*schema rename.*db push.*cutover/) + }) + + it('drops both db push mentions from full lifecycle when usesProxy: false', () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode: 'plan', + planStep: 'complete', + usesProxy: false, + }) + expect(out).toMatch(/schema-add.*dual-write code.*backfill.*schema rename/) + // The lifecycle line should not have "db push" twice + const migrateSection = out.substring( + out.indexOf('**Migrate existing columns**'), + ) + const firstLine = migrateSection.split('\n')[0] + const dbPushCount = (firstLine.match(/db push/g) || []).length + expect(dbPushCount).toBe(0) + }) + }) +}) diff --git a/packages/cli/src/commands/init/lib/read-context.ts b/packages/cli/src/commands/init/lib/read-context.ts index bee0d042..5945c559 100644 --- a/packages/cli/src/commands/init/lib/read-context.ts +++ b/packages/cli/src/commands/init/lib/read-context.ts @@ -37,7 +37,12 @@ export function readContextFile(cwd: string): ContextFile | undefined { if (!existsSync(path)) return undefined try { const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown - return isContextFile(parsed) ? parsed : undefined + if (!isContextFile(parsed)) return undefined + // Ensure usesProxy defaults to false for older files that don't have it + return { + ...parsed, + usesProxy: parsed.usesProxy ?? false, + } } catch { return undefined } diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 95715185..8e60e310 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -32,6 +32,8 @@ export interface SetupPromptContext { * to `'rollout'` when plan mode is invoked without explicit state — that * matches a fresh project where there are no recorded events yet. */ planStep?: PlanStep + /** Whether the user runs CipherStash Proxy. False = SDK-only (no `stash db push` in default flows). True = prompts include push steps. Captured in stash init; persisted to .cipherstash/context.json. */ + usesProxy: boolean } interface MigrationCommands { @@ -249,10 +251,17 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { "2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.", `3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`, `4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`, - `5. Register the encryption config — \`${cli} db push\`. If the project has no active EQL config yet (first encrypted column ever), this writes directly to active and you can skip step 6. If an active config already exists, push writes \`pending\` and prints a next-step note.`, - `6. **If db push wrote pending**, promote it to active — \`${cli} db activate\`. (Use \`${cli} db activate\` here because no rename is needed; \`${cli} encrypt cutover\` is reserved for the migrate-existing-column flow.)`, - '7. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', - '8. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', + ...(ctx.usesProxy + ? [ + `5. Register the encryption config — \`${cli} db push\`. If the project has no active EQL config yet (first encrypted column ever), this writes directly to active and you can skip step 6. If an active config already exists, push writes \`pending\` and prints a next-step note.`, + `6. **If db push wrote pending**, promote it to active — \`${cli} db activate\`. (Use \`${cli} db activate\` here because no rename is needed; \`${cli} encrypt cutover\` is reserved for the migrate-existing-column flow.)`, + '7. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', + '8. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', + ] + : [ + '5. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).', + '6. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.', + ]), '', '### Migrate an existing column to encrypted', '', @@ -263,8 +272,14 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '#### Encryption rollout — what lands before the deploy', '', `1. **Schema-add.** Add a \`_encrypted\` twin column (nullable \`jsonb\`) alongside the existing plaintext column in the user's real schema file. Generate and apply the schema migration. **If this is the first encrypted column in the project, configure the bundler exclusion now** — see the snippets in the previous section. Without it, importing the encryption client at backfill time will crash.`, - `2. **Register pending config** — \`${cli} db push\`. With an existing active config, this writes the new column-set as \`pending\`. Cutover (later) will promote it. (If this is the very first push for the project, db push writes active directly — fine, the rest of the flow still works.)`, - `3. **Dual-write.** Edit the application code so **every persistence path that mutates this row writes both \`\` (plaintext, unchanged) and \`_encrypted\` (ciphertext via the encryption client) — in the same transaction, on every code branch, with no exceptions.** A single missed branch causes silent migration drift later. Reads still come from the plaintext column.`, + ...(ctx.usesProxy + ? [ + `2. **Register pending config** — \`${cli} db push\`. With an existing active config, this writes the new column-set as \`pending\`. Cutover (later) will promote it. (If this is the very first push for the project, db push writes active directly — fine, the rest of the flow still works.)`, + `3. **Dual-write.** Edit the application code so **every persistence path that mutates this row writes both \`\` (plaintext, unchanged) and \`_encrypted\` (ciphertext via the encryption client) — in the same transaction, on every code branch, with no exceptions.** A single missed branch causes silent migration drift later. Reads still come from the plaintext column.`, + ] + : [ + `2. **Dual-write.** Edit the application code so **every persistence path that mutates this row writes both \`\` (plaintext, unchanged) and \`_encrypted\` (ciphertext via the encryption client) — in the same transaction, on every code branch, with no exceptions.** A single missed branch causes silent migration drift later. Reads still come from the plaintext column.`, + ]), '', `⛔ **Deploy gate.** Stop here. The application must be running this code in production — the deployed environment that owns the database — before backfill is safe to run. "Live on the user's laptop" or "live in CI" does not count. After the user deploys, tell them to run`, '', @@ -274,11 +289,21 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string { '', '#### Encryption cutover — after dual-writes are live', '', - `4. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, - `5. **Switch the schema and re-push, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`\` to \`encryptedType\`). Run \`${cli} db push\` again — pending now reflects the renamed shape. Then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, \`_encrypted\` → \`\`) and promotes pending → active.`, - '6. **Wire the read path through the encryption client.** Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw `eql_v2_encrypted` payloads to end users. The integration skill has the exact API.', - '7. **Remove the dual-write code.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.', - `8. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused \`_plaintext\`. Apply with the project's normal migration tooling.`, + ...(ctx.usesProxy + ? [ + `4. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, + `5. **Switch the schema and re-push, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`\` to \`encryptedType\`). Run \`${cli} db push\` again — pending now reflects the renamed shape. Then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, \`_encrypted\` → \`\`) and promotes pending → active.`, + '6. **Wire the read path through the encryption client.** Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw `eql_v2_encrypted` payloads to end users. The integration skill has the exact API.', + '7. **Remove the dual-write code.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.', + `8. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused \`_plaintext\`. Apply with the project's normal migration tooling.`, + ] + : [ + `3. **Backfill.** Run \`${cli} encrypt backfill --table --column \`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`, + `4. **Switch the schema, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`\` to \`encryptedType\`). Then \`${cli} encrypt cutover --table --column \` runs the rename in one transaction (\`\` → \`_plaintext\`, \`_encrypted\` → \`\`) and promotes pending → active. *If you use CipherStash Proxy, run \`stash db push\` before this step so the new shape is registered.*`, + '5. **Wire the read path through the encryption client.** Post-cutover, `` holds ciphertext. Read code paths must decrypt before returning the value to callers — `decryptModel(row, table)` for Drizzle, the `encryptedSupabase` wrapper for Supabase, or the equivalent `decrypt`/`bulkDecryptModels` calls. Without this step, your read paths return raw `eql_v2_encrypted` payloads to end users. The integration skill has the exact API.', + '6. **Remove the dual-write code.** The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.', + `7. **Drop.** Run \`${cli} encrypt drop --table --column \`. Generates a migration that removes the now-unused \`_plaintext\`. Apply with the project's normal migration tooling.`, + ]), '', 'Recovery: if the user reports that backfill ran *before* the dual-write code was actually live, drift is expected (rows written during the backfill window land in plaintext only). Re-run with `--force` to encrypt every plaintext row regardless of current state.', '', @@ -455,7 +480,9 @@ function renderRolloutPlanPrompt(ctx: SetupPromptContext): string { '**Add a new encrypted column** — single deploy, no rollout/cutover split. Declared encrypted from the start.', ), bullet( - '**Encryption rollout for an existing column** — the encrypted twin column, the application-side dual-write code, and `stash db push` (writes pending). All of this lands in one PR; the user deploys it; `cs_migrations` records `dual_writing` the next time backfill is invoked.', + ctx.usesProxy + ? '**Encryption rollout for an existing column** — the encrypted twin column, the application-side dual-write code, and `stash db push` (writes pending). All of this lands in one PR; the user deploys it; `cs_migrations` records `dual_writing` the next time backfill is invoked.' + : '**Encryption rollout for an existing column** — the encrypted twin column and the application-side dual-write code (plus `stash db push` for Proxy users only). All of this lands in one PR; the user deploys it; `cs_migrations` records `dual_writing` the next time backfill is invoked.', ), '', "Converting a populated column in place is **not** supported — any \"just swap the type\" approach corrupts data. If the user asks for that, the plan must explain why and route them to the encryption-rollout flow.", @@ -481,7 +508,9 @@ function renderRolloutPlanPrompt(ctx: SetupPromptContext): string { 'Which path applies per column (additive new column or encryption-rollout for an existing one). Justify briefly.', ), bullet( - 'For migrate columns: what the rollout PR contains — schema-add, `db push` (pending), and the exact dual-write code change. The dual-write definition matters: every persistence path that mutates the row writes both columns, in the same transaction, on every code branch.', + ctx.usesProxy + ? 'For migrate columns: what the rollout PR contains — schema-add, `db push` (pending), and the exact dual-write code change. The dual-write definition matters: every persistence path that mutates the row writes both columns, in the same transaction, on every code branch.' + : 'For migrate columns: what the rollout PR contains — schema-add and the exact dual-write code change. The dual-write definition matters: every persistence path that mutates the row writes both columns, in the same transaction, on every code branch.', ), bullet( `Project-specific risks. Common ones: bundler exclusion not yet configured (Next.js / webpack / Vite), top-level-await in the placeholder encryption client breaks non-Next contexts, existing partial CipherStash state (run \`${cli} db status\` and note any pre-existing encrypted columns or pending configs).`, @@ -534,8 +563,17 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { '**Backfill.** Encrypt the historical rows that pre-date the rollout deploy. Resumable; chunked; SIGINT-safe.', ), bullet( - '**Schema rename and re-push.** Update the schema declaration to put the encrypted form under its final column name; `stash db push` registers the renamed pending config.', + ctx.usesProxy + ? '**Schema rename and re-push.** Update the schema declaration to put the encrypted form under its final column name; `stash db push` registers the renamed pending config.' + : '**Schema rename.** Update the schema declaration so the original column points at the encrypted type.', ), + ...(ctx.usesProxy + ? [] + : [ + bullet( + '*Proxy users only*: after the schema rename, also run `stash db push` to register the renamed pending config.', + ), + ]), bullet( '**Cutover.** A single transaction renames `` → `_plaintext`, `_encrypted` → ``, and promotes the pending EQL config to active.', ), @@ -569,7 +607,9 @@ function renderCutoverPlanPrompt(ctx: SetupPromptContext): string { ' encrypt backfill` invocation with concrete `--table` / `--column` values.', ), bullet( - 'The schema-edit + `db push` step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).', + ctx.usesProxy + ? 'The schema-edit + `db push` step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).' + : 'The schema-edit step, with the exact rename pattern (drop `_encrypted` suffix on the encrypted column, switch the original column declaration off `text`/`varchar` and onto the encrypted type).', ), bullet( 'The cutover invocation per column: `' + @@ -633,7 +673,9 @@ function renderCompletePlanPrompt(ctx: SetupPromptContext): string { "**Add new encrypted columns** — declared encrypted from the start; single-deploy.", ), bullet( - '**Migrate existing columns** — schema-add → dual-write code → `db push` → backfill → schema rename → `db push` → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.', + ctx.usesProxy + ? '**Migrate existing columns** — schema-add → dual-write code → `db push` → backfill → schema rename → `db push` → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.' + : '**Migrate existing columns** — schema-add → dual-write code → backfill → schema rename → cutover → read-path switch → remove dual-write code → drop plaintext. No deploy gate between rollout and cutover steps because there is no deployed application to gate on.', ), '', '## Your task: produce the complete-rollout plan file', diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 85e01857..f9b94607 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -40,6 +40,8 @@ export interface ContextFile { * handoffs use. Absent when the file was written by `stash init` or * `stash impl` rather than `stash plan`. */ planStep?: PlanStep + /** Whether the user queries encrypted data via CipherStash Proxy. Captured in stash init. SDK users default to false. */ + usesProxy?: boolean generatedAt: string } @@ -105,6 +107,7 @@ export function buildContextFile(state: InitState): ContextFile { schemas: state.schemas ?? [], installedSkills: [], planStep: state.planStep, + usesProxy: state.usesProxy, generatedAt: new Date().toISOString(), } } @@ -165,6 +168,7 @@ export function buildSetupPromptContext( mode: state.mode ?? 'implement', installedSkills, planStep: state.planStep, + usesProxy: state.usesProxy ?? false, } } diff --git a/packages/cli/src/commands/init/steps/resolve-proxy-choice.ts b/packages/cli/src/commands/init/steps/resolve-proxy-choice.ts new file mode 100644 index 00000000..78f4d802 --- /dev/null +++ b/packages/cli/src/commands/init/steps/resolve-proxy-choice.ts @@ -0,0 +1,57 @@ +import * as p from '@clack/prompts' +import type { InitProvider, InitState, InitStep } from '../types.js' + +/** + * Resolve whether the user queries encrypted data via CipherStash Proxy or + * directly via the SDK. Captured as a flag (--proxy / --no-proxy) or an + * interactive prompt, and stored on state.usesProxy. + * + * The prompt is non-blocking: cancellation falls back to false (SDK-only, + * the default). In non-TTY contexts without a flag, defaults to false and + * logs an info message. + */ +export const resolveProxyChoiceStep: InitStep = { + id: 'resolve-proxy-choice', + name: 'Resolve proxy choice', + async run(state: InitState, _provider: InitProvider): Promise { + // If the flag was already set by the user, use it + if (state.usesProxy !== undefined) { + return state + } + + // In TTY mode, prompt the user + if (process.stdout.isTTY) { + const choice = await p.select({ + message: + 'Are you planning to query encrypted data via CipherStash Proxy, or directly via the SDK?', + options: [ + { + value: false, + label: 'Directly via the SDK (most common)', + }, + { + value: true, + label: 'CipherStash Proxy', + }, + ], + initialValue: false, + }) + + // Non-blocking: if cancelled, default to false + if (p.isCancel(choice)) { + p.log.info( + 'Cancelled proxy choice; defaulting to SDK-only mode (no `stash db push` in default flows).', + ) + return { ...state, usesProxy: false } + } + + return { ...state, usesProxy: choice } + } + + // Non-TTY: default to false and log + p.log.info( + 'No --proxy flag set; defaulting to SDK-only mode (no `stash db push` in default flows).', + ) + return { ...state, usesProxy: false } + }, +} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index e365a857..147b0778 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -70,6 +70,8 @@ export interface InitState { * on-disk plan-summary block instead. Defaults to `'rollout'` when the * CLI has nothing else to go on (fresh project, no DB connectivity). */ planStep?: PlanStep + /** Whether the user queries encrypted data via CipherStash Proxy. Captured in stash init. SDK users default to false; setting true makes prompts/skills include `stash db push` steps. */ + usesProxy?: boolean } /** diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts index 51862dd7..e598f7ad 100644 --- a/packages/cli/src/commands/plan/index.ts +++ b/packages/cli/src/commands/plan/index.ts @@ -31,6 +31,7 @@ function buildStateFromContext( agents, mode: 'plan', planStep, + usesProxy: ctx.usesProxy ?? false, } } diff --git a/packages/wizard/src/__tests__/post-agent.test.ts b/packages/wizard/src/__tests__/post-agent.test.ts index 880c2876..a0408632 100644 --- a/packages/wizard/src/__tests__/post-agent.test.ts +++ b/packages/wizard/src/__tests__/post-agent.test.ts @@ -20,7 +20,7 @@ describe('runPostAgentSteps execution commands', () => { vi.mocked(childProcess.execSync).mockImplementation(() => Buffer.from('')) }) - it('executes db install/db push using the detected runner (bun → bunx)', async () => { + it('executes db install/db push using the detected runner (bun → bunx) when usesProxy=true', async () => { await runPostAgentSteps({ cwd: '/tmp/fake', integration: 'supabase', @@ -28,6 +28,7 @@ describe('runPostAgentSteps execution commands', () => { gathered: { installCommand: 'bun add @cipherstash/stack', hasStashConfig: false, + usesProxy: true, // Other GatheredContext fields aren't read in this code path; cast for the test. } as never, }) @@ -43,7 +44,7 @@ describe('runPostAgentSteps execution commands', () => { } }) - it('skips db install when hasStashConfig=true and still uses bunx for db push', async () => { + it('skips db install when hasStashConfig=true and still uses bunx for db push when usesProxy=true', async () => { await runPostAgentSteps({ cwd: '/tmp/fake', integration: 'supabase', @@ -51,6 +52,7 @@ describe('runPostAgentSteps execution commands', () => { gathered: { installCommand: 'bun add @cipherstash/stack', hasStashConfig: true, + usesProxy: true, } as never, }) const commands = vi @@ -60,7 +62,7 @@ describe('runPostAgentSteps execution commands', () => { expect(commands).not.toContain('bunx stash db install') }) - it('falls back to npx when packageManager is undefined', async () => { + it('falls back to npx when packageManager is undefined and usesProxy=true', async () => { await runPostAgentSteps({ cwd: '/tmp/fake', integration: 'supabase', @@ -68,6 +70,7 @@ describe('runPostAgentSteps execution commands', () => { gathered: { installCommand: 'npm install @cipherstash/stack', hasStashConfig: false, + usesProxy: true, } as never, }) const commands = vi @@ -76,4 +79,23 @@ describe('runPostAgentSteps execution commands', () => { expect(commands).toContain('npx stash db install') expect(commands).toContain('npx stash db push') }) + + it('skips db push when usesProxy=false', async () => { + await runPostAgentSteps({ + cwd: '/tmp/fake', + integration: 'supabase', + packageManager: bun, + gathered: { + installCommand: 'bun add @cipherstash/stack', + hasStashConfig: false, + usesProxy: false, + } as never, + }) + + const commands = vi + .mocked(childProcess.execSync) + .mock.calls.map((c) => c[0] as string) + expect(commands).not.toContain('bunx stash db push') + expect(commands).toContain('bunx stash db install') + }) }) diff --git a/packages/wizard/src/lib/gather.ts b/packages/wizard/src/lib/gather.ts index 9c1838de..adf12baf 100644 --- a/packages/wizard/src/lib/gather.ts +++ b/packages/wizard/src/lib/gather.ts @@ -47,6 +47,8 @@ export interface GatheredContext { installCommand: string /** Whether stash.config.ts already exists. */ hasStashConfig: boolean + /** Whether the user runs CipherStash Proxy. False = SDK-only (post-agent skips `stash db push`). Sourced from .cipherstash/context.json; defaults to false when the file is missing or the field is absent. */ + usesProxy: boolean } export interface GatherContextOptions { @@ -79,6 +81,9 @@ export async function gatherContext( existsSync(resolve(cwd, 'stash.config.ts')) || existsSync(resolve(cwd, 'stash.config.js')) + // Read usesProxy from .cipherstash/context.json, defaulting to false + const usesProxy = readUsesProxyFromContext(cwd) + let selectedColumns: ColumnSelection[] if (mode === 'plan') { selectedColumns = [] @@ -118,6 +123,7 @@ export async function gatherContext( outputPath, installCommand: installCmd, hasStashConfig, + usesProxy, } } @@ -417,3 +423,32 @@ function scanForPgTable( // Permission error or similar — skip } } + +// --- Context file reading --- + +/** + * Read usesProxy from .cipherstash/context.json. + * Silently defaults to false if the file is missing, invalid JSON, + * or the field is absent. + */ +function readUsesProxyFromContext(cwd: string): boolean { + const contextPath = resolve(cwd, '.cipherstash', 'context.json') + if (!existsSync(contextPath)) { + return false + } + try { + const content = readFileSync(contextPath, 'utf-8') + const parsed = JSON.parse(content) as unknown + if ( + parsed && + typeof parsed === 'object' && + 'usesProxy' in parsed && + typeof (parsed as Record).usesProxy === 'boolean' + ) { + return (parsed as Record).usesProxy as boolean + } + } catch { + // Silently ignore parse errors or other issues + } + return false +} diff --git a/packages/wizard/src/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index 89347a14..e7290535 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -53,13 +53,19 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { ) } - // Step 3: Push encryption config - await runStep( - 'Pushing encryption config to database...', - 'Encryption config pushed', - `${runner} stash db push`, - cwd, - ) + // Step 3: Push encryption config (only when using Proxy) + if (gathered.usesProxy) { + await runStep( + 'Pushing encryption config to database...', + 'Encryption config pushed', + `${runner} stash db push`, + cwd, + ) + } else { + p.log.info( + 'Skipping `stash db push` — not using CipherStash Proxy. Run it manually if you ever switch to Proxy.', + ) + } // Step 4: Integration-specific migrations if (integration === 'drizzle') { diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index d384d527..0291d5e9 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -51,11 +51,11 @@ Two paths to a fully-encrypted column: For migrate columns, the flow is: -1. **`stash plan`** detects that no `dual_writing` event is recorded and writes an encryption-rollout plan: schema-add for the encrypted twin, `stash db push` (pending), and the application-side dual-write code. +1. **`stash plan`** detects that no `dual_writing` event is recorded and writes an encryption-rollout plan: schema-add for the encrypted twin and the application-side dual-write code. (If using CipherStash Proxy, the plan also includes `stash db push` to register the pending config.) 2. **`stash impl`** executes that plan and stops with a deploy-gate banner. Encrypted values are not flowing yet — the dual-write code has to be running in production before backfill is safe. 3. **You ship and deploy** the rollout PR. 4. **`stash status`** confirms dual-writes are live. -5. **`stash plan`** detects `dual_writing` and writes a separate cutover plan: backfill, schema rename + re-push, cutover, read-path switch, drop. +5. **`stash plan`** detects `dual_writing` and writes a separate cutover plan: backfill, schema rename, cutover, read-path switch, drop. (For Proxy users, the plan also includes `stash db push` after schema rename to register the new shape.) 6. **`stash impl`** executes the cutover. The split is invisible to the user — they just keep running `stash plan` and `stash impl`; the CLI knows where they are. @@ -64,6 +64,8 @@ For users without a deployed application to gate on (local dev, sandboxes, fresh Use `stash status` at any time to see which save-points are complete and what each rollout's next move is. +> **Note:** Until issue [#447](https://github.com/cipherstash/stack/issues/447) follow-up lands, `stash encrypt cutover` requires a pending EQL configuration (set by `stash db push`). SDK users must run `stash db push` once before `stash encrypt cutover` to satisfy this precondition. Tracked separately and will be addressed in a follow-up. + ## Configuration ### 1. Create `stash.config.ts` in the project root @@ -145,8 +147,8 @@ stash plan --complete-rollout | Detected state | Plan written | |---|---| -| Manifest empty, fresh project, or no `dual_writing` events recorded | **Encryption rollout** — schema-add, dual-write code, `stash db push` (pending). Ends at the deploy gate. | -| At least one column has a `dual_writing` (or later) event recorded | **Encryption cutover** — backfill, schema rename + re-push, cutover, read-path switch, drop plaintext. Requires the rollout to already be deployed. | +| Manifest empty, fresh project, or no `dual_writing` events recorded | **Encryption rollout** — schema-add and dual-write code. (Proxy users also: `stash db push` to register pending.) Ends at the deploy gate. | +| At least one column has a `dual_writing` (or later) event recorded | **Encryption cutover** — backfill and schema rename. (Proxy users also: `stash db push` to register the renamed shape.) Requires the rollout to already be deployed. | | `--complete-rollout` flag passed | **Complete rollout** — schema-add through drop, no deploy gate. Escape hatch for databases without a deployed application. Default-no confirm with a loud warning before generating. | The chosen template drives the agent's prompt body for the Claude Code, Codex, and AGENTS.md handoffs. The wizard handoff receives `--mode plan` on argv and reads the resolved step from `.cipherstash/context.json` (the `planStep` field). Every target produces a valid plan-mode artifact at `.cipherstash/plan.md`. @@ -178,7 +180,7 @@ stash impl --continue-without-plan | No plan, `--continue-without-plan` | Skips the picker, runs the security confirm (still default-no), then implements. | | No plan, non-TTY, no flag | Errors out with "Run `stash plan` first, or pass `--continue-without-plan` to skip planning." Forces explicit intent in CI. | -Once the user clears the gate, `impl` dispatches to a handoff target (Claude Code, Codex, AGENTS.md for Cursor/Windsurf/Cline, or `@cipherstash/wizard`) and the agent executes the plan: schema edits, migrations, `stash db push`, `stash encrypt {backfill,cutover,drop}` as appropriate. +Once the user clears the gate, `impl` dispatches to a handoff target (Claude Code, Codex, AGENTS.md for Cursor/Windsurf/Cline, or `@cipherstash/wizard`) and the agent executes the plan: schema edits, migrations, `stash encrypt {backfill,cutover,drop}` as appropriate. (Proxy users also run `stash db push` where indicated in the plan.) #### Deploy-gate enforcement @@ -340,10 +342,11 @@ Validation also runs automatically before `db push` — issues are logged as war ### `db push` — Register the encryption schema with EQL -Synchronises the CipherStash configuration in `eql_v2_configuration` with what your encryption client declares. Required: +Synchronises the CipherStash configuration in `eql_v2_configuration` with what your encryption client declares. + +**Required for CipherStash Proxy users** — Proxy needs to know which columns to encrypt/decrypt. -- For CipherStash Proxy users (so Proxy knows which columns to encrypt/decrypt). -- For SDK users running the column-encryption lifecycle (`stash encrypt {backfill,cutover,drop}`) — `cutover` reads pending columns from EQL config to know what to rename. +**Not needed for SDK users** — Drizzle, Supabase, and plain PostgreSQL SDK users have their encryption config in application code. The database does not need a copy. See the "Known gap" note below. ```bash stash db push @@ -372,6 +375,8 @@ When pushing, the CLI: | Adding a brand-new encrypted column (no rename) | `stash db activate` | | Cutting over from a `_encrypted` twin (path 3 lifecycle) | `stash encrypt cutover --table T --column C` | +> **Known gap:** `stash encrypt cutover` currently requires a pending EQL configuration (satisfied by `stash db push`). SDK-only users running the migrate-existing-column flow will encounter this precondition. Work to decouple `encrypt cutover` from EQL config for SDK-only users (using direct SQL rename instead) is tracked as follow-up work to [issue #447](https://github.com/cipherstash/stack/issues/447) and will be addressed in a future release. + **SDK to EQL type mapping:** | SDK type (`dataType()`) | EQL `cast_as` | diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 570e9d6d..15012b41 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -336,7 +336,7 @@ if (!decrypted.failure) { 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. -CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code + `db push`) and an **encryption cutover** (backfill + rename + drop). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape. +CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + rename + drop). (If using CipherStash Proxy, the rollout also includes `stash db push` to register the encryption config with EQL.) The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Drizzle-specific shape. > **Runner note.** `stash init` adds `stash` to the project as a dev dependency, so `stash ` runs through whichever package manager the project uses (Bun, pnpm, Yarn, or npm) — examples below show this bare form. Before init has run, prefix with your package manager's one-shot runner: `bunx`, `pnpm dlx`, `yarn dlx`, or `npx`. The CLI's behaviour is identical across all of them. @@ -393,13 +393,17 @@ export const encryptionClient = await Encryption({ schemas: [usersEncryptionSche Generate the migration with `drizzle-kit generate`. The generated SQL should be a single `ALTER TABLE ... ADD COLUMN email_encrypted eql_v2_encrypted;`. Apply with `drizzle-kit migrate`. -Register the new encryption config with EQL: - -```bash -stash db push -``` - -If this is the project's first encrypted column, `db push` writes directly to the active EQL config (nothing to rename). If an active config already exists, `db push` writes the new config as `pending` — that's expected. The pending row will be promoted to active by `stash encrypt cutover` in the cutover step. +> **Using CipherStash Proxy?** +> +> If your app queries encrypted data through CipherStash Proxy, register the new encryption config with EQL: +> +> ```bash +> stash db push +> ``` +> +> If this is the project's first encrypted column, `db push` writes directly to the active EQL config (nothing to rename). If an active config already exists, `db push` writes the new config as `pending` — that's expected. The pending row will be promoted to active by `stash encrypt cutover` in the cutover step. +> +> SDK-only users can skip this step. #### Dual-writing: write to both columns from app code @@ -454,15 +458,21 @@ Resumable, idempotent, chunked. The CLI walks the table in keyset-pagination ord If something goes wrong (e.g. you discover the dual-write code wasn't actually live when backfill ran), re-run with `--force` to re-encrypt every row regardless of current state. -#### Cutover: rename swap and activate +> **SDK-only note:** `stash encrypt cutover` currently requires a pending EQL configuration set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. [Issue #447](https://github.com/cipherstash/stack/issues/447) tracks decoupling this requirement. -First, update the Drizzle schema to the post-cutover shape — switch `email` to use `encryptedType` and remove the `email_encrypted` column. Then re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): +#### Cutover: rename swap and activate -```bash -stash db push -# → writes the new config as `pending`. Active config (still pointing at -# `email_encrypted`) keeps serving while we complete the cutover. -``` +First, update the Drizzle schema to the post-cutover shape — switch `email` to use `encryptedType` and remove the `email_encrypted` column. + +> **Using CipherStash Proxy?** +> +> If using Proxy, re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): +> +> ```bash +> stash db push +> # → writes the new config as `pending`. Active config (still pointing at +> # `email_encrypted`) keeps serving while we complete the cutover. +> ``` Now run the cutover: diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index a5972d08..061f25b3 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -596,8 +596,8 @@ ENCRYPTION ROLLOUT → ⛔ deploy gate → ENCRYPTION CUTOVER ───────────────────── ────────────────────── schema-add backfill historical rows dual-write code switch reads to encrypted -db push (writes pending) drop plaintext column ``` +then drop the plaintext column when reads are decrypting. The gate is the rule that backfill is only safe once the dual-write code is **running in the production environment that owns the database** — not on the developer's laptop, not in CI. Any row inserted during the backfill window must be written to both columns by the application; otherwise it lands in plaintext only and creates silent migration drift. @@ -614,9 +614,10 @@ Everything that lands in the repo and ships in **one** PR: | Action | What changes | |---|---| | Schema-add | Migration adds `_encrypted` (nullable `jsonb`) alongside the existing plaintext column. Plaintext column unchanged; application still writes only plaintext. | -| `stash db push` | Registers the new column in `eql_v2_configuration`. With no active config yet, writes directly to `active`. With an existing active config, writes `pending` — cutover (later) will promote it. | | Dual-write code | Application now writes both `` and `_encrypted` on every persistence path that mutates the row, in the same transaction, on every code branch. Reads still come from the plaintext column. | +> **If you use CipherStash Proxy:** After the schema-add, run `stash db push` to register the new column in `eql_v2_configuration`. With no active config yet it writes directly to `active`; with an existing active config it writes `pending` (cutover will promote it). Required for Proxy-based queries. + **The dual-write definition matters.** "Writes both columns" is not enough. The rule is: every persistence path that mutates this row writes both columns, in the same transaction, on every code branch. A single missed branch — a CSV import, an admin action, a background job, a third-party webhook handler — means rows inserted in production after deploy land in plaintext only, and backfill won't catch them. Grep for every site that writes the plaintext column before declaring rollout complete. ### ⛔ Deploy gate @@ -634,12 +635,14 @@ Once dual-writes are recorded as live in `cs_migrations`: | Action | What changes | |---|---| | `stash encrypt backfill` | Walks the table in keyset-pagination order, encrypts each chunk, writes a single transactional `UPDATE` per chunk plus a `cs_migrations` checkpoint. SIGINT-safe; idempotent re-runs converge. | -| Schema rename + `stash db push` | Update the schema file: drop the `_encrypted` suffix; switch the original column declaration onto the encrypted type. Push registers the renamed shape as `pending`. | +| Schema rename | Update the schema file: drop the `_encrypted` suffix; switch the original column declaration onto the encrypted type. | | `stash encrypt cutover` | One transaction: renames `` → `_plaintext`, `_encrypted` → ``, and promotes `pending` → `active`. Application reads of `` now return decrypted ciphertext transparently. | | Wire reads through the encryption client | Read paths must decrypt before returning the value to callers (`decryptModel(row, table)` for Drizzle; `encryptedSupabase` wrapper for Supabase; `decrypt`/`bulkDecryptModels` otherwise). Without this step, reads return raw `eql_v2_encrypted` payloads to end users. | | Remove dual-write code | The plaintext column is now `_plaintext` and is no longer authoritative. Delete the dual-write logic. | | `stash encrypt drop` | Emits a migration that removes `_plaintext`. Apply with the project's normal migration tooling. | +> **If you use CipherStash Proxy:** After the schema rename, run `stash db push` to register the renamed shape as `pending`. This is required for Proxy-based queries; SDK users skip this step. + ### State storage Three sources of truth, kept separate on purpose: @@ -654,6 +657,47 @@ Three sources of truth, kept separate on purpose: ### CLI sequence for a single column +> **Known limitation:** `stash encrypt cutover` currently requires a pending EQL configuration registered via `stash db push`. SDK-only users may hit a "No pending EQL configuration" error. **Workaround:** Run `stash db push` once before `stash encrypt cutover`, even if you don't use CipherStash Proxy. Decoupling cutover from EQL config for SDK users is tracked in issue [#447](https://github.com/cipherstash/stack/issues/447) follow-up work. + +```bash +# Run this often — it's the canonical "where am I?" command. +stash status + +# ---- ENCRYPTION ROLLOUT (one PR, one deploy) ---- +# 1. Add the encrypted twin column via your normal migration tooling +# (drizzle-kit / supabase migrations / etc.). +# 2. Edit application code so every persistence path writes both +# `` and `_encrypted` in the same transaction, on every +# code branch. +# 3. Ship the PR to production. + +# ---- ⛔ DEPLOY GATE ---- +# Verify dual-writes are live, then redraft the plan for cutover work: +stash status +stash plan + +# ---- ENCRYPTION CUTOVER ---- +stash encrypt backfill --table users --column email +# Prompts to confirm dual-writes are live (or pass +# --confirm-dual-writes-deployed in CI). Resumable; SIGINT-safe. + +# Recovery — if dual-writes weren't actually live when backfill ran, +# re-run with --force to encrypt every plaintext row regardless. +stash encrypt backfill --table users --column email --force + +# Edit the schema to drop the `_encrypted` suffix: +stash encrypt cutover --table users --column email +# In one transaction: rename physical columns, promote pending → active. + +# Wire the read paths through the encryption client. Remove dual-write +# code. Then drop the plaintext column: +stash encrypt drop --table users --column email +``` + +#### If you use CipherStash Proxy + +Register and promote encryption config at each phase: + ```bash # Run this often — it's the canonical "where am I?" command. stash status diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index c156b2c4..49e39035 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -406,7 +406,9 @@ type EncryptedSupabaseError = { The hard case: a Supabase table that already exists with live data in a plaintext column you want to encrypt. You can't just change the column type — that would drop the data. -CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code + `db push`) and an **encryption cutover** (backfill + rename + drop). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Supabase-specific shape. +CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + rename + drop). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Supabase-specific shape. + +> **Using CipherStash Proxy?** If you query encrypted data through [CipherStash Proxy](https://github.com/cipherstash/proxy) instead of the SDK, also run `stash db push` after schema-add and again before cutover to register the encrypted column shape with EQL. > **Runner note.** `stash init` adds `stash` to the project as a dev dependency, so `stash ` runs through whichever package manager the project uses (Bun, pnpm, Yarn, or npm) — examples below show this bare form. Before init has run, prefix with your package manager's one-shot runner: `bunx`, `pnpm dlx`, `yarn dlx`, or `npx`. The CLI's behaviour is identical across all of them. @@ -468,13 +470,15 @@ import { users } from './schema' export const encryptionClient = await Encryption({ schemas: [users] }) ``` -Register the new encryption config with EQL: - -```bash -stash db push -``` - -If this is the project's first encrypted column, `db push` writes directly to the active EQL config. If an active config already exists, it writes the new config as `pending` — that's expected. Cutover (later) will promote it. +> **Using CipherStash Proxy?** Register the new encryption config with EQL: +> +> ```bash +> stash db push +> ``` +> +> If this is the project's first encrypted column, `db push` writes directly to the active EQL config. If an active config already exists, it writes the new config as `pending` — that's expected. Cutover (later) will promote it. +> +> **SDK users:** Skip this step. Your encryption config lives in app code. #### Dual-writing: write to both columns from app code @@ -548,13 +552,15 @@ export const users = encryptedTable('users', { }) ``` -Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): +> **Known gap (SDK-only users):** `stash encrypt cutover` currently requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. This will be decoupled in a future release — see [issue #447](https://github.com/cipherstash/stack/issues/447). -```bash -stash db push -# → writes the new config as `pending`. Active config (still pointing at -# `email_encrypted`) keeps serving while we complete the cutover. -``` +> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): +> +> ```bash +> stash db push +> # → writes the new config as `pending`. Active config (still pointing at +> # `email_encrypted`) keeps serving while we complete the cutover. +> ``` Now run the cutover: