| 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 |
| 64 | package relay |
| 65 | |
| 66 | import ( |
| 67 | "encoding/base64" |
| 68 | "encoding/json" |
| 69 | "strings" |
| 70 | "testing" |
| 71 | "time" |
| 72 | ) |
| 73 | |
| 74 | const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| 75 | |
| 76 | func 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 | |
| 85 | func 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 | |
| 94 | func 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 | |
| 102 | func 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 | |
| 112 | func 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 | |
| 119 | func 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 | |
| 143 | func 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 | |
| 162 | func 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 | |
| 178 | func 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 | |
| 187 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/relay/` |
| 188 | Expected: 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. |
| 197 | package relay |
| 198 | |
| 199 | import ( |
| 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. |
| 216 | type 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. |
| 225 | type ICEServer struct { |
| 226 | URLs []string `json:"urls"` |
| 227 | Username string `json:"username,omitempty"` |
| 228 | Credential string `json:"credential,omitempty"` |
| 229 | } |
| 230 | |
| 231 | type 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 | |
| 238 | var ( |
| 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. |
| 248 | func 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. |
| 284 | func 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. |
| 295 | func 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 | |
| 319 | func 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 | |
| 330 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/relay/ -v` |
| 331 | Expected: all PASS, the vector test first. |
| 332 | |
| 333 | - [ ] **Step 5: Gate and commit** |
| 334 | |
| 335 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` |
| 336 | |
| 337 | ```bash |
| 338 | git add internal/calls/relay |
| 339 | git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "relay: parse CARLOS_RELAY and mint TURN credentials (platform vector) |
| 340 | |
| 341 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 342 | Claude-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 |
| 360 | func 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 | |
| 373 | func 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 | |
| 391 | func 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 | |
| 408 | func 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 | |
| 423 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/calls/ -run Relay` |
| 424 | Expected: compile errors (`cfg.Relay`, `out.relay`, `out.relayConfigured` undefined). |
| 425 | |
| 426 | - [ ] **Step 3: Implement** |
| 427 | |
| 428 | In `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 | |
| 442 | In `resolved`: |
| 443 | |
| 444 | ```go |
| 445 | relay []relay.Region |
| 446 | relayConfigured bool |
| 447 | ``` |
| 448 | |
| 449 | In `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 | |
| 468 | Then: 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. |
| 474 | func (a *app) relayRegions() []relay.Region { |
| 475 | v, _ := a.relay.Load().([]relay.Region) |
| 476 | return v |
| 477 | } |
| 478 | |
| 479 | func (a *app) relayConfigured() bool { return len(a.relayRegions()) > 0 } |
| 480 | ``` |
| 481 | |
| 482 | with `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. |
| 486 | func (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 | |
| 496 | In `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 | |
| 500 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` |
| 501 | Expected: 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 |
| 506 | git 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 |
| 507 | git -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 | |
| 509 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 510 | Claude-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 |
| 535 | package hub |
| 536 | |
| 537 | import ( |
| 538 | "context" |
| 539 | "errors" |
| 540 | "testing" |
| 541 | "time" |
| 542 | ) |
| 543 | |
| 544 | func 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 | |
| 571 | func 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 | |
| 582 | Fill 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 |
| 587 | package hub |
| 588 | |
| 589 | import "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. |
| 597 | func (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 |
| 619 | package callstest |
| 620 | |
| 621 | import ( |
| 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 | |
| 633 | const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| 634 | |
| 635 | func 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 | |
| 645 | type iceAnswer struct { |
| 646 | ICEServers []relay.ICEServer `json:"iceServers"` |
| 647 | ExpiresAt int64 `json:"expiresAt"` |
| 648 | RenewAt int64 `json:"renewAt"` |
| 649 | } |
| 650 | |
| 651 | func 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 | |
| 693 | func 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 | |
| 727 | func 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 | |
| 742 | func 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 | |
| 749 | func 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 | |
| 756 | Fill 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 |
| 761 | package meet |
| 762 | |
| 763 | import ( |
| 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 | |
| 774 | const ( |
| 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. |
| 788 | func (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 | |
| 839 | Route 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 | |
| 841 | Update `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 | |
| 845 | Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` and `CGO_ENABLED=1 go test -race ./internal/...` |
| 846 | |
| 847 | ```bash |
| 848 | git 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 |
| 849 | git -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 | |
| 851 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 852 | Claude-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 |
| 881 | git 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 |
| 882 | git -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 | |
| 884 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 885 | Claude-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 |
| 912 | git add test/browser/call.mjs internal/callstest/browser_test.go |
| 913 | git -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 | |
| 915 | Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| 916 | Claude-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 | |