Skip to content

feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms#767

Open
coderdan wants to merge 1 commit into
mainfrom
feat/wasm-inline-model-helpers
Open

feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms#767
coderdan wants to merge 1 commit into
mainfrom
feat/wasm-inline-model-helpers

Conversation

@coderdan

@coderdan coderdan commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Closes #742.

Problem

@cipherstash/stack/wasm-inline exposed no model operations, so edge code hand-rolled the per-field bulkEncrypt mapping for every read and write. The failure mode is the quiet one the issue describes: add a column to the schema, forget to extend the hand-written mapping, and the field silently persists in plaintext. #741 deliberately shipped only the value-level bulk halves; this is the remaining, larger half.

Approach: share the traversal, don't port it

The pure model walk — field matching by JS property name, dotted-path nesting, null bookkeeping, nested placement, per-field numeric validation, and the property→DB-name column map (resolveEncryptColumnMap) — moves out of packages/stack/src/encryption/helpers/model-helpers.ts into a new bundle-safe model-traversal.ts (no runtime protect-ffi imports; the existing wasm-inline-bundle-isolation test enforces that at the artifact level). Both entries now consume it:

  • the native model helpers pair it with the NAPI encryptBulk/decryptBulk exactly as before (pure move — no behaviour change, resolveEncryptColumnMap re-exported);
  • the WASM entry pairs it with the /wasm-inline FFI batch calls.

The traversal is where the plaintext-leak class of bug lives, so the two entries can no longer drift on which fields get encrypted.

The new surface

Follows this module's local conventions, per the issue's guidance and the #741 precedent (note: the issue's "this surface throws" is pre-#741 — the entry returns { data } | { failure } Results now, and the new methods do too):

Method Notes
encryptModel(model, table) Schema columns encrypted (matched by JS property name; nested fields via the column's dotted path), everything else passes through, null/undefined preserved without reaching ZeroKMS
bulkEncryptModels(models, table) All fields across all models in one FFI crossing; result index-aligned; []{ data: [] } with no call
decryptModel(model, table) Schema-blind collection (any EQL envelope decrypts, wherever it nests); table drives Date reconstruction from cast_as
bulkDecryptModels(models, table) One crossing; failures labelled [model <i>] <field.path>

Details:

  • One ZeroKMS round trip per call, with assertBatchLength guarding the positional pairing (the model batches don't route through runBatch — they're never sparse and carry { modelIndex, fieldKey } structure; its docblock is updated accordingly).
  • Per-field failure reporting on decrypt via decryptBulkFallible: one bad field doesn't mask the rest; the single { failure } names every failed field by its model path with its per-item code.
  • Date columns round-trip DateDate: encrypt serializes to ISO-8601 (WasmPlaintext deliberately carries no Date — a raw Date across the wasm serde would arrive as {}, silent corruption), decrypt rebuilds via the table's cast_as, mirroring the native v3 client's rowReconstructor.
  • No .withLockContext() — identity-bound encryption on the edge is configured at client construction via config.authStrategy (wasm-inline has no auto/dev-profile auth strategy — credential-free local dev impossible on WASM #663 context), noted in the class docblock.
  • Types reuse the v3 model types the native typed client already exports (V3ModelInput / V3EncryptedModel / V3DecryptedModel), so date columns demand Date in and yield Date out at the type level too.

Changes

  • packages/stack/src/encryption/helpers/model-traversal.ts — new shared pure-traversal module (moved code, not new logic)
  • packages/stack/src/encryption/helpers/model-helpers.ts — slimmed to the native FFI pairing; imports the traversal
  • packages/stack/src/wasm-inline.ts — the four methods + two private batch engines, FallibleDecryptItem hoisted for sharing, docblocks updated (the class doc recorded this exact gap)
  • packages/stack/__tests__/wasm-inline-models.test.ts — 13 mocked tests: FFI payload shapes, single-crossing batching, property→DB-name mapping (createdOncreated_on), dotted-path nesting, null/passthrough preservation, Date round-trip, NaN pre-FFI rejection, short-response refusal, per-field failure labels, empty-batch short-circuits
  • e2e/wasm/roundtrip.test.ts — live Deno coverage for all four methods (gated on real CS_* creds, as the rest of that suite)
  • skills/stash-encryption/SKILL.md — replaces "model helpers are not available on the WASM entry" with the actual contract + example
  • .changeset/wasm-inline-model-helpers.md@cipherstash/stack minor

Testing

  • pnpm --filter @cipherstash/stack test — 1034 tests pass (includes the 13 new ones and the bundle-isolation suite against a fresh dist/)
  • pnpm --filter @cipherstash/stack build — clean, including .d.ts emission
  • pnpm run test:types — no type errors; tsc --noEmit reports zero errors in every touched file (remaining repo-wide errors are pre-existing in integration files)
  • pnpm -w run code:check — clean (exit 0)
  • Deno e2e not run locally (needs live credentials); the gated CI job covers it

Out of scope

Summary by CodeRabbit

  • New Features

    • Added model-level encryption and decryption helpers for the WASM inline client.
    • Added bulk model encryption and decryption with efficient batch processing.
    • Supports nested fields, schema-based field mapping, date values, and preservation of null/undefined.
    • Decryption failures now identify affected model fields and paths.
  • Documentation

    • Documented the new model helper APIs, behavior, result handling, and WASM-specific usage.

…ryptModel + bulk forms

`@cipherstash/stack/wasm-inline` exposed no model operations, so edge code
hand-rolled the per-field bulkEncrypt mapping for every read and write. The
failure mode of that mapping is the quiet one #742 describes: add a column
to the schema, forget to extend the mapping, and the field silently
persists in PLAINTEXT. The model helpers close that gap by construction.

The traversal is SHARED, not ported: the pure walk (field matching by JS
property name, dotted-path nesting, null bookkeeping, nested placement,
numeric validation, the property→DB-name column map) moves out of
packages/stack's model-helpers.ts into a new bundle-safe
encryption/helpers/model-traversal.ts with no runtime protect-ffi imports,
and both entries consume it — the native model helpers pair it with the
NAPI batch calls exactly as before, the WASM entry pairs it with the
/wasm-inline FFI batch calls. The two entries can no longer drift on which
fields get encrypted.

The new surface follows this entry's local conventions (#741 precedent):

- Every method returns the `{ data } | { failure }` WasmResult; arguments
  are plain models plus a v3 table — no `{ id, … }` envelopes.
- One ZeroKMS round trip per call, however many fields/models it covers,
  with assertBatchLength guarding the positional pairing.
- decryptModel/bulkDecryptModels ride decryptBulkFallible: one bad field
  doesn't mask the rest, and the single failure names EVERY failed field
  by its model path (`[model 1] profile.ssn`), with per-item codes.
- Date columns round-trip Date → Date: encrypt serializes to ISO-8601
  (WasmPlaintext carries no Date across the wasm serde — a raw Date would
  arrive as `{}`), decrypt rebuilds via the table's cast_as, mirroring the
  native v3 client's rowReconstructor.
- No .withLockContext(): identity-bound encryption on the edge is
  configured at client construction via config.authStrategy.

13 new mocked unit tests pin the FFI payload shapes, single-crossing
batching, passthrough/null preservation, the property→DB-name mapping,
dotted-path nesting, Date round-trip, per-field failure labelling, and the
short-response refusal; the gated Deno e2e gains live round-trip coverage
for all four methods. skills/stash-encryption drops its "not available on
the WASM entry" caveat in favour of the real contract.

Closes #742
@coderdan
coderdan requested a review from a team as a code owner July 22, 2026 13:47
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6fdb7f6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
stash Minor
@cipherstash/prisma-next Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Minor

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

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds single and bulk model encryption/decryption to @cipherstash/stack/wasm-inline, using shared schema traversal, nested field mapping, null preservation, date reconstruction, batched FFI calls, aggregated failures, and unit/e2e coverage.

Changes

WASM inline model helpers

Layer / File(s) Summary
Shared model traversal and reconstruction
packages/stack/src/encryption/helpers/model-traversal.ts, packages/stack/src/encryption/helpers/model-helpers.ts
Centralizes schema-aware traversal, nested assignment, null handling, bulk index mapping, and reconstruction for encryption and decryption.
WASM model operation engines
packages/stack/src/wasm-inline.ts, .changeset/wasm-inline-model-helpers.md, skills/stash-encryption/SKILL.md
Adds typed single and bulk model APIs, one-call WASM FFI batching, Date serialization/reconstruction, and field-level failure reporting.
Model helper validation and round trips
packages/stack/__tests__/wasm-inline-models.test.ts, e2e/wasm/roundtrip.test.ts
Tests schema mapping, nested paths, null and passthrough values, dates, empty batches, FFI validation, failures, and aligned round trips.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: tobyhede

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding wasm-inline model helpers and their bulk variants.
Linked Issues check ✅ Passed The PR implements the requested wasm-inline model helpers, shared traversal, bulk round-trip behavior, and nested/schema-driven handling for #742.
Out of Scope Changes check ✅ Passed The changes stay focused on wasm-inline model-helper parity, shared traversal, tests, and documentation with no clear unrelated additions.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wasm-inline-model-helpers

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/stack/src/encryption/helpers/model-traversal.ts (1)

162-174: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Optional: anchor the nested-recursion prefix match to a path boundary. The gate columnPaths.some((path) => path.startsWith(fullKey)) matches sibling keys that merely share a textual prefix (e.g. 'ab.c'.startsWith('a')), so the walk recurses into objects that have no schema column beneath them. This is benign today — it only over-recurses and never skips a real nested column, so there is no plaintext-leak path — but tightening it to a path boundary keeps this sensitive traversal precise as the schema shapes grow.

  • packages/stack/src/encryption/helpers/model-traversal.ts#L162-L174: change the recurse guard to columnPaths.some((path) => path.startsWith(\${fullKey}.`))`.
  • packages/stack/src/encryption/helpers/model-traversal.ts#L244-L256: apply the same \${fullKey}.`` boundary in the bulk walker.
🤖 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/encryption/helpers/model-traversal.ts` around lines 162 -
174, Anchor both nested-recursion guards to a path boundary by changing the
prefix checks in the model traversal logic at
packages/stack/src/encryption/helpers/model-traversal.ts lines 162-174 and
244-256 to match `${fullKey}.`; update the guards in both the single-object
walker and bulk walker, preserving recursion only when a schema column exists
beneath the current key.
🤖 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/__tests__/wasm-inline-models.test.ts`:
- Around line 124-130: Update the assertion in the test around expectData(out)
to use strict equality, ensuring the createdOn property is explicitly preserved
as undefined rather than omitted. Keep the expected object and surrounding
encryption assertions unchanged.

---

Nitpick comments:
In `@packages/stack/src/encryption/helpers/model-traversal.ts`:
- Around line 162-174: Anchor both nested-recursion guards to a path boundary by
changing the prefix checks in the model traversal logic at
packages/stack/src/encryption/helpers/model-traversal.ts lines 162-174 and
244-256 to match `${fullKey}.`; update the guards in both the single-object
walker and bulk walker, preserving recursion only when a schema column exists
beneath the current key.
🪄 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: 2797e1e6-3876-4d2a-a54e-003fc15f5cde

📥 Commits

Reviewing files that changed from the base of the PR and between 2aeba94 and 6fdb7f6.

📒 Files selected for processing (7)
  • .changeset/wasm-inline-model-helpers.md
  • e2e/wasm/roundtrip.test.ts
  • packages/stack/__tests__/wasm-inline-models.test.ts
  • packages/stack/src/encryption/helpers/model-helpers.ts
  • packages/stack/src/encryption/helpers/model-traversal.ts
  • packages/stack/src/wasm-inline.ts
  • skills/stash-encryption/SKILL.md

Comment on lines +124 to +130
expect(ffi.encryptBulk).not.toHaveBeenCalled()
expect(expectData(out)).toEqual({
email: null,
createdOn: undefined,
id: 1,
})
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Vitest toEqual ignore undefined object properties while toStrictEqual checks them?

💡 Result:

Yes, that is correct. In Vitest,.toEqual ignores properties that are set to undefined, whereas.toStrictEqual includes them in its check [1][2]. When you use.toEqual, Vitest asserts that the objects have the same structure and values, but it treats missing keys and keys explicitly set to undefined as equivalent [1][2]. Conversely,.toStrictEqual is more rigorous: it requires that the objects not only have the same structure and values but also the same types and that all properties—including those explicitly set to undefined—are present [1][2]. For example: expect({ a: 1 }).toEqual({ a: 1, b: undefined }) // Passes expect({ a: 1 }).not.toStrictEqual({ a: 1, b: undefined }) // Passes [2]

Citations:


Use toStrictEqual for this undefined-preservation assertion

toEqual treats undefined properties as absent, so this still passes if createdOn is dropped instead of preserved.

💚 Assert undefined preservation strictly
-    expect(ffi.encryptBulk).not.toHaveBeenCalled()
-    expect(expectData(out)).toEqual({
-      email: null,
-      createdOn: undefined,
-      id: 1,
-    })
+    expect(ffi.encryptBulk).not.toHaveBeenCalled()
+    expect(expectData(out)).toStrictEqual({
+      email: null,
+      createdOn: undefined,
+      id: 1,
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(ffi.encryptBulk).not.toHaveBeenCalled()
expect(expectData(out)).toEqual({
email: null,
createdOn: undefined,
id: 1,
})
})
expect(ffi.encryptBulk).not.toHaveBeenCalled()
expect(expectData(out)).toStrictEqual({
email: null,
createdOn: undefined,
id: 1,
})
})
🤖 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-models.test.ts` around lines 124 - 130,
Update the assertion in the test around expectData(out) to use strict equality,
ensuring the createdOn property is explicitly preserved as undefined rather than
omitted. Keep the expected object and surrounding encryption assertions
unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wasm-inline has no model helpers: encryptModel/decryptModel (and their bulk forms) are Node-only

1 participant