feat(stack): expose encryptQuery on the WASM entry — searchable encryption on the edge#676
Conversation
…ption on the edge Closes #662. `WasmEncryptionClient` exposed only encrypt/decrypt/isEncrypted, so encrypted WHERE-clause search was architecturally impossible on Deno/edge runtimes — even though the protect-ffi WASM build has carried encryptQuery/encryptQueryBulk all along. The 2026-07-16 skilltester edge run hit exactly this wall (grep -c encryptQuery dist/wasm-inline.js == 0). Adds encryptQuery + encryptQueryBulk minting ciphertext-free EQL v3 query terms (equality / match / ORE / JSON containment+selector). Index-type resolution is a local ~40-line port of the native client's resolveIndexType (this module deliberately never imports @cipherstash/protect — that would drag the Node-only native FFI into the WASM bundle); explicit queryType is validated against the column's indexes, omission infers by the same unique > match > ore > ste_vec priority as the native client, and searchableJson infers selector-vs-term from the plaintext shape. Errors throw (consistent with this surface); the bulk form is position-stable with nulls passing through. Tests: unit suite pins resolution, FFI opts shape, validation, null handling, and bulk stability against a mocked WASM module; the Deno e2e smoke now also mints a live term (v:3, ciphertext-free) and exercises the bulk path. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
🦋 Changeset detectedLatest commit: 804fe3b The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe WASM inline client adds EQL v3 ChangesWASM query encryption
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Application
participant WasmEncryptionClient
participant ProtectFfiWasmInline
participant Postgres
Application->>WasmEncryptionClient: encryptQuery(plaintext, options)
WasmEncryptionClient->>WasmEncryptionClient: resolve indexType and queryOp
WasmEncryptionClient->>ProtectFfiWasmInline: encrypt query term
ProtectFfiWasmInline-->>WasmEncryptionClient: ciphertext-free EQL v3 term
WasmEncryptionClient-->>Application: query term
Application->>Postgres: execute SQL with query term
Postgres-->>Application: matching row keys
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The Deno e2e caught it live: serde on the WASM side fails with "invalid type: unit value, expected a string" when queryOp is present-but-undefined (the native NAPI layer tolerates undefined optionals; serde-wasm does not). Omit the field at both call sites AND in resolveQueryIndex's return (the queryTypeToQueryOp map only carries JSON ops, so eq/match/ore mapped to undefined). Unit test now pins the field is ABSENT, not undefined. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Second live catch from the Deno e2e: encryptQueryBulk's opts field matches
the native ffiEncryptQueryBulk call ({ queries }), not the SDK-level term
naming. Unit-test mock aligned to the real FFI shape.
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
packages/stack/src/wasm-inline.ts (1)
401-470: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffConsider documenting the
as nevercasts per Biome-ignore convention.The new FFI calls (Lines 414-422, 459-464) extend the file's existing
as neverpattern for opaque WASM handles. As per coding guidelines,**/*.{ts,tsx}: "Avoidas any,as never, andas unknownin source; narrow types or use a specific assertion, and document deliberate suppressions with the required Biome ignore reason." Since the FFI boundary genuinely needs an escape hatch here, a narrower typed wrapper (or at least a documented Biome-ignore comment explaining why) would satisfy the guideline without weakening the existing pattern.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/src/wasm-inline.ts` around lines 401 - 470, Document the deliberate `as never` casts at the `wasmEncryptQuery` and `wasmEncryptQueryBulk` FFI boundaries using the repository’s required Biome-ignore reason convention, or replace them with a narrower typed wrapper if an existing one is available. Keep the casts limited to the opaque WASM handle and FFI argument boundaries.Source: Coding guidelines
packages/stack/__tests__/wasm-inline-query.test.ts (1)
153-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRedundant
null as unknown as stringcasts —WasmPlaintextalready includesnull.All three sites cast a literal
nullthroughas unknown as stringwhen assigningWasmQueryTerm.value.WasmQueryTerm.valueis typedWasmPlaintext, andWasmPlaintext(packages/stack/src/wasm-inline.tslines 151-158) already includesnullin its union, so the double-cast is unnecessary and, per coding guidelines (**/*.{ts,tsx}: avoidas unknown), should simply be dropped.
packages/stack/__tests__/wasm-inline-query.test.ts#L153: replacevalue: null as unknown as string,withvalue: null,.packages/stack/__tests__/wasm-inline-query.test.ts#L179: replacevalue: null as unknown as string,withvalue: null,.e2e/wasm/roundtrip.test.ts#L127: replacevalue: null as unknown as string,withvalue: null,.♻️ Proposed fix (apply to all three sites)
- { value: null as unknown as string, table: users, column: users.email }, + { value: null, table: users, column: users.email },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/__tests__/wasm-inline-query.test.ts` at line 153, Remove the redundant null casts from all three WasmQueryTerm.value assignments: packages/stack/__tests__/wasm-inline-query.test.ts lines 153-153 and 179-179, and e2e/wasm/roundtrip.test.ts line 127. Set each value directly to null, relying on the WasmPlaintext type and avoiding as unknown.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/stack/src/wasm-inline.ts`:
- Around line 270-317: Update resolveQueryIndex to support the OPE index: when
resolving orderAndRange, accept the configured ope index via the same ore-to-ope
fallback used by the stack-side resolver, and include ope in inference when ore
is absent. Preserve existing ore behavior and add coverage for a column
configured only with ope.
---
Nitpick comments:
In `@packages/stack/__tests__/wasm-inline-query.test.ts`:
- Line 153: Remove the redundant null casts from all three WasmQueryTerm.value
assignments: packages/stack/__tests__/wasm-inline-query.test.ts lines 153-153
and 179-179, and e2e/wasm/roundtrip.test.ts line 127. Set each value directly to
null, relying on the WasmPlaintext type and avoiding as unknown.
In `@packages/stack/src/wasm-inline.ts`:
- Around line 401-470: Document the deliberate `as never` casts at the
`wasmEncryptQuery` and `wasmEncryptQueryBulk` FFI boundaries using the
repository’s required Biome-ignore reason convention, or replace them with a
narrower typed wrapper if an existing one is available. Keep the casts limited
to the opaque WASM handle and FFI argument boundaries.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b65d459c-b93a-4252-bf5b-b8f4859302b9
📒 Files selected for processing (4)
.changeset/wasm-encrypt-query.mde2e/wasm/roundtrip.test.tspackages/stack/__tests__/wasm-inline-query.test.tspackages/stack/src/wasm-inline.ts
…surface Review feedback on #676: - encryptQuery/encryptQueryBulk get complete TSDoc: @example per query type (equality, free-text, ORE range, JSON containment + selector) each with the ::eql_v3.query_<domain> SQL cast pattern, @param/@returns/@throws, the ciphertext-free semantics, and the domain→query-type mapping (including the query_jsonb irregular). WasmEncryptQueryOptions.queryType documents all four types and the inference rules (be explicit on multi-index domains like TextSearch). - New e2e/wasm/query-types.test.ts: live matrix mirroring the adapters' per-query-type coverage — one real ZeroKMS term per type (equality/unique, freeTextSearch/match, orderAndRange/ore on IntegerOrd, searchableJson selector + containment on Json), plus inference and a mixed-type bulk with null passthrough. Every term asserted v3 + ciphertext-free. Each type crosses the WASM serde boundary live — the layer that already ate two mock-invisible bugs (undefined fields; the `queries` batch field). Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
|
Both review points addressed in the latest commit: TSDoc — Per-query-type e2e — new |
Third live catch from the Deno query-type matrix: orderAndRange on a v3 IntegerOrd column failed 'index type "ore" is not configured' — the local resolver was ported from packages/protect's OLDER helper, but stack has its own @/encryption/helpers/infer-index-type with three v3 behaviours the port lacked: the ore→ope swap for v3 ord domains, equality answered via the ordering index on order-capable columns without `unique`, and ope in the inference priority. Delete the port entirely and import the shared resolver — it's type-only on protect-ffi, so it's WASM-bundle-safe, and the lockstep-by-comment risk goes away by construction. Unit tests pin the two previously-missing behaviours (ope swap; equality-via-ordering) so they're covered without live creds. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…velope
Fourth live catch from the Deno matrix — this one was in the TEST and the
docs, not the code: by protect-ffi's v3 contract there is no
encrypted-selector envelope ("JSONB selector (path) queries: the bare
Selector hash (a string) — bind it as the text argument of -> / ->>").
The e2e asserted v:3 on it; now asserts the bare non-empty string, and the
encryptQuery TSDoc example shows the correct -> binding (plus the @>
containment cast).
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Fifth live catch, again in the test's assumptions: eql_v3.query_jsonb
containment needles are strict {sv: [query-entry]} — no v field (per
protect-ffi's SteVecQuery contract; "no c" enforced by
is_valid_ste_vec_query_payload). Only SCALAR query terms carry the v:3
envelope. The e2e now pins each kind's true wire shape: scalar envelope,
bare selector string, sv containment needle — all ciphertext-free.
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
…g creds Review feedback on #676, part two: 1. The Deno e2e suites now FAIL with an actionable message naming every missing CS_* variable instead of silently skipping — a skipped credential suite reads as green coverage that never ran (the integration harness doctrine, applied to e2e/wasm). 2. New packages/stack/integration/wasm/ runs the FULL shared v3 family suite (@cipherstash/test-kit) over the WASM entry — every covered domain, every capability-derived positive and negative op, real ZeroKMS encryption and real row matching in a real Postgres, exactly like the Drizzle/Supabase adapters. The WASM surface mints terms only, so the adapter IS the SQL layer: per-field client.encrypt inserts, and each query op renders the documented raw-SQL recipe (eql_v3.<fn>(col, $n::jsonb::eql_v3.query_<d>), parenthesised gte/lte ranges, contains for bloom, ord_term ordering); in/notIn decompose over ONE encryptQueryBulk batch per list, exercising the bulk path on every family. Passing proves the whole edge recipe: WASM term → query-domain cast → indexed operator → correct rows. Wiring: test-kit's adapter name union gains 'wasm'; stack's integration vitest restores the REAL protect-ffi/auth wasm modules (the shared alias stubs them for unit tests); the suite joins integration-drizzle.yml's CS_IT_SUITE on both db legs, with wasm-inline.ts added to the path filters. Date/timestamp values cross the boundary as ISO strings (WasmPlaintext has no Date arm — serde doesn't consult toJSON). Validated locally to the credential boundary: with a live postgres-eql and dummy CS_* values, collection, EQL install, DDL, schema construction, and the WASM factory all execute, failing precisely at AccessKeyStrategy.create(INVALID_CRN) — CI's real secrets take it from there. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
|
Second round of review feedback addressed: Fail loud, never skip — both Deno e2e suites now throw inside the test with a message naming every missing test-kit adapter-grade e2e — new Wiring: Validated locally to the credential boundary (live postgres-eql + dummy |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
e2e/wasm/query-types.test.ts (1)
152-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNarrow the selector without
as unknown.Use a type-predicate assertion or explicit
typeof selector !== 'string'guard before reading.length.As per coding guidelines, “Avoid
as any,as never, andas unknownin source.” <coding_guidelines>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@e2e/wasm/query-types.test.ts` around lines 152 - 155, Update the selector validation assertion to avoid casting selector through unknown before accessing length. Narrow selector with an explicit typeof selector check or a type-predicate assertion, then validate that the resulting string is non-empty while preserving the existing failure message.Source: Coding guidelines
packages/stack/integration/wasm/adapter.ts (1)
284-291: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRemove the blanket schema type escapes.
as neverandas unknownsuppress validation of the dynamic schema-to-column mapping. Use a typed schema construction helper or a specific documented assertion instead.As per coding guidelines, “Avoid
as any,as never, andas unknownin source.” <coding_guidelines>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/stack/integration/wasm/adapter.ts` around lines 284 - 291, Replace the blanket as never and as unknown assertions in the tableSchema construction and dynamic column lookup within the adapter’s schema-mapping flow. Introduce or reuse a typed schema construction/access helper, or use a narrowly scoped documented assertion that preserves validation of the schema-to-column mapping; do not use any, never, or unknown casts.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@e2e/wasm/query-types.test.ts`:
- Around line 83-92: Update assertContainmentNeedle to explicitly assert that
the payload does not contain the version-envelope field v, alongside the
existing sv and c assertions, so only strict {sv: [...]} containment needles
pass.
In `@packages/stack/integration/wasm/adapter.ts`:
- Around line 335-343: Update expectRejected to swallow only the specific plain
Error representing the expected WASM rejection, and rethrow all other errors
from run(op), including database, network, or ZeroKMS failures. Preserve the
existing success path that throws when the operation is not rejected.
---
Nitpick comments:
In `@e2e/wasm/query-types.test.ts`:
- Around line 152-155: Update the selector validation assertion to avoid casting
selector through unknown before accessing length. Narrow selector with an
explicit typeof selector check or a type-predicate assertion, then validate that
the resulting string is non-empty while preserving the existing failure message.
In `@packages/stack/integration/wasm/adapter.ts`:
- Around line 284-291: Replace the blanket as never and as unknown assertions in
the tableSchema construction and dynamic column lookup within the adapter’s
schema-mapping flow. Introduce or reuse a typed schema construction/access
helper, or use a narrowly scoped documented assertion that preserves validation
of the schema-to-column mapping; do not use any, never, or unknown casts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a977be21-eec6-4ed1-9b25-9145bedb6320
⛔ Files ignored due to path filters (1)
e2e/wasm/deno.lockis excluded by!**/*.lock
📒 Files selected for processing (9)
.github/workflows/integration-drizzle.ymle2e/wasm/query-types.test.tse2e/wasm/roundtrip.test.tspackages/stack/__tests__/wasm-inline-query.test.tspackages/stack/integration/vitest.config.tspackages/stack/integration/wasm/adapter.tspackages/stack/integration/wasm/families.integration.test.tspackages/stack/src/wasm-inline.tspackages/test-kit/src/adapter.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/stack/tests/wasm-inline-query.test.ts
- packages/stack/src/wasm-inline.ts
Two failures from the first live run of the WASM family suite: 1. Every family failed its domain CHECK at insert. Root cause is a postgres.js serializer trap, reproduced locally with a hand-valid envelope and no credentials: the $n::jsonb casts make the server type those params as jsonb, and postgres.js serializes jsonb params with JSON.stringify — so the adapter's pre-stringified payloads were encoded twice, arriving as jsonb *string* scalars that fail every jsonb_typeof(VALUE) = 'object' domain check. Bind the raw objects instead (storage payloads and query terms both) and let the driver stringify exactly once. A pre-insert assertWireEnvelope pin now names the payload, not the column domain, if a boundary shape ever regresses. 2. roundtrip.test.ts referenced env without calling requireEnv() — ReferenceError under Deno. Bound it, and added storage-payload wire pins (property access + JSON round-trip) so the Deno job also proves the envelope survives JSON.stringify — the exact crossing every SQL insert performs, which isEncrypted/decrypt alone cannot pin. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The kit's ordering oracle breaks ties on row_key ascending and expects
the query to mirror it with a secondary ORDER BY — date/timestamp have
fewer samples than rows, so tied values are guaranteed and the previous
single-key ORDER BY returned them in arbitrary order (4 live failures:
{date,timestamp}_ord asc+desc).
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
|
CI is fully green, including the first complete live runs of the WASM family suite on both database legs. The suite earned its keep immediately — two catches from live CI:
|
…rdening Product fix: the WASM encryptQuery/encryptQueryBulk now run the same pre-FFI guards as every other query path (assertValidNumericValue, assertValueIndexCompatibility) — NaN/Infinity/out-of-int64 bigint and numeric-on-match-index fail with the named errors the Node entry raises instead of an opaque serde failure or a silently no-match term. Both paths now share one toFfiQueryTerm() so the serde-omission and ope-swap subtleties can't drift between single and bulk; the deliberate as-never casts at the FFI seam carry biome-ignore reasons per AGENTS.md. Test hardening from the same review: - FFI stub gains encryptQuery/encryptQueryBulk so unmocked unit tests fail with the stub's named error, not 'is not a function' - query-types.test.ts gains roundtrip's ffi:false permissions pin - wasm adapter expectRejected discriminates capability rejections from infrastructure failures (mirrors the Supabase adapter's doctrine) - empty in/notIn lists throw (parity with drizzle inArrayOp) instead of rendering 'WHERE ()' - independent ZeroKMS round-trips run concurrently (per-field encrypts, bulk-insert rows, between lo/hi terms) - dropped the redundant '| null' from encryptQuery's signature and the gratuitous 'null as unknown as string' casts it appeared to justify - fixed the nonexistent .env.example pointer in both Deno suites and the stale skip-behavior comments in tests.yml/integration-drizzle.yml - three new unit pins for the validation behavior (828 total green) Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
The suite header documents the wire contract as a strict {sv: [...]}
with no version field, but assertContainmentNeedle only rejected
ciphertext — a regression reintroducing the v envelope would have
passed. One-line pin from CodeRabbit's review.
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
|
Comprehensive human + AI review (Coderabbit and Claude/Fable). |
Closes #662.
Why
@cipherstash/stack/wasm-inline'sWasmEncryptionClientexposed onlyencrypt/decrypt/isEncrypted— encrypted WHERE-clause search was architecturally impossible on Deno/edge runtimes, even though the underlying protect-ffi WASM build has carriedencryptQuery/encryptQueryBulkall along. The 2026-07-16 skilltester edge eval hit exactly this wall (its assessor confirmedgrep -c encryptQuery dist/wasm-inline.js= 0, and the surface scored PARTIAL solely on this product gap).What
encryptQuery(plaintext, { table, column, queryType? })— mints a ciphertext-free EQL v3 query term (equality / free-text match / ORE range / JSON containment+selector). Cast to the column'seql_v3.query_<domain>in SQL to reach the indexed operators (worked example in the module docblock).encryptQueryBulk(terms)— one round trip for many terms; position-stable,nullvalues pass through asnull.resolveIndexType— this module deliberately never imports@cipherstash/protect(it would drag the Node-only native FFI into the WASM bundle). ExplicitqueryTypeis validated against the column's configured indexes; omission infers by the sameunique > match > ore > ste_vecpriority as the native client;searchableJsoninfers selector-vs-term from the plaintext shape. A code comment binds the two implementations to behavioural lockstep.encrypt/decrypt(the Result envelope is a native-entry concept).Tests
queryTypemapping + not-configured rejection,searchableJsonselector/term inference, null → null without an FFI call, bulk position-stability + all-null short-circuit. 823/823 stack tests green,test:typesclean.Not in scope
#663 (no credential-free dev auth on WASM) is the adjacent gap and remains open.
https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
Summary by CodeRabbit
encryptQueryandencryptQueryBulk.nullentries asnullresults.