Skip to content

Commit cf13a34

Browse files
calvinbrewerclaude
authored andcommitted
fix(prisma-next): PR #655 review hardening across the v3 seams
- wire-v3: v3ToDriver coerces JSON.stringify's undefined branch to null; v3FromDriver gains overloads so a nullable input yields a nullable result instead of laundering null into T. - sdk-adapter-v3: bulkDecrypt validates response cardinality (a truncated response would silently misalign plaintexts); the deliberate as-never bridges carry the repo's biome suppression. - pack meta: extensionPacks.cipherstash now registers the 40 v3 codec metadata instances + storage rows (derived from the catalog, never hand-listed) and the EncryptedNumber type import, so consumer contracts describe the codecs their fields use. - live suites: direct dotenv/config imports, canonical column-order binding in insertEncryptedRows (a differing key order could bind values to the wrong SQL columns), and migration-apply now uninstalls EQL v3 first so the customer-facing migration SQL genuinely executes on a reused database (test:live is serial now — the reinstall is destructive across files). - bundling-isolation: entry checks scan the complete import graph for every v2 wire/codec-factory marker, not just entry bodies. Verified live: 599 unit + 30 live-PG + 582 integration + 40 example-e2e + 7 README-walkthrough tests green against real ZeroKMS + Postgres. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6d17336 commit cf13a34

17 files changed

Lines changed: 322 additions & 92 deletions

packages/prisma-next/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,8 @@
7777
"dev": "tsup --watch",
7878
"test": "vitest run",
7979
"test:coverage": "vitest run --coverage",
80-
"test:live": "vitest run test/live",
80+
"test:live": "vitest run test/live --fileParallelism=false",
81+
"test:integration": "vitest run --config integration/vitest.config.ts",
8182
"typecheck": "tsc --project tsconfig.json --noEmit",
8283
"lint": "biome check . --error-on-warnings",
8384
"lint:fix": "biome check --write .",
@@ -99,6 +100,7 @@
99100
},
100101
"devDependencies": {
101102
"@cipherstash/eql": "3.0.0",
103+
"@cipherstash/test-kit": "workspace:*",
102104
"@prisma-next/adapter-postgres": "0.14.0",
103105
"@prisma-next/cli": "0.14.0",
104106
"@prisma-next/driver-postgres": "0.14.0",
@@ -108,6 +110,7 @@
108110
"@prisma-next/sql-contract-ts": "0.14.0",
109111
"@prisma-next/sql-schema-ir": "0.14.0",
110112
"@prisma-next/target-postgres": "0.14.0",
113+
"dotenv": "17.4.2",
111114
"fast-check": "^4.8.0",
112115
"pathe": "^2.0.3",
113116
"postgres": "^3.4.8",

packages/prisma-next/src/extension-metadata/codec-metadata.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ import type { JsonValue } from '@prisma-next/contract/types'
2626
import {
2727
type AnyCodecDescriptor,
2828
CodecImpl,
29+
type CodecTrait,
2930
} from '@prisma-next/framework-components/codec'
31+
import {
32+
envelopeTypeNameForCastAs,
33+
V3_DOMAIN_META_BY_CODEC_ID,
34+
type V3DomainMeta,
35+
} from '../v3/catalog'
3036
import {
3137
CIPHERSTASH_BIGINT_CODEC_ID,
3238
CIPHERSTASH_BOOLEAN_CODEC_ID,
@@ -37,6 +43,7 @@ import {
3743
CIPHERSTASH_STRING_CODEC_ID,
3844
EQL_V2_ENCRYPTED_TYPE,
3945
} from './constants'
46+
import { v3TraitsForCapabilities } from './constants-v3'
4047

4148
function makeMetadataDescriptor(
4249
codecId: string,
@@ -132,3 +139,68 @@ export const cipherstashJsonCodecMetadata = new CipherstashCodecMetadata(
132139
makeMetadataDescriptor(CIPHERSTASH_JSON_CODEC_ID, 'EncryptedJson'),
133140
'EncryptedJson',
134141
)
142+
143+
// ---------------------------------------------------------------------------
144+
// EQL v3 — one metadata codec per catalog domain (all 40), DERIVED from the
145+
// catalog (never hand-listed) so pack-meta can never drift from what the
146+
// runtime registers. Mirrors the truthful metadata of the runtime auxiliary
147+
// descriptors in `../v3/codec-runtime-v3.ts` (traits from capabilities,
148+
// concrete `public.eql_v3_*` native type, `isParameterized: true` for the
149+
// static `{ castAs, capabilities }` typeParams block v3 authoring emits) —
150+
// minus the SDK-bound encode/decode, which pack-meta consumers never call.
151+
// The catalog import stays SDK/envelope-free, preserving the control-vs-
152+
// runtime bundling split this module's header describes.
153+
// ---------------------------------------------------------------------------
154+
155+
function makeV3MetadataDescriptor(
156+
codecId: string,
157+
meta: V3DomainMeta,
158+
typeName: string,
159+
): AnyCodecDescriptor {
160+
return {
161+
codecId,
162+
// Type-level adapter into the framework's closed `CodecTrait` union —
163+
// same rationale as `v3CodecTraits` in `../v3/codec-runtime-v3.ts`.
164+
traits: v3TraitsForCapabilities(meta.capabilities) as readonly CodecTrait[],
165+
targetTypes: [meta.nativeType],
166+
meta: { db: { sql: { postgres: { nativeType: meta.nativeType } } } },
167+
paramsSchema: {
168+
'~standard': {
169+
version: 1,
170+
vendor: 'cipherstash',
171+
validate: (value: unknown) => ({ value }),
172+
},
173+
},
174+
isParameterized: true,
175+
renderOutputType: () => typeName,
176+
factory: () => () => {
177+
throw new Error(
178+
'cipherstash codec: metadata descriptor factory is not callable',
179+
)
180+
},
181+
}
182+
}
183+
184+
/** All 40 v3 metadata codecs, in catalog order. */
185+
export const cipherstashV3CodecMetadataInstances: readonly CipherstashCodecMetadata[] =
186+
[...V3_DOMAIN_META_BY_CODEC_ID.entries()].map(([codecId, meta]) => {
187+
const typeName = envelopeTypeNameForCastAs(meta.castAs)
188+
return new CipherstashCodecMetadata(
189+
makeV3MetadataDescriptor(codecId, meta, typeName),
190+
typeName,
191+
)
192+
})
193+
194+
/** Pack-meta `types.storage` rows for the v3 codecs — one per domain,
195+
* each targeting its own concrete `public.eql_v3_*` native type. */
196+
export const cipherstashV3StorageRows: ReadonlyArray<{
197+
readonly typeId: string
198+
readonly familyId: 'sql'
199+
readonly targetId: 'postgres'
200+
readonly nativeType: string
201+
}> = [...V3_DOMAIN_META_BY_CODEC_ID.entries()].map(([codecId, meta]) => ({
202+
typeId: codecId,
203+
familyId: 'sql',
204+
targetId: 'postgres',
205+
nativeType: meta.nativeType,
206+
}))

packages/prisma-next/src/extension-metadata/descriptor-meta.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
cipherstashDoubleCodecMetadata,
3030
cipherstashJsonCodecMetadata,
3131
cipherstashStringCodecMetadata,
32+
cipherstashV3CodecMetadataInstances,
33+
cipherstashV3StorageRows,
3234
} from './codec-metadata'
3335
import {
3436
CIPHERSTASH_BIGINT_CODEC_ID,
@@ -62,6 +64,8 @@ export const cipherstashPackMeta = {
6264
cipherstashDateCodecMetadata,
6365
cipherstashBooleanCodecMetadata,
6466
cipherstashJsonCodecMetadata,
67+
// EQL v3 — all 40 catalog domains, derived (see codec-metadata.ts).
68+
...cipherstashV3CodecMetadataInstances,
6569
],
6670
// Drives the contract emitter to add
6771
// `import type { CodecTypes as CipherstashTypes } from '@prisma-next/extension-cipherstash/codec-types'`
@@ -113,6 +117,14 @@ export const cipherstashPackMeta = {
113117
named: 'EncryptedJson',
114118
alias: 'EncryptedJson',
115119
},
120+
// v3-only envelope (every numeric v3 domain renders as
121+
// `EncryptedNumber`; the other v3 domains reuse the
122+
// version-neutral envelope classes already imported above).
123+
{
124+
package: '@prisma-next/extension-cipherstash/runtime',
125+
named: 'EncryptedNumber',
126+
alias: 'EncryptedNumber',
127+
},
116128
],
117129
},
118130
queryOperationTypes: {
@@ -159,6 +171,9 @@ export const cipherstashPackMeta = {
159171
targetId: 'postgres',
160172
nativeType: EQL_V2_ENCRYPTED_TYPE,
161173
},
174+
// EQL v3 — one row per catalog domain, each targeting its own
175+
// concrete `public.eql_v3_*` native type (see codec-metadata.ts).
176+
...cipherstashV3StorageRows,
162177
],
163178
},
164179
} as const

packages/prisma-next/src/v3/sdk-adapter-v3.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export function createCipherstashV3Sdk(
218218
// `never` operands so the real client's GENERIC method satisfies
219219
// the structural surface (see the interface doc); the concrete
220220
// operand is re-widened at each call site.
221+
// biome-ignore lint/plugin: deliberate bridge into the structural `never`-operand surface (rationale above)
221222
const result = await client.encryptQuery(terms as never)
222223
const data = unwrap(result, `encryptQuery(${queryType})`)
223224
if (data.length !== group.length) {
@@ -244,6 +245,7 @@ export function createCipherstashV3Sdk(
244245
await Promise.all(
245246
jsonTerms.map(async (slot) => {
246247
// Cast rationale: see the scalar branch above.
248+
// biome-ignore lint/plugin: deliberate bridge into the structural `never`-operand surface (rationale above)
247249
const result = await client.encryptQuery(
248250
slot.plaintext as never,
249251
{ table, column, queryType: 'searchableJson' } as never,
@@ -274,7 +276,15 @@ export function createCipherstashV3Sdk(
274276
data: ensureV3Payload(data, 'bulkDecrypt', index),
275277
}))
276278
const result = await client.bulkDecrypt(payload)
277-
return unwrap(result, 'bulkDecrypt').map((entry) => {
279+
const data = unwrap(result, 'bulkDecrypt')
280+
// Same cardinality contract as bulkEncrypt above: a truncated
281+
// response would silently misalign plaintexts with `ciphertexts`.
282+
if (data.length !== ciphertexts.length) {
283+
throw new Error(
284+
`cipherstash v3 bulkDecrypt: client returned ${data.length} plaintexts for ${ciphertexts.length} ciphertexts.`,
285+
)
286+
}
287+
return data.map((entry) => {
278288
if ('error' in entry && entry.error !== undefined) {
279289
throw new Error(
280290
`cipherstash v3 bulkDecrypt entry failed: ${String(entry.error)}`,

packages/prisma-next/src/v3/wire-v3.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,27 @@ function bigintSafeReplacer(_key: string, value: unknown): unknown {
2828
/** Serialise an EQL payload to plain JSONB text; `null`/`undefined` → `null`. */
2929
export function v3ToDriver(value: unknown): string | null {
3030
if (value === null || value === undefined) return null
31-
return JSON.stringify(value, bigintSafeReplacer)
31+
// `JSON.stringify` returns `undefined` for unsupported top-level values
32+
// (a function, a symbol, a lone `undefined` survivor of the replacer);
33+
// coerce that branch to `null` so the declared contract holds.
34+
return JSON.stringify(value, bigintSafeReplacer) ?? null
3235
}
3336

3437
/**
3538
* Parse a JSONB wire back to the EQL payload. Text wires are
3639
* `JSON.parse`d; objects (drivers that pre-parse jsonb) pass through;
37-
* `null`/`undefined` are preserved as-is.
40+
* `null`/`undefined` are preserved as-is — the overloads keep that
41+
* passthrough visible to callers (a nullable input yields a nullable
42+
* result) instead of laundering `null` into `T`.
3843
*/
39-
export function v3FromDriver<T>(value: string | object | null | undefined): T {
40-
if (value === null || value === undefined) return value as T
44+
export function v3FromDriver<T>(value: string | object): T
45+
export function v3FromDriver<T>(
46+
value: string | object | null | undefined,
47+
): T | null | undefined
48+
export function v3FromDriver<T>(
49+
value: string | object | null | undefined,
50+
): T | null | undefined {
51+
if (value === null || value === undefined) return value
4152
if (typeof value === 'object') return value as T
4253
return JSON.parse(value) as T
4354
}

packages/prisma-next/test/bundling-isolation.test.ts

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,21 @@ const V3_WIRE_MARKERS = [
188188
'bulkEncryptMiddlewareV3',
189189
] as const
190190

191+
/**
192+
* The six v2 codec-RUNTIME factories (all built on the composite wire's
193+
* `makeCipherstashCellCodec`). Distinct from {@link V2_WIRE_MARKERS} so
194+
* the v3-graph scans can name exactly which factory leaked, and safe as
195+
* substrings: no v3 identifier contains any of them.
196+
*/
197+
const V2_CODEC_FACTORY_MARKERS = [
198+
'createCipherstashStringCodec',
199+
'createCipherstashDoubleCodec',
200+
'createCipherstashBigIntCodec',
201+
'createCipherstashDateCodec',
202+
'createCipherstashBooleanCodec',
203+
'createCipherstashJsonCodec',
204+
] as const
205+
191206
interface ChunkFile {
192207
readonly file: string
193208
readonly body: string
@@ -250,6 +265,45 @@ function isAllowedSharedChunk(chunk: string): boolean {
250265
)
251266
}
252267

268+
/**
269+
* Scan an entry's COMPLETE import graph (entry body + every transitive
270+
* chunk) for forbidden markers, returning `"<chunk> → <marker>"`
271+
* diagnostics so a failure names the offending chunk, not just the
272+
* entry. `exempt` skips known-safe chunks (with the invariant that the
273+
* exemption itself is pinned elsewhere — see the call sites).
274+
*/
275+
function findLeaksInGraph(
276+
entry: string,
277+
forbidden: readonly string[],
278+
exempt: (chunk: ChunkFile) => boolean = () => false,
279+
): string[] {
280+
const leaks: string[] = []
281+
for (const [file, chunk] of collectGraph(entry)) {
282+
if (exempt(chunk)) continue
283+
for (const marker of forbidden) {
284+
if (chunk.body.includes(marker)) {
285+
leaks.push(`${file}${marker}`)
286+
}
287+
}
288+
}
289+
return leaks
290+
}
291+
292+
/**
293+
* The version-neutral execution chunk — envelope base/classes, routing,
294+
* abort, `stampRoutingKeysFromAst` — is legitimately reachable from
295+
* EVERY entry (the v3 middleware imports the shared routing walk), and
296+
* it currently also DEFINES the v2 wire encoder. Identified by CONTENT
297+
* (it is the one chunk that defines the envelope base class), never by
298+
* tsup's hash-named file. It is exempted ONLY from the v2-wire-marker
299+
* scan of the v3 graph; the per-chunk "no chunk mixes the two wires"
300+
* test below still pins that this chunk never gains a v3 wire marker,
301+
* so the exemption cannot hide a fused chunk.
302+
*/
303+
function definesVersionNeutralExecutionChunk(chunk: ChunkFile): boolean {
304+
return /var EncryptedEnvelopeBase\s*=/.test(chunk.body)
305+
}
306+
253307
describe('bundling isolation', () => {
254308
it('dist entry files exist (run `pnpm --filter @cipherstash/prisma-next build` first)', () => {
255309
for (const entry of ENTRY_FILES) {
@@ -298,39 +352,39 @@ describe('bundling isolation', () => {
298352
expect(leaks, `v3 entry leaks: ${leaks.join(', ')}`).toEqual([])
299353
})
300354

301-
it('v3.js does not pull the v2 composite-literal wire', () => {
302-
const entry = readChunk('v3.js')
303-
const leaks = findLeaksInEntry(entry, V2_WIRE_MARKERS)
304-
expect(leaks, `v3 entry leaks v2 wire: ${leaks.join(', ')}`).toEqual([])
355+
it('no chunk in the v3 graph references the v2 composite-literal wire (version-neutral execution chunk exempted)', () => {
356+
// COMPLETE-graph scan, not just the entry body: a cross-plane leak
357+
// usually arrives through a transitive chunk import, which an
358+
// entry-body substring check never sees. `v3.js` legitimately
359+
// shares the version-neutral execution chunk (envelope
360+
// base/classes, routing, abort, `stampRoutingKeysFromAst`) with the
361+
// v2 entries — that chunk currently also DEFINES the v2 wire
362+
// encoder, so it is exempted here by content and pinned against v3
363+
// contamination by the per-chunk disjointness test below.
364+
const leaks = findLeaksInGraph(
365+
'v3.js',
366+
V2_WIRE_MARKERS,
367+
definesVersionNeutralExecutionChunk,
368+
)
369+
expect(leaks, `v3 graph leaks v2 wire: ${leaks.join(', ')}`).toEqual([])
305370
})
306371

307-
it('the v3 entry graph never reaches the v2 codec-runtime chunk', () => {
308-
// `v3.js` legitimately shares the version-neutral execution chunk
309-
// (envelope base/classes, routing, abort, `stampRoutingKeysFromAst`)
310-
// with the v2 entries — that chunk currently also DEFINES the v2
311-
// wire encoder, which the per-chunk disjointness test below pins.
312-
// What must never happen is the v3 graph importing the v2
313-
// codec-RUNTIME chunk (the six `createCipherstash*Codec` factories
314-
// built on the composite wire).
315-
const v3Chunks = collectGraph('v3.js')
316-
const offenders = [...v3Chunks.entries()]
317-
.filter(([file]) => file !== 'v3.js')
318-
.filter(([, chunk]) =>
319-
chunk.body.includes('createCipherstashStringCodec'),
320-
)
321-
.map(([file]) => file)
372+
it('no chunk in the v3 graph references any v2 codec-runtime factory', () => {
373+
// All six `createCipherstash*Codec` factories (built on the
374+
// composite wire), against every chunk in the v3 graph — including
375+
// the version-neutral execution chunk, which must never grow one.
376+
const leaks = findLeaksInGraph('v3.js', V2_CODEC_FACTORY_MARKERS)
322377
expect(
323-
offenders,
324-
`v3 graph reaches v2 codec-runtime chunk(s): ${offenders.join(', ')}`,
378+
leaks,
379+
`v3 graph reaches v2 codec-runtime factories: ${leaks.join(', ')}`,
325380
).toEqual([])
326381
})
327382

328-
it('middleware.js (v2 bulk-encrypt entry) does not pull the v3 wire plane', () => {
329-
const entry = readChunk('middleware.js')
330-
const leaks = findLeaksInEntry(entry, V3_WIRE_MARKERS)
383+
it('no chunk in the middleware.js graph (v2 bulk-encrypt entry) references the v3 wire plane', () => {
384+
const leaks = findLeaksInGraph('middleware.js', V3_WIRE_MARKERS)
331385
expect(
332386
leaks,
333-
`middleware entry leaks v3 wire: ${leaks.join(', ')}`,
387+
`middleware graph leaks v3 wire: ${leaks.join(', ')}`,
334388
).toEqual([])
335389
})
336390

0 commit comments

Comments
 (0)