Skip to content

sync: the session's broadcast surface (client-side show) - #454

Merged
nyblnet merged 4 commits into
mainfrom
kernel-show-session
Sep 13, 2026
Merged

sync: the session's broadcast surface (client-side show)#454
nyblnet merged 4 commits into
mainfrom
kernel-show-session

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 13, 2026

Copy link
Copy Markdown
Owner

The client-side kernel half of live broadcast (#293), under the kernel lock —
the surface slides' dispatched client builds against. Relay half is already on
main and deployed (#453, f997be57); this is what the presenter and audience
CLIENTS call.

The shape

A show is collaboration with a second key. The presenter's transport keeps the
room key and gains a per-show key Ke; an audience copy is a reader whose
own collab.key is Ke — so it decrypts the aud stream on the ordinary
path and only the presenter side needs the second key.

Session surface (kernel/src/sync/session.ts):

  • startShow({ showKey, projectOp, snapshot }) installs Ke, waits for the
    socket to be writable, 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 verb/count/
    checkpoint/closed as typed events.
  • Each local op batch runs through the injected projectOp; survivors go under
    Ke tagged s:'aud'. The kernel never learns an app's field names
    projectOp and snapshot are the app's (slides/src/audience.ts).

Two security-driven decisions, both from the projection review:

  1. The audsnap state is a FRESH ADOPT of the projected doc, never the live
    or stampInto state. Those carry stash (dead-window values — a deleted
    slide's speaker notes) and the whole per-character text history, neither of
    which the projection removed. freshAudState rebuilds from the projected
    doc alone. Asserted byte-identical to an independent adopt, with stash and
    txt empty — while the live state carries both.
  2. collab.role gains a distinct 'audience'. The audience transport
    connects on the chain with ivr=audience (the role the owner signed) and is
    receive-only: it puts no protocol frame on the wire, and acts on the
    relay's close codes — 4001/1008 terminal (no reconnect), 4002/4003 transient
    ("waiting for the presenter"). Aud frames bypass vouched() by design: a
    relay-routed, Ke-sealed stream is a different trust path from the room's
    read-only guarantee.

Rigs

  • test-sync-show (28) — the session surface over a real Store. The
    fresh-adopt guard is mutation-caught two ways: audsnap from the live state
    → 3 red (stash, txt, and the secret slide leak); projection skipped → 1 red.
  • test-sync-vouch gains the audience transport (30 total): receive-only
    proven by contrast with a normal reader whose send() does reach the wire
    (isolating the gate from write() buffering), plus close-code routing and
    audonShowOps. Removing the gate, the terminal-no-reconnect, or the
    close-surface each goes red.
  • All sync/relay rigs green; test-relay-protocol 19 (broadcast is slides-only,
    not on dash's shared wire, so its verbs are correctly not compared);
    slides/spaces/type/dash typecheck; slides builds and passes shell-gate.

The two app commits

4212ddc widens collab.invite.role in slides/src/model.ts and
type/src/model.ts — one line each, app-owned, flagged like #452's dash
half.
The kernel's CollabInvite.role gained 'audience'; each app pins its
own and would not compile otherwise. slides is already building the client half
on niemes/live-broadcast and will carry the identical line, so it merges
either order. The slides and type owners should accept or fold these in.

Deploy

No deploy of its own — the relay already speaks the verbs. This ships in the
client release with slides' present-side and audience code, which lands
separately and feature-detects on ready.bc.


Update — security review of the client half (in scope for this PR)

  • Receive-only now holds for all four show senders, not just send().
    Security probed an audience transport: setShowKey + sendVerb/sendAud/
    sendAudSnap put five frames on the wire. Unreachable from the shipped boot
    (audience mode exposes no editor) and the relay drops them, but the guarantee
    should not rest on one sender of four. All four no-op in audience mode,
    audienceMode is decided in the constructor so it precedes any call,
    startShow refuses on an audience transport, and test-sync-vouch drives all
    four and asserts an empty wire (guards removed → red).
  • Co-presenter edits reach the audience live. projectToAudience ran on
    local flush only; a co-presenter's edits arrived at the audience only at the
    next checkpoint. applyRemote now projects too — only the presenter has a
    show config, so it fires for that session alone.

Deploy order (ordering to state — the live relay already satisfies it)

A client release carrying this must follow the #453 relay (live as
f997be57). 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. No action — recorded so a future rollback of the relay below
#453 is known to break audiences.

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.
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.
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Build size

main (394b873) → kernel-show-session (8ba38a8)

app base PR change
bento/slides 678.6 KiB 679.9 KiB +1.3 KiB (+0.19%)
bento/spaces 271.6 KiB 272.9 KiB +1.3 KiB (+0.48%)
bento/dash 424.4 KiB 424.4 KiB 0.0 KiB (0.00%)

Updated: 2026-09-13T08:30:48Z

nyblnet added a commit to niemes/bento that referenced this pull request Sep 13, 2026
…ion's show surface; role unions widened in the kernel, slides' casts removed
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.
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.
nyblnet added a commit to niemes/bento 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
nyblnet merged commit 2666e20 into main Sep 13, 2026
3 checks passed
nyblnet added a commit to niemes/bento that referenced this pull request Sep 13, 2026
…nch's merges of kernel's branch are now redundant
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