Skip to content

Commit f743fcc

Browse files
calvinbrewerclaude
authored andcommitted
feat: upgrade protect-ffi to 0.23.0 (split storage/query types)
protect-ffi 0.23.0 is a types-only release that splits the previously- conflated `Encrypted` type into: - `Encrypted` — storage-only payload returned by encrypt/encryptBulk, the only shape decrypt accepts. `c` is now required (was `c?`). - `EncryptedQuery` (new) — query payload returned by encryptQuery / encryptQueryBulk for scalar unique/match/ore lookups and ste_vec selector path queries. Carries no ciphertext (`c?: never`) so the union discriminates cleanly via `'c' in payload`. JSON containment queries (ste_vec_term) still come back as a storage-shaped `Encrypted`, hence the union return. - `isEncrypted` arg widened to `unknown`. - `EncryptedSteVecStorage` collapsed into `EncryptedSteVec`. No runtime change; consumers that lied about the loose union now have to narrow properly. Stack-side updates: - `EncryptedSearchTerm` and `EncryptedQueryResult` (in protect and stack) widen to `Encrypted | EncryptedQuery | string [| null]`. - `formatEncryptedResult`, `encryptedToCompositeLiteral`, and `encryptedToEscapedCompositeLiteral` accept the union via a new internal `EncryptedQueryTerm = CipherStashEncrypted | CipherStashEncryptedQuery` alias. - `BatchEncryptQueryOperation.assembleResults` takes the union for the FFI's bulk return shape. - `protect-dynamodb`'s `SearchTermsOperation` narrows via `'hm' in term && typeof term.hm === 'string'` so the new `EncryptedScalarQuery & { hm }` / `& { bf }` / `& { ob }` discriminator unions narrow cleanly without reading off the loose union. Verified: pnpm test (1876 passed | 20 skipped, 14/14 packages) and prisma-next e2e (36/36 across the seven shape suites). No DB-side changes needed since the runtime is unchanged from 0.22.0 → 0.23.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b0a1ac commit f743fcc

11 files changed

Lines changed: 124 additions & 58 deletions

File tree

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
"stash": minor
88
---
99

10-
Upgrade `@cipherstash/protect-ffi` to `0.22.0` and the bundled CipherStash EQL extension to `eql-2.3.1`.
10+
Upgrade `@cipherstash/protect-ffi` to `0.23.0` and the bundled CipherStash EQL extension to `eql-2.3.1`.
1111

1212
Breaking upstream changes adopted in this release:
1313

14-
- **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` validates this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code.
14+
- **Encrypt-config schema version**: `buildEncryptConfig` now emits `{ v: 1, ... }` (was `{ v: 2, ... }`). protect-ffi `0.22.0` started validating this field and rejects any value other than `1` with the new `UNSUPPORTED_CONFIG_VERSION` error code.
15+
- **Storage and query payloads are now distinct types** (protect-ffi `0.23.0`): the previously-conflated `Encrypted` type splits into `Encrypted` (storage-only, `c` required) and a new `EncryptedQuery` (search terms — scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` JSON path queries; no `c`). JSON containment queries (`ste_vec_term`) still return a storage-shaped `Encrypted` payload. `encryptQuery` / `encryptQueryBulk` now return `Encrypted | EncryptedQuery`, and the stack's `EncryptedSearchTerm` / `EncryptedQueryResult` unions widen to match. `decrypt` rejects query payloads at the type level. The DynamoDB `SearchTermsOperation` narrows via `'hm' in term` rather than `term.hm`.
1516
- **SteVec encoding default flipped**: protect-ffi's default `mode` for `ste_vec` indexes changed from `compat` to `standard`. The two encodings are not cross-compatible. Existing JSON-searchable data that was indexed under `compat` will need to be re-encrypted to be queryable. The stack adopts the new `standard` default — there is no longer a way to pin `compat` from the SDK.
1617
- **EQL extension bumped to `eql-2.3.1`**: the new SteVec `standard` encoding requires matching support in the database EQL extension. The CLI's bundled SQL (`packages/cli/src/sql/*.sql`) and the `@cipherstash/prisma-next` install bundle (`migrations/20260601T0000_install_eql_bundle/ops.json` + `eql-install.generated.ts`) are updated to `eql-2.3.1`. Databases installed with an older EQL extension must be reinstalled (`stash db install`) before containment / contained-by queries against SteVec columns will work. `eql-2.3.1` ships the `_encrypted_check_c` fix for SteVec storage payloads ([cipherstash/encrypt-query-language#232](https://github.com/cipherstash/encrypt-query-language/issues/232)).
1718
- **New error codes**: `ProtectErrorCode` (re-exported from `@cipherstash/protect-ffi`) gains `MATCH_REQUIRES_TEXT` and `UNSUPPORTED_CONFIG_VERSION`. Exhaustive switches over `ProtectErrorCode` will need additional cases.

packages/protect-dynamodb/src/operations/search-terms.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ export class SearchTermsOperation extends DynamoDBOperation<string[]> {
5353
)
5454
}
5555

56-
if (term?.k !== 'ct' || !term.hm) {
56+
// DynamoDB lookups go through equality queries → the FFI returns an
57+
// EncryptedScalarQuery carrying `hm`. Anything else (scalar storage,
58+
// a `bf`/`ob` query, or a SteVec payload) is a misconfiguration.
59+
if (term?.k !== 'ct' || !('hm' in term) || typeof term.hm !== 'string') {
5760
throw new Error('expected encrypted search term to have an HMAC')
5861
}
5962

packages/protect/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
},
7070
"dependencies": {
7171
"@byteslice/result": "^0.2.0",
72-
"@cipherstash/protect-ffi": "0.22.0",
72+
"@cipherstash/protect-ffi": "0.23.0",
7373
"@cipherstash/schema": "workspace:*",
7474
"@stricli/core": "^1.2.7",
7575
"dotenv": "17.4.2",

packages/protect/src/ffi/operations/batch-encrypt-query.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
type QueryPayload,
55
encryptQueryBulk as ffiEncryptQueryBulk,
66
} from '@cipherstash/protect-ffi'
7-
import type { Encrypted as CipherStashEncrypted } from '@cipherstash/protect-ffi'
7+
import type {
8+
Encrypted as CipherStashEncrypted,
9+
EncryptedQuery as CipherStashEncryptedQuery,
10+
} from '@cipherstash/protect-ffi'
811
import { type ProtectError, ProtectErrorTypes } from '../..'
912
import { logger } from '../../../../utils/logger'
1013
import { formatEncryptedResult } from '../../helpers'
@@ -83,7 +86,7 @@ function buildQueryPayload(
8386
*/
8487
function assembleResults(
8588
totalLength: number,
86-
encryptedValues: CipherStashEncrypted[],
89+
encryptedValues: (CipherStashEncrypted | CipherStashEncryptedQuery)[],
8790
nonNullTerms: { term: ScalarQueryTerm; originalIndex: number }[],
8891
): EncryptedQueryResult[] {
8992
const results: EncryptedQueryResult[] = new Array(totalLength).fill(null)

packages/protect/src/helpers/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
Encrypted as CipherStashEncrypted,
3+
EncryptedQuery as CipherStashEncryptedQuery,
34
KeysetIdentifier as KeysetIdentifierFfi,
45
} from '@cipherstash/protect-ffi'
56
import type {
@@ -8,6 +9,14 @@ import type {
89
KeysetIdentifier,
910
} from '../types'
1011

12+
/**
13+
* The shape `encryptQuery` / `encryptQueryBulk` can return: a full storage
14+
* payload (`Encrypted`, returned for `ste_vec_term` containment queries) or a
15+
* query-only payload with no ciphertext (`EncryptedQuery`, returned for
16+
* scalar `unique`/`match`/`ore` lookups and `ste_vec_selector` path queries).
17+
*/
18+
type EncryptedQueryTerm = CipherStashEncrypted | CipherStashEncryptedQuery
19+
1120
export type EncryptedPgComposite = {
1221
data: Encrypted
1322
}
@@ -43,7 +52,7 @@ export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite {
4352
* await supabase.from('table').select().eq('column', searchTerm)
4453
* ```
4554
*/
46-
export function encryptedToCompositeLiteral(obj: CipherStashEncrypted): string {
55+
export function encryptedToCompositeLiteral(obj: EncryptedQueryTerm): string {
4756
if (obj === null) {
4857
throw new Error('encryptedToCompositeLiteral: obj cannot be null')
4958
}
@@ -71,7 +80,7 @@ export function encryptedToCompositeLiteral(obj: CipherStashEncrypted): string {
7180
* ```
7281
*/
7382
export function encryptedToEscapedCompositeLiteral(
74-
obj: CipherStashEncrypted,
83+
obj: EncryptedQueryTerm,
7584
): string {
7685
if (obj === null) {
7786
throw new Error('encryptedToEscapedCompositeLiteral: obj cannot be null')
@@ -80,7 +89,7 @@ export function encryptedToEscapedCompositeLiteral(
8089
}
8190

8291
export function formatEncryptedResult(
83-
encrypted: CipherStashEncrypted,
92+
encrypted: EncryptedQueryTerm,
8493
returnType?: string,
8594
): EncryptedQueryResult {
8695
if (returnType === 'composite-literal') {

packages/protect/src/types.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
Encrypted as CipherStashEncrypted,
3+
EncryptedQuery as CipherStashEncryptedQuery,
34
JsPlaintext,
45
QueryOpName,
56
newClient,
@@ -17,10 +18,22 @@ import type {
1718
export type Client = Awaited<ReturnType<typeof newClient>> | undefined
1819

1920
/**
20-
* Type to represent an encrypted payload
21+
* Type to represent an encrypted payload stored in the database. Always carries
22+
* a ciphertext — scalar payloads at the root (`c`), SteVec payloads at
23+
* `sv[0].c`. For search-term payloads returned by `encryptQuery`, see
24+
* {@link EncryptedQuery}.
2125
*/
2226
export type Encrypted = CipherStashEncrypted | null
2327

28+
/**
29+
* Type to represent an encrypted query term (search needle) returned by
30+
* `encryptQuery` / `encryptQueryBulk` for scalar (`unique` / `match` / `ore`)
31+
* lookups and `ste_vec_selector` JSON path queries. Carries no ciphertext — it
32+
* is matched against stored values, never decrypted. JSON containment queries
33+
* (`ste_vec_term`) return a storage-shaped {@link Encrypted} payload instead.
34+
*/
35+
export type EncryptedQuery = CipherStashEncryptedQuery | null
36+
2437
/**
2538
* Represents an encrypted payload in the database
2639
* @deprecated Use `Encrypted` instead
@@ -52,18 +65,21 @@ export type KeysetIdentifier =
5265
}
5366

5467
/**
55-
* The return type of the search term based on the return type specified in the `SearchTerm` type
56-
* If the return type is `eql`, the return type is `Encrypted`
57-
* If the return type is `composite-literal`, the return type is `string` where the value is a composite literal
58-
* If the return type is `escaped-composite-literal`, the return type is `string` where the value is an escaped composite literal
68+
* The return type of the search term based on the return type specified in the `SearchTerm` type.
69+
* - `eql` → an `Encrypted` storage payload (for `ste_vec_term` containment) or an
70+
* {@link EncryptedQuery} term (for scalar lookups and `ste_vec_selector` queries).
71+
* - `composite-literal` → `string` where the value is a composite literal.
72+
* - `escaped-composite-literal` → `string` where the value is an escaped composite literal.
5973
*/
60-
export type EncryptedSearchTerm = Encrypted | string
74+
export type EncryptedSearchTerm = Encrypted | EncryptedQuery | string
6175

6276
/**
6377
* Result type for encryptQuery batch operations.
64-
* Can be Encrypted (default), string (for composite-literal formats), or null.
78+
* Can be an `Encrypted` storage payload (e.g. for `ste_vec_term`), an
79+
* {@link EncryptedQuery} term (for scalar lookups and `ste_vec_selector`),
80+
* a `string` (for composite-literal formats), or `null` (for null inputs).
6581
*/
66-
export type EncryptedQueryResult = Encrypted | string | null
82+
export type EncryptedQueryResult = Encrypted | EncryptedQuery | string | null
6783

6884
/**
6985
* Represents a payload to be encrypted using the `encrypt` function

packages/stack/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,13 @@
203203
},
204204
"dependencies": {
205205
"@byteslice/result": "0.2.0",
206+
<<<<<<< HEAD
206207
"@cipherstash/protect-ffi": "0.21.4",
207208
"evlog": "1.11.0",
209+
=======
210+
"@cipherstash/protect-ffi": "0.23.0",
211+
"evlog": "1.9.0",
212+
>>>>>>> 4c2af98 (feat: upgrade protect-ffi to 0.23.0 (split storage/query types))
208213
"uuid": "14.0.0",
209214
"zod": "3.25.76"
210215
},

packages/stack/src/encryption/helpers/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import type { Encrypted, EncryptedQueryResult, KeysetIdentifier } from '@/types'
22
import type {
33
Encrypted as CipherStashEncrypted,
4+
EncryptedQuery as CipherStashEncryptedQuery,
45
KeysetIdentifier as KeysetIdentifierFfi,
56
} from '@cipherstash/protect-ffi'
67

8+
/**
9+
* The shape `encryptQuery` / `encryptQueryBulk` can return: a full storage
10+
* payload (returned for `ste_vec_term` containment queries) or a query-only
11+
* payload with no ciphertext (scalar `unique`/`match`/`ore` lookups and
12+
* `ste_vec_selector` path queries).
13+
*/
14+
type EncryptedQueryTerm = CipherStashEncrypted | CipherStashEncryptedQuery
15+
716
export type EncryptedPgComposite = {
817
data: Encrypted
918
}
@@ -28,7 +37,7 @@ export function encryptedToPgComposite(obj: Encrypted): EncryptedPgComposite {
2837
* await supabase.from('table').select().eq('column', literal)
2938
* ```
3039
*/
31-
export function encryptedToCompositeLiteral(obj: CipherStashEncrypted): string {
40+
export function encryptedToCompositeLiteral(obj: EncryptedQueryTerm): string {
3241
return `(${JSON.stringify(JSON.stringify(obj))})`
3342
}
3443

@@ -42,7 +51,7 @@ export function encryptedToCompositeLiteral(obj: CipherStashEncrypted): string {
4251
* ```
4352
*/
4453
export function encryptedToEscapedCompositeLiteral(
45-
obj: CipherStashEncrypted,
54+
obj: EncryptedQueryTerm,
4655
): string {
4756
return JSON.stringify(encryptedToCompositeLiteral(obj))
4857
}
@@ -55,7 +64,7 @@ export function encryptedToEscapedCompositeLiteral(
5564
* - default (`'eql'` or omitted) → raw encrypted object
5665
*/
5766
export function formatEncryptedResult(
58-
encrypted: CipherStashEncrypted,
67+
encrypted: EncryptedQueryTerm,
5968
returnType?: string,
6069
): EncryptedQueryResult {
6170
if (returnType === 'composite-literal') {

packages/stack/src/encryption/operations/batch-encrypt-query.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
type QueryPayload,
1010
encryptQueryBulk as ffiEncryptQueryBulk,
1111
} from '@cipherstash/protect-ffi'
12-
import type { Encrypted as CipherStashEncrypted } from '@cipherstash/protect-ffi'
12+
import type {
13+
Encrypted as CipherStashEncrypted,
14+
EncryptedQuery as CipherStashEncryptedQuery,
15+
} from '@cipherstash/protect-ffi'
1316
import { resolveIndexType } from '../helpers/infer-index-type'
1417
import {
1518
assertValidNumericValue,
@@ -58,7 +61,7 @@ function buildQueryPayload(
5861
*/
5962
function assembleResults(
6063
terms: readonly ScalarQueryTerm[],
61-
encryptedValues: CipherStashEncrypted[],
64+
encryptedValues: (CipherStashEncrypted | CipherStashEncryptedQuery)[],
6265
): EncryptedQueryResult[] {
6366
return terms.map((term, i) =>
6467
formatEncryptedResult(encryptedValues[i], term.returnType),

packages/stack/src/types.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from '@/schema'
77
import type {
88
Encrypted as CipherStashEncrypted,
9+
EncryptedQuery as CipherStashEncryptedQuery,
910
JsPlaintext,
1011
QueryOpName,
1112
newClient,
@@ -30,9 +31,20 @@ export type Client = Awaited<ReturnType<typeof newClient>> | undefined
3031
/** A branded type representing encrypted data. Cannot be accidentally used as plaintext. */
3132
export type EncryptedValue = Brand<CipherStashEncrypted, 'encrypted'>
3233

33-
/** Structural type representing encrypted data. See also `EncryptedValue` for branded nominal typing. */
34+
/** Structural type representing encrypted data stored in the database. Always
35+
* carries a ciphertext. See also `EncryptedValue` for branded nominal typing,
36+
* and {@link EncryptedQuery} for the search-term shape returned by
37+
* `encryptQuery`. */
3438
export type Encrypted = CipherStashEncrypted
3539

40+
/** Structural type representing an encrypted query term (search needle)
41+
* returned by `encryptQuery` / `encryptQueryBulk` for scalar
42+
* (`unique` / `match` / `ore`) lookups and `ste_vec_selector` JSON path
43+
* queries. Carries no ciphertext — matched against stored values, never
44+
* decrypted. JSON containment queries (`ste_vec_term`) return a
45+
* storage-shaped {@link Encrypted} payload instead. */
46+
export type EncryptedQuery = CipherStashEncryptedQuery
47+
3648
// ---------------------------------------------------------------------------
3749
// Client configuration
3850
// ---------------------------------------------------------------------------
@@ -112,11 +124,16 @@ export type SearchTerm = {
112124
returnType?: EncryptedReturnType
113125
}
114126

115-
/** Encrypted search term result: EQL object or composite literal string */
116-
export type EncryptedSearchTerm = Encrypted | string
127+
/** Encrypted search term result. `eql` return type yields either a storage
128+
* payload (`Encrypted`, for `ste_vec_term`) or a query-only term
129+
* (`EncryptedQuery`, for scalar lookups and `ste_vec_selector`); the
130+
* `composite-literal` return types yield a string. */
131+
export type EncryptedSearchTerm = Encrypted | EncryptedQuery | string
117132

118-
/** Result of encryptQuery (single or batch): EQL or composite literal string */
119-
export type EncryptedQueryResult = Encrypted | string
133+
/** Result of encryptQuery (single or batch). `eql` return type yields either a
134+
* storage payload (`Encrypted`) or a query-only term (`EncryptedQuery`); the
135+
* `composite-literal` return types yield a string. */
136+
export type EncryptedQueryResult = Encrypted | EncryptedQuery | string
120137

121138
// ---------------------------------------------------------------------------
122139
// Model field types (encrypted vs decrypted views)

0 commit comments

Comments
 (0)