oficina / meet Public
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/calls/relay parses the contract and mints credentials (the platform's test vector is its oracle). config.go parses CARLOS_RELAY at startup and refuses a malformed value outside development. A new endpoint /m/{sqid}/ice hands an admitted participation the RTCIceServer list with a three-hour expiry; the hub authorizes it exactly as it authorizes a relay message (spec §2.5 last row, §15.12). room.js fetches the list before it builds a connection, renews per connection at the server-named time with an ICE restart proven by a new usernameFragment on the selected candidate pair, and shows the warning and the "relay expired" states. The Playwright gate proves the fetch, the renewal under a development-only shortened schedule, and the failure path against a fake /ice; nothing touches a real relay until the canary.
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=0throughout;make ci(vet, gofmt, tests, migration check) is the gate on every task;make race(CGO_ENABLED=1) andmake 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_RELAYis 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).
/icelogs nothing on success. - A 404 is never a 403. The
/ice404 body is byte-identical to/signal's not-found body ({"boot":…,"error":"not-found"}). - Wire contracts are client-neutral (ruling 15):
/iceis plain JSON a native client can consume. - Expiry is three hours ahead; the contract's maximum is four.
renewAtis 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_modulesorpackage-lock.json; trailersCo-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>andClaude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2.
---
File structure
internal/calls/relay/relay.go—Parse,Region,ICEServer,Mint; pure, no logging, no clock of its own.internal/calls/relay/relay_test.go— the platform vector, parse refusals, scheme handling.internal/calls/config.go—Config.Relay string(raw),resolved.relay []relay.Region; startup refusal.cmd/calls/main.go— passesRelay: os.Getenv("CARLOS_RELAY").internal/calls/hub/authorize.go—Authorize(ctx, sqid, session, peer): the §2.5 last-row check with §15.12's member re-derivation, shared by/ice.internal/calls/ice.go—GET /m/{sqid}/ice.internal/callstest/ice_test.go— the endpoint over HTTP.internal/calls/static/room.js— ICE list, renewal state machine, warnings,state().ice.internal/calls/templates/room.html—data-relayon#room.test/browser/call.mjs,internal/callstest/browser_test.go— scenariosice,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/calls/relay/relay.go,internal/calls/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 []stringjson:"urls"; Username stringjson:"username,omitempty"; Credential stringjson:"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
Parsenever contains the input (it names the position and the field, not the value).
- [ ] Step 1: Write the failing tests
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/calls/relay/ Expected: build failure, no package.
- [ ] Step 3: Implement
// 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/calls/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
git add internal/calls/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/calls/config.go(theConfigstruct,resolved,resolve),cmd/calls/main.go:60,internal/calls/app.go(whereverRelayConfiguredis read for theFloorvalue; keep the name, derive it) - Test:
internal/calls/config_test.go(exists; add cases)
Interfaces:
- Consumes:
relay.Parse. - Produces:
Config.Relay string(raw$CARLOS_RELAY;RelayConfiguredstays as a field for tests butresolveoverwrites it fromRelay);resolved.relay []relay.Region;func (a *app) relayRegions() []relay.Regionfor Task 3;TestHandle.SetRelay(raw string) errorfor 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/calls/config_test.go; follow the file's existingresolvetest shape for a minimal Config in development with a logged mailer)
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/calls/ -run Relay Expected: compile errors (cfg.Relay, out.relay, out.relayConfigured undefined).
- [ ] Step 3: Implement
In internal/calls/config.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 boolIn resolved:
relay []relay.Region
relayConfigured boolIn resolve, after the allowlist is resolved:
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("calls: CARLOS_RELAY is malformed and is ignored in development", "err", err.Error())
default:
return out, fmt.Errorf("calls: CARLOS_RELAY is present but malformed (%v); a relay the app cannot read is a relay it must not pretend to have", err)
}(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:
// 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:
// 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/calls/main.go replace the RelayConfigured: line with Relay: os.Getenv("CARLOS_RELAY"),. Log one INFO line at startup when a relay is present: logger.Info("calls: relay configured", "regions", len(regions)) — the count only — inside build, not resolve.
- [ ] 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
git add internal/calls/config.go internal/calls/config_test.go internal/calls/app.go internal/calls/handlers_join.go internal/calls/handlers_room.go cmd/calls/main.go
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/calls/hub/authorize.go,internal/calls/ice.go - Modify:
internal/calls/app.go(route inside therequirePersongroup, beside/streamand/signal) - Test:
internal/calls/hub/authorize_test.go,internal/callstest/ice_test.go; updateinternal/callstest/stream_test.go:557(the/icetripwire 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(), hubfindLocked,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};404witha.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 headerX-Meet-Test-Ice-TTL: <duration>(honoured only whena.cfg.development(), likeX-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
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/calls/hub/authorize.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/callstest/ice_test.go)
package callstest
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"amadan.net/oficina/meet/internal/calls/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/calls/ice.go)
package meet
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"amadan.net/oficina/meet/internal/calls/hub"
"amadan.net/oficina/meet/internal/calls/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("calls: 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("calls: 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("calls: ice refused", "room", sqid, "peer", peer, "outcome", "authority")
streamJSON(w, http.StatusNotFound, a.notFoundBody())
return
default:
a.logger.Error("calls: 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/callstest/stream_test.go:557 (Task 10's tripwire that asserted /ice is 404 for an admitted guest past expiry): it must now assert 200 with servers for the admitted guest (ruling 14: an admitted guest continues, and /ice never consults standing for a guest).
- [ ] 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/...
git add internal/calls/hub/authorize.go internal/calls/hub/authorize_test.go internal/calls/ice.go internal/calls/app.go internal/callstest/ice_test.go internal/callstest/stream_test.go
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/calls/static/room.js(createPC,restartIce,state(), a new renewal module:fetchIce,scheduleRenewal,renewAll,renewOne,proveRenewed,iceWarning),internal/calls/templates/room.html(data-relay="1"on#roomwhen the relay is configured; the template already receivesFloor, so passRelay: a.relayConfigured()),internal/calls/static/calls.css(the warning and the expired tile note),docs/superpowers/copy/2026-09-10-slice-2-strings.md(a "Slice 3" section)
Interfaces (the client contract):
- On becoming admitted (the first
roster), before the firstcreatePC,await fetchIce():GET /m/{sqid}/ice?peer=<state.peer>withcredentials: 'same-origin', no special Accept. Answer{iceServers, expiresAt, renewAt}(unix seconds). Storeice = {servers, expiresAt, renewAt, fetchedAt, failedSince}. On a non-200 or a network error: keep the previous list (or[]on the first call), setice.failedSince, show the warning sentence in#status's neighbour#ice-warning(arole="status"<p>the script creates once), and retry every minute (ICE_RETRY = 60000; the gate shortens it throughX-Meet-Test-Ice-TTL's effect onrenewAt, 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 existingrecoverFromNotFound-style probe ladder (reuse the probe-first recovery so/ice's 404 is handled like/signal's). createPCbuildsnew RTCPeerConnection({ iceServers: ice.servers }). An empty list is the floor (unchanged behaviour). Withdata-relaypresent, 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 fromfetchedAt; onvisibilitychangeto visible, compare the clock withrenewAtand run at once if overdue),renewAll():await fetchIce(); on failure retry each minute and keep the oldexpiresAtas the must-succeed-by; on success, for every live pc:renewOne(id): recordgeneration = { 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, thenproveRenewed(id)pollsgetStats()every 500 ms for up to 30 s untilsignalingState === 'stable'AND acandidate-pairwithstate === 'succeeded'andnominated/selected whoselocalCandidateIdresolves to alocal-candidatewithusernameFragmentdifferent fromoldUfrag(whenoldUfragis 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 whennow > mustSucceedByfor a connection not yet renewed: markentry.relayExpired = true, the tile shows "The relay credential expired for this connection." with a Retry control that runsrenewOneafter a freshfetchIce(). restartIce(id)(the existing Retry) also renews ifice.serversis non-empty and older thanrenewAt.state()gainsice: { fetches, lastFetchAt, expiresAt, renewAt, warning: <bool>, servers: ice.servers.length }and per-peerrenewals: {peer: {attempts, renewed: bool, ufrag}}for the gate.- Nothing about the servers is logged;
consolecarries no credential.
- [ ] Step 1: Read §5.2, §5.3 and §5.5, then the current
room.jsaroundcreatePC(line ~751),restartIce(~1607),recoverFromNotFoundandstate()(~1791), andtask-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 browserwith the sandbox off must stay 23/23 (the new scenarios are Task 5's).
- [ ] Step 5: Commit
git add internal/calls/static/room.js internal/calls/static/calls.css internal/calls/templates/room.html internal/calls/handlers_room.go docs/superpowers/copy/2026-09-10-slice-2-strings.md
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/callstest/browser_test.go(the Go rig sets the relay contract on the app throughTestHandle.SetRelaybefore theice*scenarios; a control commandrelay on/relay offif the rig's per-scenario restart needs it)
Interfaces:
- Consumes:
state().iceandstate().renewalsfrom 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; eachstate().ice.servers === 2after 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; assertsignaling stable,connection connected. Also assert, through a route on**/ice*, that the response body carries aturn:entry with a username of five colon-separated fields and no credential appears in any console line. -icerenewal: route rewrites/icerequests to addX-Meet-Test-Ice-TTL: 12s(renewAt ≈ 10 s); after the pair connects, within 25 s assertstate().ice.fetches >= 2, and for the other peerrenewals[peer].renewed === truewith aufragdifferent from the one recorded after the first connection (readgetStatsin 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/icewith 503 from the second fetch on; assert the warning sentence appears within 15 s ofrenewAt, 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,stateflattening, per-scenario restart), each printing one line per assertion.
- [ ] Step 2: Run
make browserwith 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
git add test/browser/call.mjs internal/callstest/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/icewire shape{iceServers, expiresAt, renewAt}, the?peer=query, the development-only header, and that/icefor a member re-derives standing like/signal),docs/superpowers/reviews/2026-09-10-slice-2-record.md→ createdocs/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; commitdocs: 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.