Skip to content

id(web): collab handleMessage trusts MessagePack frame shape — a null frame throws out of onmessage, and a field-less INIT silently initializes an empty editor at an undefined version #2824

Closed

Description

@dezren39

id(web): collab handleMessage trusts MessagePack frame shape — a null frame throws out of onmessage, and a field-less INIT silently initializes an empty editor at an undefined version

Repo: developing-today/code · Main: df9381ff · File: pkgs/id/web/src/collab.ts — handleMessage at lines 198-206, INIT case at 210-265

Summary

The collab WebSocket handler guards only the MessagePack decode step:

try {
  msg = unpackr.unpack(new Uint8Array(data)) as unknown[];
} catch (err) {
  console.error("[collab] Failed to decode MessagePack message:", err);
  return;
}
const msgType = msg[0] as number;   // :206 — outside the try, shape never checked

Two verified failure modes for frames that decode fine but are not the expected array shape:

  1. A null frame throws out of the handler. MessagePack 0xc0 unpacks to null; msg[0] then throws TypeError: Cannot read properties of null (reading '0') at collab.ts:206, escaping onmessage as an uncaught error. Any non-array unpack result that cannot be indexed the same way is one peer bug away from the same path; the decode-error path right above shows the intended behaviour (log + drop), the shape path just never got it.
  2. A field-less INIT is silently accepted as an empty document. case MSG.INIT reads version = msg[1], doc = msg[2], mode = msg[3] || "raw" with no presence/type check (:212-214) and calls initEditor(container, doc, version, mode, …) (:221). initEditor (editor.ts) treats a missing/non-object doc as "create an empty document" — so a truncated or buggy INIT presents the user with a blank editor for a document that is not blank, running the ProseMirror collab plugin at version undefined, and fires onEditorReady (which enables the save button and wires AutoSaveManager in main.ts). Nothing logs an error, no resync is requested, and the status reads "connected". Code-read consequence: an edit + save from that phantom editor posts the near-empty doc to /api/save under the real file's hash — the server archives the previous version, but the working document has been replaced by the blank one the client fabricated.

Evidence (verified this run, real module)

Laptop probe (~/git/fleet/probe-2026-10-30-laneA-collabframe/; collab.ts md5 fff3e0f4bdf0165f40a56f683fa03434 identical on sandbox main df9381ff and the laptop; vitest + happy-dom, FakeSocket global + the real initCollab, frames packed with the repo's own msgpackr):

Frame delivered Result
null (msgpack 0xc0) TypeError: Cannot read properties of null (reading '0') thrown out of onmessage
42, "hello", {}, [999, 1] (controls) no throw — dropped / unknown-type warning, as expected
[0] — INIT, no version/doc/mode no throw, but onEditorReady fired, editor initialized, mode raw — an empty editor built from an absent doc at an undefined version
[0, 0, {doc…code_block "hi"}, "raw"] (well-formed control) no throw, editor initialized with the real doc — well-formed handling unchanged
[4] — CURSOR with no fields, after valid INIT no throw (cursor plugin tolerates it — checked, not part of this filing)
[2, "nope", [123]] — UPDATE with string steps no throw out of the handler; the existing step-apply catch closes with 4001 and reconnects (the resync behaviour INIT should arguably share — noted, not part of this filing)

Why it matters

Collab frames originate from the serving node / peer, not from this client — the same untrusted-shape boundary the codebase already defends for world frames (#2802) and shell frames (shell-contract.ts). The throw is the loud half; the silent half is worse: a user whose INIT arrives truncated (or who connects to a buggy/experimental server build) is shown an empty document, told they are connected, and given a working save path over the real document's hash. "My file went blank" with no error anywhere is the hardest class of report to attribute after the fact.

Acceptance criteria

  • A frame whose unpacked value is not an array (including null) is dropped with a logged error and does not throw out of handleMessage/onmessage; a subsequent well-formed INIT on the same connection still initializes the editor.
  • An INIT frame without a numeric version or without a document object is rejected — logged, and handled as an invalid/desynced state (error status and/or the resync-reconnect the UPDATE path already uses) — and never initializes an editor from an absent doc or an undefined version; onEditorReady does not fire for it.
  • Regression tests in a new pkgs/id/web/src/collab.test.ts (FakeSocket harness in the style of world-records.test.ts) pin: the null frame, the field-less INIT, an INIT with a non-object doc, and the well-formed INIT control; existing web tests still pass (bunx vitest run in pkgs/id/web).
  • Well-formed frames of every existing type (INIT/UPDATE/CURSOR/ACK/ERROR/CURSOR_REMOVE/NEW_VERSION/AUTH_OK) behave byte-for-byte as today.

Dedup notes: searched open + closed issues this run for collab frame shape / handleMessage / INIT — no hit. Distinct from #2802 (the world page's JSON receive() in world.ts; different protocol, handler and consequences), #1618 (server-side step validation), #1463/#1368 (collab reconnect/token behaviour), and closed #1783 (the Rust codec's tag-6 decode).

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    effort/sSmall: expected under 2 hours of work
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    on Oct 30, 2026
  2. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator (lane A, new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a, run 2026-10-30 14:35 CDT
    • Found via: filed this run
    • Task: Triage the MessagePack frame-shape boundary in pkgs/id/web/src/collab.ts handleMessage — a null frame throws at msg[0], and a field-less INIT silently initializes an empty editor at an undefined version — and pin the drop/reject behaviour plus regression tests
    • Expectation: effort/s — done when non-array frames drop without throwing, a doc-less/version-less INIT never initializes an editor or fires onEditorReady, and a new collab.test.ts pins both plus the well-formed control
    • Links: probe (laptop) ~/git/fleet/probe-2026-10-30-laneA-collabframe/ · distinct from id(web): world WebSocket receive() trusts frame shape past the type tag — a parseable-but-wrong-shaped snapshot/event throws out of the handler and is dropped mid-processing #2802 (world.ts JSON receive)
  3. dezren39 commented on Oct 31, 2026

    @dezren39
    MemberAuthor

    🤖 Design-architect pass 26 (2026-10-31) — agent note: this issue is adopted as part of Phase 3 of design #3068 ("parsed is not validated"), alongside the new closing-audit issue #3069. Triage: do NOT re-file, re-scope or split it — record the phase structure instead. Unlike the audit issue, this frame work is NOT gated on #3068's Phases 1–2. The design records the rule this phase lands under: frames are shape-checked past the type tag at the boundary; a wrong-shaped frame is dropped and logged, never thrown out of the handler, never half-applied, never silently initialised from. Details on #3068 and in files/designs/code-parsed-is-not-validated.md (goal workspace).

  4. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  5. 53 remaining items

  6. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 2, 2026
  7. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor
  8. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 2, 2026
  9. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up (ownership correction)

  10. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions