@@ -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