diff --git a/.changeset/prisma-next-encrypt-backfill.md b/.changeset/prisma-next-encrypt-backfill.md new file mode 100644 index 000000000..6991ced45 --- /dev/null +++ b/.changeset/prisma-next-encrypt-backfill.md @@ -0,0 +1,12 @@ +--- +'stash': patch +--- + +Make `stash encrypt` work in Prisma Next projects. + +`stash encrypt backfill` could not run against a Prisma Next project for two independent reasons, both now fixed: + +- **No encryption client file.** Prisma Next integrations deliberately have none — encrypted columns are declared in the PSL contract. Loading the encryption context hard-failed on the missing file. It now falls back (mirroring the existing Drizzle auto-derive) to detecting the project, locating the emitted `contract.json`, and deriving the v3 schemas with the adapter's own `deriveStackSchemasV3` + `Encryption`. Both `@cipherstash/stack-prisma` and `@cipherstash/stack` are resolved from the user's project, so the CLI's schema view always matches the application's. +- **`cipherstash.cs_migrations` never existed.** That schema is created by `stash eql install`, which the Prisma Next flow skips (EQL installs through the `prisma-next` migration graph, which doesn't carry the tracking schema). The first checkpoint write then failed with an opaque relation-does-not-exist error. `backfill` now bootstraps it via the existing idempotent `installMigrationsSchema` before any event is written. + +`skills/stash-cli` documents both, including that the `client` config option is not required in Prisma Next projects. diff --git a/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts new file mode 100644 index 000000000..4b57c3e4a --- /dev/null +++ b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts @@ -0,0 +1,307 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Prisma Next projects have no hand-authored encryption client file — the +// schema lives in the emitted contract.json. `loadEncryptionContext` +// must derive the v3 schemas from the contract (mirroring the runtime's +// `cipherstashFromStack`) instead of hard-failing on the missing file, and +// `backfillCommand` must create `cipherstash.cs_migrations` itself because the +// Prisma Next EQL install path never runs `stash eql install`. + +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn((_p: string) => false), + readFileSync: vi.fn((_p: string) => '{}'), +})) +vi.mock('node:fs', () => ({ default: fsMocks })) + +const detectPrismaNextMock = vi.hoisted(() => vi.fn(() => false)) +vi.mock('@/commands/db/detect.js', () => ({ + detectPrismaNext: detectPrismaNextMock, + detectDrizzle: vi.fn(() => false), +})) + +const requireUsableEncryptConfigMock = vi.hoisted(() => + vi.fn((config: unknown) => config), +) +vi.mock('@/config/index.js', () => ({ + loadStashConfig: vi.fn(async () => ({ + databaseUrl: 'postgres://test', + client: './src/encryption/index.ts', + })), + requireUsableEncryptConfig: requireUsableEncryptConfigMock, +})) + +vi.mock('@/commands/init/utils.js', () => ({ + detectPackageManager: vi.fn(() => 'npm'), + runnerCommand: vi.fn((_pm: string, ref: string) => `npx ${ref}`), +})) + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + outro: vi.fn(), + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + }, + note: vi.fn(), +})) + +const encryptionClientStub = vi.hoisted(() => ({ + getEncryptConfig: vi.fn(() => ({ v: 2, tables: {} })), +})) +const deriveStackSchemasV3Mock = vi.hoisted(() => + vi.fn((_contract: unknown): unknown[] => []), +) +const encryptionMock = vi.hoisted(() => vi.fn(async () => encryptionClientStub)) +const jitiImportMock = vi.hoisted(() => + vi.fn(async (specifier: string) => { + if (specifier === '@cipherstash/stack-prisma/v3') { + return { deriveStackSchemasV3: deriveStackSchemasV3Mock } + } + if (specifier === '@cipherstash/stack/v3') { + return { Encryption: encryptionMock } + } + throw new Error(`unexpected jiti import: ${specifier}`) + }), +) +vi.mock('jiti', () => ({ + createJiti: vi.fn(() => ({ import: jitiImportMock })), +})) + +const queryMock = vi.hoisted(() => + vi.fn(async (_sql: string) => ({ rows: [] as unknown[] })), +) +vi.mock('pg', () => ({ + default: { + Pool: class { + connect = vi.fn(async () => ({ query: queryMock, release: vi.fn() })) + end = vi.fn(async () => {}) + }, + }, +})) + +// The workspace `@cipherstash/stack` package resolves via its built dist in +// CI; mock the `schema` subpath with a zod enum matching the shape +// `translateCastAs` needs so this test doesn't depend on a prior build. +vi.mock('@cipherstash/stack/schema', async () => { + const { z } = await import('zod') + return { + castAsEnum: z.enum(['string', 'text', 'number', 'bigint']).default('text'), + toEqlCastAs: vi.fn((v: string) => v), + } +}) + +const migrateMocks = vi.hoisted(() => ({ + appendEvent: vi.fn(async () => {}), + detectColumnEqlVersion: vi.fn(async () => 3), + installMigrationsSchema: vi.fn(async () => {}), + progress: vi.fn(async () => ({ phase: 'dual-writing' })), + runBackfill: vi.fn(async () => ({ rowsProcessed: 0, rowsTotal: 0 })), + upsertManifestColumn: vi.fn(async () => {}), +})) +vi.mock('@cipherstash/migrate', () => migrateMocks) + +import { loadEncryptionContext } from '../context.js' + +const FAKE_TABLE = { + tableName: 'transaction', + build: () => ({ + tableName: 'transaction', + columns: { email_encrypted: { cast_as: 'text' } }, + }), +} + +/** process.exit that throws, so exit paths terminate the code under test. */ +function spyExit() { + return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`) + }) as never) +} + +function spyConsoleError() { + return vi.spyOn(console, 'error').mockImplementation(() => {}) +} + +beforeEach(() => { + vi.clearAllMocks() + fsMocks.existsSync.mockImplementation(() => false) + fsMocks.readFileSync.mockImplementation(() => '{}') + detectPrismaNextMock.mockReturnValue(false) + deriveStackSchemasV3Mock.mockReturnValue([]) +}) + +describe('loadEncryptionContext — Prisma Next contract derivation', () => { + it('still hard-fails on a missing client file outside Prisma Next projects', async () => { + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('Encrypt client file not found'), + ) + expect(jitiImportMock).not.toHaveBeenCalled() + + exit.mockRestore() + consoleError.mockRestore() + }) + + it('derives tables and client from contract.json in a Prisma Next project', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + fsMocks.readFileSync.mockReturnValue('{"storage":{}}') + deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE]) + + const ctx = await loadEncryptionContext() + + expect(deriveStackSchemasV3Mock).toHaveBeenCalledWith({ storage: {} }) + expect(encryptionMock).toHaveBeenCalledWith({ schemas: [FAKE_TABLE] }) + expect(requireUsableEncryptConfigMock).toHaveBeenCalled() + expect(ctx.client).toBe(encryptionClientStub) + expect(ctx.tables.get('transaction')).toBe(FAKE_TABLE) + }) + + it('errors with `contract emit` guidance when no contract.json exists', async () => { + detectPrismaNextMock.mockReturnValue(true) + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('prisma-next contract emit'), + ) + + exit.mockRestore() + consoleError.mockRestore() + }) + + // `deriveStackSchemasV3` comes from a jiti import, so its array return type + // is an assertion. A version skew that reshaped the export must not reach the + // `.length` read as a bare TypeError (#819 review). + it('errors when deriveStackSchemasV3 returns a non-array', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue(undefined as unknown as unknown[]) + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('not an array of tables'), + ) + + exit.mockRestore() + consoleError.mockRestore() + }) + + // `loadEncryptionContext` runs before `backfillCommand`'s own try/catch, so + // a throw from `Encryption()` — invalid credentials, a bad keyset — would + // escape as a raw stack trace rather than this function's guided message + // (#819 review). + it('errors with a credentials hint when Encryption() throws', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE]) + encryptionMock.mockRejectedValueOnce(new Error('invalid keyset id')) + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('Failed to initialize the encryption client'), + ) + expect(requireUsableEncryptConfigMock).not.toHaveBeenCalled() + + exit.mockRestore() + consoleError.mockRestore() + }) + + it('errors when the contract has no cipherstash columns', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue([]) + const exit = spyExit() + const consoleError = spyConsoleError() + + await expect(loadEncryptionContext()).rejects.toThrow('process.exit:1') + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('No cipherstash-encrypted columns'), + ) + + exit.mockRestore() + consoleError.mockRestore() + }) +}) + +describe('backfillCommand — cs_migrations bootstrap', () => { + it('installs the migrations schema before running the backfill', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE]) + + const { backfillCommand } = await import('../backfill.js') + await backfillCommand({ + table: 'transaction', + column: 'email', + pkColumn: 'id', + confirmDualWritesDeployed: true, + }) + + expect(migrateMocks.installMigrationsSchema).toHaveBeenCalledTimes(1) + expect(migrateMocks.runBackfill).toHaveBeenCalledTimes(1) + const installOrder = + migrateMocks.installMigrationsSchema.mock.invocationCallOrder[0]! + const backfillOrder = migrateMocks.runBackfill.mock.invocationCallOrder[0]! + expect(installOrder).toBeLessThan(backfillOrder) + }) + + // Without the BackfillConfigError wrapper this lands in the generic handler, + // which deliberately prints only "Backfill failed (…)" — the opaque message + // the bootstrap exists to remove (#819 review). + it('explains a bootstrap failure instead of the generic "Backfill failed"', async () => { + detectPrismaNextMock.mockReturnValue(true) + fsMocks.existsSync.mockImplementation((p: string) => + p.endsWith('src/prisma/contract.json'), + ) + deriveStackSchemasV3Mock.mockReturnValue([FAKE_TABLE]) + migrateMocks.installMigrationsSchema.mockRejectedValueOnce( + new Error('permission denied for database app'), + ) + const exit = spyExit() + const p = await import('@clack/prompts') + + const { backfillCommand } = await import('../backfill.js') + await expect( + backfillCommand({ + table: 'transaction', + column: 'email', + pkColumn: 'id', + confirmDualWritesDeployed: true, + }), + ).rejects.toThrow('process.exit:1') + + expect(migrateMocks.runBackfill).not.toHaveBeenCalled() + const logged = vi + .mocked(p.log.error) + .mock.calls.map(([m]) => m) + .join('\n') + expect(logged).toContain('cs_migrations') + expect(logged).toContain('permission denied for database app') + expect(logged).not.toContain('Backfill failed') + + exit.mockRestore() + }) +}) diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts index b4e3fdcf4..216bd1f5b 100644 --- a/packages/cli/src/commands/encrypt/backfill.ts +++ b/packages/cli/src/commands/encrypt/backfill.ts @@ -2,6 +2,7 @@ import { appendEvent, columnExists, detectColumnEqlVersion, + installMigrationsSchema, type ManifestColumn, progress, runBackfill, @@ -131,6 +132,27 @@ export async function backfillCommand(options: BackfillCommandOptions) { process.on('SIGINT', onSignal) process.on('SIGTERM', onSignal) db = await pool.connect() + + // `stash eql install` normally creates `cipherstash.cs_migrations`, but + // not every integration runs it — Prisma Next installs EQL through its + // own migration graph, which doesn't carry the tracking schema. The DDL + // is CREATE IF NOT EXISTS throughout, so this is a no-op everywhere else. + // + // Rethrown as a config error: this is a known prerequisite with a known + // fix (usually CREATE privileges), and the generic handler below would + // reduce it to "Backfill failed" — the opaque message this whole pass + // exists to remove (#819 review). The DDL touches no row data, so its + // message is safe to surface verbatim. + try { + await installMigrationsSchema(db) + } catch (error) { + throw new BackfillConfigError( + 'Could not create the `cipherstash.cs_migrations` tracking schema, which backfill needs to checkpoint progress.\n\n' + + `Cause: ${error instanceof Error ? error.message : String(error)}\n\n` + + 'This database user needs CREATE on the database (to create the `cipherstash` schema) and on that schema. Grant it, or have someone run `stash eql install` once, then re-run.', + ) + } + const pkColumn = options.pkColumn ?? (await detectPkColumn(db, options.table)) diff --git a/packages/cli/src/commands/encrypt/context.ts b/packages/cli/src/commands/encrypt/context.ts index 264bc2099..af22df0f2 100644 --- a/packages/cli/src/commands/encrypt/context.ts +++ b/packages/cli/src/commands/encrypt/context.ts @@ -1,6 +1,7 @@ import fs from 'node:fs' import path from 'node:path' import type { EncryptionClient } from '@cipherstash/stack/encryption' +import { detectPrismaNext } from '@/commands/db/detect.js' import { loadStashConfig, type ResolvedStashConfig, @@ -49,6 +50,13 @@ export async function loadEncryptionContext(): Promise { const resolvedPath = path.resolve(process.cwd(), stashConfig.client) if (!fs.existsSync(resolvedPath)) { + // Prisma Next projects have no hand-authored encryption client — the + // schema lives in the emitted contract.json and the runtime derives it + // via `cipherstashFromStack`. Mirror that derivation here so the encrypt + // commands work without asking users to author a bridge file. + const derived = await tryLoadPrismaNextContext(stashConfig) + if (derived) return derived + console.error( `Error: Encrypt client file not found at ${resolvedPath}\n\nCheck the "client" path in your stash.config.ts.`, ) @@ -153,6 +161,156 @@ export async function loadEncryptionContext(): Promise { return { stashConfig, client, tables } } +/** + * Well-known locations for a Prisma Next emitted contract, relative to the + * project root. `prisma-next contract emit` writes `contract.json` next to + * the authored contract, which the scaffolds place under `src/prisma/` or + * `prisma/`. + */ +const PRISMA_NEXT_CONTRACT_CANDIDATES = [ + 'src/prisma/contract.json', + 'prisma/contract.json', + 'contract.json', +] + +/** + * Derive an `EncryptionContext` for a Prisma Next project — the fallback + * used when the configured encrypt client file does not exist. + * + * Prisma Next integrations deliberately have no client file: encrypted + * columns are declared in the PSL contract, and the runtime adapter derives + * the v3 schemas from the emitted `contract.json` (`deriveStackSchemasV3`) + * before constructing the same `Encryption` client this function builds. + * Reusing that derivation keeps the CLI's view of the schema identical to + * the application's. + * + * Returns `undefined` when the project doesn't look like Prisma Next, so + * the caller can fall through to its existing missing-client error. Inside + * a detected Prisma Next project, failures are hard errors (exit 1) with + * the specific missing piece named — falling through to "client file not + * found" from here would point users at a file they are not supposed to + * author. + * + * Both `@cipherstash/stack-prisma` and `@cipherstash/stack` are resolved + * from the *user's* project (via jiti anchored at their package.json), not + * from the CLI's own dependency tree, so the derived schemas and client + * always match the versions the application runs. + */ +async function tryLoadPrismaNextContext( + stashConfig: ResolvedStashConfig, +): Promise { + const cwd = process.cwd() + if (!detectPrismaNext(cwd)) return undefined + + const contractPath = PRISMA_NEXT_CONTRACT_CANDIDATES.map((candidate) => + path.resolve(cwd, candidate), + ).find((candidate) => fs.existsSync(candidate)) + if (!contractPath) { + console.error( + `Error: This looks like a Prisma Next project, but no emitted contract was found.\n` + + `Searched: ${PRISMA_NEXT_CONTRACT_CANDIDATES.join(', ')}\n\n` + + 'Run `prisma-next contract emit` first. (Or point "client" in stash.config.ts at an encryption client file to skip contract derivation.)', + ) + process.exit(1) + } + + const { createJiti } = await import('jiti') + const jiti = createJiti(path.join(cwd, 'package.json'), { + interopDefault: true, + }) + + let deriveStackSchemasV3: (contract: unknown) => readonly unknown[] + let Encryption: (opts: { + schemas: readonly unknown[] + }) => Promise + try { + const stackPrismaV3 = (await jiti.import( + '@cipherstash/stack-prisma/v3', + )) as { + deriveStackSchemasV3: typeof deriveStackSchemasV3 + } + deriveStackSchemasV3 = stackPrismaV3.deriveStackSchemasV3 + const stackV3 = (await jiti.import('@cipherstash/stack/v3')) as { + Encryption: typeof Encryption + } + Encryption = stackV3.Encryption + } catch (error) { + console.error( + 'Error: Failed to load @cipherstash/stack-prisma / @cipherstash/stack from this project.\n' + + 'Both must be installed to run encrypt commands against a Prisma Next contract.\n', + ) + console.error(error) + process.exit(1) + } + + let schemas: readonly unknown[] + try { + const contractJson = JSON.parse( + fs.readFileSync(contractPath, 'utf-8'), + ) as unknown + schemas = deriveStackSchemasV3(contractJson) + } catch (error) { + // deriveStackSchemasV3's own errors are author-facing and name the + // offending column; JSON.parse errors name the broken file below. + console.error( + `Error: Failed to derive encryption schemas from ${contractPath}\n`, + ) + console.error(error) + process.exit(1) + } + + // `deriveStackSchemasV3` is typed from a jiti import, so its return value is + // an assertion, not a checked fact. A version skew that renamed or reshaped + // the export would hand us `undefined` here, and the `.length` read below + // would throw a raw TypeError past all of this guidance (#819 review). + if (!Array.isArray(schemas)) { + console.error( + `Error: @cipherstash/stack-prisma's deriveStackSchemasV3 returned ${typeof schemas}, not an array of tables.\n\n` + + "This usually means the installed @cipherstash/stack-prisma doesn't match this CLI release. Align the versions and re-run.", + ) + process.exit(1) + } + + if (schemas.length === 0) { + console.error( + `Error: No cipherstash-encrypted columns found in ${contractPath}.\n\n` + + 'Declare at least one `cipherstash.*()` column in your contract and re-run `prisma-next contract emit`.', + ) + process.exit(1) + } + + // `Encryption()` throws on invalid credentials or config — a very plausible + // first run. `loadEncryptionContext` is called before `backfillCommand`'s own + // try/catch, so an unguarded throw here escapes as a raw stack trace, unlike + // every other failure in this function. The client-file path doesn't have + // this hole: there the client is constructed inside the user's module, which + // `jiti.import` already wraps (#819 review). + let client: EncryptionClient + try { + client = await Encryption({ schemas }) + } catch (error) { + console.error( + `Error: Failed to initialize the encryption client from ${contractPath}\n\n` + + 'Encryption schemas derived fine, so this is a credentials or keyset problem, not a contract one. Check CS_* in this shell (`stash env`) or your `~/.cipherstash` profile.\n', + ) + console.error(error) + process.exit(1) + } + + requireUsableEncryptConfig( + client.getEncryptConfig(), + contractPath, + `Encryption schemas derived from ${contractPath}`, + ) + + const tables = new Map() + for (const schema of schemas as EncryptedTableLike[]) { + tables.set(schema.tableName, schema) + } + + return { stashConfig, client, tables } +} + /** * Look up the `EncryptedTable` for the given table name in the loaded * context. Exits the process with code `1` if the table is not declared diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 2932be644..267d5c9a6 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -251,15 +251,20 @@ export async function loadEncryptConfig( * * Both refusals are hard exits: there is no partially-usable state here, and * every caller would otherwise have to re-derive that. + * + * `sourceLabel` names where the config came from. It defaults to the + * client-file phrasing, but not every caller loads a client file: the Prisma + * Next pass derives the schemas from an emitted `contract.json`, where + * "Encryption client in …/contract.json" would send the user looking for a + * file they are not supposed to author (#819 review). */ export function requireUsableEncryptConfig( config: EncryptConfig | undefined, encryptClientPath: string, + sourceLabel = `Encryption client in ${encryptClientPath}`, ): EncryptConfig { if (!config) { - console.error( - `Error: Encryption client in ${encryptClientPath} has no initialized encrypt config.`, - ) + console.error(`Error: ${sourceLabel} has no initialized encrypt config.`) process.exit(1) } @@ -275,7 +280,7 @@ export function requireUsableEncryptConfig( const tables = Object.keys(config.tables ?? {}) if (tables.length === 1 && tables[0] === PLACEHOLDER_TABLE_NAME) { console.error( - `Error: ${encryptClientPath} still contains the placeholder table \`${PLACEHOLDER_TABLE_NAME}\` that \`stash init\` wrote.\n\nDeclare your encrypted columns and pass those tables to Encryption({ schemas: [...] }) in that file, then re-run this command.`, + `Error: ${sourceLabel} still contains the placeholder table \`${PLACEHOLDER_TABLE_NAME}\` that \`stash init\` wrote.\n\nDeclare your encrypted columns and pass those tables to Encryption({ schemas: [...] }) in that file, then re-run this command.`, ) process.exit(1) } diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index cf354bd8c..fb2807f0d 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -198,7 +198,9 @@ export default defineConfig({ | Option | Required | Default | Purpose | |---|---|---|---| | `databaseUrl` | yes | — | PostgreSQL connection string | -| `client` | no | `./src/encryption/index.ts` | Encryption client, loaded by `db validate` and `encrypt backfill` (`schema build` only writes here; `encrypt drop` resolves against the database) | +| `client` | no | `./src/encryption/index.ts` | Encryption client, loaded by `db validate` and `encrypt backfill` (`schema build` only writes here; `encrypt drop` resolves against the database). **Not required in Prisma Next projects** — see below. | + +In a **Prisma Next** project there is deliberately no client file: encrypted columns are declared in the PSL contract. When the configured `client` path is missing, `encrypt backfill` — the only command that loads it — detects Prisma Next, reads the emitted `contract.json` (searched at `src/prisma/`, `prisma/`, then the project root), and derives the schemas with the adapter's own `deriveStackSchemasV3`, so the CLI's schema view matches the application's. (`encrypt status` and `encrypt drop` never read the client file; they resolve against the database.) Both `@cipherstash/stack-prisma` and `@cipherstash/stack` are resolved from *your* project, not the CLI's dependency tree. Run `prisma-next contract emit` first; if the contract declares no `cipherstash.*()` column, the command says so rather than reporting a missing client file. Resolved by walking up from `process.cwd()`, like `tsconfig.json`. `stash init` scaffolds it; `stash eql install` offers to. @@ -440,6 +442,8 @@ Chunked, resumable, idempotent. Walks the table in keyset-pagination order, encr Backfill requires a `public.eql_v3_*` target column, records version 3 and the `dropped` target phase in `.cipherstash/migrations.json`, then prints the next steps: switch the application to the encrypted column by name and run `stash encrypt drop`. A missing, plaintext, or legacy v2 target is rejected before encryption begins. +**Prisma Next.** Works with no encryption client file — schemas come from the emitted `contract.json` (see [`client`](#configuration)). Backfill also bootstraps the `cipherstash.cs_migrations` tracking schema itself, since the Prisma Next flow installs EQL through the `prisma-next` migration graph rather than `stash eql install` (which is what creates that schema elsewhere). Both steps are idempotent. + **Dual-write precondition.** The application must already write both `` and `_encrypted` on every insert and update. Otherwise rows written *during* the backfill land in plaintext only, silently. The first run prompts (interactive) or requires `--confirm-dual-writes-deployed` (non-interactive), then records `dual_writing`. Resumes don't re-prompt. **Keyset precondition — the backfill's client must resolve to the same keyset as the application's.** Backfill encrypts through whatever credentials its environment *resolves*: `CS_*` variables when present, otherwise the native auto strategy falls back to the local `~/.cipherstash` dev profile — so a shell without the variables silently runs as your laptop's client. What must match between backfill and app is the **keyset** their clients resolve to, not the credential strings (`stash-zerokms` is canonical). Two clients bound to the same keyset interoperate fully — search included, since index terms come from a per-keyset key. A backfill bound to a *different* keyset is the quiet failure: the app can still decrypt those rows if granted that keyset, but its query terms derive under its own keyset, so encrypted search returns zero rows for them — no error. Export the target environment's `CS_*` values in the shell running the backfill (non-negotiable in CI/production) so the ciphertext lands in that environment's keyspace and the run is attributed to its client. See [`env`](#env) and `stash-auth`. diff --git a/skills/stash-prisma/SKILL.md b/skills/stash-prisma/SKILL.md index f91af86d1..fb3234190 100644 --- a/skills/stash-prisma/SKILL.md +++ b/skills/stash-prisma/SKILL.md @@ -169,6 +169,27 @@ standalone installer for exactly this reason. The CLI enforces this: `stash eql install` detects a Prisma Next project and refuses (pointing you at `prisma-next migrate`) unless you pass `--force`. +## Encrypting data that already exists (`stash encrypt`) + +Declaring an encrypted column only covers new writes. To encrypt rows already in +a plaintext column, use the CLI's rollout lifecycle — `stash encrypt backfill`, +then switch reads, then `stash encrypt drop` (`stash-cli` and `stash-encryption` +are canonical for the sequence and its dual-write precondition). + +Two things are Prisma-Next-specific: + +- **No encryption client file is needed.** `stash.config.ts`'s `client` option + points at a file this integration deliberately doesn't have. `stash encrypt + backfill` — the only command that loads it — detects a Prisma Next project, + reads the emitted `contract.json` (`src/prisma/`, `prisma/`, or the project + root), and derives the schemas the same way the runtime does. So run + `prisma-next contract emit` before `stash encrypt backfill`, and don't + hand-author a bridge client file. +- **The tracking schema is created for you.** `cipherstash.cs_migrations` is + normally created by `stash eql install`, which this integration never runs. + `stash encrypt backfill` bootstraps it itself (idempotently), so the backfill + user needs CREATE on the database the first time. + ## Indexing encrypted columns The adapter emits the encrypted query operators, but **no index DDL** — without