Skip to content

sync: transport tidy-up, relay and both clients - #452

Merged
nyblnet merged 5 commits into
mainfrom
kernel-transport-tidy
Sep 13, 2026
Merged

sync: transport tidy-up, relay and both clients#452
nyblnet merged 5 commits into
mainfrom
kernel-transport-tidy

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Kernel change under the serialization lock. Recovers the relay authorization
work from 07263b3 onto today's tree with the kernel review's items applied.
The review itself is private (bento-team handoffs); this body describes the
mechanism, which is in the code either way.

Ready for review. The question of a later revision behind #270's notes
is dissolved, measured by slides: no security advisory in the repo concerns
relay authorization and none of the private repos holds a relay-auth note
beyond this week's — the recovered commit is the whole of what was ever
written, and the blobCreds() line in the issue describes edits that were
lost or misdescribed. Deployed relay SHA: pending — the maintainer will
supply it and it will be recorded here.

What it does

The relay stamps a fanned-out frame with exactly what it verified. q on
a frame it persisted, the sender's g echoed on a signed ephemeral frame it
checked — never a stamp it did not earn. In a w room the client applies op
batches and fork snapshots only when stamped (vouched); r rooms stay on
the older model. The fork snapshot is signed on the way out so it can earn
the stamp. Verified nothing legitimate is unstamped: send() is the only
producer of an ops frame and is unconditionally p:1; the relay stamps q
on live fan-out, not only replay.

Blob uploads take a write ticket; the ticket takes a possession proof.
A hash-match on ?w= certifies a socket for per-frame verification, but the
key it matches is the owner's public key and every copy carries it — so a
ticket issued on the hash-match alone would go to any reader. Now ready
carries a nonce, the client signs prove.<nonce>.<room>, and the ticket
follows on its own wt frame. Room name in the text (no cross-room replay),
nonce single-use (no same-room replay). The per-room latch that turns uploads
ticket-only is set by a proven bt=1 writer, so the relay deploys ahead of
every shipped client without breaking their asset offload. Re-mint on
revocation goes to proven sockets only. Reads keep the room token.

A snapshot cannot claim to cover ops the room has not seen. snap with
q > seq is refused with snap-ahead (naming the frame) rather than stored,
because storing it prunes the log up to q. Nothing is lost on refusal, so
it is logged, not surfaced — and deliberately not added to RefusalCode,
which would have made every app's notice switch non-exhaustive.

The commits, and one flagged as another zone's

  1. 74e66aa — kernel: relay + kernel/src/sync/online.ts + rigs.
  2. f170879dash zone, from the kernel branch, flagged on the board.
    Dash's transport is a deliberate twin of the kernel's, not a facade (it
    runs a different CRDT engine; scripts/test-relay-protocol.ts says why),
    so it cannot inherit the change. The parity guard now blocks merge until
    the two spell the wire identically; the dash session is down; and one
    deployed relay verifies both clients, so the change must reach both apps
    in one release. Accepted by the dash owner after running it
    independently: tsc clean, test-relay-protocol 19/19, test-sync-vouch
    20/20, dash-sync 23107, canvassync 284 — and negative-controlled in dash
    alone (vouched → true with the sabotage present in dash and absent from
    kernel fails exactly the five dash: checks and none of the kernel's),
    which is the result that shows the two transports are guarded
    independently rather than one rig accidentally covering both.
  3. docs — CI registration only. The DECISIONS.md entry and the
    collab-design.md wire-section changes follow at release
    , on the
    maintainer's ruling: they describe the mechanism precisely while the
    deployed relay still lacks it. Their content is parked verbatim in the
    private team repo (handoffs/pr452-docs.md) and promotes to docs/ as a
    mechanical copy once the relay is deployed and the client half has shipped.
  4. f29d21ctest-sync-vouch.ts, the client-side rig (see below).

The wire-parity guard was checking almost nothing

test-relay-protocol.ts listed query parameters that are not on the wire
(room|pub|sig|inv|role|exp) and omitted most that are; its signature-text
alternation lacked dlg.. It reported the two transports identical while
matching tok= alone. Extended to the parameters, signature texts and
control-frame names the worker actually reads — and it then reported exactly
this change's four additions as dash's skew, nothing pre-existing.

Verification

  • test-relay-auth49 (recovered 29 + the review's negatives: a socket
    presenting the owner's public key is challenged, handed no ticket, cannot
    latch; a wrong, replayed, or cross-room proof earns nothing; snap-ahead
    refused with its frame id and nothing pruned; re-mint reaches proven
    sockets only). Mutation-tested: removing the clamp → 5 red; skipping
    proof verification → 3 red.
  • test-sync-vouch20, both transports through a fake socket:
    unstamped ops/snapshots refused, stamped applied, presence unaffected,
    r-rooms permissive. With vouched mutated to return true in both
    transports: 10 of 20 red. (No rig had ever driven the kernel transport —
    it uses parameter properties, which node's strip loader rejects — hence
    esbuild-bundled, the test-sanitize pattern.)
  • test-relay-protocol 19/19 · test-sync 45368 · test-sync-session ·
    -spaces · -spaces-session · -type · -shape · -equiv · -flat ·
    -parent-text · test-offline · test-blobs · test-export-secrets ·
    test-dash-sync 23107 · test-dash-canvassync · test-ci-registered 292.
  • slides, spaces, type, dash all typecheck (tsc -b, exit 0) and build;
    shell-gate passes on the slides shell.

Deploy order — maintainer

Relay first (wrangler deploy in server/sync-worker/), then the
clients through the update channel, both apps in the same release. A
client newer than the relay degrades safely on every path but one: its fork
snapshot, unstamped by an old relay, is refused live and the fork converges
through the persisted log. Bounded, not lossy. The relay must not be deployed
with #293's verbs bundled in — that is the next serialized change, so a
regression in either can be rolled back alone.

No changelog line: a user of a shipped file notices nothing, and the release
wording is the maintainer's. The design-doc changes follow at release (above).


Relay deployed 2026-09-13T01:43:15Z from main at b1b4a67 (this PR's squash). Worker bento-sync, version 62a12ffa-f676-4ae0-94af-1b40f465f3bb, 100% of traffic, sync.bento.page. Deployed by the maintainer's instruction from the maintainer's machine. No client carrying the vouched/prove half has shipped yet; clients follow in one release for both apps.

…en ticket

Recovers the relay authorization work from 07263b3 (issue #270) onto today's
tree, with the kernel review's items applied (handoffs/kernel-review-270.md).

The relay now stamps a fanned-out frame with exactly what it checked: `q` on
a frame it persisted, and the sender's `g` echoed on a signed ephemeral frame
it verified. It never stamps a frame it did not check. In a signed room the
client refuses content-bearing frames (op batches, fork snapshots) that carry
neither stamp, so read-only holds for live peers and not only for the stored
log. Legacy `r` rooms stay on the pre-signing model. The fork snapshot is
signed on the way out for the same reason op batches are.

Blob uploads take a write ticket rather than the room token; reads still take
the token. The ticket is issued over the socket — and only to a socket that
PROVES the key it presented: `ready` carries a nonce, the client signs
`prove.<nonce>.<room>`, and the ticket follows on its own frame. A hash-match
on `?w=` alone certifies a socket for per-frame verification but is not proof
of possession, since the key it matches is public and in every copy. The
per-room latch that turns uploads ticket-only is set by a proven, ticket-
capable writer, so the relay deploys ahead of every shipped client without
breaking their asset offload.

A snapshot claiming a seq the room has not reached is refused with a code
(`snap-ahead`) rather than stored, since storing it would prune the whole op
log. Nothing is lost on refusal, so it never reaches SyncNotice.

Client half is in kernel/src/sync/online.ts (slides' file is a facade). It
reconnects on an older relay without change: no nonce means no proof, uploads
take the token as before, and a fork snapshot degrades to log convergence
until the relay is deployed.

Rigs: test-relay-auth 49 (recovered 29 + the review's negatives: a socket
presenting the owner's public key is challenged, not handed a ticket, and
cannot latch; a wrong or replayed proof earns nothing; snap-ahead refused with
its frame id and nothing pruned). Mutation-tested — removing the clamp or the
proof check turns it red. test-relay-protocol extended to the parameters and
signature texts actually on the wire; it listed several that were not and was
matching `tok=` alone. test-sync 45368, session/spaces/type/shape/equiv/flat/
parent-text/offline/blobs/export-secrets green. slides, spaces, type
typecheck.

Deploy order: the relay first, then the client through the update channel.
DASH ZONE, from the kernel branch, and flagged as such on the board. The
kernel brief says a kernel PR hands the app half to the zone owner; this
commit is that half, for the dash owner to accept or replace, landed here
because (1) dash's transport is a deliberate twin of the kernel's rather than
a facade — it runs a different CRDT engine (scripts/test-relay-protocol.ts
says why at length) — so it cannot inherit the change; (2) that guard now
compares the parameters and signature texts actually on the wire and blocks
merge until the two match; (3) the dash session is down; and (4) the change
has to reach both apps in the same release, since one deployed relay verifies
both clients.

What moved, mirroring the kernel commit before this one: `bt=1` on both
writer URLs; the `ready` challenge answered with `prove.<nonce>.<room>` and
the ticket taken from its own `wt` frame (never from `ready`); `wt` re-minted
on `revoked`; `vouched` — in a `w` room, op batches and fork snapshots are
applied only when the relay stamped them (`q` or echoed `g`); the fork
snapshot signed on the way out; `snap-ahead` recognised and logged. dash does
not offload assets yet, so `blobCreds()` and `writeReady()` are the accessor
the day it does, not a behaviour change today.

test-relay-protocol 19/19 (the two clients spell the wire identically),
test-dash-sync 23107, test-dash-canvassync 284; dash typechecks.
collab-design.md: the "Relay enforcement" block and the Phase 1 wire section
now describe the shipped mechanism — the nonce on ready, `prove`, the ticket
on its own frame, the latch set only by a proven writer, the snapshot clamp —
and no longer say the ticket rides on `ready`, which it never must. The
possession signature text joins the list the wire-parity guard holds both
clients to.

DECISIONS.md: the three rules, why the latch stays, the deploy order, and the
finding that the parity guard had been matching `tok=` alone.

ci.yml: test-relay-auth.ts registered, beside the protocol guard.
@nyblnet nyblnet changed the title sync: relay stamps, blob write tickets with possession proof, snapshot clamp sync: transport tidy-up, relay and both clients Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Build size

main (accf858) → kernel-transport-tidy (dae68d2)

app base PR change
bento/slides 678.5 KiB 678.6 KiB +0.2 KiB (+0.02%)
bento/spaces 271.3 KiB 271.6 KiB +0.4 KiB (+0.13%)
bento/dash 424.1 KiB 424.4 KiB +0.3 KiB (+0.08%)

Updated: 2026-09-13T01:23:36Z

…esting

scripts/test-sync-vouch.ts drives a real OnlineTransport — the kernel's and
dash's twin, with the same frames — through a fake socket in a signed room.
An unstamped op batch and an unstamped fork snapshot are refused; the same
frames carrying the relay's `q` or echoed `g` are applied; a persisted
{snap:1} follows the same rule; presence keeps flowing unstamped; a legacy
r-room applies everything. 20 checks.

Why it exists: with `vouched()` mutated to `return true`, every rig in the
tree stayed green — the relay rig cannot see a client decision, and no
session rig drives the online transport. Security found that by mutation.
Under the same mutation this rig fails 10 of 20, five per transport.

Bundled with esbuild, as test-sanitize is: kernel/src/sync/online.ts uses
constructor parameter properties, which node's strip-only loader rejects —
which is why dash's twin avoids that syntax, and why no rig had ever driven
the kernel transport before this one.
…e client half

DECISIONS.md and collab-design.md are reverted to main on this branch. Their
content is parked, verbatim, in the private team repo and is promoted to
docs/ with the release — a mechanical copy at that point.
@nyblnet
nyblnet marked this pull request as ready for review September 13, 2026 01:31
@nyblnet
nyblnet merged commit b1b4a67 into main Sep 13, 2026
4 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
APP-OWNED files, from the kernel branch, flagged like nyblnet#452's dash half — one
line each, for the slides and type owners to accept or fold into their own
work. The kernel's CollabInvite.role gained 'audience' (an audience ticket is
an owner-signed invite of that role); each app's model.ts pins its own
collab.invite.role and would otherwise not compile against the widened kernel
type. slides is already building the client half on niemes/live-broadcast and
its branch will carry this same line — identical, so it merges either order.
No behaviour change; a type widening only.
nyblnet added a commit that referenced this pull request Sep 13, 2026
…ch the show

Two from security's review of the client half.

(1) The receive-only guarantee held for send() only. sendVerb/sendAud/
sendAudSnap/setShowKey had no audience guard, so on an audience transport
setShowKey + the three senders put five frames on the wire (live/nav/laser/
aud/audsnap). Unreachable from the shipped boot and the relay drops them, but
"not sending is the guarantee" must hold for all four senders, not one of four.
All four now no-op in audience mode; audienceMode is decided in the constructor
(from the auth) so it is set before any call; the transport exposes `audience`;
and startShow refuses on an audience transport. test-sync-vouch drives all four
senders on an audience socket and asserts the wire stays empty — removing the
guards turns it red.

(2) projectToAudience ran on LOCAL flush only, so a CO-PRESENTER's edits
reached the audience only at the next checkpoint. applyRemote now projects too;
only the presenter has a show config, so it fires for the presenter's session
alone (the audience reaches applyRemote via applyShowOps and has no config).

For the PRs' deploy note (ordering, not work — the live relay is already #453):
a client release must follow the #453 relay. On a #452-era relay the room token
is stored before the chain check, so an audience socket landing first on a fresh
room would poison the token and 403 the presenter until the idle wipe; #453
skips the token store for the audience role, which is why it must be live first.

All sync/relay rigs green; slides/spaces/type typecheck.
nyblnet added a commit that referenced this pull request Sep 13, 2026
* sync: the session's broadcast surface, on a second key

The client-side kernel half of live broadcast (#293), needed by slides'
dispatched client. A show is collaboration with a second key: the presenter's
transport keeps the room key and gains a per-show key Ke; the audience is a
reader whose own key IS Ke.

Session surface (kernel/src/sync/session.ts):
- startShow({ showKey, projectOp, snapshot }) installs Ke on the online
  transport, waits until the socket may write, sends `live`, then a first
  audsnap; endShow sends `end` and drops the key. `session.show` is the
  live verb surface (nav/black/laser), null when off. onShow(fn) delivers
  verbs, count, checkpoint and close as typed events.
- Each local op batch is run through the injected projectOp and the survivors
  sent under Ke, tagged s:'aud'. The kernel never learns an app's field names:
  projectOp and snapshot are the app's.
- THE AUDSNAP STATE IS A FRESH ADOPT of the projected doc, never the live or
  stampInto state. Security's finding: those carry `stash` (dead-window values,
  e.g. deleted slides' notes) and the full per-character text history, neither
  of which the projection removed. freshAudState rebuilds position/birth
  registers from the projected doc alone — no stash, no txt.

Transport (kernel/src/sync/online.ts):
- a second key + sendAud/sendAudSnap/sendVerb; encrypt() takes the key.
- an AUDIENCE transport (collab.role 'audience', ivr=audience on the chain the
  owner signed) is RECEIVE-ONLY: it puts no protocol frame on the wire, and
  reads the relay's close codes — 4001/1008 terminal (no reconnect), 4002/4003
  transient ("waiting for the presenter"). Aud ops/snaps/verbs surface via
  hooks; they bypass vouched() by design (a relay-routed, Ke-sealed stream, a
  different trust path from the room's read-only guarantee).
- CollabCreds.role and invite.role gain 'audience' (crdt.ts).

Rigs: test-sync-show (28, session surface — the fresh-adopt guard is
mutation-caught two ways: live-state leak → 3 red, projection skipped → 1
red; byte-identical to an independent adopt of the projected doc, stash and
txt asserted empty while the LIVE state carries both). test-sync-vouch gains
the audience transport (30 total; receive-only proven by contrast with a
normal reader whose send DOES reach the wire, close-code routing, aud→onShowOps
— gate removal, terminal-reconnect and close-suppression each go red). All
sync/relay rigs green; test-relay-protocol 19 (broadcast is slides-only, not
on dash's shared wire); slides/spaces/type/dash typecheck; shell-gate passes.

Deploys after the relay already carries the verbs (#453, live as f997be57).
The client half (slides) lands separately.

* slides, type: widen collab.invite.role to include 'audience'

APP-OWNED files, from the kernel branch, flagged like #452's dash half — one
line each, for the slides and type owners to accept or fold into their own
work. The kernel's CollabInvite.role gained 'audience' (an audience ticket is
an owner-signed invite of that role); each app's model.ts pins its own
collab.invite.role and would otherwise not compile against the widened kernel
type. slides is already building the client half on niemes/live-broadcast and
its branch will carry this same line — identical, so it merges either order.
No behaviour change; a type widening only.

* sync: ShowConfig.snapshot returns the projected doc only

The type asked the app for a { doc, state } but startShow reads only snap.doc
and builds the state itself via freshAudState (the security-required fresh
adopt). Narrow it to { doc } so the app cannot supply a state it must not —
slides was passing `state: undefined as never`. Caught by slides against the
built client half.

* sync: an audience transport sends nothing, and co-presenter edits reach the show

Two from security's review of the client half.

(1) The receive-only guarantee held for send() only. sendVerb/sendAud/
sendAudSnap/setShowKey had no audience guard, so on an audience transport
setShowKey + the three senders put five frames on the wire (live/nav/laser/
aud/audsnap). Unreachable from the shipped boot and the relay drops them, but
"not sending is the guarantee" must hold for all four senders, not one of four.
All four now no-op in audience mode; audienceMode is decided in the constructor
(from the auth) so it is set before any call; the transport exposes `audience`;
and startShow refuses on an audience transport. test-sync-vouch drives all four
senders on an audience socket and asserts the wire stays empty — removing the
guards turns it red.

(2) projectToAudience ran on LOCAL flush only, so a CO-PRESENTER's edits
reached the audience only at the next checkpoint. applyRemote now projects too;
only the presenter has a show config, so it fires for the presenter's session
alone (the audience reaches applyRemote via applyShowOps and has no config).

For the PRs' deploy note (ordering, not work — the live relay is already #453):
a client release must follow the #453 relay. On a #452-era relay the room token
is stored before the chain check, so an audience socket landing first on a fresh
room would poison the token and 403 the presenter until the idle wipe; #453
skips the token store for the audience role, which is why it must be live first.

All sync/relay rigs green; slides/spaces/type typecheck.
nyblnet added a commit that referenced this pull request Sep 13, 2026
* feat(relay): broadcast control frames — nav, laser, black; rate budget 400/10s

Owner-signed plaintext control frames beside the collab path: {ctl:'nav',n,g}
(sig over nav.${n}), {ctl:'laser',p,g} / {ctl:'laser',off:1,g} (sig over
laser.${p} / laser.off, slide-fraction point, never stored), and
{ctl:'black',on:1|0,g} (sig over black.on/black.off, persisted as lastBlack
and replayed to late joiners). Every block verifies the signature against
the socket's OWN pinned key AND that the key hash-commits to the room name —
a member/chain socket's key never hash-matches, so only the room owner can
broadcast. RATE_BURST 200→400: a 30fps laser stroke is 300 frames/10s; the
count limiter is an abuse guard, laser frames are ~60 bytes so the byte
budget is the real cap.

* feat(sync): broadcast socket, creds and signed control frames

BroadcastSocket: lightweight WS (viewer ?tok=&since=0, owner ?w=<pub>&tok=
&since=0), backoff 800ms×1.8 cap 30s, ping 25s; ignores everything except
nav/presence/laser/black frames so it safely replays ciphertext noise from
collab rooms. resolveBroadcastCreds: case 1 reuses the EXISTING collab room
and signs with the owner key; case 2 mints a device-local broadcast-only
room (bento-broadcast-<docId> in localStorage, fresh keypair + room key —
the private key never leaves the machine). Transport-agnostic sendNav/
sendLaser/sendBlack helpers + onCtl passthrough on OnlineTransport for the
directOwner path.

* feat(client): broadcast copy export — collab.broadcast creds + UI strings

Share menu gains 'Broadcast copy…': exports a standalone .bento.html that
boots straight into present-follow mode, carrying only {room, tok, relay} —
no owner key, no symmetric key, no CRDT state. model.ts: collab.broadcast
field (additive, old shells ignore it) and room/key made optional for
broadcast-only copies. New broadcast icon + 13 UI strings across all 9
catalogs.

* feat(present): broadcast presenter — nav sync, hold-to-draw laser, black screen, remote trail

Speaker view gains a broadcast arm/teardown toggle (📡): on arm, current
slide is sent and every slidechange fans out an owner-signed nav frame;
viewer-count badge + popup clipboard script. Laser became hold-to-draw (L
arms, pointer-down draws) and streams slide-fraction points at ~30fps
(33ms throttle, Excalidraw's CURSOR_SYNC_TIMEOUT) with an off frame on
release; black-screen toggle syncs and replays to late joiners. Broadcast
copies re-synthesize the trail Excalidraw-style: a rAF sampler feeds the
dot's RENDERED position into the shared trail ring buffer so the head stays
glued to the pointer (never pre-baked ahead of it), 0.8s fade, and a
re-shown dot snaps to the new stroke instead of tweening a ghost line
across the gap. B toggles black from the audience document too; text
selection is locked while the laser is armed.

* feat(main): broadcast copy boot + ?b= re-point override

Copies carrying doc.collab.broadcast boot straight into a locked follow-mode
viewer: real present overlay, read-only broadcast socket driving navigation,
status chip (connecting / waiting / live · N viewers). ?b=<viewer url> on
the copy's own URL re-points it at ANOTHER broadcaster's room — e.g. the
owner takes over a presentation the exporter can't do. The relay's auth is
a per-room trust-on-first-use token, so the room alone is not a capability:
the owner's broadcast link (relay + room + tok) must come along; everything
after ?b= is that link, raw. The copy stays a passive viewer — no signing
key, view access only. Malformed overrides fall back to the embedded room.

* test(relay): control-frame suite — nav, laser, black, replay

23 asserts against a local relay: unsigned/forged/member-key drops, valid
owner nav/laser/black fan-out, laser off, lastBlack replay to late joiners,
laser non-replay, rate limiter intact.

* chore(scripts): broadcast demo fixture builder

Builds two self-contained .bento.html fixtures (owner + broadcast copy)
minted with real case-1 collab credentials for manual testing against a
local relay.

* docs: broadcast design, plan, decisions

docs/broadcast-design.md: nav-frame + broadcast-room model, threat model,
wire format. docs/broadcast-plan.md: 7-phase implementation plan with
verified anchors. DECISIONS.md: live-broadcast control channel (laser +
black frames) and the ?b= re-point override entries.

* docs: hosted broadcast client design

* docs: hosted broadcast implementation plan

* feat: doc.meta.hostClient — hosted broadcast copy URL

* feat: hostedLink() — mint hosted broadcast client URLs

* feat: hosted broadcast export — reader creds + hosting URL prompt

* feat: speaker view hosted-link row

* fix: speaker hosted-link row — register copy listener once

* feat: hosted broadcast client — ?room=&tok= re-point + live reader replica

* feat: i18n — hosted broadcast strings in all catalogs

* feat: add hosted copy to broadcast demo fixture

* docs: hosted broadcast client decision

* docs: fix hosted broadcast plan Task 4 Step 3 — data scope inside message listener

* fix: hosted broadcast final review — packed i18n, boot-gap render, teardown, docs

* feat: speaker broadcast popup — one viewer link, set-host from popup

* fix: remove stale viewerUrl reference and clean up broadcast listener on teardown

* fix: purge broadcastLink/viewerUrl reference in broadcast plan; dedupe popup script listeners

* fix: speaker popup script regex — escape backslashes for template literal

The setHost validation regex was written as /^https?:\/\//i inside the
bcastScript template literal, where \/ is an identity escape that emits
/, so the injected script contained /^https?:///i and failed to parse —
the popup never bound its message listener and the broadcast link row
stayed dead. Escape as \\/ in the template so the emitted script is a
valid regex literal.

* feat: derive broadcast rooms from the presenter's signing key

Owner decks sign with the owner key, shared editor copies with their
per-copy invite key, legacy copies with the writer key, and non-collab
decks with a device-local key — so every presenter broadcasts into their
own room (the docId-derived room collided across invite copies). The
connect token is derived from the room name, so URLs and files carry no
secret; the relay TOFU-pins the signer key per room and verifies
nav/laser/black frames against it.

* fix: hide speaker broadcast row when off; use editor broadcast icon

The row and viewer-count badge set display:flex/inline-block, which
overrode the UA's [hidden]{display:none} — the empty URL row stayed
visible with the broadcast off. The [hidden] variants now restate
display:none. The broadcast button also reuses the editor's broadcast
SVG icon instead of the 📡 emoji, and openSpeaker re-posts the link so
a popup reopened mid-broadcast shows it.

* docs: broadcast rooms derived from the presenter's signing key

* docs: broadcast design — implementation facts, decisions consolidated, plans removed

* fix: relay — broadcast presence excludes the presenter, strict laser point format

* refactor: sync — drop dead OnlineTransport broadcast senders and stale comments

* fix: present — reconnect sends the current slide, generic arm-failure toast, popup style dedup

* fix: export — broadcast copy is a plaintext snapshot, plain-http hosting URL refused

* i18n: broadcast — scoped copy keys, Broadcast failed

* test/docs: relay burst budget, fixture script, changelog entry

* fix: use laser icon because U+1F7D2 dont render on MacOS

* broadcast: open the socket through net.ts, so the offline switch holds

The offline rig failed on the relocated transport: "no file outside
kernel/src/net.ts touches a network primitive". BroadcastSocket opened a raw
WebSocket. That was true on the original branch too — it was invisible only
because slides/src/sync/online.ts was the allowlisted transport file then — so
a viewer who had switched on "nothing leaves this computer" would still have
connected to the relay. netWebSocket() is the one chokepoint that keeps that
promise; it throws OfflineError, which the existing catch treats like any
failure to connect. 33/33.

* regenerate modelkeys.generated.ts for doc.broadcast

The model gained a top-level field and the key table is generated from the
model; CI's 'Model key tables match the format' step caught the omission.

* broadcast: the audience projection — one module for the handout, the join snapshot and the live stream

An audience copy and the aud stream keep back speaker notes and review
comments and nothing else; hidden slides stay. slides/src/audience.ts
owns the boundary (AUDIENCE_HIDDEN), projectDoc builds both the handout
and the snapshot, projectOp filters the presenter's mid-show ops so a
stripped snapshot is never repaired by the next notes edit. Blobs are
dropped (a show-key copy cannot open room-key blobs) and the bytes stay
inline; the room key appears nowhere in the copy. The presenter's file
gains collab.audience (the reusable ticket), additive.

scripts/test-audience-projection.ts pins all of it, 37 checks, and CI
runs its negative control first: with the boundary emptied, 12 go red.

* broadcast projection: the layouts register is projected on the wire too, and the audience role is its own

Security drove the real engine and found doc.layouts diffs as ONE
doc-level whole-value register, which no slide-scoped rule saw — "Save
slide as layout" mid-show would have streamed the layout's notes and
comments while the handout from the same doc was clean. projectOp now
projects every entry of a slide-shaped list register (AUDIENCE_SLIDE_LISTS),
under the rule that every place projectDoc projects, projectOp must
project the op carrying the same content. The rig gains an engine-driven
section: each hidden-carrying edit the editor can make is diffed by the
CRDT, projected, and the wire searched for a sentinel — content-agnostic,
so it catches a register it does not know about. 69 checks; 20 red under
the negative control.

collab.role on an audience copy is the distinct 'audience', not 'reader':
every reader check would otherwise join the room path and boot the locked
editor, and the boot path is the show.

* broadcast: "Audience copy…" and "Issue new tickets…" replace the broadcast copy

The hand-out for a live show is now the audience projection written
through serializeAuto: an owner-signed audience invite plus a per-show
key as collab.key, no room key, no private halves, no speaker notes, no
comments, blobs inlined. The ticket is minted once per deck
(collab.audience, presenter's copy only) and reused for every show;
"Issue new tickets" re-mints it, revokes the old invite at the relay,
and warns that every outstanding copy stops working. The hosting-URL
field and doc.broadcast's goLive guard go with the design they served.

test-export-secrets pins the new path by shape (projectDoc, never the
live doc, serializeAuto) and by running the projection on a deck that
carries everything it must lose. 64 checks. i18n catalogs follow in the
client half's final pass.

* broadcast: drop nav-check.mjs — the relay design it checked (TOFU pinning, derived rooms) is superseded by #453 on main

* sync: the session's broadcast surface, on a second key

The client-side kernel half of live broadcast (#293), needed by slides'
dispatched client. A show is collaboration with a second key: the presenter's
transport keeps the room key and gains a per-show key Ke; the audience is a
reader whose own key IS Ke.

Session surface (kernel/src/sync/session.ts):
- startShow({ showKey, projectOp, snapshot }) installs Ke on the online
  transport, waits until the socket may write, sends `live`, then a first
  audsnap; endShow sends `end` and drops the key. `session.show` is the
  live verb surface (nav/black/laser), null when off. onShow(fn) delivers
  verbs, count, checkpoint and close as typed events.
- Each local op batch is run through the injected projectOp and the survivors
  sent under Ke, tagged s:'aud'. The kernel never learns an app's field names:
  projectOp and snapshot are the app's.
- THE AUDSNAP STATE IS A FRESH ADOPT of the projected doc, never the live or
  stampInto state. Security's finding: those carry `stash` (dead-window values,
  e.g. deleted slides' notes) and the full per-character text history, neither
  of which the projection removed. freshAudState rebuilds position/birth
  registers from the projected doc alone — no stash, no txt.

Transport (kernel/src/sync/online.ts):
- a second key + sendAud/sendAudSnap/sendVerb; encrypt() takes the key.
- an AUDIENCE transport (collab.role 'audience', ivr=audience on the chain the
  owner signed) is RECEIVE-ONLY: it puts no protocol frame on the wire, and
  reads the relay's close codes — 4001/1008 terminal (no reconnect), 4002/4003
  transient ("waiting for the presenter"). Aud ops/snaps/verbs surface via
  hooks; they bypass vouched() by design (a relay-routed, Ke-sealed stream, a
  different trust path from the room's read-only guarantee).
- CollabCreds.role and invite.role gain 'audience' (crdt.ts).

Rigs: test-sync-show (28, session surface — the fresh-adopt guard is
mutation-caught two ways: live-state leak → 3 red, projection skipped → 1
red; byte-identical to an independent adopt of the projected doc, stash and
txt asserted empty while the LIVE state carries both). test-sync-vouch gains
the audience transport (30 total; receive-only proven by contrast with a
normal reader whose send DOES reach the wire, close-code routing, aud→onShowOps
— gate removal, terminal-reconnect and close-suppression each go red). All
sync/relay rigs green; test-relay-protocol 19 (broadcast is slides-only, not
on dash's shared wire); slides/spaces/type/dash typecheck; shell-gate passes.

Deploys after the relay already carries the verbs (#453, live as f997be57).
The client half (slides) lands separately.

* slides, type: widen collab.invite.role to include 'audience'

APP-OWNED files, from the kernel branch, flagged like #452's dash half — one
line each, for the slides and type owners to accept or fold into their own
work. The kernel's CollabInvite.role gained 'audience' (an audience ticket is
an owner-signed invite of that role); each app's model.ts pins its own
collab.invite.role and would otherwise not compile against the widened kernel
type. slides is already building the client half on niemes/live-broadcast and
its branch will carry this same line — identical, so it merges either order.
No behaviour change; a type widening only.

* broadcast: the show, on the collaboration session — Live/Lock, follow mode, the audience boot path

The client half of live broadcast, built on the session's show surface
(#454) against the deployed relay (#453). No transport of its own:
present.ts hands every wire concern to the session and owns only what
is visible.

Presenter: a Live toggle in the speaker view, off on every show, that
starts the show with the audience ticket and the projection injected;
a Lock toggle whose title says what it is not ("It does not hide the
rest of the deck, which they already have"); the audience count; nav by
slide ID with the visible index as fallback; laser at ≤ 20 fps with
pen-up always sent. Audience: main.ts boots an audience copy
(role 'audience') straight into the show over the session's receive-only
path, the deck re-renders live as the presenter edits, and a follow chip
lets the viewer browse and snap back unless the presenter has locked;
cards for waiting / ended / ticket no longer valid. The pure decisions
live in slides/src/follow.ts, driven by scripts/test-broadcast-follow.ts
(26 checks, including the mid-talk insert that moved every viewer under
the old index-based design).

Deleted with the old shape: slides/src/broadcast.ts, doc.broadcast and
meta.hostClient, the hosted client, the old design docs. docs/
broadcast-design.md now describes the shipped shape; DECISIONS.md
records the client half's reasoning; the changelog entry is rewritten;
25 new strings in all eight catalogs, the old broadcast strings removed.
Build and splice gate green.

* sync: ShowConfig.snapshot returns the projected doc only

The type asked the app for a { doc, state } but startShow reads only snap.doc
and builds the state itself via freshAudState (the security-required fresh
adopt). Narrow it to { doc } so the app cannot supply a state it must not —
slides was passing `state: undefined as never`. Caught by slides against the
built client half.

* broadcast: snapshot hands the session the projected doc only, matching #454's narrowed type

* broadcast: the audience ticket store is presenter-only, stripped like the other private halves

stripCollabSecrets dropped writerPriv, ownerPriv and invite and nothing
else, so a read-only or invite copy carried collab.audience — the show
key and the audience invite's private half, with which a reader could
mint audience tickets the presenter never issued (security, 2026-09-13).
Deleted beside the other three. test-export-secrets now discovers
private material by SHAPE — any collab field whose declared type carries
a priv key — so the next nested keypair is caught without being listed;
negative-controlled by removing the delete (64/65).

* sync: an audience transport sends nothing, and co-presenter edits reach the show

Two from security's review of the client half.

(1) The receive-only guarantee held for send() only. sendVerb/sendAud/
sendAudSnap/setShowKey had no audience guard, so on an audience transport
setShowKey + the three senders put five frames on the wire (live/nav/laser/
aud/audsnap). Unreachable from the shipped boot and the relay drops them, but
"not sending is the guarantee" must hold for all four senders, not one of four.
All four now no-op in audience mode; audienceMode is decided in the constructor
(from the auth) so it is set before any call; the transport exposes `audience`;
and startShow refuses on an audience transport. test-sync-vouch drives all four
senders on an audience socket and asserts the wire stays empty — removing the
guards turns it red.

(2) projectToAudience ran on LOCAL flush only, so a CO-PRESENTER's edits
reached the audience only at the next checkpoint. applyRemote now projects too;
only the presenter has a show config, so it fires for the presenter's session
alone (the audience reaches applyRemote via applyShowOps and has no config).

For the PRs' deploy note (ordering, not work — the live relay is already #453):
a client release must follow the #453 relay. On a #452-era relay the room token
is stored before the chain check, so an audience socket landing first on a fresh
room would poison the token and 403 the presenter until the idle wipe; #453
skips the token store for the audience role, which is why it must be live first.

All sync/relay rigs green; slides/spaces/type typecheck.

* broadcast: CLAUDE.md returns to main's text — the paragraph described the superseded design (derived rooms, TOFU, the hosted client); docs/broadcast-design.md carries the shipped shape

---------

Co-authored-by: nyblnet <306268465+nyblnet@users.noreply.github.com>
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