oficina / meet Public

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
Paul Campbell pushed by paul@keymail.dev af2936a2b3b67ed6940ecfd020f5cf8b0e68ebed parent e8517d1
8 files changed, +1184 −59
  • internal/meet/agents.go +62 −14
  • internal/meet/api.go +415 −0
  • internal/meet/app.go +50 −34
  • internal/meet/hub/admission_test.go +5 −8
  • internal/meet/hub/count.go +21 −0
  • internal/meet/hub/count_test.go +64 −0
  • internal/meet/store.go +17 −3
  • internal/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]
+}