@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,209 @@
1
+ /**
2
+ * Cipherstash storage codec runtime — wraps the `EncryptedString`
3
+ * envelope at the SQL codec boundary.
4
+ *
5
+ * Responsibilities are intentionally thin:
6
+ *
7
+ * - `decode(wire, ctx)` constructs a fresh envelope carrying the wire
8
+ * ciphertext + the cell's `(table, column)` from `ctx.column` + the
9
+ * SDK reference captured at codec construction time. The envelope's
10
+ * `decrypt({signal?})` later routes through the captured SDK; callers
11
+ * can also `await decryptAll(rows)` to coalesce decrypts across many
12
+ * envelopes into one bulk SDK call.
13
+ *
14
+ * - `encode(envelope, ctx)` extracts the ciphertext from the envelope's
15
+ * handle. The bulk-encrypt middleware populates the ciphertext slot
16
+ * before the codec runs; an envelope whose ciphertext
17
+ * slot is empty at encode time is a programmer error (the middleware
18
+ * was not registered, or this codec instance was used in a non-
19
+ * cipherstash context).
20
+ *
21
+ * The wire format wraps the SDK's JSON ciphertext payload in the
22
+ * Postgres composite literal `("...escaped JSON...")` because EQL
23
+ * defines `eql_v2_encrypted` as `CREATE TYPE eql_v2_encrypted AS (data
24
+ * jsonb)`, not as a domain over jsonb. The default `pg` driver encodes
25
+ * JS objects as JSON which Postgres then rejects when coercing into the
26
+ * composite. Mirrors the reference Drizzle integration at
27
+ * `reference/cipherstash/.../drizzle/src/pg/index.ts`.
28
+ *
29
+ * The codec captures the SDK at construction time, so multi-tenant
30
+ * deployments construct one extension instance per tenant — each with
31
+ * its own SDK — rather than sharing a module-singleton codec.
32
+ */
33
+
34
+ import type { JsonValue } from '@prisma-next/contract/types';
35
+ import { type AnyCodecDescriptor, CodecImpl } from '@prisma-next/framework-components/codec';
36
+ import type { Codec, SqlCodecCallContext } from '@prisma-next/sql-relational-core/ast';
37
+ import { CIPHERSTASH_STRING_CODEC_ID } from '../extension-metadata/constants';
38
+ import { EncryptedString } from './envelope';
39
+ import type { CipherstashSdk } from './sdk';
40
+
41
+ const CIPHERSTASH_STRING_TARGET_TYPE = 'eql_v2_encrypted' as const;
42
+ // Cipherstash columns intentionally declare no codec traits.
43
+ //
44
+ // The framework's `equality` trait gates the built-in `eq` / `neq` /
45
+ // `in` / `notIn` comparison methods (see `COMPARISON_METHODS_META` in
46
+ // `packages/3-extensions/sql-orm-client/src/types.ts`). Those built-
47
+ // ins lower to standard SQL `=` / `!=` / `IN`, which is wrong for
48
+ // cipherstash columns because EQL ciphers contain randomized nonces
49
+ // and do not byte-equal under `=`. Declaring `equality` here would
50
+ // silently expose the wrong-SQL footgun; declaring `[]` makes
51
+ // `email.eq(...)` undefined at the column accessor and forces callers
52
+ // onto the cipherstash-namespaced operator surface
53
+ // (`email.cipherstashEq(...)` — see `./operators.ts`). The trait
54
+ // declaration is regression-pinned by `test/equality-trait-removal.test.ts`.
55
+ //
56
+ // The user-visible `EncryptedString({ equality: true })` flag in PSL
57
+ // / TS authoring is unrelated to this codec trait — it controls
58
+ // whether the codec lifecycle hook contributes a per-column search-
59
+ // config migration op for the column's `unique` index. The two
60
+ // `equality` concepts share only their name.
61
+ const CIPHERSTASH_STRING_TRAITS = [] as const;
62
+
63
+ /**
64
+ * Encode the SDK ciphertext payload as a Postgres composite literal
65
+ * `("...escaped JSON...")`. Embedded `"` are doubled per the composite
66
+ * text-format escape rules.
67
+ */
68
+ function encodeEqlV2EncryptedWire(payload: unknown): string {
69
+ const json = JSON.stringify(payload);
70
+ if (json === undefined) {
71
+ throw new Error(
72
+ 'cipherstash codec: ciphertext payload is not JSON-serializable. ' +
73
+ 'The CipherStash SDK must return a JSON-encodable bulk-encrypt result.',
74
+ );
75
+ }
76
+ const escaped = json.replaceAll('"', '""');
77
+ return `("${escaped}")`;
78
+ }
79
+
80
+ /**
81
+ * Inverse of {@link encodeEqlV2EncryptedWire}. Postgres returns
82
+ * `eql_v2_encrypted` cells in composite text format; some pg clients
83
+ * pre-parse composite cells into `{ data: ... }` row objects. Both
84
+ * shapes — and `null`/`undefined` passthrough — are accepted.
85
+ */
86
+ function decodeEqlV2EncryptedWire(wire: unknown): unknown {
87
+ if (wire === null || wire === undefined) return wire;
88
+ if (typeof wire === 'object') {
89
+ if ('data' in wire) {
90
+ return (wire as { data: unknown }).data;
91
+ }
92
+ return wire;
93
+ }
94
+ if (typeof wire !== 'string') {
95
+ throw new Error(
96
+ `cipherstash codec: unexpected wire shape for eql_v2_encrypted: ${typeof wire}`,
97
+ );
98
+ }
99
+ const trimmed = wire.trim();
100
+ if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) {
101
+ throw new Error(
102
+ `cipherstash codec: expected composite literal "(...)" but got: ${trimmed.slice(0, 40)}`,
103
+ );
104
+ }
105
+ const inner = trimmed.slice(1, -1);
106
+ const unquoted =
107
+ inner.startsWith('"') && inner.endsWith('"') ? inner.slice(1, -1).replaceAll('""', '"') : inner;
108
+ return JSON.parse(unquoted);
109
+ }
110
+
111
+ export class CipherstashStringCodec
112
+ extends CodecImpl<
113
+ typeof CIPHERSTASH_STRING_CODEC_ID,
114
+ typeof CIPHERSTASH_STRING_TRAITS,
115
+ unknown,
116
+ EncryptedString
117
+ >
118
+ implements
119
+ Codec<
120
+ typeof CIPHERSTASH_STRING_CODEC_ID,
121
+ typeof CIPHERSTASH_STRING_TRAITS,
122
+ unknown,
123
+ EncryptedString
124
+ >
125
+ {
126
+ readonly sdk: CipherstashSdk | undefined;
127
+
128
+ constructor(descriptor: AnyCodecDescriptor, sdk: CipherstashSdk | undefined) {
129
+ super(descriptor);
130
+ this.sdk = sdk;
131
+ }
132
+
133
+ async encode(value: EncryptedString, _ctx: SqlCodecCallContext): Promise<unknown> {
134
+ const handle = value.expose();
135
+ if (handle.ciphertext === undefined) {
136
+ throw new Error(
137
+ 'cipherstash codec: envelope has no ciphertext at encode time. ' +
138
+ 'Register the bulk-encrypt middleware in the runtime so envelopes are encrypted before encoding.',
139
+ );
140
+ }
141
+ return encodeEqlV2EncryptedWire(handle.ciphertext);
142
+ }
143
+
144
+ async decode(wire: unknown, ctx: SqlCodecCallContext): Promise<EncryptedString> {
145
+ if (this.sdk === undefined) {
146
+ throw new Error(
147
+ 'cipherstash codec: decode called on the metadata-only codec instance. ' +
148
+ 'Construct a runtime descriptor via `createCipherstashRuntimeDescriptor({ sdk })` and use that instead.',
149
+ );
150
+ }
151
+ const column = ctx.column;
152
+ if (!column) {
153
+ throw new Error(
154
+ 'cipherstash codec: decode requires ctx.column to construct a routing-aware envelope. ' +
155
+ 'The SQL runtime populates ctx.column for projected columns; aggregate/computed cells are not supported by this codec.',
156
+ );
157
+ }
158
+ return EncryptedString.fromInternal({
159
+ ciphertext: decodeEqlV2EncryptedWire(wire),
160
+ table: column.table,
161
+ column: column.name,
162
+ sdk: this.sdk,
163
+ });
164
+ }
165
+
166
+ encodeJson(_value: EncryptedString): JsonValue {
167
+ return { $encryptedString: '<opaque>' };
168
+ }
169
+
170
+ decodeJson(_json: JsonValue): EncryptedString {
171
+ throw new Error(
172
+ 'cipherstash codec: decodeJson is not supported; envelopes do not round-trip through JSON.',
173
+ );
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Variance-erased descriptor placeholder used by `createCipherstashStringCodec`
179
+ * so legacy callers that need a bare `Codec` instance (e.g. extension control-
180
+ * plane wiring that built up a flat list of codecs) can keep constructing one
181
+ * directly. Production runtime descriptors should resolve the per-instance
182
+ * codec through `CipherstashStringDescriptor.factory(params)(ctx)`.
183
+ */
184
+ const FALLBACK_DESCRIPTOR: AnyCodecDescriptor = {
185
+ codecId: CIPHERSTASH_STRING_CODEC_ID,
186
+ traits: CIPHERSTASH_STRING_TRAITS,
187
+ targetTypes: [CIPHERSTASH_STRING_TARGET_TYPE],
188
+ meta: {
189
+ db: { sql: { postgres: { nativeType: CIPHERSTASH_STRING_TARGET_TYPE } } },
190
+ },
191
+ paramsSchema: {
192
+ '~standard': {
193
+ version: 1,
194
+ vendor: 'cipherstash',
195
+ validate: (value: unknown) => ({ value }),
196
+ },
197
+ },
198
+ isParameterized: false,
199
+ renderOutputType: () => 'EncryptedString',
200
+ factory: () => () => {
201
+ throw new Error('cipherstash codec: fallback descriptor factory is not callable');
202
+ },
203
+ };
204
+
205
+ export function createCipherstashStringCodec(sdk: CipherstashSdk): CipherstashStringCodec {
206
+ return new CipherstashStringCodec(FALLBACK_DESCRIPTOR, sdk);
207
+ }
208
+
209
+ export { CIPHERSTASH_STRING_CODEC_ID };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * `decryptAll` — read-side bulk-decrypt walker.
3
+ *
4
+ * Public utility users invoke after `findMany` (or any other read
5
+ * surface) to materialize the plaintext for every `EncryptedString`
6
+ * envelope reachable from the result set in a fixed number of bulk SDK
7
+ * round-trips:
8
+ *
9
+ * const rows = await db.select(...).from(User).execute();
10
+ * await decryptAll(rows);
11
+ * // every envelope's `decrypt()` now returns plaintext synchronously.
12
+ *
13
+ * Why a separate utility (rather than middleware that auto-decrypts on
14
+ * every read): the framework`s streaming-read path cannot bulk-amortize
15
+ * decryption across rows it`s yielding incrementally — by the time row
16
+ * N is yielded, rows 1..N-1 have already been delivered to the caller.
17
+ * The `decryptAll` shape lets the caller buffer the result set
18
+ * explicitly (with `await stream.toArray()`) and then opt into bulk
19
+ * decryption in one round-trip per `(table, column)` group. The runtime
20
+ * descriptor wrapper deliberately does NOT register an implicit-decrypt
21
+ * middleware for this reason.
22
+ *
23
+ * **Walker shape**.
24
+ *
25
+ * - Recursive on plain objects + plain arrays only. Date / Map / Set /
26
+ * typed arrays / Buffer / function / etc. are not recursed into:
27
+ * cipherstash envelopes are user data and would not normally embed
28
+ * inside these host containers; if a future caller needs to bulk-
29
+ * decrypt envelopes inside such a container they extract them into a
30
+ * plain row first. The narrow scope keeps the walker`s behavior
31
+ * trivially predictable and avoids the cycle / iterator / lazy-eval
32
+ * surface those exotic types bring.
33
+ * - Cycle-safe via a `WeakSet` of visited objects/arrays; the same
34
+ * envelope appearing in N positions is collected once.
35
+ * - Skips envelopes whose plaintext slot is already populated
36
+ * (write-side envelopes from `EncryptedString.from(plaintext)`, or
37
+ * read-side envelopes already materialized by a prior
38
+ * `decrypt()` / `decryptAll(...)`). The skip means a re-run is a
39
+ * no-op and a mixed write/read row tree only round-trips for the
40
+ * envelopes that need it.
41
+ *
42
+ * **Grouping**. Envelopes are grouped by `(sdk, table, column)` —
43
+ * routing key plus the envelope handle`s SDK reference. The SDK split
44
+ * preserves the per-tenant SDK isolation `runtime.ts`'s docblock spells
45
+ * out: each tenant constructs its own runtime descriptor with its own
46
+ * SDK so per-tenant key material never crosses runtimes. Envelopes from
47
+ * different tenants happening to share `(table, column)` therefore
48
+ * still receive separate `bulkDecrypt` calls.
49
+ *
50
+ * **Cancellation**. `opts.signal` is forwarded by identity to every
51
+ * `bulkDecrypt` call via `ifDefined` — the same shape the bulk-encrypt
52
+ * middleware and `EncryptedString.decrypt({ signal? })` use. The
53
+ * walker also races each SDK promise against `opts.signal` via
54
+ * `raceCipherstashAbort` so an abort surfaces `RUNTIME.ABORTED
55
+ * { phase: 'decrypt-all' }` promptly even when the SDK body itself
56
+ * ignores the signal. A pre-check before the first SDK round-trip
57
+ * short-circuits when the signal is already aborted at entry; the
58
+ * no-envelopes-reachable fast path returns immediately without
59
+ * observing the signal.
60
+ */
61
+
62
+ import { ifDefined } from '@prisma-next/utils/defined';
63
+ import { checkCipherstashAborted, raceCipherstashAbort } from './abort';
64
+ import { EncryptedString, isHandleDecrypted, setHandlePlaintextCache } from './envelope';
65
+ import type { CipherstashRoutingKey, CipherstashSdk } from './sdk';
66
+
67
+ export interface DecryptAllOptions {
68
+ readonly signal?: AbortSignal;
69
+ }
70
+
71
+ interface BulkDecryptTarget {
72
+ readonly envelope: EncryptedString;
73
+ readonly ciphertext: unknown;
74
+ readonly sdk: CipherstashSdk;
75
+ readonly routingKey: CipherstashRoutingKey;
76
+ }
77
+
78
+ /**
79
+ * Walk a result set and bulk-decrypt every `EncryptedString` envelope
80
+ * reachable from it. After the returned promise resolves, every touched
81
+ * envelope's `decrypt()` returns the cached plaintext synchronously
82
+ * without consulting the SDK.
83
+ *
84
+ * The walker is a no-op when no envelopes are reachable (returns
85
+ * without making any SDK call), so it is cheap to call defensively
86
+ * after queries that may or may not contain encrypted columns.
87
+ */
88
+ export async function decryptAll(rows: unknown, opts?: DecryptAllOptions): Promise<void> {
89
+ const targets = collectTargets(rows);
90
+ if (targets.length === 0) {
91
+ return;
92
+ }
93
+ const groups = groupTargets(targets);
94
+ for (const group of groups.values()) {
95
+ const first = group[0];
96
+ if (!first) continue;
97
+ const ciphertexts = group.map((t) => t.ciphertext);
98
+ checkCipherstashAborted(opts?.signal, 'decrypt-all');
99
+ const plaintexts = await raceCipherstashAbort(
100
+ first.sdk.bulkDecrypt({
101
+ routingKey: first.routingKey,
102
+ ciphertexts,
103
+ ...ifDefined('signal', opts?.signal),
104
+ }),
105
+ opts?.signal,
106
+ 'decrypt-all',
107
+ );
108
+ if (plaintexts.length !== group.length) {
109
+ throw new Error(
110
+ `cipherstash decryptAll: SDK returned ${plaintexts.length} plaintexts ` +
111
+ `for routing key (${first.routingKey.table}, ${first.routingKey.column}) ` +
112
+ `but ${group.length} were requested.`,
113
+ );
114
+ }
115
+ for (let i = 0; i < group.length; i++) {
116
+ const target = group[i];
117
+ const plaintext = plaintexts[i];
118
+ if (!target || plaintext === undefined) continue;
119
+ setHandlePlaintextCache(target.envelope, plaintext);
120
+ }
121
+ }
122
+ }
123
+
124
+ function collectTargets(root: unknown): BulkDecryptTarget[] {
125
+ const targets: BulkDecryptTarget[] = [];
126
+ const seenObjects = new WeakSet<object>();
127
+ const seenEnvelopes = new WeakSet<EncryptedString>();
128
+ visit(root, seenObjects, (envelope) => {
129
+ if (seenEnvelopes.has(envelope)) return;
130
+ seenEnvelopes.add(envelope);
131
+ if (isHandleDecrypted(envelope)) return;
132
+ const handle = envelope.expose();
133
+ if (handle.table === undefined || handle.column === undefined) {
134
+ throw new Error(
135
+ 'cipherstash decryptAll: envelope is missing (table, column) routing context. ' +
136
+ 'Read-side envelopes constructed via codec.decode always carry routing context; ' +
137
+ 'this typically means the envelope was constructed manually outside the codec path.',
138
+ );
139
+ }
140
+ if (handle.sdk === undefined) {
141
+ throw new Error(
142
+ 'cipherstash decryptAll: envelope is missing the SDK reference needed to decrypt. ' +
143
+ 'Read-side envelopes constructed via codec.decode always carry an SDK reference; ' +
144
+ 'this typically means the envelope was constructed manually outside the codec path.',
145
+ );
146
+ }
147
+ targets.push({
148
+ envelope,
149
+ ciphertext: handle.ciphertext,
150
+ sdk: handle.sdk,
151
+ routingKey: { table: handle.table, column: handle.column },
152
+ });
153
+ });
154
+ return targets;
155
+ }
156
+
157
+ function visit(
158
+ value: unknown,
159
+ seen: WeakSet<object>,
160
+ found: (envelope: EncryptedString) => void,
161
+ ): void {
162
+ if (value === null || value === undefined) return;
163
+ if (value instanceof EncryptedString) {
164
+ found(value);
165
+ return;
166
+ }
167
+ if (typeof value !== 'object') return;
168
+ if (seen.has(value)) return;
169
+ // Walker is intentionally scoped to plain arrays + plain objects.
170
+ // Date / Map / Set / typed arrays / Buffer / Error / class instances
171
+ // are passed over so the walker`s shape stays trivially predictable
172
+ // and immune to host-object iterator surprises.
173
+ if (Array.isArray(value)) {
174
+ seen.add(value);
175
+ for (const item of value) {
176
+ visit(item, seen, found);
177
+ }
178
+ return;
179
+ }
180
+ if (!isPlainObject(value)) {
181
+ return;
182
+ }
183
+ seen.add(value);
184
+ for (const key of Object.keys(value)) {
185
+ visit((value as Record<string, unknown>)[key], seen, found);
186
+ }
187
+ }
188
+
189
+ function isPlainObject(value: object): boolean {
190
+ const proto = Object.getPrototypeOf(value);
191
+ return proto === null || proto === Object.prototype;
192
+ }
193
+
194
+ function groupTargets(targets: ReadonlyArray<BulkDecryptTarget>): Map<string, BulkDecryptTarget[]> {
195
+ // Group by `(sdk identity, table, column)`. The SDK identity portion
196
+ // of the key uses a per-SDK index issued on first encounter so
197
+ // grouping never depends on object reference equality colliding
198
+ // accidentally (different SDK instances always partition into
199
+ // different groups even if their `(table, column)` matches).
200
+ const sdkIndex = new Map<CipherstashSdk, number>();
201
+ const groups = new Map<string, BulkDecryptTarget[]>();
202
+ for (const target of targets) {
203
+ let idx = sdkIndex.get(target.sdk);
204
+ if (idx === undefined) {
205
+ idx = sdkIndex.size;
206
+ sdkIndex.set(target.sdk, idx);
207
+ }
208
+ const id = `${idx}\u0000${target.routingKey.table}\u0000${target.routingKey.column}`;
209
+ let group = groups.get(id);
210
+ if (!group) {
211
+ group = [];
212
+ groups.set(id, group);
213
+ }
214
+ group.push(target);
215
+ }
216
+ return groups;
217
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `EncryptedString` envelope and its package-internal handle helpers.
3
+ *
4
+ * The envelope is the user-facing input/output type for cipherstash-
5
+ * backed columns. It wraps an `EncryptedStringHandle` (plaintext slot,
6
+ * ciphertext slot, routing key, SDK reference) holding the per-cell
7
+ * lifecycle state.
8
+ *
9
+ * ## Encapsulation pattern (Rust `secrecy` style)
10
+ *
11
+ * Storage is a `#private` instance field. The blessed read path is the
12
+ * explicit `expose()` method — same shape as the Rust `secrecy` crate's
13
+ * `SecretBox<T>::expose_secret`. Calling `expose()` is a deliberate
14
+ * opt-in: the caller is announcing "I want the wrapped state". The
15
+ * envelope does not — and is not meant to — make that *impossible*; it
16
+ * is meant to make accidental exposure (logger output, error envelopes,
17
+ * stringification, JSON serialization, primitive coercion) impossible
18
+ * unless the caller goes through `expose()`.
19
+ *
20
+ * Concretely the class overrides every coercion / serialization vector
21
+ * that would otherwise reveal the handle:
22
+ *
23
+ * - `toJSON()` — `JSON.stringify`
24
+ * - `toString()` — `String(envelope)`
25
+ * - `valueOf()` — legacy primitive coercion
26
+ * - `[Symbol.toPrimitive]()` — template literals, `+`
27
+ * - `[Symbol.for('nodejs.util.inspect.custom')]()` — `console.log`,
28
+ * Node REPL, debuggers
29
+ *
30
+ * All five return the same `[REDACTED]` placeholder. Without these
31
+ * overrides, modern Node runtimes surface `#private` fields in
32
+ * `util.inspect` output by default, which would silently re-expose
33
+ * the handle through `console.log(envelope)` (and any error message
34
+ * that interpolates an envelope).
35
+ *
36
+ * ## Lifecycle
37
+ *
38
+ * The handle has two flavours:
39
+ * - **Write side** — `EncryptedString.from(plaintext)` populates the
40
+ * `plaintext` slot and leaves `ciphertext` empty. The bulk-encrypt
41
+ * middleware populates `ciphertext` post-SDK and intentionally
42
+ * leaves the plaintext slot in place (zeroing JS strings is
43
+ * best-effort and GC-driven lifecycle is sufficient here). As a
44
+ * side effect a write-side envelope's `decrypt()` returns the
45
+ * original plaintext synchronously without an SDK round-trip.
46
+ * - **Read side** — `EncryptedString.fromInternal({...})` (called from
47
+ * the codec `decode` body) populates `ciphertext`, `(table, column)`
48
+ * from `SqlCodecCallContext.column`, and an `sdk` reference so
49
+ * `decrypt({signal?})` can issue the SDK's single-cell decrypt.
50
+ */
51
+
52
+ import { ifDefined } from '@prisma-next/utils/defined';
53
+ import { checkCipherstashAborted, raceCipherstashAbort } from './abort';
54
+ import type { CipherstashSdk } from './sdk';
55
+
56
+ /**
57
+ * The mutable state of an `EncryptedString` — exposed by `expose()` for
58
+ * callers that explicitly opt in. Mutating these slots from outside the
59
+ * package is supported (we don't stop you) but unusual; the framework's
60
+ * own lifecycle mutators (`setHandleCiphertext`, `setHandleRoutingKey`,
61
+ * etc.) are the conventional path.
62
+ */
63
+ export interface EncryptedStringHandle {
64
+ plaintext: string | undefined;
65
+ ciphertext: unknown;
66
+ table: string | undefined;
67
+ column: string | undefined;
68
+ sdk: CipherstashSdk | undefined;
69
+ }
70
+
71
+ const REDACTED = '[REDACTED]';
72
+
73
+ export interface EncryptedStringFromInternalArgs {
74
+ readonly ciphertext: unknown;
75
+ readonly table: string;
76
+ readonly column: string;
77
+ readonly sdk: CipherstashSdk;
78
+ }
79
+
80
+ export class EncryptedString {
81
+ readonly #handle: EncryptedStringHandle;
82
+
83
+ private constructor(handle: EncryptedStringHandle) {
84
+ this.#handle = handle;
85
+ }
86
+
87
+ /**
88
+ * Construct a write-side envelope from plaintext. Bulk-encrypt
89
+ * middleware populates the handle's ciphertext slot before the codec
90
+ * encodes the envelope to wire format.
91
+ */
92
+ static from(plaintext: string): EncryptedString {
93
+ return new EncryptedString({
94
+ plaintext,
95
+ ciphertext: undefined,
96
+ table: undefined,
97
+ column: undefined,
98
+ sdk: undefined,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Construct a read-side envelope from a wire ciphertext + the column
104
+ * identity + the SDK used to decrypt the cell. Called from the codec
105
+ * `decode` body.
106
+ */
107
+ static fromInternal(args: EncryptedStringFromInternalArgs): EncryptedString {
108
+ return new EncryptedString({
109
+ plaintext: undefined,
110
+ ciphertext: args.ciphertext,
111
+ table: args.table,
112
+ column: args.column,
113
+ sdk: args.sdk,
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Explicitly retrieve the wrapped handle. Modelled on Rust `secrecy`'s
119
+ * `SecretBox<T>::expose_secret`: the handle is reachable, but you have
120
+ * to ask for it by name. Callers reach for `expose()` when they need
121
+ * to inspect or transport the ciphertext envelope, debug lifecycle
122
+ * state, or wire ad-hoc tooling around the SDK reference.
123
+ *
124
+ * Mutating the returned handle is supported but unusual — the
125
+ * framework's lifecycle mutators (`setHandleCiphertext`,
126
+ * `setHandleRoutingKey`, etc.) are the conventional path during
127
+ * encrypt / decrypt flow.
128
+ */
129
+ expose(): EncryptedStringHandle {
130
+ return this.#handle;
131
+ }
132
+
133
+ /**
134
+ * Decrypt and return the plaintext.
135
+ *
136
+ * - If the handle's `plaintext` slot is already populated (write-side
137
+ * envelopes from `from(plaintext)`, or read-side envelopes already
138
+ * materialized by `decryptAll(...)` or a prior `decrypt()`), returns
139
+ * the cached plaintext synchronously without consulting the SDK.
140
+ * - Otherwise (read-side handle without a cached plaintext), invokes
141
+ * the SDK's single-cell `decrypt` with the handle's routing context.
142
+ * The caller-supplied `signal` is forwarded to the SDK by identity
143
+ * per the umbrella cancellation contract; the SDK promise is also
144
+ * raced against the signal so an abort surfaces a `RUNTIME.ABORTED
145
+ * { phase: 'decrypt' }` envelope promptly even if the SDK body
146
+ * ignores the signal. The cached-plaintext fast path returns
147
+ * synchronously without consulting the signal — no IO, no abort
148
+ * observation point.
149
+ */
150
+ async decrypt(opts?: { signal?: AbortSignal }): Promise<string> {
151
+ if (this.#handle.plaintext !== undefined) {
152
+ return this.#handle.plaintext;
153
+ }
154
+ if (
155
+ !this.#handle.sdk ||
156
+ this.#handle.table === undefined ||
157
+ this.#handle.column === undefined
158
+ ) {
159
+ throw new Error(
160
+ 'EncryptedString.decrypt(): envelope has no cached plaintext and no SDK binding. ' +
161
+ 'This typically means the bulk-encrypt middleware did not run before the encode site.',
162
+ );
163
+ }
164
+ checkCipherstashAborted(opts?.signal, 'decrypt');
165
+ const plaintext = await raceCipherstashAbort(
166
+ this.#handle.sdk.decrypt({
167
+ ciphertext: this.#handle.ciphertext,
168
+ table: this.#handle.table,
169
+ column: this.#handle.column,
170
+ ...ifDefined('signal', opts?.signal),
171
+ }),
172
+ opts?.signal,
173
+ 'decrypt',
174
+ );
175
+ this.#handle.plaintext = plaintext;
176
+ return plaintext;
177
+ }
178
+
179
+ toJSON(): string {
180
+ return REDACTED;
181
+ }
182
+
183
+ toString(): string {
184
+ return REDACTED;
185
+ }
186
+
187
+ valueOf(): string {
188
+ return REDACTED;
189
+ }
190
+
191
+ [Symbol.toPrimitive](): string {
192
+ return REDACTED;
193
+ }
194
+
195
+ [Symbol.for('nodejs.util.inspect.custom')](): string {
196
+ return REDACTED;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Populate the handle's ciphertext slot. Called by the bulk-encrypt
202
+ * middleware after the SDK returns the encrypted batch.
203
+ *
204
+ * The plaintext slot is intentionally retained — zeroing in JS is
205
+ * best-effort (strings are immutable) and the GC-driven lifecycle is
206
+ * sufficient.
207
+ */
208
+ export function setHandleCiphertext(envelope: EncryptedString, ciphertext: unknown): void {
209
+ envelope.expose().ciphertext = ciphertext;
210
+ }
211
+
212
+ /**
213
+ * Populate the handle's plaintext slot with a freshly-decrypted value
214
+ * (read-side caching path used by `decryptAll` and by `decrypt()`'s own
215
+ * memoization).
216
+ */
217
+ export function setHandlePlaintextCache(envelope: EncryptedString, plaintext: string): void {
218
+ envelope.expose().plaintext = plaintext;
219
+ }
220
+
221
+ /**
222
+ * Stamp the encrypt-side `(table, column)` routing context onto a
223
+ * write-side envelope's handle. Called by the bulk-encrypt middleware
224
+ * before grouping envelopes into per-routing-key bulk-encrypt batches.
225
+ *
226
+ * Idempotent for matching reassignments (re-stamping the same
227
+ * `(table, column)` is a no-op, which covers envelopes reconstructed
228
+ * via `fromInternal` on the read side and re-stamped on the way back
229
+ * in). Conflicting reassignments throw a descriptive error: an
230
+ * envelope reused across plans with a different routing context is a
231
+ * programming error — silently keeping the stale binding would lower
232
+ * to the wrong bulk-encrypt batch.
233
+ */
234
+ export function setHandleRoutingKey(
235
+ envelope: EncryptedString,
236
+ table: string,
237
+ column: string,
238
+ ): void {
239
+ const handle = envelope.expose();
240
+ if (handle.table === undefined) {
241
+ handle.table = table;
242
+ } else if (handle.table !== table) {
243
+ throw new Error(
244
+ `cipherstash envelope: routing-key table conflict — handle already bound to "${handle.table}", refusing to rebind to "${table}". Re-encode the value or construct a fresh envelope for the new routing target.`,
245
+ );
246
+ }
247
+ if (handle.column === undefined) {
248
+ handle.column = column;
249
+ } else if (handle.column !== column) {
250
+ throw new Error(
251
+ `cipherstash envelope: routing-key column conflict on table "${handle.table}" — handle already bound to "${handle.column}", refusing to rebind to "${column}". Re-encode the value or construct a fresh envelope for the new routing target.`,
252
+ );
253
+ }
254
+ }
255
+
256
+ /**
257
+ * `true` when the handle already carries a usable plaintext (write-side
258
+ * construction or post-`decrypt` caching). Used by `decryptAll` to skip
259
+ * envelopes that don't need a round-trip.
260
+ */
261
+ export function isHandleDecrypted(envelope: EncryptedString): boolean {
262
+ return envelope.expose().plaintext !== undefined;
263
+ }