Skip to content

feat(adapter-store): validate broadcast payloads before they touch state#128

Merged
Goosterhof merged 2 commits into
mainfrom
fix/broadcast-payload-validation
Jun 15, 2026
Merged

feat(adapter-store): validate broadcast payloads before they touch state#128
Goosterhof merged 2 commits into
mainfrom
fix/broadcast-payload-validation

Conversation

@Goosterhof

Copy link
Copy Markdown
Contributor

The broadcast half of the trust-surface review on #120 / #119.

Problem

broadcast applies server-pushed events to the store without an HTTP round-trip — that non-HTTP path is the whole point. But the bare internal setById/deleteById were handed straight to the consumer-authored subscribe, and whatever the channel emits landed in frozen state unvalidated:

broadcast?.subscribe({onUpdate: setById, onDelete: deleteById});
// setById: state.value = {...state.value, [item.id]: Object.freeze(item)}  — no checks

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:

  • 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 validation: the raw setById/deleteById no 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 because extend methods 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 why extend should 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.warn for realtime resilience — rejected as it silently drops malformed updates.)

Version cascade (pre-1.0)

  • fs-adapter-store 0.1.70.2.0 (new public export BroadcastPayloadError + behaviour change).
  • fs-cached-adapter-store widens @script-development/fs-adapter-store ^0.1.0^0.1.0 || ^0.2.0 (peer + dev) and patch-bumps 0.2.00.2.1.
  • Lockfile regenerated; every node_modules/@script-development/* resolves to the workspace, 0 registry copies.

Coordination with #120: both PRs target fs-adapter-store 0.2.0. Whichever merges first wins; the other rebases (the extend feature would then become 0.3.0).

Verification (local; vitest/coverage/mutation are CI-gated on this box)

Gate Result
build (tsdown) complete
typecheck (tsc) clean
lint (oxlint) clean
format:check (oxfmt) clean
audit ⚠️ inherits main's 3 dev-only highs (esbuild via vitepress) — fixed independently by #127; clears on rebase once that merges

New tests: BroadcastPayloadError unit coverage, plus broadcast-integration tests asserting onUpdate/onDelete reject malformed payloads (one case per validation clause, incl. undefined to 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

@Goosterhof Goosterhof requested a review from a team as a code owner June 15, 2026 09:26
@Goosterhof Goosterhof added the Agent Review Requested Requesting review of specialized AI review agents. label Jun 15, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 15, 2026

Copy link
Copy Markdown

Deploying fs-packages with  Cloudflare Pages  Cloudflare Pages

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

View logs

@jasperboerhof

This comment has been minimized.

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

⚠️ 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.70.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 audit fails on 3 dev-only high advisories: esbuild 0.17.0–0.28.0 via vitepressvite (run 27536816384). I verified this is inherited from main and 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:64typeof === 'number' admits NaN / 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.

Comment thread packages/adapter-store/src/adapter-store.ts Outdated
Goosterhof added a commit that referenced this pull request Jun 15, 2026
…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>
@Goosterhof Goosterhof force-pushed the fix/broadcast-payload-validation branch from f815626 to b4dacc5 Compare June 15, 2026 10:07
@Goosterhof

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #126 (the esbuild override) merged — the audit gate that was inherited-red is now green, and the lockfile carries both the esbuild override and this PR's version bumps (workspace links intact, 0 registry copies).

Round-1 concern addressed: the integer-id hole (NaN/Infinity/non-integer float passing typeof === 'number' yet corrupting the keyspace) is closed in b4dacc5 via Number.isInteger, with tests + wording updated. Both gates green locally (build, typecheck, lint, format).

@jasperboerhof

Copy link
Copy Markdown
Contributor

Review Loop · 9/10 · PASS

fs-packages #128 · AC anchor: none (no issue/plan/PR-AC; reviewed against package conventions + consumer contract) · head b4dacc5b14

Tip

No findings — clean against the review checklist.

Action

merge-ready

@jasperboerhof jasperboerhof 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.

Auto-approved — review-loop verdict PASS, CI green, no human blocker, no open MAJOR+ threads. See the verdict comment + inline notes.

Goosterhof and others added 2 commits June 15, 2026 12:34
`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>
@Goosterhof Goosterhof force-pushed the fix/broadcast-payload-validation branch from b4dacc5 to 7e0484e Compare June 15, 2026 10:35
@Goosterhof Goosterhof enabled auto-merge June 15, 2026 10:36
@Goosterhof Goosterhof merged commit 9aae30a into main Jun 15, 2026
2 checks passed
@Goosterhof Goosterhof deleted the fix/broadcast-payload-validation branch June 15, 2026 10:40

@Goosterhof Goosterhof left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

✅ 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.

jasperboerhof added a commit that referenced this pull request Jun 15, 2026
…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>
jasperboerhof added a commit that referenced this pull request Jun 15, 2026
…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>
Goosterhof pushed a commit that referenced this pull request Jun 15, 2026
…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>
Goosterhof pushed a commit that referenced this pull request Jun 15, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Agent Review Requested Requesting review of specialized AI review agents.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants