@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.
- package/README.md +153 -0
- package/dist/call-classes-CSvD7w8U.mjs +206 -0
- package/dist/call-classes-CSvD7w8U.mjs.map +1 -0
- package/dist/column-types.d.mts +33 -0
- package/dist/column-types.d.mts.map +1 -0
- package/dist/column-types.mjs +42 -0
- package/dist/column-types.mjs.map +1 -0
- package/dist/constants-BDxL9Pe3.d.mts +22 -0
- package/dist/constants-BDxL9Pe3.d.mts.map +1 -0
- package/dist/constants-B_2TNvUi.mjs +46 -0
- package/dist/constants-B_2TNvUi.mjs.map +1 -0
- package/dist/control.d.mts +7 -0
- package/dist/control.d.mts.map +1 -0
- package/dist/control.mjs +430 -0
- package/dist/control.mjs.map +1 -0
- package/dist/descriptor-meta-BgQfZTAF.mjs +129 -0
- package/dist/descriptor-meta-BgQfZTAF.mjs.map +1 -0
- package/dist/envelope-P9BxfJNr.mjs +271 -0
- package/dist/envelope-P9BxfJNr.mjs.map +1 -0
- package/dist/middleware.d.mts +13 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +129 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/migration.d.mts +141 -0
- package/dist/migration.d.mts.map +1 -0
- package/dist/migration.mjs +2 -0
- package/dist/operation-types.d.mts +49 -0
- package/dist/operation-types.d.mts.map +1 -0
- package/dist/operation-types.mjs +1 -0
- package/dist/pack.d.mts +86 -0
- package/dist/pack.d.mts.map +1 -0
- package/dist/pack.mjs +2 -0
- package/dist/runtime.d.mts +207 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +429 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/sdk-D5FTGyzp.d.mts +67 -0
- package/dist/sdk-D5FTGyzp.d.mts.map +1 -0
- package/package.json +69 -0
- package/src/contract/authoring.ts +62 -0
- package/src/contract/contract.d.ts +149 -0
- package/src/contract/contract.json +104 -0
- package/src/contract/contract.prisma +46 -0
- package/src/execution/abort.ts +143 -0
- package/src/execution/codec-runtime.ts +209 -0
- package/src/execution/decrypt-all.ts +217 -0
- package/src/execution/envelope.ts +263 -0
- package/src/execution/operators.ts +211 -0
- package/src/execution/parameterized.ts +71 -0
- package/src/execution/routing.ts +93 -0
- package/src/execution/sdk.ts +68 -0
- package/src/exports/column-types.ts +62 -0
- package/src/exports/contract-space-typing.ts +86 -0
- package/src/exports/control.ts +120 -0
- package/src/exports/middleware.ts +24 -0
- package/src/exports/migration.ts +43 -0
- package/src/exports/operation-types.ts +16 -0
- package/src/exports/pack.ts +13 -0
- package/src/exports/runtime.ts +110 -0
- package/src/extension-metadata/codec-metadata.ts +81 -0
- package/src/extension-metadata/constants.ts +70 -0
- package/src/extension-metadata/descriptor-meta.ts +76 -0
- package/src/middleware/bulk-encrypt.ts +192 -0
- package/src/migration/call-classes.ts +350 -0
- package/src/migration/cipherstash-codec.ts +157 -0
- package/src/migration/eql-bundle.ts +29 -0
- package/src/migration/eql-install.generated.ts +5751 -0
- 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
|
+
}
|