|
| 1 | +/** |
| 2 | + * One-call setup for `@cipherstash/prisma-next` against the |
| 3 | + * `@cipherstash/stack` EQL v3 client — the v3-only sibling of |
| 4 | + * `./from-stack.ts` (decision 1b: v2 and v3 are SEPARATE entry points |
| 5 | + * that are never co-registered in one client; the v2 |
| 6 | + * `cipherstashFromStack` is untouched by this module). |
| 7 | + * |
| 8 | + * const cipherstash = await cipherstashFromStackV3({ contractJson }) |
| 9 | + * |
| 10 | + * const db = postgres<Contract>({ |
| 11 | + * contractJson, |
| 12 | + * extensions: cipherstash.extensions, |
| 13 | + * middleware: cipherstash.middleware, |
| 14 | + * }) |
| 15 | + * |
| 16 | + * A v3 client is v3-only: a contract carrying a v2 cipherstash codec id |
| 17 | + * is the WRONG entry point (mixed v2+v3 columns in one client are |
| 18 | + * unsupported this release) and is a hard error rather than a silently |
| 19 | + * ignored column. |
| 20 | + * |
| 21 | + * Override semantics: a user-supplied `schemasV3` array is allowed to |
| 22 | + * add tables the contract doesn't model. For tables the contract |
| 23 | + * **does** declare, the override must agree on EXACT domain identity |
| 24 | + * per column (`integer_ord` ≠ `integer_ord_ore` even though both are |
| 25 | + * `cast_as: number`) — divergence throws at setup so ZeroKMS can't end |
| 26 | + * up minting query terms the installed domain's CHECK rejects. |
| 27 | + */ |
| 28 | + |
| 29 | +import type { AnyV3Table } from '@cipherstash/stack/eql/v3' |
| 30 | +import type { ClientConfig } from '@cipherstash/stack/types' |
| 31 | +import { EncryptionV3, type TypedEncryptionClient } from '@cipherstash/stack/v3' |
| 32 | +import type { |
| 33 | + SqlMiddleware, |
| 34 | + SqlRuntimeExtensionDescriptor, |
| 35 | +} from '@prisma-next/sql-runtime' |
| 36 | + |
| 37 | +import { isCipherstashV3CodecId } from '../extension-metadata/constants-v3' |
| 38 | +import { bulkEncryptMiddlewareV3 } from '../v3/bulk-encrypt-v3' |
| 39 | +import { |
| 40 | + deriveStackSchemasV3, |
| 41 | + type V3ContractShape, |
| 42 | + v3ContractColumnEntries, |
| 43 | +} from '../v3/derive-schemas-v3' |
| 44 | +import { assertV3SchemasAgree } from '../v3/from-stack-v3-validate' |
| 45 | +import { createCipherstashV3RuntimeDescriptor } from '../v3/runtime-v3' |
| 46 | +import { createCipherstashV3Sdk } from '../v3/sdk-adapter-v3' |
| 47 | + |
| 48 | +export interface CipherstashFromStackV3Options { |
| 49 | + /** The contract.json artefact emitted by `prisma-next contract emit`. */ |
| 50 | + readonly contractJson: V3ContractShape |
| 51 | + |
| 52 | + /** |
| 53 | + * Optional schema override. Use this to add tables the contract does |
| 54 | + * not model. For tables the contract **does** declare, the override |
| 55 | + * must match on column names and EXACT `public.eql_v3_*` domain |
| 56 | + * identity — divergence throws at setup. |
| 57 | + */ |
| 58 | + readonly schemasV3?: ReadonlyArray<AnyV3Table> |
| 59 | + |
| 60 | + /** Pass-through to `EncryptionV3({ config })` (keyset overrides, logging, …). */ |
| 61 | + readonly encryptionConfig?: ClientConfig |
| 62 | +} |
| 63 | + |
| 64 | +export interface CipherstashFromStackV3Result { |
| 65 | + /** Ready to spread into `postgres<Contract>({ extensions })`. */ |
| 66 | + readonly extensions: ReadonlyArray<SqlRuntimeExtensionDescriptor<'postgres'>> |
| 67 | + /** Ready to spread into `postgres<Contract>({ middleware })`. */ |
| 68 | + readonly middleware: ReadonlyArray<SqlMiddleware> |
| 69 | + /** |
| 70 | + * The initialised v3 `TypedEncryptionClient` for direct SDK access |
| 71 | + * outside the ORM path (`encryptModel`, `encryptQuery`, …). |
| 72 | + */ |
| 73 | + readonly encryptionClient: TypedEncryptionClient<readonly AnyV3Table[]> |
| 74 | +} |
| 75 | + |
| 76 | +export async function cipherstashFromStackV3( |
| 77 | + opts: CipherstashFromStackV3Options, |
| 78 | +): Promise<CipherstashFromStackV3Result> { |
| 79 | + const foreignIds = collectNonV3CipherstashCodecIds(opts.contractJson) |
| 80 | + if (foreignIds.length > 0) { |
| 81 | + throw new Error( |
| 82 | + `cipherstashFromStackV3: contract.json contains non-v3 cipherstash codec ids [${foreignIds.join(', ')}]. ` + |
| 83 | + 'A v3 client is v3-only; use cipherstashFromStack for a v2 contract. ' + |
| 84 | + 'Mixed v2+v3 cipherstash columns in one client are unsupported.', |
| 85 | + ) |
| 86 | + } |
| 87 | + |
| 88 | + const derived = deriveStackSchemasV3(opts.contractJson) |
| 89 | + if (derived.length === 0) { |
| 90 | + throw new Error( |
| 91 | + 'cipherstashFromStackV3: no v3 cipherstash columns found in contract.json. ' + |
| 92 | + 'Declare at least one v3 `cipherstash.Encrypted*()` column in prisma/schema.prisma and re-emit the contract ' + |
| 93 | + '(or use cipherstashFromStack if this is a v2 contract).', |
| 94 | + ) |
| 95 | + } |
| 96 | + |
| 97 | + const schemas = resolveV3Schemas(derived, opts.schemasV3) |
| 98 | + |
| 99 | + const encryptionClient = await EncryptionV3({ |
| 100 | + schemas, |
| 101 | + ...(opts.encryptionConfig !== undefined |
| 102 | + ? { config: opts.encryptionConfig } |
| 103 | + : {}), |
| 104 | + }) |
| 105 | + |
| 106 | + const sdk = createCipherstashV3Sdk(encryptionClient, schemas) |
| 107 | + |
| 108 | + return { |
| 109 | + extensions: [createCipherstashV3RuntimeDescriptor({ sdk })], |
| 110 | + middleware: [bulkEncryptMiddlewareV3(sdk)], |
| 111 | + encryptionClient, |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * Every `cipherstash/*` codec id in the contract that is NOT a member |
| 117 | + * of the pinned v3 set — i.e. v2 (or unknown-generation) cipherstash |
| 118 | + * columns, which make the contract the wrong input for the v3 entry |
| 119 | + * point. |
| 120 | + */ |
| 121 | +function collectNonV3CipherstashCodecIds( |
| 122 | + contractJson: V3ContractShape, |
| 123 | +): string[] { |
| 124 | + const ids = new Set<string>() |
| 125 | + for (const { codecId } of v3ContractColumnEntries(contractJson)) { |
| 126 | + if ( |
| 127 | + typeof codecId === 'string' && |
| 128 | + codecId.startsWith('cipherstash/') && |
| 129 | + !isCipherstashV3CodecId(codecId) |
| 130 | + ) { |
| 131 | + ids.add(codecId) |
| 132 | + } |
| 133 | + } |
| 134 | + return [...ids].sort() |
| 135 | +} |
| 136 | + |
| 137 | +/** |
| 138 | + * Validate contract-declared tables against their overrides (exact |
| 139 | + * domain identity) and append override-only tables — same merge |
| 140 | + * semantics as the v2 `resolveSchemas`. |
| 141 | + */ |
| 142 | +function resolveV3Schemas( |
| 143 | + derived: ReadonlyArray<AnyV3Table>, |
| 144 | + override: ReadonlyArray<AnyV3Table> | undefined, |
| 145 | +): ReadonlyArray<AnyV3Table> { |
| 146 | + if (override === undefined || override.length === 0) return derived |
| 147 | + |
| 148 | + const derivedByName = new Map(derived.map((t) => [t.tableName, t])) |
| 149 | + const overrideByName = new Map(override.map((t) => [t.tableName, t])) |
| 150 | + |
| 151 | + for (const [tableName, derivedTable] of derivedByName) { |
| 152 | + const overrideTable = overrideByName.get(tableName) |
| 153 | + if (overrideTable === undefined) continue |
| 154 | + assertV3SchemasAgree(derivedTable, overrideTable) |
| 155 | + } |
| 156 | + |
| 157 | + return [ |
| 158 | + ...derived, |
| 159 | + ...override.filter((t) => !derivedByName.has(t.tableName)), |
| 160 | + ] |
| 161 | +} |
0 commit comments