Skip to content

Commit fc2a25f

Browse files
calvinbrewerclaude
authored andcommitted
feat(prisma-next): v3 plain-JSONB cell codecs + EncryptedNumber envelope
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ea64929 commit fc2a25f

7 files changed

Lines changed: 822 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* v3 user-facing value types, surfaced through the v3 subtree.
3+
* Re-exports the version-neutral envelope classes (including
4+
* `EncryptedJson` — the json domain is a first-class v3 domain) plus the
5+
* v3-only `EncryptedNumber`. Pulls in NO v2 wire/codec code.
6+
*/
7+
export { EncryptedBigInt } from '../execution/envelope-bigint'
8+
export { EncryptedBoolean } from '../execution/envelope-boolean'
9+
export { EncryptedDate } from '../execution/envelope-date'
10+
export { EncryptedJson } from '../execution/envelope-json'
11+
export { EncryptedString } from '../execution/envelope-string'
12+
export { EncryptedNumber } from './envelope-number'
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* v3 runtime codecs — one `RuntimeParameterizedCodecDescriptor` per
3+
* catalog domain, derived from `V3_DOMAIN_META_BY_CODEC_ID` (never
4+
* hand-listed). Parallel to the v2 `../execution/codec-runtime.ts` +
5+
* `../execution/parameterized.ts` pair, with three deliberate
6+
* differences:
7+
*
8+
* - **Wire**: plain JSONB (`./wire-v3`), never the v2
9+
* `eql_v2_encrypted` composite literal. This module MUST NOT import
10+
* the v2 codec/wire (`encodeEqlV2EncryptedWire`), the v2
11+
* codec-runtime, or the v2 bulk-encrypt middleware.
12+
* - **Native type**: each descriptor targets its concrete
13+
* `public.eql_v3_*` domain (v2's six codecs all share one
14+
* `eql_v2_encrypted` composite type).
15+
* - **Traits**: derived per-domain from the catalog's query
16+
* capabilities via `v3TraitsForCapabilities` — the capability set is
17+
* intrinsic to the domain, not a per-column authoring option.
18+
*
19+
* Params shape matches the static `typeParams` block that v3 authoring
20+
* emits (`contract-authoring.ts`): `{ castAs, capabilities }`. The codec
21+
* runtime is stateless across params (encode reads ciphertext from the
22+
* handle; decode constructs the per-castAs envelope), so the factory
23+
* returns one shared codec per domain, mirroring v2 / pgvector.
24+
*/
25+
26+
import type { JsonValue } from '@prisma-next/contract/types'
27+
import {
28+
type AnyCodecDescriptor,
29+
CodecImpl,
30+
type CodecInstanceContext,
31+
type CodecTrait,
32+
} from '@prisma-next/framework-components/codec'
33+
import { runtimeError } from '@prisma-next/framework-components/runtime'
34+
import type { SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast'
35+
import type { RuntimeParameterizedCodecDescriptor } from '@prisma-next/sql-runtime'
36+
import { type as arktype } from 'arktype'
37+
import type { EncryptedEnvelopeBase } from '../execution/envelope-base'
38+
import { EncryptedBigInt } from '../execution/envelope-bigint'
39+
import { EncryptedBoolean } from '../execution/envelope-boolean'
40+
import { EncryptedDate } from '../execution/envelope-date'
41+
import { EncryptedJson } from '../execution/envelope-json'
42+
import { EncryptedString } from '../execution/envelope-string'
43+
import type { CipherstashSdk } from '../execution/sdk'
44+
import {
45+
isCipherstashV3CodecId,
46+
v3TraitsForCapabilities,
47+
} from '../extension-metadata/constants-v3'
48+
import {
49+
envelopeTypeNameForCastAs,
50+
V3_DOMAIN_META_BY_CODEC_ID,
51+
type V3CastAs,
52+
type V3DomainMeta,
53+
} from './catalog'
54+
import { EncryptedNumber } from './envelope-number'
55+
import { v3FromDriver, v3ToDriver } from './wire-v3'
56+
57+
/**
58+
* Per-domain params emitted by v3 authoring as a static `typeParams`
59+
* block. Purely descriptive — the runtime codec ignores them (the
60+
* capability set already determined the descriptor's traits at
61+
* derivation time).
62+
*/
63+
export interface CipherstashV3CodecParams {
64+
readonly castAs: string
65+
readonly capabilities: object
66+
}
67+
68+
export const cipherstashV3ParamsSchema = arktype({
69+
castAs: 'string',
70+
capabilities: 'object',
71+
})
72+
73+
type FromInternal = (args: {
74+
readonly ciphertext: unknown
75+
readonly table: string
76+
readonly column: string
77+
readonly sdk: CipherstashSdk
78+
}) => EncryptedEnvelopeBase<unknown>
79+
80+
/**
81+
* Map a domain's `castAs` to its envelope `fromInternal` factory. Total
82+
* over `V3CastAs` — a new castAs kind fails to compile here. The
83+
* `date`/`timestamp` pair shares `EncryptedDate`; `json` reuses the
84+
* version-neutral `EncryptedJson` envelope class.
85+
*/
86+
function fromInternalForCastAs(castAs: V3CastAs): FromInternal {
87+
switch (castAs) {
88+
case 'string':
89+
return EncryptedString.fromInternal
90+
case 'number':
91+
return EncryptedNumber.fromInternal
92+
case 'bigint':
93+
return EncryptedBigInt.fromInternal
94+
case 'date':
95+
case 'timestamp':
96+
return EncryptedDate.fromInternal
97+
case 'boolean':
98+
return EncryptedBoolean.fromInternal
99+
case 'json':
100+
return EncryptedJson.fromInternal
101+
}
102+
}
103+
104+
/**
105+
* `CodecDescriptor.traits` is typed against the framework's closed
106+
* `CodecTrait` union; the cipherstash traits live in the
107+
* extension-private `cipherstash:` namespace so trait-gated framework
108+
* built-ins (e.g. `eq` → SQL `=`) can never attach to encrypted
109+
* columns. Framework 0.14 treats traits as opaque strings at runtime;
110+
* the assertion is a type-level adapter only — same pattern (and full
111+
* rationale) as v2's `CIPHERSTASH_CODEC_TRAITS` in
112+
* `../extension-metadata/constants.ts`.
113+
*/
114+
function v3CodecTraits(meta: V3DomainMeta): readonly CodecTrait[] {
115+
return v3TraitsForCapabilities(meta.capabilities) as readonly CodecTrait[]
116+
}
117+
118+
export class CipherstashV3CellCodec<
119+
E extends EncryptedEnvelopeBase<unknown>,
120+
> extends CodecImpl<string, readonly CodecTrait[], unknown, E> {
121+
readonly #sdk: CipherstashSdk
122+
readonly #fromInternal: FromInternal
123+
readonly #typeName: string
124+
125+
constructor(
126+
descriptor: AnyCodecDescriptor,
127+
sdk: CipherstashSdk,
128+
typeName: string,
129+
fromInternal: FromInternal,
130+
) {
131+
super(descriptor)
132+
this.#sdk = sdk
133+
this.#typeName = typeName
134+
this.#fromInternal = fromInternal
135+
}
136+
137+
async encode(value: E, _ctx: SqlCodecCallContext): Promise<unknown> {
138+
// Two-pass write path (same contract as v2, plain-JSONB wire): the
139+
// v3 bulk-encrypt middleware replaces the param with the JSONB text
140+
// before the driver reads it — pass a pre-serialised string through.
141+
// If we still hold an envelope, serialise its stamped ciphertext, or
142+
// return the envelope itself as the pre-encrypt sentinel.
143+
if (typeof value === 'string') {
144+
return value
145+
}
146+
if (value === null || value === undefined) {
147+
return value
148+
}
149+
const handle = value.expose()
150+
if (handle.ciphertext === undefined) {
151+
return value
152+
}
153+
return v3ToDriver(handle.ciphertext)
154+
}
155+
156+
async decode(wire: unknown, ctx: SqlCodecCallContext): Promise<E> {
157+
const column = ctx.column
158+
if (!column) {
159+
throw runtimeError(
160+
'RUNTIME.DECODE_FAILED',
161+
`cipherstash ${this.descriptor.codecId}: decode requires the projected-column routing context that the SQL runtime populates ` +
162+
'for projected columns. The cell being decoded came from an aggregate, computed expression, or other unrouted source. ' +
163+
'cipherstash codecs need a stable `(table, column)` routing key for envelope construction and bulk-decrypt grouping; ' +
164+
'project the underlying encrypted column directly instead of through an aggregate.',
165+
{
166+
codecId: this.descriptor.codecId,
167+
reason: 'cipherstash-v3-decode-column-context-missing',
168+
},
169+
)
170+
}
171+
// The per-domain `fromInternal` returns the base type; each codec is
172+
// constructed with the factory matching its domain's castAs, so the
173+
// narrow to `E` holds by construction.
174+
return this.#fromInternal({
175+
ciphertext: v3FromDriver(wire as string | object | null | undefined),
176+
table: column.table,
177+
column: column.name,
178+
sdk: this.#sdk,
179+
}) as E
180+
}
181+
182+
encodeJson(_value: E): JsonValue {
183+
const marker = `$${this.#typeName.charAt(0).toLowerCase()}${this.#typeName.slice(1)}`
184+
return { [marker]: '<opaque>' } as JsonValue
185+
}
186+
187+
decodeJson(_json: JsonValue): E {
188+
throw new Error(
189+
'cipherstash v3 codec: decodeJson is not supported; envelopes do not round-trip through JSON.',
190+
)
191+
}
192+
}
193+
194+
/**
195+
* Auxiliary descriptor for the `CodecImpl` base class — carries truthful
196+
* metadata (codecId, traits, targetTypes, meta, renderOutputType) for
197+
* readers that proxy through `codec.descriptor`, with a throwing
198+
* `factory` stub: production resolution goes through the parameterized
199+
* descriptors below, never `codec.descriptor.factory`. Same shape (and
200+
* circularity rationale) as v2's `makeAuxiliaryDescriptor` in
201+
* `../execution/cell-codec-factory.ts`.
202+
*/
203+
function makeV3AuxiliaryDescriptor(
204+
codecId: string,
205+
meta: V3DomainMeta,
206+
typeName: string,
207+
): AnyCodecDescriptor {
208+
return {
209+
codecId,
210+
traits: v3CodecTraits(meta),
211+
targetTypes: [meta.nativeType],
212+
meta: { db: { sql: { postgres: { nativeType: meta.nativeType } } } },
213+
paramsSchema: cipherstashV3ParamsSchema,
214+
isParameterized: true,
215+
renderOutputType: () => typeName,
216+
factory: () => () => {
217+
throw new Error(
218+
'cipherstash v3 codec: auxiliary descriptor factory was invoked. ' +
219+
'This is a programming error — v3 codecs are resolved through the ' +
220+
'parameterized descriptors returned by `createV3CodecDescriptors(sdk)`, ' +
221+
'not through `codec.descriptor.factory`.',
222+
)
223+
},
224+
}
225+
}
226+
227+
function makeV3Descriptor(
228+
sdk: CipherstashSdk,
229+
codecId: string,
230+
meta: V3DomainMeta,
231+
): RuntimeParameterizedCodecDescriptor<CipherstashV3CodecParams> {
232+
const typeName = envelopeTypeNameForCastAs(meta.castAs)
233+
const codec = new CipherstashV3CellCodec<EncryptedEnvelopeBase<unknown>>(
234+
makeV3AuxiliaryDescriptor(codecId, meta, typeName),
235+
sdk,
236+
typeName,
237+
fromInternalForCastAs(meta.castAs),
238+
)
239+
return {
240+
codecId,
241+
traits: v3CodecTraits(meta),
242+
targetTypes: [meta.nativeType],
243+
meta: { db: { sql: { postgres: { nativeType: meta.nativeType } } } },
244+
paramsSchema: cipherstashV3ParamsSchema,
245+
isParameterized: true as const,
246+
renderOutputType: (_params: CipherstashV3CodecParams) => typeName,
247+
factory:
248+
(_params: CipherstashV3CodecParams) => (_ctx: CodecInstanceContext) =>
249+
codec,
250+
}
251+
}
252+
253+
/**
254+
* One runtime descriptor per catalog domain (all 40, including the
255+
* authoring-unexposed `*_ord_ore` variants — the codec layer must decode
256+
* whatever the catalog can name), closed over `sdk` so multi-tenant
257+
* deployments can compose multiple cipherstash extensions side-by-side
258+
* without cross-talk.
259+
*/
260+
export function createV3CodecDescriptors(
261+
sdk: CipherstashSdk,
262+
): ReadonlyArray<
263+
RuntimeParameterizedCodecDescriptor<CipherstashV3CodecParams>
264+
> {
265+
return [...V3_DOMAIN_META_BY_CODEC_ID.entries()].map(([codecId, meta]) => {
266+
// The catalog and the pinned codec-id union are compile-time-locked
267+
// to each other (see constants-v3.ts); a mismatch here means the
268+
// derivation itself drifted — fail loudly.
269+
if (!isCipherstashV3CodecId(codecId)) {
270+
throw new Error(
271+
`cipherstash v3 codec: catalog produced unpinned codec id "${codecId}" — update CIPHERSTASH_V3_CODEC_IDS.`,
272+
)
273+
}
274+
return makeV3Descriptor(sdk, codecId, meta)
275+
})
276+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* `EncryptedNumber` envelope — the user-facing input/output type for the
3+
* v3 number-family codecs (`cipherstash/eql-v3/eql_v3_integer*@1`,
4+
* `..._smallint*`, `..._numeric*`, `..._real*`, `..._double*`). Concrete
5+
* subclass of {@link EncryptedEnvelopeBase} parameterised on `number`.
6+
*
7+
* A SIBLING of the v2 `EncryptedDouble` — NOT a subclass or alias. Its
8+
* distinct `typeName` drives the `$encryptedNumber` placeholder marker;
9+
* keeping the class separate prevents `value instanceof EncryptedNumber`
10+
* from matching a v2 `EncryptedDouble` (a cross-version leak). Every v3
11+
* number-castAs domain (integer / smallint / numeric / real / double)
12+
* renders as `EncryptedNumber`.
13+
*
14+
* No `parseDecryptedValue` override is needed: the SDK's polymorphic
15+
* `bulkDecrypt` / single-cell `decrypt` already returns numeric
16+
* plaintexts as `number`; the base's default identity cast suffices.
17+
*/
18+
19+
import {
20+
EncryptedEnvelopeBase,
21+
type EncryptedEnvelopeFromInternalArgs,
22+
type EncryptedEnvelopeHandle,
23+
} from '../execution/envelope-base'
24+
25+
export type EncryptedNumberHandle = EncryptedEnvelopeHandle<number>
26+
27+
export type EncryptedNumberFromInternalArgs = EncryptedEnvelopeFromInternalArgs
28+
29+
export class EncryptedNumber extends EncryptedEnvelopeBase<number> {
30+
protected override get typeName(): string {
31+
return 'EncryptedNumber'
32+
}
33+
34+
/**
35+
* Construct a write-side envelope from a plaintext number. Bulk-encrypt
36+
* middleware populates the handle's ciphertext slot before the codec
37+
* encodes the envelope to wire format.
38+
*/
39+
static from(plaintext: number): EncryptedNumber {
40+
return new EncryptedNumber({
41+
plaintext,
42+
ciphertext: undefined,
43+
table: undefined,
44+
column: undefined,
45+
sdk: undefined,
46+
})
47+
}
48+
49+
/**
50+
* Construct a read-side envelope from a wire ciphertext + the column
51+
* identity + the SDK used to decrypt the cell. Called from the codec
52+
* `decode` body.
53+
*/
54+
static fromInternal(args: EncryptedNumberFromInternalArgs): EncryptedNumber {
55+
return new EncryptedNumber({
56+
plaintext: undefined,
57+
ciphertext: args.ciphertext,
58+
table: args.table,
59+
column: args.column,
60+
sdk: args.sdk,
61+
})
62+
}
63+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* v3 wire format: plain JSONB.
3+
*
4+
* v3 columns are `CREATE DOMAIN public.eql_v3_* AS jsonb ...`, so a cell
5+
* serialises as plain JSONB text — deliberately distinct from v2's
6+
* `eql_v2_encrypted` composite-literal wire (`("...")`,
7+
* see `../execution/cell-codec-factory.ts`), which this module MUST NOT
8+
* import or reproduce. Encode is `JSON.stringify` of the EQL payload;
9+
* decode is `JSON.parse` for text wires with object passthrough for pg
10+
* clients that pre-parse jsonb cells.
11+
*
12+
* The original plan intended these helpers to be a thin re-export of a
13+
* shared, framework-neutral codec lifted to `@cipherstash/stack/eql/v3`.
14+
* That lift has not happened (the stack's v3 barrel exports only the
15+
* authoring DSL), so the implementation lives here; if/when the shared
16+
* primitive lands, replace the bodies below with a re-export.
17+
*/
18+
19+
// `bigintSafeReplacer` is LOAD-BEARING and must not be dropped: a
20+
// well-formed envelope never carries a bigint (bigint plaintext is
21+
// encrypted to ciphertext strings first), but a malformed envelope with
22+
// a stray bigint would otherwise throw
23+
// `TypeError: Do not know how to serialize a BigInt`.
24+
function bigintSafeReplacer(_key: string, value: unknown): unknown {
25+
return typeof value === 'bigint' ? value.toString() : value
26+
}
27+
28+
/** Serialise an EQL payload to plain JSONB text; `null`/`undefined` → `null`. */
29+
export function v3ToDriver(value: unknown): string | null {
30+
if (value === null || value === undefined) return null
31+
return JSON.stringify(value, bigintSafeReplacer)
32+
}
33+
34+
/**
35+
* Parse a JSONB wire back to the EQL payload. Text wires are
36+
* `JSON.parse`d; objects (drivers that pre-parse jsonb) pass through;
37+
* `null`/`undefined` are preserved as-is.
38+
*/
39+
export function v3FromDriver<T>(value: string | object | null | undefined): T {
40+
if (value === null || value === undefined) return value as T
41+
if (typeof value === 'object') return value as T
42+
return JSON.parse(value) as T
43+
}

0 commit comments

Comments
 (0)