| 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 |
| 89 | package hub |
| 90 | |
| 91 | import ( |
| 92 | "context" |
| 93 | "io" |
| 94 | "log/slog" |
| 95 | "testing" |
| 96 | "time" |
| 97 | ) |
| 98 | |
| 99 | func 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 | |
| 112 | func 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 | |
| 134 | func 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 | |
| 153 | func 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 | |
| 166 | func 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 | |
| 180 | func 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 | |
| 193 | func 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 | |
| 215 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ 2>&1 | head -5` |
| 216 | Expected: build failure, package does not exist. |
| 217 | |
| 218 | - [ ] **Step 3: Implement the core** |
| 219 | |
| 220 | `subscriber.go`: |
| 221 | |
| 222 | ```go |
| 223 | package hub |
| 224 | |
| 225 | import "sync/atomic" |
| 226 | |
| 227 | const 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. |
| 231 | type Sub struct { |
| 232 | ch chan Message |
| 233 | lost atomic.Bool |
| 234 | done chan struct{} |
| 235 | } |
| 236 | |
| 237 | func newSub() *Sub { return &Sub{ch: make(chan Message, subBuffer), done: make(chan struct{})} } |
| 238 | |
| 239 | func (s *Sub) C() <-chan Message { return s.ch } |
| 240 | func (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. |
| 244 | func (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 | |
| 257 | func (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 | |
| 271 | func (s *Sub) Close() { s.terminate(Message{Type: "lost", Reason: ReasonBuffer}) } |
| 272 | ``` |
| 273 | |
| 274 | `messages.go`: |
| 275 | |
| 276 | ```go |
| 277 | package hub |
| 278 | |
| 279 | import "encoding/json" |
| 280 | |
| 281 | type PeerID string |
| 282 | type Kind int |
| 283 | type Reason string |
| 284 | |
| 285 | const ( |
| 286 | KindGuest Kind = iota |
| 287 | KindMember |
| 288 | ) |
| 289 | |
| 290 | func (k Kind) String() string { |
| 291 | if k == KindMember { |
| 292 | return "member" |
| 293 | } |
| 294 | return "guest" |
| 295 | } |
| 296 | |
| 297 | const ( |
| 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. |
| 307 | const ( |
| 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. |
| 320 | const ( |
| 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. |
| 336 | type 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 | |
| 351 | type Roster struct { |
| 352 | Peers []RosterPeer `json:"peers"` |
| 353 | Waiting []RosterPeer `json:"waiting,omitempty"` |
| 354 | } |
| 355 | |
| 356 | type 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 |
| 367 | package hub |
| 368 | |
| 369 | import ( |
| 370 | "context" |
| 371 | "crypto/rand" |
| 372 | "encoding/hex" |
| 373 | "errors" |
| 374 | "log/slog" |
| 375 | "sync" |
| 376 | "time" |
| 377 | ) |
| 378 | |
| 379 | const ( |
| 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 | |
| 390 | var ( |
| 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 | |
| 403 | type 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 | |
| 411 | type 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 | |
| 430 | type pairKey struct{ a, b PeerID } |
| 431 | |
| 432 | func keyFor(x, y PeerID) pairKey { |
| 433 | if x < y { |
| 434 | return pairKey{x, y} |
| 435 | } |
| 436 | return pairKey{y, x} |
| 437 | } |
| 438 | |
| 439 | type room struct { |
| 440 | sqid string |
| 441 | peers map[PeerID]*Participation |
| 442 | gens map[pairKey]uint64 |
| 443 | ended bool |
| 444 | } |
| 445 | |
| 446 | type tombstone struct { |
| 447 | reason Reason |
| 448 | at time.Time |
| 449 | } |
| 450 | |
| 451 | type 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 | |
| 462 | func 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 | |
| 473 | func 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 | |
| 481 | func (h *Hub) Boot() string { return h.boot } |
| 482 | |
| 483 | func (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 | |
| 492 | type 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. |
| 500 | func (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. |
| 530 | func (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 | |
| 537 | func (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 | |
| 549 | func (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. |
| 558 | func (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. |
| 579 | func (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). |
| 602 | func (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 | |
| 608 | func (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. |
| 629 | func (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 | |
| 654 | with `announceDepartureLocked` stubbed in this task (`gone` to admitted peers, `knock-gone` to admitted members; Task 2 fills the recipient helpers): |
| 655 | |
| 656 | ```go |
| 657 | func (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 | |
| 666 | type audience func(*Participation) bool |
| 667 | |
| 668 | func admittedPeers(p *Participation) bool { return p.Admitted } |
| 669 | func admittedMembers(p *Participation) bool { return p.Admitted && p.Kind == KindMember } |
| 670 | |
| 671 | func (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 | |
| 682 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ -v 2>&1 | tail -12` |
| 683 | Expected: six tests PASS. |
| 684 | |
| 685 | - [ ] **Step 5: Gate and commit** |
| 686 | |
| 687 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache make ci 2>&1 | tail -3` |
| 688 | |
| 689 | ```bash |
| 690 | git add internal/calls/hub |
| 691 | git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "hub: rooms, participations, peer ids, limits, tombstones |
| 692 | |
| 693 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 694 | Claude-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 |
| 721 | package hub |
| 722 | |
| 723 | import ( |
| 724 | "context" |
| 725 | "testing" |
| 726 | "time" |
| 727 | ) |
| 728 | |
| 729 | // drain returns every event currently buffered on a sub, non-blocking. |
| 730 | func 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 | |
| 745 | func 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 | |
| 753 | func 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 | |
| 778 | func 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 | |
| 800 | func 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 | |
| 819 | func 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 | |
| 857 | func 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 | |
| 874 | func 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 | |
| 888 | func 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 | |
| 919 | func 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 | |
| 952 | func 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 | |
| 996 | func 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 | |
| 1019 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ 2>&1 | grep -c 'undefined'` |
| 1020 | Expected: non-zero (Join, Admit, Leave, Takeover, End undefined). |
| 1021 | |
| 1022 | - [ ] **Step 3: Implement admission** |
| 1023 | |
| 1024 | `admission.go`: |
| 1025 | |
| 1026 | ```go |
| 1027 | package hub |
| 1028 | |
| 1029 | import "context" |
| 1030 | |
| 1031 | func 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 | |
| 1041 | func 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. |
| 1054 | func (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. |
| 1077 | func (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. |
| 1088 | func (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). |
| 1112 | func (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. |
| 1154 | func (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. |
| 1170 | func (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 | |
| 1225 | func (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. |
| 1244 | func (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. |
| 1274 | func (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. |
| 1292 | func (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 | |
| 1307 | Extend `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 | |
| 1311 | Run: `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` |
| 1312 | Expected: all PASS. |
| 1313 | |
| 1314 | - [ ] **Step 5: Gate and commit** |
| 1315 | |
| 1316 | ```bash |
| 1317 | git add internal/calls/hub |
| 1318 | git -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 | |
| 1320 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 1321 | Claude-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 |
| 1345 | package hub |
| 1346 | |
| 1347 | import ( |
| 1348 | "context" |
| 1349 | "encoding/json" |
| 1350 | "testing" |
| 1351 | "time" |
| 1352 | ) |
| 1353 | |
| 1354 | func 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 | |
| 1368 | func 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 | |
| 1400 | func 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 | |
| 1426 | func 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 | |
| 1455 | func 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 | |
| 1469 | func 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 | |
| 1488 | func 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 | |
| 1503 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ 2>&1 | grep -c undefined` |
| 1504 | Expected: non-zero. |
| 1505 | |
| 1506 | - [ ] **Step 3: Implement relay and resume** |
| 1507 | |
| 1508 | `relay.go`: |
| 1509 | |
| 1510 | ```go |
| 1511 | package hub |
| 1512 | |
| 1513 | import "fmt" |
| 1514 | |
| 1515 | func 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. |
| 1519 | func (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 | |
| 1551 | func (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. |
| 1559 | func (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 | |
| 1591 | type ResumeResult struct { |
| 1592 | Sub *Sub |
| 1593 | Admitted bool |
| 1594 | Roster *Roster |
| 1595 | Replay []Message |
| 1596 | Gap []PeerID |
| 1597 | } |
| 1598 | |
| 1599 | type ResumeError struct { |
| 1600 | Status int |
| 1601 | Reason Reason |
| 1602 | Boot string |
| 1603 | } |
| 1604 | |
| 1605 | func (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. |
| 1608 | func (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 | |
| 1664 | func (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 | |
| 1674 | Note: 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 | |
| 1678 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache go test ./internal/calls/hub/ -race 2>&1 | tail -3` then `make ci`. |
| 1679 | |
| 1680 | ```bash |
| 1681 | git add internal/calls/hub |
| 1682 | git -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 | |
| 1684 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 1685 | Claude-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 |
| 1704 | package callstest |
| 1705 | |
| 1706 | import ( |
| 1707 | "encoding/json" |
| 1708 | "net/http" |
| 1709 | "testing" |
| 1710 | "time" |
| 1711 | ) |
| 1712 | |
| 1713 | func 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 | |
| 1734 | func 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 | |
| 1757 | func 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 | |
| 1779 | func 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 | |
| 1798 | func 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 | |
| 1823 | The 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 |
| 1830 | package meet |
| 1831 | |
| 1832 | import ( |
| 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. |
| 1840 | func (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 | |
| 1863 | Add `store.personByID(ctx, id int64) (Person, error)`. |
| 1864 | |
| 1865 | `stream.go`: |
| 1866 | |
| 1867 | ```go |
| 1868 | package meet |
| 1869 | |
| 1870 | import ( |
| 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. |
| 1885 | func (a *app) sessionKey(r *http.Request) string { |
| 1886 | s, _ := sessions.Current(r) |
| 1887 | return s.Subject + "|" + s.At.UTC().Format(time.RFC3339Nano) |
| 1888 | } |
| 1889 | |
| 1890 | func 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). |
| 1903 | func (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 | |
| 2025 | In `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 | |
| 2029 | Run: `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 |
| 2032 | git add internal/calls internal/callstest |
| 2033 | git -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 | |
| 2035 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 2036 | Claude-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 |
| 2053 | package callstest |
| 2054 | |
| 2055 | import ( |
| 2056 | "bytes" |
| 2057 | "log/slog" |
| 2058 | "net/http" |
| 2059 | "strings" |
| 2060 | "testing" |
| 2061 | "time" |
| 2062 | ) |
| 2063 | |
| 2064 | func 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 | |
| 2085 | func 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 | |
| 2112 | func 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. |
| 2133 | func 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 | |
| 2163 | Add `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 |
| 2172 | package meet |
| 2173 | |
| 2174 | import ( |
| 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 | |
| 2186 | const maxSignalBody = 64 * 1024 |
| 2187 | |
| 2188 | type 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 | |
| 2197 | func (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 | |
| 2261 | func (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. |
| 2267 | func (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 | |
| 2272 | Route: `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 |
| 2277 | git add internal/calls internal/callstest |
| 2278 | git -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 | |
| 2280 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 2281 | Claude-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 |
| 2300 | package callstest |
| 2301 | |
| 2302 | import ( |
| 2303 | "net/http" |
| 2304 | "net/url" |
| 2305 | "strings" |
| 2306 | "testing" |
| 2307 | ) |
| 2308 | |
| 2309 | func 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 | |
| 2328 | func 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 | |
| 2358 | func 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 | |
| 2393 | func 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 |
| 2415 | package meet |
| 2416 | |
| 2417 | import ( |
| 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. |
| 2427 | func (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 | |
| 2450 | func (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 | |
| 2462 | func (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 | |
| 2478 | func (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 | |
| 2496 | func (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 | |
| 2507 | func (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 | |
| 2520 | Routes 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 | |
| 2547 | Add `"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 |
| 2552 | git add internal AGENTS.md |
| 2553 | git -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 | |
| 2555 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 2556 | Claude-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 | |
| 2572 | Replace 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 | |
| 2594 | Remove 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 | |
| 2701 | Add 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 |
| 2704 | git add internal |
| 2705 | git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "holding screen: devices.js, preview, pickers, mute state, join |
| 2706 | |
| 2707 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 2708 | Claude-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 | |
| 2734 | The 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 | |
| 2738 | Before 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 |
| 2745 | git add internal |
| 2746 | git -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 | |
| 2748 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 2749 | Claude-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. |
| 2771 | browser: |
| 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 | |
| 2788 | Write 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 | |
| 2792 | Run: `make browser 2>&1 | tail -20` |
| 2793 | Expected: every scenario passes; record the run's output in the branch discussion. |
| 2794 | |
| 2795 | ```bash |
| 2796 | git add Makefile package.json test internal/callstest/browser_test.go .gitignore |
| 2797 | git -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 | |
| 2799 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 2800 | Claude-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 | |
| 2810 | Cover, 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 | |
| 2819 | Every 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 | |