oficina / meet Public

ice: GET /m/{sqid}/ice mints the RTCIceServer list for an admitted participation

hub.Authorize is the §2.5 last-row check: a participation bound to
this session, in this room, admitted. A member re-derives standing
first (§15.12) exactly as Relay does; a guest never consults it
(ruling 14). GET /m/{sqid}/ice, routed beside /stream and /signal,
answers with relay.Mint's per-call TURN credentials, 404 with the
identical body /signal uses for every refusal, and honours
X-Meet-Test-Ice-TTL only in development.

Deviations from the task brief's sketch:
- memberStandingGone returns (bool, error), not a bare error; Authorize
  mirrors Relay's own gone/err handling rather than the brief's
  single-return sketch.
- The hub test's waiting-guest case opens Gus with personID 100, not 2:
  testHub's own StandingFor treats personID < 100 as a member, so 2
  would have been admitted outright and proved nothing.
- meettest's client.signal takes (sqid, peer, body), not a body map; the
  brief's iceContract-adjacent snippets used the map-shaped call form
  that does not exist. Likewise x.h (the mux) has no SetRelay — that
  lives on x.ctl (*meet.TestHandle) — so every SetRelay call in the new
  tests goes through x.ctl.
- Added newHarnessWithEnv and guestJoinedAndWaiting as local helpers in
  ice_test.go (isolation_test.go's steps were still inline; nothing
  needed lifting from there, so nothing else was touched).
- The development-header test uses a 12s/10s TTL rather than 2s: at 2s,
  life*5/6's fractional remainder can round to the same whole second as
  life itself once truncated by Unix(), intermittently tying
  expiresAt and renewAt.
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 be5a4ce266805a224b9a89ff88c9f9d2138b55c6 parent 2f4344b
6 files changed, +557 −7
  • internal/meet/app.go +1 −0
  • internal/meet/hub/authorize.go +29 −0
  • internal/meet/hub/authorize_test.go +96 −0
  • internal/meet/ice.go +104 −0
  • internal/meettest/ice_test.go +308 −0
  • internal/meettest/stream_test.go +19 −7

CI passed — run details

diff --git a/internal/meet/app.go b/internal/meet/app.go
index ae01879..8b761e2 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -421,6 +421,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
})
r.Get("/m/{sqid}/stream", a.stream)
r.Post("/m/{sqid}/signal", a.signal)
+ r.Get("/m/{sqid}/ice", a.ice)
})
mux := http.NewServeMux()
diff --git a/internal/meet/hub/authorize.go b/internal/meet/hub/authorize.go
new file mode 100644
index 0000000..fafec08
--- /dev/null
+++ b/internal/meet/hub/authorize.go
@@ -0,0 +1,29 @@
+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 {
+ gone, err := h.memberStandingGone(ctx, sqid, session, peer, true)
+ if err != nil {
+ return err
+ }
+ if gone {
+ return ErrNotYours
+ }
+ 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
+}
diff --git a/internal/meet/hub/authorize_test.go b/internal/meet/hub/authorize_test.go
new file mode 100644
index 0000000..d3bfd86
--- /dev/null
+++ b/internal/meet/hub/authorize_test.go
@@ -0,0 +1,96 @@
+package hub
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "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)
+ }
+ // personID 100 is testHub's own guest threshold (personID < 100 is a
+ // member, as TestMemberJoinsAndGuestWaits in admission_test.go
+ // already relies on) — a personID of 2 here would derive as a
+ // member and be admitted outright, proving nothing about a waiting
+ // guest.
+ g, _ := h.Open("r1", "sg", 100, "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)
+ }
+}
+
+// TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest is
+// §15.12 through Authorize, mirroring admission_test.go's
+// TestJoinDropsAndAnnouncesAMemberWhoLostStanding: standing is a
+// mutable map keyed on personID, moved without a tick running, so the
+// only thing that could have refused the member is Authorize's own
+// re-derivation. Bob is admitted as a guest (personID 100, testHub's
+// own guest threshold) before his grant "expires" — ruling 14 says
+// Authorize never even asks for a guest.
+func TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) {
+ now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC)
+ standing := map[int64]bool{1: true, 100: true}
+ h := New(Config{
+ Now: func() time.Time { return now },
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) {
+ if id == 100 {
+ return KindGuest, standing[id], nil
+ }
+ return KindMember, standing[id], nil
+ },
+ })
+ ctx := context.Background()
+ a, _ := h.Open("r1", "sa", 1, "Ann", KindMember)
+ if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil {
+ t.Fatal(err)
+ }
+ b, _ := h.Open("r1", "sb", 100, "Bob", KindGuest)
+ if err := h.Join(ctx, "r1", "sb", b.Peer); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Admit(ctx, "r1", "sa", a.Peer, b.Peer); err != nil {
+ t.Fatal(err)
+ }
+ drain(a.Sub)
+ drain(b.Sub)
+
+ standing[1] = false // Ann taken off the team, no tick has run
+ if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotYours) {
+ t.Fatalf("member whose standing is gone: want ErrNotYours, got %v", err)
+ }
+ if _, found := h.Find("r1", "sa", a.Peer); found {
+ t.Fatal("Ann's participation should have ended")
+ }
+ bev := drain(b.Sub)
+ if len(bev) == 0 || bev[len(bev)-1].Type != TypeGone || bev[len(bev)-1].From != a.Peer {
+ t.Fatalf("Bob's stream got %v, want gone naming Ann last", types(bev))
+ }
+
+ standing[100] = false // Bob's grant has "expired"; Authorize never asks for a guest
+ if err := h.Authorize(ctx, "r1", "sb", b.Peer); err != nil {
+ t.Fatalf("admitted guest with a lapsed grant: %v", err)
+ }
+}
diff --git a/internal/meet/ice.go b/internal/meet/ice.go
new file mode 100644
index 0000000..4d62547
--- /dev/null
+++ b/internal/meet/ice.go
@@ -0,0 +1,104 @@
+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 and iceRenewAfter are §5.2's numbers: a call's credentials
+ // are minted for three hours, inside the platform contract's
+ // four-hour maximum, and the browser is told to renew at 150
+ // minutes (§5.3 step 4) — well ahead of expiry, so a slow renewal
+ // never runs out mid-call.
+ iceLife = 3 * time.Hour
+ iceRenewAfter = 150 * time.Minute
+ // testIceHeader shortens one answer's life, and only in development
+ // (like stream.go's testTTLHeader): a plain GET can set it, unlike
+ // EventSource, so this one needs no browser gate to reach — the
+ // development gate is what keeps it off production.
+ testIceHeader = "X-Meet-Test-Ice-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
+ }
+ if !validSqid(sqid) {
+ a.logger.Info("meet: ice refused", "room", "invalid", "peer", "", "outcome", "shape")
+ streamJSON(w, http.StatusNotFound, a.notFoundBody())
+ return
+ }
+ peer := r.URL.Query().Get("peer")
+ if !validPeerID(hub.PeerID(peer)) {
+ // peer is never logged here, exactly like signal.go's refusal:
+ // it has not been validated as bounded, printable text (review
+ // Important 2's rule, carried over).
+ a.logger.Info("meet: ice refused", "room", sqid, "peer", "", "outcome", "shape")
+ streamJSON(w, http.StatusNotFound, a.notFoundBody())
+ return
+ }
+ // The meeting itself must be live: a fresh process holds no hub
+ // state for a room nobody has opened, so ended_at is the only
+ // authority it has — stream.go's own fresh-open precedent.
+ m, found, err := a.store.meetingBySqid(r.Context(), sqid)
+ if err != nil {
+ a.logger.Error("meet: ice: load meeting", "err", err)
+ streamJSON(w, http.StatusInternalServerError, nil)
+ return
+ }
+ if !found || m.EndedAt != nil {
+ 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(),
+ })
+}
diff --git a/internal/meettest/ice_test.go b/internal/meettest/ice_test.go
new file mode 100644
index 0000000..586f4cb
--- /dev/null
+++ b/internal/meettest/ice_test.go
@@ -0,0 +1,308 @@
+package meettest
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/db"
+
+ meet "amadan.net/oficina/meet/internal/meet"
+ "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"`
+}
+
+// guestJoinedAndWaiting signs a fresh outsider in through tok, opens its
+// stream and posts join without anyone admitting it — the waiting shape
+// isolation_test.go's openIsolationRoom also builds inline, lifted here
+// for a single guest.
+func guestJoinedAndWaiting(t *testing.T, x *harness, sqid, tok string) (*client, string) {
+ t.Helper()
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("waiting-guest@example.com")
+ s := g.stream("/m/" + sqid + "/stream")
+ t.Cleanup(s.close)
+ peer, _ := s.hello()
+ if resp, b := g.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("guest join (the knock): %d %s", resp.StatusCode, b)
+ }
+ return g, peer
+}
+
+// newHarnessWithEnv is newHarnessWithLogger with the environment as a
+// parameter, so a test can build the production harness that proves
+// the development-only ICE TTL header is inert outside development.
+// Production needs the full set of startup secrets
+// TestProductionRefusesToStartWithoutSecrets exercises — Origin,
+// InstanceKey, DEK, Mailer and a non-empty allowlist — so this fills
+// those in rather than merely swapping Env.
+func newHarnessWithEnv(t *testing.T, env string, members ...string) *harness {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ dbPath := filepath.Join(t.TempDir(), "app.db")
+ d, err := db.Open(dbPath, logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ rec := &recordingMailer{}
+ cfg := meet.Config{
+ Env: env, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger,
+ }
+ if env != meet.EnvDevelopment {
+ cfg.InstanceKey = "test-instance-key-not-a-real-secret"
+ cfg.DEK = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+ }
+ mux, ctl, err := meet.NewForTest(d, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, ctl: ctl, now: time.Now().UTC(), t: t}
+ x.advance(0)
+ return x
+}
+
+func TestIceHandsAnAdmittedParticipationCredentialsThatVerify(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ if err := x.ctl.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()
+ if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("member join: %d %s", resp.StatusCode, b)
+ }
+ 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, tok := memberWithMeeting(t)
+ if err := x.ctl.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()
+ 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, tok)
+ 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()
+ if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("member join: %d %s", resp.StatusCode, b)
+ }
+ 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())
+ }
+}
+
+// TestIceTestTTLHeaderIsHonouredOnlyInDevelopment is stream_test.go's
+// TestTheTestTTLHeaderIsDevelopmentOnly over HTTP: EventSource cannot
+// set a header, but a plain GET can, so this one needs no browser gate
+// to reach the handler — only the development check does, and this
+// test is what proves it.
+func TestIceTestTTLHeaderIsHonouredOnlyInDevelopment(t *testing.T) {
+ withHeader := func(c *client, target, ttl string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ req.Header.Set("X-Meet-Test-Ice-TTL", ttl)
+ return c.do(req)
+ }
+
+ t.Run("development", func(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ if err := x.ctl.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, peer, `{"type":"join"}`)
+
+ // Twelve seconds, not two: expiresAt and renewAt are both
+ // truncated to whole seconds by Unix(), and life*5/6's fractional
+ // remainder can tie the two together at a short duration. Twelve
+ // gives a two-second gap (12s vs 10s) that survives the rounding.
+ rec := withHeader(c, "/m/"+sqid+"/ice?peer="+peer, "12s")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("development with header: %d %s", rec.Code, rec.Body.String())
+ }
+ var ans iceAnswer
+ if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
+ t.Fatal(err)
+ }
+ now := x.now
+ if ans.ExpiresAt < now.Unix() || ans.ExpiresAt > now.Add(15*time.Second).Unix() {
+ t.Fatalf("expiresAt %d is not ~12s ahead of %d", ans.ExpiresAt, now.Unix())
+ }
+ if ans.RenewAt >= ans.ExpiresAt {
+ t.Fatalf("renewAt %d is not before expiresAt %d", ans.RenewAt, ans.ExpiresAt)
+ }
+ })
+
+ t.Run("production", func(t *testing.T) {
+ x := newHarnessWithEnv(t, "production", "paul@example.com")
+ if err := x.ctl.SetRelay(relayContract(t)); err != nil {
+ t.Fatal(err)
+ }
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ sqid, _ := createMeeting(t, c, "Board meeting", "")
+ s := c.stream("/m/" + sqid + "/stream")
+ defer s.close()
+ peer, _ := s.hello()
+ c.signal(sqid, peer, `{"type":"join"}`)
+
+ rec := withHeader(c, "/m/"+sqid+"/ice?peer="+peer, "12s")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("production with header: %d %s", rec.Code, rec.Body.String())
+ }
+ var ans iceAnswer
+ if err := json.Unmarshal(rec.Body.Bytes(), &ans); err != nil {
+ t.Fatal(err)
+ }
+ 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("production must ignore the header: expiresAt %d, want ~3h ahead of %d", ans.ExpiresAt, now.Unix())
+ }
+ })
+}
+
+// TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal is
+// signal_test.go's TestSignallingLogsCarryNoContent for this endpoint:
+// nothing at all is logged for a successful call (the answer carries a
+// credential), and a refusal's log line never carries the relay key,
+// the minted username or its credential.
+func TestIceLogsNothingOnSuccessAndNoCredentialOnRefusal(t *testing.T) {
+ var buf bytes.Buffer
+ x := newHarnessWithLogger(t, slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), "paul@example.com")
+ if err := x.ctl.SetRelay(relayContract(t)); err != nil {
+ t.Fatal(err)
+ }
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ sqid, _ := createMeeting(t, c, "Board meeting", "")
+ s := c.stream("/m/" + sqid + "/stream")
+ defer s.close()
+ peer, _ := s.hello()
+ if resp, b := c.signal(sqid, peer, `{"type":"join"}`); resp.StatusCode != http.StatusOK {
+ t.Fatalf("member join: %d %s", resp.StatusCode, b)
+ }
+
+ before := buf.Len()
+ if rec := c.getStream("/m/" + sqid + "/ice?peer=" + peer); rec.Code != http.StatusOK {
+ t.Fatalf("ice: %d %s", rec.Code, rec.Body.String())
+ }
+ if success := buf.String()[before:]; strings.Contains(success, "ice") {
+ t.Fatalf("success logged something: %q", success)
+ }
+
+ if rec := c.getStream("/m/" + sqid + "/ice?peer=zz"); rec.Code != http.StatusNotFound {
+ t.Fatalf("malformed peer: %d", rec.Code)
+ }
+
+ logs := buf.String()
+ if !strings.Contains(logs, "meet: ice refused") {
+ t.Fatalf("the refusal was not logged:\n%s", logs)
+ }
+ for _, forbidden := range []string{vectorKey, ":abc:meet:"} {
+ if strings.Contains(logs, forbidden) {
+ t.Fatalf("log leaked a credential:\n%s", logs)
+ }
+ }
+}
diff --git a/internal/meettest/stream_test.go b/internal/meettest/stream_test.go
index d9123b5..8eb7eed 100644
--- a/internal/meettest/stream_test.go
+++ b/internal/meettest/stream_test.go
@@ -549,13 +549,25 @@ func TestAnAdmittedGuestCarriesOnPastGrantExpiryButGetsNoFreshEntry(t *testing.T
t.Fatalf("the resumed guest is not admitted any more (got %q)", ev)
}
- // GET /m/{sqid}/ice is slice 3's, and is not mounted yet, so it
- // answers chi's own 404 for everybody. When slice 3 mounts it, this
- // line becomes the assertion that the expired guest is served like
- // every other admitted peer — the endpoint is named in §13's "on
- // every endpoint" and this is where it lands.
- if rec := g.get("/m/" + sqid + "/ice"); rec.Code != http.StatusNotFound {
- t.Fatalf("GET /ice: %d — slice 3 has mounted it; give this test its real arm", rec.Code)
+ // GET /m/{sqid}/ice is authorized by the guest's admitted
+ // participation (hub.Authorize), never by standing — ruling 14
+ // again, on the one endpoint §13 names "on every endpoint": a
+ // lapsed grant does not cost this expired guest their ICE servers.
+ x.ctl.SetRelay(relayContract(t))
+ rec := g.get("/m/" + sqid + "/ice?peer=" + pg)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /ice past grant expiry: %d, want 200", rec.Code)
+ }
+ var ice struct {
+ ICEServers []struct {
+ URLs []string `json:"urls"`
+ } `json:"iceServers"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &ice); err != nil {
+ t.Fatal(err)
+ }
+ if len(ice.ICEServers) == 0 {
+ t.Fatal("GET /ice past grant expiry returned no servers")
}
// Fresh entry is not theirs: a stream with no peer is authorized by