Skip to content

Commit 8fac8aa

Browse files
committed
refactor: narrow DynamoDB search terms via isEncryptedScalarQuery guard
Add an `isEncryptedScalarQuery` type guard to `@cipherstash/protect` that narrows a value to protect-ffi's `EncryptedScalarQuery` — a scalar query term carrying no ciphertext and exactly one of `hm`/`bf`/`ob`. Use it in protect-dynamodb's `SearchTermsOperation` in place of the hand-rolled `k`/`c`/`hm` property checks, so the narrowing is centralised, self-documenting, and tied to protect-ffi's exact query type. No behaviour change.
1 parent 1b2bb66 commit 8fac8aa

2 files changed

Lines changed: 43 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { type Result, withResult } from '@byteslice/result'
2-
import type { ProtectClient, SearchTerm } from '@cipherstash/protect'
2+
import {
3+
type ProtectClient,
4+
type SearchTerm,
5+
isEncryptedScalarQuery,
6+
} from '@cipherstash/protect'
37
import { handleError } from '../helpers'
48
import type { ProtectDynamoDBError } from '../types'
59
import {
@@ -56,7 +60,7 @@ export class SearchTermsOperation extends DynamoDBOperation<string[]> {
5660
// DynamoDB lookups go through equality queries → the FFI returns an
5761
// EncryptedScalarQuery carrying `hm`. Anything else (scalar storage,
5862
// a `bf`/`ob` query, or a SteVec payload) is a misconfiguration.
59-
if (term?.k !== 'ct' || !('hm' in term) || typeof term.hm !== 'string') {
63+
if (!isEncryptedScalarQuery(term) || !('hm' in term)) {
6064
throw new Error('expected encrypted search term to have an HMAC')
6165
}
6266

packages/protect/src/helpers/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
Encrypted as CipherStashEncrypted,
33
EncryptedQuery as CipherStashEncryptedQuery,
4+
EncryptedScalarQuery,
45
KeysetIdentifier as KeysetIdentifierFfi,
56
} from '@cipherstash/protect-ffi'
67
import type {
@@ -158,6 +159,42 @@ export function isEncryptedPayload(value: unknown): value is Encrypted {
158159
return false
159160
}
160161

162+
/**
163+
* Type guard narrowing a value to {@link EncryptedScalarQuery} — the scalar
164+
* query term (`unique` / `match` / `ore` lookup) returned by `encryptQuery` /
165+
* `encryptQueryBulk`. Unlike a storage payload it carries no ciphertext (`c`);
166+
* it carries exactly one lookup term: `hm`, `bf`, or `ob`.
167+
*
168+
* Use this to discriminate a scalar query term from a storage payload
169+
* (`EncryptedScalar`/`EncryptedSteVec`) or a `ste_vec_selector` query.
170+
*/
171+
export function isEncryptedScalarQuery(
172+
value: unknown,
173+
): value is EncryptedScalarQuery {
174+
if (value === null || typeof value !== 'object') return false
175+
176+
const obj = value as Record<string, unknown>
177+
178+
// `k: 'ct'` is the scalar discriminant; a query term never carries the
179+
// ciphertext (`c`) that every storage payload has.
180+
if (obj.k !== 'ct' || 'c' in obj) return false
181+
if (
182+
typeof obj.v !== 'number' ||
183+
typeof obj.i !== 'object' ||
184+
obj.i === null
185+
) {
186+
return false
187+
}
188+
189+
// Exactly one lookup term: `hm` (unique), `bf` (match), or `ob` (ore).
190+
const lookupTerms = [
191+
typeof obj.hm === 'string',
192+
Array.isArray(obj.bf),
193+
Array.isArray(obj.ob),
194+
].filter(Boolean)
195+
return lookupTerms.length === 1
196+
}
197+
161198
export {
162199
toJsonPath,
163200
buildNestedObject,

0 commit comments

Comments
 (0)