Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
dded935
feat(relay): broadcast control frames — nav, laser, black; rate budge…
niemes Aug 8, 2026
3055e0b
feat(sync): broadcast socket, creds and signed control frames
niemes Aug 8, 2026
3a427f6
feat(client): broadcast copy export — collab.broadcast creds + UI str…
niemes Aug 8, 2026
4e72d9b
feat(present): broadcast presenter — nav sync, hold-to-draw laser, bl…
niemes Aug 8, 2026
2aea88c
feat(main): broadcast copy boot + ?b= re-point override
niemes Aug 8, 2026
58179f8
test(relay): control-frame suite — nav, laser, black, replay
niemes Aug 8, 2026
2ed2d09
chore(scripts): broadcast demo fixture builder
niemes Aug 8, 2026
c695b6a
docs: broadcast design, plan, decisions
niemes Aug 8, 2026
64a9edb
docs: hosted broadcast client design
niemes Aug 9, 2026
312ef5d
docs: hosted broadcast implementation plan
niemes Aug 9, 2026
e78d577
feat: doc.meta.hostClient — hosted broadcast copy URL
niemes Aug 9, 2026
44b03f8
feat: hostedLink() — mint hosted broadcast client URLs
niemes Aug 9, 2026
1441311
feat: hosted broadcast export — reader creds + hosting URL prompt
niemes Aug 9, 2026
bb5b988
feat: speaker view hosted-link row
niemes Aug 9, 2026
e599ebe
fix: speaker hosted-link row — register copy listener once
niemes Aug 9, 2026
2b17efc
feat: hosted broadcast client — ?room=&tok= re-point + live reader re…
niemes Aug 9, 2026
8034045
feat: i18n — hosted broadcast strings in all catalogs
niemes Aug 9, 2026
76aa94f
feat: add hosted copy to broadcast demo fixture
niemes Aug 9, 2026
f3a519b
docs: hosted broadcast client decision
niemes Aug 9, 2026
e3cb67f
docs: fix hosted broadcast plan Task 4 Step 3 — data scope inside mes…
niemes Aug 9, 2026
fd41eae
fix: hosted broadcast final review — packed i18n, boot-gap render, te…
niemes Aug 9, 2026
7cdf9a4
feat: speaker broadcast popup — one viewer link, set-host from popup
niemes Aug 9, 2026
6275965
fix: remove stale viewerUrl reference and clean up broadcast listener…
niemes Aug 9, 2026
f5dab47
fix: purge broadcastLink/viewerUrl reference in broadcast plan; dedup…
niemes Aug 9, 2026
add2db5
fix: speaker popup script regex — escape backslashes for template lit…
niemes Aug 9, 2026
39e5235
feat: derive broadcast rooms from the presenter's signing key
niemes Aug 10, 2026
7a7c731
fix: hide speaker broadcast row when off; use editor broadcast icon
niemes Aug 10, 2026
1d90732
docs: broadcast rooms derived from the presenter's signing key
niemes Aug 10, 2026
107db8a
docs: broadcast design — implementation facts, decisions consolidated…
niemes Aug 10, 2026
53fd634
Merge remote-tracking branch 'origin/main' into live-broadcast
niemes Aug 10, 2026
614d953
fix: relay — broadcast presence excludes the presenter, strict laser …
niemes Aug 10, 2026
71e46cb
refactor: sync — drop dead OnlineTransport broadcast senders and stal…
niemes Aug 10, 2026
237d53c
fix: present — reconnect sends the current slide, generic arm-failure…
niemes Aug 10, 2026
27f87f9
fix: export — broadcast copy is a plaintext snapshot, plain-http host…
niemes Aug 10, 2026
53b08d8
i18n: broadcast — scoped copy keys, Broadcast failed
niemes Aug 10, 2026
5b4be20
test/docs: relay burst budget, fixture script, changelog entry
niemes Aug 10, 2026
18a5b3c
Merge remote-tracking branch 'origin/main' into live-broadcast
niemes Aug 10, 2026
abc9f12
fix: use laser icon because U+1F7D2 dont render on MacOS
niemes Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ pre-1.0.

## [Unreleased]

- **Live slide broadcast.** *Save broadcast copy…* in the Share menu exports a
standalone viewer file of the current deck. Hand that file out and present:
every open copy follows your current slide — transitions and morphs included
— over the existing relay, with a presenter laser and black-screen controls.
The channel carries only a slide number, never content, and the broadcast
room is derived from your signing key, so copies carry no credentials and
the shareable link has nothing secret in it. A deck with a hosting URL mints
a hosted client link instead, so any presenter's room can drive the same
hosted copy. The relay must be deployed with the control-frame changes for
enforcement.

## [1.0.17] — 2026-08-10

- **Security: update this file. A deck could run code hidden in its own
Expand Down
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,26 @@ names provisional.
**Share exports** (invite/viewonly/presentonly/template) pass a filename
suffix and NEVER retain the FSA handle (`writeUpdatedFileAs` opts.keepHandle
— retaining it made a later ⌘S overwrite the export with the full doc).
- **Live broadcast (v0.9.19)**: "Save broadcast copy…" (Share menu) embeds
`collab.broadcast = {room, relay}` — no tok, no keys, `on:false`, fresh
docId, plaintext even for encrypted decks — and the copy boots into a locked
present-follow viewer. The room is derived from the PRESENTER's signing key
(`broadcastRoom`: b64url(sha256(pub))[0:10], re-hashed until it doesn't
start with 'w' — a 'w' name would be mistaken for a signed collab room by
the relay); the connect tok is derived from the room name, so URLs carry no
tok. Signer resolution: ownerPriv → invite → writerPriv → device-local
`bento-broadcast-<docId>` key. The relay TOFUs the presenter's `?w=` per
non-`w` room and verifies nav/laser/black frames (`nav.${n}` /
`laser.${p}` / `laser.off` / `black.on` / `black.off` signature texts);
laser is ~30fps (33ms), RATE_BURST 400/10s. The speaker popup's Broadcast
link row + Copy live in an injected IIFE (bcastScript) — GOTCHAS: regexes
inside the template literal need DOUBLE-escaped backslashes (`/^https?:\/\//i`
→ `/^https?:\\/\\//i`), bind click listeners once (`window.__bentoBcastBound`
+ per-row `dataset.bound`), and restate `[hidden]{display:none}` for any
rule that sets `display:flex/inline-block` on the same element (a display
property overrides the UA's `[hidden]`). Hosted variant: `doc.meta.hostClient`
(About dialog, Document properties) + a live reader replica on the deck's
collab room; see docs/broadcast-design.md + docs/hosted-broadcast-design.md.
- **Canvas slide nav (v1.0.2)**: with NOTHING selected (and not text/cell/path
editing), arrow keys walk slides and a plain wheel over `.ed-scroll` walks
slides (threshold 40px + 400ms cooldown so a trackpad swipe = one slide; skips
Expand Down
48 changes: 48 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2247,3 +2247,51 @@ too, at the single point where it returns.
payload 72KB → 79KB). Most of it is the finding messages, which are the
product: a code with no explanation is not actionable. Anyone tempted to shrink
this should shorten prose, not drop checks.

## 2026-08-08 — Live broadcast control channel

**Decision.** The presenter of a deck can broadcast to copies of it over the
existing relay: a "Save broadcast copy…" Share-menu export embeds
`collab.broadcast = {room, relay}` and boots the copy into a locked
present-follow viewer; the presenter's speaker view arms a broadcast per show
(off by default) and sends signed control frames over a dedicated
BroadcastSocket. Frame types: `{ctl:'nav',n,g}` (presenter-visible slide
number, 1-based with interactive states excluded), `{ctl:'laser',p,g}` /
`{ctl:'laser',off:1,g}` (slide-fraction stroke points, hold-to-draw, ~30fps
client throttle) and `{ctl:'black',on:1|0,g}` (persisted as `lastBlack` and
replayed to late joiners, unlike laser which is transient). Signatures over
literal texts (`nav.${n}`, `laser.${p}`, `laser.off`, `black.on`/`black.off`)
with the presenter's key; the relay fans a signature-less copy (clients trust
its verification, like ops) and sends `{ctl:'presence',n}` viewer counts on
connect/close. The relay keeps the generic per-socket rate limiter (RATE_BURST
400/10s — enough for a full laser stroke with nav headroom) and the storage is
two small values (`lastNav`/`lastBlack`) replayed ahead of any live frame, so
a late joiner always lands on the newest slide. Relay changes are
backward-compatible: old copies ignore unknown ctl frames. Details:
docs/broadcast-design.md, server/sync-worker/src/worker.js,
slides/src/present.ts, slides/src/main.ts.

## 2026-08-10 — Broadcast rooms derived from the presenter's signing key; hosted client

**Decision.** One room system for ALL broadcasts: the room is derived from the
PRESENTER's signing key — the key this copy signs control frames with: the
owner key (owner deck), the per-copy invite key (a shared editor copy — each
invitee's room is unique to their copy), the shared writer key (legacy), or a
device-local broadcast key (no-collab decks). `room = b64url(sha256(signerPub))
[0:10]`, re-hashed until it doesn't start with 'w' (a 'w' name would be
mistaken for a signed collab room by the relay); the relay connect token is
derived from the room name (`tok = b64url(sha256(room))[0:18]`). Presenter and
viewers compute the same values, so the shareable link is
`<hostClient>?room=<name>` (no tok) and the copy embeds `broadcast:{room,relay}`
(no tok). The relay TOFUs the presenter's `?w=` signer key per non-`w` room
(like the tok) and verifies nav/laser/black against it; signed rooms keep the
owner-key commitment. The collab-socket reuse path is removed; earlier
`?b=` re-pointing and `?room=&tok=` URL formats were dropped during
development (no backward compatibility). The hosted variant: a broadcast copy
hosted once on the presenter's server (`doc.meta.hostClient`, set in the
About dialog's Document properties — no prompt), re-pointed at any presenter
via `?room=`, and joining the deck's collab room as a live reader replica so
content updates in real time. The URL carries only the nav capability; the
file carries the deck key (same trust as the read-only copy). Key rotation
breaks the pinned signer key until the deck is duplicated (new docId) —
accepted. Design: docs/broadcast-design.md, docs/hosted-broadcast-design.md.
228 changes: 228 additions & 0 deletions docs/broadcast-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# Live slide broadcast — design

Status: approved (2026-08-10). Companion to `collab-design.md` and
`relay-design.md`; the hosted viewer variant extends this in
`docs/hosted-broadcast-design.md`. Nothing touches the CRDT, the op log, the
room byte cap, or existing file formats.

## What it is

The presenter of a `.bento.html` deck broadcasts their **current slide** to
any number of viewers in near real time, over the existing Cloudflare Worker +
Durable Object relay. Viewers need no write access, no account, and no
collaboration keys — just the broadcast copy.

**The broadcast copy is a standalone `.bento.html` built from the presenter's
deck** via the Share menu ("Save broadcast copy…"). It carries the deck
content plus the room connection credentials, boots directly into a locked
present-follow mode, and is driven live by the presenter's show: on slide
change, every connected copy follows — same slide, same transitions, same
morphs — because the copy runs the real renderer on its own copy of the
document.

The nav channel stays deliberately narrow: it carries **only a slide number**
(plus laser/black control), never content — the relay must never hold a
document, and the copy already has one.

## Room & key model

One room system for ALL broadcasts. The broadcast room is **derived from the
presenter's signing key** — the key this copy signs control frames with:

- owner key (`collab.ownerPriv`, an owner deck),
- per-copy invite key (`collab.invite`, a shared editor copy — each invitee's
room is unique to their copy),
- shared writer key (legacy rooms),
- or a device-local broadcast key (`bento-broadcast-<docId>` in localStorage,
no-collab decks).

`room = b64url(sha256(signerPub))[0:10]`, re-hashed until the name does not
start with `w` (a `w` name would be mistaken for a signed collab room by the
relay). The relay connect token is derived from the room name:
`tok = b64url(sha256(room))[0:18]` — the presenter and every viewer compute
the same value, so **URLs and files carry no tok**: the shareable link is
`<hostClient>?room=<name>` and the copy embeds `broadcast:{room,relay}`.

The presenter always opens a **dedicated BroadcastSocket** on the derived room
(the collab-socket reuse path is removed). The relay pins the presenter's
signer key per room (trust-on-first-use, like the tok) and verifies
nav/laser/black frames against it; signed collab rooms keep the owner-key
commitment check.

## Frame protocol

### Presenter → relay

```jsonc
{ "ctl": "nav", "n": 4, "g": "<b64url ECDSA-P256 signature>" }
{ "ctl": "laser", "p": "0.42,0.71", "g": "…" } // stroke point (slide fractions)
{ "ctl": "laser", "off": 1, "g": "…" } // stroke end
{ "ctl": "black", "on": 1, "g": "…" } // blackout on/off
```

- `n` — positive integer, the **presenter-visible slide number**:
`visibleIndex()` in present.ts (1-based, interactive states excluded) — the
same number the speaker view's counter shows. The copy maps `n` back onto
its own copy of the same document, so indices agree by construction.
- `p` — pointer position as slide-fraction `x,y`; `off` ends the stroke.
- Signature texts: `nav.${n}`, `laser.${p}`, `laser.off`, `black.on`,
`black.off` — literal text signed with the presenter's key via `signText()`
from `sync/online.ts`, same shape as `rev.${pub}`.
- Laser is throttled to ~30fps client-side (33ms); the relay's per-socket rate
budget (RATE_BURST 400/10s) covers a full stroke with nav headroom.
- **Rejections are silent** — fire-and-forget control frames, no `refused`
echo; the presenter's UI state is the feedback.

### Relay handling (worker.js)

For non-`w` broadcast rooms: trust-on-first-use pins the first `?w=` signer
key per room (validated `/^[A-Za-z0-9_-]{80,200}$/`), 403 on mismatch.
`controlKeyOk(meta)` verifies frames: signed rooms check the name commitment
(`'w'+sha256b64u(pubRaw) === name`), broadcast rooms check `meta.w ===
pinned signerKey`. The owner's own socket is the only one whose key passes,
so member/viewer sockets cannot broadcast even with a valid sig under their
own key.

- **Storage**: `lastNav` and `lastBlack` persist as single small values (not
in the op log, not in the byte accounting); they die with the room after
~30 idle days. Laser is transient by design — a mid-stroke joiner misses it.
- **Fan-out**: `{ctl:'nav',n}` / `{ctl:'laser',…}` / `{ctl:'black',…}` with no
signature in the copy — clients trust the relay's verification, exactly like
fanned-out ops. Sender excluded.
- **Replay**: `replay()` sends `lastNav` (and `lastBlack`) as its **first**
step, before the snapshot/op reads — a live nav fanned out mid-replay can
only interleave at a later await, so "apply every nav as it arrives" is
race-free: the joiner always ends on the newest slide.
- **Presence**: on connect and on close, the relay fans
`{ctl:'presence', n: viewers}` where viewers = connected sockets whose
pinned key does not commit to the room's owner key (the presenter's own
socket is excluded; collab members count as viewers).
- **Rate limiting**: control frames ride the generic per-socket limiter
(`RATE_BURST` / `RATE_WINDOW_MS`) that runs on every message before parse —
no exemption.

## Broadcast copy (Share menu export)

**Where it lives**: the Share panel, beside "Read-only copy…": **"Save
broadcast copy…"**. One click serializes the current doc with the broadcast
fields embedded and downloads a **new file** (original untouched, rollback
free — same pattern as every other export). The file *is* the viewer; no
hosting, no site generation.

**The mode marker is additive**: the copy embeds
`doc.collab.broadcast = { room, relay }` — nothing in the format changes for
existing files, and old shells ignore the unknown field. The current shell
checks for it at boot: if present → **broadcast-viewer mode** (below), and
the editor never mounts. The copy sets `collab.on:false` explicitly (legacy
shells treat an absent `on` as "on"), gets a **fresh docId** (a derived
artifact, not an identity-keeping copy), and carries **no private keys, no
symmetric key, no sync state**. Encrypted decks export as plaintext (the copy
is a plaintext share by definition, same tradeoff as every export); the
owner's file stays encrypted.

**Stale copy, accepted**: the copy is a build-time snapshot. If the owner
edits after building, an old copy can show an outdated deck and an
out-of-range `n` (the client clamps to the last slide). Rebuild after
material edits.

## Presenter client

- **Toggle**: a button in the speaker view's `.sv-ctrls` toolbar
(`navBtn('broadcast', ICONS.broadcast, t('Broadcast to audience'))`), **off
by default, every show** — presenting locally must never silently broadcast.
Active state + viewer count refresh through `updateSpeakerControls()`.
- **Arm flow** (`toggleBroadcast` in present.ts → helpers in `sync/online.ts`):
resolve the signing key (room derivation above) → open the dedicated
`BroadcastSocket` → send the **current** slide immediately (the opening
slide never fires `slidechanged`, so late joiners must get the starting
position from the stored `lastNav`). From then on the existing
`slidechanged` handler sends `sendNav(visibleIndex(toIdx))`.
- **Broadcast link row**: while armed, and only when `doc.meta.hostClient`
is set, the speaker popup shows a "Broadcast link" row with the hosted
viewer URL `<hostClient>?room=<name>` + a Copy button; a "Set hosting URL"
button appears when the field is missing. `doc.meta.hostClient` lives in
the About dialog's Document properties (additive format field, inherited by
every collaborator's copy) — the broadcast popup never prompts for it.
- **Clipboard**: `navigator.clipboard.writeText` needs focus and user
activation in the document that calls it; the click is in the popup, so
`openSpeaker` appends one small inline script that fills a readonly input
from `postMessage` (`{bento:'broadcast', link|null}`) and copies in popup
context. The editor owns crypto/state; the popup owns clipboard.
- **Teardown**: toggle off, or show exit (`exit()`), closes the socket,
unhooks the flag, resets the button and the popup row. A broadcast never
outlives its show; `lastNav` persists on the relay, so a copy that
reconnects mid-show (or for the next show) lands on the right slide.

## Broadcast client (the copy's runtime)

Boot: `doc.collab.broadcast` present → **broadcast-viewer mode** — the full
present overlay (real Reveal, morphs, fx, the entire renderer) on the embedded
document, with no editor, no autosave, no collab session, no Save path.

- **Connect**: `new WebSocket(relay + '/d/' + room + '?tok=' + derivedTok)`
— no `?w=`: the unauthenticated-for-reads path. `since=0` replays the room
(ciphertext noise the copy ignores, never a crash).
- **Apply**: every `{ctl:'nav', n}` maps `n` onto the deck's own slide order
(the same 1-based, states-excluded numbering) and goes there via the same
goTo machinery the presenter uses, so transitions and morphs play normally.
The replayed `lastNav` lands a mid-show joiner on the current slide; before
the first frame: "Waiting for presenter". Out-of-range `n` clamps to the
last slide.
- **Status chip**: dark corner chip — "Connecting…" / "Waiting for
presenter" / "Live · N viewers" / "Broadcast ended".
- **Reconnect/backoff** copied from `OnlineTransport`: 800ms × 1.8, cap 30s,
reset on open; ping every 25s with pong check so a half-open socket
reconnects instead of hanging. Connection loss shows "Broadcast ended" but
keeps retrying — the presenter may re-present on the same room.
- **Ignore everything else** — collab ciphertext, unknown control frames.
- **Esc** exits to a minimal card (playerMode's exit pattern).

## Hosted client

A broadcast copy can be hosted ONCE on the presenter's server and re-pointed
at any presenter's room via `?room=<name>` — so a replacement presenter takes
over without re-exporting files. The hosted copy additionally joins the deck's
collab room as a live reader replica, so slide content updates in real time
as the deck is edited. Full design: `docs/hosted-broadcast-design.md`.

## Auth model & threat model

- **Read**: the broadcast copy *is* the capability — sharing the file shares
the feed, exactly as "Save read-only copy…" shares the document today. The
room name is a hash of the presenter's public key (256-bit entropy —
unguessable); possession of the URL `?room=<name>` is possession of the
room (the tok is derived from it).
- **Write**: impossible. Control frames require the presenter's signature —
verified against the per-room pinned key; op batches require the writer key
the copy doesn't have.
- **Spoofing**: presenter-signed per frame, verified inline by the relay. A
hostile copy, a link-scraper, and the relay itself can all fabricate
frames — same trust position as every frame in this system, and the worst
case is a wrong slide on one screen.
- **Leakage**: the relay learns the deck exists (room activity), the slide
number, the viewer count — the metadata it already sees for collab rooms;
never a title, never content. The copy itself is a plaintext deck by
construction: the presenter chose to share it.
- **Key rotation** changes the signer key → the pinned broadcast room refuses
the new key until the deck is duplicated as a new deck (new docId, new
room) — accepted limitation.

## Verification

- **Relay**: `npx wrangler dev --port 8787` + `scripts/test-nav.mjs` (Node's
built-in WebSocket, no deps): mints a key, connects presenter + viewer
sockets, asserts unsigned/forged/member-key frames dropped, valid frames
fanned out, `lastNav` replayed ahead of any live frame, presence rises on
connect and falls on close, rate limiter still applies. The relay must be
`wrangler deploy`d for enforcement — same operational rule as every relay
change.
- **Client**: `tsc -b` + `npm run build:single`; manual two-tab session:
`scripts/build-broadcast-example.mjs` builds the owner/copy/hosted fixtures
into `working/broadcast-demo/` → open the copy (boots into present-follow,
"Waiting for presenter") → present + arm → the copy follows slide-for-slide,
transitions included → a second copy opened mid-show lands on the current
slide → disarm → the count drops and the feed goes quiet (copies stay on
the last slide, re-follow on re-arm) → exit ends the broadcast → a stale
copy clamps instead of crashing.
- New UI strings go into **all** i18n catalogs (AGENTS.md hard rule 6).
- No `crdt.ts` changes → `scripts/test-sync.ts` unaffected.
Loading
Loading