package hub import ( "crypto/rand" "encoding/base32" "strings" "time" ) // Quality and problem reports (spec §15.26, 2026-10-03). // // A report is written to the app log under two random tags and nothing // that names the call or the people in it: a call tag, minted when a room // that was empty gains a participation, so every line from one call shares // it; and a peer tag, minted per participation. Neither is derived from // the sqid, the peer id, the person or the session, and neither is ever // logged beside any of them, so a log line cannot be walked back to a // meeting or a person. They live here, in memory, and die with the // process like everything else in the hub. // // A client is handed a report token in its hello. It is the client's // authority to report: for the life of the participation, and for // ReportAfterLeave after it ends, so "Report a problem" still works on the // screen that says the call is over. const ( // ReportAfterLeave is how long a report token outlives its // participation. ReportAfterLeave = 10 * time.Minute // maxReportGrants bounds the token table: every live participation // (at most sixteen to a room) plus those ended in the last ten // minutes. A real deployment holds tens; past this, the oldest ended // grant is dropped first. maxReportGrants = 4096 // maxCallPeers bounds how many participations one call instance // remembers a tag for. A remote peer past it is reported as unknown. maxCallPeers = 64 ) // callInstance is one call: a room from the moment it stopped being empty // to the moment it is empty again. type callInstance struct { tag string peers map[PeerID]string // peer id -> peer tag, for every participation this call has held } type reportGrant struct { room string session string call *callInstance peerTag string endedAt time.Time // zero while the participation lives } // Reporter is what a report token authorizes: the tags to log under, // and the tags of everyone this call has held, by peer id, so a report // that names a remote peer by its id is logged under that peer's tag. type Reporter struct { CallTag string PeerTag string Live bool Peers map[PeerID]string } // newTag is a random tag: ten bytes in lowercase base32, sixteen // characters, from crypto/rand. Not a hash of anything. func newTag() string { b := make([]byte, 10) if _, err := rand.Read(b); err != nil { panic(err) } return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)) } // enrolReportLocked gives a fresh participation its peer tag and report // token, starting a new call instance when the room held nobody. func (h *Hub) enrolReportLocked(r *room, p *Participation) { if r.call == nil || len(r.peers) == 0 { r.call = &callInstance{tag: newTag(), peers: map[PeerID]string{}} } p.peerTag = newTag() p.reportToken = randomID() if len(r.call.peers) < maxCallPeers { r.call.peers[p.Peer] = p.peerTag } if len(h.reports) >= maxReportGrants { h.evictReportLocked() } h.reports[p.reportToken] = &reportGrant{room: r.sqid, session: p.Session, call: r.call, peerTag: p.peerTag} } // endReportLocked starts a participation's after-leave window. func (h *Hub) endReportLocked(p *Participation) { if g, ok := h.reports[p.reportToken]; ok && g.endedAt.IsZero() { g.endedAt = h.cfg.Now() } } // sweepReportsLocked drops grants whose after-leave window has passed. func (h *Hub) sweepReportsLocked(now time.Time) { for token, g := range h.reports { if !g.endedAt.IsZero() && now.Sub(g.endedAt) > ReportAfterLeave { delete(h.reports, token) } } } // evictReportLocked makes room: the grant that ended longest ago, or, if // none has ended, nothing — a live participation's token is never taken // from it, and the table is then bounded by the participations // themselves. func (h *Hub) evictReportLocked() { var oldest string var at time.Time for token, g := range h.reports { if g.endedAt.IsZero() { continue } if oldest == "" || g.endedAt.Before(at) { oldest, at = token, g.endedAt } } if oldest != "" { delete(h.reports, oldest) } } // ReportTokenFor is the token of the participation this session holds in // the room under peer, for a resumed stream's hello. func (h *Hub) ReportTokenFor(sqid, session string, peer PeerID) string { h.mu.Lock() defer h.mu.Unlock() p, ok := h.findLocked(sqid, session, peer) if !ok { return "" } return p.reportToken } // Reporter answers a report token, presented by this session for this // room: the participation's while it lives, and for ReportAfterLeave // after it ends. Anything else is not found. func (h *Hub) Reporter(sqid, session, token string) (Reporter, bool) { h.mu.Lock() defer h.mu.Unlock() g, ok := h.reports[token] if !ok || g.room != sqid || g.session != session { return Reporter{}, false } if !g.endedAt.IsZero() && h.cfg.Now().Sub(g.endedAt) > ReportAfterLeave { delete(h.reports, token) return Reporter{}, false } peers := make(map[PeerID]string, len(g.call.peers)) for id, tag := range g.call.peers { peers[id] = tag } return Reporter{CallTag: g.call.tag, PeerTag: g.peerTag, Live: g.endedAt.IsZero(), Peers: peers}, true }