Skip to content

Commit e3c62d8

Browse files
authored
Merge pull request #618 from cipherstash/feat/eql-v3-type-robustness
refactor(stack): restore erased EQL v3 envelope + Result types
2 parents 2565616 + 311dfc6 commit e3c62d8

6 files changed

Lines changed: 173 additions & 29 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
Restore the EQL v3 envelope and `Result` types the adapters were erasing.
6+
7+
Both v3 adapters typed their operand-encryption paths as `unknown` and dropped
8+
the `Result` wrapper, so the query-type encoding and the failure channel were
9+
invisible to the type system:
10+
11+
- `eql/v3/drizzle/operators.ts` typed the client's `encrypt`/`bulkEncrypt` as
12+
returning `unknown`, collapsed the operation's `Result` to
13+
`{ data?: unknown; failure?: { message } }`, and cast the bulk response to
14+
`Array<{ data: unknown }>`.
15+
- `supabase/query-builder-v3.ts` returned `Promise<unknown[]>` from
16+
`encryptCollectedTerms`, `bulkEncryptGroup` and `encryptGroupPerTerm`, and the
17+
base `query-builder.ts` did the same.
18+
19+
These now carry the SDK's real types — `Encrypted` (the storage envelope union,
20+
which includes every v3 per-domain payload), `BulkEncryptedData`, and
21+
`EncryptedQueryResult` — threaded through a properly-typed operation surface that
22+
resolves `Result<T, EncryptionError>`. The Supabase divergence the erasure hid is
23+
now explicit: the v2 path yields `encryptQuery` composite literals and the v3
24+
path yields `JSON.stringify`'d envelope strings, and both are `EncryptedQueryResult`.
25+
26+
Bumped `minor`, not `patch`: `createEncryptionOperatorsV3` is a public export
27+
(`@cipherstash/stack/eql/v3/drizzle`), and tightening its client contract from
28+
`unknown` to a typed operation surface is a compile-time breaking change — a
29+
downstream consumer passing a loosely-typed (`unknown`-returning) client double
30+
will now fail `tsc`. That tightening has teeth: `operators.test-d.ts` pins it
31+
with a negative type-test asserting an `unknown`-returning `{ encrypt }` double
32+
is rejected (a positive "correctly-typed double is accepted" assertion cannot
33+
catch a re-erasure, since a correct value is assignable to `unknown`).
34+
35+
Behaviour is otherwise unchanged, with one addition: the Supabase v3 bulk path
36+
now rejects a `null` envelope returned by `bulkEncrypt` (the restored
37+
`Encrypted | null` type makes that arm reachable, and a `null` would otherwise
38+
be `JSON.stringify`'d to the literal `"null"` and sent as a filter operand).

packages/stack/__tests__/drizzle-v3/operators.test-d.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import type { Result } from '@byteslice/result'
12
import { describe, expectTypeOf, it } from 'vitest'
23
import type { EncryptionClient } from '@/encryption'
4+
import type { AuditConfig } from '@/encryption/operations/base-operation'
35
import type { EncryptionV3 } from '@/encryption/v3'
46
import { createEncryptionOperatorsV3 } from '@/eql/v3/drizzle'
7+
import type { EncryptionError } from '@/errors'
8+
import type { LockContext } from '@/identity'
9+
import type { Encrypted } from '@/types'
510

611
/**
712
* Static regression guard for M1: `createEncryptionOperatorsV3` must accept the
@@ -27,9 +32,34 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => {
2732
})
2833

2934
it('accepts a minimal structural { encrypt } double', () => {
35+
// The double now models what the real client returns: an operation whose
36+
// `then` resolves a `Result<Encrypted, …>`, not `unknown`. That is the point
37+
// of the un-erasure — a `{ encrypt }` returning `unknown` no longer
38+
// typechecks, because the factory reads `result.data` as an `Encrypted`
39+
// envelope rather than casting it.
40+
type Op = {
41+
withLockContext(lc: LockContext): Op
42+
audit(cfg: AuditConfig): Op
43+
then: PromiseLike<Result<Encrypted, EncryptionError>>['then']
44+
}
3045
const double = {
31-
encrypt: (_plaintext: never, _opts: never) => ({}) as unknown,
46+
encrypt: (_plaintext: never, _opts: never) => ({}) as Op,
3247
}
3348
expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double)
3449
})
50+
51+
it('rejects a { encrypt } double that returns `unknown` (the erasure regression)', () => {
52+
// The complement of the test above, and the one with teeth: `toBeCallableWith`
53+
// a correctly-typed double keeps passing even if the client contract
54+
// regressed to `unknown` (a correct value is assignable to `unknown`), so it
55+
// cannot catch re-erasure. This can. `encrypt` returning `unknown` must NOT
56+
// satisfy the factory, whose contract is `ChainableOperation<Encrypted>`; if
57+
// the erasure ever comes back, the `@ts-expect-error` goes unused and fails.
58+
const erased = {
59+
encrypt: (_plaintext: never, _opts: never): unknown => ({}),
60+
}
61+
// @ts-expect-error — `encrypt` returning `unknown` does not satisfy the
62+
// factory's `ChainableOperation<Encrypted>` client contract.
63+
createEncryptionOperatorsV3(erased)
64+
})
3565
})

packages/stack/__tests__/supabase-v3-builder.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,4 +1901,29 @@ describe('v3 in-list term encryption batches by column', () => {
19011901
expect(status).toBe(500)
19021902
expect(error?.message).toMatch(/1 term(s)? for 2 value(s)?/)
19031903
})
1904+
1905+
it('rejects a bulk response with a null envelope rather than sending "null"', async () => {
1906+
const supabase = createMockSupabase()
1907+
const encryption = createMockEncryptionClient()
1908+
// A length-matched response, but position 0 is a null envelope. Without the
1909+
// guard, `JSON.stringify(null)` would send the literal `"null"` as the `in`
1910+
// operand — matching whatever `"null"` encodes to instead of failing.
1911+
;(
1912+
encryption as unknown as { bulkEncrypt: (...a: unknown[]) => unknown }
1913+
).bulkEncrypt = () =>
1914+
operation([{ data: null }, { data: fakeEnvelope('grace', 'nickname') }])
1915+
1916+
const { error, status } = await new EncryptedQueryBuilderV3Impl(
1917+
'users',
1918+
users,
1919+
encryption,
1920+
supabase.client,
1921+
USERS_ALL_COLUMNS,
1922+
)
1923+
.select('id')
1924+
.in('nickname', ['ada', 'grace'])
1925+
1926+
expect(status).toBe(500)
1927+
expect(error?.message).toMatch(/null envelope at position 0/)
1928+
})
19041929
})

packages/stack/src/eql/v3/drizzle/operators.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Result } from '@byteslice/result'
12
import {
23
and,
34
asc,
@@ -17,9 +18,11 @@ import {
1718
import type { PgTable } from 'drizzle-orm/pg-core'
1819
import type { AuditConfig } from '@/encryption/operations/base-operation'
1920
import type { AnyEncryptedV3Column, AnyV3Table } from '@/eql/v3'
21+
import type { EncryptionError } from '@/errors'
2022
import type { LockContext } from '@/identity'
2123
import type { ColumnSchema } from '@/schema'
2224
import { matchNeedleError } from '@/schema/match-defaults'
25+
import type { BulkEncryptedData, Encrypted } from '@/types'
2326
import { getEqlV3Column } from './column.js'
2427
import {
2528
extractEncryptionSchemaV3,
@@ -48,11 +51,11 @@ type OperandEncryptionClient = {
4851
encrypt(
4952
plaintext: never,
5053
opts: { table: AnyV3Table; column: AnyEncryptedV3Column },
51-
): unknown
54+
): ChainableOperation<Encrypted>
5255
bulkEncrypt?(
5356
plaintexts: never,
5457
opts: { table: AnyV3Table; column: AnyEncryptedV3Column },
55-
): unknown
58+
): ChainableOperation<BulkEncryptedData>
5659
}
5760

5861
/**
@@ -91,13 +94,33 @@ export type EncryptionOperatorCallOpts = {
9194
audit?: AuditConfig
9295
}
9396

94-
type ChainableOperation = {
95-
withLockContext(lockContext: LockContext): ChainableOperation
96-
audit(config: AuditConfig): ChainableOperation
97-
then: PromiseLike<{
98-
data?: unknown
99-
failure?: { message: string }
100-
}>['then']
97+
/**
98+
* An SDK encryption operation after its lock context has been applied: still
99+
* auditable and awaitable, but not re-lockable. `withLockContext` returns this,
100+
* not the full {@link ChainableOperation}, mirroring the real
101+
* `EncryptOperationWithLockContext`, which drops `withLockContext` (you cannot
102+
* lock-context twice). Modelling that is what lets the real client type satisfy
103+
* the structural surface with no cast.
104+
*/
105+
type AuditableOperation<T> = {
106+
audit(config: AuditConfig): AuditableOperation<T>
107+
then: PromiseLike<Result<T, EncryptionError>>['then']
108+
}
109+
110+
/**
111+
* The subset of an SDK encryption operation this factory drives: the fluent
112+
* `withLockContext`/`audit` chain, and a `then` that resolves the operation's
113+
* `Result`. Generic over the resolved payload `T` so `encrypt` carries an
114+
* `Encrypted` envelope and `bulkEncrypt` a `BulkEncryptedData`, rather than the
115+
* `unknown` this erased to before.
116+
*
117+
* Structural, not the concrete `EncryptOperation` class, because the client is
118+
* passed in and the factory must accept any implementation with this surface.
119+
*/
120+
type ChainableOperation<T> = {
121+
withLockContext(lockContext: LockContext): AuditableOperation<T>
122+
audit(config: AuditConfig): AuditableOperation<T>
123+
then: PromiseLike<Result<T, EncryptionError>>['then']
101124
}
102125

103126
async function mapWithConcurrency<T, R>(
@@ -225,10 +248,10 @@ export function createEncryptionOperatorsV3(
225248
const ORDERING_INDEXES = ['ore', 'ope'] as const
226249
const MATCH_INDEXES = ['match'] as const
227250

228-
function applyOperationOptions(
229-
op: ChainableOperation,
251+
function applyOperationOptions<T>(
252+
op: ChainableOperation<T>,
230253
opts?: EncryptionOperatorCallOpts,
231-
): ChainableOperation {
254+
): AuditableOperation<T> {
232255
const lockContext = opts?.lockContext ?? defaults.lockContext
233256
const audit = opts?.audit ?? defaults.audit
234257
const withLock = lockContext ? op.withLockContext(lockContext) : op
@@ -298,12 +321,13 @@ export function createEncryptionOperatorsV3(
298321
client.encrypt(value as never, {
299322
table: ctx.table,
300323
column: ctx.builder,
301-
}) as unknown as ChainableOperation,
324+
}),
302325
opts,
303326
)
304327
if (result.failure) {
305328
throw operandFailure(ctx, operator, result.failure.message)
306329
}
330+
// `result.data` is now `Encrypted` — the storage envelope — not `unknown`.
307331
return sql`${JSON.stringify(result.data)}`
308332
}
309333

@@ -336,19 +360,22 @@ export function createEncryptionOperatorsV3(
336360
bulkEncrypt(values.map((plaintext) => ({ plaintext })) as never, {
337361
table: ctx.table,
338362
column: ctx.builder,
339-
}) as unknown as ChainableOperation,
363+
}),
340364
opts,
341365
)
342366
if (result.failure) {
343367
throw operandFailure(ctx, operator, result.failure.message)
344368
}
345369

346-
const encrypted = result.data as Array<{ data: unknown }> | undefined
347-
if (!Array.isArray(encrypted) || encrypted.length !== values.length) {
370+
// `result.data` is `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]`
371+
// — not `unknown`. The length check stays: a position-stable contract
372+
// violation must not silently truncate the predicate.
373+
const encrypted = result.data
374+
if (encrypted.length !== values.length) {
348375
throw operandFailure(
349376
ctx,
350377
operator,
351-
`bulk encryption returned ${Array.isArray(encrypted) ? encrypted.length : 0} terms for ${values.length} values.`,
378+
`bulk encryption returned ${encrypted.length} terms for ${values.length} values.`,
352379
)
353380
}
354381
return encrypted.map((term) => sql`${JSON.stringify(term.data)}`)

packages/stack/src/supabase/query-builder-v3.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
} from '@/schema'
99
import type {
1010
BuildableQueryColumn,
11+
Encrypted,
12+
EncryptedQueryResult,
1113
QueryTypeName,
1214
ScalarQueryTerm,
1315
} from '@/types'
@@ -443,7 +445,7 @@ export class EncryptedQueryBuilderV3Impl<
443445
*/
444446
protected override async encryptCollectedTerms(
445447
terms: ScalarQueryTerm[],
446-
): Promise<unknown[]> {
448+
): Promise<EncryptedQueryResult[]> {
447449
const groups = new Map<
448450
V3ColumnLike,
449451
{ indices: number[]; values: ScalarQueryTerm['value'][] }
@@ -459,7 +461,11 @@ export class EncryptedQueryBuilderV3Impl<
459461
const bulkEncrypt = this.encryptionClient.bulkEncrypt?.bind(
460462
this.encryptionClient,
461463
)
462-
const results = new Array<unknown>(terms.length)
464+
// Each term becomes the `JSON.stringify`'d storage envelope — a `string`,
465+
// which is one arm of `EncryptedQueryResult`. PostgREST cannot cast a filter
466+
// value to the `eql_v3.query_<name>` twins, so v3 sends full envelopes where
467+
// v2 sends `encryptQuery` composite literals; both are `EncryptedQueryResult`.
468+
const results = new Array<EncryptedQueryResult>(terms.length)
463469

464470
await Promise.all(
465471
Array.from(groups, async ([column, { indices, values }]) => {
@@ -481,7 +487,7 @@ export class EncryptedQueryBuilderV3Impl<
481487
bulkEncrypt: NonNullable<EncryptionClient['bulkEncrypt']>,
482488
column: V3ColumnLike,
483489
values: ScalarQueryTerm['value'][],
484-
): Promise<unknown[]> {
490+
): Promise<Array<Encrypted | null>> {
485491
const baseOp = bulkEncrypt(
486492
values.map((plaintext) => ({ plaintext })) as never,
487493
{ column, table: this.v3Table } as never,
@@ -497,21 +503,34 @@ export class EncryptedQueryBuilderV3Impl<
497503

498504
// `bulkEncrypt` is position-stable, so a length mismatch means the contract
499505
// was violated. Truncating instead would silently widen an `in` predicate
500-
// (or narrow a `not.in`) to whatever came back.
501-
const encrypted = result.data as Array<{ data: unknown }> | undefined
502-
if (!Array.isArray(encrypted) || encrypted.length !== values.length) {
506+
// (or narrow a `not.in`) to whatever came back. `result.data` is now
507+
// `BulkEncryptedData` — `{ id?, data: Encrypted | null }[]` — not `unknown`.
508+
const encrypted = result.data
509+
if (encrypted.length !== values.length) {
503510
this.encryptionFailure(
504-
`bulk encryption returned ${Array.isArray(encrypted) ? encrypted.length : 0} terms for ${values.length} values on column "${column.getName()}".`,
511+
`bulk encryption returned ${encrypted.length} terms for ${values.length} values on column "${column.getName()}".`,
505512
)
506513
}
507-
return encrypted.map((term) => term.data)
514+
return encrypted.map((term, i) => {
515+
// `BulkEncryptedData` types the element as `Encrypted | null`. A `null`
516+
// envelope here would be `JSON.stringify`'d to the literal string `"null"`
517+
// and sent as the filter operand — silently matching whatever `"null"`
518+
// encodes to rather than failing. A query term should never encrypt to a
519+
// null envelope, so treat it as a contract violation, not a value.
520+
if (term.data === null) {
521+
this.encryptionFailure(
522+
`bulk encryption returned a null envelope at position ${i} for column "${column.getName()}".`,
523+
)
524+
}
525+
return term.data
526+
})
508527
}
509528

510529
/** Fallback for a client that predates `bulkEncrypt`. */
511530
private async encryptGroupPerTerm(
512531
column: V3ColumnLike,
513532
values: ScalarQueryTerm['value'][],
514-
): Promise<unknown[]> {
533+
): Promise<Encrypted[]> {
515534
return Promise.all(
516535
values.map(async (value) => {
517536
const baseOp = this.encryptionClient.encrypt(value, {

packages/stack/src/supabase/query-builder.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { EncryptedTable, EncryptedTableColumn } from '@/schema'
1010
import { EncryptedColumn } from '@/schema'
1111
import type {
1212
BuildableQueryColumn,
13+
EncryptedQueryResult,
1314
QueryTypeName,
1415
ScalarQueryTerm,
1516
} from '@/types'
@@ -741,7 +742,7 @@ export class EncryptedQueryBuilderImpl<
741742
*/
742743
protected async encryptCollectedTerms(
743744
terms: ScalarQueryTerm[],
744-
): Promise<unknown[]> {
745+
): Promise<EncryptedQueryResult[]> {
745746
// Batch encrypt all terms in one call
746747
const baseOp = this.encryptionClient.encryptQuery(terms)
747748
const op = this.lockContext
@@ -1532,7 +1533,11 @@ type TermMapping =
15321533
}
15331534

15341535
type EncryptedFilterState = {
1535-
encryptedValues: unknown[]
1536+
// `EncryptedQueryResult[]`, not `unknown[]` — `encryptCollectedTerms` returns
1537+
// that type, and typing the field to match is what lets the restored envelope
1538+
// type reach the use site (`encryptedValues[i]`) instead of widening back to
1539+
// `unknown` at this boundary.
1540+
encryptedValues: EncryptedQueryResult[]
15361541
termMap: TermMapping[]
15371542
}
15381543

0 commit comments

Comments
 (0)