Skip to content

Commit 5fcf982

Browse files
authored
Merge pull request #676 from cipherstash/feat/662-wasm-encrypt-query
feat(stack): expose encryptQuery on the WASM entry — searchable encryption on the edge
2 parents bab1307 + 804fe3b commit 5fcf982

13 files changed

Lines changed: 1344 additions & 31 deletions

File tree

.changeset/wasm-encrypt-query.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@cipherstash/stack': minor
3+
---
4+
5+
`@cipherstash/stack/wasm-inline` now exposes `encryptQuery` and
6+
`encryptQueryBulk` on `WasmEncryptionClient` (#662) — searchable encryption
7+
is reachable on Deno/edge runtimes. Previously the WASM entry exposed only
8+
`encrypt`/`decrypt`/`isEncrypted`, so encrypted WHERE-clause search was
9+
architecturally impossible on the edge even though the underlying protect-ffi
10+
WASM build carries the capability.
11+
12+
The new methods mint ciphertext-free EQL v3 query terms — equality,
13+
free-text match, ORE range, and JSON containment/selector — with the same
14+
index-type resolution as the native client (explicit `queryType`, or
15+
inference from the column's configured indexes). Cast the term to the
16+
column's `eql_v3.query_<domain>` type in SQL to reach the indexed operators.
17+
Errors throw, consistent with the WASM surface's `encrypt`/`decrypt`; the
18+
bulk form is position-stable (`null` values pass through as `null`).

.github/workflows/integration-drizzle.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ on:
2626
- 'packages/stack/src/schema/**'
2727
- 'packages/schema/**'
2828
- 'packages/stack/integration/**'
29+
# The WASM family suite (integration/wasm/**) exercises this entry:
30+
- 'packages/stack/src/wasm-inline.ts'
2931
- 'packages/test-kit/**'
3032
- 'packages/cli/src/installer/**'
3133
- 'local/docker-compose.postgres.yml'
@@ -46,6 +48,8 @@ on:
4648
- 'packages/stack/src/schema/**'
4749
- 'packages/schema/**'
4850
- 'packages/stack/integration/**'
51+
# The WASM family suite (integration/wasm/**) exercises this entry:
52+
- 'packages/stack/src/wasm-inline.ts'
4953
- 'packages/test-kit/**'
5054
- 'packages/cli/src/installer/**'
5155
- 'local/docker-compose.postgres.yml'
@@ -125,11 +129,13 @@ jobs:
125129
# The Drizzle adapter suites (incl. the Clerk-federated lock-context one)
126130
# now live in @cipherstash/stack-drizzle (run below via its own
127131
# test:integration). This glob scopes what still lives in @cipherstash/stack:
128-
# the adapter-agnostic `shared/` core suites and the model-path
129-
# `identity/matrix-identity` suite.
132+
# the adapter-agnostic `shared/` core suites, the model-path
133+
# `identity/matrix-identity` suite, and the `wasm/` family suite (the
134+
# `@cipherstash/stack/wasm-inline` adapter over the shared v3 matrix).
130135
CS_IT_SUITE: >-
131136
integration/shared/**/*.integration.test.ts,
132-
integration/identity/**/*.integration.test.ts
137+
integration/identity/**/*.integration.test.ts,
138+
integration/wasm/**/*.integration.test.ts
133139
134140
steps:
135141
- uses: actions/checkout@v6
@@ -167,7 +173,7 @@ jobs:
167173
# different package now). Its globalSetup calls the same EQL v3 install, but
168174
# `isInstalled` short-circuits against the DB the first invocation already
169175
# provisioned — a fast no-op check, not a second schema apply.
170-
- name: Shared core + identity integration suites
176+
- name: Shared core + identity + wasm integration suites
171177
run: pnpm --filter @cipherstash/stack run test:integration
172178

173179
- name: Stop ${{ matrix.db }}

.github/workflows/tests.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,10 @@ jobs:
294294
- name: Build stack
295295
run: pnpm exec turbo run build --filter @cipherstash/stack
296296

297-
# roundtrip.test.ts skips itself (Deno.test.ignore) when any of the four
298-
# CS_* env vars is missing — fine locally, but in CI a silent skip would
299-
# let a rotated / cleared secret hide a real WASM regression behind a
300-
# green job. Fail loudly instead.
297+
# The e2e/wasm suites FAIL when any of the four CS_* env vars is
298+
# missing (requireEnv throws — no skip gating), so a rotated / cleared
299+
# secret can't hide a real WASM regression behind a green job. This
300+
# preflight just fails faster, before the Deno module downloads.
301301
- name: Require CipherStash secrets
302302
uses: ./.github/actions/require-cs-secrets
303303
with:

e2e/wasm/deno.lock

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/wasm/query-types.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* Query-type matrix for `@cipherstash/stack/wasm-inline`'s `encryptQuery` /
3+
* `encryptQueryBulk` (#662) — the WASM twin of the per-query-type coverage
4+
* the Drizzle/Supabase adapters have.
5+
*
6+
* Runs under Deno against real CipherStash credentials, one live term per
7+
* query type the surface supports:
8+
*
9+
* | queryType | column domain | FFI index |
10+
* |------------------|----------------------|------------|
11+
* | equality | eql_v3_text_eq | unique |
12+
* | freeTextSearch | eql_v3_text_search | match |
13+
* | orderAndRange | eql_v3_integer_ord | ore |
14+
* | searchableJson | eql_v3_json | ste_vec |
15+
* | (string value) | → ste_vec_selector | |
16+
* | (object value) | → ste_vec_term | |
17+
*
18+
* Wire shapes differ by kind — which is exactly what this suite pins:
19+
* SCALAR terms are v3 envelopes (`{v: 3, i, …}`); a JSON CONTAINMENT
20+
* needle is a strict `{sv: [query-entry]}` with no version field; a
21+
* SELECTOR query returns the BARE selector-hash string (v3 has no
22+
* encrypted-selector envelope — bind as the text argument of `->`/`->>`).
23+
* All are CIPHERTEXT-FREE. The boundary bugs this suite's first runs
24+
* caught (undefined fields rejected by serde; the bulk field is `queries`;
25+
* the ore→ope swap; both JSON shapes) are exactly why each type needs a
26+
* live crossing, not a mock.
27+
*
28+
* FAILS LOUDLY when any CS_* env var is missing, matching `roundtrip.test.ts`.
29+
*/
30+
31+
import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0'
32+
import {
33+
Encryption,
34+
encryptedTable,
35+
types,
36+
} from '@cipherstash/stack/wasm-inline'
37+
38+
const REQUIRED_ENV = [
39+
'CS_WORKSPACE_CRN',
40+
'CS_CLIENT_ACCESS_KEY',
41+
'CS_CLIENT_ID',
42+
'CS_CLIENT_KEY',
43+
] as const
44+
45+
function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> {
46+
const values = {} as Record<(typeof REQUIRED_ENV)[number], string>
47+
const missing: string[] = []
48+
for (const key of REQUIRED_ENV) {
49+
const value = Deno.env.get(key)
50+
if (value) values[key] = value
51+
else missing.push(key)
52+
}
53+
if (missing.length > 0) {
54+
// FAIL, don't skip: a silently-skipped credential suite reads as green
55+
// coverage that never ran (same doctrine as the repo's integration
56+
// harness — no skipIf credential gates).
57+
throw new Error(
58+
`Missing required env: ${missing.join(', ')}. This suite needs real ` +
59+
'CipherStash credentials — export the four CS_* variables (or put them ' +
60+
'in a repo-root .env; see AGENTS.md "Environment variables") or run ' +
61+
'via the CI job, which injects them.',
62+
)
63+
}
64+
return values
65+
}
66+
67+
const catalog = encryptedTable('wasm_query_matrix', {
68+
email: types.TextEq('email'), // equality only
69+
bio: types.TextSearch('bio'), // free-text (also carries eq + ore)
70+
age: types.IntegerOrd('age'), // eq + order/range
71+
prefs: types.Json('prefs'), // searchable JSON (ste_vec)
72+
})
73+
74+
/** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */
75+
function assertV3Term(term: unknown, label: string) {
76+
assertExists(term, `${label}: encryptQuery returned null`)
77+
const obj = term as Record<string, unknown>
78+
assertEquals(obj.v, 3, `${label}: term is not EQL v3`)
79+
assertEquals('c' in obj, false, `${label}: term carries ciphertext`)
80+
}
81+
82+
/** A v3 JSON CONTAINMENT needle: strict `{sv: [query-entry]}` — no `v`
83+
* envelope, no ciphertext (per `is_valid_ste_vec_query_payload`). */
84+
function assertContainmentNeedle(term: unknown, label: string) {
85+
assertExists(term, `${label}: encryptQuery returned null`)
86+
const obj = term as Record<string, unknown>
87+
assertEquals(
88+
Array.isArray(obj.sv),
89+
true,
90+
`${label}: expected an {sv: [...]} containment needle`,
91+
)
92+
assertEquals('c' in obj, false, `${label}: needle carries ciphertext`)
93+
assertEquals('v' in obj, false, `${label}: needle carries a version envelope`)
94+
}
95+
96+
Deno.test({
97+
name: 'stack/wasm-inline: encryptQuery covers every v3 query type',
98+
permissions: {
99+
env: true,
100+
net: true,
101+
read: true,
102+
sys: true,
103+
// No FFI permission, same pin as roundtrip.test.ts: if protect-ffi ever
104+
// silently fell back to a native binding under Deno, the call would
105+
// reject — so a green run proves the WASM path minted every term.
106+
ffi: false,
107+
},
108+
async fn() {
109+
const env = requireEnv()
110+
const client = await Encryption({
111+
schemas: [catalog],
112+
config: {
113+
workspaceCrn: env.CS_WORKSPACE_CRN,
114+
accessKey: env.CS_CLIENT_ACCESS_KEY,
115+
clientId: env.CS_CLIENT_ID,
116+
clientKey: env.CS_CLIENT_KEY,
117+
},
118+
})
119+
120+
// equality → unique index
121+
assertV3Term(
122+
await client.encryptQuery('alice@example.com', {
123+
table: catalog,
124+
column: catalog.email,
125+
queryType: 'equality',
126+
}),
127+
'equality',
128+
)
129+
130+
// freeTextSearch → match index
131+
assertV3Term(
132+
await client.encryptQuery('needle phrase', {
133+
table: catalog,
134+
column: catalog.bio,
135+
queryType: 'freeTextSearch',
136+
}),
137+
'freeTextSearch',
138+
)
139+
140+
// orderAndRange → ore index (numeric)
141+
assertV3Term(
142+
await client.encryptQuery(42, {
143+
table: catalog,
144+
column: catalog.age,
145+
queryType: 'orderAndRange',
146+
}),
147+
'orderAndRange',
148+
)
149+
150+
// searchableJson, string value → ste_vec_selector (JSONPath). By
151+
// contract the selector "term" is a BARE selector-hash string — no
152+
// envelope — bound as the text argument of `->` / `->>`.
153+
const selector = await client.encryptQuery('$.theme', {
154+
table: catalog,
155+
column: catalog.prefs,
156+
queryType: 'searchableJson',
157+
})
158+
assertExists(selector, 'selector: encryptQuery returned null')
159+
assertEquals(
160+
typeof selector,
161+
'string',
162+
'selector: expected the bare selector-hash string',
163+
)
164+
assertEquals(
165+
(selector as unknown as string).length > 0,
166+
true,
167+
'selector: empty selector hash',
168+
)
169+
170+
// searchableJson, object value → ste_vec_term (containment needle:
171+
// strict {sv: [...]}, no version envelope — per the eql_v3.query_jsonb
172+
// wire contract)
173+
assertContainmentNeedle(
174+
await client.encryptQuery(
175+
{ theme: 'dark' },
176+
{
177+
table: catalog,
178+
column: catalog.prefs,
179+
queryType: 'searchableJson',
180+
},
181+
),
182+
'searchableJson/containment',
183+
)
184+
185+
// Omitted queryType → inference from the column's indexes (TextEq has
186+
// exactly one: unique), mirroring the native client.
187+
assertV3Term(
188+
await client.encryptQuery('bob@example.com', {
189+
table: catalog,
190+
column: catalog.email,
191+
}),
192+
'inference',
193+
)
194+
195+
// Bulk: one round trip across mixed query types, position-stable with
196+
// nulls passing through.
197+
const bulk = await client.encryptQueryBulk([
198+
{
199+
value: 'alice@example.com',
200+
table: catalog,
201+
column: catalog.email,
202+
queryType: 'equality',
203+
},
204+
{
205+
value: null,
206+
table: catalog,
207+
column: catalog.email,
208+
},
209+
{
210+
value: 'needle',
211+
table: catalog,
212+
column: catalog.bio,
213+
queryType: 'freeTextSearch',
214+
},
215+
{
216+
value: 7,
217+
table: catalog,
218+
column: catalog.age,
219+
queryType: 'orderAndRange',
220+
},
221+
{
222+
value: { theme: 'dark' },
223+
table: catalog,
224+
column: catalog.prefs,
225+
queryType: 'searchableJson',
226+
},
227+
])
228+
assertEquals(bulk.length, 5)
229+
assertV3Term(bulk[0], 'bulk/equality')
230+
assertEquals(bulk[1], null, 'bulk: null value must yield null')
231+
assertV3Term(bulk[2], 'bulk/freeTextSearch')
232+
assertV3Term(bulk[3], 'bulk/orderAndRange')
233+
assertContainmentNeedle(bulk[4], 'bulk/searchableJson')
234+
},
235+
})

0 commit comments

Comments
 (0)