oficina / meet Public

plan: slice 3, the platform relay

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 0690a7abb1c16c7fe32c205784fd8de266f55067 parent 2215f26
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.