Skip to content

fix(stack,bench): wasm-inline DynamoDB v2 reads, and a bench seed that never encrypted (#772 review findings 10, 12)#788

Open
tobyhede wants to merge 2 commits into
remove-v2from
fix/stack-runtime-correctness
Open

fix(stack,bench): wasm-inline DynamoDB v2 reads, and a bench seed that never encrypted (#772 review findings 10, 12)#788
tobyhede wants to merge 2 commits into
remove-v2from
fix/stack-runtime-correctness

Conversation

@tobyhede

Copy link
Copy Markdown
Contributor

Review remediation for #772 — findings 10 and 12.

Finding 10 — the wasm-inline client on the DynamoDB v2 read path

decrypt-model.ts calls client.decryptModel(item) with no table for a v2 table, deliberately: a v2 table means nothing to a v3 client's reconstructor map, and the native clients derive it from the payloads. WasmEncryptionClient cannot — its decrypt requires the table and resolves date fields from a per-table map, so the omitted argument reached requireTable(undefined) and threw TypeError: Cannot read properties of undefined (reading 'tableName') from deep inside the client.

That client ships in this package, is the documented entry for Deno / Workers / Supabase Edge Functions, and satisfies DynamoDBEncryptionClient structurally — so it was accepted with no cast and only failed at the first read.

Now rejected where the message can name the combination, keyed on a declared capability (requiresTableForDecrypt) rather than sniffing arity or constructor name. EQL v3 tables are always passed the table, so they still work — pinned by a test, so this cannot become a blanket rejection of the wasm entry.

Three comments claimed audit metadata is forwarded "regardless of client shape" and that "every client this package ships carries .audit() on decrypt". Neither is true of the wasm client. Corrected, along with a debug message that told the user to build a client with Encryption({ schemas }) — which is exactly what the wasm entry's own factory is.

Finding 12 — the bench seed never encrypted anything

extractEncryptionSchema keys the column map by the Drizzle table's JS property (encText), not the DB column name (enc_text). The v2→v3 port left the seed keyed by DB name, so no field matched: bulkEncryptModels returned every row untouched with no failure, and the remap at seed.ts:66 was moving plaintext.

Not a data-safety bug — verified against a real Postgres with the pinned EQL 3.0.2 bundle: all three domains reject the insert with 23514. It is a bench that cannot run for anyone with credentials.

Nothing caught it because nothing could: tsc --noEmit passes (the row type was hand-written and agreed with itself), harness.test.ts asserts a row count and never that a column is encrypted, and CI runs only the db-only filter, which never seeds.

So the new check is deliberately cheap — __unit__/seed-keys.test.ts compares the row's keys against buildColumnKeyMap()'s under a second vitest config with no globalSetup, so it needs neither a database nor credentials and cannot be skipped for want of either.

Not in this PR

Cut-list items 3 (nested dotted date columns never Date-reconstructed — pre-existing on main) and 8 (dead v2-only PgComposite re-exports on adapter-kit and the top-level entry). Both are real; 8 removes public surface and wants its own review.

Verification

pnpm --filter @cipherstash/stack test → 140 passed, 51 skipped; the 2 failing files are the pre-existing credential-gated live suites (identical count on the base). test:types 99 passed. pnpm --filter @cipherstash/bench build (tsc) clean, test:unit 3 passed. Biome: 0 errors.

tobyhede added 2 commits July 25, 2026 14:37
…base

extractEncryptionSchema keys the encrypted-table column map by the Drizzle
table's JS PROPERTY (encText), not the DB column name (enc_text) — so
buildColumnKeyMap() is {encText: 'enc_text', ...} and resolveEncryptColumnMap
matches models on the property names. The v2 -> v3 port left the seed keyed by
DB name, so no field matched: bulkEncryptModels returned every row untouched,
with no failure, and the insert then carried PLAINTEXT into eql_v3_* columns.
The remap at seed.ts:66 was moving plaintext, not ciphertext, and its comment
had become false.

Not a data-safety bug — verified against a real Postgres with the pinned EQL
3.0.2 bundle, all three domains reject the insert with 23514. It is a bench
that cannot run at all for anyone with credentials.

Nothing caught it because nothing could. `tsc --noEmit` passes: BenchPlaintextRow
was hand-written and agreed with itself. harness.test.ts asserts a row count and
never that a column is encrypted. And CI runs only `test:local db-only`, which
never seeds — every suite that touches the seed needs credentials.

So the check that fails on this is deliberately cheap: __unit__/seed-keys.test.ts
compares the row's keys against buildColumnKeyMap()'s, under a second vitest
config with no globalSetup, so it needs neither a database nor credentials and
cannot be skipped for want of either. BenchPlaintextRow is now the property
space, and the identity remap is gone.
The adapter's v2 read path calls decryptModel(item) with NO table, on purpose:
a v2 table means nothing to a v3 client's reconstructor map, and the native
clients derive the table from the payloads. WasmEncryptionClient cannot — its
decrypt requires the table and resolves date fields from a per-table map, so
the omitted argument reached requireTable(undefined) and threw
`TypeError: Cannot read properties of undefined (reading 'tableName')` from
deep inside the client.

That client ships in this package, is the documented entry for Deno, Workers
and Supabase Edge Functions, and satisfies DynamoDBEncryptionClient
structurally — so encryptedDynamoDB accepted it with no cast and the pairing
only failed at the first read, with a message pointing nowhere near the cause.

Reject it where the message can name the combination. The signal is a declared
capability on the client (`requiresTableForDecrypt`) rather than sniffing arity
or constructor name. v3 tables are always passed the table, so they still work.

Also corrects three comments this package carried claiming audit metadata is
forwarded "regardless of client shape" and that "every client this package
ships carries .audit() on decrypt". The wasm-inline client's decrypt is a plain
async method; its audit metadata is dropped, and the debug message that fires
told the user to build a client with Encryption({ schemas }) — which is exactly
what the wasm entry's own factory is.
@tobyhede
tobyhede requested a review from a team as a code owner July 25, 2026 04:40
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bcf247a

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 Patch
@cipherstash/bench Patch
stash Patch
@cipherstash/prisma-next Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/test-kit Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch
@cipherstash/e2e Patch
@cipherstash/wizard Patch

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 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ca736924-bb3e-4fe1-b579-3415c48b7a89

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stack-runtime-correctness

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.

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve.

Both fixes are correct and contract-preserving. Finding 10 rejects the wasm-inline + EQL-v2 pairing at the call site keyed on the declared requiresTableForDecrypt capability rather than sniffing arity/constructor, and the native/typed path is provably unaffected (they never set that flag — the still-green forwarding tests confirm it). Finding 12 is a real "moving plaintext" bench bug; the fix is right and is now pinned by a credential-free structural test (derived from the table's own buildColumnKeyMap()) wired into tests-bench.yml, which closes the "too expensive to run, so it rotted" gap that caused it. Result/payload shapes, ESM/CJS exports, and the v3 read path are untouched. Changeset present and correctly scoped (bench is private, needs none).

Non-blocking:

  • dynamodb/index.ts guard message is read-flavored ("would fail at the first read") but also fires on encryptModel/bulkEncryptModels. Outcome is correct (wasm-inline is v3-only), but consider softening to "cannot handle legacy EQL v2 tables" so it reads correctly on encrypt calls too.
  • skills/stash-dynamodb/SKILL.md states a blanket "decrypt still reads existing v2 items" — a one-line caveat that the wasm-inline edge client is now the exception would preempt a customer hitting the new throw.

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.

2 participants