@prisma-next/extension-cipherstash 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +153 -0
  2. package/dist/call-classes-CSvD7w8U.mjs +206 -0
  3. package/dist/call-classes-CSvD7w8U.mjs.map +1 -0
  4. package/dist/column-types.d.mts +33 -0
  5. package/dist/column-types.d.mts.map +1 -0
  6. package/dist/column-types.mjs +42 -0
  7. package/dist/column-types.mjs.map +1 -0
  8. package/dist/constants-BDxL9Pe3.d.mts +22 -0
  9. package/dist/constants-BDxL9Pe3.d.mts.map +1 -0
  10. package/dist/constants-B_2TNvUi.mjs +46 -0
  11. package/dist/constants-B_2TNvUi.mjs.map +1 -0
  12. package/dist/control.d.mts +7 -0
  13. package/dist/control.d.mts.map +1 -0
  14. package/dist/control.mjs +430 -0
  15. package/dist/control.mjs.map +1 -0
  16. package/dist/descriptor-meta-BgQfZTAF.mjs +129 -0
  17. package/dist/descriptor-meta-BgQfZTAF.mjs.map +1 -0
  18. package/dist/envelope-P9BxfJNr.mjs +271 -0
  19. package/dist/envelope-P9BxfJNr.mjs.map +1 -0
  20. package/dist/middleware.d.mts +13 -0
  21. package/dist/middleware.d.mts.map +1 -0
  22. package/dist/middleware.mjs +129 -0
  23. package/dist/middleware.mjs.map +1 -0
  24. package/dist/migration.d.mts +141 -0
  25. package/dist/migration.d.mts.map +1 -0
  26. package/dist/migration.mjs +2 -0
  27. package/dist/operation-types.d.mts +49 -0
  28. package/dist/operation-types.d.mts.map +1 -0
  29. package/dist/operation-types.mjs +1 -0
  30. package/dist/pack.d.mts +86 -0
  31. package/dist/pack.d.mts.map +1 -0
  32. package/dist/pack.mjs +2 -0
  33. package/dist/runtime.d.mts +207 -0
  34. package/dist/runtime.d.mts.map +1 -0
  35. package/dist/runtime.mjs +429 -0
  36. package/dist/runtime.mjs.map +1 -0
  37. package/dist/sdk-D5FTGyzp.d.mts +67 -0
  38. package/dist/sdk-D5FTGyzp.d.mts.map +1 -0
  39. package/package.json +69 -0
  40. package/src/contract/authoring.ts +62 -0
  41. package/src/contract/contract.d.ts +149 -0
  42. package/src/contract/contract.json +104 -0
  43. package/src/contract/contract.prisma +46 -0
  44. package/src/execution/abort.ts +143 -0
  45. package/src/execution/codec-runtime.ts +209 -0
  46. package/src/execution/decrypt-all.ts +217 -0
  47. package/src/execution/envelope.ts +263 -0
  48. package/src/execution/operators.ts +211 -0
  49. package/src/execution/parameterized.ts +71 -0
  50. package/src/execution/routing.ts +93 -0
  51. package/src/execution/sdk.ts +68 -0
  52. package/src/exports/column-types.ts +62 -0
  53. package/src/exports/contract-space-typing.ts +86 -0
  54. package/src/exports/control.ts +120 -0
  55. package/src/exports/middleware.ts +24 -0
  56. package/src/exports/migration.ts +43 -0
  57. package/src/exports/operation-types.ts +16 -0
  58. package/src/exports/pack.ts +13 -0
  59. package/src/exports/runtime.ts +110 -0
  60. package/src/extension-metadata/codec-metadata.ts +81 -0
  61. package/src/extension-metadata/constants.ts +70 -0
  62. package/src/extension-metadata/descriptor-meta.ts +76 -0
  63. package/src/middleware/bulk-encrypt.ts +192 -0
  64. package/src/migration/call-classes.ts +350 -0
  65. package/src/migration/cipherstash-codec.ts +157 -0
  66. package/src/migration/eql-bundle.ts +29 -0
  67. package/src/migration/eql-install.generated.ts +5751 -0
  68. package/src/types/operation-types.ts +81 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Cipherstash query-operations registry.
3
+ *
4
+ * `cipherstashEq` and `cipherstashIlike` lower to EQL's encrypted-aware
5
+ * comparison functions (`eql_v2.eq`, `eql_v2.ilike`) on
6
+ * `cipherstash/string@1`-typed columns. The lowering shape mirrors the
7
+ * canonical templates in the reference Prisma integration at
8
+ * `reference/cipherstash/stack/packages/stack/src/prisma/core/
9
+ * operation-templates.ts`:
10
+ *
11
+ * eql_v2.eq(<self>, <encrypted-arg>)
12
+ * eql_v2.ilike(<self>, <encrypted-arg>)
13
+ *
14
+ * Why we diverge from Postgres' native `=` / `ILIKE` operators: EQL
15
+ * ciphers contain randomized nonces, so two encrypts of the same
16
+ * plaintext do not byte-equal under SQL `=`. EQL's `eql_v2.eq` /
17
+ * `eql_v2.ilike` short-circuit through the per-column index
18
+ * (`unique` / `match`) emitted by the codec lifecycle hook and produce
19
+ * correct results.
20
+ *
21
+ * **Why cipherstash-namespaced method names (`cipherstashEq`,
22
+ * `cipherstashIlike`) rather than reusing the framework`s `eq` /
23
+ * `ilike`.** The framework`s `OperationRegistry` is a flat method-keyed
24
+ * map and operator overriding is disallowed by project decision. Equally
25
+ * importantly, cipherstash`s search operators are semantically distinct
26
+ * from the framework built-ins — they take encrypted-aware envelope
27
+ * arguments and lower to `eql_v2.eq` / `eql_v2.ilike`, which short-
28
+ * circuit through EQL`s per-column index — so they belong under a
29
+ * cipherstash-prefixed surface that flags the divergence at the call
30
+ * site. The supported user-facing call shape on a cipherstash column is:
31
+ *
32
+ * model.users.where((u) => u.email.cipherstashEq('alice@example.com'))
33
+ * model.users.where((u) => u.email.cipherstashIlike('%alice%'))
34
+ *
35
+ * The framework`s built-in `email.eq(...)` is **not reachable** on
36
+ * cipherstash columns: the cipherstash codec declares no `equality`
37
+ * trait (see `codec-runtime.ts` / `codec-metadata.ts` / `parameterized.ts`),
38
+ * and the model-accessor synthesis in `sql-orm-client` gates
39
+ * `COMPARISON_METHODS_META.eq` on the `equality` trait being present in
40
+ * the column codec`s trait set. Calling `email.eq(...)` on a cipherstash
41
+ * column is therefore `undefined` — the wrong-SQL footgun (where the
42
+ * built-in `eq` would lower to standard SQL `=` against an
43
+ * `eql_v2_encrypted` value, silently returning zero rows because EQL
44
+ * ciphers contain randomized nonces) is closed at the codec layer, not
45
+ * the operator layer. The trait declaration is regression-pinned by
46
+ * `test/equality-trait-removal.test.ts`.
47
+ *
48
+ * The encrypted-arg path: the operator wraps the user-supplied value
49
+ * in an `EncryptedString` envelope and stamps the column`s
50
+ * `(table, column)` routing context onto the envelope`s handle. The
51
+ * bulk-encrypt middleware then groups the envelope alongside
52
+ * any others targeting the same `(table, column)` and issues one
53
+ * `sdk.bulkEncrypt` per group. The cipherstash codec encodes the
54
+ * resulting ciphertext as the wire payload at
55
+ * `eql_v2_encrypted` cast time. Stamping at lowering time is the
56
+ * load-bearing step — the middleware`s AST walk only handles
57
+ * `InsertAst` / `UpdateAst` (see
58
+ * `src/middleware/bulk-encrypt.ts:stampRoutingKeysFromAst`); SELECT
59
+ * envelopes have to arrive at the middleware already routing-keyed.
60
+ *
61
+ * Build-time return type is the postgres `pg/bool@1` codec — that`s
62
+ * the codec the framework`s predicate machinery looks at via the
63
+ * `'boolean'` trait to decide that the operator`s return value is a
64
+ * predicate suitable for a WHERE clause (see
65
+ * `packages/3-extensions/sql-orm-client/src/model-accessor.ts:172-178`).
66
+ *
67
+ * **`isNull` / `isNotNull` are NOT registered here.** The framework`s
68
+ * always-on `isNull` / `isNotNull` comparison methods construct
69
+ * `NullCheckExpr` directly, bypassing
70
+ * the operator-registry dispatch, and lower to `<col> IS [NOT] NULL`
71
+ * regardless of codec — pinned by `test/operator-lowering.test.ts`.
72
+ */
73
+
74
+ import type { SqlOperationDescriptor, SqlOperationDescriptors } from '@prisma-next/sql-operations';
75
+ import { type AnyExpression, type ColumnRef, ParamRef } from '@prisma-next/sql-relational-core/ast';
76
+ import {
77
+ buildOperation,
78
+ type Expression,
79
+ type ScopeField,
80
+ toExpr,
81
+ } from '@prisma-next/sql-relational-core/expression';
82
+ import { CIPHERSTASH_STRING_CODEC_ID } from '../extension-metadata/constants';
83
+ import { EncryptedString, setHandleRoutingKey } from './envelope';
84
+
85
+ /**
86
+ * Codec ID of the framework's Postgres boolean codec. Referenced as a
87
+ * string (rather than imported from `@prisma-next/target-postgres`)
88
+ * so cipherstash does not pick up a peer-dep on the target package
89
+ * just to identify a return-codec id. Mirrors the same pattern in the
90
+ * reference cipherstash integration's `operation-templates.ts:RETURN_BOOL`.
91
+ */
92
+ const PG_BOOL_CODEC_ID = 'pg/bool@1' as const;
93
+
94
+ type PgBoolReturn = { readonly codecId: typeof PG_BOOL_CODEC_ID; readonly nullable: false };
95
+
96
+ /**
97
+ * Convert a user-supplied value (raw string plaintext or an existing
98
+ * `EncryptedString` envelope) into a `ParamRef` carrying an envelope
99
+ * tagged with the cipherstash storage codec id. The envelope's handle
100
+ * is stamped with the column's `(table, column)` routing context so
101
+ * the bulk-encrypt middleware can group it for SELECT-side bulk
102
+ * encryption (the middleware's AST walk only stamps for INSERT /
103
+ * UPDATE).
104
+ *
105
+ * Already-stamped envelopes are preserved write-once-wins per
106
+ * `setHandleRoutingKey`'s contract.
107
+ */
108
+ function asEncryptedParam(selfAst: AnyExpression, value: unknown): ParamRef {
109
+ const envelope = coerceToEnvelope(value);
110
+ const columnRef = extractColumnRef(selfAst);
111
+ if (columnRef !== undefined) {
112
+ setHandleRoutingKey(envelope, columnRef.table, columnRef.column);
113
+ }
114
+ return ParamRef.of(envelope, { codecId: CIPHERSTASH_STRING_CODEC_ID });
115
+ }
116
+
117
+ function coerceToEnvelope(value: unknown): EncryptedString {
118
+ if (value instanceof EncryptedString) {
119
+ return value;
120
+ }
121
+ if (typeof value === 'string') {
122
+ return EncryptedString.from(value);
123
+ }
124
+ throw new TypeError(
125
+ 'cipherstash operator: expected a string plaintext or an EncryptedString envelope, ' +
126
+ `got ${value === null ? 'null' : typeof value}. ` +
127
+ 'Use `EncryptedString.from(plaintext)` to construct an envelope explicitly, or ' +
128
+ 'pass the plaintext directly and let the operator wrap it.',
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Find the column reference inside a `self` expression so the operator
134
+ * can stamp its `(table, column)` onto the encrypted-param envelope.
135
+ *
136
+ * Most calls flow through the ORM model-accessor, where `self` is a
137
+ * column-field accessor whose `buildAst()` returns a `ColumnRef`
138
+ * directly. For more complex `self` expressions (e.g. wrapped in a
139
+ * function call) we fall back to the `baseColumnRef()` inherited from
140
+ * `Expression` — every standard AST node walks down to the underlying
141
+ * column. If no column is reachable (e.g. a literal `self`), routing
142
+ * stamping is skipped; the envelope will surface the
143
+ * "envelope reached the bulk-encrypt phase without a (table, column)
144
+ * routing context" diagnostic from `collectTargets` at execute time.
145
+ */
146
+ function extractColumnRef(selfAst: AnyExpression): ColumnRef | undefined {
147
+ if (selfAst.kind === 'column-ref') {
148
+ return selfAst;
149
+ }
150
+ try {
151
+ return selfAst.baseColumnRef();
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Build a cipherstash operator descriptor.
159
+ *
160
+ * @param publicMethod - The user-facing method name on the column
161
+ * accessor (e.g. `cipherstashEq`). Must not collide with any
162
+ * framework- or adapter-shipped method name.
163
+ * @param eqlFunction - The EQL function to lower to (`eq`, `ilike`).
164
+ * Embedded into the SQL lowering template as `eql_v2.<eqlFunction>(...)`.
165
+ */
166
+ function eqlOperator(publicMethod: string, eqlFunction: 'eq' | 'ilike'): SqlOperationDescriptor {
167
+ return {
168
+ self: { codecId: CIPHERSTASH_STRING_CODEC_ID },
169
+ impl: (self: Expression<ScopeField>, value: unknown): Expression<PgBoolReturn> => {
170
+ const selfAst = toExpr(self);
171
+ return buildOperation({
172
+ method: publicMethod,
173
+ args: [selfAst, asEncryptedParam(selfAst, value)],
174
+ returns: { codecId: PG_BOOL_CODEC_ID, nullable: false },
175
+ lowering: {
176
+ targetFamily: 'sql',
177
+ strategy: 'function',
178
+ template: `eql_v2.${eqlFunction}({{self}}, {{arg0}})`,
179
+ },
180
+ });
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Cipherstash`s query-operations contributions. Wired into the
187
+ * runtime descriptor by `createCipherstashRuntimeDescriptor` and read
188
+ * by the SQL runtime`s `extractCodecLookup` / `queryOperations`
189
+ * aggregation (`packages/2-sql/5-runtime/src/sql-context.ts`). Two
190
+ * descriptors today:
191
+ *
192
+ * - `cipherstashEq` — encrypted equality via EQL`s `unique` index.
193
+ * SQL: `eql_v2.eq("col", $1::eql_v2_encrypted)`.
194
+ * - `cipherstashIlike` — encrypted free-text match via EQL`s
195
+ * `match` index. SQL:
196
+ * `eql_v2.ilike("col", $1::eql_v2_encrypted)`.
197
+ *
198
+ * Both descriptors register `self: { codecId: 'cipherstash/string@1' }`
199
+ * so the model accessor only attaches them to columns whose codec id
200
+ * is exactly `cipherstash/string@1`. The method names are
201
+ * intentionally cipherstash-prefixed so they coexist with the
202
+ * framework`s `eq` / `ilike` registrations rather than overriding
203
+ * them — see the `Why unique method names` section in this file`s
204
+ * top-level docblock.
205
+ */
206
+ export function cipherstashQueryOperations(): SqlOperationDescriptors {
207
+ return {
208
+ cipherstashEq: eqlOperator('cipherstashEq', 'eq'),
209
+ cipherstashIlike: eqlOperator('cipherstashIlike', 'ilike'),
210
+ };
211
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `RuntimeParameterizedCodecDescriptor` for the cipherstash storage
3
+ * codec — the post-#402 unified `CodecDescriptor<P>` shape consumed by
4
+ * the SQL runtime via `SqlStaticContributions.parameterizedCodecs()`.
5
+ *
6
+ * Mirrors pgvector's `vectorParamsSchema` + `vectorFactory` precedent
7
+ * (`packages/3-extensions/pgvector/src/exports/runtime.ts`). Cipherstash
8
+ * differs from pgvector in one respect: the codec depends on the SDK
9
+ * (read-side single-cell `decrypt`, the bulk-encrypt middleware), so
10
+ * each `createParameterizedCodecDescriptors(sdk)` call produces its
11
+ * own descriptor list closed over its SDK so multi-tenant
12
+ * deployments can side-by-side multiple cipherstash extensions without
13
+ * cross-talk.
14
+ *
15
+ * The factory is per-cell stateless across `(equality, freeTextSearch)`
16
+ * params on the write side (encode reads ciphertext from the handle,
17
+ * independent of the search-mode flags) — search-mode flags only affect
18
+ * operator lowering and the codec lifecycle hook on the control plane.
19
+ * The factory therefore returns the same shared codec
20
+ * for every params instance, mirroring pgvector's `vectorFactory`. When
21
+ * future per-instance state (e.g. decode-time index gating) lands, the
22
+ * closure is the place to add it.
23
+ */
24
+
25
+ import type { CodecInstanceContext } from '@prisma-next/framework-components/codec';
26
+ import type { RuntimeParameterizedCodecDescriptor } from '@prisma-next/sql-runtime';
27
+ import { type as arktype } from 'arktype';
28
+ import { CIPHERSTASH_STRING_CODEC_ID, createCipherstashStringCodec } from './codec-runtime';
29
+ import type { CipherstashSdk } from './sdk';
30
+
31
+ export interface CipherstashStringParams {
32
+ readonly equality: boolean;
33
+ readonly freeTextSearch: boolean;
34
+ }
35
+
36
+ export const encryptedStringParamsSchema = arktype({
37
+ equality: 'boolean',
38
+ freeTextSearch: 'boolean',
39
+ });
40
+
41
+ export function renderEncryptedStringOutputType(_params: CipherstashStringParams): string {
42
+ return 'EncryptedString';
43
+ }
44
+
45
+ export function createParameterizedCodecDescriptors(
46
+ sdk: CipherstashSdk,
47
+ ): ReadonlyArray<RuntimeParameterizedCodecDescriptor<CipherstashStringParams>> {
48
+ const sharedCodec = createCipherstashStringCodec(sdk);
49
+ const factory = (_params: CipherstashStringParams) => (_ctx: CodecInstanceContext) => sharedCodec;
50
+ return [
51
+ {
52
+ codecId: CIPHERSTASH_STRING_CODEC_ID,
53
+ // Empty traits — equality search on cipherstash columns goes
54
+ // through the cipherstash-namespaced operator (`cipherstashEq`
55
+ // in `./operators.ts`), not the framework`s trait-gated built-in
56
+ // `eq`. See `./codec-runtime.ts` for the full rationale.
57
+ traits: [] as const,
58
+ targetTypes: ['eql_v2_encrypted'] as const,
59
+ // Postgres native-type metadata. The SQL renderer reads this off
60
+ // the descriptor via `codecLookup.metaFor(codecId)` to insert the
61
+ // `$N::eql_v2_encrypted` cast on bound params (the EQL composite
62
+ // type isn`t inferrable from a `text` literal, so the cast is
63
+ // load-bearing).
64
+ meta: { db: { sql: { postgres: { nativeType: 'eql_v2_encrypted' } } } },
65
+ paramsSchema: encryptedStringParamsSchema,
66
+ isParameterized: true as const,
67
+ renderOutputType: renderEncryptedStringOutputType,
68
+ factory,
69
+ },
70
+ ] as const satisfies ReadonlyArray<RuntimeParameterizedCodecDescriptor<CipherstashStringParams>>;
71
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Routing-key derivation for cipherstash bulk operations.
3
+ *
4
+ * The routing key is derived from the envelope handle's
5
+ * `(table, column)` — there is no per-column override surface. Every
6
+ * cipherstash envelope passing through `bulkEncryptMiddleware` (and
7
+ * `decryptAll`) carries `(table, column)` on its handle, populated by
8
+ * the middleware's AST walk before the bulk-encrypt phase begins.
9
+ *
10
+ * `groupByRoutingKey` produces one homogeneous group per
11
+ * `(table, column)` pair so each `bulkEncrypt` call serves a single
12
+ * routing key — matching the SDK's
13
+ * `bulkEncrypt({ routingKey, values, signal })` shape. Heterogeneous
14
+ * batching is a future optimization.
15
+ */
16
+
17
+ import type { EncryptedString } from './envelope';
18
+ import type { CipherstashRoutingKey } from './sdk';
19
+
20
+ /**
21
+ * Per-target context the bulk-encrypt middleware accumulates while
22
+ * walking `params.entries()`. Each target carries the envelope, its
23
+ * routing key (derived from the handle), the plaintext to encrypt, and
24
+ * the param-ref handle the mutator yielded so the post-encrypt
25
+ * `replaceValues` write-back can find the slot.
26
+ */
27
+ export interface BulkEncryptTarget<TRef = unknown> {
28
+ readonly ref: TRef;
29
+ readonly plaintext: string;
30
+ readonly envelope: EncryptedString;
31
+ readonly routingKey: CipherstashRoutingKey;
32
+ }
33
+
34
+ /**
35
+ * Stable string key used to group targets by their `(table, column)`
36
+ * routing key. Exported for tests; not part of the package's public
37
+ * surface. Uses a NUL byte as the separator so the id never collides
38
+ * across pairs whose names happen to share a literal concatenation
39
+ * (e.g. `(a, bc)` vs `(ab, c)`).
40
+ */
41
+ export function routingKeyId(routingKey: CipherstashRoutingKey): string {
42
+ return `${routingKey.table}\u0000${routingKey.column}`;
43
+ }
44
+
45
+ /**
46
+ * Read the routing key from an envelope's internal handle. Throws if
47
+ * the handle's `(table, column)` slots are unset — which happens when
48
+ * the bulk-encrypt middleware's AST walk did not see this envelope
49
+ * (typical cause: the envelope was passed in a context the AST walk
50
+ * does not yet handle, e.g. a raw-SQL plan with no `InsertAst` /
51
+ * `UpdateAst` arm). The throw matches the codec's
52
+ * "missing ciphertext" diagnostic shape: it points at the workflow that
53
+ * should have populated the slot.
54
+ */
55
+ export function getRoutingKey(envelope: EncryptedString): CipherstashRoutingKey {
56
+ const handle = envelope.expose();
57
+ if (handle.table === undefined || handle.column === undefined) {
58
+ throw new Error(
59
+ 'cipherstash bulk-encrypt: envelope has no (table, column) routing context. ' +
60
+ 'The bulk-encrypt middleware stamps routing context from the lowered AST ' +
61
+ '(insert/update); raw-SQL plans embedding cipherstash envelopes must stamp ' +
62
+ 'routing context explicitly before execute.',
63
+ );
64
+ }
65
+ return { table: handle.table, column: handle.column };
66
+ }
67
+
68
+ /**
69
+ * Group bulk-encrypt targets by `(table, column)` routing key. Each
70
+ * `Map` entry yields one homogeneous batch suitable for a single
71
+ * `sdk.bulkEncrypt({ routingKey, values, signal })` call.
72
+ *
73
+ * Order preservation: within each group, targets keep the order they
74
+ * were collected from `params.entries()` — which is the canonical
75
+ * ParamRef order the renderer's `$N` index map and the encode-side walk
76
+ * both consume. Iteration order across groups follows the order each
77
+ * routing key was first observed in the input.
78
+ */
79
+ export function groupByRoutingKey<TRef>(
80
+ targets: ReadonlyArray<BulkEncryptTarget<TRef>>,
81
+ ): Map<string, BulkEncryptTarget<TRef>[]> {
82
+ const groups = new Map<string, BulkEncryptTarget<TRef>[]>();
83
+ for (const target of targets) {
84
+ const id = routingKeyId(target.routingKey);
85
+ let group = groups.get(id);
86
+ if (!group) {
87
+ group = [];
88
+ groups.set(id, group);
89
+ }
90
+ group.push(target);
91
+ }
92
+ return groups;
93
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Framework-native shape for the CipherStash SDK that the cipherstash
3
+ * extension wraps.
4
+ *
5
+ * The first-attempt SDK (see `reference/cipherstash/stack/...`) is rich
6
+ * and Prisma-adapter shaped. The framework-native shape consumed by the
7
+ * codec runtime, the bulk-encrypt middleware, and `decryptAll` is
8
+ * intentionally smaller — three async methods that each map cleanly to
9
+ * one CipherStash bulk-call shape:
10
+ *
11
+ * - `decrypt` — single-cell read used by `EncryptedString#decrypt()`
12
+ * when the user opts out of bulk decryption.
13
+ * - `bulkEncrypt` — write-side coalesced encrypt; the bulk-encrypt
14
+ * middleware calls this from `beforeExecute`.
15
+ * - `bulkDecrypt` — read-side coalesced decrypt; `decryptAll` calls
16
+ * this from a recursive walker.
17
+ *
18
+ * Each method accepts an optional `AbortSignal`. Cancellation is forwarded
19
+ * directly to the SDK (the per-execute `MiddlewareContext.signal` from
20
+ * the middleware-param-transform seam, or the caller-supplied signal on
21
+ * `decrypt({signal})`).
22
+ */
23
+
24
+ /**
25
+ * Routing-key tuple used by `bulkEncrypt`/`bulkDecrypt` to group requests
26
+ * so each ZeroKMS round-trip handles one homogeneous batch. Routing key
27
+ * is `(table, column)`.
28
+ */
29
+ export interface CipherstashRoutingKey {
30
+ readonly table: string;
31
+ readonly column: string;
32
+ }
33
+
34
+ export interface CipherstashSingleDecryptArgs {
35
+ /**
36
+ * The wire ciphertext to decrypt. Opaque to the framework; the SDK
37
+ * inspects the embedded `i.t` / `i.c` schema markers to pick the
38
+ * right `cast_as` for the round-trip.
39
+ */
40
+ readonly ciphertext: unknown;
41
+ readonly table: string;
42
+ readonly column: string;
43
+ readonly signal?: AbortSignal;
44
+ }
45
+
46
+ export interface CipherstashBulkEncryptArgs {
47
+ readonly routingKey: CipherstashRoutingKey;
48
+ readonly values: ReadonlyArray<string>;
49
+ readonly signal?: AbortSignal;
50
+ }
51
+
52
+ export interface CipherstashBulkDecryptArgs {
53
+ readonly routingKey: CipherstashRoutingKey;
54
+ readonly ciphertexts: ReadonlyArray<unknown>;
55
+ readonly signal?: AbortSignal;
56
+ }
57
+
58
+ /**
59
+ * The framework-native CipherStash SDK contract consumed by the envelope,
60
+ * codec, middleware, and `decryptAll` surfaces. Real implementations wrap
61
+ * a CipherStash `EncryptionClient`; tests construct mock SDKs that
62
+ * implement these three methods directly.
63
+ */
64
+ export interface CipherstashSdk {
65
+ decrypt(args: CipherstashSingleDecryptArgs): Promise<string>;
66
+ bulkEncrypt(args: CipherstashBulkEncryptArgs): Promise<ReadonlyArray<unknown>>;
67
+ bulkDecrypt(args: CipherstashBulkDecryptArgs): Promise<ReadonlyArray<string>>;
68
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * TS contract factory for cipherstash-encrypted string columns.
3
+ *
4
+ * Counterpart to the PSL constructor `cipherstash.EncryptedString({...})`
5
+ * registered in `../contract/authoring.ts`. Both factories produce the same
6
+ * `ColumnTypeDescriptor` shape so PSL- and TS-authored contracts emit
7
+ * byte-identical `contract.json` (verified by the parity fixture under
8
+ * `test/integration/test/authoring/parity/cipherstash-encrypted-string/`).
9
+ *
10
+ * Both flags default to `true` — searchable encryption is the
11
+ * legitimate default for an extension whose entire reason for existing
12
+ * is to make encrypted columns queryable. Users who want storage-only
13
+ * encryption opt out explicitly: `encryptedString({ equality: false,
14
+ * freeTextSearch: false })`. Mirrors the PSL constructor's `true`
15
+ * defaults declared via `AuthoringArgRef.default`.
16
+ */
17
+
18
+ import {
19
+ CIPHERSTASH_STRING_CODEC_ID,
20
+ EQL_V2_ENCRYPTED_TYPE,
21
+ } from '../extension-metadata/constants';
22
+
23
+ /**
24
+ * Search-mode parameters for `encryptedString({...})`. Both flags are
25
+ * optional and default to `true` when omitted.
26
+ */
27
+ export interface EncryptedStringOptions {
28
+ readonly equality?: boolean;
29
+ readonly freeTextSearch?: boolean;
30
+ }
31
+
32
+ export interface EncryptedStringColumnDescriptor {
33
+ readonly codecId: typeof CIPHERSTASH_STRING_CODEC_ID;
34
+ readonly nativeType: typeof EQL_V2_ENCRYPTED_TYPE;
35
+ readonly typeParams: {
36
+ readonly equality: boolean;
37
+ readonly freeTextSearch: boolean;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * `encryptedString({ equality?, freeTextSearch? })` — TS contract
43
+ * factory that lowers to a `ColumnTypeDescriptor` with the
44
+ * `cipherstash/string@1` codec and the `eql_v2_encrypted` Postgres
45
+ * native type. The two boolean flags become `typeParams.equality` and
46
+ * `typeParams.freeTextSearch`. Both default to `true`.
47
+ *
48
+ * The shape matches what the PSL constructor
49
+ * `cipherstash.EncryptedString({...})` lowers to, byte-for-byte.
50
+ */
51
+ export function encryptedString(
52
+ options: EncryptedStringOptions = {},
53
+ ): EncryptedStringColumnDescriptor {
54
+ return {
55
+ codecId: CIPHERSTASH_STRING_CODEC_ID,
56
+ nativeType: EQL_V2_ENCRYPTED_TYPE,
57
+ typeParams: {
58
+ equality: options.equality ?? true,
59
+ freeTextSearch: options.freeTextSearch ?? true,
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Typed-narrowing helpers for the on-disk contract-space JSON artefacts
3
+ * the cipherstash control descriptor wires into its
4
+ * `SqlControlExtensionDescriptor`.
5
+ *
6
+ * JSON-imported values come back as widened, structurally-typed
7
+ * objects: branded fields (`storageHash: StorageHashBase<string>`) and
8
+ * discriminated unions (`MigrationPlanOperation['operationClass']`)
9
+ * fall back to plain strings, so a direct assignment into the
10
+ * descriptor surfaces is a type error. The cipherstash MVP previously
11
+ * suppressed that error with `as unknown as X` triple-casts, which
12
+ * silently masks any future shape drift between the emitted JSON and
13
+ * the in-package descriptor.
14
+ *
15
+ * This module replaces the blind casts with thin runtime assertions
16
+ * that fail fast on drift and narrow the JSON inputs to the framework
17
+ * types in a single, auditable place. The assertions are intentionally
18
+ * minimal — they check the canonical discriminator fields (`storageHash`,
19
+ * `space`, `dirName`, `operationClass`, …) rather than re-validating
20
+ * the whole emitter contract — which is enough to surface schema-level
21
+ * drift while keeping the descriptor module light.
22
+ */
23
+
24
+ import type { Contract } from '@prisma-next/contract/types';
25
+ import type { MigrationPlanOperation } from '@prisma-next/framework-components/control';
26
+ import type { MigrationMetadata } from '@prisma-next/migration-tools/metadata';
27
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
28
+
29
+ function fail(field: string, value: unknown): never {
30
+ throw new Error(
31
+ `cipherstash contract-space JSON is missing or malformed at "${field}" (saw ${typeof value}). The on-disk JSON drifted from the framework's expected shape — re-run \`prisma-next contract emit\` and \`prisma-next migration plan\` for the cipherstash space.`,
32
+ );
33
+ }
34
+
35
+ function isRecord(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === 'object' && value !== null;
37
+ }
38
+
39
+ /**
40
+ * Narrow a JSON-imported `contract.json` value to `Contract<SqlStorage>`.
41
+ * Checks the discriminators the framework relies on at descriptor
42
+ * registration time; everything else is consumed downstream by the
43
+ * runner / verifier, which performs its own validation.
44
+ */
45
+ export function asCipherstashContract(value: unknown): Contract<SqlStorage> {
46
+ if (!isRecord(value)) fail('<root>', value);
47
+ if (typeof value['target'] !== 'string') fail('target', value['target']);
48
+ if (typeof value['targetFamily'] !== 'string') fail('targetFamily', value['targetFamily']);
49
+ const storage = value['storage'];
50
+ if (!isRecord(storage)) fail('storage', storage);
51
+ if (typeof storage['storageHash'] !== 'string')
52
+ fail('storage.storageHash', storage['storageHash']);
53
+ return value as unknown as Contract<SqlStorage>;
54
+ }
55
+
56
+ /**
57
+ * Narrow a JSON-imported `migration.json` value to `MigrationMetadata`.
58
+ * The framework's runner consumes the metadata for ordering /
59
+ * provenance; missing `to` or a non-string `migrationHash` here means
60
+ * a non-emitted artefact slipped into the import path.
61
+ */
62
+ export function asCipherstashMigrationMetadata(value: unknown): MigrationMetadata {
63
+ if (!isRecord(value)) fail('<root>', value);
64
+ if (typeof value['to'] !== 'string') fail('to', value['to']);
65
+ if (typeof value['migrationHash'] !== 'string') fail('migrationHash', value['migrationHash']);
66
+ return value as unknown as MigrationMetadata;
67
+ }
68
+
69
+ /**
70
+ * Narrow a JSON-imported `ops.json` value to
71
+ * `readonly MigrationPlanOperation[]`. Checks each entry carries the
72
+ * canonical `id` / `operationClass` discriminator so a malformed entry
73
+ * doesn't reach the planner.
74
+ */
75
+ export function asCipherstashMigrationOps(value: unknown): readonly MigrationPlanOperation[] {
76
+ if (!Array.isArray(value)) fail('<root>', value);
77
+ for (let index = 0; index < value.length; index += 1) {
78
+ const entry = value[index];
79
+ if (!isRecord(entry)) fail(`[${index}]`, entry);
80
+ if (typeof entry['id'] !== 'string') fail(`[${index}].id`, entry['id']);
81
+ if (typeof entry['operationClass'] !== 'string') {
82
+ fail(`[${index}].operationClass`, entry['operationClass']);
83
+ }
84
+ }
85
+ return value as unknown as readonly MigrationPlanOperation[];
86
+ }