feat(adapter-store): validate broadcast payloads before they touch state#128
Conversation
Deploying fs-packages with
|
| Latest commit: |
7e0484e
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e36c21e8.fs-packages.pages.dev |
| Branch Preview URL: | https://fix-broadcast-payload-valida.fs-packages.pages.dev |
This comment has been minimized.
This comment has been minimized.
Goosterhof
left a comment
There was a problem hiding this comment.
⚠️ Concerns
0 blockers · 2 concerns · 0 nits · 1 praise · 1 inline · round 1
This PR swaps the bare internal setById/deleteById handed to broadcast.subscribe for validating wrappers, so a malformed server-pushed payload throws the new BroadcastPayloadError instead of silently corrupting frozen state. The thesis is sound and the test matrix is thorough (one case per validation clause, including the undefined-isolates-object-guard case, plus the well-formed pass-through on both handlers and the errors.spec message assertions). The non-breaking type-surface claim checks out — AdapterStoreBroadcast<T>'s public shape is unchanged; only runtime behaviour on bad payloads shifts.
The body's scope claims are honest: the version cascade matches the diff (fs-adapter-store 0.1.7→0.2.0, fs-cached-adapter-store peer/dev widened to ^0.1.0 || ^0.2.0 + patch to 0.2.1), and the #120 coordination note is declared. No description-vs-diff mismatch.
Praise — the JSDoc + docs framing of broadcast as a closed contract ("don't re-export the handlers onto your own public surface") is the load-bearing call here. The irreducible-leak argument in the body (broadcast's reason to exist is a non-HTTP write path, so validation is the only available lever, unlike extend's closeable leak in #120) is the right distinction and worth documenting where you put it.
Cross-file findings
- Concern —
check(npm audit) required gate is RED, unrelated to this diff.Run npm auditfails on 3 dev-only high advisories:esbuild 0.17.0–0.28.0viavitepress→vite(run 27536816384). I verified this is inherited frommainand not introduced by this PR's hunks, and that the fix (#127,chore(deps): override esbuild to ^0.28.1) is OPEN as of this review — so the body's "clears on rebase once that merges" is accurate. This caps the verdict at COMMENT (a red required check forbids approve-worthy) but is not a blocker against this PR's code. Merge order: land #127 (or #120, per your coordination note) first, rebase, gate goes green.
Findings (detail inline)
- Concern —
packages/adapter-store/src/adapter-store.ts:64—typeof === 'number'admitsNaN/ non-integer ids; the guard's own "can't corrupt state" promise has a residual hole.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
…st a number
Review concern on the broadcast validation: `typeof id === 'number'` admits
`NaN`, `Infinity`, and non-integer floats — all of which still corrupt the
keyspace the guard was built to protect. `onUpdate({id: NaN})` keys the entry
under `state.value[NaN]` → stringifies to "NaN", and `onDelete(NaN)` can never
remove it because `deleteById` filters with `Number(key) !== id` and
`Number("NaN") !== NaN` is always true. A `1.5` / `Infinity` id breaks it the
same way. (JSON transport serializes NaN/Infinity to null — already caught —
so the surviving real-world case is a non-integer float, but the guard's stated
contract shouldn't admit any number that can corrupt state.)
Both guards now use `Number.isInteger(...)`, which also subsumes the
type/null/undefined checks for the id itself. Error wording, JSDoc, and docs
updated "numeric" -> "integer". Test matrix extended: NaN + non-integer id on
both onUpdate and onDelete, each asserting `toThrow(BroadcastPayloadError)`
with no state mutation.
Addresses the round-1 agent review concern on #128.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
f815626 to
b4dacc5
Compare
|
Rebased onto Round-1 concern addressed: the integer-id hole ( |
Review Loop · 9/10 · PASSfs-packages #128 · AC anchor: none (no issue/plan/PR-AC; reviewed against package conventions + consumer contract) · head Tip No findings — clean against the review checklist. Actionmerge-ready |
jasperboerhof
left a comment
There was a problem hiding this comment.
Auto-approved — review-loop verdict PASS, CI green, no human blocker, no open MAJOR+ threads. See the verdict comment + inline notes.
`broadcast` applies server-pushed events to the store without an HTTP
round-trip — that non-HTTP path is the feature. The cost was that the bare
internal `setById`/`deleteById` were handed straight to consumer-authored
`subscribe`, and any payload (the WebSocket gives whatever it gives) landed
in frozen state unvalidated: a malformed event (`{id: undefined}`, a
non-object, a non-numeric id) silently corrupted the store, keyed under
"undefined"/"null".
The store now passes validating wrappers instead of the raw mutators:
- `onUpdate` requires an object with a numeric `id`
- `onDelete` requires a numeric id
- a bad payload throws the new `BroadcastPayloadError` rather than mutating
Two consequences beyond the validation itself: the raw `setById`/`deleteById`
no longer leave the factory (only the wrappers do), and the closed-contract
discipline (don't re-export the handlers onto your own public surface) is now
documented. `AdapterStoreBroadcast<T>`'s public shape is unchanged.
This is the broadcast half of the trust-surface review on #120/#119. Unlike
`extend`, broadcast's leak is irreducible — its purpose *is* a non-HTTP write
path — so the lever here is payload validation, not capability constraint.
Version cascade (pre-1.0): fs-adapter-store 0.1.7 -> 0.2.0 (new public export +
behavior change); fs-cached-adapter-store widens its fs-adapter-store range
^0.1.0 -> ^0.1.0 || ^0.2.0 (peer + dev) and patch-bumps 0.2.0 -> 0.2.1.
Lockfile regenerated; all @script-development/* resolve to the workspace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…st a number
Review concern on the broadcast validation: `typeof id === 'number'` admits
`NaN`, `Infinity`, and non-integer floats — all of which still corrupt the
keyspace the guard was built to protect. `onUpdate({id: NaN})` keys the entry
under `state.value[NaN]` → stringifies to "NaN", and `onDelete(NaN)` can never
remove it because `deleteById` filters with `Number(key) !== id` and
`Number("NaN") !== NaN` is always true. A `1.5` / `Infinity` id breaks it the
same way. (JSON transport serializes NaN/Infinity to null — already caught —
so the surviving real-world case is a non-integer float, but the guard's stated
contract shouldn't admit any number that can corrupt state.)
Both guards now use `Number.isInteger(...)`, which also subsumes the
type/null/undefined checks for the id itself. Error wording, JSDoc, and docs
updated "numeric" -> "integer". Test matrix extended: NaN + non-integer id on
both onUpdate and onDelete, each asserting `toThrow(BroadcastPayloadError)`
with no state mutation.
Addresses the round-1 agent review concern on #128.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
b4dacc5 to
7e0484e
Compare
Goosterhof
left a comment
There was a problem hiding this comment.
✅ Approve-worthy
0 blockers · 0 concerns · 0 nits · 1 praise · 0 inline · round 2
Round 1 (f8156264) raised 2 concerns — both cleared. The typeof === 'number' hole is closed: both guards now use Number.isInteger(...) at adapter-store.ts:67,73, and the test matrix gained {id: NaN} / {id: 1.5} on onUpdate and NaN / 1.5 on onDelete, each asserting toThrow(BroadcastPayloadError) with no state mutation. Error message, JSDoc, and docs were tightened "numeric" → "integer" to match the actual contract. The round-1 audit gate that capped the verdict at COMMENT has cleared — check is green (the rebased HEAD 7e0484e1 carries the esbuild override), and jasperboerhof has approved.
I re-walked the corruption trace against live HEAD: setById keys under [item.id] and deleteById filters via Number(key) !== id (adapter-store.ts:38,45), so a NaN id keyed as "NaN" would have been undeletable. Number.isInteger now rejects it before either mutator runs — the trace is closed.
Beyond-the-diff: the broadcast path has a single producer (createAdapterStoreModule), and the validating wrappers sit at that one chokepoint. No other broadcast consumer exists in the repo, and fs-cached-adapter-store wraps the same factory, so it inherits the validation. The new explicit-validation invariant holds on every consumer, conforming to ADR-0019's explicit-hydration line — untrusted channel input is validated before touching frozen state rather than mass-assigned blind.
Praise — the integer-vs-typeof number distinction is the load-bearing fix: the original guard would have admitted a class of numbers (NaN / Infinity / non-integer floats) that demonstrably corrupt the keyspace, and the new code documents why in the inline comment with the exact Number("NaN") !== NaN mechanism. That's the right depth for a guard whose stated contract is "a malformed broadcast cannot corrupt store state."
No remaining findings. Verdict is approve-worthy; the event is COMMENT only because the author is the war-room runner.
Automated war-room agent review — posted because this PR carries the Agent Review Requested label.
…ast hardening #128 moved broadcast off the raw mutator tier onto validating wrappers, so a malformed server-pushed payload throws BroadcastPayloadError instead of corrupting frozen state. After that, `extend` was the only construction hook still handed the bare setById/deleteById — its own setById(data) path had the identical keyspace-corruption vector ({id: NaN} -> state.value[NaN] -> "NaN" key, undeletable). This brings extend to parity: - extend now receives validating wrappers, not the raw mutators. setById requires an object with an integer id; deleteById requires an integer id. A malformed payload throws the new ExtendPayloadError rather than mutating. - The integer-id check is lifted into a shared isStorableItem predicate used by both the broadcast wrappers (DRY with #128's inline check) and the new extend wrappers. Raw setById/deleteById never leave the factory through either door. - Only `adapter` (trusted, package-internal) still gets the raw tier. Trust-model docs corrected: extend is no longer "the raw tier, same as broadcast" — it's validating wrappers like broadcast. The residual asymmetry stays documented (broadcast is closed; extend is open, so a re-exported wrapper still permits non-HTTP writes of *well-formed* data — validation stops corruption, not deliberate re-export). Tests: ExtendPayloadError unit coverage + extend-integration matrix asserting setById/deleteById reject non-object / missing-id / non-numeric / NaN / non-integer payloads without corrupting state, and that well-formed payloads still pass through both wrappers. Version cascade (rebased onto #128, which took fs-adapter-store 0.2.0): - fs-adapter-store 0.2.0 -> 0.3.0 (new ExtendPayloadError export + behaviour change on malformed extend writes). - fs-cached-adapter-store widens fs-adapter-store peer/dev range to add `|| ^0.3.0` and patch-bumps 0.2.1 -> 0.2.2. Lockfile regenerated; workspace links intact, 0 registry copies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tators (#119) Implements the #119 "Option 2, hardened" decision. `extend` no longer receives the raw `{setById, deleteById}` tier; it receives `ExtendCapabilities` whose sole ingest path is `retrieveInto(endpoint, options?)` — a GET that upserts the validated response into the store. Why: extend's return value IS the public store surface. Handed the raw setById, a consumer could re-export a non-HTTP write path — `extend: (cap) => ({save: cap.setById})`, or a `(item) => cap.setById(item)` wrapper that no runtime guard can distinguish from a legitimate fetch-then-set. Payload validation (the prior approach) closes corruption but does nothing about a deliberate non-HTTP write of well-formed data. Routing the only ingest through retrieveInto makes that structurally unexpressible, not guarded — which matters for consumer territories under ISO 27001 / NEN 7510 where the HTTP path carries authz/audit. The asymmetry with broadcast (per #128): broadcast's non-HTTP write path is irreducible (it is the feature), so it can only be validated; extend's isn't, so it is designed out. Carries over from the prior approach: the `isStorableItem` predicate (now validates retrieveInto's response, still shared with broadcast), `ExtendPayloadError` (reworded for the retrieveInto context), the collision guard, version cascade, and test scaffold. retrieveInto upserts a single item or an array, validating each. - types.ts: `ExtendCapabilities` type; `extend?: (cap) => X`. Raw mutator tier gone from the extend surface (still used by adapter/broadcast). Options type derived via `Parameters<HttpService['getRequest']>[1]` — no direct axios import. - New test asserts `setById`/`deleteById` are unreachable from the extend argument — the structural-impossibility guarantee, not just a behavioural one. Version unchanged at 0.3.0 (already breaking); cascade unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ast hardening #128 moved broadcast off the raw mutator tier onto validating wrappers, so a malformed server-pushed payload throws BroadcastPayloadError instead of corrupting frozen state. After that, `extend` was the only construction hook still handed the bare setById/deleteById — its own setById(data) path had the identical keyspace-corruption vector ({id: NaN} -> state.value[NaN] -> "NaN" key, undeletable). This brings extend to parity: - extend now receives validating wrappers, not the raw mutators. setById requires an object with an integer id; deleteById requires an integer id. A malformed payload throws the new ExtendPayloadError rather than mutating. - The integer-id check is lifted into a shared isStorableItem predicate used by both the broadcast wrappers (DRY with #128's inline check) and the new extend wrappers. Raw setById/deleteById never leave the factory through either door. - Only `adapter` (trusted, package-internal) still gets the raw tier. Trust-model docs corrected: extend is no longer "the raw tier, same as broadcast" — it's validating wrappers like broadcast. The residual asymmetry stays documented (broadcast is closed; extend is open, so a re-exported wrapper still permits non-HTTP writes of *well-formed* data — validation stops corruption, not deliberate re-export). Tests: ExtendPayloadError unit coverage + extend-integration matrix asserting setById/deleteById reject non-object / missing-id / non-numeric / NaN / non-integer payloads without corrupting state, and that well-formed payloads still pass through both wrappers. Version cascade (rebased onto #128, which took fs-adapter-store 0.2.0): - fs-adapter-store 0.2.0 -> 0.3.0 (new ExtendPayloadError export + behaviour change on malformed extend writes). - fs-cached-adapter-store widens fs-adapter-store peer/dev range to add `|| ^0.3.0` and patch-bumps 0.2.1 -> 0.2.2. Lockfile regenerated; workspace links intact, 0 registry copies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tators (#119) Implements the #119 "Option 2, hardened" decision. `extend` no longer receives the raw `{setById, deleteById}` tier; it receives `ExtendCapabilities` whose sole ingest path is `retrieveInto(endpoint, options?)` — a GET that upserts the validated response into the store. Why: extend's return value IS the public store surface. Handed the raw setById, a consumer could re-export a non-HTTP write path — `extend: (cap) => ({save: cap.setById})`, or a `(item) => cap.setById(item)` wrapper that no runtime guard can distinguish from a legitimate fetch-then-set. Payload validation (the prior approach) closes corruption but does nothing about a deliberate non-HTTP write of well-formed data. Routing the only ingest through retrieveInto makes that structurally unexpressible, not guarded — which matters for consumer territories under ISO 27001 / NEN 7510 where the HTTP path carries authz/audit. The asymmetry with broadcast (per #128): broadcast's non-HTTP write path is irreducible (it is the feature), so it can only be validated; extend's isn't, so it is designed out. Carries over from the prior approach: the `isStorableItem` predicate (now validates retrieveInto's response, still shared with broadcast), `ExtendPayloadError` (reworded for the retrieveInto context), the collision guard, version cascade, and test scaffold. retrieveInto upserts a single item or an array, validating each. - types.ts: `ExtendCapabilities` type; `extend?: (cap) => X`. Raw mutator tier gone from the extend surface (still used by adapter/broadcast). Options type derived via `Parameters<HttpService['getRequest']>[1]` — no direct axios import. - New test asserts `setById`/`deleteById` are unreachable from the extend argument — the structural-impossibility guarantee, not just a behavioural one. Version unchanged at 0.3.0 (already breaking); cascade unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The
broadcasthalf of the trust-surface review on #120 / #119.Problem
broadcastapplies server-pushed events to the store without an HTTP round-trip — that non-HTTP path is the whole point. But the bare internalsetById/deleteByIdwere handed straight to the consumer-authoredsubscribe, and whatever the channel emits landed in frozen state unvalidated:A malformed event —
{id: undefined}, a non-object, a non-numeric id — silently corrupts the store (entry keyed under"undefined"/"null"). For care-data / compliance territories, untrusted-channel input reaching state unvalidated is the concern.Change
The store passes validating wrappers instead of the raw mutators:
onUpdaterequires an object with a numericidonDeleterequires a numeric idBroadcastPayloadErrorrather than mutatingTwo consequences beyond validation: the raw
setById/deleteByIdno longer leave the factory (only wrappers do), and the closed-contract discipline (don't re-export the handlers onto your own public surface) is now documented in the type JSDoc and package docs.AdapterStoreBroadcast<T>'s public shape is unchanged — this is a non-breaking type surface; only the runtime behaviour on malformed payloads changes (was: silent corruption; now: throw).Why not the same fix as
extend(#120)?extend's leak is closeable becauseextendmethods should fetch — route them through HTTP. broadcast's leak is irreducible: its reason to exist is a non-HTTP write path, so no constraint removes the bypass capability without removing the feature. The available lever is payload validation, not capability constraint. (That asymmetry is itself whyextendshould be constrained where broadcast can't be.)Design choice
On a malformed payload the handler throws (Commander's call) — a missing/non-numeric id is a contract violation, not a transient, and the item can't be stored correctly anyway. Consumers wiring a socket should guard their handler. (Alternative considered: skip +
console.warnfor realtime resilience — rejected as it silently drops malformed updates.)Version cascade (pre-1.0)
fs-adapter-store0.1.7→0.2.0(new public exportBroadcastPayloadError+ behaviour change).fs-cached-adapter-storewidens@script-development/fs-adapter-store^0.1.0→^0.1.0 || ^0.2.0(peer + dev) and patch-bumps0.2.0→0.2.1.node_modules/@script-development/*resolves to the workspace, 0 registry copies.Verification (local; vitest/coverage/mutation are CI-gated on this box)
main's 3 dev-only highs (esbuild via vitepress) — fixed independently by #127; clears on rebase once that mergesNew tests:
BroadcastPayloadErrorunit coverage, plus broadcast-integration tests assertingonUpdate/onDeletereject malformed payloads (one case per validation clause, incl.undefinedto isolate the object-type guard for mutation testing) without corrupting state, and that well-formed payloads still pass through the wrapper.🤖 Generated with Claude Code