Skip to content

relay: broadcast as a special case of collaboration - #453

Merged
nyblnet merged 3 commits into
mainfrom
kernel-broadcast-relay
Sep 13, 2026
Merged

relay: broadcast as a special case of collaboration#453
nyblnet merged 3 commits into
mainfrom
kernel-broadcast-relay

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 13, 2026

Copy link
Copy Markdown
Owner

The relay half of live broadcast (#293, the maintainer's unified design of
2026-09-13), under the kernel lock. Relay onlyserver/sync-worker/,
its rigs, CI, and a DECISIONS.md entry. No client code; slides' client half
lands separately against the frame contract it already has, and
feature-detects the channel.

The design, in the relay's terms

An audience member is a collaborator holding a ticket — an owner-signed
invite with role audience, on the same chain as "Invite to edit" — whose
collab.key is a per-show show key, not the room key. The relay:

  1. Admits on the invite, not the token. An audience copy cannot derive
    ?tok= (it holds the show key), so the token compare is skipped for the
    role and only the role. w rooms only; only while live (4002 not-live);
    never on a revoked invite; 4003 show-full past the cap.
  2. Makes audience sockets receive-only. Every frame from one is dropped.
    Control verbs verify by role (the socket's pinned writer key), never by
    chain membership — the audience invite is on the same chain.
  3. Routes two streams. s:'aud' frames go to audience sockets only, are
    never persisted even when p:1 and validly signed, and come only from
    writer sockets while live. The room stream — presence included — never
    reaches an audience socket. nav/black are signed with the stream in the
    text and retained as latest state; laser is unsigned and not retained.
  4. Holds the show in DO storage. One audsnap on live and at
    checkpoints, aud ops since, nav/black. A late joiner is served that and
    never the op log or the room's persisted snapshot. Storage, not memory —
    hibernation evicts the object mid-connection; the rig serves a joiner from
    a second Room over the same storage. 256 KB of held ops asks the
    presenter to checkpoint once; twice that refuses joiners until it does.
  5. Ends on any writer's end, or grace. 60 s on presenter-socket loss,
    cancelled by a writer's live, never extended by audience activity. When
    it fires, the room survives.

The trap this had to route around

The Durable Object has one alarm, and it meant "wipe the room." Arming a
grace timer with a bare setAlarm would have replaced the 30-day idle alarm
and, on firing, run the wipe on a live room. Every timer now goes through one
multiplexer (schedule/rearm/alarm): each kind stores its due time, the
DO alarm is armed to the earliest, the handler runs whichever are due. The
rig asserts a grace expiry ends the show and leaves the room, and that the
idle alarm still evaporates it.

Also in here

  • ready carries v (relay protocol version, 2) and bc:1. v is the
    one read-only way to tell a deployed relay from the last one — asked for
    by the lead after the sync: transport tidy-up, relay and both clients #452 deploy could only be confirmed from its
    deployment record, every protocol probe being a write or needing an owner
    key. bc is for feature detection.
  • The fake Durable Object is now scripts/lib/relay-harness.ts, shared by
    test-relay-auth and the new test-relay-broadcast, so two rigs cannot
    drift on what "the relay" is. The auth rig is ported onto it unchanged in
    substance.

Verification

  • test-relay-broadcast85 checks, mutation-tested five ways: audience
    not receive-only → 1 red; streams not isolated → 4; grace running the wipe
    → 3; stream absent from the signed text → 6; aud frames falling through
    to the persist path → 4
    . That last mutation passed until the fixture was
    made realistic — p:1 plus a valid signature, which is exactly what the
    client's send() produces — and that is the shape that would have put
    show-key ciphertext in the room's op log forever. A fixture too polite to
    match the real client is a fixture that proves nothing.
  • test-relay-auth 49 (on the shared harness) · test-relay-protocol 19 ·
    test-sync 45368 · test-offline 33 · test-sync-vouch 20 ·
    test-ci-registered 295.
  • No server.accept() anywhere in the file — the refusal path for
    4002/4003 uses acceptWebSocket then closes, so a grep for the v0.9.7
    bug finds nothing.

Deploy — maintainer

After #452's relay (62a12ffa, from b1b4a67), as its own deploy. The
merge gate on this branch was released when that deploy landed. Additive to
every shipped client: none sends ivr=audience or s:'aud', and v/bc
on ready are ignored by clients that do not read them.

Frame contract for the client half is unchanged from what slides has; the
only new field a client may want is ready.v.


Relay deployed 2026-09-13 from main at 394b873 (this PR's squash). Worker bento-sync, version f997be57-0ebf-4501-bf63-fd9accc032f7, sync.bento.page. Deployed by the maintainer's instruction from the maintainer's machine. Verified read-only: a plain reader socket on a fresh room received {"ctl":"ready","q":0,"bc":1,"v":2} — the first relay deploy confirmed from outside, and it confirms #452's deploy retroactively. No client carrying broadcast has shipped.

The relay half of live broadcast (#293, the maintainer's unified design). An
audience member is a collaborator holding an owner-signed `audience` invite
and a per-show key; the relay routes by stream and holds the show for late
joiners. Five rules, each mutation-tested:

- Admission is the invite, not the token: the audience copy cannot derive the
  room token (it holds the show key), so the compare is skipped for the role
  and only the role. `w` rooms only; only while live (4002 not-live); never on
  a revoked invite; 4003 show-full past the cap.
- Audience sockets are receive-only. Control verbs verify by ROLE against the
  socket's pinned writer key, never by chain membership.
- Two streams. s:'aud' frames reach audience sockets only, are never persisted
  even when p:1 and validly signed (the shape the real client sends), and come
  only from writer sockets while live. The room stream — presence included —
  never reaches an audience socket. nav/black are signed with the stream in
  the text and retained; laser is unsigned and not retained.
- The show lives in DO storage, not memory: hibernation evicts the object
  mid-connection. A late joiner is served snapshot + aud ops + nav/black and
  never the op log. The rig serves a joiner from a SECOND Room over the same
  storage. 256 KB of held ops asks the presenter to checkpoint once; twice
  that refuses joiners until it does.
- `end` is any writer's; grace (60 s on presenter loss, cancelled by a
  writer's `live`) is the only unsigned path. When it fires the room SURVIVES.

The DO has one alarm and it meant "wipe the room". Every timer now goes
through schedule/rearm/alarm; a grace timer armed the naive way would have
evaporated a live room. Asserted: grace expiry ends the show and leaves the
room; the idle alarm still wipes.

`ready` gains `v` (relay protocol version 2) — the one read-only way to tell
a deployed relay from the last one, asked for by the lead after verifying the
#452 deploy could only be confirmed by its deployment record — and `bc:1`
for feature detection.

The fake Durable Object the relay rigs drive is lifted into
scripts/lib/relay-harness.ts and shared by test-relay-auth and the new
test-relay-broadcast, so the two cannot drift on what "the relay" is.

test-relay-broadcast 85 checks. Mutations: audience not receive-only 1 red;
streams not isolated 4; grace running the wipe 3; stream absent from the
signed text 6; aud frames falling through to the persist path 4 — that last
one passed until the fixture was made realistic (p:1 + a valid signature, as
the client's send() produces), which is the shape that would have put
show-key ciphertext in the room's op log forever. test-relay-auth 49,
test-relay-protocol 19, test-sync 45368, test-offline 33, test-sync-vouch 20,
test-ci-registered 295.

Deploys after #452's relay (62a12ffa), on its own. Additive to every shipped
client. The client half (slides) lands separately and feature-detects.
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Build size

main (b1b4a67) → kernel-broadcast-relay (2a0c053)

app base PR change
bento/slides 678.6 KiB 678.6 KiB 0.0 KiB (0.00%)
bento/spaces 271.6 KiB 271.6 KiB 0.0 KiB (0.00%)
bento/dash 424.4 KiB 424.4 KiB 0.0 KiB (0.00%)

Updated: 2026-09-13T02:04:34Z

The broadcast paths gate on `proven` rather than the pinned key alone — the
same rule the write ticket already follows. The checkpoint request goes to
the presenter's socket; past the hard cap nothing more is appended and the
sender is refused with a code; audience sockets hear nothing of the room's
membership.

test-relay-broadcast 100 (was 85), the new checks mutation-tested five more
ways (4/5/5/2/1 red). test-relay-auth 49, test-relay-protocol 19,
test-ci-registered 295.
@nyblnet
nyblnet merged commit 394b873 into main Sep 13, 2026
2 checks passed
nyblnet added a commit to niemes/bento that referenced this pull request Sep 13, 2026
nyblnet added a commit to niemes/bento that referenced this pull request Sep 13, 2026
nyblnet added a commit to niemes/bento that referenced this pull request Sep 13, 2026
The client-side kernel half of live broadcast (nyblnet#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 (nyblnet#453, live as f997be57).
The client half (slides) lands separately.
nyblnet added a commit to niemes/bento that referenced this pull request Sep 13, 2026
… mode, the audience boot path

The client half of live broadcast, built on the session's show surface
(nyblnet#454) against the deployed relay (nyblnet#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.
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant