api: /api/v1 meetings for agents, reach re-derived per request
Deviations from the brief's file list, all recorded in the task report: /api/v1 mounts outside csrf.Protect and outside the session middlewares as its own chi group at the router root, because csrf.SameOrigin refuses a POST carrying none of Sec-Fetch-Site, Origin or Referer — which is every agent request; the existing routes moved one level in under a csrf group, unchanged otherwise. store.go gained meetingViewOf (factored out of listMeetings) so the single-meeting read answers link_present from the same query the list does, and agents.go gained agentAnyByID plus creatorLabel to render created_by as the agent's principal or the person's display name, with withOperator factored out of the three agent lookups that had the same fail-closed tail. A malformed bearer is judged on the header alone, never on the token's shape, so "Bearer nope" is the not-found body and not a 401.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
8 files changed,
+1184
−59
internal/meet/agents.go+62 −14internal/meet/api.go+415 −0internal/meet/app.go+50 −34internal/meet/hub/admission_test.go+5 −8internal/meet/hub/count.go+21 −0internal/meet/hub/count_test.go+64 −0internal/meet/store.go+17 −3internal/meettest/api_test.go+550 −0
CI passed — run details
diff --git a/internal/meet/agents.go b/internal/meet/agents.go| index 955f7b4..d1473f7 100644 |
| --- a/internal/meet/agents.go |
| +++ b/internal/meet/agents.go |
| @@ -131,20 +131,7 @@ func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (boo |
| func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) { |
| var ag Agent |
| err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error |
| - if errors.Is(err, gorm.ErrRecordNotFound) { |
| - return Agent{}, Person{}, false, nil |
| - } |
| - if err != nil { |
| - return Agent{}, Person{}, false, err |
| - } |
| - op, found, err := s.personByID(ctx, ag.OperatorPersonID) |
| - if err != nil { |
| - return Agent{}, Person{}, false, err |
| - } |
| - if !found { |
| - return Agent{}, Person{}, false, nil |
| - } |
| - return ag, op, true, nil |
| + return s.withOperator(ctx, ag, err) |
| } |
| // agentByID resolves an agent scoped to its operator (fix round 1, |
| @@ -153,6 +140,27 @@ func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, |
| func (s *store) agentByID(ctx context.Context, id int64, operator Person) (Agent, Person, bool, error) { |
| var ag Agent |
| err := s.g.WithContext(ctx).Where("id = ? AND operator_person_id = ?", id, operator.ID).Take(&ag).Error |
| + return s.withOperator(ctx, ag, err) |
| +} |
| + |
| +// agentAnyByID resolves an agent by id alone, with no operator scope, |
| +// and exists for exactly one purpose: rendering the principal of a |
| +// write an agent made (§8.1, "robot badge and operator name on every |
| +// write"). It is not an ownership check and must never stand in for |
| +// one — agentByID is that — but every member already sees every |
| +// meeting on the list, so naming which agent created one tells them |
| +// nothing the list did not. |
| +func (s *store) agentAnyByID(ctx context.Context, id int64) (Agent, Person, bool, error) { |
| + var ag Agent |
| + err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error |
| + return s.withOperator(ctx, ag, err) |
| +} |
| + |
| +// withOperator finishes every agent lookup the same way (fix round 1, |
| +// item 1): a missing agent row, a driver error, and an agent whose |
| +// operator row is gone are all one fail-closed answer, so no caller can |
| +// tell them apart and none is ever handed a bare-ID Person. |
| +func (s *store) withOperator(ctx context.Context, ag Agent, err error) (Agent, Person, bool, error) { |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| return Agent{}, Person{}, false, nil |
| } |
| @@ -168,3 +176,43 @@ func (s *store) agentByID(ctx context.Context, id int64, operator Person) (Agent |
| } |
| return ag, op, true, nil |
| } |
| + |
| +// creatorLabel renders a stored actor (§7.2) as the identity a reader |
| +// is shown: an agent's principal, <name>~<operator email>, or a |
| +// person's display name. It is a label and not an authority — nothing |
| +// is decided on it — so an actor that no longer resolves comes back as |
| +// the raw actor string rather than failing the whole list, logged the |
| +// way store.title logs an unreadable title. |
| +func (s *store) creatorLabel(ctx context.Context, actor string) string { |
| + kind, rest, found := strings.Cut(actor, ":") |
| + if !found { |
| + return actor |
| + } |
| + id, err := strconv.ParseInt(rest, 10, 64) |
| + if err != nil { |
| + return actor |
| + } |
| + switch kind { |
| + case "agent": |
| + ag, op, ok, err := s.agentAnyByID(ctx, id) |
| + if err != nil { |
| + s.logger.Error("meet: resolve creator agent", "agent_id", id, "err", err) |
| + return actor |
| + } |
| + if !ok { |
| + return actor |
| + } |
| + return principal(ag, op) |
| + case "person": |
| + p, ok, err := s.personByID(ctx, id) |
| + if err != nil { |
| + s.logger.Error("meet: resolve creator person", "person_id", id, "err", err) |
| + return actor |
| + } |
| + if !ok { |
| + return actor |
| + } |
| + return p.Name |
| + } |
| + return actor |
| +} |
diff --git a/internal/meet/api.go b/internal/meet/api.go| new file mode 100644 |
| index 0000000..ff9e897 |
| --- /dev/null |
| +++ b/internal/meet/api.go |
| @@ -0,0 +1,415 @@ |
| +package meet |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// maxAPIBody is the drive surface's request cap: 16 KB, refused before |
| +// anything is decoded. An agent posts a title and a timestamp; nothing |
| +// legitimate comes close. |
| +const maxAPIBody = 16 * 1024 |
| + |
| +// The per-agent budget (spec §8, Global Constraints): sixty requests a |
| +// minute, burst sixty — one token a second into a bucket of sixty, so |
| +// a run of sixty back to back is fine and a loop is not. maxAgentBuckets |
| +// bounds the map for the same reason maxSignalBuckets does. |
| +const ( |
| + apiRatePerSecond = 1 |
| + apiBurst = 60 |
| + maxAgentBuckets = 4096 |
| +) |
| + |
| +// agentCtxKey carries the resolved agent and its operator from |
| +// requireAgent to the handler. Nothing else reads it, and no handler |
| +// re-derives either. |
| +type agentCtxKey struct{} |
| + |
| +type agentPrincipal struct { |
| + agent Agent |
| + operator Person |
| +} |
| + |
| +func currentAgent(r *http.Request) (agentPrincipal, bool) { |
| + ap, ok := r.Context().Value(agentCtxKey{}).(agentPrincipal) |
| + return ap, ok |
| +} |
| + |
| +// apiJSON is every answer the drive surface makes. no-store because a |
| +// response can carry a link token (§8.2) and because an agent's cache |
| +// is not a place a meeting's state should live. |
| +func apiJSON(w http.ResponseWriter, status int, body any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.WriteHeader(status) |
| + if body != nil { |
| + _ = json.NewEncoder(w).Encode(body) |
| + } |
| +} |
| + |
| +// apiNotFound is the one not-found shape this surface ever answers: an |
| +// unknown token, a revoked token, a token whose operator has left the |
| +// team, and a meeting that does not exist are all these same bytes, so |
| +// nothing about which it was is observable (spec §8.1, §2.5). |
| +func apiNotFound() map[string]any { return map[string]any{"error": "not-found"} } |
| + |
| +// apiRoutes is the drive surface, mounted at the router root OUTSIDE |
| +// csrf.Protect and outside the session middlewares (see build). |
| +// |
| +// Outside csrf.Protect because rastrillo's csrf.SameOrigin refuses a |
| +// state-changing request carrying none of Sec-Fetch-Site, Origin or |
| +// Referer, and an agent — which is not a browser — sends none of the |
| +// three; a POST from one would be a 403 before it reached a handler. |
| +// That is safe precisely because this surface honours no cookie: the |
| +// attack CSRF defends against is a browser spending a session it holds |
| +// ambiently, and there is no ambient credential here to spend. The |
| +// bearer is the only thing that authenticates a request, and a page on |
| +// another origin cannot read it. |
| +// |
| +// Outside the session middlewares because RequireSession would redirect |
| +// a bearer-only request to the sign-in page and requirePerson would |
| +// never run. |
| +func (a *app) apiRoutes(r chi.Router) { |
| + r.Route("/api/v1", func(r chi.Router) { |
| + r.Use(a.requireAgent) |
| + // A path or method this surface does not have is the same |
| + // not-found body everything else answers — and it is answered |
| + // behind requireAgent, so an unauthenticated caller cannot map |
| + // the routes at all. |
| + r.NotFound(func(w http.ResponseWriter, r *http.Request) { |
| + a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "no-route") |
| + }) |
| + r.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { |
| + a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "no-route") |
| + }) |
| + r.Get("/meetings", a.apiListMeetings) |
| + r.Post("/meetings", a.apiCreateMeeting) |
| + r.Get("/meetings/{sqid}", a.apiGetMeeting) |
| + r.Post("/meetings/{sqid}/links", a.apiReplaceLink) |
| + r.Post("/meetings/{sqid}/end", a.apiEndMeeting) |
| + }) |
| +} |
| + |
| +// requireAgent is spec §2.5's `/api/v1/*` row: the bearer names an |
| +// unrevoked agent AND its operator holds member standing right now |
| +// (§8.1). Both halves are re-derived per request, so an operator |
| +// dropped from the team takes their agent's reach with them at the next |
| +// call, with no revocation step (§13's slice-5 gate). |
| +// |
| +// A cookie is never read here — not for identity, not for anything. |
| +func (a *app) requireAgent(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + token, ok := bearerToken(r.Header.Get("Authorization")) |
| + if !ok { |
| + // The header is missing or is not a bearer at all. This is |
| + // the one refusal that is not the not-found body: it says |
| + // "you presented no credential", which is true of every |
| + // caller and tells an attacker nothing they did not send. |
| + w.Header().Set("WWW-Authenticate", "Bearer") |
| + a.refuseAPI(w, r, http.StatusUnauthorized, map[string]any{"error": "unauthorized"}, "unauthorized") |
| + return |
| + } |
| + ag, operator, found, err := a.store.agentByToken(r.Context(), token) |
| + if err != nil { |
| + a.apiServerError(w, r, "resolve agent", err) |
| + return |
| + } |
| + if !found { |
| + a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "token") |
| + return |
| + } |
| + if !a.isMember(operator) { |
| + a.refuseAPI(w, r.WithContext(withAgent(r.Context(), ag, operator)), |
| + http.StatusNotFound, apiNotFound(), "reach") |
| + return |
| + } |
| + r = r.WithContext(withAgent(r.Context(), ag, operator)) |
| + if ok, retry := a.apiLimiter.allow(actorForAgent(ag), a.store.now()); !ok { |
| + w.Header().Set("Retry-After", strconv.Itoa(retryAfterSeconds(retry))) |
| + a.refuseAPI(w, r, http.StatusTooManyRequests, map[string]any{"error": "rate"}, "rate") |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
| + |
| +func withAgent(ctx context.Context, ag Agent, operator Person) context.Context { |
| + return context.WithValue(ctx, agentCtxKey{}, agentPrincipal{agent: ag, operator: operator}) |
| +} |
| + |
| +// bearerToken reads RFC 6750's Authorization: Bearer <token>. The |
| +// scheme is matched case-insensitively, as HTTP requires; the token |
| +// itself is taken exactly as sent and is not inspected for shape — |
| +// whether it looks like one of ours is not this function's question, |
| +// and answering it here would make a made-up token distinguishable |
| +// from an unknown one by status alone. |
| +func bearerToken(header string) (string, bool) { |
| + scheme, rest, found := strings.Cut(strings.TrimSpace(header), " ") |
| + if !found || !strings.EqualFold(scheme, "bearer") { |
| + return "", false |
| + } |
| + token := strings.TrimSpace(rest) |
| + return token, token != "" |
| +} |
| + |
| +// retryAfterSeconds rounds a wait up to whole seconds, never below one: |
| +// Retry-After is expressed in seconds, and a zero would invite an |
| +// immediate retry that is certain to be refused again. |
| +func retryAfterSeconds(d time.Duration) int { |
| + s := int((d + time.Second - 1) / time.Second) |
| + if s < 1 { |
| + return 1 |
| + } |
| + return s |
| +} |
| + |
| +// refuseAPI is every refusal this surface makes: one log line carrying |
| +// the agent (when the bearer resolved to one) and the outcome, and |
| +// nothing else — never the token, never its hash, never a link token, |
| +// never a title (spec §0.1, §8.1). Nothing at all is logged on success. |
| +func (a *app) refuseAPI(w http.ResponseWriter, r *http.Request, status int, body map[string]any, outcome string) { |
| + if ap, ok := currentAgent(r); ok { |
| + a.logger.Warn("meet: api refused", "agent_id", ap.agent.ID, "outcome", outcome) |
| + } else { |
| + a.logger.Warn("meet: api refused", "outcome", outcome) |
| + } |
| + apiJSON(w, status, body) |
| +} |
| + |
| +func (a *app) apiServerError(w http.ResponseWriter, r *http.Request, what string, err error) { |
| + a.logger.Error("meet: api: "+what, "err", err) |
| + apiJSON(w, http.StatusInternalServerError, map[string]any{"error": "server"}) |
| +} |
| + |
| +// apiMeetingBody is one meeting on the wire (spec §8.2). It is built |
| +// field by field from the stored row: no model is ever marshalled. |
| +type apiMeetingBody struct { |
| + Sqid string `json:"sqid"` |
| + Title string `json:"title"` |
| + StartsAt *string `json:"starts_at"` |
| + EndedAt *string `json:"ended_at"` |
| + LinkPresent bool `json:"link_present"` |
| + CreatedBy string `json:"created_by"` |
| +} |
| + |
| +// apiTime is RFC 3339 in UTC, or null. Every moment on this surface is |
| +// UTC: an agent has no instance zone to render in. |
| +func apiTime(t *time.Time) *string { |
| + if t == nil { |
| + return nil |
| + } |
| + s := t.UTC().Format(time.RFC3339) |
| + return &s |
| +} |
| + |
| +// apiBody renders one view, resolving created_by through labels, a |
| +// per-request memo so a list of meetings one agent made costs one |
| +// lookup rather than one per row. |
| +func (a *app) apiBody(ctx context.Context, v meetingView, labels map[string]string) apiMeetingBody { |
| + by, seen := labels[v.CreatedBy] |
| + if !seen { |
| + by = a.store.creatorLabel(ctx, v.CreatedBy) |
| + labels[v.CreatedBy] = by |
| + } |
| + return apiMeetingBody{ |
| + Sqid: v.Sqid, Title: v.Title, |
| + StartsAt: apiTime(v.StartsAt), EndedAt: apiTime(v.EndedAt), |
| + LinkPresent: v.LinkPresent, CreatedBy: by, |
| + } |
| +} |
| + |
| +func (a *app) apiListMeetings(w http.ResponseWriter, r *http.Request) { |
| + views, err := a.store.listMeetings(r.Context()) |
| + if err != nil { |
| + a.apiServerError(w, r, "list meetings", err) |
| + return |
| + } |
| + labels := map[string]string{} |
| + out := make([]apiMeetingBody, 0, len(views)) |
| + for _, v := range views { |
| + out = append(out, a.apiBody(r.Context(), v, labels)) |
| + } |
| + apiJSON(w, http.StatusOK, map[string]any{"meetings": out}) |
| +} |
| + |
| +// apiCreateRequest is the only shape POST /api/v1/meetings accepts. |
| +// StartsAt is a *string rather than a time so an unparseable moment is |
| +// this surface's own 400 rather than encoding/json's. |
| +type apiCreateRequest struct { |
| + Title string `json:"title"` |
| + StartsAt *string `json:"starts_at"` |
| +} |
| + |
| +func (a *app) apiCreateMeeting(w http.ResponseWriter, r *http.Request) { |
| + var req apiCreateRequest |
| + if !a.apiDecode(w, r, &req) { |
| + return |
| + } |
| + // Copied out field by field, never bound onto a model. |
| + title := strings.TrimSpace(req.Title) |
| + if title == "" || len(title) > maxTitleLen { |
| + a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "title") |
| + return |
| + } |
| + var start *time.Time |
| + if req.StartsAt != nil { |
| + t, err := time.Parse(time.RFC3339, *req.StartsAt) |
| + if err != nil { |
| + a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "starts-at") |
| + return |
| + } |
| + u := t.UTC() |
| + start = &u |
| + } |
| + |
| + ap, _ := currentAgent(r) |
| + m, token, err := a.store.createMeeting(r.Context(), title, start, actorForAgent(ap.agent)) |
| + if err != nil { |
| + a.apiServerError(w, r, "create meeting", err) |
| + return |
| + } |
| + view, err := a.store.meetingViewOf(r.Context(), m) |
| + if err != nil { |
| + a.apiServerError(w, r, "read the new meeting", err) |
| + return |
| + } |
| + apiJSON(w, http.StatusCreated, map[string]any{ |
| + "meeting": a.apiBody(r.Context(), view, map[string]string{}), |
| + "link": a.linkURL(token), |
| + }) |
| +} |
| + |
| +func (a *app) apiGetMeeting(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.apiMeetingFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + view, err := a.store.meetingViewOf(r.Context(), m) |
| + if err != nil { |
| + a.apiServerError(w, r, "read meeting", err) |
| + return |
| + } |
| + body := map[string]any{"meeting": a.apiBody(r.Context(), view, map[string]string{})} |
| + // While it runs, and only while it runs: how many peers are in the |
| + // call, never who they are or anything they said (spec §8.2). |
| + if m.EndedAt == nil { |
| + body["admitted"] = a.hub.AdmittedCount(m.Sqid) |
| + } |
| + apiJSON(w, http.StatusOK, body) |
| +} |
| + |
| +func (a *app) apiReplaceLink(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.apiMeetingFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + a.refuseAPI(w, r, http.StatusConflict, map[string]any{"error": "ended"}, "ended") |
| + return |
| + } |
| + ap, _ := currentAgent(r) |
| + token, err := a.store.replaceLink(r.Context(), m.ID, actorForAgent(ap.agent)) |
| + if err != nil { |
| + a.apiServerError(w, r, "replace link", err) |
| + return |
| + } |
| + view, err := a.store.meetingViewOf(r.Context(), m) |
| + if err != nil { |
| + a.apiServerError(w, r, "read meeting", err) |
| + return |
| + } |
| + apiJSON(w, http.StatusOK, map[string]any{ |
| + "meeting": a.apiBody(r.Context(), view, map[string]string{}), |
| + "link": a.linkURL(token), |
| + }) |
| +} |
| + |
| +func (a *app) apiEndMeeting(w http.ResponseWriter, r *http.Request) { |
| + m, ok := a.apiMeetingFor(w, r) |
| + if !ok { |
| + return |
| + } |
| + if m.EndedAt != nil { |
| + a.refuseAPI(w, r, http.StatusConflict, map[string]any{"error": "ended"}, "ended") |
| + return |
| + } |
| + ap, _ := currentAgent(r) |
| + if err := a.store.endMeeting(r.Context(), m.ID, actorForAgent(ap.agent)); err != nil { |
| + a.apiServerError(w, r, "end meeting", err) |
| + return |
| + } |
| + // The row is the authority; the hub is told so every open stream |
| + // gets lost{ended} now rather than at the next tick (spec §2.4), |
| + // exactly as the screen's own end does. |
| + a.hub.End(m.Sqid) |
| + |
| + ended, ok, err := a.store.meetingBySqid(r.Context(), m.Sqid) |
| + if err != nil { |
| + a.apiServerError(w, r, "read the ended meeting", err) |
| + return |
| + } |
| + if !ok { |
| + a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "meeting") |
| + return |
| + } |
| + view, err := a.store.meetingViewOf(r.Context(), ended) |
| + if err != nil { |
| + a.apiServerError(w, r, "read the ended meeting", err) |
| + return |
| + } |
| + apiJSON(w, http.StatusOK, map[string]any{"meeting": a.apiBody(r.Context(), view, map[string]string{})}) |
| +} |
| + |
| +// apiMeetingFor loads the meeting named in the path, or answers the |
| +// one not-found body. The id is checked for shape first, as every |
| +// other sqid-taking endpoint does: chi hands back whatever the request |
| +// line contained, and an unbounded string has no business reaching a |
| +// query or a log line. |
| +func (a *app) apiMeetingFor(w http.ResponseWriter, r *http.Request) (Meeting, bool) { |
| + sqid := chi.URLParam(r, "sqid") |
| + if !validSqid(sqid) { |
| + a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "bad-room") |
| + return Meeting{}, false |
| + } |
| + m, ok, err := a.store.meetingBySqid(r.Context(), sqid) |
| + if err != nil { |
| + a.apiServerError(w, r, "load meeting", err) |
| + return Meeting{}, false |
| + } |
| + if !ok { |
| + a.refuseAPI(w, r, http.StatusNotFound, apiNotFound(), "meeting") |
| + return Meeting{}, false |
| + } |
| + return m, true |
| +} |
| + |
| +// apiDecode reads the body as dst: capped at 16 KB before anything is |
| +// parsed, unknown fields refused, and nothing accepted after the one |
| +// object — so a second document smuggled behind the first is a 400 |
| +// rather than a silently ignored tail. |
| +func (a *app) apiDecode(w http.ResponseWriter, r *http.Request, dst any) bool { |
| + r.Body = http.MaxBytesReader(w, r.Body, maxAPIBody) |
| + dec := json.NewDecoder(r.Body) |
| + dec.DisallowUnknownFields() |
| + if err := dec.Decode(dst); err != nil { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + a.refuseAPI(w, r, http.StatusRequestEntityTooLarge, map[string]any{"error": "too-large"}, "size") |
| + return false |
| + } |
| + a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "shape") |
| + return false |
| + } |
| + if dec.More() { |
| + a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "shape") |
| + return false |
| + } |
| + return true |
| +} |
diff --git a/internal/meet/app.go b/internal/meet/app.go| index 9f2ad1b..a587440 100644 |
| --- a/internal/meet/app.go |
| +++ b/internal/meet/app.go |
| @@ -40,6 +40,9 @@ type app struct { |
| // signalLimiter is POST /m/{sqid}/signal's per-peer token bucket |
| // (spec §4.4): thirty a second, burst sixty. |
| signalLimiter *rateLimiter |
| + // apiLimiter is the drive surface's per-agent token bucket (spec |
| + // §8): sixty requests a minute, burst sixty, keyed "agent:<id>". |
| + apiLimiter *rateLimiter |
| // tickPass is the housekeeping pass tickLoop runs, nil in production |
| // and everywhere but the one test that records what deadline the |
| @@ -368,6 +371,7 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| Now: st.now, Logger: rc.Logger, StandingFor: a.hubStanding, |
| }) |
| a.signalLimiter = newRateLimiter(30, 60, maxSignalBuckets) |
| + a.apiLimiter = newRateLimiter(apiRatePerSecond, apiBurst, maxAgentBuckets) |
| writer, err := d.G.DB() |
| if err != nil { |
| @@ -387,45 +391,57 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err |
| cancel() |
| r := chi.NewRouter() |
| - r.Use(csrf.Protect(rc.Origin)) |
| - // Inside CSRF, outside the session: sign-in and /j/. |
| - r.Get(signinPath, a.signin) |
| - r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| - r.Get("/auth/callback", authPlugin.Callback) |
| - r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| - r.Get("/j/{token}", a.followLink) |
| + // The drive surface (spec §8.2) is mounted at the router root, |
| + // outside csrf.Protect and outside the session middlewares: an |
| + // agent is not a browser and sends none of the headers |
| + // csrf.SameOrigin needs (a POST carrying none of them is refused), |
| + // while RequireSession would redirect its bearer to the sign-in |
| + // page. apiRoutes says why that is safe — this surface honours no |
| + // cookie, so there is no ambient credential for CSRF to protect. |
| + r.Group(a.apiRoutes) |
| - // Signed in, with a persons row. |
| r.Group(func(r chi.Router) { |
| - r.Use(authPlugin.RequireSession) |
| - r.Use(a.requirePerson) |
| - r.Get("/continue", a.continueSignedIn) |
| - r.Get("/welcome", a.welcome) |
| - r.Get("/m/{sqid}/join", a.holding) |
| - r.Get("/m/{sqid}", a.room) |
| - r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove) |
| - r.Post("/m/{sqid}/remove/{peer}", a.remove) |
| - r.Get("/m/{sqid}/takeover", a.confirmTakeover) |
| - r.Post("/m/{sqid}/takeover", a.takeover) |
| + r.Use(csrf.Protect(rc.Origin)) |
| + |
| + // Inside CSRF, outside the session: sign-in and /j/. |
| + r.Get(signinPath, a.signin) |
| + r.Post(signinPath, a.gateBegin(authPlugin.Begin)) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + r.Get("/j/{token}", a.followLink) |
| + |
| + // Signed in, with a persons row. |
| r.Group(func(r chi.Router) { |
| - r.Use(a.requireMember) |
| - r.Get("/", a.index) |
| - r.Post("/meetings/start", a.startCall) |
| - r.Post("/meetings", a.createMeeting) |
| - r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) |
| - r.Post("/meetings/{sqid}/links", a.replaceLink) |
| - r.Get("/meetings/{sqid}/end", a.confirmEnd) |
| - r.Post("/meetings/{sqid}/end", a.endMeeting) |
| - r.Get("/settings", a.settings) |
| - r.Post("/settings/agents", a.createAgentHandler) |
| - r.Get("/settings/agents/{id}/revoke", a.confirmRevokeAgent) |
| - r.Post("/settings/agents/{id}/revoke", a.revokeAgentHandler) |
| + r.Use(authPlugin.RequireSession) |
| + r.Use(a.requirePerson) |
| + r.Get("/continue", a.continueSignedIn) |
| + r.Get("/welcome", a.welcome) |
| + r.Get("/m/{sqid}/join", a.holding) |
| + r.Get("/m/{sqid}", a.room) |
| + r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove) |
| + r.Post("/m/{sqid}/remove/{peer}", a.remove) |
| + r.Get("/m/{sqid}/takeover", a.confirmTakeover) |
| + r.Post("/m/{sqid}/takeover", a.takeover) |
| + r.Group(func(r chi.Router) { |
| + r.Use(a.requireMember) |
| + r.Get("/", a.index) |
| + r.Post("/meetings/start", a.startCall) |
| + r.Post("/meetings", a.createMeeting) |
| + r.Get("/meetings/{sqid}/links/replace", a.confirmReplaceLink) |
| + r.Post("/meetings/{sqid}/links", a.replaceLink) |
| + r.Get("/meetings/{sqid}/end", a.confirmEnd) |
| + r.Post("/meetings/{sqid}/end", a.endMeeting) |
| + r.Get("/settings", a.settings) |
| + r.Post("/settings/agents", a.createAgentHandler) |
| + r.Get("/settings/agents/{id}/revoke", a.confirmRevokeAgent) |
| + r.Post("/settings/agents/{id}/revoke", a.revokeAgentHandler) |
| + }) |
| + r.Get("/m/{sqid}/stream", a.stream) |
| + r.Post("/m/{sqid}/signal", a.signal) |
| + r.Get("/m/{sqid}/ice", a.ice) |
| }) |
| - 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/admission_test.go b/internal/meet/hub/admission_test.go| index d1f7a8a..89cce02 100644 |
| --- a/internal/meet/hub/admission_test.go |
| +++ b/internal/meet/hub/admission_test.go |
| @@ -611,16 +611,13 @@ func TestConcurrentAdmissionsFillTheLastSlotExactlyOnce(t *testing.T) { |
| } |
| // admittedNow counts the room's admitted participations through the |
| -// hub's own lock, without reaching past its API for a field. |
| +// hub's own lock, without reaching past its API for a field. Since |
| +// slice 5 that is exactly AdmittedCount, the drive surface's own |
| +// reader (count.go), so this is its name in the tests that predate it |
| +// rather than a second copy of the same walk. |
| func admittedNow(t *testing.T, h *Hub, sqid string) int { |
| t.Helper() |
| - h.mu.Lock() |
| - defer h.mu.Unlock() |
| - r, ok := h.rooms[sqid] |
| - if !ok { |
| - return 0 |
| - } |
| - return admittedCountLocked(r) |
| + return h.AdmittedCount(sqid) |
| } |
| // Spec §2.4 and ruling 14: a loss of standing costs what the kind held |
diff --git a/internal/meet/hub/count.go b/internal/meet/hub/count.go| new file mode 100644 |
| index 0000000..e04cfae |
| --- /dev/null |
| +++ b/internal/meet/hub/count.go |
| @@ -0,0 +1,21 @@ |
| +package hub |
| + |
| +// AdmittedCount is how many participations are admitted in the room — |
| +// the one number the drive surface reads back out of a live meeting |
| +// (spec §8.2: the count of admitted peers, never their names or the |
| +// chat). A room nobody has opened answers zero, which is what a |
| +// meeting with no room actually means; the caller already knows the |
| +// meeting exists, so there is nothing to distinguish. |
| +// |
| +// Taken under the hub's own lock, like every other reader, and built |
| +// on admittedCountLocked so the API and the mesh cap can never |
| +// disagree about what "admitted" counts. |
| +func (h *Hub) AdmittedCount(sqid string) int { |
| + h.mu.Lock() |
| + defer h.mu.Unlock() |
| + r, ok := h.rooms[sqid] |
| + if !ok { |
| + return 0 |
| + } |
| + return admittedCountLocked(r) |
| +} |
diff --git a/internal/meet/hub/count_test.go b/internal/meet/hub/count_test.go| new file mode 100644 |
| index 0000000..43867fb |
| --- /dev/null |
| +++ b/internal/meet/hub/count_test.go |
| @@ -0,0 +1,64 @@ |
| +package hub |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// AdmittedCount is the drive surface's one window into a live room |
| +// (spec §8.2): how many peers are actually in the call, and never who |
| +// they are. A room nobody has opened is zero rather than an error — |
| +// the caller holds a meeting row, and a meeting with no room is simply |
| +// a meeting nobody has entered. |
| +func TestAdmittedCountCountsAdmittedOnlyAndNeverAWaitingGuest(t *testing.T) { |
| + now := time.Date(2026, 9, 11, 9, 0, 0, 0, time.UTC) |
| + h := testHub(&now) |
| + ctx := context.Background() |
| + |
| + if n := h.AdmittedCount("r1"); n != 0 { |
| + t.Fatalf("a room nobody has opened: %d, want 0", n) |
| + } |
| + |
| + m, err := h.Open("r1", "sm", 1, "Mia", KindMember) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := h.AdmittedCount("r1"); n != 0 { |
| + t.Fatalf("an unjoined participation counts: %d, want 0", n) |
| + } |
| + if err := h.Join(ctx, "r1", "sm", m.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := h.AdmittedCount("r1"); n != 1 { |
| + t.Fatalf("a joined member: %d, want 1", n) |
| + } |
| + |
| + g, err := h.Open("r1", "sg", 100, "Gus", KindGuest) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := h.Join(ctx, "r1", "sg", g.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := h.AdmittedCount("r1"); n != 1 { |
| + t.Fatalf("a waiting guest is counted: %d, want 1", n) |
| + } |
| + |
| + if err := h.Admit(ctx, "r1", "sm", m.Peer, g.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := h.AdmittedCount("r1"); n != 2 { |
| + t.Fatalf("after admitting the guest: %d, want 2", n) |
| + } |
| + if n := h.AdmittedCount("r2"); n != 0 { |
| + t.Fatalf("another room's count leaked: %d, want 0", n) |
| + } |
| + |
| + if err := h.Leave("r1", "sg", g.Peer); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := h.AdmittedCount("r1"); n != 1 { |
| + t.Fatalf("after the guest left: %d, want 1", n) |
| + } |
| +} |
diff --git a/internal/meet/store.go b/internal/meet/store.go| index 7866fe0..a63a837 100644 |
| --- a/internal/meet/store.go |
| +++ b/internal/meet/store.go |
| @@ -144,15 +144,29 @@ func (s *store) listMeetings(ctx context.Context) ([]meetingView, error) { |
| } |
| out := make([]meetingView, 0, len(ms)) |
| for _, m := range ms { |
| - var n int64 |
| - if err := s.g.WithContext(ctx).Model(&MeetingLink{}).Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil { |
| + v, err := s.meetingViewOf(ctx, m) |
| + if err != nil { |
| return nil, err |
| } |
| - out = append(out, meetingView{Meeting: m, Title: s.title(m), LinkPresent: n > 0}) |
| + out = append(out, v) |
| } |
| return out, nil |
| } |
| +// meetingViewOf is one row as every reader wants it: the meeting, its |
| +// title opened, and whether a live link exists. Factored out of |
| +// listMeetings so the drive surface's single-meeting read (§8.2) |
| +// answers link_present from the same query the list does, rather than |
| +// growing a second opinion about what "present" means. |
| +func (s *store) meetingViewOf(ctx context.Context, m Meeting) (meetingView, error) { |
| + var n int64 |
| + if err := s.g.WithContext(ctx).Model(&MeetingLink{}). |
| + Where("meeting_id = ? AND revoked_at IS NULL", m.ID).Count(&n).Error; err != nil { |
| + return meetingView{}, err |
| + } |
| + return meetingView{Meeting: m, Title: s.title(m), LinkPresent: n > 0}, nil |
| +} |
| + |
| func (s *store) meetingBySqid(ctx context.Context, sqid string) (Meeting, bool, error) { |
| var m Meeting |
| err := s.g.WithContext(ctx).Where("sqid = ?", sqid).Take(&m).Error |
diff --git a/internal/meettest/api_test.go b/internal/meettest/api_test.go| new file mode 100644 |
| index 0000000..9749297 |
| --- /dev/null |
| +++ b/internal/meettest/api_test.go |
| @@ -0,0 +1,550 @@ |
| +package meettest |
| + |
| +import ( |
| + "bytes" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + meet "amadan.net/oficina/meet/internal/meet" |
| +) |
| + |
| +// mintAgent drives Settings as the signed-in member and hands back the |
| +// token that page shows exactly once — the only way an agent token |
| +// ever comes into existence, here as in production. |
| +func mintAgent(t *testing.T, x *harness, c *client, name string) string { |
| + t.Helper() |
| + rec := c.post("/settings/agents", url.Values{"name": {name}}) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST /settings/agents: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + return extractToken(t, rec.Body.String()) |
| +} |
| + |
| +// apiAuth is a request to the drive surface with a literal |
| +// Authorization header and no cookies, and — deliberately — no Origin, |
| +// Referer or Sec-Fetch-Site: an agent is not a browser and sends none |
| +// of the three, which is exactly why /api/v1 mounts outside |
| +// csrf.Protect (rastrillo's csrf.SameOrigin refuses a POST carrying |
| +// none of them). |
| +func apiAuth(t *testing.T, x *harness, method, target, authorization, body string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + var rdr io.Reader |
| + if body != "" { |
| + rdr = strings.NewReader(body) |
| + } |
| + req := httptest.NewRequest(method, target, rdr) |
| + if authorization != "" { |
| + req.Header.Set("Authorization", authorization) |
| + } |
| + if body != "" { |
| + req.Header.Set("Content-Type", "application/json") |
| + } |
| + rec := httptest.NewRecorder() |
| + x.h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// api is apiAuth with a bearer token. |
| +func api(t *testing.T, x *harness, method, target, token, body string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + return apiAuth(t, x, method, target, "Bearer "+token, body) |
| +} |
| + |
| +// apiBody asserts the status and the two headers every API answer |
| +// carries, and decodes the JSON. |
| +func apiBody(t *testing.T, rec *httptest.ResponseRecorder, want int) map[string]any { |
| + t.Helper() |
| + if rec.Code != want { |
| + t.Fatalf("status %d, want %d\n%s", rec.Code, want, rec.Body.String()) |
| + } |
| + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { |
| + t.Fatalf("Content-Type %q, want application/json", ct) |
| + } |
| + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Fatalf("Cache-Control %q, want no-store", cc) |
| + } |
| + var out map[string]any |
| + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { |
| + t.Fatalf("decode %q: %v", rec.Body.String(), err) |
| + } |
| + return out |
| +} |
| + |
| +func apiMeeting(t *testing.T, body map[string]any) map[string]any { |
| + t.Helper() |
| + m, ok := body["meeting"].(map[string]any) |
| + if !ok { |
| + t.Fatalf("no meeting object in %v", body) |
| + } |
| + return m |
| +} |
| + |
| +func apiString(t *testing.T, m map[string]any, key string) string { |
| + t.Helper() |
| + s, ok := m[key].(string) |
| + if !ok { |
| + t.Fatalf("%s is %#v, want a string", key, m[key]) |
| + } |
| + return s |
| +} |
| + |
| +// unknownSqid is the shape newSqid produces and a value it never has: |
| +// a valid-looking room id belonging to no meeting. |
| +const unknownSqid = "2345678999" |
| + |
| +// Spec §8.1 and the slice's ruling: a cookie is never honoured on the |
| +// API and the bearer is the only credential; a missing or malformed |
| +// one is 401, while an unknown token is the not-found body — the same |
| +// bytes an unknown meeting answers, so nothing about whether a token |
| +// exists is observable. |
| +func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "scheduler") |
| + |
| + // The positive control first: the bearer alone, no cookie, works. |
| + apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) |
| + |
| + for _, tc := range []struct { |
| + name, header string |
| + }{ |
| + {"no Authorization header at all", ""}, |
| + {"a Basic credential", "Basic " + base64.StdEncoding.EncodeToString([]byte("paul@example.com:hunter2"))}, |
| + {"the scheme without a token", "Bearer "}, |
| + {"a token with no scheme", token}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + rec := apiAuth(t, x, http.MethodGet, "/api/v1/meetings", tc.header, "") |
| + body := apiBody(t, rec, http.StatusUnauthorized) |
| + if body["error"] != "unauthorized" { |
| + t.Fatalf("%v, want error unauthorized", body) |
| + } |
| + }) |
| + } |
| + |
| + // A signed-in member's own cookie, carried exactly as the browser |
| + // carries it to every HTML screen, buys nothing here. |
| + t.Run("a session cookie alone", func(t *testing.T) { |
| + rec := c.get("/api/v1/meetings") |
| + body := apiBody(t, rec, http.StatusUnauthorized) |
| + if body["error"] != "unauthorized" { |
| + t.Fatalf("%v, want error unauthorized", body) |
| + } |
| + }) |
| + |
| + // An unknown token and an unknown meeting are one 404, byte for |
| + // byte. |
| + unknownToken := api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "") |
| + if unknownToken.Code != http.StatusNotFound { |
| + t.Fatalf("an unknown token: %d, want 404\n%s", unknownToken.Code, unknownToken.Body.String()) |
| + } |
| + unknownMeeting := api(t, x, http.MethodGet, "/api/v1/meetings/"+unknownSqid, token, "") |
| + if unknownMeeting.Code != http.StatusNotFound { |
| + t.Fatalf("an unknown meeting: %d, want 404\n%s", unknownMeeting.Code, unknownMeeting.Body.String()) |
| + } |
| + if unknownToken.Body.String() != unknownMeeting.Body.String() { |
| + t.Fatalf("the two 404s differ:\ntoken: %q\nmeeting: %q", |
| + unknownToken.Body.String(), unknownMeeting.Body.String()) |
| + } |
| + if got := apiBody(t, unknownMeeting, http.StatusNotFound)["error"]; got != "not-found" { |
| + t.Fatalf("not-found body carries error %v", got) |
| + } |
| + |
| + // A path this surface does not have answers the same not-found body |
| + // — and only to a caller that already holds a token, so the routes |
| + // cannot be mapped without one. |
| + if body := apiBody(t, api(t, x, http.MethodGet, "/api/v1/agents", token, ""), http.StatusNotFound); body["error"] != "not-found" { |
| + t.Fatalf("an unknown API path: %v, want the not-found body", body) |
| + } |
| + if body := apiBody(t, apiAuth(t, x, http.MethodGet, "/api/v1/agents", "", ""), http.StatusUnauthorized); body["error"] != "unauthorized" { |
| + t.Fatalf("an unknown API path without a bearer: %v, want unauthorized", body) |
| + } |
| + if body := apiBody(t, api(t, x, http.MethodDelete, "/api/v1/meetings", token, ""), http.StatusNotFound); body["error"] != "not-found" { |
| + t.Fatalf("a method this surface does not have: %v, want the not-found body", body) |
| + } |
| +} |
| + |
| +// The other half of the same ruling: an HTML route never honours a |
| +// bearer. A token alone, with no session cookie, is simply a signed-out |
| +// request. |
| +func TestABearerOnAnHTMLRouteIsIgnored(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "scheduler") |
| + |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + rec := httptest.NewRecorder() |
| + x.h.ServeHTTP(rec, req) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin" { |
| + t.Fatalf("GET / with a bearer: %d → %q, want 303 → /signin\n%s", |
| + rec.Code, rec.Header().Get("Location"), rec.Body.String()) |
| + } |
| + |
| + // And the API's own not-found handler stays on the API: an unknown |
| + // HTML path is still the router's plain 404, not a JSON body. |
| + miss := get(t, x.h, "/nope") |
| + if miss.Code != http.StatusNotFound { |
| + t.Fatalf("GET /nope: %d, want 404", miss.Code) |
| + } |
| + if ct := miss.Header().Get("Content-Type"); strings.Contains(ct, "json") { |
| + t.Fatalf("an unknown HTML path answers %q\n%s", ct, miss.Body.String()) |
| + } |
| +} |
| + |
| +// Spec §8.1 and §13's slice-5 gate: reach is the operator's standing at |
| +// the moment of the request, not at the moment the token was minted. An |
| +// operator dropped from the team takes their agent's reach with them, |
| +// with no revocation step, and gets it back the same way. |
| +func TestAPIReachIsTheOperatorsStandingOnEveryRequest(t *testing.T) { |
| + x, _, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "scheduler") |
| + |
| + apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) |
| + |
| + x.ctl.SetAllowlist("bea@rslw.com") |
| + gone := api(t, x, http.MethodGet, "/api/v1/meetings", token, "") |
| + if body := apiBody(t, gone, http.StatusNotFound); body["error"] != "not-found" { |
| + t.Fatalf("an operator off the team: %v, want the not-found body", body) |
| + } |
| + unknown := api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "") |
| + if gone.Body.String() != unknown.Body.String() { |
| + t.Fatalf("a reach-less token is distinguishable from an unknown one:\nreach: %q\nunknown: %q", |
| + gone.Body.String(), unknown.Body.String()) |
| + } |
| + |
| + x.ctl.SetAllowlist("paul@example.com", "paul@rslw.com") |
| + apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) |
| + |
| + id := agentRevokeID(t, c.get("/settings").Body.String()) |
| + if rec := c.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("revoke: %d\n%s", rec.Code, rec.Body.String()) |
| + } |
| + revoked := api(t, x, http.MethodGet, "/api/v1/meetings", token, "") |
| + if body := apiBody(t, revoked, http.StatusNotFound); body["error"] != "not-found" { |
| + t.Fatalf("a revoked token: %v, want the not-found body", body) |
| + } |
| + if revoked.Body.String() != unknown.Body.String() { |
| + t.Fatalf("a revoked token is distinguishable from an unknown one:\nrevoked: %q\nunknown: %q", |
| + revoked.Body.String(), unknown.Body.String()) |
| + } |
| +} |
| + |
| +// Spec §8.2, the whole surface in one run: the five operations, the two |
| +// responses that carry a link token and no others, the admitted count |
| +// while the meeting runs, and the 409 an ended meeting answers. |
| +func TestAPIListsCreatesLinksAndEnds(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "claude") |
| + |
| + created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + `{"title":"Board meeting","starts_at":"2026-09-12T09:00:00Z"}`), http.StatusCreated) |
| + m := apiMeeting(t, created) |
| + sqid := apiString(t, m, "sqid") |
| + if apiString(t, m, "title") != "Board meeting" { |
| + t.Fatalf("title %v", m["title"]) |
| + } |
| + if apiString(t, m, "starts_at") != "2026-09-12T09:00:00Z" { |
| + t.Fatalf("starts_at %v, want RFC 3339 in UTC", m["starts_at"]) |
| + } |
| + if m["ended_at"] != nil { |
| + t.Fatalf("a new meeting has ended_at %v", m["ended_at"]) |
| + } |
| + if m["link_present"] != true { |
| + t.Fatalf("link_present %v, want true", m["link_present"]) |
| + } |
| + if apiString(t, m, "created_by") != "claude~paul@example.com" { |
| + t.Fatalf("created_by %v, want the agent's principal", m["created_by"]) |
| + } |
| + link := apiString(t, created, "link") |
| + if !strings.HasPrefix(link, testOrigin+"/j/") { |
| + t.Fatalf("link %q, want the configured origin's /j/ URL", link) |
| + } |
| + if strings.Contains(link, token) { |
| + t.Fatal("the link carries the agent token rather than a link token") |
| + } |
| + |
| + // The meeting, before anyone is in the room. |
| + got := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) |
| + if got["admitted"] != float64(0) { |
| + t.Fatalf("admitted %#v, want 0", got["admitted"]) |
| + } |
| + |
| + // A member joins through the browser and is admitted on standing. |
| + s := c.stream("/m/" + sqid + "/stream") |
| + defer s.close() |
| + peer, _ := s.hello() |
| + c.signal(sqid, peer, `{"type":"join"}`) |
| + s.nextOf("roster", 2*time.Second) |
| + got = apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+sqid, token, ""), http.StatusOK) |
| + if got["admitted"] != float64(1) { |
| + t.Fatalf("admitted %#v with one member in the room, want 1", got["admitted"]) |
| + } |
| + |
| + // Replacing the link hands back a new one and kills the old. |
| + replaced := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/links", token, ""), http.StatusOK) |
| + newLink := apiString(t, replaced, "link") |
| + if newLink == link { |
| + t.Fatal("replace handed back the same link") |
| + } |
| + oldToken := strings.TrimPrefix(link, testOrigin+"/j/") |
| + if rec := x.browser(t).get("/j/" + oldToken); rec.Code != http.StatusNotFound { |
| + t.Fatalf("the replaced link still works: %d", rec.Code) |
| + } |
| + |
| + // Ending: the meeting comes back ended, and without a live count. |
| + ended := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings/"+sqid+"/end", token, ""), http.StatusOK) |
| + if _, present := ended["admitted"]; present { |
| + t.Fatalf("an ended meeting still reports admitted: %v", ended) |
| + } |
| + em := apiMeeting(t, ended) |
| + if em["ended_at"] == nil { |
| + t.Fatalf("ended_at is null after end: %v", em) |
| + } |
| + if em["link_present"] != false { |
| + t.Fatalf("link_present %v after end, want false", em["link_present"]) |
| + } |
| + |
| + for _, target := range []string{"/api/v1/meetings/" + sqid + "/end", "/api/v1/meetings/" + sqid + "/links"} { |
| + body := apiBody(t, api(t, x, http.MethodPost, target, token, ""), http.StatusConflict) |
| + if body["error"] != "ended" { |
| + t.Fatalf("POST %s on an ended meeting: %v, want error ended", target, body) |
| + } |
| + } |
| + |
| + // And the list, which is what a second agent run would read. |
| + list := apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", token, ""), http.StatusOK) |
| + rows, ok := list["meetings"].([]any) |
| + if !ok || len(rows) != 1 { |
| + t.Fatalf("meetings %#v, want one row", list["meetings"]) |
| + } |
| + row := rows[0].(map[string]any) |
| + if apiString(t, row, "sqid") != sqid || apiString(t, row, "created_by") != "claude~paul@example.com" { |
| + t.Fatalf("list row %v", row) |
| + } |
| + if row["ended_at"] == nil { |
| + t.Fatalf("the listed row is not ended: %v", row) |
| + } |
| + if strings.Contains(list["meetings"].([]any)[0].(map[string]any)["title"].(string), "mt_") { |
| + t.Fatal("a token leaked into the title") |
| + } |
| +} |
| + |
| +// Spec's Global Constraints: the body is a typed request read under a |
| +// 16 KB cap with unknown fields refused, and a title is 1..200 |
| +// characters. Nothing here is ever bound onto a model. |
| +func TestAPIBodyRules(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "claude") |
| + |
| + for _, tc := range []struct { |
| + name, body string |
| + status int |
| + err string |
| + }{ |
| + {"an unknown field", `{"title":"Standup","meeting_id":7}`, http.StatusBadRequest, "bad-request"}, |
| + {"a title of 201 characters", `{"title":"` + strings.Repeat("a", 201) + `"}`, http.StatusBadRequest, "bad-request"}, |
| + {"an empty title", `{"title":""}`, http.StatusBadRequest, "bad-request"}, |
| + {"a whitespace title", `{"title":" "}`, http.StatusBadRequest, "bad-request"}, |
| + {"no title at all", `{}`, http.StatusBadRequest, "bad-request"}, |
| + {"a start that is not RFC 3339", `{"title":"Standup","starts_at":"tomorrow"}`, http.StatusBadRequest, "bad-request"}, |
| + {"a start in the form screen's own format", `{"title":"Standup","starts_at":"2026-09-12T09:00"}`, http.StatusBadRequest, "bad-request"}, |
| + {"a title that is not a string", `{"title":7}`, http.StatusBadRequest, "bad-request"}, |
| + {"trailing content after the object", `{"title":"Standup"} {}`, http.StatusBadRequest, "bad-request"}, |
| + {"no body at all", ``, http.StatusBadRequest, "bad-request"}, |
| + {"seventeen kilobytes", `{"title":"` + strings.Repeat("a", 17*1024) + `"}`, http.StatusRequestEntityTooLarge, "too-large"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + rec := api(t, x, http.MethodPost, "/api/v1/meetings", token, tc.body) |
| + body := apiBody(t, rec, tc.status) |
| + if body["error"] != tc.err { |
| + t.Fatalf("%v, want error %q", body, tc.err) |
| + } |
| + }) |
| + } |
| + |
| + // A title of exactly 200 characters is accepted: the boundary is |
| + // inclusive, and the refusals above are not simply "every title". |
| + edge := strings.Repeat("b", 200) |
| + created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + `{"title":"`+edge+`"}`), http.StatusCreated) |
| + if apiString(t, apiMeeting(t, created), "title") != edge { |
| + t.Fatal("a 200-character title came back changed") |
| + } |
| + if apiMeeting(t, created)["starts_at"] != nil { |
| + t.Fatalf("an absent starts_at came back as %v", apiMeeting(t, created)["starts_at"]) |
| + } |
| + |
| + // A title is data, not markup: it is stored and listed as a plain |
| + // JSON string, escaped by the encoder and never by an HTML escaper. |
| + const scriptish = `<script>alert("x")</script>` |
| + apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + `{"title":`+string(mustJSON(t, scriptish))+`}`), http.StatusCreated) |
| + rec := api(t, x, http.MethodGet, "/api/v1/meetings", token, "") |
| + list := apiBody(t, rec, http.StatusOK) |
| + var found bool |
| + for _, row := range list["meetings"].([]any) { |
| + if row.(map[string]any)["title"] == scriptish { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("the title did not survive the round trip:\n%s", rec.Body.String()) |
| + } |
| + if strings.Contains(rec.Body.String(), "<script>") { |
| + t.Fatalf("the raw JSON carries an unescaped tag:\n%s", rec.Body.String()) |
| + } |
| +} |
| + |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// Spec §0.1 and §8.1: the API's own log lines name the agent and the |
| +// outcome, and nothing else. No token, no hash, no link, no title. |
| +func TestAPILogsCarryNoToken(t *testing.T) { |
| + var buf bytes.Buffer |
| + x := newHarnessWithLogger(t, meet.EnvDevelopment, |
| + slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), |
| + "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "claude") |
| + |
| + created := apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + `{"title":"Quarterly review"}`), http.StatusCreated) |
| + link := apiString(t, created, "link") |
| + linkToken := strings.TrimPrefix(link, testOrigin+"/j/") |
| + |
| + before := buf.Len() |
| + apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings/"+unknownSqid, token, ""), http.StatusNotFound) |
| + apiAuth(t, x, http.MethodGet, "/api/v1/meetings", "", "") |
| + api(t, x, http.MethodGet, "/api/v1/meetings", "nope", "") |
| + |
| + log := buf.String() |
| + for _, secret := range []string{token, meetHash(token), linkToken, "Quarterly review"} { |
| + if secret != "" && strings.Contains(log, secret) { |
| + t.Fatalf("the log carries a secret (%q):\n%s", secret, log) |
| + } |
| + } |
| + if !strings.Contains(log[before:], "outcome") { |
| + t.Fatalf("no refusal was logged at all:\n%s", log[before:]) |
| + } |
| + if !strings.Contains(log[before:], "agent_id") { |
| + t.Fatalf("a resolved agent's refusal carries no agent_id:\n%s", log[before:]) |
| + } |
| + // A success is silent: the only lines since the refusals began are |
| + // the refusal lines themselves. |
| + if n := strings.Count(log[before:], "meet: api"); n != 3 { |
| + t.Fatalf("want exactly three api log lines (one per refusal), got %d:\n%s", n, log[before:]) |
| + } |
| +} |
| + |
| +// meetHash is the token hash exactly as the store stores it — hex of |
| +// the SHA-256 of the token, mt_ prefix included — recomputed here |
| +// because meettest is an external test package and cannot call |
| +// hashAgentToken. If the two ever disagree this assertion goes quiet |
| +// rather than wrong, which is why the plaintext token is checked |
| +// alongside it. |
| +func meetHash(token string) string { |
| + sum := sha256.Sum256([]byte(token)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// Spec's Global Constraints: sixty requests a minute per agent, burst |
| +// sixty, and the budget is the agent's own — a second agent minted by |
| +// the same operator is untouched by the first's exhaustion. |
| +func TestAPIRateLimitsPerAgent(t *testing.T) { |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + first := mintAgent(t, x, c, "claude") |
| + second := mintAgent(t, x, c, "scheduler") |
| + |
| + for i := range 60 { |
| + rec := api(t, x, http.MethodGet, "/api/v1/meetings", first, "") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("request %d: %d\n%s", i+1, rec.Code, rec.Body.String()) |
| + } |
| + } |
| + rec := api(t, x, http.MethodGet, "/api/v1/meetings", first, "") |
| + body := apiBody(t, rec, http.StatusTooManyRequests) |
| + if body["error"] != "rate" { |
| + t.Fatalf("%v, want error rate", body) |
| + } |
| + ra := rec.Header().Get("Retry-After") |
| + if n, err := strconv.Atoi(ra); err != nil || n < 1 { |
| + t.Fatalf("Retry-After %q, want a positive number of seconds", ra) |
| + } |
| + |
| + apiBody(t, api(t, x, http.MethodGet, "/api/v1/meetings", second, ""), http.StatusOK) |
| +} |
| + |
| +// Spec §8.1: every write an agent makes is marked as the agent's on the |
| +// list the member reads, with the robot badge and the principal. The |
| +// assertion belongs to this file; the badge itself is Task 4's. |
| +func TestAPIWritesAreMarkedAsTheAgents(t *testing.T) { |
| + t.Skip("Task 4: the badge and the principal on the HTML list are not implemented yet") |
| + |
| + x := newHarness(t, "paul@example.com") |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + token := mintAgent(t, x, c, "claude") |
| + |
| + apiBody(t, api(t, x, http.MethodPost, "/api/v1/meetings", token, |
| + `{"title":"Agent meeting"}`), http.StatusCreated) |
| + createMeeting(t, c, "Member meeting", "") |
| + |
| + body := c.get("/").Body.String() |
| + agentRow := rowFor(t, body, "Agent meeting") |
| + memberRow := rowFor(t, body, "Member meeting") |
| + if !strings.Contains(agentRow, "claude~paul@example.com") { |
| + t.Fatalf("the agent's meeting shows no principal:\n%s", agentRow) |
| + } |
| + if !strings.Contains(agentRow, "lucide-bot") { |
| + t.Fatalf("the agent's meeting shows no robot badge:\n%s", agentRow) |
| + } |
| + if strings.Contains(memberRow, "lucide-bot") || strings.Contains(memberRow, "~") { |
| + t.Fatalf("a member's own meeting is badged as an agent's:\n%s", memberRow) |
| + } |
| +} |
| + |
| +// rowFor is one meeting's <article> on the list page, so a badge can be |
| +// attributed to the meeting it is actually on rather than to the page. |
| +func rowFor(t *testing.T, body, title string) string { |
| + t.Helper() |
| + i := strings.Index(body, title) |
| + if i < 0 { |
| + t.Fatalf("%q is not on the list:\n%s", title, body) |
| + } |
| + start := strings.LastIndex(body[:i], "<article") |
| + end := strings.Index(body[i:], "</article>") |
| + if start < 0 || end < 0 { |
| + t.Fatalf("%q is not inside a list row:\n%s", title, body) |
| + } |
| + return body[start : i+end] |
| +} |