plan: slice 3, the platform relay
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
1 file changed,
+939
−0
docs/superpowers/plans/2026-09-10-meet-slice-3-relay.md+939 −0
CI passed — run details
diff --git a/docs/superpowers/plans/2026-09-10-meet-slice-3-relay.md b/docs/superpowers/plans/2026-09-10-meet-slice-3-relay.md| new file mode 100644 |
| index 0000000..12941b2 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-10-meet-slice-3-relay.md |
| @@ -0,0 +1,939 @@ |
| +# Meet slice 3: the platform relay — Implementation Plan |
| + |
| +> **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. |
| + |
| +**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. |
| + |
| +**Architecture:** A pure package `internal/meet/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. |
| + |
| +**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. |
| + |
| +**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). |
| + |
| +## Global Constraints |
| + |
| +- `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. |
| +- 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. |
| +- 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). |
| +- No address, name, title, token, session key, payload, key or credential in any log line (§4.4). `/ice` logs nothing on success. |
| +- A 404 is never a 403. The `/ice` 404 body is byte-identical to `/signal`'s not-found body (`{"boot":…,"error":"not-found"}`). |
| +- Wire contracts are client-neutral (ruling 15): `/ice` is plain JSON a native client can consume. |
| +- Expiry is three hours ahead; the contract's maximum is four. `renewAt` is two and a half hours ahead (§5.3 step 4). |
| +- 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). |
| +- Screens work with JavaScript disabled; nothing in this slice changes the scriptless floor. |
| +- 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`. |
| + |
| +--- |
| + |
| +## File structure |
| + |
| +- `internal/meet/relay/relay.go` — `Parse`, `Region`, `ICEServer`, `Mint`; pure, no logging, no clock of its own. |
| +- `internal/meet/relay/relay_test.go` — the platform vector, parse refusals, scheme handling. |
| +- `internal/meet/config.go` — `Config.Relay string` (raw), `resolved.relay []relay.Region`; startup refusal. |
| +- `cmd/meet/main.go` — passes `Relay: os.Getenv("CARLOS_RELAY")`. |
| +- `internal/meet/hub/authorize.go` — `Authorize(ctx, sqid, session, peer)`: the §2.5 last-row check with §15.12's member re-derivation, shared by `/ice`. |
| +- `internal/meet/ice.go` — `GET /m/{sqid}/ice`. |
| +- `internal/meettest/ice_test.go` — the endpoint over HTTP. |
| +- `internal/meet/static/room.js` — ICE list, renewal state machine, warnings, `state().ice`. |
| +- `internal/meet/templates/room.html` — `data-relay` on `#room`. |
| +- `test/browser/call.mjs`, `internal/meettest/browser_test.go` — scenarios `ice`, `icerenewal`, `icefail`. |
| +- `docs/superpowers/copy/2026-09-10-slice-2-strings.md` — slice 3 strings appended. |
| + |
| +--- |
| + |
| +### Task 1: The relay package: parse the contract, mint a credential, the vector |
| + |
| +**Files:** |
| +- Create: `internal/meet/relay/relay.go`, `internal/meet/relay/relay_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + ```go |
| + package relay |
| + type Region struct { Region string; URLs []string; Key []byte /* 32 raw bytes */; UsernameSuffix string } |
| + type ICEServer struct { URLs []string `json:"urls"`; Username string `json:"username,omitempty"`; Credential string `json:"credential,omitempty"` } |
| + func Parse(raw string) ([]Region, error) // raw = $CARLOS_RELAY; "" → nil, nil |
| + 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 |
| + func Credential(key []byte, username string) string // base64std(HMAC-SHA256(key, username)) |
| + ``` |
| +- The error from `Parse` never contains the input (it names the position and the field, not the value). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +package relay |
| + |
| +import ( |
| + "encoding/base64" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| + |
| +func encode(t *testing.T, v any) string { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(b) |
| +} |
| + |
| +func oneRegion() []map[string]any { |
| + return []map[string]any{{ |
| + "region": "eu-west-1", |
| + "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"}, |
| + "key": vectorKey, |
| + "username_suffix": "abc:meet:0123456789abcdef:k1", |
| + }} |
| +} |
| + |
| +func TestThePlatformVectorMints(t *testing.T) { |
| + key, _ := base64.StdEncoding.DecodeString(vectorKey) |
| + got := Credential(key, "1900000000:abc:meet:0123456789abcdef:k1") |
| + if got != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" { |
| + t.Fatalf("credential %q does not match the platform vector", got) |
| + } |
| +} |
| + |
| +func TestParseAcceptsTheContractAndDecodesTheKey(t *testing.T) { |
| + regions, err := Parse(encode(t, oneRegion())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + 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 { |
| + t.Fatalf("parsed %+v", regions) |
| + } |
| +} |
| + |
| +func TestParseOfNothingIsNoRelay(t *testing.T) { |
| + regions, err := Parse("") |
| + if err != nil || regions != nil { |
| + t.Fatalf("empty must mean no relay: %v %v", regions, err) |
| + } |
| +} |
| + |
| +func TestParseRefusesWithoutEchoingTheValue(t *testing.T) { |
| + cases := map[string]string{ |
| + "not base64": "%%%", |
| + "not json": base64.StdEncoding.EncodeToString([]byte("nope")), |
| + "not an array": base64.StdEncoding.EncodeToString([]byte(`{"region":"x"}`)), |
| + "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"}}), |
| + "suffix wrong fields": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:k1"}}), |
| + "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"}}), |
| + "no urls": encode(t, []map[string]any{{"region": "eu-west-1", "urls": []string{}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), |
| + "empty region": encode(t, []map[string]any{{"region": "", "urls": []string{"stun:a:1"}, "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1"}}), |
| + } |
| + for name, raw := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + _, err := Parse(raw) |
| + if err == nil { |
| + t.Fatal("accepted") |
| + } |
| + if strings.Contains(err.Error(), vectorKey) || strings.Contains(err.Error(), "0123456789abcdef") || strings.Contains(err.Error(), raw) { |
| + t.Fatalf("the error echoes the value: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestMintCarriesCredentialsOnTurnOnlyAndPassesURLsThrough(t *testing.T) { |
| + regions, err := Parse(encode(t, oneRegion())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + expiry := time.Unix(1900000000, 0) |
| + servers := Mint(regions, expiry) |
| + if len(servers) != 2 { |
| + t.Fatalf("want a bare stun entry and a credentialed turn entry, got %+v", servers) |
| + } |
| + stun, turn := servers[0], servers[1] |
| + if len(stun.URLs) != 1 || stun.URLs[0] != "stun:relay.eu-west-1.oncarlos.com:3478" || stun.Username != "" || stun.Credential != "" { |
| + t.Fatalf("stun entry %+v", stun) |
| + } |
| + if len(turn.URLs) != 2 || turn.Username != "1900000000:abc:meet:0123456789abcdef:k1" || turn.Credential != "jRU479Y3GO45nrRHqxBf3BMeBHE6Fip1cwAccNv28MQ=" { |
| + t.Fatalf("turn entry %+v", turn) |
| + } |
| +} |
| + |
| +func TestMintKeepsConsoleOrderAndUnknownSchemesBare(t *testing.T) { |
| + two := append(oneRegion(), map[string]any{ |
| + "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"}, |
| + "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", |
| + }) |
| + regions, err := Parse(encode(t, two)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + servers := Mint(regions, time.Unix(1900000000, 0)) |
| + // eu-west-1 first (stun, turn), then us-east-1 (bare: stuns+future, credentialed: turns). |
| + 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" { |
| + t.Fatalf("order or scheme handling wrong: %+v", servers) |
| + } |
| +} |
| + |
| +func TestMintOfNoRegionsIsAnEmptyListNotNil(t *testing.T) { |
| + if got := Mint(nil, time.Now()); got == nil || len(got) != 0 { |
| + t.Fatalf("want [] so the JSON is [] not null, got %#v", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to see them fail** |
| + |
| +Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/meet/relay/` |
| +Expected: build failure, no package. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +```go |
| +// Package relay reads the platform's CARLOS_RELAY contract and mints |
| +// per-call TURN credentials from it (platform relay design §2, frozen |
| +// at revision 6). It is pure: no logging, no clock, no network. The |
| +// decoded keys live only in memory; nothing here ever formats one. |
| +package relay |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Region is one element of the contract: the relay hosts for one AWS |
| +// region, the 32-byte key the platform issued for this app and |
| +// incarnation, and the username suffix that names account, app, |
| +// incarnation and key id. |
| +type Region struct { |
| + Region string |
| + URLs []string |
| + Key []byte |
| + UsernameSuffix string |
| +} |
| + |
| +// ICEServer is one RTCIceServer as the browser wants it. A stun entry |
| +// carries no username; a turn entry carries the minted pair. |
| +type ICEServer struct { |
| + URLs []string `json:"urls"` |
| + Username string `json:"username,omitempty"` |
| + Credential string `json:"credential,omitempty"` |
| +} |
| + |
| +type wire struct { |
| + Region string `json:"region"` |
| + URLs []string `json:"urls"` |
| + Key string `json:"key"` |
| + UsernameSuffix string `json:"username_suffix"` |
| +} |
| + |
| +var ( |
| + accountRe = regexp.MustCompile(`^[a-z0-9]+$`) |
| + appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + incarnationRe = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| + kidRe = regexp.MustCompile(`^[a-z0-9]{1,8}$`) |
| +) |
| + |
| +// Parse decodes $CARLOS_RELAY. An empty value is no relay (nil, nil). |
| +// Errors name the element index and the field, never the value, so a |
| +// startup log line built from one cannot leak a key or a suffix. |
| +func Parse(raw string) ([]Region, error) { |
| + raw = strings.TrimSpace(raw) |
| + if raw == "" { |
| + return nil, nil |
| + } |
| + b, err := base64.StdEncoding.DecodeString(raw) |
| + if err != nil { |
| + return nil, errors.New("relay: CARLOS_RELAY is not standard base64") |
| + } |
| + var elems []wire |
| + if err := json.Unmarshal(b, &elems); err != nil { |
| + return nil, errors.New("relay: CARLOS_RELAY does not decode to a JSON array of regions") |
| + } |
| + out := make([]Region, 0, len(elems)) |
| + for i, e := range elems { |
| + if e.Region == "" { |
| + return nil, fmt.Errorf("relay: element %d has no region", i) |
| + } |
| + if len(e.URLs) == 0 { |
| + return nil, fmt.Errorf("relay: element %d (%s) has no urls", i, e.Region) |
| + } |
| + key, err := base64.StdEncoding.DecodeString(e.Key) |
| + if err != nil || len(key) != 32 { |
| + return nil, fmt.Errorf("relay: element %d (%s) key is not 32 base64 bytes", i, e.Region) |
| + } |
| + fields := strings.Split(e.UsernameSuffix, ":") |
| + if len(fields) != 4 || !accountRe.MatchString(fields[0]) || !appRe.MatchString(fields[1]) || !incarnationRe.MatchString(fields[2]) || !kidRe.MatchString(fields[3]) { |
| + return nil, fmt.Errorf("relay: element %d (%s) username_suffix is not account:app:incarnation:kid", i, e.Region) |
| + } |
| + out = append(out, Region{Region: e.Region, URLs: append([]string(nil), e.URLs...), Key: key, UsernameSuffix: e.UsernameSuffix}) |
| + } |
| + return out, nil |
| +} |
| + |
| +// Credential is base64std(HMAC-SHA256(key, username)), the platform's |
| +// TURN REST scheme. |
| +func Credential(key []byte, username string) string { |
| + mac := hmac.New(sha256.New, key) |
| + mac.Write([]byte(username)) |
| + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) |
| +} |
| + |
| +// Mint builds the RTCIceServer list for one call. Per region, in the |
| +// contract's order: one bare entry for every URL whose scheme is not |
| +// turn or turns (stun, stuns, or a scheme the contract adds later), |
| +// then one credentialed entry for the turn and turns URLs. Either may |
| +// be absent. Expiry is the caller's; §5.2 uses three hours ahead. |
| +func Mint(regions []Region, expiry time.Time) []ICEServer { |
| + out := []ICEServer{} |
| + username := strconv.FormatInt(expiry.Unix(), 10) |
| + for _, r := range regions { |
| + var bare, turn []string |
| + for _, u := range r.URLs { |
| + switch scheme(u) { |
| + case "turn", "turns": |
| + turn = append(turn, u) |
| + default: |
| + bare = append(bare, u) |
| + } |
| + } |
| + if len(bare) > 0 { |
| + out = append(out, ICEServer{URLs: bare}) |
| + } |
| + if len(turn) > 0 { |
| + name := username + ":" + r.UsernameSuffix |
| + out = append(out, ICEServer{URLs: turn, Username: name, Credential: Credential(r.Key, name)}) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +func scheme(u string) string { |
| + i := strings.IndexByte(u, ':') |
| + if i < 0 { |
| + return "" |
| + } |
| + return strings.ToLower(u[:i]) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run to see them pass** |
| + |
| +Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/meet/relay/ -v` |
| +Expected: all PASS, the vector test first. |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` |
| + |
| +```bash |
| +git add internal/meet/relay |
| +git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit -m "relay: parse CARLOS_RELAY and mint TURN credentials (platform vector) |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| +Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Startup: parse the relay, refuse a bad one outside development |
| + |
| +**Files:** |
| +- Modify: `internal/meet/config.go` (the `Config` struct, `resolved`, `resolve`), `cmd/meet/main.go:60`, `internal/meet/app.go` (wherever `RelayConfigured` is read for the `Floor` value; keep the name, derive it) |
| +- Test: `internal/meet/config_test.go` (exists; add cases) |
| + |
| +**Interfaces:** |
| +- Consumes: `relay.Parse`. |
| +- 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). |
| + |
| +- [ ] **Step 1: Write the failing tests** (append to `internal/meet/config_test.go`; follow the file's existing `resolve` test shape for a minimal Config in development with a logged mailer) |
| + |
| +```go |
| +func TestAMalformedRelayRefusesToStartOutsideDevelopment(t *testing.T) { |
| + 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 |
| + cfg.Env = "production" |
| + cfg.Relay = "%%%not-base64" |
| + _, err := resolve(cfg) |
| + if err == nil || !strings.Contains(err.Error(), "CARLOS_RELAY") { |
| + t.Fatalf("want a refusal naming the variable, got %v", err) |
| + } |
| + if strings.Contains(err.Error(), "%%%") { |
| + t.Fatal("the refusal echoes the value") |
| + } |
| +} |
| + |
| +func TestAMalformedRelayIsIgnoredWithAWarningInDevelopment(t *testing.T) { |
| + var buf bytes.Buffer |
| + cfg := minimalConfig(t) |
| + cfg.Env = EnvDevelopment |
| + cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil)) |
| + cfg.Relay = "%%%not-base64" |
| + out, err := resolve(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if out.relay != nil || out.relayConfigured { |
| + t.Fatal("a malformed relay must mean no relay in development") |
| + } |
| + if !strings.Contains(buf.String(), "CARLOS_RELAY") || strings.Contains(buf.String(), "%%%") { |
| + t.Fatalf("want one warning naming the variable and not the value, got %q", buf.String()) |
| + } |
| +} |
| + |
| +func TestAGoodRelayIsParsedAndNeverLogged(t *testing.T) { |
| + var buf bytes.Buffer |
| + cfg := minimalConfig(t) |
| + cfg.Logger = slog.New(slog.NewTextHandler(&buf, nil)) |
| + cfg.Relay = relayVector(t) // base64 of the one-region contract with the vector key; put this helper in the test file |
| + out, err := resolve(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(out.relay) != 1 || !out.relayConfigured { |
| + t.Fatalf("parsed %+v", out.relay) |
| + } |
| + if strings.Contains(buf.String(), "uMhwW4P") || strings.Contains(buf.String(), "0123456789abcdef") { |
| + t.Fatal("the key or the suffix reached a log line") |
| + } |
| +} |
| + |
| +func TestNoRelayIsTheFloor(t *testing.T) { |
| + cfg := minimalConfig(t) |
| + cfg.Relay = "" |
| + out, err := resolve(cfg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if out.relay != nil || out.relayConfigured { |
| + t.Fatal("absent must mean the floor") |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to see them fail** |
| + |
| +Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 go test ./internal/meet/ -run Relay` |
| +Expected: compile errors (`cfg.Relay`, `out.relay`, `out.relayConfigured` undefined). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `internal/meet/config.go`: |
| + |
| +```go |
| + // Relay is the raw $CARLOS_RELAY: the platform's base64-wrapped JSON |
| + // list of regions, keys and username suffixes (platform relay design |
| + // §2). Absent means the floor (§5.5). It is parsed once here and the |
| + // decoded value is held in memory and never logged (§5.2). |
| + Relay string |
| + // RelayConfigured is derived from Relay by resolve; callers read the |
| + // resolved value, not this field. Kept so existing tests that set it |
| + // still compile; resolve overwrites it. |
| + RelayConfigured bool |
| +``` |
| + |
| +In `resolved`: |
| + |
| +```go |
| + relay []relay.Region |
| + relayConfigured bool |
| +``` |
| + |
| +In `resolve`, after the allowlist is resolved: |
| + |
| +```go |
| + regions, err := relay.Parse(cfg.Relay) |
| + switch { |
| + case err == nil: |
| + out.relay = regions |
| + out.relayConfigured = len(regions) > 0 |
| + case cfg.development(): |
| + // One line, the variable and not the value: the value carries a |
| + // key. Development runs without a relay rather than refusing. |
| + cfg.Logger.Warn("meet: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error()) |
| + default: |
| + return out, fmt.Errorf("meet: CARLOS_RELAY is present but malformed (%v); a relay the app cannot read is a relay it must not pretend to have", err) |
| + } |
| +``` |
| + |
| +(`err.Error()` is safe to log: Task 1 guarantees the message never contains the value.) |
| + |
| +Then: every read of `a.cfg.RelayConfigured` (the `Floor` value in `handlers_join.go` and `handlers_room.go`) becomes `!a.relayConfigured()`, where: |
| + |
| +```go |
| +// relayRegions is the parsed contract, swapped atomically so a test can |
| +// hand the app a relay after boot (TestHandle.SetRelay). Production |
| +// sets it once in build. |
| +func (a *app) relayRegions() []relay.Region { |
| + v, _ := a.relay.Load().([]relay.Region) |
| + return v |
| +} |
| + |
| +func (a *app) relayConfigured() bool { return len(a.relayRegions()) > 0 } |
| +``` |
| + |
| +with `relay atomic.Value` on `app`, stored in `build` from `rc.relay`, and on `TestHandle`: |
| + |
| +```go |
| +// SetRelay hands the app a CARLOS_RELAY value after boot. Tests only. |
| +func (h *TestHandle) SetRelay(raw string) error { |
| + regions, err := relay.Parse(raw) |
| + if err != nil { |
| + return err |
| + } |
| + h.app.relay.Store(regions) |
| + return nil |
| +} |
| +``` |
| + |
| +In `cmd/meet/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("meet: relay configured", "regions", len(regions))` — the count only — inside `build`, not `resolve`. |
| + |
| +- [ ] **Step 4: Run to see them pass; run the whole gate** |
| + |
| +Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` |
| +Expected: green; the holding and room page tests that assert the floor sentence still pass (they run with no relay). |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/meet/config.go internal/meet/config_test.go internal/meet/app.go internal/meet/handlers_join.go internal/meet/handlers_room.go cmd/meet/main.go |
| +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 |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| +Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: `GET /m/{sqid}/ice`: the list for an admitted participation |
| + |
| +**Files:** |
| +- Create: `internal/meet/hub/authorize.go`, `internal/meet/ice.go` |
| +- Modify: `internal/meet/app.go` (route inside the `requirePerson` group, beside `/stream` and `/signal`) |
| +- Test: `internal/meet/hub/authorize_test.go`, `internal/meettest/ice_test.go`; update `internal/meettest/stream_test.go:557` (the `/ice` tripwire from Task 10 now expects the real answer) |
| + |
| +**Interfaces:** |
| +- 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). |
| +- Produces: |
| + ```go |
| + // hub |
| + func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error |
| + // 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). |
| + ``` |
| + 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. |
| + |
| +- [ ] **Step 1: Write the failing hub test** |
| + |
| +```go |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestAuthorizeIsTheAdmittedParticipationRule(t *testing.T) { |
| + now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| + if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotAdmitted) { |
| + t.Fatalf("unjoined: want ErrNotAdmitted, got %v", err) |
| + } |
| + if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Authorize(ctx, "r1", "sa", a.Peer); err != nil { |
| + t.Fatalf("admitted member: %v", err) |
| + } |
| + if err := h.Authorize(ctx, "r1", "sb", a.Peer); !errors.Is(err, ErrUnknownPeer) { |
| + t.Fatalf("another session: want ErrUnknownPeer, got %v", err) |
| + } |
| + if err := h.Authorize(ctx, "r2", "sa", a.Peer); !errors.Is(err, ErrUnknownPeer) { |
| + t.Fatalf("another room: want ErrUnknownPeer, got %v", err) |
| + } |
| + g, _ := h.Open("r1", "sg", 2, "Gus", KindGuest) |
| + h.Join(ctx, "r1", "sg", g.Peer) |
| + if err := h.Authorize(ctx, "r1", "sg", g.Peer); !errors.Is(err, ErrNotAdmitted) { |
| + t.Fatalf("waiting guest: want ErrNotAdmitted, got %v", err) |
| + } |
| +} |
| + |
| +func TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) { |
| + // Use the same StandingFor switch the relay tests use (relay_test.go: |
| + // TestAMemberWithNoStandingIsRefusedByTheNextRequestNotTheNextTick) |
| + // to revoke Ann's standing without a tick; then Authorize → ErrNotYours, |
| + // her participation is gone, Bob's stream received `gone` naming her. |
| + // Then an admitted guest whose grant has "expired" (StandingFor !ok |
| + // for the guest) → Authorize returns nil (ruling 14). |
| + // Copy the fixture shape from that test verbatim. |
| +} |
| +``` |
| + |
| +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. |
| + |
| +- [ ] **Step 2: Implement `Authorize`** (`internal/meet/hub/authorize.go`) |
| + |
| +```go |
| +package hub |
| + |
| +import "context" |
| + |
| +// Authorize is the §2.5 last-row check on its own: a participation |
| +// bound to this session, in this room, admitted. A member-kind |
| +// participation re-derives standing first (§15.12), exactly as Relay |
| +// does, and one whose standing is gone is ended here with lost{standing} |
| +// and answered ErrNotYours; a guest never consults standing (ruling 14). |
| +// GET /m/{sqid}/ice is its caller; it changes nothing on success. |
| +func (h *Hub) Authorize(ctx context.Context, sqid, session string, peer PeerID) error { |
| + if err := h.memberStandingGone(ctx, sqid, session, peer, true); err != nil { |
| + return err |
| + } |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + p, ok := h.findLocked(sqid, session, peer) |
| + if !ok { |
| + return ErrUnknownPeer |
| + } |
| + if !p.Admitted { |
| + return ErrNotAdmitted |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +(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`.) |
| + |
| +- [ ] **Step 3: Write the failing HTTP tests** (`internal/meettest/ice_test.go`) |
| + |
| +```go |
| +package meettest |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "fmt" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/meet/internal/meet/relay" |
| +) |
| + |
| +const vectorKey = "uMhwW4P/9O5dQUIs91t2CCHGhwJbeiM0xQMQ2G0CJLU=" |
| + |
| +func relayContract(t *testing.T) string { |
| + t.Helper() |
| + b, _ := json.Marshal([]map[string]any{{ |
| + "region": "eu-west-1", |
| + "urls": []string{"stun:relay.eu-west-1.oncarlos.com:3478", "turn:relay.eu-west-1.oncarlos.com:3478?transport=udp"}, |
| + "key": vectorKey, "username_suffix": "abc:meet:0123456789abcdef:k1", |
| + }}) |
| + return base64.StdEncoding.EncodeToString(b) |
| +} |
| + |
| +type iceAnswer struct { |
| + ICEServers []relay.ICEServer `json:"iceServers"` |
| + ExpiresAt int64 `json:"expiresAt"` |
| + RenewAt int64 `json:"renewAt"` |
| +} |
| + |
| +func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + if err := x.h.SetRelay(relayContract(t)); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + c.signal(sqid, map[string]any{"peer": peer, "type": "join"}) |
| + s.nextOf("roster", 2*time.Second) |
| + |
| + rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) // any bounded GET helper; the Accept header is harmless |
| + if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatalf("ice: %d %q", rec.Code, rec.Header().Get("Cache-Control")) |
| + } |
| + var ans iceAnswer |
| + if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(ans.ICEServers) != 2 || ans.ICEServers[0].Username != "" || ans.ICEServers[1].Username == "" { |
| + t.Fatalf("servers %+v", ans.ICEServers) |
| + } |
| + now := x.now() |
| + if ans.ExpiresAt < now.Add(3*time.Hour-time.Second).Unix() || ans.ExpiresAt > now.Add(3*time.Hour+time.Second).Unix() { |
| + t.Fatalf("expiry %d is not three hours ahead of %d", ans.ExpiresAt, now.Unix()) |
| + } |
| + if ans.RenewAt != now.Add(150*time.Minute).Unix() { |
| + t.Fatalf("renewAt %d", ans.RenewAt) |
| + } |
| + // The credential verifies against the vector key for the username the server chose. |
| + key, _ := base64.StdEncoding.DecodeString(vectorKey) |
| + turn := ans.ICEServers[1] |
| + if relay.Credential(key, turn.Username) != turn.Credential { |
| + t.Fatal("credential does not verify") |
| + } |
| + if want := fmt.Sprintf("%d:abc:meet:0123456789abcdef:k1", ans.ExpiresAt); turn.Username != want { |
| + t.Fatalf("username %q, want %q", turn.Username, want) |
| + } |
| +} |
| + |
| +func TestIceIs404ForAnythingButAnAdmittedParticipationOfThisSession(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + x.h.SetRelay(relayContract(t)) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + unjoined := c.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + if unjoined.Code != http.StatusNotFound { |
| + t.Fatalf("unjoined: %d", unjoined.Code) |
| + } |
| + other := x.browser(t) |
| + other.signIn("paul@rslw.com") |
| + foreign := other.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + if foreign.Code != http.StatusNotFound || !bytes.Equal(foreign.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatalf("another session must get the identical 404: %d %s vs %s", foreign.Code, foreign.Body.Bytes(), unjoined.Body.Bytes()) |
| + } |
| + bad := c.getStream("/m/" + sqid + "/ice?peer=zz") |
| + if bad.Code != http.StatusNotFound || !bytes.Equal(bad.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatal("a malformed peer is the same 404") |
| + } |
| + room := c.getStream("/m/2222222222/ice?peer=" + peer) |
| + if room.Code != http.StatusNotFound || !bytes.Equal(room.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatal("an unknown room is the same 404") |
| + } |
| + // A waiting guest: sign a guest in through the link and join; 404. |
| + 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 |
| + waiting := g.getStream("/m/" + sqid + "/ice?peer=" + gpeer) |
| + if waiting.Code != http.StatusNotFound || !bytes.Equal(waiting.Body.Bytes(), unjoined.Body.Bytes()) { |
| + t.Fatal("a waiting guest is the same 404") |
| + } |
| +} |
| + |
| +func TestIceWithNoRelayIsAnEmptyList(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + c.signal(sqid, map[string]any{"peer": peer, "type": "join"}) |
| + s.nextOf("roster", 2*time.Second) |
| + rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer) |
| + if rec.Code != http.StatusOK || rec.Body.String() != "{\"expiresAt\":0,\"iceServers\":[],\"renewAt\":0}\n" { |
| + t.Fatalf("%d %q", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +func TestIceTestTTLHeaderIsHonouredOnlyInDevelopment(t *testing.T) { |
| + // Same shape as stream_test.go's X-Meet-Test-TTL test: development |
| + // harness → expiresAt ≈ now+2s and renewAt < expiresAt; production |
| + // harness (newHarnessWithEnv or the existing production fixture) → |
| + // three hours regardless of the header. |
| +} |
| + |
| +func TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal(t *testing.T) { |
| + // newHarnessWithLogger (signal_test.go): one success, one refusal; |
| + // the buffer contains no "uMhwW4P", no ":abc:meet:", no credential, |
| + // and the success produced no line at all mentioning "ice". |
| +} |
| +``` |
| + |
| +Fill the two sketched tests with the file's neighbouring patterns (they are named in the comments); they must assert what their names say. |
| + |
| +- [ ] **Step 4: Implement the handler** (`internal/meet/ice.go`) |
| + |
| +```go |
| +package meet |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/oficina/meet/internal/meet/hub" |
| + "amadan.net/oficina/meet/internal/meet/relay" |
| +) |
| + |
| +const ( |
| + iceLife = 3 * time.Hour // §5.2: inside the contract's four-hour maximum |
| + iceRenewAfter = 150 * time.Minute // §5.3 step 4 |
| + testIceHeader = "X-Meet-Test-Ice-TTL" // development only, like X-Meet-Test-TTL |
| + minIceLife = 2 * time.Second |
| +) |
| + |
| +// ice answers GET /m/{sqid}/ice?peer= with the RTCIceServer list for one |
| +// call (§5.2). Authority is the §2.5 last row — an admitted participation |
| +// bound to this session — through hub.Authorize, which also applies the |
| +// member re-derivation of §15.12. Every refusal is the same 404 as |
| +// /signal's, so nothing about another session's participation is |
| +// observable here. Nothing is logged on success: the answer carries a |
| +// credential. |
| +func (a *app) ice(w http.ResponseWriter, r *http.Request) { |
| + sqid := chi.URLParam(r, "sqid") |
| + session, ok := a.sessionKey(r) |
| + if !ok { |
| + a.logger.Error("meet: ice with no session subject", "path", r.URL.Path) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| + return |
| + } |
| + peer := r.URL.Query().Get("peer") |
| + if !validSqid(sqid) || !validPeerID(peer) { |
| + a.logger.Info("meet: ice refused", "room", "invalid", "peer", "", "outcome", "shape") |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + } |
| + if ended, err := a.store.meetingEnded(sqid); err != nil || ended { // reuse whatever stream.go calls to read ended_at before attaching; match its name |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + } |
| + switch err := a.hub.Authorize(r.Context(), sqid, session, hub.PeerID(peer)); { |
| + case err == nil: |
| + case errors.Is(err, hub.ErrUnknownPeer), errors.Is(err, hub.ErrNotAdmitted), errors.Is(err, hub.ErrNotYours): |
| + a.logger.Info("meet: ice refused", "room", sqid, "peer", peer, "outcome", "authority") |
| + streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| + return |
| + default: |
| + a.logger.Error("meet: ice: authorize", "room", sqid, "peer", peer, "outcome", "internal", "err", err) |
| + streamJSON(w, http.StatusInternalServerError, nil) |
| + return |
| + } |
| + now := a.store.now() |
| + life, renew := iceLife, iceRenewAfter |
| + if a.cfg.development() { |
| + if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil && d > 0 { |
| + life = max(d, minIceLife) |
| + renew = life * 5 / 6 |
| + } |
| + } |
| + regions := a.relayRegions() |
| + if len(regions) == 0 { |
| + streamJSON(w, http.StatusOK, map[string]any{"iceServers": []relay.ICEServer{}, "expiresAt": 0, "renewAt": 0}) |
| + return |
| + } |
| + expiry := now.Add(life) |
| + streamJSON(w, http.StatusOK, map[string]any{ |
| + "iceServers": relay.Mint(regions, expiry), |
| + "expiresAt": expiry.Unix(), |
| + "renewAt": now.Add(renew).Unix(), |
| + }) |
| +} |
| +``` |
| + |
| +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). |
| + |
| +Update `internal/meettest/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). |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +Run: `GOCACHE=/home/paulca/.cache/tmp/claude-1001/gocache CGO_ENABLED=0 make ci` and `CGO_ENABLED=1 go test -race ./internal/...` |
| + |
| +```bash |
| +git add internal/meet/hub/authorize.go internal/meet/hub/authorize_test.go internal/meet/ice.go internal/meet/app.go internal/meettest/ice_test.go internal/meettest/stream_test.go |
| +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 |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| +Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: `room.js`: the ICE list and renewal (§5.3) |
| + |
| +**Files:** |
| +- Modify: `internal/meet/static/room.js` (`createPC`, `restartIce`, `state()`, a new renewal module: `fetchIce`, `scheduleRenewal`, `renewAll`, `renewOne`, `proveRenewed`, `iceWarning`), `internal/meet/templates/room.html` (`data-relay="1"` on `#room` when the relay is configured; the template already receives `Floor`, so pass `Relay: a.relayConfigured()`), `internal/meet/static/meet.css` (the warning and the expired tile note), `docs/superpowers/copy/2026-09-10-slice-2-strings.md` (a "Slice 3" section) |
| + |
| +**Interfaces (the client contract):** |
| +- 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). |
| +- `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). |
| +- 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()`. |
| +- `restartIce(id)` (the existing Retry) also renews if `ice.servers` is non-empty and older than `renewAt`. |
| +- `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. |
| +- Nothing about the servers is logged; `console` carries no credential. |
| + |
| +- [ ] **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`**. |
| + |
| +- [ ] **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`. |
| + |
| +- [ ] **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. |
| + |
| +- [ ] **Step 4: Gate** — `make ci`; `make browser` with the sandbox off must stay 23/23 (the new scenarios are Task 5's). |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/meet/static/room.js internal/meet/static/meet.css internal/meet/templates/room.html internal/meet/handlers_room.go docs/superpowers/copy/2026-09-10-slice-2-strings.md |
| +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 |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| +Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: The browser gate: `ice`, `icerenewal`, `icefail` |
| + |
| +**Files:** |
| +- Modify: `test/browser/call.mjs`, `internal/meettest/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) |
| + |
| +**Interfaces:** |
| +- Consumes: `state().ice` and `state().renewals` from Task 4; the rig's route helpers; `X-Meet-Test-Ice-TTL`. |
| +- Scenarios (§13, slice 3): |
| + - `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. |
| + - `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. |
| + - `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. |
| +- No scenario touches a real relay (§13's last sentence). |
| + |
| +- [ ] **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. |
| + |
| +- [ ] **Step 2: Run `make browser`** with the sandbox off; every scenario passes; record the run's tail in the report. |
| + |
| +- [ ] **Step 3: Update the Makefile comment's scenario pointer if it enumerates; update AGENTS.md's gate line if the runtime changed materially.** |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add test/browser/call.mjs internal/meettest/browser_test.go |
| +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 |
| + |
| +Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
| +Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Record and spec note |
| + |
| +**Files:** |
| +- 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). |
| + |
| +- [ ] **Step 1: Write §15.13 and the record.** |
| +- [ ] **Step 2: `make ci`; commit `docs: slice 3 record and spec note`.** |
| + |
| +--- |
| + |
| +## Self-review |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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. |