oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1# Meet slice 2: holding screen and room, direct connectivity — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Two people on the same network open a meeting link, set up camera and microphone on the holding screen, and see and hear each other in the room, with a guest waiting to be admitted by a member; every server-side rule of spec §2.4, §2.5, §4 and §5.1 enforced and tested, and a Playwright gate proving a real call.
6
7**Architecture:** An in-process hub (`internal/calls/hub`) owns rooms, participations, admission, the cap, pair generations, replay rings and the tick, and knows nothing about HTTP. Two handlers wrap it: an SSE stream per peer and a JSON `POST /signal`. Two hand-written scripts do the rest in the browser: `devices.js` for the holding screen and `room.js` for signalling, perfect negotiation, resume and reconciliation. Media never touches the server. `CARLOS_RELAY` is absent in this slice, so calls connect on host candidates only, which is what the localhost browser gate exercises.
8
9**Tech Stack:** Go 1.26, rastrillo v0.26.0, chi, gorm/SQLite (unchanged schema), `html/template`, vanilla JavaScript (ES2020, no bundler), Playwright 1.62 driven from a Go test through `node`.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` revision 8: §2.4, §2.5, §3.3, §3.4, §3.5, §4.1 to §4.6, §5.1, §9, §12 item 2, §13 slice-2 lines. The programme: `docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md` §6.
12
13## Global Constraints
14
15- Module `amadan.net/oficina/meet`; framework `amadan.net/rastrillo/rastrillo v0.26.0`; `CGO_ENABLED=0`; `make ci` green at the end of every task; `make browser` (Playwright) green at the end of Tasks 8, 9 and 10.
16- Media never touches the server: no handler accepts a media byte; the hub reads a message's `type`, `from`, `to`, `seq`, `gen` and byte length and never its payload.
17- No payload, chat text, SDP, candidate, display name, title or link token is ever a log argument (spec §4.4); the captured-log test proves it.
18- Authorization per endpoint is exactly spec §2.5's table: entry (`join`, fresh stream) checks standing; everything after admission checks the participation; `admit` and tick promotion re-derive member kind and check the target's entry authority under the hub lock.
19- Recipients per message type are exactly spec §4.3's table; a waiting guest receives `hello`, `state`, then `roster` on admission, or `lost`, or `ended`, and nothing else.
20- Limits (spec §4.4): 64 KB body, 30 messages per second with burst 60 per peer (429 with `Retry-After`), sixteen participations per room, eight waiting, one peer per session per room, four streams per person, 64-event subscriber buffer, unjoined and resume windows of thirty seconds, replay ring of thirty-two per participation, tombstones ten minutes.
21- The cap is four admitted peers, enforced at every transition into admitted under the hub lock, never on resume.
22- `seq` is monotonic per (sender, target) for the life of a participation; `gen` is per unordered pair, held by the hub, starting at one.
23- Destructive actions (remove, takeover) are confirm pages with `ui/confirm-form` and POSTs under `csrf.Protect`; a `GET` never ends a participation.
24- Every page still works with scripts off; the one exception, browser media participation, is written into `AGENTS.md` in Task 6.
25- Copy review is waived for this round (suite #5); new strings are still catalogued in `docs/superpowers/copy/2026-09-10-slice-2-strings.md` in Task 11.
26- Commits end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2`.
27- Run from the slice worktree with `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache`; one git command per shell call; never `git add .claude/`.
28
29---
30
31## File structure
32
33| File | Responsibility |
34|---|---|
35| `internal/calls/hub/hub.go` | `Hub`, `Room`, `Participation`, peer ids, boot id, locks, limits, tombstones, `Open`/`Resume`/`Close`. |
36| `internal/calls/hub/messages.go` | `Message` envelope, `Type` constants, `Roster` and its builders, recipient rules. |
37| `internal/calls/hub/admission.go` | `Join`, `Admit`, `Leave`, `Remove`, `Takeover`, `End`, the cap, waiting queue, kind transitions, `Tick`. |
38| `internal/calls/hub/relay.go` | `Relay` (targeted, `seq`/`gen` checked), `Reset`, pair generations, replay rings, `gap`. |
39| `internal/calls/hub/subscriber.go` | Per-participation channel with the 64-event buffer and the `lost {reason: "buffer"}` outcome. |
40| `internal/calls/hub/*_test.go` | The §13 hub gates, no HTTP. |
41| `internal/calls/stream.go` | `GET /m/{sqid}/stream`: SSE, `hello {peer, boot}`, heartbeat, TTL, resume answers 404/409/410/503. |
42| `internal/calls/signal.go` | `POST /m/{sqid}/signal`: body limit, rate limit, type dispatch, refusal logging. |
43| `internal/calls/participation.go` | `Standing`-or-participation middleware for `/m/{sqid}` pages; `standingKind` adapter the hub calls on tick. |
44| `internal/calls/handlers_room.go` | Room page, removal and takeover confirm pages and POSTs. |
45| `internal/calls/templates/room.html`, `confirm.html` (reused), `join.html` (extended) | Server-rendered halves. |
46| `internal/calls/static/devices.js` | Holding screen: enumerate, preview, pickers, mute state, local storage, join button. |
47| `internal/calls/static/room.js` | SSE client, outbound queue, perfect negotiation, roster reconciliation, resume, terminal states, admission UI. |
48| `internal/calls/static/calls.css` | The grid, tiles and controls; vendored beside rastrillo's sheets. |
49| `test/browser/call.mjs`, `package.json`, `Makefile` `browser` target, `internal/callstest/browser_test.go` (`//go:build browser`) | The Playwright gate. |
50| `AGENTS.md`, `docs/superpowers/copy/2026-09-10-slice-2-strings.md` | The scriptless exception and the copy catalogue. |
51
52---
53
54### Task 1: Hub core: rooms, participations, peer ids, limits
55
56**Files:**
57- Create: `internal/calls/hub/hub.go`, `internal/calls/hub/subscriber.go`, `internal/calls/hub/messages.go`, `internal/calls/hub/hub_test.go`
58
59**Interfaces:**
60- Produces:
61 ```go
62 package hub
63 type PeerID string // 16 random bytes, hex, issued by Open
64 type Kind int // KindGuest, KindMember
65 type Reason string // "buffer","replaced","standing","expired","removed","ended"
66 type Config struct{ Now func() time.Time; Logger *slog.Logger; StandingFor func(ctx context.Context, room string, personID int64) (Kind, bool, error) }
67 func New(cfg Config) *Hub
68 func (h *Hub) Boot() string
69 type Participation struct{ Peer PeerID; Room string; Session string; PersonID int64; Name string; Kind Kind; Admitted bool; JoinedAt time.Time /* unexported: sub, resumeUntil, seqOut map[PeerID]uint64, ring, lastSeen map[PeerID]uint64 */ }
70 type Message struct{ Type string `json:"type"`; From PeerID `json:"from,omitempty"`; Name string `json:"name,omitempty"`; To PeerID `json:"to,omitempty"`; Seq uint64 `json:"seq,omitempty"`; Gen uint64 `json:"gen,omitempty"`; Payload json.RawMessage `json:"payload,omitempty"`; Reason Reason `json:"reason,omitempty"`; Roster *Roster `json:"roster,omitempty"`; Boot string `json:"boot,omitempty"`; Peer PeerID `json:"peer,omitempty"`; Waiting bool `json:"waiting,omitempty"` }
71 type Roster struct{ Peers []RosterPeer `json:"peers"`; Waiting []RosterPeer `json:"waiting,omitempty"` }
72 type RosterPeer struct{ Peer PeerID `json:"peer"`; Name string `json:"name"`; Kind string `json:"kind"`; Gen uint64 `json:"gen,omitempty"` }
73 type Sub struct{ /* C() <-chan Message; Lost() bool; Close() */ }
74 var ErrRoomFull, ErrSessionHasPeer, ErrPersonStreams, ErrUnknownPeer, ErrNotYours error
75 type OpenResult struct{ Peer PeerID; Sub *Sub; Existing PeerID /* set when ErrSessionHasPeer */ }
76 func (h *Hub) Open(room, session string, personID int64, name string, kind Kind) (OpenResult, error) // fresh stream: creates an unjoined participation
77 type ResumeResult struct{ Sub *Sub; Admitted bool; Replay []Message; Gap []PeerID; Roster *Roster }
78 type ResumeError struct{ Status int; Reason Reason; Boot string } // 404 with Boot, 410 with Reason
79 func (h *Hub) Resume(room, session string, peer PeerID, last map[PeerID]uint64) (ResumeResult, error)
80 func (h *Hub) Detach(room string, peer PeerID) // stream closed: start the resume window
81 func (h *Hub) Find(room, session string, peer PeerID) (*Participation, bool) // bound to session
82 func (h *Hub) LiveForSession(room, session string) (PeerID, bool) // attached or in resume window
83 ```
84- The 64-event buffer and `lost {reason: "buffer"}`; unjoined participations dropped after thirty seconds; resume window thirty seconds; sixteen participations per room (503 before allocation); one peer per session per room (409 with the existing id); four streams per person; tombstones for ten minutes keyed by peer id with their reason.
85
86- [ ] **Step 1: Write the failing tests**
87
88```go
89package hub
90
91import (
92 "context"
93 "io"
94 "log/slog"
95 "testing"
96 "time"
97)
98
99func testHub(now *time.Time) *Hub {
100 return New(Config{
101 Now: func() time.Time { return *now },
102 Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
103 StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) {
104 if personID < 100 {
105 return KindMember, true, nil
106 }
107 return KindGuest, true, nil
108 },
109 })
110}
111
112func TestOpenIssuesADistinctPeerBoundToTheSession(t *testing.T) {
113 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
114 h := testHub(&now)
115 a, err := h.Open("r1", "sessA", 1, "Ann", KindMember)
116 if err != nil {
117 t.Fatal(err)
118 }
119 b, _ := h.Open("r1", "sessB", 2, "Bob", KindMember)
120 if a.Peer == b.Peer || len(a.Peer) != 32 {
121 t.Fatalf("peer ids %q %q", a.Peer, b.Peer)
122 }
123 if _, ok := h.Find("r1", "sessB", a.Peer); ok {
124 t.Fatal("a peer was found through another session")
125 }
126 if _, ok := h.Find("r2", "sessA", a.Peer); ok {
127 t.Fatal("a peer was found through another room")
128 }
129 if p, ok := h.Find("r1", "sessA", a.Peer); !ok || p.Admitted {
130 t.Fatal("fresh participation should exist and be unjoined")
131 }
132}
133
134func TestOneLivePeerPerSessionPerRoom(t *testing.T) {
135 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
136 h := testHub(&now)
137 a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember)
138 _, err := h.Open("r1", "sessA", 1, "Ann", KindMember)
139 if err != ErrSessionHasPeer {
140 t.Fatalf("second stream: %v", err)
141 }
142 h.Detach("r1", a.Peer)
143 if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != ErrSessionHasPeer {
144 t.Fatal("a detached peer inside its resume window should still block a fresh stream")
145 }
146 now = now.Add(31 * time.Second)
147 h.Tick(context.Background())
148 if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != nil {
149 t.Fatalf("after the window: %v", err)
150 }
151}
152
153func TestSixteenParticipationsPerRoomThenRefuse(t *testing.T) {
154 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
155 h := testHub(&now)
156 for i := 0; i < 16; i++ {
157 if _, err := h.Open("r1", "s"+string(rune('a'+i)), int64(100+i), "g", KindGuest); err != nil {
158 t.Fatalf("open %d: %v", i, err)
159 }
160 }
161 if _, err := h.Open("r1", "sZ", 200, "g", KindGuest); err != ErrRoomFull {
162 t.Fatalf("seventeenth: %v", err)
163 }
164}
165
166func TestUnjoinedParticipationDropsAfterThirtySeconds(t *testing.T) {
167 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
168 h := testHub(&now)
169 a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember)
170 now = now.Add(31 * time.Second)
171 h.Tick(context.Background())
172 if _, ok := h.Find("r1", "sessA", a.Peer); ok {
173 t.Fatal("unjoined participation survived the window")
174 }
175 if !a.Sub.Lost() {
176 t.Fatal("its subscriber was not closed")
177 }
178}
179
180func TestFourStreamsPerPerson(t *testing.T) {
181 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
182 h := testHub(&now)
183 for i := 0; i < 4; i++ {
184 if _, err := h.Open("r"+string(rune('1'+i)), "s"+string(rune('1'+i)), 1, "Ann", KindMember); err != nil {
185 t.Fatal(err)
186 }
187 }
188 if _, err := h.Open("r9", "s9", 1, "Ann", KindMember); err != ErrPersonStreams {
189 t.Fatalf("fifth stream: %v", err)
190 }
191}
192
193func TestSlowSubscriberIsDeclaredLost(t *testing.T) {
194 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
195 h := testHub(&now)
196 a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember)
197 for i := 0; i < 70; i++ {
198 h.deliver("r1", a.Peer, Message{Type: "peer"})
199 }
200 if !a.Sub.Lost() {
201 t.Fatal("65th undelivered event did not declare the subscriber lost")
202 }
203 var last Message
204 for m := range a.Sub.C() {
205 last = m
206 }
207 if last.Type != "lost" || last.Reason != "buffer" {
208 t.Fatalf("last event %+v, want lost/buffer", last)
209 }
210}
211```
212
213- [ ] **Step 2: Run to verify they fail**
214
215Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ 2>&1 | head -5`
216Expected: build failure, package does not exist.
217
218- [ ] **Step 3: Implement the core**
219
220`subscriber.go`:
221
222```go
223package hub
224
225import "sync/atomic"
226
227const subBuffer = 64
228
229// Sub is one stream's outbound channel. A subscriber that falls 64
230// events behind is declared lost and told so (spec §4.4), as in Sheets.
231type Sub struct {
232 ch chan Message
233 lost atomic.Bool
234 done chan struct{}
235}
236
237func newSub() *Sub { return &Sub{ch: make(chan Message, subBuffer), done: make(chan struct{})} }
238
239func (s *Sub) C() <-chan Message { return s.ch }
240func (s *Sub) Lost() bool { return s.lost.Load() }
241
242// send is non-blocking; a full buffer declares the subscriber lost,
243// drains room for the lost event, and closes.
244func (s *Sub) send(m Message) {
245 select {
246 case <-s.done:
247 return
248 default:
249 }
250 select {
251 case s.ch <- m:
252 default:
253 s.terminate(Message{Type: "lost", Reason: ReasonBuffer})
254 }
255}
256
257func (s *Sub) terminate(final Message) {
258 if s.lost.Swap(true) {
259 return
260 }
261 close(s.done)
262 // Make room for the final event, then close so the range ends.
263 select {
264 case <-s.ch:
265 default:
266 }
267 s.ch <- final
268 close(s.ch)
269}
270
271func (s *Sub) Close() { s.terminate(Message{Type: "lost", Reason: ReasonBuffer}) }
272```
273
274`messages.go`:
275
276```go
277package hub
278
279import "encoding/json"
280
281type PeerID string
282type Kind int
283type Reason string
284
285const (
286 KindGuest Kind = iota
287 KindMember
288)
289
290func (k Kind) String() string {
291 if k == KindMember {
292 return "member"
293 }
294 return "guest"
295}
296
297const (
298 ReasonBuffer Reason = "buffer"
299 ReasonReplaced Reason = "replaced"
300 ReasonStanding Reason = "standing"
301 ReasonExpired Reason = "expired"
302 ReasonRemoved Reason = "removed"
303 ReasonEnded Reason = "ended"
304)
305
306// Upstream types a browser may send.
307const (
308 TypeJoin = "join"
309 TypeAdmit = "admit"
310 TypeLeave = "leave"
311 TypeOffer = "offer"
312 TypeAnswer = "answer"
313 TypeCandidate = "candidate"
314 TypeReset = "reset"
315 TypeChat = "chat" // slice 4
316 TypeShare = "share" // slice 4
317)
318
319// Downstream types the server emits.
320const (
321 TypeHello = "hello"
322 TypeRoster = "roster"
323 TypePeer = "peer"
324 TypeGone = "gone"
325 TypeKnock = "knock"
326 TypeKnockGone = "knock-gone"
327 TypeFull = "full"
328 TypeEnded = "ended"
329 TypeLost = "lost"
330 TypePair = "pair"
331 TypeGap = "gap"
332 TypeState = "state"
333)
334
335// Message is the one envelope, built by the server. Payload is opaque.
336type Message struct {
337 Type string `json:"type"`
338 From PeerID `json:"from,omitempty"`
339 Name string `json:"name,omitempty"`
340 To PeerID `json:"to,omitempty"`
341 Seq uint64 `json:"seq,omitempty"`
342 Gen uint64 `json:"gen,omitempty"`
343 Payload json.RawMessage `json:"payload,omitempty"`
344 Reason Reason `json:"reason,omitempty"`
345 Roster *Roster `json:"roster,omitempty"`
346 Boot string `json:"boot,omitempty"`
347 Peer PeerID `json:"peer,omitempty"`
348 Waiting bool `json:"waiting,omitempty"`
349}
350
351type Roster struct {
352 Peers []RosterPeer `json:"peers"`
353 Waiting []RosterPeer `json:"waiting,omitempty"`
354}
355
356type RosterPeer struct {
357 Peer PeerID `json:"peer"`
358 Name string `json:"name"`
359 Kind string `json:"kind"`
360 Gen uint64 `json:"gen,omitempty"`
361}
362```
363
364`hub.go` (the data model and the four lifecycle entry points; admission and relay come in Tasks 2 and 3 but their fields are declared here):
365
366```go
367package hub
368
369import (
370 "context"
371 "crypto/rand"
372 "encoding/hex"
373 "errors"
374 "log/slog"
375 "sync"
376 "time"
377)
378
379const (
380 MaxParticipations = 16
381 MaxWaiting = 8
382 MaxAdmitted = 4
383 MaxStreamsPerPerson = 4
384 UnjoinedWindow = 30 * time.Second
385 ResumeWindow = 30 * time.Second
386 TombstoneLife = 10 * time.Minute
387 RingSize = 32
388)
389
390var (
391 ErrRoomFull = errors.New("hub: room holds sixteen participations")
392 ErrSessionHasPeer = errors.New("hub: this session already has a peer in the room")
393 ErrPersonStreams = errors.New("hub: this person has four streams open")
394 ErrUnknownPeer = errors.New("hub: no such peer")
395 ErrNotYours = errors.New("hub: peer is not bound to this session")
396 ErrNotAdmitted = errors.New("hub: peer is not admitted")
397 ErrNotMember = errors.New("hub: peer is not a member")
398 ErrNotWaiting = errors.New("hub: target is not waiting")
399 ErrStaleGen = errors.New("hub: stale generation")
400 ErrTooManyWaiting = errors.New("hub: eight already waiting")
401)
402
403type Config struct {
404 Now func() time.Time
405 Logger *slog.Logger
406 // StandingFor re-derives a person's kind and whether they still hold
407 // entry authority for the room. ok=false means standing none.
408 StandingFor func(ctx context.Context, room string, personID int64) (kind Kind, ok bool, err error)
409}
410
411type Participation struct {
412 Peer PeerID
413 Room string
414 Session string
415 PersonID int64
416 Name string
417 Kind Kind
418 Admitted bool
419 Joined bool // has posted join (waiting or admitted)
420 JoinedAt time.Time
421 OpenedAt time.Time
422
423 sub *Sub
424 resumeUntil time.Time // zero while a stream is attached
425 seqOut map[PeerID]uint64 // next seq per target (unused by the hub; kept by browsers)
426 lastSeen map[PeerID]uint64
427 ring []Message // targeted messages delivered to this peer, newest last, cap RingSize
428}
429
430type pairKey struct{ a, b PeerID }
431
432func keyFor(x, y PeerID) pairKey {
433 if x < y {
434 return pairKey{x, y}
435 }
436 return pairKey{y, x}
437}
438
439type room struct {
440 sqid string
441 peers map[PeerID]*Participation
442 gens map[pairKey]uint64
443 ended bool
444}
445
446type tombstone struct {
447 reason Reason
448 at time.Time
449}
450
451type Hub struct {
452 cfg Config
453 boot string
454
455 mu sync.Mutex
456 rooms map[string]*room
457 bySession map[string]map[string]PeerID // room -> session -> peer
458 byPerson map[int64]int // open streams per person
459 tombstones map[PeerID]tombstone
460}
461
462func New(cfg Config) *Hub {
463 if cfg.Now == nil {
464 cfg.Now = time.Now
465 }
466 return &Hub{
467 cfg: cfg, boot: randomID(),
468 rooms: map[string]*room{}, bySession: map[string]map[string]PeerID{},
469 byPerson: map[int64]int{}, tombstones: map[PeerID]tombstone{},
470 }
471}
472
473func randomID() string {
474 b := make([]byte, 16)
475 if _, err := rand.Read(b); err != nil {
476 panic(err)
477 }
478 return hex.EncodeToString(b)
479}
480
481func (h *Hub) Boot() string { return h.boot }
482
483func (h *Hub) roomLocked(sqid string) *room {
484 r, ok := h.rooms[sqid]
485 if !ok {
486 r = &room{sqid: sqid, peers: map[PeerID]*Participation{}, gens: map[pairKey]uint64{}}
487 h.rooms[sqid] = r
488 }
489 return r
490}
491
492type OpenResult struct {
493 Peer PeerID
494 Sub *Sub
495 Existing PeerID
496}
497
498// Open is a fresh stream: a new unjoined participation, subject to the
499// room, session and person limits, all checked before allocation.
500func (h *Hub) Open(sqid, session string, personID int64, name string, kind Kind) (OpenResult, error) {
501 h.mu.Lock()
502 defer h.mu.Unlock()
503 r := h.roomLocked(sqid)
504 if r.ended {
505 return OpenResult{}, ErrUnknownPeer
506 }
507 if existing, ok := h.bySession[sqid][session]; ok {
508 return OpenResult{Existing: existing}, ErrSessionHasPeer
509 }
510 if len(r.peers) >= MaxParticipations {
511 return OpenResult{}, ErrRoomFull
512 }
513 if h.byPerson[personID] >= MaxStreamsPerPerson {
514 return OpenResult{}, ErrPersonStreams
515 }
516 p := &Participation{
517 Peer: PeerID(randomID()), Room: sqid, Session: session, PersonID: personID, Name: name, Kind: kind,
518 OpenedAt: h.cfg.Now(), sub: newSub(), seqOut: map[PeerID]uint64{}, lastSeen: map[PeerID]uint64{},
519 }
520 r.peers[p.Peer] = p
521 if h.bySession[sqid] == nil {
522 h.bySession[sqid] = map[string]PeerID{}
523 }
524 h.bySession[sqid][session] = p.Peer
525 h.byPerson[personID]++
526 return OpenResult{Peer: p.Peer, Sub: p.sub}, nil
527}
528
529// Find answers a peer only through its own room and session.
530func (h *Hub) Find(sqid, session string, peer PeerID) (*Participation, bool) {
531 h.mu.Lock()
532 defer h.mu.Unlock()
533 p, ok := h.findLocked(sqid, session, peer)
534 return p, ok
535}
536
537func (h *Hub) findLocked(sqid, session string, peer PeerID) (*Participation, bool) {
538 r, ok := h.rooms[sqid]
539 if !ok {
540 return nil, false
541 }
542 p, ok := r.peers[peer]
543 if !ok || p.Session != session {
544 return nil, false
545 }
546 return p, true
547}
548
549func (h *Hub) LiveForSession(sqid, session string) (PeerID, bool) {
550 h.mu.Lock()
551 defer h.mu.Unlock()
552 id, ok := h.bySession[sqid][session]
553 return id, ok
554}
555
556// Detach starts the resume window for a participation whose stream
557// closed. Its subscriber is closed; a resume attaches a new one.
558func (h *Hub) Detach(sqid string, peer PeerID) {
559 h.mu.Lock()
560 defer h.mu.Unlock()
561 r, ok := h.rooms[sqid]
562 if !ok {
563 return
564 }
565 p, ok := r.peers[peer]
566 if !ok {
567 return
568 }
569 if p.sub != nil {
570 p.sub.terminate(Message{Type: TypeLost, Reason: ReasonBuffer})
571 p.sub = nil
572 }
573 p.resumeUntil = h.cfg.Now().Add(ResumeWindow)
574}
575
576// endLocked removes a participation for a terminal reason, closes its
577// stream with lost{reason}, leaves a tombstone, and returns whether it
578// was admitted or waiting so the caller can announce the departure.
579func (h *Hub) endLocked(r *room, p *Participation, reason Reason) (wasAdmitted, wasWaiting bool) {
580 wasAdmitted, wasWaiting = p.Admitted, p.Joined && !p.Admitted
581 if p.sub != nil {
582 p.sub.terminate(Message{Type: TypeLost, Reason: reason})
583 }
584 delete(r.peers, p.Peer)
585 if h.bySession[r.sqid][p.Session] == p.Peer {
586 delete(h.bySession[r.sqid], p.Session)
587 }
588 if h.byPerson[p.PersonID] > 0 {
589 h.byPerson[p.PersonID]--
590 }
591 h.tombstones[p.Peer] = tombstone{reason: reason, at: h.cfg.Now()}
592 for k := range r.gens {
593 if k.a == p.Peer || k.b == p.Peer {
594 delete(r.gens, k)
595 }
596 }
597 return
598}
599
600// deliver is the one place an event reaches a subscriber; targeted
601// messages are also kept in the recipient's replay ring (Task 3).
602func (h *Hub) deliver(sqid string, to PeerID, m Message) {
603 h.mu.Lock()
604 defer h.mu.Unlock()
605 h.deliverLocked(sqid, to, m)
606}
607
608func (h *Hub) deliverLocked(sqid string, to PeerID, m Message) {
609 r, ok := h.rooms[sqid]
610 if !ok {
611 return
612 }
613 p, ok := r.peers[to]
614 if !ok {
615 return
616 }
617 if p.sub != nil {
618 p.sub.send(m)
619 }
620}
621```
622
623`Tick` (in `admission.go`, Task 2) is referenced by the tests here; for this task add a minimal `Tick` in `hub.go` that drops unjoined participations past `UnjoinedWindow` and resume-held ones past `resumeUntil` (as a leave, no tombstone), and expires tombstones. Task 2 extends it with kind re-derivation.
624
625```go
626// Tick runs the fifteen-second housekeeping: unjoined and lapsed
627// participations are dropped, tombstones expire. Task 2 adds kind
628// re-derivation and promotion.
629func (h *Hub) Tick(ctx context.Context) {
630 h.mu.Lock()
631 defer h.mu.Unlock()
632 now := h.cfg.Now()
633 for _, r := range h.rooms {
634 for _, p := range r.peers {
635 switch {
636 case !p.Joined && p.sub != nil && now.Sub(p.OpenedAt) > UnjoinedWindow:
637 h.endLocked(r, p, ReasonBuffer) // dropped quietly; the browser sees a closed stream
638 delete(h.tombstones, p.Peer)
639 case p.sub == nil && !p.resumeUntil.IsZero() && now.After(p.resumeUntil):
640 wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonBuffer)
641 delete(h.tombstones, p.Peer)
642 h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting)
643 }
644 }
645 }
646 for id, t := range h.tombstones {
647 if now.Sub(t.at) > TombstoneLife {
648 delete(h.tombstones, id)
649 }
650 }
651}
652```
653
654with `announceDepartureLocked` stubbed in this task (`gone` to admitted peers, `knock-gone` to admitted members; Task 2 fills the recipient helpers):
655
656```go
657func (h *Hub) announceDepartureLocked(r *room, p *Participation, wasAdmitted, wasWaiting bool) {
658 switch {
659 case wasAdmitted:
660 h.broadcastLocked(r, Message{Type: TypeGone, From: p.Peer}, admittedPeers)
661 case wasWaiting:
662 h.broadcastLocked(r, Message{Type: TypeKnockGone, From: p.Peer}, admittedMembers)
663 }
664}
665
666type audience func(*Participation) bool
667
668func admittedPeers(p *Participation) bool { return p.Admitted }
669func admittedMembers(p *Participation) bool { return p.Admitted && p.Kind == KindMember }
670
671func (h *Hub) broadcastLocked(r *room, m Message, who audience) {
672 for _, q := range r.peers {
673 if who(q) && q.sub != nil {
674 q.sub.send(m)
675 }
676 }
677}
678```
679
680- [ ] **Step 4: Run the tests**
681
682Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ -v 2>&1 | tail -12`
683Expected: six tests PASS.
684
685- [ ] **Step 5: Gate and commit**
686
687Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache make ci 2>&1 | tail -3`
688
689```bash
690git add internal/calls/hub
691git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "hub: rooms, participations, peer ids, limits, tombstones
692
693Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
694Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
695```
696
697---
698
699### Task 2: Admission: join, admit, leave, remove, takeover, end, the cap, the tick
700
701**Files:**
702- Create: `internal/calls/hub/admission.go`, `internal/calls/hub/admission_test.go`
703- Modify: `internal/calls/hub/hub.go` (`Tick` extended)
704
705**Interfaces:**
706- Produces:
707 ```go
708 func (h *Hub) Join(ctx, sqid, session string, peer PeerID) error // standing re-checked via cfg.StandingFor; member: admit under cap or ErrRoomFull→`full` to the joiner; guest: waiting or ErrTooManyWaiting
709 func (h *Hub) Admit(ctx, sqid, session string, actor, target PeerID) error // actor admitted member (kind re-derived now); target waiting with entry authority now; cap under lock
710 func (h *Hub) Leave(sqid, session string, peer PeerID) error
711 func (h *Hub) Remove(ctx, sqid, session string, actor, target PeerID) error // §3.5, not a signalling message
712 func (h *Hub) Takeover(sqid, session string) (old PeerID, err error) // §4.2: ends the session's live participation with the state-dependent departure
713 func (h *Hub) End(sqid string) // meeting ended: `ended` to all, room dropped
714 func (h *Hub) rosterFor(r *room, viewer *Participation) *Roster // waiting queue only for members; per-pair gen for the viewer
715 ```
716- The cap check `admittedCountLocked(r) >= MaxAdmitted` at both transitions; `full` to the actor; `roster` to the newly admitted; `peer` to the others; `knock` to admitted members on a guest join; `knock-gone` on any waiting departure; `Tick` re-derives kind for every joined participation through `cfg.StandingFor`, ends members who lost standing with `ReasonStanding`, ends waiting guests whose authority lapsed with `ReasonExpired`, refreshes `roster` to admitted peers on a kind change, and attempts promotion of a waiting peer that became a member.
717
718- [ ] **Step 1: Write the failing tests**
719
720```go
721package hub
722
723import (
724 "context"
725 "testing"
726 "time"
727)
728
729// drain returns every event currently buffered on a sub, non-blocking.
730func drain(s *Sub) []Message {
731 var out []Message
732 for {
733 select {
734 case m, ok := <-s.C():
735 if !ok {
736 return out
737 }
738 out = append(out, m)
739 default:
740 return out
741 }
742 }
743}
744
745func types(ms []Message) []string {
746 out := make([]string, len(ms))
747 for i, m := range ms {
748 out[i] = m.Type
749 }
750 return out
751}
752
753func TestMemberJoinsAndGuestWaits(t *testing.T) {
754 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
755 h := testHub(&now)
756 ctx := context.Background()
757 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
758 if err := h.Join(ctx, "r1", "sm", m.Peer); err != nil {
759 t.Fatal(err)
760 }
761 ev := drain(m.Sub)
762 if len(ev) != 1 || ev[0].Type != TypeRoster || len(ev[0].Roster.Peers) != 1 {
763 t.Fatalf("member join events %v", types(ev))
764 }
765 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
766 if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil {
767 t.Fatal(err)
768 }
769 if ev := drain(g.Sub); len(ev) != 0 {
770 t.Fatalf("waiting guest received %v", types(ev))
771 }
772 ev = drain(m.Sub)
773 if len(ev) != 1 || ev[0].Type != TypeKnock || ev[0].From != g.Peer || ev[0].Name != "Gus" {
774 t.Fatalf("member did not get the knock: %v", types(ev))
775 }
776}
777
778func TestAdmitSendsRosterToGuestPeerToOthersAndKnockGone(t *testing.T) {
779 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
780 h := testHub(&now)
781 ctx := context.Background()
782 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
783 h.Join(ctx, "r1", "sm", m.Peer)
784 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
785 h.Join(ctx, "r1", "sg", g.Peer)
786 drain(m.Sub)
787 if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil {
788 t.Fatal(err)
789 }
790 gev := drain(g.Sub)
791 if len(gev) != 1 || gev[0].Type != TypeRoster || len(gev[0].Roster.Peers) != 2 || gev[0].Roster.Waiting != nil {
792 t.Fatalf("guest got %v (waiting queue must not reach a guest)", types(gev))
793 }
794 mev := types(drain(m.Sub))
795 if len(mev) != 2 || mev[0] != TypePeer || mev[1] != TypeKnockGone {
796 t.Fatalf("member got %v", mev)
797 }
798}
799
800func TestOnlyAnAdmittedMemberMayAdmit(t *testing.T) {
801 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
802 h := testHub(&now)
803 ctx := context.Background()
804 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
805 h.Join(ctx, "r1", "sm", m.Peer)
806 g1, _ := h.Open("r1", "s1", 100, "G1", KindGuest)
807 h.Join(ctx, "r1", "s1", g1.Peer)
808 g2, _ := h.Open("r1", "s2", 101, "G2", KindGuest)
809 h.Join(ctx, "r1", "s2", g2.Peer)
810 h.Admit(ctx, "r1", "sm", m.Peer, g1.Peer)
811 if err := h.Admit(ctx, "r1", "s1", g1.Peer, g2.Peer); err != ErrNotMember {
812 t.Fatalf("admitted guest admitting: %v", err)
813 }
814 if err := h.Admit(ctx, "r1", "s2", g2.Peer, g2.Peer); err != ErrNotAdmitted {
815 t.Fatalf("waiting guest admitting: %v", err)
816 }
817}
818
819func TestCapAtJoinAndAtAdmit(t *testing.T) {
820 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
821 h := testHub(&now)
822 ctx := context.Background()
823 var members []OpenResult
824 for i := 0; i < 5; i++ {
825 o, _ := h.Open("r1", "s"+string(rune('a'+i)), int64(1+i), "M", KindMember)
826 members = append(members, o)
827 h.Join(ctx, "r1", "s"+string(rune('a'+i)), o.Peer)
828 }
829 fifth := drain(members[4].Sub)
830 if len(fifth) != 1 || fifth[0].Type != TypeFull {
831 t.Fatalf("fifth member got %v, want full", types(fifth))
832 }
833 // One leaves; four queued guests and the freed slot yield one admission.
834 h.Leave("r1", "sd", members[3].Peer)
835 var guests []OpenResult
836 for i := 0; i < 4; i++ {
837 o, _ := h.Open("r1", "g"+string(rune('a'+i)), int64(100+i), "G", KindGuest)
838 guests = append(guests, o)
839 h.Join(ctx, "r1", "g"+string(rune('a'+i)), o.Peer)
840 }
841 drain(members[0].Sub)
842 if err := h.Admit(ctx, "r1", "sa", members[0].Peer, guests[0].Peer); err != nil {
843 t.Fatal(err)
844 }
845 if err := h.Admit(ctx, "r1", "sa", members[0].Peer, guests[1].Peer); err != nil {
846 t.Fatal(err) // Admit itself returns nil; the outcome is the `full` event
847 }
848 ev := types(drain(members[0].Sub))
849 if ev[len(ev)-1] != TypeFull {
850 t.Fatalf("second admit over the cap: %v, want full last", ev)
851 }
852 if p, _ := h.Find("r1", "gb", guests[1].Peer); p.Admitted {
853 t.Fatal("guest admitted over the cap")
854 }
855}
856
857func TestEightWaitingThenFull(t *testing.T) {
858 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
859 h := testHub(&now)
860 ctx := context.Background()
861 for i := 0; i < 8; i++ {
862 o, _ := h.Open("r1", "g"+string(rune('a'+i)), int64(100+i), "G", KindGuest)
863 h.Join(ctx, "r1", "g"+string(rune('a'+i)), o.Peer)
864 }
865 o, _ := h.Open("r1", "gz", 200, "G", KindGuest)
866 if err := h.Join(ctx, "r1", "gz", o.Peer); err != ErrTooManyWaiting {
867 t.Fatalf("ninth waiting: %v", err)
868 }
869 if ev := drain(o.Sub); len(ev) != 1 || ev[0].Type != TypeFull {
870 t.Fatalf("ninth got %v", types(ev))
871 }
872}
873
874func TestGuestFirstArrivalIsInTheMembersRoster(t *testing.T) {
875 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
876 h := testHub(&now)
877 ctx := context.Background()
878 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
879 h.Join(ctx, "r1", "sg", g.Peer)
880 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
881 h.Join(ctx, "r1", "sm", m.Peer)
882 ev := drain(m.Sub)
883 if len(ev) != 1 || ev[0].Type != TypeRoster || len(ev[0].Roster.Waiting) != 1 || ev[0].Roster.Waiting[0].Peer != g.Peer {
884 t.Fatalf("member roster %+v", ev)
885 }
886}
887
888func TestAdmitRefusesAnExpiredGrantBeforeTheTick(t *testing.T) {
889 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
890 authority := map[int64]bool{100: true}
891 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
892 if id < 100 {
893 return KindMember, true, nil
894 }
895 return KindGuest, authority[id], nil
896 }})
897 ctx := context.Background()
898 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
899 h.Join(ctx, "r1", "sm", m.Peer)
900 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
901 h.Join(ctx, "r1", "sg", g.Peer)
902 authority[100] = false // grant expired, no tick yet
903 drain(m.Sub)
904 if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil {
905 t.Fatal(err)
906 }
907 if p, ok := h.Find("r1", "sg", g.Peer); ok && p.Admitted {
908 t.Fatal("expired guest was admitted")
909 }
910 gev := drain(g.Sub)
911 if len(gev) == 0 || gev[len(gev)-1].Type != TypeLost || gev[len(gev)-1].Reason != ReasonExpired {
912 t.Fatalf("expired guest got %v", types(gev))
913 }
914 if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeKnockGone {
915 t.Fatalf("member got %v", ev)
916 }
917}
918
919func TestTickDropsAMemberWhoLostStandingAndPromotesAWaitingGuest(t *testing.T) {
920 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
921 kinds := map[int64]Kind{1: KindMember, 100: KindGuest}
922 ok := map[int64]bool{1: true, 100: true}
923 h := New(Config{Now: func() time.Time { return now }, StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
924 return kinds[id], ok[id], nil
925 }})
926 ctx := context.Background()
927 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
928 h.Join(ctx, "r1", "sm", m.Peer)
929 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
930 h.Join(ctx, "r1", "sg", g.Peer)
931 drain(m.Sub)
932 kinds[100] = KindMember // promoted while waiting
933 h.Tick(ctx)
934 if p, _ := h.Find("r1", "sg", g.Peer); !p.Admitted || p.Kind != KindMember {
935 t.Fatal("waiting guest promoted to member was not admitted on the tick")
936 }
937 ok[1] = false // member removed from the allowlist
938 drain(g.Sub)
939 h.Tick(ctx)
940 if _, found := h.Find("r1", "sm", m.Peer); found {
941 t.Fatal("removed member still in the room")
942 }
943 mev := drain(m.Sub)
944 if len(mev) == 0 || mev[len(mev)-1].Reason != ReasonStanding {
945 t.Fatalf("removed member got %v", types(mev))
946 }
947 if ev := types(drain(g.Sub)); len(ev) == 0 || ev[len(ev)-1] != TypeGone {
948 t.Fatalf("remaining peer got %v, want gone last", ev)
949 }
950}
951
952func TestTakeoverDepartureDependsOnState(t *testing.T) {
953 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
954 h := testHub(&now)
955 ctx := context.Background()
956 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
957 h.Join(ctx, "r1", "sm", m.Peer)
958 ag, _ := h.Open("r1", "sag", 100, "Adm", KindGuest)
959 h.Join(ctx, "r1", "sag", ag.Peer)
960 h.Admit(ctx, "r1", "sm", m.Peer, ag.Peer)
961 w, _ := h.Open("r1", "sw", 101, "Wait", KindGuest)
962 h.Join(ctx, "r1", "sw", w.Peer)
963 u, _ := h.Open("r1", "su", 102, "Unj", KindGuest)
964 drain(m.Sub)
965 drain(ag.Sub)
966 // waiting takeover: knock-gone to members only
967 if _, err := h.Takeover("r1", "sw"); err != nil {
968 t.Fatal(err)
969 }
970 if ev := types(drain(ag.Sub)); len(ev) != 0 {
971 t.Fatalf("admitted guest saw a waiting departure: %v", ev)
972 }
973 if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeKnockGone {
974 t.Fatalf("member got %v", ev)
975 }
976 // unjoined takeover: nothing
977 h.Takeover("r1", "su")
978 _ = u
979 if ev := types(drain(m.Sub)); len(ev) != 0 {
980 t.Fatalf("unjoined takeover announced %v", ev)
981 }
982 // admitted takeover: gone to admitted peers, old stream lost/replaced
983 h.Takeover("r1", "sag")
984 agev := drain(ag.Sub)
985 if len(agev) == 0 || agev[len(agev)-1].Reason != ReasonReplaced {
986 t.Fatalf("replaced peer got %v", types(agev))
987 }
988 if ev := types(drain(m.Sub)); len(ev) != 1 || ev[0] != TypeGone {
989 t.Fatalf("member got %v", ev)
990 }
991 if _, err := h.Open("r1", "sag", 100, "Adm", KindGuest); err != nil {
992 t.Fatalf("fresh stream after takeover: %v", err)
993 }
994}
995
996func TestEndSendsEndedToEveryoneAndDropsTheRoom(t *testing.T) {
997 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
998 h := testHub(&now)
999 ctx := context.Background()
1000 m, _ := h.Open("r1", "sm", 1, "Mia", KindMember)
1001 h.Join(ctx, "r1", "sm", m.Peer)
1002 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
1003 h.Join(ctx, "r1", "sg", g.Peer)
1004 h.End("r1")
1005 for _, s := range []*Sub{m.Sub, g.Sub} {
1006 ev := drain(s)
1007 if len(ev) == 0 || ev[len(ev)-1].Type != TypeLost || ev[len(ev)-1].Reason != ReasonEnded {
1008 t.Fatalf("got %v, want lost/ended last", types(ev))
1009 }
1010 }
1011 if _, err := h.Open("r1", "sx", 5, "X", KindMember); err == nil {
1012 t.Fatal("ended room accepted a stream")
1013 }
1014}
1015```
1016
1017- [ ] **Step 2: Run to verify they fail**
1018
1019Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ 2>&1 | grep -c 'undefined'`
1020Expected: non-zero (Join, Admit, Leave, Takeover, End undefined).
1021
1022- [ ] **Step 3: Implement admission**
1023
1024`admission.go`:
1025
1026```go
1027package hub
1028
1029import "context"
1030
1031func admittedCountLocked(r *room) int {
1032 n := 0
1033 for _, p := range r.peers {
1034 if p.Admitted {
1035 n++
1036 }
1037 }
1038 return n
1039}
1040
1041func waitingCountLocked(r *room) int {
1042 n := 0
1043 for _, p := range r.peers {
1044 if p.Joined && !p.Admitted {
1045 n++
1046 }
1047 }
1048 return n
1049}
1050
1051// rosterFor is the authoritative snapshot for one viewer: admitted peers
1052// with the viewer's pair generation, and the waiting queue only when the
1053// viewer is a member.
1054func (h *Hub) rosterFor(r *room, viewer *Participation) *Roster {
1055 ro := &Roster{}
1056 for _, p := range r.peers {
1057 if !p.Admitted {
1058 continue
1059 }
1060 rp := RosterPeer{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String()}
1061 if p.Peer != viewer.Peer {
1062 rp.Gen = h.genLocked(r, viewer.Peer, p.Peer)
1063 }
1064 ro.Peers = append(ro.Peers, rp)
1065 }
1066 if viewer.Kind == KindMember {
1067 for _, p := range r.peers {
1068 if p.Joined && !p.Admitted {
1069 ro.Waiting = append(ro.Waiting, RosterPeer{Peer: p.Peer, Name: p.Name, Kind: "guest"})
1070 }
1071 }
1072 }
1073 return ro
1074}
1075
1076// genLocked returns the pair's generation, starting it at one on first sight.
1077func (h *Hub) genLocked(r *room, a, b PeerID) uint64 {
1078 k := keyFor(a, b)
1079 if r.gens[k] == 0 {
1080 r.gens[k] = 1
1081 }
1082 return r.gens[k]
1083}
1084
1085// admitLocked is the one transition into the admitted state: cap under
1086// the lock, roster to the newcomer, peer to the others, knock-gone to
1087// members if they were waiting. Returns false when the room is full.
1088func (h *Hub) admitLocked(r *room, p *Participation) bool {
1089 if admittedCountLocked(r) >= MaxAdmitted {
1090 return false
1091 }
1092 wasWaiting := p.Joined && !p.Admitted
1093 p.Admitted = true
1094 p.Joined = true
1095 p.JoinedAt = h.cfg.Now()
1096 if p.sub != nil {
1097 p.sub.send(Message{Type: TypeRoster, Roster: h.rosterFor(r, p)})
1098 }
1099 for _, q := range r.peers {
1100 if q.Admitted && q.Peer != p.Peer && q.sub != nil {
1101 q.sub.send(Message{Type: TypePeer, Peer: p.Peer, Name: p.Name, Payload: nil,
1102 Roster: &Roster{Peers: []RosterPeer{{Peer: p.Peer, Name: p.Name, Kind: p.Kind.String(), Gen: h.genLocked(r, q.Peer, p.Peer)}}}})
1103 }
1104 }
1105 if wasWaiting {
1106 h.broadcastLocked(r, Message{Type: TypeKnockGone, From: p.Peer}, admittedMembers)
1107 }
1108 return true
1109}
1110
1111// Join is the entry. Standing is re-derived now (spec §2.5).
1112func (h *Hub) Join(ctx context.Context, sqid, session string, peer PeerID) error {
1113 kind, ok, err := h.standing(ctx, sqid, session, peer)
1114 if err != nil {
1115 return err
1116 }
1117 h.mu.Lock()
1118 defer h.mu.Unlock()
1119 r := h.roomLocked(sqid)
1120 p, found := h.findLocked(sqid, session, peer)
1121 if !found {
1122 return ErrUnknownPeer
1123 }
1124 if !ok {
1125 h.endLocked(r, p, ReasonStanding)
1126 return ErrNotYours
1127 }
1128 p.Kind = kind
1129 if p.Joined {
1130 return nil // idempotent
1131 }
1132 if kind == KindMember {
1133 if !h.admitLocked(r, p) {
1134 if p.sub != nil {
1135 p.sub.send(Message{Type: TypeFull})
1136 }
1137 return ErrRoomFull
1138 }
1139 return nil
1140 }
1141 if waitingCountLocked(r) >= MaxWaiting {
1142 if p.sub != nil {
1143 p.sub.send(Message{Type: TypeFull})
1144 }
1145 return ErrTooManyWaiting
1146 }
1147 p.Joined = true
1148 p.JoinedAt = h.cfg.Now()
1149 h.broadcastLocked(r, Message{Type: TypeKnock, From: p.Peer, Name: p.Name}, admittedMembers)
1150 return nil
1151}
1152
1153// standing looks the peer up to find its person, then asks the app.
1154func (h *Hub) standing(ctx context.Context, sqid, session string, peer PeerID) (Kind, bool, error) {
1155 h.mu.Lock()
1156 p, found := h.findLocked(sqid, session, peer)
1157 var personID int64
1158 if found {
1159 personID = p.PersonID
1160 }
1161 h.mu.Unlock()
1162 if !found {
1163 return 0, false, ErrUnknownPeer
1164 }
1165 return h.cfg.StandingFor(ctx, sqid, personID)
1166}
1167
1168// Admit: the actor must be admitted with current member kind; the target
1169// waiting with entry authority now; the cap under the lock.
1170func (h *Hub) Admit(ctx context.Context, sqid, session string, actor, target PeerID) error {
1171 actorKind, actorOK, err := h.standing(ctx, sqid, session, actor)
1172 if err != nil {
1173 return err
1174 }
1175 h.mu.Lock()
1176 tp, found := h.rooms[sqid].peers[target]
1177 var targetPerson int64
1178 if found {
1179 targetPerson = tp.PersonID
1180 }
1181 h.mu.Unlock()
1182 if !found {
1183 return ErrNotWaiting
1184 }
1185 targetKind, targetOK, err := h.cfg.StandingFor(ctx, sqid, targetPerson)
1186 if err != nil {
1187 return err
1188 }
1189 h.mu.Lock()
1190 defer h.mu.Unlock()
1191 r := h.roomLocked(sqid)
1192 a, ok := h.findLocked(sqid, session, actor)
1193 if !ok {
1194 return ErrUnknownPeer
1195 }
1196 if !actorOK {
1197 h.endLocked(r, a, ReasonStanding)
1198 return ErrNotYours
1199 }
1200 a.Kind = actorKind
1201 if !a.Admitted {
1202 return ErrNotAdmitted
1203 }
1204 if a.Kind != KindMember {
1205 return ErrNotMember
1206 }
1207 tp, ok = r.peers[target]
1208 if !ok || !tp.Joined || tp.Admitted {
1209 return ErrNotWaiting
1210 }
1211 if !targetOK {
1212 wasAdmitted, wasWaiting := h.endLocked(r, tp, ReasonExpired)
1213 h.announceDepartureLocked(r, tp, wasAdmitted, wasWaiting)
1214 return nil
1215 }
1216 tp.Kind = targetKind
1217 if !h.admitLocked(r, tp) {
1218 if a.sub != nil {
1219 a.sub.send(Message{Type: TypeFull})
1220 }
1221 }
1222 return nil
1223}
1224
1225func (h *Hub) Leave(sqid, session string, peer PeerID) error {
1226 h.mu.Lock()
1227 defer h.mu.Unlock()
1228 r, ok := h.rooms[sqid]
1229 if !ok {
1230 return ErrUnknownPeer
1231 }
1232 p, found := h.findLocked(sqid, session, peer)
1233 if !found {
1234 return ErrUnknownPeer
1235 }
1236 wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonBuffer)
1237 delete(h.tombstones, p.Peer) // a leave is not a tombstoned ending
1238 h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting)
1239 return nil
1240}
1241
1242// Remove is §3.5: the actor holds an admitted member participation in
1243// this room (kind re-derived), the target is any other participation.
1244func (h *Hub) Remove(ctx context.Context, sqid, session string, actor, target PeerID) error {
1245 kind, ok, err := h.standing(ctx, sqid, session, actor)
1246 if err != nil {
1247 return err
1248 }
1249 h.mu.Lock()
1250 defer h.mu.Unlock()
1251 r := h.roomLocked(sqid)
1252 a, found := h.findLocked(sqid, session, actor)
1253 if !found || !ok {
1254 return ErrUnknownPeer
1255 }
1256 a.Kind = kind
1257 if !a.Admitted {
1258 return ErrNotAdmitted
1259 }
1260 if a.Kind != KindMember {
1261 return ErrNotMember
1262 }
1263 tp, found := r.peers[target]
1264 if !found || tp.Peer == a.Peer {
1265 return ErrUnknownPeer
1266 }
1267 wasAdmitted, wasWaiting := h.endLocked(r, tp, ReasonRemoved)
1268 h.announceDepartureLocked(r, tp, wasAdmitted, wasWaiting)
1269 return nil
1270}
1271
1272// Takeover ends the session's live participation in the room with the
1273// state-dependent departure (spec §4.2), leaving a replaced tombstone.
1274func (h *Hub) Takeover(sqid, session string) (PeerID, error) {
1275 h.mu.Lock()
1276 defer h.mu.Unlock()
1277 r, ok := h.rooms[sqid]
1278 if !ok {
1279 return "", ErrUnknownPeer
1280 }
1281 id, ok := h.bySession[sqid][session]
1282 if !ok {
1283 return "", ErrUnknownPeer
1284 }
1285 p := r.peers[id]
1286 wasAdmitted, wasWaiting := h.endLocked(r, p, ReasonReplaced)
1287 h.announceDepartureLocked(r, p, wasAdmitted, wasWaiting)
1288 return id, nil
1289}
1290
1291// End is the meeting ending: ended to every stream, room dropped.
1292func (h *Hub) End(sqid string) {
1293 h.mu.Lock()
1294 defer h.mu.Unlock()
1295 r, ok := h.rooms[sqid]
1296 if !ok {
1297 r = h.roomLocked(sqid)
1298 }
1299 for _, p := range r.peers {
1300 h.endLocked(r, p, ReasonEnded)
1301 }
1302 r.ended = true
1303 delete(h.bySession, sqid)
1304}
1305```
1306
1307Extend `Tick` in `hub.go`: after the drops, for every joined participation call `cfg.StandingFor` (outside the lock: collect `(room, peer, person)` triples under the lock, call, then re-lock and apply) and apply the §2.4 table: member → none: end with `ReasonStanding` and announce; waiting guest → not ok: end with `ReasonExpired` and announce; kind changed on an admitted peer: set and send `roster` to admitted peers; waiting peer now member: `admitLocked` or `full` to it. Admitted guest with ok=false: nothing (ruling 14).
1308
1309- [ ] **Step 4: Run the tests**
1310
1311Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ -v 2>&1 | grep -E '^(=== RUN|--- (PASS|FAIL)|FAIL|ok)' | tail -20`
1312Expected: all PASS.
1313
1314- [ ] **Step 5: Gate and commit**
1315
1316```bash
1317git add internal/calls/hub
1318git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "hub: join, admit, leave, remove, takeover, end, the cap and the tick
1319
1320Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1321Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
1322```
1323
1324---
1325
1326### Task 3: Relay: targeted messages, seq and gen, replay ring, reset and pair, resume and gap
1327
1328**Files:**
1329- Create: `internal/calls/hub/relay.go`, `internal/calls/hub/relay_test.go`
1330- Modify: `internal/calls/hub/hub.go` (`Resume` implemented; `deliverLocked` records targeted messages into the ring)
1331
1332**Interfaces:**
1333- Produces:
1334 ```go
1335 func (h *Hub) Relay(sqid, session string, from PeerID, m Message) error // m.Type in offer/answer/candidate; from admitted; m.To admitted in this room; m.Gen == current gen else ErrStaleGen; delivers envelope {Type, From, Name, To, Seq, Gen, Payload} and rings it on the target
1336 func (h *Hub) Reset(sqid, session string, from, to PeerID) error // both admitted; gen++ under lock; `pair {peer, gen}` to both, ringed
1337 func (h *Hub) Resume(sqid, session string, peer PeerID, last map[PeerID]uint64) (ResumeResult, error)
1338 ```
1339- Ring: newest last, capped at `RingSize`; replay on resume = every ringed message with `From == sender && Seq > last[sender]`, plus every `pair` whose `Gen` exceeds the roster's for that peer; if the ring has dropped a message a sender sent above `last` (detected by the ring's oldest `Seq` for that sender being greater than `last[sender]+1` while messages exist), emit `gap {from}` for that sender instead.
1340- `Resume` answers: unknown peer and no tombstone → `ResumeError{Status: 404, Boot: h.boot}`; tombstone → `ResumeError{Status: 410, Reason}`; peer bound to another session → 404 with boot (never leak); found → attach a new `Sub`, clear `resumeUntil`; admitted: `hello` is the handler's, then the hub returns `Roster`, `Replay`, `Gap`; waiting/unjoined: `Admitted=false`, nothing else.
1341
1342- [ ] **Step 1: Write the failing tests**
1343
1344```go
1345package hub
1346
1347import (
1348 "context"
1349 "encoding/json"
1350 "testing"
1351 "time"
1352)
1353
1354func twoAdmitted(t *testing.T) (*Hub, *time.Time, OpenResult, OpenResult) {
1355 t.Helper()
1356 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
1357 h := testHub(&now)
1358 ctx := context.Background()
1359 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
1360 h.Join(ctx, "r1", "sa", a.Peer)
1361 b, _ := h.Open("r1", "sb", 2, "Bob", KindMember)
1362 h.Join(ctx, "r1", "sb", b.Peer)
1363 drain(a.Sub)
1364 drain(b.Sub)
1365 return h, &now, a, b
1366}
1367
1368func TestRelayGoesOnlyToTheNamedAdmittedPeerWithServerBuiltEnvelope(t *testing.T) {
1369 h, _, a, b := twoAdmitted(t)
1370 ctx := context.Background()
1371 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
1372 h.Join(ctx, "r1", "sg", g.Peer)
1373 drain(a.Sub)
1374 drain(b.Sub)
1375 err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 1, From: "forged", Name: "Forged", Payload: json.RawMessage(`{"sdp":"x"}`)})
1376 if err != nil {
1377 t.Fatal(err)
1378 }
1379 bev := drain(b.Sub)
1380 if len(bev) != 1 || bev[0].From != a.Peer || bev[0].Name != "Ann" || string(bev[0].Payload) != `{"sdp":"x"}` {
1381 t.Fatalf("b got %+v", bev)
1382 }
1383 if ev := drain(a.Sub); len(ev) != 0 {
1384 t.Fatalf("sender echoed %v", types(ev))
1385 }
1386 if ev := drain(g.Sub); len(ev) != 0 {
1387 t.Fatalf("waiting guest received a relay: %v", types(ev))
1388 }
1389 if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: g.Peer, Seq: 2, Gen: 1}); err == nil {
1390 t.Fatal("relay to a waiting peer accepted")
1391 }
1392 if err := h.Relay("r1", "sg", g.Peer, Message{Type: TypeOffer, To: a.Peer, Seq: 1, Gen: 1}); err != ErrNotAdmitted {
1393 t.Fatalf("waiting guest relaying: %v", err)
1394 }
1395 if err := h.Relay("r1", "sb", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 3, Gen: 1}); err != ErrUnknownPeer {
1396 t.Fatalf("peer through another session: %v", err)
1397 }
1398}
1399
1400func TestStaleGenIsRefusedAndResetConverges(t *testing.T) {
1401 h, _, a, b := twoAdmitted(t)
1402 if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 2}); err != ErrStaleGen {
1403 t.Fatalf("gen 2 before any reset: %v", err)
1404 }
1405 if err := h.Reset("r1", "sa", a.Peer, b.Peer); err != nil {
1406 t.Fatal(err)
1407 }
1408 if err := h.Reset("r1", "sb", b.Peer, a.Peer); err != nil { // simultaneous resets converge on the higher gen
1409 t.Fatal(err)
1410 }
1411 aev, bev := drain(a.Sub), drain(b.Sub)
1412 if len(aev) != 2 || aev[1].Type != TypePair || aev[1].Gen != 3 || aev[1].Peer != b.Peer {
1413 t.Fatalf("a got %+v", aev)
1414 }
1415 if len(bev) != 2 || bev[1].Gen != 3 || bev[1].Peer != a.Peer {
1416 t.Fatalf("b got %+v", bev)
1417 }
1418 if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 1}); err != ErrStaleGen {
1419 t.Fatal("old gen accepted after reset")
1420 }
1421 if err := h.Relay("r1", "sa", a.Peer, Message{Type: TypeOffer, To: b.Peer, Seq: 1, Gen: 3}); err != nil {
1422 t.Fatal(err)
1423 }
1424}
1425
1426func TestResumeReplaysAboveLastAndSendsRosterOnlyWhenAdmitted(t *testing.T) {
1427 h, now, a, b := twoAdmitted(t)
1428 ctx := context.Background()
1429 for i := uint64(1); i <= 5; i++ {
1430 h.Relay("r1", "sb", b.Peer, Message{Type: TypeCandidate, To: a.Peer, Seq: i, Gen: 1, Payload: json.RawMessage(`{}`)})
1431 }
1432 drain(a.Sub)
1433 h.Detach("r1", a.Peer)
1434 *now = now.Add(5 * time.Second)
1435 res, err := h.Resume("r1", "sa", a.Peer, map[PeerID]uint64{b.Peer: 3})
1436 if err != nil {
1437 t.Fatal(err)
1438 }
1439 if !res.Admitted || res.Roster == nil || len(res.Replay) != 2 || res.Replay[0].Seq != 4 || res.Replay[1].Seq != 5 {
1440 t.Fatalf("resume %+v", res)
1441 }
1442 // A waiting guest resumes and learns nothing.
1443 g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest)
1444 h.Join(ctx, "r1", "sg", g.Peer)
1445 h.Detach("r1", g.Peer)
1446 gres, err := h.Resume("r1", "sg", g.Peer, nil)
1447 if err != nil {
1448 t.Fatal(err)
1449 }
1450 if gres.Admitted || gres.Roster != nil || len(gres.Replay) != 0 {
1451 t.Fatalf("waiting guest resume leaked %+v", gres)
1452 }
1453}
1454
1455func TestResumeGapWhenTheRingRolledOver(t *testing.T) {
1456 h, now, a, b := twoAdmitted(t)
1457 for i := uint64(1); i <= 40; i++ {
1458 h.Relay("r1", "sb", b.Peer, Message{Type: TypeCandidate, To: a.Peer, Seq: i, Gen: 1})
1459 }
1460 drain(a.Sub)
1461 h.Detach("r1", a.Peer)
1462 *now = now.Add(time.Second)
1463 res, _ := h.Resume("r1", "sa", a.Peer, map[PeerID]uint64{b.Peer: 2})
1464 if len(res.Gap) != 1 || res.Gap[0] != b.Peer || len(res.Replay) != 0 {
1465 t.Fatalf("expected a gap for b, got replay %d gap %v", len(res.Replay), res.Gap)
1466 }
1467}
1468
1469func TestResumeAnswers(t *testing.T) {
1470 h, now, a, _ := twoAdmitted(t)
1471 if _, err := h.Resume("r1", "sb", a.Peer, nil); err == nil || err.(*ResumeError).Status != 404 || err.(*ResumeError).Boot != h.Boot() {
1472 t.Fatalf("resume through another session: %v", err)
1473 }
1474 h.Takeover("r1", "sa")
1475 if _, err := h.Resume("r1", "sa", a.Peer, nil); err == nil || err.(*ResumeError).Status != 410 || err.(*ResumeError).Reason != ReasonReplaced {
1476 t.Fatalf("resume after takeover: %v", err)
1477 }
1478 *now = now.Add(TombstoneLife + time.Minute)
1479 h.Tick(context.Background())
1480 if _, err := h.Resume("r1", "sa", a.Peer, nil); err == nil || err.(*ResumeError).Status != 404 {
1481 t.Fatalf("resume after tombstone expiry: %v", err)
1482 }
1483 if _, err := h.Open("r1", "sa", 1, "Ann", KindMember); err != nil {
1484 t.Fatalf("fresh stream after tombstone: %v", err)
1485 }
1486}
1487
1488func TestResumeAfterWindowIsA404WithTheSameBoot(t *testing.T) {
1489 h, now, a, _ := twoAdmitted(t)
1490 h.Detach("r1", a.Peer)
1491 *now = now.Add(ResumeWindow + time.Second)
1492 h.Tick(context.Background())
1493 _, err := h.Resume("r1", "sa", a.Peer, nil)
1494 re, ok := err.(*ResumeError)
1495 if !ok || re.Status != 404 || re.Boot != h.Boot() {
1496 t.Fatalf("lapsed resume: %v", err)
1497 }
1498}
1499```
1500
1501- [ ] **Step 2: Run to verify they fail**
1502
1503Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ 2>&1 | grep -c undefined`
1504Expected: non-zero.
1505
1506- [ ] **Step 3: Implement relay and resume**
1507
1508`relay.go`:
1509
1510```go
1511package hub
1512
1513import "fmt"
1514
1515func isTargeted(t string) bool { return t == TypeOffer || t == TypeAnswer || t == TypeCandidate }
1516
1517// Relay delivers one targeted message. The envelope is rebuilt from the
1518// binding; the payload is never read.
1519func (h *Hub) Relay(sqid, session string, from PeerID, m Message) error {
1520 if !isTargeted(m.Type) {
1521 return fmt.Errorf("hub: %q is not a targeted type", m.Type)
1522 }
1523 h.mu.Lock()
1524 defer h.mu.Unlock()
1525 r, ok := h.rooms[sqid]
1526 if !ok {
1527 return ErrUnknownPeer
1528 }
1529 p, found := h.findLocked(sqid, session, from)
1530 if !found {
1531 return ErrUnknownPeer
1532 }
1533 if !p.Admitted {
1534 return ErrNotAdmitted
1535 }
1536 tp, ok := r.peers[m.To]
1537 if !ok || !tp.Admitted {
1538 return ErrUnknownPeer
1539 }
1540 if m.Gen != h.genLocked(r, p.Peer, tp.Peer) {
1541 return ErrStaleGen
1542 }
1543 out := Message{Type: m.Type, From: p.Peer, Name: p.Name, To: tp.Peer, Seq: m.Seq, Gen: m.Gen, Payload: m.Payload}
1544 h.ringLocked(tp, out)
1545 if tp.sub != nil {
1546 tp.sub.send(out)
1547 }
1548 return nil
1549}
1550
1551func (h *Hub) ringLocked(p *Participation, m Message) {
1552 p.ring = append(p.ring, m)
1553 if len(p.ring) > RingSize {
1554 p.ring = p.ring[len(p.ring)-RingSize:]
1555 }
1556}
1557
1558// Reset bumps the pair's generation and tells both sides.
1559func (h *Hub) Reset(sqid, session string, from, to PeerID) error {
1560 h.mu.Lock()
1561 defer h.mu.Unlock()
1562 r, ok := h.rooms[sqid]
1563 if !ok {
1564 return ErrUnknownPeer
1565 }
1566 p, found := h.findLocked(sqid, session, from)
1567 if !found {
1568 return ErrUnknownPeer
1569 }
1570 if !p.Admitted {
1571 return ErrNotAdmitted
1572 }
1573 tp, ok := r.peers[to]
1574 if !ok || !tp.Admitted {
1575 return ErrUnknownPeer
1576 }
1577 k := keyFor(p.Peer, tp.Peer)
1578 h.genLocked(r, p.Peer, tp.Peer)
1579 r.gens[k]++
1580 gen := r.gens[k]
1581 for _, pair := range [][2]*Participation{{p, tp}, {tp, p}} {
1582 m := Message{Type: TypePair, Peer: pair[1].Peer, Gen: gen, To: pair[0].Peer}
1583 h.ringLocked(pair[0], m)
1584 if pair[0].sub != nil {
1585 pair[0].sub.send(m)
1586 }
1587 }
1588 return nil
1589}
1590
1591type ResumeResult struct {
1592 Sub *Sub
1593 Admitted bool
1594 Roster *Roster
1595 Replay []Message
1596 Gap []PeerID
1597}
1598
1599type ResumeError struct {
1600 Status int
1601 Reason Reason
1602 Boot string
1603}
1604
1605func (e *ResumeError) Error() string { return fmt.Sprintf("hub: resume refused %d %s", e.Status, e.Reason) }
1606
1607// Resume reattaches a stream to a participation in its window.
1608func (h *Hub) Resume(sqid, session string, peer PeerID, last map[PeerID]uint64) (ResumeResult, error) {
1609 h.mu.Lock()
1610 defer h.mu.Unlock()
1611 if t, ok := h.tombstones[peer]; ok {
1612 return ResumeResult{}, &ResumeError{Status: 410, Reason: t.reason}
1613 }
1614 r, ok := h.rooms[sqid]
1615 if !ok {
1616 return ResumeResult{}, &ResumeError{Status: 404, Boot: h.boot}
1617 }
1618 p, found := h.findLocked(sqid, session, peer)
1619 if !found {
1620 return ResumeResult{}, &ResumeError{Status: 404, Boot: h.boot}
1621 }
1622 if p.sub != nil {
1623 p.sub.terminate(Message{Type: TypeLost, Reason: ReasonReplaced}) // a second stream for the same peer replaces the first attachment only
1624 }
1625 p.sub = newSub()
1626 p.resumeUntil = time.Time{}
1627 res := ResumeResult{Sub: p.sub, Admitted: p.Admitted}
1628 if !p.Admitted {
1629 return res, nil
1630 }
1631 res.Roster = h.rosterFor(r, p)
1632 // Replay: per sender, everything above last; a gap if the ring no
1633 // longer holds the first message the browser has not seen.
1634 oldest := map[PeerID]uint64{}
1635 for _, m := range p.ring {
1636 if isTargeted(m.Type) {
1637 if o, ok := oldest[m.From]; !ok || m.Seq < o {
1638 oldest[m.From] = m.Seq
1639 }
1640 }
1641 }
1642 gapped := map[PeerID]bool{}
1643 for from, o := range oldest {
1644 if o > last[from]+1 {
1645 gapped[from] = true
1646 res.Gap = append(res.Gap, from)
1647 }
1648 }
1649 for _, m := range p.ring {
1650 switch {
1651 case isTargeted(m.Type):
1652 if !gapped[m.From] && m.Seq > last[m.From] {
1653 res.Replay = append(res.Replay, m)
1654 }
1655 case m.Type == TypePair:
1656 if m.Gen > h.currentGenSeenLocked(res.Roster, m.Peer) {
1657 res.Replay = append(res.Replay, m)
1658 }
1659 }
1660 }
1661 return res, nil
1662}
1663
1664func (h *Hub) currentGenSeenLocked(ro *Roster, peer PeerID) uint64 {
1665 for _, rp := range ro.Peers {
1666 if rp.Peer == peer {
1667 return rp.Gen
1668 }
1669 }
1670 return 0
1671}
1672```
1673
1674Note: since the roster already carries the current generation for every admitted peer, a `pair` in the ring is only replayed when its `Gen` exceeds what the roster says, which by construction never happens for a current roster; keep the branch for correctness and let the browser also reconcile from the roster (spec §4.6). Add `"time"` to the imports.
1675
1676- [ ] **Step 4: Run the tests, gate, commit**
1677
1678Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ -race 2>&1 | tail -3` then `make ci`.
1679
1680```bash
1681git add internal/calls/hub
1682git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "hub: targeted relay with seq and gen, replay ring, reset and pair, resume and gap
1683
1684Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1685Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
1686```
1687
1688---
1689
1690### Task 4: The stream: SSE, hello with boot, heartbeat and TTL, resume answers
1691
1692**Files:**
1693- Create: `internal/calls/stream.go`, `internal/calls/participation.go`
1694- Modify: `internal/calls/app.go` (hub field, tick goroutine, routes), `internal/callstest/harness_test.go` (SSE client helper)
1695- Test: `internal/callstest/stream_test.go`
1696
1697**Interfaces:**
1698- Produces: `app.hub *hub.Hub` constructed in `build` with `StandingFor` adapting `standingFor` (look up the meeting by sqid and the person by id; `KindMember` when `StandingMember`, `KindGuest` when `StandingGuest`, `ok=false` when none or the meeting ended); a fifteen-second tick goroutine started by `App` only, plus `TestHandle.TickForTest()`; `GET /m/{sqid}/stream` inside the `requirePerson` group; SSE events `event: <type>\ndata: <json>\n\n`; `hello {peer, boot}` first; heartbeat every 15 s as a comment line `: hb\n\n`; TTL five minutes then the handler returns (the browser resumes); `Detach` on any exit; resume via `?peer=<id>&last=<json>`; answers per spec §4.5 with JSON bodies; test hooks `TestHandle.SetStreamTimings(heartbeat, ttl time.Duration)`.
1699- Harness: `func (c *client) stream(target string) *sseReader` with `next(timeout) (event string, data []byte, ok bool)`, `close()`, and `hello() (peer, boot string)`.
1700
1701- [ ] **Step 1: Write the failing tests**
1702
1703```go
1704package callstest
1705
1706import (
1707 "encoding/json"
1708 "net/http"
1709 "testing"
1710 "time"
1711)
1712
1713func TestStreamSaysHelloWithPeerAndBootAndRefusesTheSecondFreshStream(t *testing.T) {
1714 x, sqid, _ := memberWithMeeting(t)
1715 c := x.browser(t)
1716 c.signIn("paul@example.com")
1717 s := c.stream("/m/" + sqid + "/stream")
1718 defer s.close()
1719 peer, boot := s.hello()
1720 if len(peer) != 32 || len(boot) != 32 {
1721 t.Fatalf("hello peer=%q boot=%q", peer, boot)
1722 }
1723 rec := c.get("/m/" + sqid + "/stream")
1724 if rec.Code != http.StatusConflict {
1725 t.Fatalf("second fresh stream: %d, want 409", rec.Code)
1726 }
1727 var body struct{ Peer string }
1728 json.Unmarshal(rec.Body.Bytes(), &body)
1729 if body.Peer != peer {
1730 t.Fatalf("409 names %q, want %q", body.Peer, peer)
1731 }
1732}
1733
1734func TestStreamNeedsStandingAndAResumeNeedsTheSession(t *testing.T) {
1735 x, sqid, tok := memberWithMeeting(t)
1736 stranger := x.browser(t)
1737 // stranger has no standing: no grant, not a member
1738 stranger2 := newHarness(t).browser(t)
1739 _ = stranger2
1740 if rec := stranger.get("/m/" + sqid + "/stream"); rec.Code != http.StatusSeeOther {
1741 t.Fatalf("signed-out stream: %d", rec.Code)
1742 }
1743 c := x.browser(t)
1744 c.signIn("paul@example.com")
1745 s := c.stream("/m/" + sqid + "/stream")
1746 peer, _ := s.hello()
1747 s.close()
1748 other := x.browser(t)
1749 other.get("/j/" + tok)
1750 other.signIn("guest@example.com")
1751 rec := other.get("/m/" + sqid + "/stream?peer=" + peer)
1752 if rec.Code != http.StatusNotFound {
1753 t.Fatalf("resume through another session: %d, want 404", rec.Code)
1754 }
1755}
1756
1757func TestResumeAfterTheStreamClosesKeepsAdmission(t *testing.T) {
1758 x, sqid, _ := memberWithMeeting(t)
1759 c := x.browser(t)
1760 c.signIn("paul@example.com")
1761 s := c.stream("/m/" + sqid + "/stream")
1762 peer, boot := s.hello()
1763 c.signal(sqid, peer, `{"type":"join"}`)
1764 if ev, _, ok := s.next(time.Second); !ok || ev != "roster" {
1765 t.Fatalf("after join: %s ok=%v", ev, ok)
1766 }
1767 s.close()
1768 s2 := c.stream("/m/" + sqid + "/stream?peer=" + peer)
1769 defer s2.close()
1770 p2, b2 := s2.hello()
1771 if p2 != peer || b2 != boot {
1772 t.Fatalf("resume hello %s %s", p2, b2)
1773 }
1774 if ev, _, ok := s2.next(time.Second); !ok || ev != "roster" {
1775 t.Fatalf("resume of an admitted peer should send roster, got %s", ev)
1776 }
1777}
1778
1779func TestTTLClosesTheStreamAndResumeWorksWithinTheWindow(t *testing.T) {
1780 x, sqid, _ := memberWithMeeting(t)
1781 x.ctl.SetStreamTimings(50*time.Millisecond, 300*time.Millisecond)
1782 c := x.browser(t)
1783 c.signIn("paul@example.com")
1784 s := c.stream("/m/" + sqid + "/stream")
1785 peer, _ := s.hello()
1786 c.signal(sqid, peer, `{"type":"join"}`)
1787 s.next(time.Second)
1788 if !s.waitClosed(2 * time.Second) {
1789 t.Fatal("stream did not close at the TTL")
1790 }
1791 s2 := c.stream("/m/" + sqid + "/stream?peer=" + peer)
1792 defer s2.close()
1793 if p, _ := s2.hello(); p != peer {
1794 t.Fatalf("resume after TTL: %s", p)
1795 }
1796}
1797
1798func TestLapsedResumeIs404WithTheSameBootAndAFreshJoinWorks(t *testing.T) {
1799 x, sqid, _ := memberWithMeeting(t)
1800 c := x.browser(t)
1801 c.signIn("paul@example.com")
1802 s := c.stream("/m/" + sqid + "/stream")
1803 peer, boot := s.hello()
1804 c.signal(sqid, peer, `{"type":"join"}`)
1805 s.next(time.Second)
1806 s.close()
1807 x.advance(31 * time.Second)
1808 x.ctl.TickForTest()
1809 rec := c.get("/m/" + sqid + "/stream?peer=" + peer)
1810 var body struct{ Boot string }
1811 json.Unmarshal(rec.Body.Bytes(), &body)
1812 if rec.Code != http.StatusNotFound || body.Boot != boot {
1813 t.Fatalf("lapsed resume: %d %+v", rec.Code, body)
1814 }
1815 s3 := c.stream("/m/" + sqid + "/stream")
1816 defer s3.close()
1817 if p, _ := s3.hello(); p == "" || p == peer {
1818 t.Fatalf("fresh join after lapse: %q", p)
1819 }
1820}
1821```
1822
1823The harness additions (`harness_test.go`): an `sseReader` that performs the request with `httptest.NewRecorder` cannot stream; use a real `httptest.Server` wrapping `x.h` for stream tests. Add to `harness`: `srv *httptest.Server` started lazily by `x.server()`; `client.stream` issues a real HTTP GET with the client's cookies and `Origin`, reads lines in a goroutine, parses `event:`/`data:` pairs into a channel; `next(timeout)` receives; `hello()` reads the first event and unmarshals `peer` and `boot`; `waitClosed` waits for EOF; `close()` cancels the request. `client.signal(sqid, peer, body string) *http.Response` POSTs JSON `{"peer": peer, ...body fields}` with `Content-Type: application/json` and `Origin` through the same server. Because the CSRF middleware checks `Origin` against `testOrigin` (`http://app.test`), the `httptest.Server`'s origin differs; set the harness `Config.Origin` to the server's URL when the server is started (start it in `newHarness` before building the app, so `Origin` is right from the first request) and make `testOrigin` a per-harness value `x.origin`. Update `linkInPage` to match `x.origin`. This is a harness refactor the first step of this task does before the tests.
1824
1825- [ ] **Step 2: Implement**
1826
1827`participation.go`:
1828
1829```go
1830package meet
1831
1832import (
1833 "context"
1834
1835 "amadan.net/oficina/meet/internal/calls/hub"
1836)
1837
1838// hubStanding adapts standingFor for the hub: kind and whether the
1839// person still holds entry authority for the room.
1840func (a *app) hubStanding(ctx context.Context, sqid string, personID int64) (hub.Kind, bool, error) {
1841 m, ok, err := a.store.meetingBySqid(ctx, sqid)
1842 if err != nil || !ok || m.EndedAt != nil {
1843 return hub.KindGuest, false, err
1844 }
1845 p, err := a.store.personByID(ctx, personID)
1846 if err != nil {
1847 return hub.KindGuest, false, err
1848 }
1849 st, err := a.standingFor(ctx, p, m)
1850 if err != nil {
1851 return hub.KindGuest, false, err
1852 }
1853 switch st {
1854 case StandingMember:
1855 return hub.KindMember, true, nil
1856 case StandingGuest:
1857 return hub.KindGuest, true, nil
1858 }
1859 return hub.KindGuest, false, nil
1860}
1861```
1862
1863Add `store.personByID(ctx, id int64) (Person, error)`.
1864
1865`stream.go`:
1866
1867```go
1868package meet
1869
1870import (
1871 "encoding/json"
1872 "fmt"
1873 "net/http"
1874 "time"
1875
1876 "github.com/go-chi/chi/v5"
1877
1878 "amadan.net/oficina/meet/internal/calls/hub"
1879 "amadan.net/rastrillo/rastrillo/sessions"
1880)
1881
1882// sessionKey is the session's identity for the hub: the cookie token
1883// hash is not exposed by rastrillo, so the subject plus a per-boot salt
1884// stands in. Two tabs of one browser share it; two people never do.
1885func (a *app) sessionKey(r *http.Request) string {
1886 s, _ := sessions.Current(r)
1887 return s.Subject + "|" + s.At.UTC().Format(time.RFC3339Nano)
1888}
1889
1890func writeEvent(w http.ResponseWriter, flusher http.Flusher, m hub.Message) error {
1891 b, err := json.Marshal(m)
1892 if err != nil {
1893 return err
1894 }
1895 if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", m.Type, b); err != nil {
1896 return err
1897 }
1898 flusher.Flush()
1899 return nil
1900}
1901
1902// stream is GET /m/{sqid}/stream: fresh (standing) or resume (participation).
1903func (a *app) stream(w http.ResponseWriter, r *http.Request) {
1904 sqid := chi.URLParam(r, "sqid")
1905 p, _ := currentPerson(r)
1906 session := a.sessionKey(r)
1907 flusher, ok := w.(http.Flusher)
1908 if !ok {
1909 ErrorPage(w, r, http.StatusInternalServerError, "")
1910 return
1911 }
1912
1913 var sub *hub.Sub
1914 var peer hub.PeerID
1915 var first []hub.Message
1916
1917 if resume := r.URL.Query().Get("peer"); resume != "" {
1918 var last map[hub.PeerID]uint64
1919 if raw := r.URL.Query().Get("last"); raw != "" {
1920 if err := json.Unmarshal([]byte(raw), &last); err != nil {
1921 http.Error(w, `{"error":"bad last"}`, http.StatusBadRequest)
1922 return
1923 }
1924 }
1925 res, err := a.hub.Resume(sqid, session, hub.PeerID(resume), last)
1926 if err != nil {
1927 re, ok := err.(*hub.ResumeError)
1928 if !ok {
1929 ErrorPage(w, r, http.StatusInternalServerError, "")
1930 return
1931 }
1932 w.Header().Set("Content-Type", "application/json")
1933 w.WriteHeader(re.Status)
1934 json.NewEncoder(w).Encode(map[string]any{"reason": re.Reason, "boot": re.Boot})
1935 return
1936 }
1937 sub, peer = res.Sub, hub.PeerID(resume)
1938 first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot()})
1939 if res.Admitted {
1940 first = append(first, hub.Message{Type: hub.TypeRoster, Roster: res.Roster})
1941 first = append(first, res.Replay...)
1942 for _, from := range res.Gap {
1943 first = append(first, hub.Message{Type: hub.TypeGap, From: from})
1944 }
1945 } else {
1946 first = append(first, hub.Message{Type: hub.TypeState, Waiting: true})
1947 }
1948 } else {
1949 m, ok, err := a.store.meetingBySqid(r.Context(), sqid)
1950 if err != nil || !ok || m.EndedAt != nil {
1951 ErrorPage(w, r, http.StatusNotFound, "")
1952 return
1953 }
1954 st, err := a.standingFor(r.Context(), p, m)
1955 if err != nil || st == StandingNone {
1956 ErrorPage(w, r, http.StatusNotFound, "")
1957 return
1958 }
1959 kind := hub.KindGuest
1960 if st == StandingMember {
1961 kind = hub.KindMember
1962 }
1963 res, err := a.hub.Open(sqid, session, p.ID, p.Name, kind)
1964 switch err {
1965 case nil:
1966 case hub.ErrSessionHasPeer:
1967 w.Header().Set("Content-Type", "application/json")
1968 w.WriteHeader(http.StatusConflict)
1969 json.NewEncoder(w).Encode(map[string]any{"peer": res.Existing})
1970 return
1971 case hub.ErrRoomFull, hub.ErrPersonStreams:
1972 http.Error(w, `{"error":"full"}`, http.StatusServiceUnavailable)
1973 return
1974 default:
1975 ErrorPage(w, r, http.StatusNotFound, "")
1976 return
1977 }
1978 sub, peer = res.Sub, res.Peer
1979 first = append(first, hub.Message{Type: hub.TypeHello, Peer: peer, Boot: a.hub.Boot()})
1980 }
1981
1982 w.Header().Set("Content-Type", "text/event-stream")
1983 w.Header().Set("Cache-Control", "no-store")
1984 w.Header().Set("X-Accel-Buffering", "no")
1985 w.WriteHeader(http.StatusOK)
1986 defer a.hub.Detach(sqid, peer)
1987
1988 rc := http.NewResponseController(w)
1989 for _, m := range first {
1990 if err := writeEvent(w, flusher, m); err != nil {
1991 return
1992 }
1993 }
1994 heartbeat := time.NewTicker(a.streamHeartbeat)
1995 defer heartbeat.Stop()
1996 ttl := time.NewTimer(a.streamTTL)
1997 defer ttl.Stop()
1998 for {
1999 _ = rc.SetWriteDeadline(time.Now().Add(30 * time.Second))
2000 select {
2001 case <-r.Context().Done():
2002 return
2003 case <-ttl.C:
2004 return
2005 case <-heartbeat.C:
2006 if _, err := fmt.Fprint(w, ": hb\n\n"); err != nil {
2007 return
2008 }
2009 flusher.Flush()
2010 case m, ok := <-sub.C():
2011 if !ok {
2012 return
2013 }
2014 if err := writeEvent(w, flusher, m); err != nil {
2015 return
2016 }
2017 if m.Type == hub.TypeLost || m.Type == hub.TypeEnded {
2018 return
2019 }
2020 }
2021 }
2022}
2023```
2024
2025In `app.go`: fields `hub *hub.Hub`, `streamHeartbeat`, `streamTTL` (defaults 15 s and 5 min); construct the hub in `build` with `Now: st.now`, `Logger`, `StandingFor: a.hubStanding`; `App` starts `go a.tickForever(ctx)` (15 s ticker calling `a.hub.Tick(ctx)`); `TestHandle.TickForTest()` and `SetStreamTimings`. Route inside the `requirePerson` group: `r.Get("/m/{sqid}/stream", a.stream)`. Also wire `endMeeting` to call `a.hub.End(m.Sqid)` after the store update.
2026
2027- [ ] **Step 3: Run tests, gate, commit**
2028
2029Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/callstest/ -run 'Stream|Resume|TTL|Lapsed' -v 2>&1 | tail -15` then `make ci`.
2030
2031```bash
2032git add internal/calls internal/callstest
2033git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "stream: SSE per peer with hello and boot, heartbeat, TTL, resume answers
2034
2035Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2036Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
2037```
2038
2039---
2040
2041### Task 5: Signal: POST with limits, rate limit, dispatch, refusal logging, captured-log test
2042
2043**Files:**
2044- Create: `internal/calls/signal.go`, `internal/calls/ratelimit.go`
2045- Test: `internal/callstest/signal_test.go`
2046
2047**Interfaces:**
2048- Produces: `POST /m/{sqid}/signal` inside the `requirePerson` group; body `{"peer": "<id>", "type": "...", "to": "...", "seq": n, "gen": n, "payload": {...}}`; refused above 64 KB with 413; 429 with `Retry-After: 1` above thirty per second (burst sixty) per peer, a token bucket keyed by peer id in `a.signalLimiter`; dispatch: `join` → `hub.Join` (standing re-derived by the hub); `admit` → `hub.Admit(actor, payload.peer)`; `leave` → `hub.Leave`; `offer|answer|candidate` → `hub.Relay`; `reset` → `hub.Reset(from, payload.to)`; `chat|share` → 400 in this slice (slice 4); unknown → 400. Error mapping: `ErrUnknownPeer`/`ErrNotYours` → 404; `ErrNotAdmitted`/`ErrNotMember`/`ErrNotWaiting` → 403; `ErrStaleGen` → 409 `{"error":"stale","gen":<current>}`; `ErrRoomFull`/`ErrTooManyWaiting` → 200 (the `full` event carries the outcome). Every refusal logs `type`, `room`, `peer`, `outcome` and nothing else; an accepted message logs nothing.
2049
2050- [ ] **Step 1: Write the failing tests**
2051
2052```go
2053package callstest
2054
2055import (
2056 "bytes"
2057 "log/slog"
2058 "net/http"
2059 "strings"
2060 "testing"
2061 "time"
2062)
2063
2064func TestSignalNeedsAPeerBoundToThisSessionAndRoom(t *testing.T) {
2065 x, sqid, tok := memberWithMeeting(t)
2066 c := x.browser(t)
2067 c.signIn("paul@example.com")
2068 s := c.stream("/m/" + sqid + "/stream")
2069 defer s.close()
2070 peer, _ := s.hello()
2071 if resp := c.signal(sqid, "0123456789abcdef0123456789abcdef", `{"type":"join"}`); resp.StatusCode != http.StatusNotFound {
2072 t.Fatalf("foreign peer: %d", resp.StatusCode)
2073 }
2074 other := x.browser(t)
2075 other.get("/j/" + tok)
2076 other.signIn("guest@example.com")
2077 if resp := other.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusNotFound {
2078 t.Fatalf("peer through another session: %d", resp.StatusCode)
2079 }
2080 if resp := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
2081 t.Fatalf("own join: %d", resp.StatusCode)
2082 }
2083}
2084
2085func TestSignalLimits(t *testing.T) {
2086 x, sqid, _ := memberWithMeeting(t)
2087 c := x.browser(t)
2088 c.signIn("paul@example.com")
2089 s := c.stream("/m/" + sqid + "/stream")
2090 defer s.close()
2091 peer, _ := s.hello()
2092 c.signal(sqid, peer, `{"type":"join"}`)
2093 big := `{"type":"candidate","to":"` + peer + `","seq":1,"gen":1,"payload":{"x":"` + strings.Repeat("a", 70*1024) + `"}}`
2094 if resp := c.signal(sqid, peer, big); resp.StatusCode != http.StatusRequestEntityTooLarge {
2095 t.Fatalf("64 KB: %d", resp.StatusCode)
2096 }
2097 var got429 bool
2098 for i := 0; i < 100; i++ {
2099 if resp := c.signal(sqid, peer, `{"type":"leave"}`); resp.StatusCode == http.StatusTooManyRequests {
2100 if resp.Header.Get("Retry-After") == "" {
2101 t.Fatal("429 without Retry-After")
2102 }
2103 got429 = true
2104 break
2105 }
2106 }
2107 if !got429 {
2108 t.Fatal("100 messages in a burst never hit the rate limit")
2109 }
2110}
2111
2112func TestSignalStaleGenAnswers409WithTheCurrentGen(t *testing.T) {
2113 x, sqid, _ := memberWithMeeting(t)
2114 a := x.browser(t)
2115 a.signIn("paul@example.com")
2116 sa := a.stream("/m/" + sqid + "/stream")
2117 defer sa.close()
2118 pa, _ := sa.hello()
2119 a.signal(sqid, pa, `{"type":"join"}`)
2120 b := newMemberBrowser(t, x, "paul@rslw.com")
2121 sb := b.stream("/m/" + sqid + "/stream")
2122 defer sb.close()
2123 pb, _ := sb.hello()
2124 b.signal(sqid, pb, `{"type":"join"}`)
2125 resp := a.signal(sqid, pa, `{"type":"offer","to":"`+pb+`","seq":1,"gen":7,"payload":{}}`)
2126 if resp.StatusCode != http.StatusConflict {
2127 t.Fatalf("stale gen: %d", resp.StatusCode)
2128 }
2129}
2130
2131// The §0.1 and §4.4 promise: no payload, name, title or token in any log
2132// line, on accepted and refused messages and on an error path.
2133func TestSignallingLogsCarryNoContent(t *testing.T) {
2134 var buf bytes.Buffer
2135 x := newHarnessWithLogger(t, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com", "paul@rslw.com")
2136 c := x.browser(t)
2137 c.signIn("paul@example.com")
2138 sqid, tok := createMeeting(t, c, "Secret title", "")
2139 s := c.stream("/m/" + sqid + "/stream")
2140 defer s.close()
2141 peer, _ := s.hello()
2142 c.signal(sqid, peer, `{"type":"join"}`)
2143 b := newMemberBrowser(t, x, "paul@rslw.com")
2144 sb := b.stream("/m/" + sqid + "/stream")
2145 defer sb.close()
2146 pb, _ := sb.hello()
2147 b.signal(sqid, pb, `{"type":"join"}`)
2148 c.signal(sqid, peer, `{"type":"offer","to":"`+pb+`","seq":1,"gen":1,"payload":{"sdp":"v=0 SDPSECRET a=candidate 203.0.113.9"}}`)
2149 c.signal(sqid, peer, `{"type":"candidate","to":"`+pb+`","seq":2,"gen":1,"payload":{"candidate":"candidate:1 1 udp 2 203.0.113.9 5000 typ host"}}`)
2150 c.signal(sqid, peer, `{"type":"offer","to":"`+pb+`","seq":3,"gen":9,"payload":{"sdp":"STALESECRET"}}`)
2151 c.signal(sqid, peer, `{"type":"bogus"}`)
2152 x.advance(time.Minute)
2153 x.ctl.TickForTest()
2154 logs := buf.String()
2155 for _, needle := range []string{"Secret title", "SDPSECRET", "203.0.113.9", "STALESECRET", tok, "paul@example.com"} {
2156 if strings.Contains(logs, needle) {
2157 t.Errorf("log carries %q", needle)
2158 }
2159 }
2160}
2161```
2162
2163Add `newHarnessWithLogger(t, logger, members...)` and `newMemberBrowser(t, x, address) *client` (a browser that signs in as an allowlisted member) to the harness.
2164
2165- [ ] **Step 2: Implement**
2166
2167`ratelimit.go`: a per-key token bucket, `rate 30/s, burst 60`, `allow(key string, now time.Time) (ok bool, retryAfter time.Duration)`, entries evicted when idle for a minute (sweep on each call every thousand calls or on the tick).
2168
2169`signal.go`:
2170
2171```go
2172package meet
2173
2174import (
2175 "encoding/json"
2176 "errors"
2177 "io"
2178 "net/http"
2179 "strconv"
2180
2181 "github.com/go-chi/chi/v5"
2182
2183 "amadan.net/oficina/meet/internal/calls/hub"
2184)
2185
2186const maxSignalBody = 64 * 1024
2187
2188type signalBody struct {
2189 Peer hub.PeerID `json:"peer"`
2190 Type string `json:"type"`
2191 To hub.PeerID `json:"to,omitempty"`
2192 Seq uint64 `json:"seq,omitempty"`
2193 Gen uint64 `json:"gen,omitempty"`
2194 Payload json.RawMessage `json:"payload,omitempty"`
2195}
2196
2197func (a *app) signal(w http.ResponseWriter, r *http.Request) {
2198 sqid := chi.URLParam(r, "sqid")
2199 session := a.sessionKey(r)
2200 r.Body = http.MaxBytesReader(w, r.Body, maxSignalBody)
2201 raw, err := io.ReadAll(r.Body)
2202 if err != nil {
2203 var tooBig *http.MaxBytesError
2204 if errors.As(err, &tooBig) {
2205 a.refuse(w, sqid, "", "", http.StatusRequestEntityTooLarge, "too-large")
2206 return
2207 }
2208 a.refuse(w, sqid, "", "", http.StatusBadRequest, "read")
2209 return
2210 }
2211 var body signalBody
2212 if err := json.Unmarshal(raw, &body); err != nil || body.Peer == "" || body.Type == "" {
2213 a.refuse(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape")
2214 return
2215 }
2216 if ok, retry := a.signalLimiter.allow(string(body.Peer), a.store.now()); !ok {
2217 w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
2218 a.refuse(w, sqid, body.Peer, body.Type, http.StatusTooManyRequests, "rate")
2219 return
2220 }
2221 if _, ok := a.hub.Find(sqid, session, body.Peer); !ok {
2222 a.refuse(w, sqid, body.Peer, body.Type, http.StatusNotFound, "binding")
2223 return
2224 }
2225 var herr error
2226 switch body.Type {
2227 case hub.TypeJoin:
2228 herr = a.hub.Join(r.Context(), sqid, session, body.Peer)
2229 case hub.TypeAdmit:
2230 var p struct{ Peer hub.PeerID `json:"peer"` }
2231 json.Unmarshal(body.Payload, &p)
2232 herr = a.hub.Admit(r.Context(), sqid, session, body.Peer, p.Peer)
2233 case hub.TypeLeave:
2234 herr = a.hub.Leave(sqid, session, body.Peer)
2235 case hub.TypeOffer, hub.TypeAnswer, hub.TypeCandidate:
2236 herr = a.hub.Relay(sqid, session, body.Peer, hub.Message{Type: body.Type, To: body.To, Seq: body.Seq, Gen: body.Gen, Payload: body.Payload})
2237 case hub.TypeReset:
2238 herr = a.hub.Reset(sqid, session, body.Peer, body.To)
2239 default:
2240 a.refuse(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type")
2241 return
2242 }
2243 switch {
2244 case herr == nil, errors.Is(herr, hub.ErrRoomFull), errors.Is(herr, hub.ErrTooManyWaiting):
2245 w.WriteHeader(http.StatusOK)
2246 case errors.Is(herr, hub.ErrStaleGen):
2247 w.Header().Set("Content-Type", "application/json")
2248 w.WriteHeader(http.StatusConflict)
2249 json.NewEncoder(w).Encode(map[string]any{"error": "stale"})
2250 a.logRefusal(sqid, body.Peer, body.Type, "stale-gen")
2251 case errors.Is(herr, hub.ErrNotAdmitted), errors.Is(herr, hub.ErrNotMember), errors.Is(herr, hub.ErrNotWaiting):
2252 a.refuse(w, sqid, body.Peer, body.Type, http.StatusForbidden, "authority")
2253 case errors.Is(herr, hub.ErrUnknownPeer), errors.Is(herr, hub.ErrNotYours):
2254 a.refuse(w, sqid, body.Peer, body.Type, http.StatusNotFound, "binding")
2255 default:
2256 a.logger.Error("calls: signal", "room", sqid, "type", body.Type, "err", herr.Error())
2257 w.WriteHeader(http.StatusInternalServerError)
2258 }
2259}
2260
2261func (a *app) refuse(w http.ResponseWriter, sqid string, peer hub.PeerID, typ string, status int, outcome string) {
2262 a.logRefusal(sqid, peer, typ, outcome)
2263 w.WriteHeader(status)
2264}
2265
2266// logRefusal is the only signalling log line, and it carries no content.
2267func (a *app) logRefusal(sqid string, peer hub.PeerID, typ, outcome string) {
2268 a.logger.Warn("calls: signal refused", "room", sqid, "peer", string(peer), "type", typ, "outcome", outcome)
2269}
2270```
2271
2272Route: `r.Post("/m/{sqid}/signal", a.signal)` in the `requirePerson` group. `a.signalLimiter = newRateLimiter(30, 60)` in `build`. Note the `hub.Join` error for a `full` room is a 200 because the event is the answer; `ErrNotYours` on join (lost standing) is 404.
2273
2274- [ ] **Step 3: Run tests, gate, commit**
2275
2276```bash
2277git add internal/calls internal/callstest
2278git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "signal: POST with body and rate limits, dispatch, refusal logging without content
2279
2280Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2281Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
2282```
2283
2284---
2285
2286### Task 6: Room page, removal and takeover confirm pages, page authorization, AGENTS.md exception
2287
2288**Files:**
2289- Create: `internal/calls/handlers_room.go`, `internal/calls/templates/room.html`
2290- Modify: `internal/calls/handlers_join.go` (holding authorized by standing or participation), `internal/calls/templates/join.html`, `internal/calls/app.go` (routes), `AGENTS.md`
2291- Test: `internal/callstest/room_test.go`
2292
2293**Interfaces:**
2294- Produces: `GET /m/{sqid}` (room page; standing or live participation; scriptless output is the holding header, the paragraph, and a link to the holding screen; scripted output is the grid container, controls, admission queue, all rendered by `room.js`); `GET /m/{sqid}/remove/{peer}` and `POST` (actor must hold an admitted member participation, checked at execution through `hub.Remove`); `GET /m/{sqid}/takeover` and `POST` (standing and a live participation for this session, `hub.Takeover`, then 303 to the room); page data for the room: `Sqid, Title, Starts, Name, Standing, IsMember, Floor, Boot`; a `data-*` block on the room page carrying `sqid`, `boot`, `self-name`, `is-member`, `peer` (empty) for `room.js`.
2295- `AGENTS.md` "Screens work with JavaScript disabled" line becomes: "Screens work with JavaScript disabled, with one exception: browser media participation (the holding screen's camera preview and join button, and the call itself in the room) needs script; both pages render their title, standing, floor notice and navigation without it."
2296
2297- [ ] **Step 1: Write the failing tests**
2298
2299```go
2300package callstest
2301
2302import (
2303 "net/http"
2304 "net/url"
2305 "strings"
2306 "testing"
2307)
2308
2309func TestRoomPageIsScriptlessHonestAndStandingGated(t *testing.T) {
2310 x, sqid, _ := memberWithMeeting(t)
2311 c := x.browser(t)
2312 c.signIn("paul@example.com")
2313 rec := c.get("/m/" + sqid)
2314 body := rec.Body.String()
2315 if rec.Code != http.StatusOK || !strings.Contains(body, "Board meeting") || !strings.Contains(body, `data-sqid="`+sqid+`"`) || !strings.Contains(body, "/m/"+sqid+"/join") {
2316 t.Fatalf("room page: %d\n%s", rec.Code, body)
2317 }
2318 if !strings.Contains(body, "<noscript>") || !strings.Contains(body, "needs JavaScript") {
2319 t.Fatal("room page lacks its scriptless paragraph")
2320 }
2321 stranger := newHarness(t).browser(t)
2322 stranger.signIn("nobody@example.com")
2323 if rec := stranger.get("/m/" + sqid); rec.Code != http.StatusNotFound {
2324 t.Fatalf("no standing: %d", rec.Code)
2325 }
2326}
2327
2328func TestRemoveNeedsAConfirmPageAndAnAdmittedMemberActor(t *testing.T) {
2329 x, sqid, tok := memberWithMeeting(t)
2330 m := x.browser(t)
2331 m.signIn("paul@example.com")
2332 sm := m.stream("/m/" + sqid + "/stream")
2333 defer sm.close()
2334 pm, _ := sm.hello()
2335 m.signal(sqid, pm, `{"type":"join"}`)
2336 g := x.browser(t)
2337 g.get("/j/" + tok)
2338 g.signIn("guest@example.com")
2339 sg := g.stream("/m/" + sqid + "/stream")
2340 defer sg.close()
2341 pg, _ := sg.hello()
2342 g.signal(sqid, pg, `{"type":"join"}`)
2343 if rec := m.get("/m/" + sqid + "/remove/" + pg); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Remove") {
2344 t.Fatalf("confirm page: %d", rec.Code)
2345 }
2346 if rec := g.post("/m/"+sqid+"/remove/"+pm, nil); rec.Code != http.StatusNotFound {
2347 t.Fatalf("waiting guest removing the member: %d", rec.Code)
2348 }
2349 if rec := m.post("/m/"+sqid+"/remove/"+pg, nil); rec.Code != http.StatusSeeOther {
2350 t.Fatalf("member removing: %d", rec.Code)
2351 }
2352 ev, data, ok := sg.nextOf("lost", 2)
2353 if !ok || !strings.Contains(string(data), `"removed"`) {
2354 t.Fatalf("removed guest got %s %s", ev, data)
2355 }
2356}
2357
2358func TestTakeoverIsAConfirmedPostAndAGetEndsNothing(t *testing.T) {
2359 x, sqid, _ := memberWithMeeting(t)
2360 m := x.browser(t)
2361 m.signIn("paul@example.com")
2362 s1 := m.stream("/m/" + sqid + "/stream")
2363 defer s1.close()
2364 p1, _ := s1.hello()
2365 m.signal(sqid, p1, `{"type":"join"}`)
2366 if rec := m.get("/m/" + sqid + "/stream?takeover=1"); rec.Code != http.StatusConflict {
2367 t.Fatalf("a GET with a query parameter must not take over: %d", rec.Code)
2368 }
2369 if rec := m.get("/m/" + sqid + "/takeover"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "another tab") {
2370 t.Fatalf("takeover confirm: %d", rec.Code)
2371 }
2372 req := httptestPost("/m/"+sqid+"/takeover", url.Values{})
2373 req.Header.Set("Origin", "https://evil.example")
2374 if rec := m.do(req); rec.Code != http.StatusForbidden {
2375 t.Fatalf("cross-origin takeover: %d", rec.Code)
2376 }
2377 if rec := m.post("/m/"+sqid+"/takeover", nil); rec.Code != http.StatusSeeOther {
2378 t.Fatalf("takeover: %d", rec.Code)
2379 }
2380 if ev, data, ok := s1.nextOf("lost", 2); !ok || !strings.Contains(string(data), `"replaced"`) {
2381 t.Fatalf("old stream got %s %s", ev, data)
2382 }
2383 if rec := m.get("/m/" + sqid + "/stream?peer=" + p1); rec.Code != http.StatusGone {
2384 t.Fatalf("resume of the replaced peer: %d, want 410", rec.Code)
2385 }
2386 s2 := m.stream("/m/" + sqid + "/stream")
2387 defer s2.close()
2388 if p2, _ := s2.hello(); p2 == "" || p2 == p1 {
2389 t.Fatalf("fresh stream after takeover: %q", p2)
2390 }
2391}
2392
2393func TestEndingTheMeetingEndsTheRoom(t *testing.T) {
2394 x, sqid, _ := memberWithMeeting(t)
2395 m := x.browser(t)
2396 m.signIn("paul@example.com")
2397 s := m.stream("/m/" + sqid + "/stream")
2398 defer s.close()
2399 p, _ := s.hello()
2400 m.signal(sqid, p, `{"type":"join"}`)
2401 m.post("/meetings/"+sqid+"/end", nil)
2402 if ev, data, ok := s.nextOf("lost", 2); !ok || !strings.Contains(string(data), `"ended"`) {
2403 t.Fatalf("got %s %s", ev, data)
2404 }
2405}
2406```
2407
2408`sseReader.nextOf(event string, timeoutSeconds int)` skips events until the named one arrives.
2409
2410- [ ] **Step 2: Implement**
2411
2412`handlers_room.go`:
2413
2414```go
2415package meet
2416
2417import (
2418 "net/http"
2419
2420 "github.com/go-chi/chi/v5"
2421
2422 "amadan.net/oficina/meet/internal/calls/hub"
2423)
2424
2425// roomAccess is §2.5's row for the two pages: standing, or a live
2426// participation for this session in this room.
2427func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Person, Standing, bool) {
2428 sqid := chi.URLParam(r, "sqid")
2429 m, ok, err := a.store.meetingBySqid(r.Context(), sqid)
2430 if err != nil || !ok || m.EndedAt != nil {
2431 ErrorPage(w, r, http.StatusNotFound, "")
2432 return Meeting{}, Person{}, StandingNone, false
2433 }
2434 p, _ := currentPerson(r)
2435 st, err := a.standingFor(r.Context(), p, m)
2436 if err != nil {
2437 ErrorPage(w, r, http.StatusInternalServerError, "")
2438 return Meeting{}, Person{}, StandingNone, false
2439 }
2440 if st == StandingNone {
2441 if _, live := a.hub.LiveForSession(sqid, a.sessionKey(r)); !live {
2442 ErrorPage(w, r, http.StatusNotFound, "")
2443 return Meeting{}, Person{}, StandingNone, false
2444 }
2445 st = StandingGuest
2446 }
2447 return m, p, st, true
2448}
2449
2450func (a *app) room(w http.ResponseWriter, r *http.Request) {
2451 m, p, st, ok := a.roomAccess(w, r)
2452 if !ok {
2453 return
2454 }
2455 render(w, "room", a.page(r, map[string]any{
2456 "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
2457 "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
2458 "Floor": !a.cfg.RelayConfigured, "Boot": a.hub.Boot(),
2459 }))
2460}
2461
2462func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) {
2463 m, _, st, ok := a.roomAccess(w, r)
2464 if !ok {
2465 return
2466 }
2467 if st != StandingMember {
2468 ErrorPage(w, r, http.StatusNotFound, "")
2469 return
2470 }
2471 target := chi.URLParam(r, "peer")
2472 render(w, "confirm", a.page(r, map[string]any{
2473 "Title": "Remove this participant?", "Body": "They will be disconnected from \"" + a.store.title(m) + "\" and can only come back through the meeting link.",
2474 "Action": "/m/" + m.Sqid + "/remove/" + target, "Label": "Remove", "CancelHref": "/m/" + m.Sqid, "Sqid": m.Sqid,
2475 }))
2476}
2477
2478func (a *app) remove(w http.ResponseWriter, r *http.Request) {
2479 m, _, _, ok := a.roomAccess(w, r)
2480 if !ok {
2481 return
2482 }
2483 session := a.sessionKey(r)
2484 actor, live := a.hub.LiveForSession(m.Sqid, session)
2485 if !live {
2486 ErrorPage(w, r, http.StatusNotFound, "")
2487 return
2488 }
2489 if err := a.hub.Remove(r.Context(), m.Sqid, session, actor, hub.PeerID(chi.URLParam(r, "peer"))); err != nil {
2490 ErrorPage(w, r, http.StatusNotFound, "")
2491 return
2492 }
2493 http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther)
2494}
2495
2496func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) {
2497 m, _, _, ok := a.roomAccess(w, r)
2498 if !ok {
2499 return
2500 }
2501 render(w, "confirm", a.page(r, map[string]any{
2502 "Title": "This meeting is open in another tab", "Body": "Use this tab instead? The other tab will leave the call.",
2503 "Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab", "CancelHref": "/m/" + m.Sqid + "/join", "Sqid": m.Sqid,
2504 }))
2505}
2506
2507func (a *app) takeover(w http.ResponseWriter, r *http.Request) {
2508 m, _, _, ok := a.roomAccess(w, r)
2509 if !ok {
2510 return
2511 }
2512 if _, err := a.hub.Takeover(m.Sqid, a.sessionKey(r)); err != nil {
2513 ErrorPage(w, r, http.StatusNotFound, "")
2514 return
2515 }
2516 http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther)
2517}
2518```
2519
2520Routes in the `requirePerson` group: `r.Get("/m/{sqid}", a.room)`, `r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove)`, `r.Post("/m/{sqid}/remove/{peer}", a.remove)`, `r.Get("/m/{sqid}/takeover", a.confirmTakeover)`, `r.Post("/m/{sqid}/takeover", a.takeover)`. Change `holding` to use `roomAccess` too, and give `join.html` the `data-*` block and the scripted containers (Task 7 fills them). `room.html`:
2521
2522```html
2523{{define "title"}}{{.Title}}{{end}}
2524{{define "content"}}
2525{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
2526{{template "page-header" dict "Title" .Title}}
2527{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
2528<p>You are {{.Name}}, in the call as a {{.Standing}}.</p>
2529{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
2530<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p></noscript>
2531<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" hidden>
2532 <div id="tiles" class="meet-grid"></div>
2533 <div id="controls" class="meet-controls">
2534 <button type="button" id="mute" rst-btn>Mute</button>
2535 <button type="button" id="camera" rst-btn>Camera off</button>
2536 <button type="button" id="leave" rst-btn="danger">Leave</button>
2537 </div>
2538 <div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
2539 <p id="status" class="meet-status" role="status"></p>
2540</div>
2541{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
2542<link rel="stylesheet" href="{{asset "static/calls.css"}}">
2543<script defer src="{{asset "static/room.js"}}"></script>
2544{{end}}
2545```
2546
2547Add `"room"` to `pageNames`, create `static/calls.css` with the grid, tile, controls and queue rules (small, `rst-*` tokens for colour), and a placeholder `static/room.js` that reveals `#room` (Task 8 replaces it). `AGENTS.md` line as specified above.
2548
2549- [ ] **Step 3: Run tests, gate, commit**
2550
2551```bash
2552git add internal AGENTS.md
2553git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "room: page, removal and takeover confirm pages, participation-or-standing access, the scriptless exception
2554
2555Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2556Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
2557```
2558
2559---
2560
2561### Task 7: devices.js: the holding screen's scripted half
2562
2563**Files:**
2564- Create: `internal/calls/static/devices.js`
2565- Modify: `internal/calls/templates/join.html`
2566
2567**Interfaces:**
2568- Produces: on the holding page with script, a `<video id="preview" muted autoplay playsinline>`, three `<select>`s (camera, microphone, speaker; speaker only where `setSinkId` exists), two toggles (mute, camera off), and a "Join" button that navigates to `/m/{sqid}` with the chosen device ids and mute state stored under `localStorage["meet.devices"]` as `{camera, mic, speaker, muted, cameraOff}`; without script, the paragraph and no button. Behaviour: `navigator.mediaDevices.getUserMedia({audio: true, video: true})` on load, then `enumerateDevices`, populate selects (labels appear only after permission), restart the preview stream on selection change, stop all tracks on join. Errors (`NotAllowedError`, `NotFoundError`) show a sentence in `#status` and disable Join.
2569
2570- [ ] **Step 1: Template**
2571
2572Replace the Task 7-relevant part of `join.html`'s content:
2573
2574```html
2575<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript>
2576<div id="devices" data-sqid="{{.Sqid}}" hidden>
2577 <video id="preview" muted autoplay playsinline class="meet-preview"></video>
2578 <div class="meet-pickers">
2579 <label>Camera <select id="pick-camera"></select></label>
2580 <label>Microphone <select id="pick-mic"></select></label>
2581 <label id="speaker-row" hidden>Speaker <select id="pick-speaker"></select></label>
2582 </div>
2583 <div class="meet-controls">
2584 <button type="button" id="toggle-mute" rst-btn aria-pressed="false">Mute</button>
2585 <button type="button" id="toggle-camera" rst-btn aria-pressed="false">Camera off</button>
2586 <button type="button" id="join" rst-btn="primary lg" disabled>Join the call</button>
2587 </div>
2588 <p id="status" class="meet-status" role="status"></p>
2589</div>
2590<link rel="stylesheet" href="{{asset "static/calls.css"}}">
2591<script defer src="{{asset "static/devices.js"}}"></script>
2592```
2593
2594Remove the slice-1 "Calls coming soon" paragraph.
2595
2596- [ ] **Step 2: devices.js**
2597
2598```js
2599// devices.js: the holding screen. Enumerates devices, previews the
2600// camera, remembers choices in localStorage, and hands off to the room.
2601// Nothing here reaches the server.
2602(() => {
2603 'use strict';
2604 const root = document.getElementById('devices');
2605 if (!root || !navigator.mediaDevices) return;
2606 root.hidden = false;
2607 const KEY = 'meet.devices';
2608 const $ = (id) => document.getElementById(id);
2609 const preview = $('preview'), pickCam = $('pick-camera'), pickMic = $('pick-mic'),
2610 pickSpk = $('pick-speaker'), spkRow = $('speaker-row'), mute = $('toggle-mute'),
2611 cam = $('toggle-camera'), join = $('join'), status = $('status');
2612 let stream = null;
2613 const prefs = load();
2614
2615 function load() {
2616 try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { return {}; }
2617 }
2618 function save() {
2619 try { localStorage.setItem(KEY, JSON.stringify(prefs)); } catch {}
2620 }
2621 function say(text) { status.textContent = text; }
2622
2623 async function start() {
2624 stop();
2625 const constraints = {
2626 audio: prefs.mic ? { deviceId: { exact: prefs.mic } } : true,
2627 video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true,
2628 };
2629 try {
2630 stream = await navigator.mediaDevices.getUserMedia(constraints);
2631 } catch (err) {
2632 if (prefs.camera || prefs.mic) { delete prefs.camera; delete prefs.mic; save(); return start(); }
2633 say(err.name === 'NotAllowedError'
2634 ? 'Meet needs permission to use your camera and microphone.'
2635 : 'No camera or microphone was found.');
2636 join.disabled = true;
2637 return;
2638 }
2639 preview.srcObject = stream;
2640 applyToggles();
2641 await fill();
2642 join.disabled = false;
2643 say('');
2644 }
2645
2646 function stop() {
2647 if (stream) { for (const t of stream.getTracks()) t.stop(); stream = null; }
2648 }
2649
2650 async function fill() {
2651 const devices = await navigator.mediaDevices.enumerateDevices();
2652 const fillSelect = (sel, kind, chosen) => {
2653 sel.replaceChildren();
2654 let n = 0;
2655 for (const d of devices) {
2656 if (d.kind !== kind) continue;
2657 n++;
2658 const o = document.createElement('option');
2659 o.value = d.deviceId; o.textContent = d.label || `${kind.replace('input', '').replace('output', '')} ${n}`;
2660 if (d.deviceId === chosen) o.selected = true;
2661 sel.append(o);
2662 }
2663 return n;
2664 };
2665 fillSelect(pickCam, 'videoinput', prefs.camera || currentId('video'));
2666 fillSelect(pickMic, 'audioinput', prefs.mic || currentId('audio'));
2667 if (typeof HTMLMediaElement.prototype.setSinkId === 'function' && fillSelect(pickSpk, 'audiooutput', prefs.speaker) > 0) {
2668 spkRow.hidden = false;
2669 }
2670 }
2671
2672 function currentId(kind) {
2673 const t = stream && stream.getTracks().find((t) => t.kind === kind);
2674 return t ? t.getSettings().deviceId : '';
2675 }
2676
2677 function applyToggles() {
2678 if (!stream) return;
2679 for (const t of stream.getAudioTracks()) t.enabled = !prefs.muted;
2680 for (const t of stream.getVideoTracks()) t.enabled = !prefs.cameraOff;
2681 mute.setAttribute('aria-pressed', String(!!prefs.muted));
2682 mute.textContent = prefs.muted ? 'Unmute' : 'Mute';
2683 cam.setAttribute('aria-pressed', String(!!prefs.cameraOff));
2684 cam.textContent = prefs.cameraOff ? 'Camera on' : 'Camera off';
2685 }
2686
2687 pickCam.addEventListener('change', () => { prefs.camera = pickCam.value; save(); start(); });
2688 pickMic.addEventListener('change', () => { prefs.mic = pickMic.value; save(); start(); });
2689 pickSpk.addEventListener('change', () => { prefs.speaker = pickSpk.value; save(); });
2690 mute.addEventListener('click', () => { prefs.muted = !prefs.muted; save(); applyToggles(); });
2691 cam.addEventListener('click', () => { prefs.cameraOff = !prefs.cameraOff; save(); applyToggles(); });
2692 join.addEventListener('click', () => { stop(); location.assign('/m/' + root.dataset.sqid); });
2693 navigator.mediaDevices.addEventListener?.('devicechange', fill);
2694 window.addEventListener('pagehide', stop);
2695 start();
2696})();
2697```
2698
2699- [ ] **Step 3: Test**
2700
2701Add to `holding_test.go`: the holding page as a member contains `id="devices"`, `devices.js`, the `<noscript>` paragraph, and no longer contains "Calls coming soon". Run `make ci`, commit:
2702
2703```bash
2704git add internal
2705git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "holding screen: devices.js, preview, pickers, mute state, join
2706
2707Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2708Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
2709```
2710
2711---
2712
2713### Task 8: room.js: SSE client, outbound queue, perfect negotiation, reconciliation, resume, terminal states, admission UI
2714
2715**Files:**
2716- Replace: `internal/calls/static/room.js`
2717- Modify: `internal/calls/static/calls.css`
2718
2719**Interfaces (the client contract, spec §4.5, §4.6, §5.1):**
2720- State: `peer`, `boot`, `gen[peerId]`, `seqOut[peerId]`, `lastIn[peerId]`, `pcs[peerId] = {pc, polite, makingOffer, ignoreOffer, pendingCandidates[]}`, `queue[peerId] = [{seq, message}]` with one POST in flight per target, `local` stream from the devices prefs, `admitted` flag, `roster`.
2721- Stream: open `EventSource('/m/{sqid}/stream')`; on `hello` set `peer`, compare `boot` to the stored one, then POST `join` once per fresh stream; on `error` (closed), resume with `?peer=&last=` after a 500 ms backoff, capped at 30 s; handle the resume refusals by reading the response when `EventSource` fails: since `EventSource` hides status codes, do the resume with `fetch` first (`HEAD`-like `GET` with `Accept: text/event-stream` is not possible), so: on error, `fetch(url + '&probe=1')` where the server answers the same 404/409/410 JSON or `204` when the resume would succeed (add `probe=1` handling to `stream`: run the refusal checks and return 204 without attaching), then open the `EventSource`. Document this in `stream.go`.
2722- Terminal reasons: `replaced`, `standing`, `expired`, `removed`, `ended` and any 410 close every `pc`, stop the local stream, and show the sentence for that reason with the takeover link for `replaced`. `404` with a different `boot`: close every pc, fresh stream, join again. `404` same boot: same, but a `409` on the fresh stream shows the takeover link. `409` at first open: takeover link.
2723- Roster reconciliation: on `roster`, close pcs for ids not present, create pcs for ids present without one, and if a roster `gen` for a peer is above the local one, rebuild that pc under the new gen. On `peer`, create. On `gone`, close. On `pair`, if `gen` above local, rebuild; else ignore. On `gap`, `reset` to that peer.
2724- Perfect negotiation (W3C reference): `polite = self < other`; `onnegotiationneeded` → `makingOffer = true; setLocalDescription(); send offer`; on `offer`: `offerCollision = makingOffer || signalingState !== 'stable'; ignoreOffer = !polite && offerCollision; if ignoreOffer return; setRemoteDescription(); if offer: setLocalDescription(); send answer`; on `candidate`: `addIceCandidate` in try/catch, swallowed when `ignoreOffer`; candidates before a remote description are buffered.
2725- Outbound: `send(to, type, payload)` assigns `seq = ++seqOut[to]`, `gen = gen[to]`, enqueues; the pump POSTs `/m/{sqid}/signal` with `{peer, type, to, seq, gen, payload}`, waits for 2xx; on 429 wait `Retry-After`; on other failures retry with backoff up to 30 s then close that pc and mark it for recreation on the next roster; on 409 stale gen drop the queue for that target (a `pair` or roster will rebuild).
2726- Inbound dedupe: drop a targeted message with `seq <= lastIn[from]`; drop one with `gen < gen[from]`.
2727- Admission UI: for members, `knock` adds to `#knocks` with an "Admit" button that sends `admit {peer}`; `knock-gone` removes; `roster.waiting` fills it; `full` shows "The room is full"; `state {waiting}` shows "Waiting for someone to let you in".
2728- Controls: mute and camera toggle track `enabled`; leave sends `leave`, closes everything, navigates to the holding screen.
2729- Tiles: one `<video autoplay playsinline>` per remote peer with the name; local tile muted; failed connection state shows "could not connect" with a Retry button that calls `pc.restartIce()`.
2730- Expose a test hook: `window.meet = { state() { return {peer, admitted, peers: Object.keys(pcs), frames: <Map peer→frames received via requestVideoFrameCallback or getStats>} } }` used by the Playwright gate.
2731
2732- [ ] **Step 1: Write room.js**
2733
2734The implementer writes it to the contract above, keeping each concern in a named function (`connectStream`, `resume`, `handle(event)`, `reconcile(roster)`, `createPC(id)`, `closePC(id)`, `negotiate`, `onOffer`, `onAnswer`, `onCandidate`, `send`, `pump`, `terminal(reason)`, `renderTiles`, `renderQueue`), with a header comment naming the spec sections. No library. Target size 600 to 800 lines. The Opus-tier implementer is expected to read spec §4.5, §4.6 and §5.1 before writing.
2735
2736- [ ] **Step 2: Add the probe branch to `stream.go`**
2737
2738Before attaching a resume: if `r.URL.Query().Get("probe") == "1"`, run the same `Resume` refusal checks without attaching (add `hub.CanResume(sqid, session, peer) error` returning the same `ResumeError` or nil), answer 204 on nil. For a fresh stream with `probe=1`, answer 409 if the session has a live peer, else 204. Test in `stream_test.go`: probe answers 204, 409, 410, 404 in the four situations.
2739
2740- [ ] **Step 3: Gate and commit**
2741
2742`make ci` (Go tests only; the browser gate is Task 9).
2743
2744```bash
2745git add internal
2746git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "room.js: SSE client, queue with seq and gen, perfect negotiation, reconciliation, resume, terminal states
2747
2748Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2749Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
2750```
2751
2752---
2753
2754### Task 9: The Playwright gate
2755
2756**Files:**
2757- Create: `package.json`, `test/browser/call.mjs`, `internal/callstest/browser_test.go` (`//go:build browser`)
2758- Modify: `Makefile` (`browser` target), `.gitignore` (`node_modules/`, `package-lock.json`)
2759
2760**Interfaces:**
2761- `make browser`: `npm install --no-save playwright@1.62.1` then `go test -tags browser -run TestCallInARealBrowser ./internal/callstest/`.
2762- `browser_test.go`: starts the app on `127.0.0.1:0` with `Origin` set to that address and `AllowedAddresses` `paul@example.com,paul@rslw.com`, a recording mailer; signs two members in over HTTP and captures their session cookies (name from `rastrillo/sessions` `CookieName`, `rastrillo_session` on http); creates a meeting; runs `node test/browser/call.mjs <origin> <sqid> <cookieName> <cookieA> <cookieB> <scenario>` for each scenario with a three-minute timeout, and fails on a non-zero exit. Scenarios in this slice: `devices` (holding screen enumerates and previews, join button enabled), `call` (two contexts join, each receives the other's video track with a non-zero `frames` count within 20 s), `renewal` (with `SetStreamTimings` shortened through a test-only header `X-Meet-Test-TTL: 2s` honoured only when `Config.Env` is development; the call stays connected across a stream renewal), `guest` (a third context signs in through the link as a guest, knocks, is admitted from context A's queue, receives video; asserts through `window.meet.state()` that it had no `peers` before admission), `removed` (A removes the guest through the confirm page; the guest sees the terminal sentence), `restart` (the Go test restarts the app on the same port and asserts both contexts rejoin and reconnect), `takeover` (a second page in context A's browser gets the 409 link, confirms, the first page shows the terminal sentence and does not reconnect), `crosssite` (a page on another origin navigates to the takeover URL by GET and nothing ends), `scriptless` (a context with JavaScript disabled loads both pages and finds the paragraph).
2763- `call.mjs`: `chromium.launch({ args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] })`, one `browser.newContext()` per identity with the cookie set for the origin's host, `page.waitForFunction(() => window.meet && window.meet.state().frames...)`; prints one line per assertion and exits non-zero on failure.
2764
2765- [ ] **Step 1: Makefile and package.json**
2766
2767```make
2768# The browser gate. Not in ci: the one gate the team runs on every push
2769# must not depend on a toolchain the app itself does not use. Run it
2770# before every review that touches a page.
2771browser:
2772 npm install --no-save playwright@1.62.1
2773 go test -tags browser -run TestCallInARealBrowser -v ./internal/callstest/
2774```
2775
2776```json
2777{
2778 "name": "meet-browser-harness",
2779 "private": true,
2780 "description": "Playwright harness for the Meet browser gate. Not built; not published.",
2781 "devDependencies": { "playwright": "1.62.1" },
2782 "scripts": { "test:browser": "go test -tags browser -run TestCallInARealBrowser ./internal/callstest/" }
2783}
2784```
2785
2786- [ ] **Step 2: The Go driver and call.mjs**
2787
2788Write both to the contract. Frame counting in the page: after `ontrack`, attach the stream to a hidden video and use `video.requestVideoFrameCallback` to count; fall back to `getStats()` `framesDecoded` when unavailable. The `devices` scenario asserts the camera select has at least one option and `#join` is enabled within 10 s.
2789
2790- [ ] **Step 3: Run the gate and commit**
2791
2792Run: `make browser 2>&1 | tail -20`
2793Expected: every scenario passes; record the run's output in the branch discussion.
2794
2795```bash
2796git add Makefile package.json test internal/callstest/browser_test.go .gitignore
2797git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "browser gate: Playwright driver, two-context call, guest, removal, restart, takeover, scriptless
2798
2799Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2800Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
2801```
2802
2803---
2804
2805### Task 10: The remaining §13 hub gates and the captured-log test extension
2806
2807**Files:**
2808- Modify: `internal/calls/hub/*_test.go`, `internal/callstest/signal_test.go`
2809
2810Cover, by name, what Tasks 1 to 5 did not: concurrent admissions (five goroutines admitting five waiting guests into one free slot, exactly one succeeds), a `pair` at or below the local generation ignored (hub side: `Reset` twice from one side yields gens 2 and 3 and a relay with gen 2 is stale), an admitted guest continuing past grant expiry on `signal`, `stream?peer=` and (stubbed until slice 3) `/ice` while being refused fresh entry (`/m/{sqid}/join` 404 after `advance(2h+1m)`), the 64-event buffer over the HTTP stream (`lost {reason: "buffer"}` then a successful resume), and a member dropped on the tick over HTTP (allowlist change through `TestHandle.SetAllowlist(addrs...)`). Then `make ci` and commit `tests: remaining slice-2 hub and stream gates`.
2811
2812---
2813
2814### Task 11: Copy catalogue and branch record
2815
2816**Files:**
2817- Create: `docs/superpowers/copy/2026-09-10-slice-2-strings.md`
2818
2819Every user-facing string in Tasks 6 to 8, verbatim, with its location, marked "copy review waived for this round per suite #5; catalogued for the later pass". Commit `docs: slice 2 strings`.
2820
2821---
2822
2823## Self-review
2824
2825**Spec coverage (§12 item 2):** `devices.js` (7); hub with peer binding (1), participation (1, 2), cap (2), limits (1, 5), resume with admission-branched output (3, 4), replay ring, `gap`, pair reset (3), terminal `replaced` (2, 3, 6), the tick (1, 2); stream and `hello` (4); every §4.3 message except `chat` and `share` (2, 3, 5); removal confirm page (6); `room.js` with perfect negotiation and the §4.6 queue (8); participation transitions (2, 10); `AGENTS.md` exception (6); `CARLOS_RELAY` absent (unchanged); the browser gate (9). §13 slice-2 Go gates are spread across Tasks 1, 2, 3, 4, 5, 6 and 10 and named there; the Playwright gates are Task 9's scenarios. `/ice` belongs to slice 3 and is only stubbed in Task 10's continuation test.
2826
2827**Placeholders:** Tasks 8 and 9 give a contract and function list rather than full code because they are Opus-tier design tasks; every behaviour they must have is named, with the spec section, and the gate that proves it is in Task 9. Everything else has code.
2828
2829**Type consistency:** `hub.Message` fields, `PeerID`, `Kind`, `Reason`, the error variables and `ResumeError` are declared in Task 1 and used unchanged after; `Hub` methods `Open`, `Find`, `LiveForSession`, `Detach`, `Tick` (1), `Join`, `Admit`, `Leave`, `Remove`, `Takeover`, `End`, `rosterFor`, `genLocked`, `admitLocked` (2), `Relay`, `Reset`, `Resume`, `ringLocked` (3), `CanResume` (8); app fields `hub`, `streamHeartbeat`, `streamTTL`, `signalLimiter` (4, 5); `TestHandle.TickForTest`, `SetStreamTimings`, `SetAllowlist` (4, 10); harness `stream`, `signal`, `sseReader.next/nextOf/hello/waitClosed/close`, `newHarnessWithLogger`, `newMemberBrowser`, per-harness `origin` (4, 5).
2830
2831**Rulings for the controller:** the session key for the hub is the session subject plus the session's creation instant, since rastrillo does not expose the cookie hash; two tabs of one browser share it, which is the spec's intent. The resume probe (`?probe=1`) is an addition to the spec's §4.5 needed because `EventSource` hides status codes; record it as a spec revision note in the branch discussion.
2832