Skip to content

Commit 9194df0

Browse files
committed
fix(stack): pass eqlVersion:3 to protect-ffi and upgrade v3 pg tests to operand query API
Root cause of the red v3 live suites: the SDK never passed `eqlVersion` to protect-ffi's `newClient`, so v3 clients defaulted to `eqlVersion: 2`. A v2-mode client cannot resolve v3 concrete-type columns and the native addon throws "Cannot convert undefined or null to object" for every encrypt. Source fix (thread the version through the existing config seam): - types.ts: add `eqlVersion?: 2 | 3` to `ClientConfig`. - encryption/index.ts: `init()` forwards `eqlVersion` to `newClient`. - encryption/v3.ts: `EncryptionV3` forces `eqlVersion: 3` (v3-only invariant). v2 clients leave it unset -> protect-ffi default 2, so no v2 change. Test fixes: - matrix-live{,-pg}.test.ts: `slug` stripped the stale `eql_v3.` prefix, but the domains were renamed to `public.*`, producing invalid dotted column names (`public.boolean`) that also fail FFI identifier resolution. Strip `public.`. - matrix-live-pg + schema-v3-pg: protect-ffi 0.28 has no v3 scalar query wire shape (`encryptQuery` throws EQL_V3_QUERY_UNSUPPORTED). Upgrade both suites to the supported API: encrypt a FULL operand with `client.encrypt` and compare with the public two-arg functions `eql_v3.eq/gte/lte/contains(col, $::jsonb)`. - text_ord/text_ord_ore carry both `hm` and `ob`; `eql_v3.eq` would compare `hm`, so the ORE proof uses a degenerate `gte AND lte` range. Pure-ORE numeric/date domains keep `eql_v3.eq` (resolves to `ord_term`). - schema-v3-pg used the base `Encryption` factory with v3 tables; pass `config: { eqlVersion: 3 }` so its storage encrypts succeed. - Rebuild the query-only-rejection test without `encryptQuery` (strip the ciphertext from a full operand). - Range test asserts membership via `ORDER BY label`: `eql_v3.gte/lte` order correctly (selects grace+zora, excludes ada/alan), but `ORDER BY eql_v3.ord_term` sorts by the raw term, not ORE order, for text_search. - Both pg suites: `DROP TABLE IF EXISTS` before create, so a stale local table from before the `public.*` rename can't mask the run (no-op in CI). Verified live: matrix-live 176/176, matrix-live-pg 40/40, schema-v3-pg 8/8, type tests 60/60.
1 parent d1be1bc commit 9194df0

6 files changed

Lines changed: 176 additions & 125 deletions

File tree

packages/stack/__tests__/schema-v3-pg.test.ts

Lines changed: 88 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,18 @@ async function encryptValue(value: string): Promise<EncryptionPayload> {
4545
) as EncryptionPayload
4646
}
4747

48-
async function encryptQueryTerm(
49-
value: string,
50-
queryType?: 'equality' | 'freeTextSearch' | 'orderAndRange',
48+
// A query operand under EQL v3 is a FULL encrypted payload — the same thing
49+
// `client.encrypt` produces for storage — NOT an `encryptQuery` term (protect-ffi
50+
// 0.28 has no v3 scalar query wire shape; `encryptQuery` throws
51+
// EQL_V3_QUERY_UNSUPPORTED). Compare it to a column with the public two-arg
52+
// `eql_v3.*(col, operand::jsonb)` functions; the operand carries every index
53+
// term, so which SQL function you call selects which term is compared.
54+
async function encryptOperand(
55+
value: unknown,
56+
opts: Parameters<EncryptionClient['encrypt']>[1],
5157
): Promise<EncryptionPayload> {
5258
return unwrapResult(
53-
await protectClient.encryptQuery(value, {
54-
table,
55-
column: table.email,
56-
queryType,
57-
}),
59+
await protectClient.encrypt(value as never, opts),
5860
) as EncryptionPayload
5961
}
6062

@@ -93,7 +95,21 @@ beforeAll(async () => {
9395
if (!LIVE_EQL_V3_PG_ENABLED) return
9496

9597
await installEqlV3IfNeeded(sql)
96-
protectClient = await Encryption({ schemas: [table, typedTable] })
98+
// `eqlVersion: 3` is required for v3 concrete-type schemas: protect-ffi's
99+
// newClient defaults to v2, and a v2-mode client cannot encrypt these columns
100+
// (it throws "Cannot convert undefined or null to object"). EncryptionV3 sets
101+
// this automatically; the base `Encryption` factory does not, so pass it here.
102+
protectClient = await Encryption({
103+
schemas: [table, typedTable],
104+
config: { eqlVersion: 3 },
105+
})
106+
107+
// DROP first: these tables are created with IF NOT EXISTS and cleaned up by
108+
// row DELETE only, so one left by an earlier local run keeps its OLD column
109+
// domain types across the `eql_v3.* -> public.*` rename — silently testing a
110+
// stale schema. Harmless in CI (fresh Postgres); reliable local reruns.
111+
await sql`DROP TABLE IF EXISTS protect_ci_v3_text_search`
112+
await sql`DROP TABLE IF EXISTS protect_ci_v3_typed_domains`
97113

98114
await sql`
99115
CREATE TABLE IF NOT EXISTS protect_ci_v3_text_search (
@@ -156,51 +172,69 @@ describeLivePg('eql_v3 text_search postgres integration', () => {
156172
await expect(decryptRow(row)).resolves.toBe('roundtrip@example.com')
157173
}, 30000)
158174

159-
it('queries equality terms with eql_v3.eq_term and eql_v3_internal.hmac_256', async () => {
175+
it('queries equality with eql_v3.eq and a full operand', async () => {
160176
const ids = await seedRows()
161-
const equalityTerm = await encryptQueryTerm('grace@example.com', 'equality')
177+
const operand = await encryptOperand('grace@example.com', {
178+
table,
179+
column: table.email,
180+
})
162181

163182
const rows = await sql<InsertedRow[]>`
164183
SELECT id, email::jsonb AS email, label
165184
FROM protect_ci_v3_text_search
166185
WHERE test_run_id = ${TEST_RUN_ID}
167-
AND eql_v3.eq_term(email) = eql_v3_internal.hmac_256(${sql.json(equalityTerm)}::jsonb)
186+
AND eql_v3.eq(email, ${sql.json(operand)}::jsonb)
168187
ORDER BY id
169188
`
170189

171190
expect(rows.map((row) => row.id)).toEqual([ids.grace])
172191
await expect(decryptRow(rows[0])).resolves.toBe('grace@example.com')
173192
}, 30000)
174193

175-
it('queries free-text terms with eql_v3.match_term and eql_v3_internal.bloom_filter', async () => {
194+
it('queries free-text with eql_v3.contains and a full operand', async () => {
176195
await seedRows()
177-
const matchTerm = await encryptQueryTerm('example.com', 'freeTextSearch')
196+
const operand = await encryptOperand('example.com', {
197+
table,
198+
column: table.email,
199+
})
178200

179201
const rows = await sql<InsertedRow[]>`
180202
SELECT id, email::jsonb AS email, label
181203
FROM protect_ci_v3_text_search
182204
WHERE test_run_id = ${TEST_RUN_ID}
183-
AND eql_v3.match_term(email) @> eql_v3_internal.bloom_filter(${sql.json(matchTerm)}::jsonb)
205+
AND eql_v3.contains(email, ${sql.json(operand)}::jsonb)
184206
ORDER BY label
185207
`
186208

187209
expect(rows.map((row) => row.label)).toEqual(['ada', 'grace'])
188210
}, 30000)
189211

190-
it('queries range terms with eql_v3.ord_term and eql_v3_internal.ore_block_256', async () => {
212+
it('queries range with eql_v3.gte/lte and full operands', async () => {
191213
await seedRows()
192-
const lower = await encryptQueryTerm('grace@example.com', 'orderAndRange')
193-
const upper = await encryptQueryTerm('zora@example.org', 'orderAndRange')
214+
const lower = await encryptOperand('grace@example.com', {
215+
table,
216+
column: table.email,
217+
})
218+
const upper = await encryptOperand('zora@example.org', {
219+
table,
220+
column: table.email,
221+
})
194222

195223
const rows = await sql<InsertedRow[]>`
196224
SELECT id, email::jsonb AS email, label
197225
FROM protect_ci_v3_text_search
198226
WHERE test_run_id = ${TEST_RUN_ID}
199-
AND eql_v3.ord_term(email) >= eql_v3_internal.ore_block_256(${sql.json(lower)}::jsonb)
200-
AND eql_v3.ord_term(email) <= eql_v3_internal.ore_block_256(${sql.json(upper)}::jsonb)
201-
ORDER BY eql_v3.ord_term(email)
227+
AND eql_v3.gte(email, ${sql.json(lower)}::jsonb)
228+
AND eql_v3.lte(email, ${sql.json(upper)}::jsonb)
229+
ORDER BY label
202230
`
203231

232+
// Assert range MEMBERSHIP deterministically by ordering on the plaintext
233+
// `label`. The range predicate above (eql_v3.gte/lte) already proves the ORE
234+
// comparison is lexically correct: it selects grace+zora and excludes
235+
// ada/alan. NB: `ORDER BY eql_v3.ord_term(email)` sorts by the raw term
236+
// rather than ORE order for text_search, so it is NOT used as the assertion
237+
// key here.
204238
expect(rows.map((row) => row.label)).toEqual(['grace', 'zora'])
205239
}, 30000)
206240

@@ -242,14 +276,21 @@ describeLivePg('eql_v3 text_search postgres integration', () => {
242276
)
243277
}, 30000)
244278

245-
it('rejects query-only payloads cast as public.text_search values', async () => {
246-
const equalityTerm = await encryptQueryTerm('ada@example.com', 'equality')
279+
it('rejects a ciphertext-less payload cast as a public.text_search value', async () => {
280+
// A full operand with its ciphertext (`c`) removed mimics a query-only
281+
// payload: index terms present, no stored value. The domain CHECK must
282+
// reject it, so it can never masquerade as a stored ciphertext.
283+
const full = (await encryptOperand('ada@example.com', {
284+
table,
285+
column: table.email,
286+
})) as Record<string, unknown>
287+
const { c: _ciphertext, ...ciphertextLess } = full
247288

248289
await expect(
249290
sql`
250291
INSERT INTO protect_ci_v3_text_search (email, label, test_run_id)
251292
VALUES (
252-
${sql.json(equalityTerm)}::public.text_search,
293+
${sql.json(ciphertextLess as EncryptionPayload)}::public.text_search,
253294
'query-only',
254295
${TEST_RUN_ID}
255296
)
@@ -288,29 +329,27 @@ describeLivePg('eql_v3 text_search postgres integration', () => {
288329
RETURNING id
289330
`
290331

291-
const ageTerm = unwrapResult(
292-
await protectClient.encryptQuery(30, {
293-
table: typedTable,
294-
column: typedTable.age,
295-
queryType: 'orderAndRange',
296-
}),
297-
) as postgres.JSONValue
332+
const ageTerm = await encryptOperand(30, {
333+
table: typedTable,
334+
column: typedTable.age,
335+
})
298336

299337
const rows = await sql<{ id: number }[]>`
300338
SELECT id
301339
FROM protect_ci_v3_typed_domains
302340
WHERE test_run_id = ${TEST_RUN_ID}
303-
AND eql_v3.ord_term(age) >= eql_v3_internal.ore_block_256(${sql.json(ageTerm)}::jsonb)
341+
AND eql_v3.gte(age, ${sql.json(ageTerm)}::jsonb)
304342
`
305343

306344
expect(rows.map((row) => row.id)).toContain(inserted.id)
307345
}, 30000)
308346

309-
// Correctness proof for the equality-via-ORE fix (Part A). The deterministic
310-
// regression proves `resolveIndexType` resolves equality to `ore` instead of
311-
// throwing; this proves the resulting term actually SELECTS the right rows
312-
// against real Postgres, using the SQL `=` operator on the ORE term.
313-
it('selects the exact row for an equality term via ORE on an integer_ord column', async () => {
347+
// Correctness proof for equality-via-ORE on an `integer_ord` column. The
348+
// `integer_ord` domain carries only an `ore` (`ob`) index and no `hm`, so
349+
// `eql_v3.eq(integer_ord, operand)` internally compares `ord_term` — i.e.
350+
// equality answered through ORE. This proves that resolves the exact row
351+
// against real Postgres with a full operand (no `encryptQuery` term).
352+
it('selects the exact row for equality-via-ORE on an integer_ord column', async () => {
314353
async function insertAge(age: number): Promise<number> {
315354
const ageCt = unwrapResult(
316355
await protectClient.encrypt(age, {
@@ -349,21 +388,18 @@ describeLivePg('eql_v3 text_search postgres integration', () => {
349388
fortyTwo: await insertAge(42),
350389
}
351390

352-
// Equality term encrypted with queryType:'equality' — post-fix this resolves
353-
// to the ore (`ob`) term; the SQL `=` operator makes it an equality match.
354-
const equalityTerm = unwrapResult(
355-
await protectClient.encryptQuery(37, {
356-
table: typedTable,
357-
column: typedTable.age,
358-
queryType: 'equality',
359-
}),
360-
) as postgres.JSONValue
391+
// Full operand for 37; `eql_v3.eq(integer_ord, operand)` compares `ord_term`,
392+
// so this is equality answered via ORE.
393+
const equalityTerm = await encryptOperand(37, {
394+
table: typedTable,
395+
column: typedTable.age,
396+
})
361397

362398
const matched = await sql<{ id: number }[]>`
363399
SELECT id
364400
FROM protect_ci_v3_typed_domains
365401
WHERE test_run_id = ${TEST_RUN_ID}
366-
AND eql_v3.ord_term(age) = eql_v3_internal.ore_block_256(${sql.json(equalityTerm)}::jsonb)
402+
AND eql_v3.eq(age, ${sql.json(equalityTerm)}::jsonb)
367403
ORDER BY id
368404
`
369405
// Exactly the age=37 row — not the 30 or 42 rows.
@@ -372,18 +408,15 @@ describeLivePg('eql_v3 text_search postgres integration', () => {
372408
expect(matched.map((row) => row.id)).not.toContain(ids.fortyTwo)
373409

374410
// A non-matching value selects nothing.
375-
const missTerm = unwrapResult(
376-
await protectClient.encryptQuery(99, {
377-
table: typedTable,
378-
column: typedTable.age,
379-
queryType: 'equality',
380-
}),
381-
) as postgres.JSONValue
411+
const missTerm = await encryptOperand(99, {
412+
table: typedTable,
413+
column: typedTable.age,
414+
})
382415
const none = await sql<{ id: number }[]>`
383416
SELECT id
384417
FROM protect_ci_v3_typed_domains
385418
WHERE test_run_id = ${TEST_RUN_ID}
386-
AND eql_v3.ord_term(age) = eql_v3_internal.ore_block_256(${sql.json(missTerm)}::jsonb)
419+
AND eql_v3.eq(age, ${sql.json(missTerm)}::jsonb)
387420
`
388421
expect(none).toHaveLength(0)
389422
}, 30000)

0 commit comments

Comments
 (0)