Commit d25d100
authored
feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + bulk forms (#767)
* feat(stack): port the model helpers to wasm-inline — encryptModel/decryptModel + 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
* fix(stack): address code-review findings on the wasm-inline model helpers
Recall-mode review (#767) surfaced a cluster of defects in the SHARED model
traversal that this PR newly ships on the wasm surface, plus wasm-specific gaps.
Fixed at the right depth — mostly by rewriting the traversal to be
non-mutating and unambiguous, so the native entry benefits too.
Shared traversal (packages/stack/src/encryption/helpers/model-traversal.ts):
The walk used to shallow-copy the model and `delete` matched fields out of the
copy, which mutated the caller's nested objects (a nested-column decrypt wrote
decrypted PLAINTEXT back into the caller's "encrypted" input), and re-`split`
the dotted key to find the delete target (crash / plaintext leak on a literal
flat dotted key, and `Object.prototype` reach via `__proto__.x`). Rewritten to
build a FRESH passthrough tree that omits operation fields and keeps nulls in
place:
- caller model is never mutated (encrypt and decrypt);
- a literal flat dotted key is normalized to its column path — no crash, no
surviving plaintext;
- a `__proto__`-shaped key is refused by the setNestedValue guard (no
prototype pollution) instead of an unguarded delete-walk;
- a dotted null key no longer materializes a phantom nested object (nulls
ride in the tree; the rebuild stopped re-applying a separate null map);
- an already-encrypted field is passed through, not re-encrypted (was silent
double-encryption for a types.Json column);
- a non-object / null model element is rejected loudly (was silent
char-explosion / {});
- prefix recursion is `.`-boundary correct;
- Date/class instances pass through by reference (a plain-object clone would
rebuild a Date as {}), preserving the exact behaviour the 108 live native
model tests assert.
WASM entry (packages/stack/src/wasm-inline.ts):
- model ops validate the table against the client's schemas and precompute
per-table date paths once at construction (was table.build() per call, and
a foreign table silently accepted) — mirrors the native typed client;
- a null / empty models batch returns { data: [] } like the native helpers;
- Date is normalized to RFC 3339 at EVERY encrypt/query crossing via one
shared toWasmFfiPlaintext (was only the model path; a Date to bulkEncrypt
crossed the serde as {}), and an invalid Date is rejected per field;
- decrypt guards new Date(): an unparseable date value is returned as-is
rather than a silent Invalid Date;
- docblocks corrected (native-parity overclaims, the fabricated
"no { id } envelopes" difference).
Tests / docs:
- 11 new hardening unit tests (no mutation, literal dotted key, double-encrypt
passthrough, non-object element, null argument, unknown table, invalid Date,
__proto__ refusal, value-level Date normalization);
- the bulk-model e2e now asserts the payloads are actually encrypted (a
passthrough no-op would have passed before);
- skill: DB-named-row Date caveat + accurate per-field failure labels;
- changeset: adds the `stash` patch for the skill change.
Deferred: the V3EncryptedModel/V3DecryptedModel type-level lie for dotted-path
columns (they type the nested column as passthrough) — a recursive mapped-type
change to the shared v3 types affecting the native client too; runtime is
correct. Tracked for a follow-up.
Full suite: 1045 pass; test:types clean; code:check clean.
* fix(stack): address PR review on wasm-inline model helpers
Review feedback on #767:
- Test: assert undefined-preservation with toStrictEqual, not toEqual —
toEqual treats an undefined property as absent, so the createdOn case
would pass even if the field were dropped (CodeRabbit).
- Changeset + skill: qualify the one-round-trip claim — non-empty batches
make one ZeroKMS round trip; null/empty or no-schema-field batches
short-circuit and make zero calls (CodeRabbit).
- Traversal: store passthrough fields via defineProperty so a field
literally named __proto__ is kept verbatim instead of being swallowed by
the prototype setter; mirrors the setNestedValue guard. Fidelity only —
never reaches global Object.prototype (review note).
- Tests: cover encryptModel/bulkEncryptModels against an unregistered
table (both call requireTable), and the __proto__ passthrough case
(review note).
- Document setNestedValue's object-intermediate limitation for array-index
paths (review note).1 parent d6bc9e9 commit d25d100
7 files changed
Lines changed: 1421 additions & 361 deletions
File tree
- .changeset
- e2e/wasm
- packages/stack
- __tests__
- src
- encryption/helpers
- skills/stash-encryption
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
224 | 291 | | |
225 | 292 | | |
0 commit comments