oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Meet slice 3: the platform relay — 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:** Meet reads the platform's `CARLOS_RELAY` contract, mints TURN credentials per call at `GET /m/{sqid}/ice`, and the browser renews them before their deadline with a proven ICE restart, so calls connect across networks through the platform relay and never through a third party.
6
7**Architecture:** A pure package `internal/calls/relay` parses the contract and mints credentials (the platform's test vector is its oracle). `config.go` parses `CARLOS_RELAY` at startup and refuses a malformed value outside development. A new endpoint `/m/{sqid}/ice` hands an admitted participation the `RTCIceServer` list with a three-hour expiry; the hub authorizes it exactly as it authorizes a relay message (spec §2.5 last row, §15.12). `room.js` fetches the list before it builds a connection, renews per connection at the server-named time with an ICE restart proven by a new `usernameFragment` on the selected candidate pair, and shows the warning and the "relay expired" states. The Playwright gate proves the fetch, the renewal under a development-only shortened schedule, and the failure path against a fake `/ice`; nothing touches a real relay until the canary.
8
9**Tech Stack:** Go 1.25 (rastrillo v0.26.0, chi, gorm), hand-written `room.js`, Playwright 1.62.1 on Chromium, `codex exec` for the independent review.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` revision 9: §2.5 (the `/ice` row), §5.2 (ICE servers from the platform), §5.3 (renewal), §5.5 (the floor), §5.6, §12 item 3, §13 (the slice 3 gates), §15.12. Platform contract: `carlos/platform` `docs/superpowers/specs/2026-09-07-relay-stun-turn-design.md` §2 (revision 6, frozen).
12
13## Global Constraints
14
15- `CGO_ENABLED=0` throughout; `make ci` (vet, gofmt, tests, migration check) is the gate on every task; `make race` (`CGO_ENABLED=1`) and `make browser` (sandbox OFF: UDP is blocked inside the Claude Code sandbox) on the tasks that name them.
16- Every query touching user-owned rows goes through `scope.Owned`; a row that isn't yours 404s, never 403s. No schema change in this slice.
17- The decoded relay configuration is held in memory and never logged; a malformed `CARLOS_RELAY` is a startup refusal outside `-env development`, logged as one line naming the variable and not its value (§5.2).
18- No address, name, title, token, session key, payload, key or credential in any log line (§4.4). `/ice` logs nothing on success.
19- A 404 is never a 403. The `/ice` 404 body is byte-identical to `/signal`'s not-found body (`{"boot":…,"error":"not-found"}`).
20- Wire contracts are client-neutral (ruling 15): `/ice` is plain JSON a native client can consume.
21- Expiry is three hours ahead; the contract's maximum is four. `renewAt` is two and a half hours ahead (§5.3 step 4).
22- Copy review is waived this round per suite discussion 5; every new string is catalogued in `docs/superpowers/copy/2026-09-10-slice-2-strings.md` (append a slice 3 section).
23- Screens work with JavaScript disabled; nothing in this slice changes the scriptless floor.
24- Commits: `git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit`, files added by name, never `.claude/`, `node_modules` or `package-lock.json`; trailers `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>` and `Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2`.
25
26---
27
28## File structure
29
30- `internal/calls/relay/relay.go` — `Parse`, `Region`, `ICEServer`, `Mint`; pure, no logging, no clock of its own.
31- `internal/calls/relay/relay_test.go` — the platform vector, parse refusals, scheme handling.
32- `internal/calls/config.go` — `Config.Relay string` (raw), `resolved.relay []relay.Region`; startup refusal.
33- `cmd/calls/main.go` — passes `Relay: os.Getenv("CARLOS_RELAY")`.
34- `internal/calls/hub/authorize.go` — `Authorize(ctx, sqid, session, peer)`: the §2.5 last-row check with §15.12's member re-derivation, shared by `/ice`.
35- `internal/calls/ice.go` — `GET /m/{sqid}/ice`.
36- `internal/callstest/ice_test.go` — the endpoint over HTTP.
37- `internal/calls/static/room.js` — ICE list, renewal state machine, warnings, `state().ice`.
38- `internal/calls/templates/room.html` — `data-relay` on `#room`.
39- `test/browser/call.mjs`, `internal/callstest/browser_test.go` — scenarios `ice`, `icerenewal`, `icefail`.
40- `docs/superpowers/copy/2026-09-10-slice-2-strings.md` — slice 3 strings appended.
41
42---
43
44### Task 1: The relay package: parse the contract, mint a credential, the vector
45
46**Files:**
47- Create: `internal/calls/relay/relay.go`, `internal/calls/relay/relay_test.go`
48
49**Interfaces:**
50- Produces:
51 ```go
52 package relay
53 type Region struct { Region string; URLs []string; Key []byte /* 32 raw bytes */; UsernameSuffix string }
54 type ICEServer struct { URLs []string `json:"urls"`; Username string `json:"username,omitempty"`; Credential string `json:"credential,omitempty"` }
55 func Parse(raw string) ([]Region, error) // raw = $CARLOS_RELAY; "" → nil, nil
56 func Mint(regions []Region, expiry time.Time) []ICEServer // one ICEServer per region per scheme class: stun URLs bare, turn/turns URLs with username and credential
57 func Credential(key []byte, username string) string // base64std(HMAC-SHA256(key, username))
58 ```
59- The error from `Parse` never contains the input (it names the position and the field, not the value).
60
61- [ ] **Step 1: Write the failing tests**
62
63```go
64package relay
65
66import (
67 "encoding/base64"
68 "encoding/json"
69 "strings"
70 "testing"
71 "time"
72)
73
74const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU="
75
76func encode(t *testing.T, v any) string {
77 t.Helper()
78 b, err := json.Marshal(v)
79 if err != nil {
80 t.Fatal(err)
81 }
82 return base64.StdEncoding.EncodeToString(b)
83}
84
85func oneRegion() []map[string]any {
86 return []map[string]any{{
87 "region": "eu-west-1",
88 "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp", "turn:relay.eu-west-1.oncarlos.com:3478?transport=tcp"},
89 "key": vectorKey,
90 "username_suffix": "abc:meet:0123456789abcdef:k1",
91 }}
92}
93
94func TestThePlatformVectorMints(t *testing.T) {
95 key, _ := base64.StdEncoding.DecodeString(vectorKey)
96 got := Credential(key, "1900000000:abc:meet:0123456789abcdef:k1")
97 if got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
98 t.Fatalf("credential %q does not match the platform vector", got)
99 }
100}
101
102func TestParseAcceptsTheContractAndDecodesTheKey(t *testing.T) {
103 regions, err := Parse(encode(t, oneRegion()))
104 if err != nil {
105 t.Fatal(err)
106 }
107 if len(regions) != 1 || regions[0].Region != "eu-west-1" || len(regions[0].Key) != 32 || regions[0].UsernameSuffix != "abc:meet:0123456789abcdef:k1" || len(regions[0].URLs) != 3 {
108 t.Fatalf("parsed %+v", regions)
109 }
110}
111
112func TestParseOfNothingIsNoRelay(t *testing.T) {
113 regions, err := Parse("")
114 if err != nil || regions != nil {
115 t.Fatalf("empty must mean no relay: %v %v", regions, err)
116 }
117}
118
119func TestParseRefusesWithoutEchoingTheValue(t *testing.T) {
120 cases := map[string]string{
121 "not base64": "%%%",
122 "not json": base64.StdEncoding.EncodeToString([]byte("nope")),
123 "not an array": base64.StdEncoding.EncodeToString([]byte(`{"region":"x"}`)),
124 "short key": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": base64.StdEncoding.EncodeToString([]byte("short")), "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
125 "suffix wrong fields": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:k1"}}),
126 "suffix bad app": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:Meet!:0123456789abcdef:k1"}}),
127 "no urls": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
128 "empty region": encode(t, []map[string]any{{"region": "", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}),
129 }
130 for name, raw := range cases {
131 t.Run(name, func(t *testing.T) {
132 _, err := Parse(raw)
133 if err == nil {
134 t.Fatal("accepted")
135 }
136 if strings.Contains(err.Error(), vectorKey) || strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), raw) {
137 t.Fatalf("the error echoes the value: %v", err)
138 }
139 })
140 }
141}
142
143func TestMintCarriesCredentialsOnTurnOnlyAndPassesURLsThrough(t *testing.T) {
144 regions, err := Parse(encode(t, oneRegion()))
145 if err != nil {
146 t.Fatal(err)
147 }
148 expiry := time.Unix(1900000000, 0)
149 servers := Mint(regions, expiry)
150 if len(servers) != 2 {
151 t.Fatalf("want a bare stun entry and a credentialed turn entry, got %+v", servers)
152 }
153 stun, turn := servers[0], servers[1]
154 if len(stun.URLs) != 1 || stun.URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || stun.Username != "" || stun.Credential != "" {
155 t.Fatalf("stun entry %+v", stun)
156 }
157 if len(turn.URLs) != 2 || turn.Username != "1900000000:abc:meet:0123456789abcdef:k1" || turn.Credential != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" {
158 t.Fatalf("turn entry %+v", turn)
159 }
160}
161
162func TestMintKeepsConsoleOrderAndUnknownSchemesBare(t *testing.T) {
163 two := append(oneRegion(), map[string]any{
164 "region": "us-east-1", "urls": []string{"stuns:relay.us-east-1.oncarlos.com:5349", "turns:relay.us-east-1.oncarlos.com:5349", "future:relay.us-east-1.oncarlos.com:1"},
165 "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1",
166 })
167 regions, err := Parse(encode(t, two))
168 if err != nil {
169 t.Fatal(err)
170 }
171 servers := Mint(regions, time.Unix(1900000000, 0))
172 // eu-west-1 first (stun, turn), then us-east-1 (bare: stuns+future, credentialed: turns).
173 if len(servers) != 4 || servers[0].URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || servers[2].Username != "" || len(servers[2].URLs) != 2 || servers[3].Username == "" || servers[3].URLs[0] != "turns:relay.us-east-1.oncarlos.com:5349" {
174 t.Fatalf("order or scheme handling wrong: %+v", servers)
175 }
176}
177
178func TestMintOfNoRegionsIsAnEmptyListNotNil(t *testing.T) {
179 if got := Mint(nil, time.Now()); got == nil || len(got) != 0 {
180 t.Fatalf("want [] so the JSON is [] not null, got %#v", got)
181 }
182}
183```
184
185- [ ] **Step 2: Run to see them fail**
186
187Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/relay/`
188Expected: build failure, no package.
189
190- [ ] **Step 3: Implement**
191
192```go
193// Package relay reads the platform's CARLOS_RELAY contract and mints
194// per-call TURN credentials from it (platform relay design §2, frozen
195// at revision 6). It is pure: no logging, no clock, no network. The
196// decoded keys live only in memory; nothing here ever formats one.
197package relay
198
199import (
200 "crypto/hmac"
201 "crypto/sha256"
202 "encoding/base64"
203 "encoding/json"
204 "errors"
205 "fmt"
206 "regexp"
207 "strconv"
208 "strings"
209 "time"
210)
211
212// Region is one element of the contract: the relay hosts for one AWS
213// region, the 32-byte key the platform issued for this app and
214// incarnation, and the username suffix that names account, app,
215// incarnation and key id.
216type Region struct {
217 Region string
218 URLs []string
219 Key []byte
220 UsernameSuffix string
221}
222
223// ICEServer is one RTCIceServer as the browser wants it. A stun entry
224// carries no username; a turn entry carries the minted pair.
225type ICEServer struct {
226 URLs []string `json:"urls"`
227 Username string `json:"username,omitempty"`
228 Credential string `json:"credential,omitempty"`
229}
230
231type wire struct {
232 Region string `json:"region"`
233 URLs []string `json:"urls"`
234 Key string `json:"key"`
235 UsernameSuffix string `json:"username_suffix"`
236}
237
238var (
239 accountRe = regexp.MustCompile(`^[a-z0-9]+$`)
240 appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
241 incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`)
242 kidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`)
243)
244
245// Parse decodes $CARLOS_RELAY. An empty value is no relay (nil, nil).
246// Errors name the element index and the field, never the value, so a
247// startup log line built from one cannot leak a key or a suffix.
248func Parse(raw string) ([]Region, error) {
249 raw = strings.TrimSpace(raw)
250 if raw == "" {
251 return nil, nil
252 }
253 b, err := base64.StdEncoding.DecodeString(raw)
254 if err != nil {
255 return nil, errors.New("relay: CARLOS_RELAY is not standard base64")
256 }
257 var elems []wire
258 if err := json.Unmarshal(b, &elems); err != nil {
259 return nil, errors.New("relay: CARLOS_RELAY does not decode to a JSON array of regions")
260 }
261 out := make([]Region, 0, len(elems))
262 for i, e := range elems {
263 if e.Region == "" {
264 return nil, fmt.Errorf("relay: element %d has no region", i)
265 }
266 if len(e.URLs) == 0 {
267 return nil, fmt.Errorf("relay: element %d (%s) has no urls", i, e.Region)
268 }
269 key, err := base64.StdEncoding.DecodeString(e.Key)
270 if err != nil || len(key) != 32 {
271 return nil, fmt.Errorf("relay: element %d (%s) key is not 32 base64 bytes", i, e.Region)
272 }
273 fields := strings.Split(e.UsernameSuffix, ":")
274 if len(fields) != 4 || !accountRe.MatchString(fields[0]) || !appRe.MatchString(fields[1]) || !incarnationRe.MatchString(fields[2]) || !kidRe.MatchString(fields[3]) {
275 return nil, fmt.Errorf("relay: element %d (%s) username_suffix is not account:app:incarnation:kid", i, e.Region)
276 }
277 out = append(out, Region{Region: e.Region, URLs: append([]string(nil), e.URLs...), Key: key, UsernameSuffix: e.UsernameSuffix})
278 }
279 return out, nil
280}
281
282// Credential is base64std(HMAC-SHA256(key, username)), the platform's
283// TURN REST scheme.
284func Credential(key []byte, username string) string {
285 mac := hmac.New(sha256.New, key)
286 mac.Write([]byte(username))
287 return base64.StdEncoding.EncodeToString(mac.Sum(nil))
288}
289
290// Mint builds the RTCIceServer list for one call. Per region, in the
291// contract's order: one bare entry for every URL whose scheme is not
292// turn or turns (stun, stuns, or a scheme the contract adds later),
293// then one credentialed entry for the turn and turns URLs. Either may
294// be absent. Expiry is the caller's; §5.2 uses three hours ahead.
295func Mint(regions []Region, expiry time.Time) []ICEServer {
296 out := []ICEServer{}
297 username := strconv.FormatInt(expiry.Unix(), 10)
298 for _, r := range regions {
299 var bare, turn []string
300 for _, u := range r.URLs {
301 switch scheme(u) {
302 case "turn", "turns":
303 turn = append(turn, u)
304 default:
305 bare = append(bare, u)
306 }
307 }
308 if len(bare) > 0 {
309 out = append(out, ICEServer{URLs: bare})
310 }
311 if len(turn) > 0 {
312 name := username + ":" + r.UsernameSuffix
313 out = append(out, ICEServer{URLs: turn, Username: name, Credential: Credential(r.Key, name)})
314 }
315 }
316 return out
317}
318
319func scheme(u string) string {
320 i := strings.IndexByte(u, ':')
321 if i < 0 {
322 return ""
323 }
324 return strings.ToLower(u[:i])
325}
326```
327
328- [ ] **Step 4: Run to see them pass**
329
330Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/relay/ -v`
331Expected: all PASS, the vector test first.
332
333- [ ] **Step 5: Gate and commit**
334
335Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci`
336
337```bash
338git add internal/calls/relay
339git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "relay: parse CARLOS_RELAY and mint TURN credentials (platform vector)
340
341Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
342Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
343```
344
345---
346
347### Task 2: Startup: parse the relay, refuse a bad one outside development
348
349**Files:**
350- Modify: `internal/calls/config.go` (the `Config` struct, `resolved`, `resolve`), `cmd/calls/main.go:60`, `internal/calls/app.go` (wherever `RelayConfigured` is read for the `Floor` value; keep the name, derive it)
351- Test: `internal/calls/config_test.go` (exists; add cases)
352
353**Interfaces:**
354- Consumes: `relay.Parse`.
355- Produces: `Config.Relay string` (raw `$CARLOS_RELAY`; `RelayConfigured` stays as a field for tests but `resolve` overwrites it from `Relay`); `resolved.relay []relay.Region`; `func (a *app) relayRegions() []relay.Region` for Task 3; `TestHandle.SetRelay(raw string) error` for Task 3's and Task 5's tests (re-parses and swaps the regions under an atomic).
356
357- [ ] **Step 1: Write the failing tests** (append to `internal/calls/config_test.go`; follow the file's existing `resolve` test shape for a minimal Config in development with a logged mailer)
358
359```go
360func TestAMalformedRelayRefusesToStartOutsideDevelopment(t *testing.T) {
361 cfg := minimalConfig(t) // the file's helper; if absent, build one: Env production, Origin https://meet.example, InstanceKey/DEK 32-byte base64, Mailer a recording sender, AllowedAddresses one address
362 cfg.Env = "production"
363 cfg.Relay = "%%%not-base64"
364 _, err := resolve(cfg)
365 if err == nil || !strings.Contains(err.Error(), "CARLOS_RELAY") {
366 t.Fatalf("want a refusal naming the variable, got %v", err)
367 }
368 if strings.Contains(err.Error(), "%%%") {
369 t.Fatal("the refusal echoes the value")
370 }
371}
372
373func TestAMalformedRelayIsIgnoredWithAWarningInDevelopment(t *testing.T) {
374 var buf bytes.Buffer
375 cfg := minimalConfig(t)
376 cfg.Env = EnvDevelopment
377 cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil))
378 cfg.Relay = "%%%not-base64"
379 out, err := resolve(cfg)
380 if err != nil {
381 t.Fatal(err)
382 }
383 if out.relay != nil || out.relayConfigured {
384 t.Fatal("a malformed relay must mean no relay in development")
385 }
386 if !strings.Contains(buf.String(), "CARLOS_RELAY") || strings.Contains(buf.String(), "%%%") {
387 t.Fatalf("want one warning naming the variable and not the value, got %q", buf.String())
388 }
389}
390
391func TestAGoodRelayIsParsedAndNeverLogged(t *testing.T) {
392 var buf bytes.Buffer
393 cfg := minimalConfig(t)
394 cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil))
395 cfg.Relay = relayVector(t) // base64 of the one-region contract with the vector key; put this helper in the test file
396 out, err := resolve(cfg)
397 if err != nil {
398 t.Fatal(err)
399 }
400 if len(out.relay) != 1 || !out.relayConfigured {
401 t.Fatalf("parsed %+v", out.relay)
402 }
403 if strings.Contains(buf.String(), "uMhwW4P") || strings.Contains(buf.String(), "0123456789abcdef") {
404 t.Fatal("the key or the suffix reached a log line")
405 }
406}
407
408func TestNoRelayIsTheFloor(t *testing.T) {
409 cfg := minimalConfig(t)
410 cfg.Relay = ""
411 out, err := resolve(cfg)
412 if err != nil {
413 t.Fatal(err)
414 }
415 if out.relay != nil || out.relayConfigured {
416 t.Fatal("absent must mean the floor")
417 }
418}
419```
420
421- [ ] **Step 2: Run to see them fail**
422
423Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/ -run Relay`
424Expected: compile errors (`cfg.Relay`, `out.relay`, `out.relayConfigured` undefined).
425
426- [ ] **Step 3: Implement**
427
428In `internal/calls/config.go`:
429
430```go
431 // Relay is the raw $CARLOS_RELAY: the platform's base64-wrapped JSON
432 // list of regions, keys and username suffixes (platform relay design
433 // §2). Absent means the floor (§5.5). It is parsed once here and the
434 // decoded value is held in memory and never logged (§5.2).
435 Relay string
436 // RelayConfigured is derived from Relay by resolve; callers read the
437 // resolved value, not this field. Kept so existing tests that set it
438 // still compile; resolve overwrites it.
439 RelayConfigured bool
440```
441
442In `resolved`:
443
444```go
445 relay []relay.Region
446 relayConfigured bool
447```
448
449In `resolve`, after the allowlist is resolved:
450
451```go
452 regions, err := relay.Parse(cfg.Relay)
453 switch {
454 case err == nil:
455 out.relay = regions
456 out.relayConfigured = len(regions) > 0
457 case cfg.development():
458 // One line, the variable and not the value: the value carries a
459 // key. Development runs without a relay rather than refusing.
460 cfg.Logger.Warn("calls: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error())
461 default:
462 return out, fmt.Errorf("calls: CARLOS_RELAY is present but malformed (%v); a relay the app cannot read is a relay it must not pretend to have", err)
463 }
464```
465
466(`err.Error()` is safe to log: Task 1 guarantees the message never contains the value.)
467
468Then: every read of `a.cfg.RelayConfigured` (the `Floor` value in `handlers_join.go` and `handlers_room.go`) becomes `!a.relayConfigured()`, where:
469
470```go
471// relayRegions is the parsed contract, swapped atomically so a test can
472// hand the app a relay after boot (TestHandle.SetRelay). Production
473// sets it once in build.
474func (a *app) relayRegions() []relay.Region {
475 v, _ := a.relay.Load().([]relay.Region)
476 return v
477}
478
479func (a *app) relayConfigured() bool { return len(a.relayRegions()) > 0 }
480```
481
482with `relay atomic.Value` on `app`, stored in `build` from `rc.relay`, and on `TestHandle`:
483
484```go
485// SetRelay hands the app a CARLOS_RELAY value after boot. Tests only.
486func (h *TestHandle) SetRelay(raw string) error {
487 regions, err := relay.Parse(raw)
488 if err != nil {
489 return err
490 }
491 h.app.relay.Store(regions)
492 return nil
493}
494```
495
496In `cmd/calls/main.go` replace the `RelayConfigured:` line with `Relay: os.Getenv("CARLOS_RELAY"),`. Log one INFO line at startup when a relay is present: `logger.Info("calls: relay configured", "regions", len(regions))` — the count only — inside `build`, not `resolve`.
497
498- [ ] **Step 4: Run to see them pass; run the whole gate**
499
500Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci`
501Expected: green; the holding and room page tests that assert the floor sentence still pass (they run with no relay).
502
503- [ ] **Step 5: Commit**
504
505```bash
506git add internal/calls/config.go internal/calls/config_test.go internal/calls/app.go internal/calls/handlers_join.go internal/calls/handlers_room.go cmd/calls/main.go
507git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "config: parse CARLOS_RELAY at boot; refuse a bad one outside development
508
509Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
510Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
511```
512
513---
514
515### Task 3: `GET /m/{sqid}/ice`: the list for an admitted participation
516
517**Files:**
518- Create: `internal/calls/hub/authorize.go`, `internal/calls/ice.go`
519- Modify: `internal/calls/app.go` (route inside the `requirePerson` group, beside `/stream` and `/signal`)
520- Test: `internal/calls/hub/authorize_test.go`, `internal/callstest/ice_test.go`; update `internal/callstest/stream_test.go:557` (the `/ice` tripwire from Task 10 now expects the real answer)
521
522**Interfaces:**
523- Consumes: `a.sessionKey(r)`, `validSqid`, `validPeerID` (signal.go), `streamJSON`, `a.notFoundBody()`, `a.relayRegions()`, `relay.Mint`, `a.store.now()`, hub `findLocked`, `memberStandingGone` (relay.go, Codex wave 1: `memberStandingGone(ctx, sqid, session, peer, end bool)` — read its signature and reuse it).
524- Produces:
525 ```go
526 // hub
527 func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error
528 // ErrUnknownPeer for no such room/peer/session; ErrNotAdmitted for a waiting or unjoined peer; for a member-kind participation whose standing is gone: ended with lost{standing}, gone announced, ErrNotYours (§15.12, mutating like Relay). Guests never consult standing (ruling 14).
529 ```
530 Wire: `GET /m/{sqid}/ice?peer=<32 hex>` → `200 {"iceServers":[{urls,username?,credential?}...],"expiresAt":<unix seconds>,"renewAt":<unix seconds>}`, `Cache-Control: no-store`; with no relay `{"iceServers":[],"expiresAt":0,"renewAt":0}`; `404` with `a.notFoundBody()` for a malformed sqid or peer, unknown peer, another session's peer, a waiting or unjoined peer, a member who lost standing, a meeting that has ended. Development-only header `X-Meet-Test-Ice-TTL: <duration>` (honoured only when `a.cfg.development()`, like `X-Meet-Test-TTL`) sets expiry to now+d and renewAt to now+d*5/6, floor 2 s, so the gate can shorten renewal.
531
532- [ ] **Step 1: Write the failing hub test**
533
534```go
535package hub
536
537import (
538 "context"
539 "errors"
540 "testing"
541 "time"
542)
543
544func TestAuthorizeIsTheAdmittedParticipationRule(t *testing.T) {
545 now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
546 h := testHub(&now)
547 ctx := context.Background()
548 a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
549 if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotAdmitted) {
550 t.Fatalf("unjoined: want ErrNotAdmitted, got %v", err)
551 }
552 if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil {
553 t.Fatal(err)
554 }
555 if err := h.Authorize(ctx, "r1", "sa", a.Peer); err != nil {
556 t.Fatalf("admitted member: %v", err)
557 }
558 if err := h.Authorize(ctx, "r1", "sb", a.Peer); !errors.Is(err, ErrUnknownPeer) {
559 t.Fatalf("another session: want ErrUnknownPeer, got %v", err)
560 }
561 if err := h.Authorize(ctx, "r2", "sa", a.Peer); !errors.Is(err, ErrUnknownPeer) {
562 t.Fatalf("another room: want ErrUnknownPeer, got %v", err)
563 }
564 g, _ := h.Open("r1", "sg", 2, "Gus", KindGuest)
565 h.Join(ctx, "r1", "sg", g.Peer)
566 if err := h.Authorize(ctx, "r1", "sg", g.Peer); !errors.Is(err, ErrNotAdmitted) {
567 t.Fatalf("waiting guest: want ErrNotAdmitted, got %v", err)
568 }
569}
570
571func TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) {
572 // Use the same StandingFor switch the relay tests use (relay_test.go:
573 // TestAMemberWithNoStandingIsRefusedByTheNextRequestNotTheNextTick)
574 // to revoke Ann's standing without a tick; then Authorize → ErrNotYours,
575 // her participation is gone, Bob's stream received `gone` naming her.
576 // Then an admitted guest whose grant has "expired" (StandingFor !ok
577 // for the guest) → Authorize returns nil (ruling 14).
578 // Copy the fixture shape from that test verbatim.
579}
580```
581
582Fill the second test's body by copying the fixture from `relay_test.go`'s standing-revocation test (it constructs a hub whose `StandingFor` consults a mutable map); assert as the comment says.
583
584- [ ] **Step 2: Implement `Authorize`** (`internal/calls/hub/authorize.go`)
585
586```go
587package hub
588
589import "context"
590
591// Authorize is the §2.5 last-row check on its own: a participation
592// bound to this session, in this room, admitted. A member-kind
593// participation re-derives standing first (§15.12), exactly as Relay
594// does, and one whose standing is gone is ended here with lost{standing}
595// and answered ErrNotYours; a guest never consults standing (ruling 14).
596// GET /m/{sqid}/ice is its caller; it changes nothing on success.
597func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error {
598 if err := h.memberStandingGone(ctx, sqid, session, peer, true); err != nil {
599 return err
600 }
601 h.mu.Lock()
602 defer h.mu.Unlock()
603 p, ok := h.findLocked(sqid, session, peer)
604 if !ok {
605 return ErrUnknownPeer
606 }
607 if !p.Admitted {
608 return ErrNotAdmitted
609 }
610 return nil
611}
612```
613
614(Read `memberStandingGone`'s real signature and return contract in `relay.go` first; if it returns a bool rather than an error, mirror how `Relay` turns that into `ErrNotYours`.)
615
616- [ ] **Step 3: Write the failing HTTP tests** (`internal/callstest/ice_test.go`)
617
618```go
619package callstest
620
621import (
622 "bytes"
623 "encoding/base64"
624 "encoding/json"
625 "fmt"
626 "net/http"
627 "testing"
628 "time"
629
630 "amadan.net/oficina/meet/internal/calls/relay"
631)
632
633const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU="
634
635func relayContract(t *testing.T) string {
636 t.Helper()
637 b, _ := json.Marshal([]map[string]any{{
638 "region": "eu-west-1",
639 "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp"},
640 "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1",
641 }})
642 return base64.StdEncoding.EncodeToString(b)
643}
644
645type iceAnswer struct {
646 ICEServers []relay.ICEServer `json:"iceServers"`
647 ExpiresAt int64 `json:"expiresAt"`
648 RenewAt int64 `json:"renewAt"`
649}
650
651func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) {
652 x, sqid, _ := memberWithMeeting(t)
653 if err := x.h.SetRelay(relayContract(t)); err != nil {
654 t.Fatal(err)
655 }
656 c := x.browser(t)
657 c.signIn("paul@example.com")
658 s := c.stream("/m/" + sqid + "/stream")
659 defer s.close()
660 peer, _ := s.hello()
661 c.signal(sqid, map[string]any{"peer": peer, "type": "join"})
662 s.nextOf("roster", 2*time.Second)
663
664 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) // any bounded GET helper; the Accept header is harmless
665 if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
666 t.Fatalf("ice: %d %q", rec.Code, rec.Header().Get("Cache-Control"))
667 }
668 var ans iceAnswer
669 if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
670 t.Fatal(err)
671 }
672 if len(ans.ICEServers) != 2 || ans.ICEServers[0].Username != "" || ans.ICEServers[1].Username == "" {
673 t.Fatalf("servers %+v", ans.ICEServers)
674 }
675 now := x.now()
676 if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() {
677 t.Fatalf("expiry %d is not three hours ahead of %d", ans.ExpiresAt, now.Unix())
678 }
679 if ans.RenewAt != now.Add(150*time.Minute).Unix() {
680 t.Fatalf("renewAt %d", ans.RenewAt)
681 }
682 // The credential verifies against the vector key for the username the server chose.
683 key, _ := base64.StdEncoding.DecodeString(vectorKey)
684 turn := ans.ICEServers[1]
685 if relay.Credential(key, turn.Username) != turn.Credential {
686 t.Fatal("credential does not verify")
687 }
688 if want := fmt.Sprintf("%d:abc:meet:0123456789abcdef:k1", ans.ExpiresAt); turn.Username != want {
689 t.Fatalf("username %q, want %q", turn.Username, want)
690 }
691}
692
693func TestIceIs404ForAnythingButAnAdmittedParticipationOfThisSession(t *testing.T) {
694 x, sqid, _ := memberWithMeeting(t)
695 x.h.SetRelay(relayContract(t))
696 c := x.browser(t)
697 c.signIn("paul@example.com")
698 s := c.stream("/m/" + sqid + "/stream")
699 defer s.close()
700 peer, _ := s.hello()
701 unjoined := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
702 if unjoined.Code != http.StatusNotFound {
703 t.Fatalf("unjoined: %d", unjoined.Code)
704 }
705 other := x.browser(t)
706 other.signIn("paul@rslw.com")
707 foreign := other.getStream("/m/" + sqid + "/ice?peer=" + peer)
708 if foreign.Code != http.StatusNotFound || !bytes.Equal(foreign.Body.Bytes(), unjoined.Body.Bytes()) {
709 t.Fatalf("another session must get the identical 404: %d %s vs %s", foreign.Code, foreign.Body.Bytes(), unjoined.Body.Bytes())
710 }
711 bad := c.getStream("/m/" + sqid + "/ice?peer=zz")
712 if bad.Code != http.StatusNotFound || !bytes.Equal(bad.Body.Bytes(), unjoined.Body.Bytes()) {
713 t.Fatal("a malformed peer is the same 404")
714 }
715 room := c.getStream("/m/2222222222/ice?peer=" + peer)
716 if room.Code != http.StatusNotFound || !bytes.Equal(room.Body.Bytes(), unjoined.Body.Bytes()) {
717 t.Fatal("an unknown room is the same 404")
718 }
719 // A waiting guest: sign a guest in through the link and join; 404.
720 g, gpeer := guestJoinedAndWaiting(t, x, sqid) // reuse the slice 2 helper that signs a guest in through the emailed link, opens its stream and posts join; write it if isolation_test.go's inline steps have not been lifted
721 waiting := g.getStream("/m/" + sqid + "/ice?peer=" + gpeer)
722 if waiting.Code != http.StatusNotFound || !bytes.Equal(waiting.Body.Bytes(), unjoined.Body.Bytes()) {
723 t.Fatal("a waiting guest is the same 404")
724 }
725}
726
727func TestIceWithNoRelayIsAnEmptyList(t *testing.T) {
728 x, sqid, _ := memberWithMeeting(t)
729 c := x.browser(t)
730 c.signIn("paul@example.com")
731 s := c.stream("/m/" + sqid + "/stream")
732 defer s.close()
733 peer, _ := s.hello()
734 c.signal(sqid, map[string]any{"peer": peer, "type": "join"})
735 s.nextOf("roster", 2*time.Second)
736 rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer)
737 if rec.Code != http.StatusOK || rec.Body.String() != "{\"expiresAt\":0,\"iceServers\":[],\"renewAt\":0}\n" {
738 t.Fatalf("%d %q", rec.Code, rec.Body.String())
739 }
740}
741
742func TestIceTestTTLHeaderIsHonouredOnlyInDevelopment(t *testing.T) {
743 // Same shape as stream_test.go's X-Meet-Test-TTL test: development
744 // harness → expiresAt ≈ now+2s and renewAt < expiresAt; production
745 // harness (newHarnessWithEnv or the existing production fixture) →
746 // three hours regardless of the header.
747}
748
749func TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal(t *testing.T) {
750 // newHarnessWithLogger (signal_test.go): one success, one refusal;
751 // the buffer contains no "uMhwW4P", no ":abc:meet:", no credential,
752 // and the success produced no line at all mentioning "ice".
753}
754```
755
756Fill the two sketched tests with the file's neighbouring patterns (they are named in the comments); they must assert what their names say.
757
758- [ ] **Step 4: Implement the handler** (`internal/calls/ice.go`)
759
760```go
761package meet
762
763import (
764 "errors"
765 "net/http"
766 "time"
767
768 "github.com/go-chi/chi/v5"
769
770 "amadan.net/oficina/meet/internal/calls/hub"
771 "amadan.net/oficina/meet/internal/calls/relay"
772)
773
774const (
775 iceLife = 3 * time.Hour // §5.2: inside the contract's four-hour maximum
776 iceRenewAfter = 150 * time.Minute // §5.3 step 4
777 testIceHeader = "X-Meet-Test-Ice-TTL" // development only, like X-Meet-Test-TTL
778 minIceLife = 2 * time.Second
779)
780
781// ice answers GET /m/{sqid}/ice?peer= with the RTCIceServer list for one
782// call (§5.2). Authority is the §2.5 last row — an admitted participation
783// bound to this session — through hub.Authorize, which also applies the
784// member re-derivation of §15.12. Every refusal is the same 404 as
785// /signal's, so nothing about another session's participation is
786// observable here. Nothing is logged on success: the answer carries a
787// credential.
788func (a *app) ice(w http.ResponseWriter, r *http.Request) {
789 sqid := chi.URLParam(r, "sqid")
790 session, ok := a.sessionKey(r)
791 if !ok {
792 a.logger.Error("calls: ice with no session subject", "path", r.URL.Path)
793 streamJSON(w, http.StatusInternalServerError, nil)
794 return
795 }
796 peer := r.URL.Query().Get("peer")
797 if !validSqid(sqid) || !validPeerID(peer) {
798 a.logger.Info("calls: ice refused", "room", "invalid", "peer", "", "outcome", "shape")
799 streamJSON(w, http.StatusNotFound, a.notFoundBody())
800 return
801 }
802 if ended, err := a.store.meetingEnded(sqid); err != nil || ended { // reuse whatever stream.go calls to read ended_at before attaching; match its name
803 streamJSON(w, http.StatusNotFound, a.notFoundBody())
804 return
805 }
806 switch err := a.hub.Authorize(r.Context(), sqid, session, hub.PeerID(peer)); {
807 case err == nil:
808 case errors.Is(err, hub.ErrUnknownPeer), errors.Is(err, hub.ErrNotAdmitted), errors.Is(err, hub.ErrNotYours):
809 a.logger.Info("calls: ice refused", "room", sqid, "peer", peer, "outcome", "authority")
810 streamJSON(w, http.StatusNotFound, a.notFoundBody())
811 return
812 default:
813 a.logger.Error("calls: ice: authorize", "room", sqid, "peer", peer, "outcome", "internal", "err", err)
814 streamJSON(w, http.StatusInternalServerError, nil)
815 return
816 }
817 now := a.store.now()
818 life, renew := iceLife, iceRenewAfter
819 if a.cfg.development() {
820 if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil && d > 0 {
821 life = max(d, minIceLife)
822 renew = life * 5 / 6
823 }
824 }
825 regions := a.relayRegions()
826 if len(regions) == 0 {
827 streamJSON(w, http.StatusOK, map[string]any{"iceServers": []relay.ICEServer{}, "expiresAt": 0, "renewAt": 0})
828 return
829 }
830 expiry := now.Add(life)
831 streamJSON(w, http.StatusOK, map[string]any{
832 "iceServers": relay.Mint(regions, expiry),
833 "expiresAt": expiry.Unix(),
834 "renewAt": now.Add(renew).Unix(),
835 })
836}
837```
838
839Route in `app.go`, inside the same group as `/stream` and `/signal`: `r.Get("/m/{sqid}/ice", a.ice)`. Check that `streamJSON` sets `Cache-Control: no-store` (it does for the stream refusals; if it only sets it on non-2xx, set it here explicitly). Confirm the JSON key order matches the empty-list test (Go marshals map keys sorted: expiresAt, iceServers, renewAt).
840
841Update `internal/callstest/stream_test.go:557` (Task 10's tripwire that asserted `/ice` is 404 for an admitted guest past expiry): it must now assert **200 with servers** for the admitted guest (ruling 14: an admitted guest continues, and `/ice` never consults standing for a guest).
842
843- [ ] **Step 5: Gate and commit**
844
845Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` and `CGO_ENABLED=1 go test -race ./internal/...`
846
847```bash
848git add internal/calls/hub/authorize.go internal/calls/hub/authorize_test.go internal/calls/ice.go internal/calls/app.go internal/callstest/ice_test.go internal/callstest/stream_test.go
849git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "ice: GET /m/{sqid}/ice mints the RTCIceServer list for an admitted participation
850
851Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
852Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
853```
854
855---
856
857### Task 4: `room.js`: the ICE list and renewal (§5.3)
858
859**Files:**
860- Modify: `internal/calls/static/room.js` (`createPC`, `restartIce`, `state()`, a new renewal module: `fetchIce`, `scheduleRenewal`, `renewAll`, `renewOne`, `proveRenewed`, `iceWarning`), `internal/calls/templates/room.html` (`data-relay="1"` on `#room` when the relay is configured; the template already receives `Floor`, so pass `Relay: a.relayConfigured()`), `internal/calls/static/calls.css` (the warning and the expired tile note), `docs/superpowers/copy/2026-09-10-slice-2-strings.md` (a "Slice 3" section)
861
862**Interfaces (the client contract):**
863- On becoming admitted (the first `roster`), before the first `createPC`, `await fetchIce()`: `GET /m/{sqid}/ice?peer=<state.peer>` with `credentials: 'same-origin'`, no special Accept. Answer `{iceServers, expiresAt, renewAt}` (unix seconds). Store `ice = {servers, expiresAt, renewAt, fetchedAt, failedSince}`. On a non-200 or a network error: keep the previous list (or `[]` on the first call), set `ice.failedSince`, show the warning sentence in `#status`'s neighbour `#ice-warning` (a `role="status"` `<p>` the script creates once), and retry every minute (`ICE_RETRY = 60000`; the gate shortens it through `X-Meet-Test-Ice-TTL`'s effect on `renewAt`, not through a client knob: keep the retry at one minute and make the failure scenario assert the warning, not the recovery timing). A 404 means this participation is gone: run the existing `recoverFromNotFound`-style probe ladder (reuse the probe-first recovery so `/ice`'s 404 is handled like `/signal`'s).
864- `createPC` builds `new RTCPeerConnection({ iceServers: ice.servers })`. An empty list is the floor (unchanged behaviour). With `data-relay` present, the failed tile's note reads "Could not connect, even through the relay." instead of "Could not connect." (§5.5).
865- Renewal per connection (§5.3): at `renewAt` (a timer from `fetchedAt`; on `visibilitychange` to visible, compare the clock with `renewAt` and run at once if overdue), `renewAll()`: `await fetchIce()`; on failure retry each minute and keep the old `expiresAt` as the must-succeed-by; on success, for every live pc: `renewOne(id)`: record `generation = { started: now, oldUfrag: <selected local candidate's usernameFragment from getStats(), or null> }`, `pc.setConfiguration({ iceServers: ice.servers })`, `pc.restartIce()`, let perfect negotiation carry the offer, then `proveRenewed(id)` polls `getStats()` every 500 ms for up to 30 s until `signalingState === 'stable'` AND a `candidate-pair` with `state === 'succeeded'` and `nominated`/selected whose `localCandidateId` resolves to a `local-candidate` with `usernameFragment` different from `oldUfrag` (when `oldUfrag` is null, any succeeded selected pair after the restart's answer counts). Renewed → `entry.renewed = {at, ufrag}`. Not proven within 30 s → restart again, up to three attempts. After three failures, or when `now > mustSucceedBy` for a connection not yet renewed: mark `entry.relayExpired = true`, the tile shows "The relay credential expired for this connection." with a Retry control that runs `renewOne` after a fresh `fetchIce()`.
866- `restartIce(id)` (the existing Retry) also renews if `ice.servers` is non-empty and older than `renewAt`.
867- `state()` gains `ice: { fetches, lastFetchAt, expiresAt, renewAt, warning: <bool>, servers: ice.servers.length }` and per-peer `renewals: {peer: {attempts, renewed: bool, ufrag}}` for the gate.
868- Nothing about the servers is logged; `console` carries no credential.
869
870- [ ] **Step 1: Read §5.2, §5.3 and §5.5, then the current `room.js` around `createPC` (line ~751), `restartIce` (~1607), `recoverFromNotFound` and `state()` (~1791), and `task-8-handoffs.md`**.
871
872- [ ] **Step 2: Implement to the contract**, keeping each concern in a named function with a header comment naming §5.2 and §5.3. Do not change any slice 2 behaviour that the 23 existing scenarios assert; run `node --check`.
873
874- [ ] **Step 3: Add the strings to the copy catalogue** under "## Slice 3": the warning sentence ("Meet could not reach the relay. Calls may not connect until it can."), the expired sentence, the relay-aware failed note, the Retry label.
875
876- [ ] **Step 4: Gate** — `make ci`; `make browser` with the sandbox off must stay 23/23 (the new scenarios are Task 5's).
877
878- [ ] **Step 5: Commit**
879
880```bash
881git add internal/calls/static/room.js internal/calls/static/calls.css internal/calls/templates/room.html internal/calls/handlers_room.go docs/superpowers/copy/2026-09-10-slice-2-strings.md
882git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "room.js: ICE servers from /ice, renewal with a proven ICE restart, the relay warnings
883
884Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
885Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
886```
887
888---
889
890### Task 5: The browser gate: `ice`, `icerenewal`, `icefail`
891
892**Files:**
893- Modify: `test/browser/call.mjs`, `internal/callstest/browser_test.go` (the Go rig sets the relay contract on the app through `TestHandle.SetRelay` before the `ice*` scenarios; a control command `relay on` / `relay off` if the rig's per-scenario restart needs it)
894
895**Interfaces:**
896- Consumes: `state().ice` and `state().renewals` from Task 4; the rig's route helpers; `X-Meet-Test-Ice-TTL`.
897- Scenarios (§13, slice 3):
898 - `ice`: with the vector contract set, two members join; each `state().ice.servers === 2` after admission, `fetches === 1`; the pair connects (frames both ways) — the servers point at a host that does not exist in the test, so ICE must still succeed on host candidates; assert `signaling stable`, `connection connected`. Also assert, through a route on `**/ice*`, that the response body carries a `turn:` entry with a username of five colon-separated fields and no credential appears in any console line.
899 - `icerenewal`: route rewrites `/ice` requests to add `X-Meet-Test-Ice-TTL: 12s` (renewAt ≈ 10 s); after the pair connects, within 25 s assert `state().ice.fetches >= 2`, and for the other peer `renewals[peer].renewed === true` with a `ufrag` different from the one recorded after the first connection (read `getStats` in the page through a small evaluate to capture the initial ufrag), and frames still rising after the renewal. Assert no tile shows the expired sentence.
900 - `icefail`: route fulfils `/ice` with 503 from the second fetch on; assert the warning sentence appears within 15 s of `renewAt`, the call stays connected (frames rising), `state().ice.warning === true`; then un-route and assert the warning clears within 70 s (one retry interval) — if 70 s is too long for the gate, shorten by asserting only the warning and the call staying connected, and record that the recovery timing is unproved.
901- No scenario touches a real relay (§13's last sentence).
902
903- [ ] **Step 1: Write the scenarios** following the file's shape (`until`, `check`, `diagnose`, `stayed`, `state` flattening, per-scenario restart), each printing one line per assertion.
904
905- [ ] **Step 2: Run `make browser`** with the sandbox off; every scenario passes; record the run's tail in the report.
906
907- [ ] **Step 3: Update the Makefile comment's scenario pointer if it enumerates; update AGENTS.md's gate line if the runtime changed materially.**
908
909- [ ] **Step 4: Commit**
910
911```bash
912git add test/browser/call.mjs internal/callstest/browser_test.go
913git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "browser gate: /ice list, renewal under a shortened schedule, the failure warning
914
915Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
916Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2"
917```
918
919---
920
921### Task 6: Record and spec note
922
923**Files:**
924- Modify: `docs/superpowers/specs/2026-09-07-meet-design.md` (§15 gains §15.13: the `/ice` wire shape `{iceServers, expiresAt, renewAt}`, the `?peer=` query, the development-only header, and that `/ice` for a member re-derives standing like `/signal`), `docs/superpowers/reviews/2026-09-10-slice-2-record.md` → create `docs/superpowers/reviews/2026-09-10-slice-3-record.md` (ledger, dispositions, carried items, the canary and relay-enable steps, the deploy note).
925
926- [ ] **Step 1: Write §15.13 and the record.**
927- [ ] **Step 2: `make ci`; commit `docs: slice 3 record and spec note`.**
928
929---
930
931## Self-review
932
933**Spec coverage (§12 item 3):** `CARLOS_RELAY` parsing and the startup refusal (2); minting with the vector test (1); `/ice` (3); renewal with repeated and failed cases under shortened timers (4, 5). §13 slice-3 gates: "renewal with the schedule shortened to seconds produces a second `/ice` fetch and an ICE restart that leaves the track flowing, and a failed fetch shows the warning" (5). §5.5's relay-aware failed note (4). §5.5's README paragraph is slice 6's. Verified against the real relay: the controller's canary step, not a task.
934
935**Placeholders:** Tasks 4 and 5 give a contract and function list (Opus-tier design tasks, as slice 2's Tasks 8 and 9 did); every behaviour is named with its spec section and the gate that proves it. Two tests in Task 3 are sketched with the neighbouring test named to copy from; the implementer fills them.
936
937**Type consistency:** `relay.Region`, `relay.ICEServer`, `relay.Parse`, `relay.Mint`, `relay.Credential` (1) used unchanged in 2 and 3; `a.relayRegions()`, `a.relayConfigured()`, `TestHandle.SetRelay` (2) used in 3, 4's template value and 5; `hub.Authorize` (3); `state().ice` and `state().renewals` (4) read by 5; header `X-Meet-Test-Ice-TTL` (3) set by 5.
938
939**Rulings for the controller:** `/ice` carries the peer as a query parameter (`?peer=`), the same way the stream resume does, so a native client needs no body on a GET. A member's `/ice` re-derives standing (mutating, like `/signal`), per §15.12. The renewal retry stays at one minute in the client; the gate proves the warning, not the one-minute recovery.
940