package calls import ( "context" "encoding/json" "errors" "io" "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(capAPIBody) 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) }) } // capAPIBody puts every request on this surface under the 16 KB cap // before a handler runs — the operations that read a body and the ones // that do not alike. A cap applied inside the decoder only would leave // the bodyless writes (`/links`, `/end`) reading nothing and refusing // nothing, so a megabyte posted at `/end` would perform the write // (review S5-1). MaxBytesReader takes the ResponseWriter so an // over-cap body also stops the connection being reused for a body the // server never read. func capAPIBody(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, maxAPIBody) } next.ServeHTTP(w, r) }) } // 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 . 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("calls: api refused", "agent_id", ap.agent.ID, "outcome", outcome) } else { a.logger.Warn("calls: 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("calls: 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. created_by is v.CreatedByPrincipal, // already resolved by meetingViewOfCached (a list shares one label // cache across its rows; a single-row read passes its own throwaway // one) — apiBody used to re-resolve it a second time through its own // labels map, which cost every single-row API path two lookups where // one would do. func (a *app) apiBody(v meetingView) apiMeetingBody { return apiMeetingBody{ Sqid: v.Sqid, Title: v.Title, StartsAt: apiTime(v.StartsAt), EndedAt: apiTime(v.EndedAt), LinkPresent: v.LinkPresent, CreatedBy: v.CreatedByPrincipal, } } 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 } out := make([]apiMeetingBody, 0, len(views)) for _, v := range views { out = append(out, a.apiBody(v)) } 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 == "" || titleTooLong(title) { 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(view), "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(view)} // 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 !a.apiNoBody(w, r) { return } // No ended check on the snapshot read above. A meeting can end // between that read and this write (review S5-2); the authority is // store.replaceLink's own re-read of ended_at inside its own // transaction, which answers errMeetingEnded. ap, _ := currentAgent(r) token, err := a.store.replaceLink(r.Context(), m.ID, actorForAgent(ap.agent)) if errors.Is(err, errMeetingEnded) { a.refuseAPI(w, r, http.StatusConflict, map[string]any{"error": "ended"}, "ended") return } 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(view), "link": a.linkURL(token), }) } func (a *app) apiEndMeeting(w http.ResponseWriter, r *http.Request) { m, ok := a.apiMeetingFor(w, r) if !ok { return } if !a.apiNoBody(w, r) { return } // As with replace: the ended check is the conditional UPDATE inside // store.endMeeting, not the snapshot read above (review S5-2). The // end that changed no row is the 409, so exactly one of two racing // ends gets the 200. ap, _ := currentAgent(r) ended, err := a.store.endMeeting(r.Context(), m.ID, actorForAgent(ap.agent)) if err != nil { a.apiServerError(w, r, "end meeting", err) return } if !ended { a.refuseAPI(w, r, http.StatusConflict, map[string]any{"error": "ended"}, "ended") 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) row, 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(), row) if err != nil { a.apiServerError(w, r, "read the ended meeting", err) return } apiJSON(w, http.StatusOK, map[string]any{"meeting": a.apiBody(view)}) } // 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: under capAPIBody's 16 KB cap, // unknown fields refused, and EOF required after the one object — so a // second document, a stray `]` or `}`, or any other tail behind the // first value is a 400 rather than a silently ignored remainder. // // EOF is required rather than dec.More() asked (review S5-1). More() // answers "is another array or object element waiting", which is not // the same question: it is false for a trailing `]`, false for a // trailing `}`, and — worse — it swallows the decoder's stored read // error, so a valid object followed by 17 KB of whitespace came back as // a clean decode rather than the 413 the cap promises. A second Decode // asks the honest question and hands back the honest error. func (a *app) apiDecode(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { return a.refuseBody(w, r, err) } // Anything at all after the first value — a second value (err nil), // a syntax error, or bytes past the cap — is a refusal. Only io.EOF // means "that was the whole body". var tail json.RawMessage if err := dec.Decode(&tail); !errors.Is(err, io.EOF) { return a.refuseBody(w, r, err) } return true } // refuseBody is the one place a body error becomes a status: a cap // breach is 413 and everything else — malformed JSON, an unknown field, // a trailing value, an empty body, a read that failed — is 400. Always // returns false, so a caller can `return a.refuseBody(...)`. func (a *app) refuseBody(w http.ResponseWriter, r *http.Request, err error) bool { 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 } // apiNoBody is the bodyless writes' half of the same rule (review // S5-1): `/links` and `/end` take no body at all, so the body is read // to its end — under capAPIBody's cap — and refused if it carried // anything, before the write rather than after it. Over the cap is 413; // any other content is 400. An absent body and a Content-Length: 0 body // are the two shapes that pass. func (a *app) apiNoBody(w http.ResponseWriter, r *http.Request) bool { if r.Body == nil { return true } n, err := io.Copy(io.Discard, r.Body) if err != nil { return a.refuseBody(w, r, err) } if n > 0 { a.refuseAPI(w, r, http.StatusBadRequest, map[string]any{"error": "bad-request"}, "body") return false } return true }