Skip to content

Commit 966978a

Browse files
committed
fix(stack): stop the wasm-inline client failing every DynamoDB v3 write (#788 review)
Addresses the three review findings on #788, plus a real defect found while verifying the second one. The wasm+v2 guard message is now operation-neutral. `assertClientTableVersionMatch` runs on all four operations, so a plain-JS caller reaching `encryptModel` with a v2 table got "cannot read legacy EQL v2 items ... would fail at the first read" — naming an operation that never ran. The pairing is wrong in both directions anyway (`Encryption()` on that entry rejects a v2 schema), so the message now says so instead of describing a read. Verifying that finding surfaced the larger one: `encryptModel` / `bulkEncryptModels` chained `.audit()` onto the client's result unconditionally. The native clients return a thenable operation carrying it; the wasm-inline client's encrypt returns a bare `Promise<WasmResult>` and has no `.audit()` anywhere. So EVERY EQL v3 write through this adapter on that entry failed with `client.encryptModel(...).audit is not a function` — surfaced as a `DYNAMODB_ENCRYPTION_ERROR`, indistinguishable from a genuine encryption fault. This PR's own changeset claimed the opposite ("EQL v3 tables are unaffected ... the wasm-inline client keeps working there"); it was true of decrypt only. `resolveEncryptResult` mirrors the existing `resolveDecryptResult`: tolerate the bare promise, drop audit metadata observably rather than crashing, and fail closed on a malformed result so an unencrypted item can never pass through as a success. The changeset and the `types.ts` docblock are corrected to match. Regression tests are credential-free. The chainable half matters most — the native clients' encrypt audit trail had no coverage outside a live-ZeroKMS suite, so nothing would have caught breaking it. skills/stash-dynamodb documented v2 decrypt as unconditionally supported and never mentioned that wasm-inline refuses v2 tables, on the documented entry for the runtimes most often paired with DynamoDB. It also claimed audit metadata forwards "regardless of client shape" — the exact phrase this PR removed from the source as untrue. Both corrected, plus the wasm-inline row in skills/stash-encryption, which never said the entry is v3-only. packages/bench/package.json is back to 2-space indent, keeping only the substantive `test:unit` addition: 53 changed lines down to 1. The repo's Biome config is `indentStyle: "space"` and the file was spaces on main, so the churn was neither required nor conformant — Biome ignores `**/package.json` entirely, verified by direct invocation.
1 parent a9d430b commit 966978a

12 files changed

Lines changed: 391 additions & 58 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
The `stash-dynamodb` and `stash-encryption` skills documented EQL v2 decrypt as
6+
unconditionally supported, without noting that `@cipherstash/stack/wasm-inline`
7+
is EQL v3 only. Since that is the documented entry for Deno, Bun, Cloudflare
8+
Workers and Supabase Edge Functions — runtimes commonly paired with DynamoDB — a
9+
reader following the skill hit a runtime refusal with no forewarning. Both skills
10+
now state that legacy v2 items are readable on the native `@cipherstash/stack`
11+
entry only.
12+
13+
The `stash-dynamodb` API reference also claimed audit metadata forwards to
14+
ZeroKMS "regardless of client shape". It does not: the wasm-inline client's
15+
operations return a plain promise with no `.audit()`, so its audit metadata is
16+
dropped (logged at debug). The reference now says so, and says the operation
17+
still succeeds.

‎.changeset/dynamodb-wasm-v2-read.md‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,20 @@ Workers and Supabase Edge Functions, which satisfies the adapter's client type
1717
structurally and so was accepted with no cast.
1818

1919
The pairing is now rejected at the call site, with a message naming both the
20-
combination and the fix. EQL v3 tables are unaffected: they are always passed
21-
the table, so the wasm-inline client keeps working there.
20+
combination and the fix. The message is operation-neutral: the guard runs on all
21+
four operations, so a plain-JS caller reaching the write path with a v2 table
22+
gets a message that does not claim a read it never attempted.
23+
24+
`encryptModel` / `bulkEncryptModels` now also tolerate a client whose encrypt
25+
returns a plain promise. They chained `.audit()` onto the result
26+
unconditionally, which the wasm-inline client does not carry — so **every EQL v3
27+
write through this adapter on that entry** failed with
28+
`client.encryptModel(...).audit is not a function`, surfaced as a
29+
`DYNAMODB_ENCRYPTION_ERROR` and so indistinguishable from a real encryption
30+
fault. The read path already handled this; the write path now matches, via the
31+
same fail-closed check that rejects a malformed result rather than passing an
32+
unencrypted item through as a success. Audit metadata still has nowhere to go on
33+
that client, so it is dropped and logged at debug — the write itself succeeds.
2234

2335
Three comments in this package claimed audit metadata was forwarded "regardless
2436
of client shape" and that "every client this package ships carries `.audit()` on

‎packages/bench/package.json‎

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
{
2-
"name": "@cipherstash/bench",
3-
"version": "0.0.5-rc.4",
4-
"private": true,
5-
"description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).",
6-
"type": "module",
7-
"scripts": {
8-
"build": "tsc --noEmit",
9-
"test:unit": "vitest run --config vitest.unit.config.ts",
10-
"db:setup": "tsx src/cli/setup.ts",
11-
"db:reset": "tsx src/cli/reset.ts",
12-
"test:local": "vitest run",
13-
"bench:local": "vitest bench --run"
14-
},
15-
"dependencies": {
16-
"@cipherstash/stack": "workspace:*",
17-
"@cipherstash/stack-drizzle": "workspace:*",
18-
"drizzle-orm": "0.45.2",
19-
"pg": "^8.22.0"
20-
},
21-
"devDependencies": {
22-
"@cipherstash/test-kit": "workspace:*",
23-
"@types/node": "^22.20.1",
24-
"@types/pg": "^8.20.0",
25-
"tsx": "catalog:repo",
26-
"typescript": "catalog:repo",
27-
"vitest": "catalog:repo"
28-
}
2+
"name": "@cipherstash/bench",
3+
"version": "0.0.5-rc.4",
4+
"private": true,
5+
"description": "Performance / index-engagement benchmarks for stack integrations (Drizzle, encryptedSupabase, Prisma).",
6+
"type": "module",
7+
"scripts": {
8+
"build": "tsc --noEmit",
9+
"test:unit": "vitest run --config vitest.unit.config.ts",
10+
"db:setup": "tsx src/cli/setup.ts",
11+
"db:reset": "tsx src/cli/reset.ts",
12+
"test:local": "vitest run",
13+
"bench:local": "vitest bench --run"
14+
},
15+
"dependencies": {
16+
"@cipherstash/stack": "workspace:*",
17+
"@cipherstash/stack-drizzle": "workspace:*",
18+
"drizzle-orm": "0.45.2",
19+
"pg": "^8.22.0"
20+
},
21+
"devDependencies": {
22+
"@cipherstash/test-kit": "workspace:*",
23+
"@types/node": "^22.20.1",
24+
"@types/pg": "^8.20.0",
25+
"tsx": "catalog:repo",
26+
"typescript": "catalog:repo",
27+
"vitest": "catalog:repo"
28+
}
2929
}

‎packages/stack/__tests__/dynamodb/resolve-decrypt.test.ts‎

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
/**
2-
* Pure unit tests for the two client-shape helpers behind the DynamoDB adapter's
3-
* decrypt path. Both shipped clients — nominal `EncryptionClient` and the typed
4-
* EQL v3 client (whose decrypt returns a `MappedDecryptOperation`) — are
5-
* chainable and carry `.audit()`; the bare-promise branch remains only for a
6-
* non-conforming custom client. Every branch was previously reachable only
7-
* through a live ZeroKMS decrypt; these move that assurance onto the pure CI
8-
* lane. No credentials, no network.
2+
* Pure unit tests for the client-shape helpers behind the DynamoDB adapter's
3+
* decrypt AND encrypt paths.
4+
*
5+
* On decrypt, both native clients — nominal `EncryptionClient` and the typed EQL
6+
* v3 client (whose decrypt returns a `MappedDecryptOperation`) — are chainable
7+
* and carry `.audit()`; the bare-promise branch is taken by the wasm-inline
8+
* client and by a non-conforming custom one.
9+
*
10+
* The same split exists on encrypt, and only the decrypt half handled it: the
11+
* encrypt operations chained `.audit()` unconditionally, so the wasm-inline
12+
* client failed every write (#788 review follow-up). `resolveEncryptResult` is
13+
* the mirror, and the chainable half of its coverage matters most — the native
14+
* clients' encrypt audit trail has no other credential-free test.
15+
*
16+
* Every branch was previously reachable only through live ZeroKMS; these move
17+
* that assurance onto the pure CI lane. No credentials, no network.
918
*/
1019
import type { Result } from '@byteslice/result'
1120
import { afterEach, describe, expect, it, vi } from 'vitest'
12-
import { resolveDecryptResult, throwPreservingCode } from '@/dynamodb/helpers'
21+
import {
22+
resolveDecryptResult,
23+
resolveEncryptResult,
24+
throwPreservingCode,
25+
} from '@/dynamodb/helpers'
1326
import { EncryptionOperation } from '@/encryption/operations/base-operation'
1427
import { MappedDecryptOperation } from '@/encryption/operations/mapped-decrypt'
1528
import { type EncryptionError, EncryptionErrorTypes } from '@/errors'
@@ -178,6 +191,97 @@ describe('resolveDecryptResult', () => {
178191
})
179192
})
180193

194+
/**
195+
* The write-path mirror (#788 review follow-up). The encrypt operations used
196+
* to chain `.audit()` unconditionally, so a client returning a bare promise
197+
* (the wasm-inline entry) failed every encrypt with
198+
* `.audit is not a function`. These pin BOTH directions: the chainable path
199+
* must still forward metadata — the native clients' audit trail depends on it,
200+
* and its only other coverage is a live-credential suite — and the bare path
201+
* must resolve instead of throwing.
202+
*/
203+
describe('resolveEncryptResult', () => {
204+
it('chains .audit and forwards metadata when present (native clients)', async () => {
205+
let seen: unknown
206+
let calls = 0
207+
const operation = {
208+
audit(config: { metadata?: Record<string, unknown> }) {
209+
calls += 1
210+
seen = config.metadata
211+
return Promise.resolve({ data: { encrypted: true } })
212+
},
213+
}
214+
215+
const result = await resolveEncryptResult(
216+
operation,
217+
{ metadata: { sub: 'u1' } },
218+
'encryptModel',
219+
)
220+
221+
expect(result).toEqual({ data: { encrypted: true } })
222+
expect(seen).toEqual({ sub: 'u1' })
223+
expect(calls).toBe(1)
224+
})
225+
226+
it('awaits a bare promise instead of throwing (wasm-inline encrypt)', async () => {
227+
const result = await resolveEncryptResult(
228+
Promise.resolve({ data: { encrypted: true } }),
229+
{ metadata: { dropped: true } },
230+
'encryptModel',
231+
)
232+
233+
expect(result).toEqual({ data: { encrypted: true } })
234+
})
235+
236+
it('propagates a failure result unchanged', async () => {
237+
const failure = { failure: { message: 'boom', code: 'X' } }
238+
239+
await expect(
240+
resolveEncryptResult(Promise.resolve(failure), {}, 'bulkEncryptModels'),
241+
).resolves.toEqual(failure)
242+
})
243+
244+
it('returns a failure — not a fake success — for a malformed result', async () => {
245+
// Fail closed. A bare value cast straight through would hand the caller an
246+
// "encrypted" item that was never encrypted.
247+
for (const malformed of [{}, 42, undefined]) {
248+
const result = await resolveEncryptResult(
249+
Promise.resolve(malformed),
250+
{},
251+
'encryptModel',
252+
)
253+
254+
expect(result.failure).toBeDefined()
255+
expect(result.data).toBeUndefined()
256+
}
257+
})
258+
259+
it('names the operation in the dropped-metadata log, and stays silent without metadata', async () => {
260+
const spy = vi.spyOn(logger, 'debug').mockImplementation(() => {})
261+
262+
try {
263+
await resolveEncryptResult(
264+
Promise.resolve({ data: {} }),
265+
{ metadata: { m: 1 } },
266+
'bulkEncryptModels',
267+
)
268+
expect(spy).toHaveBeenCalledWith(
269+
expect.stringContaining('bulkEncryptModels audit metadata ignored'),
270+
)
271+
272+
spy.mockClear()
273+
await resolveEncryptResult(
274+
Promise.resolve({ data: {} }),
275+
{},
276+
'encryptModel',
277+
)
278+
expect(spy).not.toHaveBeenCalled()
279+
} finally {
280+
spy.mockRestore()
281+
}
282+
})
283+
})
284+
181285
describe('throwPreservingCode', () => {
182286
it('rethrows as an Error carrying the FFI code', () => {
183287
try {

‎packages/stack/__tests__/dynamodb/v2-table-forwarding.test.ts‎

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,13 @@ describe('a client whose decrypt requires the table', () => {
133133
"Cannot read properties of undefined (reading 'tableName')",
134134
)
135135
}
136-
return Promise.resolve({ data: {} })
136+
// A bare promise — NOT a thenable operation with `.audit()`. That is
137+
// the whole point of this stub: it is the shape `WasmEncryptionClient`
138+
// returns from `wasmResult`. The bulk methods resolve to an array,
139+
// index-aligned with their input, as the real client does.
140+
return Promise.resolve(
141+
method.startsWith('bulk') ? { data: [{}] } : { data: {} },
142+
)
137143
}
138144
const client = {
139145
requiresTableForDecrypt: true,
@@ -156,7 +162,7 @@ describe('a client whose decrypt requires the table', () => {
156162
// Synchronous: the guard runs when the operation is built, so the failure
157163
// lands at the call site rather than as a rejected promise later.
158164
expect(() => dynamo.decryptModel({ pk: 'a' }, usersV2)).toThrow(
159-
/wasm-inline client cannot read legacy EQL v2 items/,
165+
/wasm-inline client cannot be paired with the legacy EQL v2 table/,
160166
)
161167
// Refused before the client is touched, so the user never sees the
162168
// TypeError about `tableName`.
@@ -168,10 +174,41 @@ describe('a client whose decrypt requires the table', () => {
168174
const dynamo = encryptedDynamoDB({ encryptionClient: client as never })
169175

170176
expect(() => dynamo.bulkDecryptModels([{ pk: 'a' }], usersV2)).toThrow(
171-
/wasm-inline client cannot read legacy EQL v2 items/,
177+
/wasm-inline client cannot be paired with the legacy EQL v2 table/,
172178
)
173179
})
174180

181+
/**
182+
* #788 review, minor finding.
183+
*
184+
* The guard runs on all four operations, not just the two read ones, so a
185+
* plain-JS caller reaching the write path with a v2 table hits the SAME
186+
* message. It must therefore not be phrased for reads only — "would fail at
187+
* the first read" names an operation that never ran.
188+
*
189+
* Typed callers cannot get here (the write overloads are `AnyV3Table`-only,
190+
* pinned by `client-compat.test-d.ts`), so this is about the message a JS
191+
* caller or a cast lands on, not about reachable behaviour changing.
192+
*/
193+
it('is refused on the v2 WRITE path, with a message that does not claim a read', () => {
194+
const { calls, client } = wasmShapedClient([])
195+
const dynamo = encryptedDynamoDB({ encryptionClient: client as never })
196+
197+
for (const call of [
198+
() => dynamo.encryptModel({ pk: 'a' } as never, usersV2 as never),
199+
() => dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV2 as never),
200+
]) {
201+
expect(call).toThrow(
202+
/wasm-inline client cannot be paired with the legacy EQL v2 table/,
203+
)
204+
// The read-path phrasing must not survive on a write.
205+
expect(call).not.toThrow(/would fail at the first read/)
206+
expect(call).not.toThrow(/cannot read legacy EQL v2 items/)
207+
}
208+
209+
expect(calls).toHaveLength(0)
210+
})
211+
175212
// v3 tables ARE forwarded the table, so this client works there — the guard
176213
// must not turn into a blanket rejection of the wasm entry.
177214
it('is accepted for an EQL v3 table, which is always given the table', async () => {
@@ -183,4 +220,70 @@ describe('a client whose decrypt requires the table', () => {
183220
expect(calls).toHaveLength(1)
184221
expect(calls[0]?.argCount).toBe(2)
185222
})
223+
224+
/**
225+
* #788 review follow-up: the same "v3 tables are unaffected" promise, on the
226+
* WRITE path.
227+
*
228+
* The encrypt operations chained `.audit()` onto the client's result
229+
* unconditionally. The native clients return a thenable operation carrying
230+
* it; `WasmEncryptionClient.encryptModel` returns a plain
231+
* `Promise<WasmResult>` from `wasmResult` (it has no `.audit()` anywhere),
232+
* so every v3 encrypt through this adapter died with
233+
* `client.encryptModel(...).audit is not a function` — surfaced as a
234+
* `DYNAMODB_ENCRYPTION_ERROR` failure, not a crash, so it read as a genuine
235+
* encryption fault. The decrypt path already tolerates the bare promise via
236+
* `resolveDecryptResult`; the write path must match.
237+
*/
238+
it('encrypts an EQL v3 table even though its encrypt returns a bare promise', async () => {
239+
const { calls, client } = wasmShapedClient(['users_v3'])
240+
const dynamo = encryptedDynamoDB({ encryptionClient: client as never })
241+
242+
const single = await dynamo.encryptModel({ pk: 'a' } as never, usersV3)
243+
expect(single.failure).toBeUndefined()
244+
245+
const bulk = await dynamo.bulkEncryptModels([{ pk: 'a' }] as never, usersV3)
246+
expect(bulk.failure).toBeUndefined()
247+
248+
expect(calls.map((c) => c.method)).toEqual([
249+
'encryptModel',
250+
'bulkEncryptModels',
251+
])
252+
})
253+
254+
/**
255+
* Audit metadata has nowhere to go on this client shape, so it is dropped —
256+
* but the encrypt must still succeed rather than failing the whole write.
257+
* Mirrors the decrypt path's documented behaviour.
258+
*/
259+
it('drops audit metadata on that client rather than failing the encrypt', async () => {
260+
const { client } = wasmShapedClient(['users_v3'])
261+
const dynamo = encryptedDynamoDB({ encryptionClient: client as never })
262+
263+
const result = await dynamo
264+
.encryptModel({ pk: 'a' } as never, usersV3)
265+
.audit({ metadata: { requestId: 'r-1' } })
266+
267+
expect(result.failure).toBeUndefined()
268+
})
269+
270+
/**
271+
* The tolerance must not swallow a malformed result into a fake success —
272+
* the same guard `resolveDecryptResult` applies on read.
273+
*/
274+
it('rejects a bare encrypt result that is not { data } or { failure }', async () => {
275+
const client = {
276+
requiresTableForDecrypt: true,
277+
getEncryptConfig: () => ({ v: 1, tables: { users_v3: {} } }),
278+
encryptModel: () => Promise.resolve('not-a-result'),
279+
bulkEncryptModels: () => Promise.resolve('not-a-result'),
280+
decryptModel: () => Promise.resolve({ data: {} }),
281+
bulkDecryptModels: () => Promise.resolve({ data: {} }),
282+
}
283+
const dynamo = encryptedDynamoDB({ encryptionClient: client as never })
284+
285+
const result = await dynamo.encryptModel({ pk: 'a' } as never, usersV3)
286+
287+
expect(result.failure?.message).toMatch(/malformed result/)
288+
})
186289
})

0 commit comments

Comments
 (0)