sync: transport tidy-up, relay and both clients - #452
Merged
Conversation
…en ticket Recovers the relay authorization work from 07263b3 (issue #270) onto today's tree, with the kernel review's items applied (handoffs/kernel-review-270.md). The relay now stamps a fanned-out frame with exactly what it checked: `q` on a frame it persisted, and the sender's `g` echoed on a signed ephemeral frame it verified. It never stamps a frame it did not check. In a signed room the client refuses content-bearing frames (op batches, fork snapshots) that carry neither stamp, so read-only holds for live peers and not only for the stored log. Legacy `r` rooms stay on the pre-signing model. The fork snapshot is signed on the way out for the same reason op batches are. Blob uploads take a write ticket rather than the room token; reads still take the token. The ticket is issued over the socket — and only to a socket that PROVES the key it presented: `ready` carries a nonce, the client signs `prove.<nonce>.<room>`, and the ticket follows on its own frame. A hash-match on `?w=` alone certifies a socket for per-frame verification but is not proof of possession, since the key it matches is public and in every copy. The per-room latch that turns uploads ticket-only is set by a proven, ticket- capable writer, so the relay deploys ahead of every shipped client without breaking their asset offload. A snapshot claiming a seq the room has not reached is refused with a code (`snap-ahead`) rather than stored, since storing it would prune the whole op log. Nothing is lost on refusal, so it never reaches SyncNotice. Client half is in kernel/src/sync/online.ts (slides' file is a facade). It reconnects on an older relay without change: no nonce means no proof, uploads take the token as before, and a fork snapshot degrades to log convergence until the relay is deployed. Rigs: test-relay-auth 49 (recovered 29 + the review's negatives: a socket presenting the owner's public key is challenged, not handed a ticket, and cannot latch; a wrong or replayed proof earns nothing; snap-ahead refused with its frame id and nothing pruned). Mutation-tested — removing the clamp or the proof check turns it red. test-relay-protocol extended to the parameters and signature texts actually on the wire; it listed several that were not and was matching `tok=` alone. test-sync 45368, session/spaces/type/shape/equiv/flat/ parent-text/offline/blobs/export-secrets green. slides, spaces, type typecheck. Deploy order: the relay first, then the client through the update channel.
DASH ZONE, from the kernel branch, and flagged as such on the board. The kernel brief says a kernel PR hands the app half to the zone owner; this commit is that half, for the dash owner to accept or replace, landed here because (1) dash's transport is a deliberate twin of the kernel's rather than a facade — it runs a different CRDT engine (scripts/test-relay-protocol.ts says why at length) — so it cannot inherit the change; (2) that guard now compares the parameters and signature texts actually on the wire and blocks merge until the two match; (3) the dash session is down; and (4) the change has to reach both apps in the same release, since one deployed relay verifies both clients. What moved, mirroring the kernel commit before this one: `bt=1` on both writer URLs; the `ready` challenge answered with `prove.<nonce>.<room>` and the ticket taken from its own `wt` frame (never from `ready`); `wt` re-minted on `revoked`; `vouched` — in a `w` room, op batches and fork snapshots are applied only when the relay stamped them (`q` or echoed `g`); the fork snapshot signed on the way out; `snap-ahead` recognised and logged. dash does not offload assets yet, so `blobCreds()` and `writeReady()` are the accessor the day it does, not a behaviour change today. test-relay-protocol 19/19 (the two clients spell the wire identically), test-dash-sync 23107, test-dash-canvassync 284; dash typechecks.
collab-design.md: the "Relay enforcement" block and the Phase 1 wire section now describe the shipped mechanism — the nonce on ready, `prove`, the ticket on its own frame, the latch set only by a proven writer, the snapshot clamp — and no longer say the ticket rides on `ready`, which it never must. The possession signature text joins the list the wire-parity guard holds both clients to. DECISIONS.md: the three rules, why the latch stays, the deploy order, and the finding that the parity guard had been matching `tok=` alone. ci.yml: test-relay-auth.ts registered, beside the protocol guard.
Build size
Updated: |
…esting
scripts/test-sync-vouch.ts drives a real OnlineTransport — the kernel's and
dash's twin, with the same frames — through a fake socket in a signed room.
An unstamped op batch and an unstamped fork snapshot are refused; the same
frames carrying the relay's `q` or echoed `g` are applied; a persisted
{snap:1} follows the same rule; presence keeps flowing unstamped; a legacy
r-room applies everything. 20 checks.
Why it exists: with `vouched()` mutated to `return true`, every rig in the
tree stayed green — the relay rig cannot see a client decision, and no
session rig drives the online transport. Security found that by mutation.
Under the same mutation this rig fails 10 of 20, five per transport.
Bundled with esbuild, as test-sanitize is: kernel/src/sync/online.ts uses
constructor parameter properties, which node's strip-only loader rejects —
which is why dash's twin avoids that syntax, and why no rig had ever driven
the kernel transport before this one.
…e client half DECISIONS.md and collab-design.md are reverted to main on this branch. Their content is parked, verbatim, in the private team repo and is promoted to docs/ with the release — a mechanical copy at that point.
nyblnet
marked this pull request as ready for review
September 13, 2026 01:31
nyblnet
added a commit
to niemes/bento
that referenced
this pull request
Sep 13, 2026
…lnet#452, nyblnet#453); the branch's own relay diff is superseded
nyblnet
added a commit
to niemes/bento
that referenced
this pull request
Sep 13, 2026
APP-OWNED files, from the kernel branch, flagged like nyblnet#452's dash half — one line each, for the slides and type owners to accept or fold into their own work. The kernel's CollabInvite.role gained 'audience' (an audience ticket is an owner-signed invite of that role); each app's model.ts pins its own collab.invite.role and would otherwise not compile against the widened kernel type. slides is already building the client half on niemes/live-broadcast and its branch will carry this same line — identical, so it merges either order. No behaviour change; a type widening only.
nyblnet
added a commit
that referenced
this pull request
Sep 13, 2026
…ch the show Two from security's review of the client half. (1) The receive-only guarantee held for send() only. sendVerb/sendAud/ sendAudSnap/setShowKey had no audience guard, so on an audience transport setShowKey + the three senders put five frames on the wire (live/nav/laser/ aud/audsnap). Unreachable from the shipped boot and the relay drops them, but "not sending is the guarantee" must hold for all four senders, not one of four. All four now no-op in audience mode; audienceMode is decided in the constructor (from the auth) so it is set before any call; the transport exposes `audience`; and startShow refuses on an audience transport. test-sync-vouch drives all four senders on an audience socket and asserts the wire stays empty — removing the guards turns it red. (2) projectToAudience ran on LOCAL flush only, so a CO-PRESENTER's edits reached the audience only at the next checkpoint. applyRemote now projects too; only the presenter has a show config, so it fires for the presenter's session alone (the audience reaches applyRemote via applyShowOps and has no config). For the PRs' deploy note (ordering, not work — the live relay is already #453): a client release must follow the #453 relay. On a #452-era relay the room token is stored before the chain check, so an audience socket landing first on a fresh room would poison the token and 403 the presenter until the idle wipe; #453 skips the token store for the audience role, which is why it must be live first. All sync/relay rigs green; slides/spaces/type typecheck.
nyblnet
added a commit
that referenced
this pull request
Sep 13, 2026
* sync: the session's broadcast surface, on a second key The client-side kernel half of live broadcast (#293), needed by slides' dispatched client. A show is collaboration with a second key: the presenter's transport keeps the room key and gains a per-show key Ke; the audience is a reader whose own key IS Ke. Session surface (kernel/src/sync/session.ts): - startShow({ showKey, projectOp, snapshot }) installs Ke on the online transport, waits until the socket may write, sends `live`, then a first audsnap; endShow sends `end` and drops the key. `session.show` is the live verb surface (nav/black/laser), null when off. onShow(fn) delivers verbs, count, checkpoint and close as typed events. - Each local op batch is run through the injected projectOp and the survivors sent under Ke, tagged s:'aud'. The kernel never learns an app's field names: projectOp and snapshot are the app's. - THE AUDSNAP STATE IS A FRESH ADOPT of the projected doc, never the live or stampInto state. Security's finding: those carry `stash` (dead-window values, e.g. deleted slides' notes) and the full per-character text history, neither of which the projection removed. freshAudState rebuilds position/birth registers from the projected doc alone — no stash, no txt. Transport (kernel/src/sync/online.ts): - a second key + sendAud/sendAudSnap/sendVerb; encrypt() takes the key. - an AUDIENCE transport (collab.role 'audience', ivr=audience on the chain the owner signed) is RECEIVE-ONLY: it puts no protocol frame on the wire, and reads the relay's close codes — 4001/1008 terminal (no reconnect), 4002/4003 transient ("waiting for the presenter"). Aud ops/snaps/verbs surface via hooks; they bypass vouched() by design (a relay-routed, Ke-sealed stream, a different trust path from the room's read-only guarantee). - CollabCreds.role and invite.role gain 'audience' (crdt.ts). Rigs: test-sync-show (28, session surface — the fresh-adopt guard is mutation-caught two ways: live-state leak → 3 red, projection skipped → 1 red; byte-identical to an independent adopt of the projected doc, stash and txt asserted empty while the LIVE state carries both). test-sync-vouch gains the audience transport (30 total; receive-only proven by contrast with a normal reader whose send DOES reach the wire, close-code routing, aud→onShowOps — gate removal, terminal-reconnect and close-suppression each go red). All sync/relay rigs green; test-relay-protocol 19 (broadcast is slides-only, not on dash's shared wire); slides/spaces/type/dash typecheck; shell-gate passes. Deploys after the relay already carries the verbs (#453, live as f997be57). The client half (slides) lands separately. * slides, type: widen collab.invite.role to include 'audience' APP-OWNED files, from the kernel branch, flagged like #452's dash half — one line each, for the slides and type owners to accept or fold into their own work. The kernel's CollabInvite.role gained 'audience' (an audience ticket is an owner-signed invite of that role); each app's model.ts pins its own collab.invite.role and would otherwise not compile against the widened kernel type. slides is already building the client half on niemes/live-broadcast and its branch will carry this same line — identical, so it merges either order. No behaviour change; a type widening only. * sync: ShowConfig.snapshot returns the projected doc only The type asked the app for a { doc, state } but startShow reads only snap.doc and builds the state itself via freshAudState (the security-required fresh adopt). Narrow it to { doc } so the app cannot supply a state it must not — slides was passing `state: undefined as never`. Caught by slides against the built client half. * sync: an audience transport sends nothing, and co-presenter edits reach the show Two from security's review of the client half. (1) The receive-only guarantee held for send() only. sendVerb/sendAud/ sendAudSnap/setShowKey had no audience guard, so on an audience transport setShowKey + the three senders put five frames on the wire (live/nav/laser/ aud/audsnap). Unreachable from the shipped boot and the relay drops them, but "not sending is the guarantee" must hold for all four senders, not one of four. All four now no-op in audience mode; audienceMode is decided in the constructor (from the auth) so it is set before any call; the transport exposes `audience`; and startShow refuses on an audience transport. test-sync-vouch drives all four senders on an audience socket and asserts the wire stays empty — removing the guards turns it red. (2) projectToAudience ran on LOCAL flush only, so a CO-PRESENTER's edits reached the audience only at the next checkpoint. applyRemote now projects too; only the presenter has a show config, so it fires for the presenter's session alone (the audience reaches applyRemote via applyShowOps and has no config). For the PRs' deploy note (ordering, not work — the live relay is already #453): a client release must follow the #453 relay. On a #452-era relay the room token is stored before the chain check, so an audience socket landing first on a fresh room would poison the token and 403 the presenter until the idle wipe; #453 skips the token store for the audience role, which is why it must be live first. All sync/relay rigs green; slides/spaces/type typecheck.
nyblnet
added a commit
that referenced
this pull request
Sep 13, 2026
* feat(relay): broadcast control frames — nav, laser, black; rate budget 400/10s
Owner-signed plaintext control frames beside the collab path: {ctl:'nav',n,g}
(sig over nav.${n}), {ctl:'laser',p,g} / {ctl:'laser',off:1,g} (sig over
laser.${p} / laser.off, slide-fraction point, never stored), and
{ctl:'black',on:1|0,g} (sig over black.on/black.off, persisted as lastBlack
and replayed to late joiners). Every block verifies the signature against
the socket's OWN pinned key AND that the key hash-commits to the room name —
a member/chain socket's key never hash-matches, so only the room owner can
broadcast. RATE_BURST 200→400: a 30fps laser stroke is 300 frames/10s; the
count limiter is an abuse guard, laser frames are ~60 bytes so the byte
budget is the real cap.
* feat(sync): broadcast socket, creds and signed control frames
BroadcastSocket: lightweight WS (viewer ?tok=&since=0, owner ?w=<pub>&tok=
&since=0), backoff 800ms×1.8 cap 30s, ping 25s; ignores everything except
nav/presence/laser/black frames so it safely replays ciphertext noise from
collab rooms. resolveBroadcastCreds: case 1 reuses the EXISTING collab room
and signs with the owner key; case 2 mints a device-local broadcast-only
room (bento-broadcast-<docId> in localStorage, fresh keypair + room key —
the private key never leaves the machine). Transport-agnostic sendNav/
sendLaser/sendBlack helpers + onCtl passthrough on OnlineTransport for the
directOwner path.
* feat(client): broadcast copy export — collab.broadcast creds + UI strings
Share menu gains 'Broadcast copy…': exports a standalone .bento.html that
boots straight into present-follow mode, carrying only {room, tok, relay} —
no owner key, no symmetric key, no CRDT state. model.ts: collab.broadcast
field (additive, old shells ignore it) and room/key made optional for
broadcast-only copies. New broadcast icon + 13 UI strings across all 9
catalogs.
* feat(present): broadcast presenter — nav sync, hold-to-draw laser, black screen, remote trail
Speaker view gains a broadcast arm/teardown toggle (📡): on arm, current
slide is sent and every slidechange fans out an owner-signed nav frame;
viewer-count badge + popup clipboard script. Laser became hold-to-draw (L
arms, pointer-down draws) and streams slide-fraction points at ~30fps
(33ms throttle, Excalidraw's CURSOR_SYNC_TIMEOUT) with an off frame on
release; black-screen toggle syncs and replays to late joiners. Broadcast
copies re-synthesize the trail Excalidraw-style: a rAF sampler feeds the
dot's RENDERED position into the shared trail ring buffer so the head stays
glued to the pointer (never pre-baked ahead of it), 0.8s fade, and a
re-shown dot snaps to the new stroke instead of tweening a ghost line
across the gap. B toggles black from the audience document too; text
selection is locked while the laser is armed.
* feat(main): broadcast copy boot + ?b= re-point override
Copies carrying doc.collab.broadcast boot straight into a locked follow-mode
viewer: real present overlay, read-only broadcast socket driving navigation,
status chip (connecting / waiting / live · N viewers). ?b=<viewer url> on
the copy's own URL re-points it at ANOTHER broadcaster's room — e.g. the
owner takes over a presentation the exporter can't do. The relay's auth is
a per-room trust-on-first-use token, so the room alone is not a capability:
the owner's broadcast link (relay + room + tok) must come along; everything
after ?b= is that link, raw. The copy stays a passive viewer — no signing
key, view access only. Malformed overrides fall back to the embedded room.
* test(relay): control-frame suite — nav, laser, black, replay
23 asserts against a local relay: unsigned/forged/member-key drops, valid
owner nav/laser/black fan-out, laser off, lastBlack replay to late joiners,
laser non-replay, rate limiter intact.
* chore(scripts): broadcast demo fixture builder
Builds two self-contained .bento.html fixtures (owner + broadcast copy)
minted with real case-1 collab credentials for manual testing against a
local relay.
* docs: broadcast design, plan, decisions
docs/broadcast-design.md: nav-frame + broadcast-room model, threat model,
wire format. docs/broadcast-plan.md: 7-phase implementation plan with
verified anchors. DECISIONS.md: live-broadcast control channel (laser +
black frames) and the ?b= re-point override entries.
* docs: hosted broadcast client design
* docs: hosted broadcast implementation plan
* feat: doc.meta.hostClient — hosted broadcast copy URL
* feat: hostedLink() — mint hosted broadcast client URLs
* feat: hosted broadcast export — reader creds + hosting URL prompt
* feat: speaker view hosted-link row
* fix: speaker hosted-link row — register copy listener once
* feat: hosted broadcast client — ?room=&tok= re-point + live reader replica
* feat: i18n — hosted broadcast strings in all catalogs
* feat: add hosted copy to broadcast demo fixture
* docs: hosted broadcast client decision
* docs: fix hosted broadcast plan Task 4 Step 3 — data scope inside message listener
* fix: hosted broadcast final review — packed i18n, boot-gap render, teardown, docs
* feat: speaker broadcast popup — one viewer link, set-host from popup
* fix: remove stale viewerUrl reference and clean up broadcast listener on teardown
* fix: purge broadcastLink/viewerUrl reference in broadcast plan; dedupe popup script listeners
* fix: speaker popup script regex — escape backslashes for template literal
The setHost validation regex was written as /^https?:\/\//i inside the
bcastScript template literal, where \/ is an identity escape that emits
/, so the injected script contained /^https?:///i and failed to parse —
the popup never bound its message listener and the broadcast link row
stayed dead. Escape as \\/ in the template so the emitted script is a
valid regex literal.
* feat: derive broadcast rooms from the presenter's signing key
Owner decks sign with the owner key, shared editor copies with their
per-copy invite key, legacy copies with the writer key, and non-collab
decks with a device-local key — so every presenter broadcasts into their
own room (the docId-derived room collided across invite copies). The
connect token is derived from the room name, so URLs and files carry no
secret; the relay TOFU-pins the signer key per room and verifies
nav/laser/black frames against it.
* fix: hide speaker broadcast row when off; use editor broadcast icon
The row and viewer-count badge set display:flex/inline-block, which
overrode the UA's [hidden]{display:none} — the empty URL row stayed
visible with the broadcast off. The [hidden] variants now restate
display:none. The broadcast button also reuses the editor's broadcast
SVG icon instead of the 📡 emoji, and openSpeaker re-posts the link so
a popup reopened mid-broadcast shows it.
* docs: broadcast rooms derived from the presenter's signing key
* docs: broadcast design — implementation facts, decisions consolidated, plans removed
* fix: relay — broadcast presence excludes the presenter, strict laser point format
* refactor: sync — drop dead OnlineTransport broadcast senders and stale comments
* fix: present — reconnect sends the current slide, generic arm-failure toast, popup style dedup
* fix: export — broadcast copy is a plaintext snapshot, plain-http hosting URL refused
* i18n: broadcast — scoped copy keys, Broadcast failed
* test/docs: relay burst budget, fixture script, changelog entry
* fix: use laser icon because U+1F7D2 dont render on MacOS
* broadcast: open the socket through net.ts, so the offline switch holds
The offline rig failed on the relocated transport: "no file outside
kernel/src/net.ts touches a network primitive". BroadcastSocket opened a raw
WebSocket. That was true on the original branch too — it was invisible only
because slides/src/sync/online.ts was the allowlisted transport file then — so
a viewer who had switched on "nothing leaves this computer" would still have
connected to the relay. netWebSocket() is the one chokepoint that keeps that
promise; it throws OfflineError, which the existing catch treats like any
failure to connect. 33/33.
* regenerate modelkeys.generated.ts for doc.broadcast
The model gained a top-level field and the key table is generated from the
model; CI's 'Model key tables match the format' step caught the omission.
* broadcast: the audience projection — one module for the handout, the join snapshot and the live stream
An audience copy and the aud stream keep back speaker notes and review
comments and nothing else; hidden slides stay. slides/src/audience.ts
owns the boundary (AUDIENCE_HIDDEN), projectDoc builds both the handout
and the snapshot, projectOp filters the presenter's mid-show ops so a
stripped snapshot is never repaired by the next notes edit. Blobs are
dropped (a show-key copy cannot open room-key blobs) and the bytes stay
inline; the room key appears nowhere in the copy. The presenter's file
gains collab.audience (the reusable ticket), additive.
scripts/test-audience-projection.ts pins all of it, 37 checks, and CI
runs its negative control first: with the boundary emptied, 12 go red.
* broadcast projection: the layouts register is projected on the wire too, and the audience role is its own
Security drove the real engine and found doc.layouts diffs as ONE
doc-level whole-value register, which no slide-scoped rule saw — "Save
slide as layout" mid-show would have streamed the layout's notes and
comments while the handout from the same doc was clean. projectOp now
projects every entry of a slide-shaped list register (AUDIENCE_SLIDE_LISTS),
under the rule that every place projectDoc projects, projectOp must
project the op carrying the same content. The rig gains an engine-driven
section: each hidden-carrying edit the editor can make is diffed by the
CRDT, projected, and the wire searched for a sentinel — content-agnostic,
so it catches a register it does not know about. 69 checks; 20 red under
the negative control.
collab.role on an audience copy is the distinct 'audience', not 'reader':
every reader check would otherwise join the room path and boot the locked
editor, and the boot path is the show.
* broadcast: "Audience copy…" and "Issue new tickets…" replace the broadcast copy
The hand-out for a live show is now the audience projection written
through serializeAuto: an owner-signed audience invite plus a per-show
key as collab.key, no room key, no private halves, no speaker notes, no
comments, blobs inlined. The ticket is minted once per deck
(collab.audience, presenter's copy only) and reused for every show;
"Issue new tickets" re-mints it, revokes the old invite at the relay,
and warns that every outstanding copy stops working. The hosting-URL
field and doc.broadcast's goLive guard go with the design they served.
test-export-secrets pins the new path by shape (projectDoc, never the
live doc, serializeAuto) and by running the projection on a deck that
carries everything it must lose. 64 checks. i18n catalogs follow in the
client half's final pass.
* broadcast: drop nav-check.mjs — the relay design it checked (TOFU pinning, derived rooms) is superseded by #453 on main
* sync: the session's broadcast surface, on a second key
The client-side kernel half of live broadcast (#293), needed by slides'
dispatched client. A show is collaboration with a second key: the presenter's
transport keeps the room key and gains a per-show key Ke; the audience is a
reader whose own key IS Ke.
Session surface (kernel/src/sync/session.ts):
- startShow({ showKey, projectOp, snapshot }) installs Ke on the online
transport, waits until the socket may write, sends `live`, then a first
audsnap; endShow sends `end` and drops the key. `session.show` is the
live verb surface (nav/black/laser), null when off. onShow(fn) delivers
verbs, count, checkpoint and close as typed events.
- Each local op batch is run through the injected projectOp and the survivors
sent under Ke, tagged s:'aud'. The kernel never learns an app's field names:
projectOp and snapshot are the app's.
- THE AUDSNAP STATE IS A FRESH ADOPT of the projected doc, never the live or
stampInto state. Security's finding: those carry `stash` (dead-window values,
e.g. deleted slides' notes) and the full per-character text history, neither
of which the projection removed. freshAudState rebuilds position/birth
registers from the projected doc alone — no stash, no txt.
Transport (kernel/src/sync/online.ts):
- a second key + sendAud/sendAudSnap/sendVerb; encrypt() takes the key.
- an AUDIENCE transport (collab.role 'audience', ivr=audience on the chain the
owner signed) is RECEIVE-ONLY: it puts no protocol frame on the wire, and
reads the relay's close codes — 4001/1008 terminal (no reconnect), 4002/4003
transient ("waiting for the presenter"). Aud ops/snaps/verbs surface via
hooks; they bypass vouched() by design (a relay-routed, Ke-sealed stream, a
different trust path from the room's read-only guarantee).
- CollabCreds.role and invite.role gain 'audience' (crdt.ts).
Rigs: test-sync-show (28, session surface — the fresh-adopt guard is
mutation-caught two ways: live-state leak → 3 red, projection skipped → 1
red; byte-identical to an independent adopt of the projected doc, stash and
txt asserted empty while the LIVE state carries both). test-sync-vouch gains
the audience transport (30 total; receive-only proven by contrast with a
normal reader whose send DOES reach the wire, close-code routing, aud→onShowOps
— gate removal, terminal-reconnect and close-suppression each go red). All
sync/relay rigs green; test-relay-protocol 19 (broadcast is slides-only, not
on dash's shared wire); slides/spaces/type/dash typecheck; shell-gate passes.
Deploys after the relay already carries the verbs (#453, live as f997be57).
The client half (slides) lands separately.
* slides, type: widen collab.invite.role to include 'audience'
APP-OWNED files, from the kernel branch, flagged like #452's dash half — one
line each, for the slides and type owners to accept or fold into their own
work. The kernel's CollabInvite.role gained 'audience' (an audience ticket is
an owner-signed invite of that role); each app's model.ts pins its own
collab.invite.role and would otherwise not compile against the widened kernel
type. slides is already building the client half on niemes/live-broadcast and
its branch will carry this same line — identical, so it merges either order.
No behaviour change; a type widening only.
* broadcast: the show, on the collaboration session — Live/Lock, follow mode, the audience boot path
The client half of live broadcast, built on the session's show surface
(#454) against the deployed relay (#453). No transport of its own:
present.ts hands every wire concern to the session and owns only what
is visible.
Presenter: a Live toggle in the speaker view, off on every show, that
starts the show with the audience ticket and the projection injected;
a Lock toggle whose title says what it is not ("It does not hide the
rest of the deck, which they already have"); the audience count; nav by
slide ID with the visible index as fallback; laser at ≤ 20 fps with
pen-up always sent. Audience: main.ts boots an audience copy
(role 'audience') straight into the show over the session's receive-only
path, the deck re-renders live as the presenter edits, and a follow chip
lets the viewer browse and snap back unless the presenter has locked;
cards for waiting / ended / ticket no longer valid. The pure decisions
live in slides/src/follow.ts, driven by scripts/test-broadcast-follow.ts
(26 checks, including the mid-talk insert that moved every viewer under
the old index-based design).
Deleted with the old shape: slides/src/broadcast.ts, doc.broadcast and
meta.hostClient, the hosted client, the old design docs. docs/
broadcast-design.md now describes the shipped shape; DECISIONS.md
records the client half's reasoning; the changelog entry is rewritten;
25 new strings in all eight catalogs, the old broadcast strings removed.
Build and splice gate green.
* sync: ShowConfig.snapshot returns the projected doc only
The type asked the app for a { doc, state } but startShow reads only snap.doc
and builds the state itself via freshAudState (the security-required fresh
adopt). Narrow it to { doc } so the app cannot supply a state it must not —
slides was passing `state: undefined as never`. Caught by slides against the
built client half.
* broadcast: snapshot hands the session the projected doc only, matching #454's narrowed type
* broadcast: the audience ticket store is presenter-only, stripped like the other private halves
stripCollabSecrets dropped writerPriv, ownerPriv and invite and nothing
else, so a read-only or invite copy carried collab.audience — the show
key and the audience invite's private half, with which a reader could
mint audience tickets the presenter never issued (security, 2026-09-13).
Deleted beside the other three. test-export-secrets now discovers
private material by SHAPE — any collab field whose declared type carries
a priv key — so the next nested keypair is caught without being listed;
negative-controlled by removing the delete (64/65).
* sync: an audience transport sends nothing, and co-presenter edits reach the show
Two from security's review of the client half.
(1) The receive-only guarantee held for send() only. sendVerb/sendAud/
sendAudSnap/setShowKey had no audience guard, so on an audience transport
setShowKey + the three senders put five frames on the wire (live/nav/laser/
aud/audsnap). Unreachable from the shipped boot and the relay drops them, but
"not sending is the guarantee" must hold for all four senders, not one of four.
All four now no-op in audience mode; audienceMode is decided in the constructor
(from the auth) so it is set before any call; the transport exposes `audience`;
and startShow refuses on an audience transport. test-sync-vouch drives all four
senders on an audience socket and asserts the wire stays empty — removing the
guards turns it red.
(2) projectToAudience ran on LOCAL flush only, so a CO-PRESENTER's edits
reached the audience only at the next checkpoint. applyRemote now projects too;
only the presenter has a show config, so it fires for the presenter's session
alone (the audience reaches applyRemote via applyShowOps and has no config).
For the PRs' deploy note (ordering, not work — the live relay is already #453):
a client release must follow the #453 relay. On a #452-era relay the room token
is stored before the chain check, so an audience socket landing first on a fresh
room would poison the token and 403 the presenter until the idle wipe; #453
skips the token store for the audience role, which is why it must be live first.
All sync/relay rigs green; slides/spaces/type typecheck.
* broadcast: CLAUDE.md returns to main's text — the paragraph described the superseded design (derived rooms, TOFU, the hosted client); docs/broadcast-design.md carries the shipped shape
---------
Co-authored-by: nyblnet <306268465+nyblnet@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Kernel change under the serialization lock. Recovers the relay authorization
work from
07263b3onto today's tree with the kernel review's items applied.The review itself is private (
bento-teamhandoffs); this body describes themechanism, which is in the code either way.
Ready for review. The question of a later revision behind #270's notes
is dissolved, measured by slides: no security advisory in the repo concerns
relay authorization and none of the private repos holds a relay-auth note
beyond this week's — the recovered commit is the whole of what was ever
written, and the
blobCreds()line in the issue describes edits that werelost or misdescribed. Deployed relay SHA: pending — the maintainer will
supply it and it will be recorded here.
What it does
The relay stamps a fanned-out frame with exactly what it verified.
qona frame it persisted, the sender's
gechoed on a signed ephemeral frame itchecked — never a stamp it did not earn. In a
wroom the client applies opbatches and fork snapshots only when stamped (
vouched);rrooms stay onthe older model. The fork snapshot is signed on the way out so it can earn
the stamp. Verified nothing legitimate is unstamped:
send()is the onlyproducer of an ops frame and is unconditionally
p:1; the relay stampsqon live fan-out, not only replay.
Blob uploads take a write ticket; the ticket takes a possession proof.
A hash-match on
?w=certifies a socket for per-frame verification, but thekey it matches is the owner's public key and every copy carries it — so a
ticket issued on the hash-match alone would go to any reader. Now
readycarries a nonce, the client signs
prove.<nonce>.<room>, and the ticketfollows on its own
wtframe. Room name in the text (no cross-room replay),nonce single-use (no same-room replay). The per-room latch that turns uploads
ticket-only is set by a proven
bt=1writer, so the relay deploys ahead ofevery shipped client without breaking their asset offload. Re-mint on
revocation goes to proven sockets only. Reads keep the room token.
A snapshot cannot claim to cover ops the room has not seen.
snapwithq > seqis refused withsnap-ahead(naming the frame) rather than stored,because storing it prunes the log up to
q. Nothing is lost on refusal, soit is logged, not surfaced — and deliberately not added to
RefusalCode,which would have made every app's notice switch non-exhaustive.
The commits, and one flagged as another zone's
74e66aa— kernel: relay +kernel/src/sync/online.ts+ rigs.f170879— dash zone, from the kernel branch, flagged on the board.Dash's transport is a deliberate twin of the kernel's, not a facade (it
runs a different CRDT engine;
scripts/test-relay-protocol.tssays why),so it cannot inherit the change. The parity guard now blocks merge until
the two spell the wire identically; the dash session is down; and one
deployed relay verifies both clients, so the change must reach both apps
in one release. Accepted by the dash owner after running it
independently: tsc clean,
test-relay-protocol19/19,test-sync-vouch20/20, dash-sync 23107, canvassync 284 — and negative-controlled in dash
alone (
vouched → truewith the sabotage present in dash and absent fromkernel fails exactly the five
dash:checks and none of the kernel's),which is the result that shows the two transports are guarded
independently rather than one rig accidentally covering both.
docs— CI registration only. TheDECISIONS.mdentry and thecollab-design.mdwire-section changes follow at release, on themaintainer's ruling: they describe the mechanism precisely while the
deployed relay still lacks it. Their content is parked verbatim in the
private team repo (
handoffs/pr452-docs.md) and promotes todocs/as amechanical copy once the relay is deployed and the client half has shipped.
f29d21c—test-sync-vouch.ts, the client-side rig (see below).The wire-parity guard was checking almost nothing
test-relay-protocol.tslisted query parameters that are not on the wire(
room|pub|sig|inv|role|exp) and omitted most that are; its signature-textalternation lacked
dlg.. It reported the two transports identical whilematching
tok=alone. Extended to the parameters, signature texts andcontrol-frame names the worker actually reads — and it then reported exactly
this change's four additions as dash's skew, nothing pre-existing.
Verification
test-relay-auth— 49 (recovered 29 + the review's negatives: a socketpresenting the owner's public key is challenged, handed no ticket, cannot
latch; a wrong, replayed, or cross-room proof earns nothing;
snap-aheadrefused with its frame id and nothing pruned; re-mint reaches proven
sockets only). Mutation-tested: removing the clamp → 5 red; skipping
proof verification → 3 red.
test-sync-vouch— 20, both transports through a fake socket:unstamped ops/snapshots refused, stamped applied, presence unaffected,
r-rooms permissive. With
vouchedmutated toreturn truein bothtransports: 10 of 20 red. (No rig had ever driven the kernel transport —
it uses parameter properties, which node's strip loader rejects — hence
esbuild-bundled, the
test-sanitizepattern.)test-relay-protocol19/19 ·test-sync45368 ·test-sync-session·-spaces·-spaces-session·-type·-shape·-equiv·-flat·-parent-text·test-offline·test-blobs·test-export-secrets·test-dash-sync23107 ·test-dash-canvassync·test-ci-registered292.tsc -b, exit 0) and build;shell-gatepasses on the slides shell.Deploy order — maintainer
Relay first (
wrangler deployinserver/sync-worker/), then theclients through the update channel, both apps in the same release. A
client newer than the relay degrades safely on every path but one: its fork
snapshot, unstamped by an old relay, is refused live and the fork converges
through the persisted log. Bounded, not lossy. The relay must not be deployed
with
#293's verbs bundled in — that is the next serialized change, so aregression in either can be rolled back alone.
No changelog line: a user of a shipped file notices nothing, and the release
wording is the maintainer's. The design-doc changes follow at release (above).
Relay deployed 2026-09-13T01:43:15Z from
mainatb1b4a67(this PR's squash). Workerbento-sync, version62a12ffa-f676-4ae0-94af-1b40f465f3bb, 100% of traffic,sync.bento.page. Deployed by the maintainer's instruction from the maintainer's machine. No client carrying thevouched/provehalf has shipped yet; clients follow in one release for both apps.