From 1f6b16b4e30f51667307e52057cb29853eabe13d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 28 Jul 2026 19:21:13 +1000 Subject: [PATCH 1/3] fix(cli): make `stash encrypt` work in Prisma Next projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stash encrypt backfill` (and the other encrypt commands) could not run against a Prisma Next project, for two independent reasons: 1. `loadEncryptionContext` hard-failed when the configured encrypt client file was missing — but Prisma Next integrations deliberately have no client file: encrypted columns are declared in the PSL contract and the runtime derives the schemas from the emitted contract.json. Add a fallback pass (mirroring the existing Drizzle auto-derive) that detects a Prisma Next project via `detectPrismaNext`, locates contract.json, and reuses the adapter's own `deriveStackSchemasV3` + `Encryption` to build the context. Both packages are resolved from the user's project so the CLI's schema view always matches the application's. 2. `cipherstash.cs_migrations` never existed: it 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 `appendEvent` insert then failed with an opaque relation-does-not-exist error. Bootstrap the schema in `backfillCommand` via the existing idempotent `installMigrationsSchema` before any event is written. Also documents both in `skills/stash-cli` — notably that the `client` config option is not required in a Prisma Next project, which that skill's option table previously implied it was — and adds the changeset. Validated end-to-end against a Prisma Next app (Next.js 16, contract- declared BigIntOrd + TextSearch twins): backfill derives the schemas with no client file, creates cs_migrations, converges, and `encrypt status` reports both columns as backfilled v3. --- .changeset/prisma-next-encrypt-backfill.md | 12 + .../__tests__/prisma-next-context.test.ts | 225 ++++++++++++++++++ packages/cli/src/commands/encrypt/backfill.ts | 8 + packages/cli/src/commands/encrypt/context.ts | 125 ++++++++++ skills/stash-cli/SKILL.md | 6 +- 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 .changeset/prisma-next-encrypt-backfill.md create mode 100644 packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts 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..22b22cb55 --- /dev/null +++ b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts @@ -0,0 +1,225 @@ +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() + }) + + 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) + }) +}) diff --git a/packages/cli/src/commands/encrypt/backfill.ts b/packages/cli/src/commands/encrypt/backfill.ts index b4e3fdcf4..8a86d3b12 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,13 @@ 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. + await installMigrationsSchema(db) + 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..391955f8e 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,123 @@ 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) + } + + 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) + } + + const client = await Encryption({ schemas }) + requireUsableEncryptConfig(client.getEncryptConfig(), 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/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index cf354bd8c..f9ad75367 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` commands detect Prisma Next, read the emitted `contract.json` (searched at `src/prisma/`, `prisma/`, then the project root), and derive the schemas with the adapter's own `deriveStackSchemasV3` — so the CLI's schema view matches the application's. 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`. From 23f529c4d1221faf278f73b6cf4227967b3398ad Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 30 Jul 2026 15:27:33 +1000 Subject: [PATCH 2/3] fix(cli,skills): close the #819 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on #819. - `installMigrationsSchema` failures fell through to the generic catch, which prints only "Backfill failed (…)" by design (its message path can carry plaintext). That is the exact opaque error this bootstrap exists to remove. Wrap it in `BackfillConfigError` naming the cause and the likely fix (CREATE on the database), which the handler prints verbatim — safe here because the DDL touches no row data. - `deriveStackSchemasV3`'s array return type is an assertion, not a checked fact: it arrives through `jiti.import()` behind an `as` cast, so a version skew that reshaped the export would hand back `undefined` and the `schemas.length` read would throw a bare TypeError past all of the surrounding guidance. Guard with `Array.isArray` and name the skew. - `requireUsableEncryptConfig` phrased both refusals as "Encryption client in ", so the derived path produced "Encryption client in …/contract.json" — pointing users at a file Prisma Next projects are not supposed to author. Add an optional `sourceLabel` (defaulting to the existing wording, so the client-file caller is unchanged) and pass a derived-source label. - `skills/stash-prisma` gained an "Encrypting data that already exists" section: the encrypt lifecycle needs no client file here, and backfill bootstraps `cs_migrations` itself. The review asked for this skill as well as `stash-cli` (already updated in the previous commit). - Dropped a stale `#` placeholder from the test header. Two tests added: the non-array guard, and that a bootstrap failure explains itself instead of reporting "Backfill failed". cli suite 938. --- .../__tests__/prisma-next-context.test.ts | 60 ++++++++++++++++++- packages/cli/src/commands/encrypt/backfill.ts | 16 ++++- packages/cli/src/commands/encrypt/context.ts | 18 +++++- packages/cli/src/config/index.ts | 13 ++-- skills/stash-prisma/SKILL.md | 20 +++++++ 5 files changed, 120 insertions(+), 7 deletions(-) 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 index 22b22cb55..96a1babe2 100644 --- a/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts +++ b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts @@ -1,7 +1,7 @@ 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` +// 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 @@ -180,6 +180,27 @@ describe('loadEncryptionContext — Prisma Next contract derivation', () => { 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() + }) + it('errors when the contract has no cipherstash columns', async () => { detectPrismaNextMock.mockReturnValue(true) fsMocks.existsSync.mockImplementation((p: string) => @@ -222,4 +243,41 @@ describe('backfillCommand — cs_migrations bootstrap', () => { 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 8a86d3b12..216bd1f5b 100644 --- a/packages/cli/src/commands/encrypt/backfill.ts +++ b/packages/cli/src/commands/encrypt/backfill.ts @@ -137,7 +137,21 @@ export async function backfillCommand(options: BackfillCommandOptions) { // 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. - await installMigrationsSchema(db) + // + // 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 391955f8e..452baf2b2 100644 --- a/packages/cli/src/commands/encrypt/context.ts +++ b/packages/cli/src/commands/encrypt/context.ts @@ -259,6 +259,18 @@ async function tryLoadPrismaNextContext( 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` + @@ -268,7 +280,11 @@ async function tryLoadPrismaNextContext( } const client = await Encryption({ schemas }) - requireUsableEncryptConfig(client.getEncryptConfig(), contractPath) + requireUsableEncryptConfig( + client.getEncryptConfig(), + contractPath, + `Encryption schemas derived from ${contractPath}`, + ) const tables = new Map() for (const schema of schemas as EncryptedTableLike[]) { 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-prisma/SKILL.md b/skills/stash-prisma/SKILL.md index f91af86d1..fbe140ea9 100644 --- a/skills/stash-prisma/SKILL.md +++ b/skills/stash-prisma/SKILL.md @@ -169,6 +169,26 @@ 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. The encrypt + commands detect a Prisma Next project, read the emitted `contract.json` + (`src/prisma/`, `prisma/`, or the project root), and derive the schemas the + same way the runtime does — so run `prisma-next contract emit` before + `stash encrypt`, 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 From 977140bbc9ff5e66fd4fc6f3e2de5d217a97efbd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 30 Jul 2026 15:54:48 +1000 Subject: [PATCH 3/3] fix(cli,skills): guard Encryption() in the Prisma Next pass; name the one command that derives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the #819 review. `tryLoadPrismaNextContext` caught every failure mode except the last one. `Encryption({ schemas })` throws on invalid credentials or a bad keyset — a plausible first run — and `loadEncryptionContext()` is called at `backfillCommand`'s line 114, before that command's own try/catch at 131. So the one unguarded call in the function was also the one whose throw escaped as a raw stack trace. The client-file path never had this hole: there the client is constructed inside the user's module, which `jiti.import` already wraps. Now caught, with the message pointing at credentials rather than the contract, since the schemas by definition derived fine to reach that line. Both skills claimed "the encrypt commands" detect Prisma Next. Only `encrypt backfill` does — it is the sole caller of `loadEncryptionContext`. `encrypt status` and `encrypt drop` load `stash.config.ts` alone and resolve against the database, so they never read a client file and never needed the derivation. Named the single command in `stash-cli` and `stash-prisma`, and said what the other two do instead. Tests: 939 passed (61 files), up one for the new guard. --- .../__tests__/prisma-next-context.test.ts | 24 +++++++++++++++++++ packages/cli/src/commands/encrypt/context.ts | 19 ++++++++++++++- skills/stash-cli/SKILL.md | 2 +- skills/stash-prisma/SKILL.md | 11 +++++---- 4 files changed, 49 insertions(+), 7 deletions(-) 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 index 96a1babe2..4b57c3e4a 100644 --- a/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts +++ b/packages/cli/src/commands/encrypt/__tests__/prisma-next-context.test.ts @@ -201,6 +201,30 @@ describe('loadEncryptionContext — Prisma Next contract derivation', () => { 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) => diff --git a/packages/cli/src/commands/encrypt/context.ts b/packages/cli/src/commands/encrypt/context.ts index 452baf2b2..af22df0f2 100644 --- a/packages/cli/src/commands/encrypt/context.ts +++ b/packages/cli/src/commands/encrypt/context.ts @@ -279,7 +279,24 @@ async function tryLoadPrismaNextContext( process.exit(1) } - const client = await Encryption({ schemas }) + // `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, diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index f9ad75367..fb2807f0d 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -200,7 +200,7 @@ export default defineConfig({ | `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). **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` commands detect Prisma Next, read the emitted `contract.json` (searched at `src/prisma/`, `prisma/`, then the project root), and derive the schemas with the adapter's own `deriveStackSchemasV3` — so the CLI's schema view matches the application's. 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. +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. diff --git a/skills/stash-prisma/SKILL.md b/skills/stash-prisma/SKILL.md index fbe140ea9..fb3234190 100644 --- a/skills/stash-prisma/SKILL.md +++ b/skills/stash-prisma/SKILL.md @@ -179,11 +179,12 @@ 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. The encrypt - commands detect a Prisma Next project, read the emitted `contract.json` - (`src/prisma/`, `prisma/`, or the project root), and derive the schemas the - same way the runtime does — so run `prisma-next contract emit` before - `stash encrypt`, and don't hand-author a bridge client file. + 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