mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
5035168e5c
* wip(security): BUG-3982 pinned-PAT enforcement, in progress Claude-Session: https://claude.ai/code/session_01EZ6hZzQiytCeBxXrtCWcVd * fix(security): narrow a pinned token's token doors; MCP global-route test (BUG-3982 r1) r1 rulings (lead, on BUG-3982): - F1: docs/api.md says details.token_workspace is absent for a deleted pin. - F2: for a workspace-pinned PAT, GET /auth/tokens lists only the user's tokens pinned to the same workspace, and DELETE /auth/tokens/{id} revokes only those (DeleteUserAPITokenInWorkspace: id, user and workspace in one statement); any other id is the missing-id 404. - Q1: an MCP tool mapped to a global route (pad_workspace create) is refused for a pinned PAT, tested through wireMCP. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test(e2e): the suite token is unpinned (BUG-3982 CI) global-setup minted the browser suite's token with a workspace_id on a stale premise (api_tokens once required one). Under BUG-3982 a pinned token is refused the global routes the UI calls on every page, so the E2E jobs failed on 403s for /me/workspace-tabs, /events/stream and /auth/export. task-3415's deliberate pin is kept. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): refuse import-status to a pinned token; the route walk checks admitted routes (BUG-3982 r3) GET /api/v1/workspaces/import-status sits outside the /{slug} subrouter, but the token route gate read "import-status" as a slug and admitted a pinned token, which could then read the user's import outcome for any workspace by key. It joins reorder, import and deleted as a refused segment. The route walk skipped every route the gate admits, so it could not catch a route admitted wrongly. Each admitted route must now sit inside the workspace subrouter or be in a hand-written expected list, and the list must match what the gate admits. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): pin refusals precede slug resolution; a foreign pin is signed out on public routes (BUG-3982 r4) r4 rulings (lead, on BUG-3982): - On every door that takes a workspace by name (RequireWorkspaceAccess, SSE /events?workspace=, search ?workspace=), a pinned token naming any other workspace is refused before the name is resolved (pinAdmitsWorkspaceRef), so a nonexistent, non-member and member slug get byte-identical refusals. The resolved-ID compares stay behind it. - The email-restricted share link and the invitation preview read the caller through signedInFor: a token pinned to another workspace counts as signed out there. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * test: SSE compares a pinned token by resolved ID behind the by-name check (BUG-3982 r4) The r4 pre-resolution check shadows the SSE ID compare, and the 14-mutant rerun showed that mutant surviving. This direct test, shaped like TestBUG3982_WorkspaceAccessComparesThePinByID, holds it. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): copy target and ref resolver refuse a foreign pin before resolving (BUG-3982 r5) r5 rulings (lead, on BUG-3982): - authorizeCrossWorkspace refuses a pinned token naming any other target_workspace before resolving it, so copy and preflight give a missing, non-member and member target the same refusal. The OAuth allow-list check, reported after the role check, is unchanged. - The /-/r/ resolver checks the pin by name before resolving; its answer was already the same 404. - Both join the identical-bytes test; the resolver also gets a by-ID test, since its resolved-ID compare is now behind the by-name one. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4 * fix(security): cross-workspace authorization compares the pin by resolved ID; collab by-ID test; docs separators (BUG-3982 r6) r6 rulings (lead, on BUG-3982): - authorizeCrossWorkspace compares the pin by resolved ID behind its by-name check, like RequireWorkspaceAccess, SSE, the resolver and collab, with a by-ID test. - Collab's resolved-ID compare gets the by-ID test the others have. - docs/api.md: five BUG-3982 sentences appended without a separator now end the sentence before them. Claude-Session: https://claude.ai/code/session_01WTUZBwmpXmsWxJSaKqZGq4
422 lines
16 KiB
Go
422 lines
16 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/cli"
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
"github.com/PerpetualSoftware/pad/internal/server"
|
|
)
|
|
|
|
// itemListResourceLimit mirrors the pad CLI's default `item list` cap
|
|
// (cmd/pad/cmd_item.go) so the remote items-list resource returns the
|
|
// same bounded slice the stdio transport does.
|
|
const itemListResourceLimit = 200
|
|
|
|
// HTTPResourceFetcher satisfies ResourceFetcher + BinaryResourceFetcher
|
|
// for the remote /mcp transport (PLAN-943). Where ExecResourceFetcher
|
|
// shells out to the pad binary — inheriting one user's ~/.pad/credentials
|
|
// context, which is unusable in the shared multi-OAuth-user cloud process
|
|
// — this dispatches each resource's underlying read in-process through the
|
|
// pad-cloud handler chain, reproducing the CLI's `--format json` output
|
|
// that the resource handlers in resources.go parse.
|
|
//
|
|
// It reuses the tool dispatcher's user resolution + buildAuthedRequest
|
|
// (token-scope check, verified-email gate, consent Apply) so resource
|
|
// reads honor the exact same auth/consent perimeter as tool calls. Every
|
|
// resource read is a GET or HEAD, so the mutating-only verified-email gate
|
|
// is a no-op here and a read scope always passes.
|
|
//
|
|
// Because it implements both fetch interfaces, RegisterResources wires the
|
|
// full read-only resource set (items, items/{ref}, dashboard, collections,
|
|
// bootstrap, workspaces, attachments/{id}) onto the remote transport with
|
|
// the SAME handlers the stdio path uses — formatItemAsMarkdown, the
|
|
// attachment size/MIME bounds, base64 encoding, and MIME sniffing are all
|
|
// shared, so the rendered output (markdown, blob bytes) can't drift.
|
|
//
|
|
// Parity is on the DATA, not the literal bytes: pass-through JSON resources
|
|
// return the compact endpoint body, whereas the stdio path pipes the same
|
|
// data through the CLI's `cli.PrintJSON` (2-space indented, except bootstrap
|
|
// which the CLI also emits compact). The JSON is semantically identical
|
|
// across transports; only the whitespace differs.
|
|
type HTTPResourceFetcher struct {
|
|
d *HTTPHandlerDispatcher
|
|
}
|
|
|
|
// NewHTTPResourceFetcher builds a fetcher backed by an already-configured
|
|
// tool dispatcher (its Handler + UserResolver + auth perimeter are reused).
|
|
func NewHTTPResourceFetcher(d *HTTPHandlerDispatcher) *HTTPResourceFetcher {
|
|
return &HTTPResourceFetcher{d: d}
|
|
}
|
|
|
|
// Fetch translates the fixed CLI-shaped arg vectors that resources.go
|
|
// emits into an in-process HTTP read and returns the response text as the
|
|
// resource handler expects it (matching the pad CLI's `--format json`).
|
|
func (f *HTTPResourceFetcher) Fetch(ctx context.Context, args []string) (string, error) {
|
|
user := f.d.UserResolver(ctx)
|
|
if user == nil {
|
|
return "", fmt.Errorf("resource fetch: no authenticated user in context")
|
|
}
|
|
flags, pos := splitResourceArgs(args)
|
|
switch resourceCmdKey(pos) {
|
|
case "item show":
|
|
ws, ref := flags["workspace"], nthPositional(pos, 2)
|
|
if ws == "" || ref == "" {
|
|
return "", fmt.Errorf("item show: workspace and ref required (args: %v)", args)
|
|
}
|
|
body, err := f.getJSON(ctx, user, "/api/v1/workspaces/"+url.PathEscape(ws)+"/items/"+url.PathEscape(ref))
|
|
return string(body), err
|
|
case "item list":
|
|
ws := flags["workspace"]
|
|
if ws == "" {
|
|
return "", fmt.Errorf("item list: workspace required")
|
|
}
|
|
return f.fetchItemList(ctx, user, ws, flags)
|
|
case "project dashboard":
|
|
ws := flags["workspace"]
|
|
if ws == "" {
|
|
return "", fmt.Errorf("project dashboard: workspace required")
|
|
}
|
|
body, err := f.getJSON(ctx, user, "/api/v1/workspaces/"+url.PathEscape(ws)+"/dashboard")
|
|
return string(body), err
|
|
case "collection list":
|
|
ws := flags["workspace"]
|
|
if ws == "" {
|
|
return "", fmt.Errorf("collection list: workspace required")
|
|
}
|
|
body, err := f.getJSON(ctx, user, "/api/v1/workspaces/"+url.PathEscape(ws)+"/collections")
|
|
return string(body), err
|
|
case "bootstrap":
|
|
ws := flags["workspace"]
|
|
if ws == "" {
|
|
return "", fmt.Errorf("bootstrap: workspace required")
|
|
}
|
|
body, err := f.getJSON(ctx, user, "/api/v1/workspaces/"+url.PathEscape(ws)+"/agent/bootstrap")
|
|
return string(body), err
|
|
case "workspace list":
|
|
return f.fetchWorkspaceList(ctx, user)
|
|
case "attachment show":
|
|
return f.fetchAttachmentMetadata(ctx, user, flags, pos)
|
|
default:
|
|
return "", fmt.Errorf("resource fetch: unsupported command (args: %v)", args)
|
|
}
|
|
}
|
|
|
|
// Bootstrap satisfies BootstrapFetcher, so the remote transport's
|
|
// pad_set_workspace embeds the same blob its bootstrap resource serves
|
|
// (BUG-3326). It is the resource read itself, run as the dispatcher's
|
|
// resolved user through the same auth/consent perimeter, so the embed can
|
|
// never reach a workspace the token cannot read.
|
|
func (f *HTTPResourceFetcher) Bootstrap(ctx context.Context, workspace string) ([]byte, error) {
|
|
if workspace == "" {
|
|
return nil, fmt.Errorf("bootstrap fetcher: workspace is required")
|
|
}
|
|
body, err := f.Fetch(ctx, []string{"bootstrap", "--workspace", workspace, "--format", "json"})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []byte(body), nil
|
|
}
|
|
|
|
// FetchBytes serves the attachment-download resource read in-process,
|
|
// bounding retained output at the resource limit so a large blob can't
|
|
// balloon memory in the shared cloud process (the stdout-cap analog of
|
|
// ExecResourceFetcher.FetchBytes — preserves PR #933's download bound).
|
|
func (f *HTTPResourceFetcher) FetchBytes(ctx context.Context, args []string) ([]byte, error) {
|
|
user := f.d.UserResolver(ctx)
|
|
if user == nil {
|
|
return nil, fmt.Errorf("resource fetch: no authenticated user in context")
|
|
}
|
|
flags, pos := splitResourceArgs(args)
|
|
if resourceCmdKey(pos) != "attachment download" {
|
|
return nil, fmt.Errorf("resource fetch bytes: unsupported command (args: %v)", args)
|
|
}
|
|
ws, id := flags["workspace"], nthPositional(pos, 2)
|
|
if ws == "" || id == "" {
|
|
return nil, fmt.Errorf("attachment download: workspace and id required")
|
|
}
|
|
urlPath := attachmentVariantPath(ws, id, flags["variant"])
|
|
w := newCappedResponseWriter(attachmentResourceMaxBytes + 1)
|
|
if err := f.serve(ctx, user, http.MethodGet, urlPath, w); err != nil {
|
|
return nil, err
|
|
}
|
|
if w.status >= 400 {
|
|
return nil, resourceHTTPError(urlPath, w.status, w.body.Bytes())
|
|
}
|
|
if w.exceededCap() {
|
|
return nil, fmt.Errorf("attachment download %s: output exceeded %d-byte cap", id, attachmentResourceMaxBytes)
|
|
}
|
|
return w.body.Bytes(), nil
|
|
}
|
|
|
|
// fetchItemList reproduces `pad item list --all --format json`: the CLI
|
|
// projects the raw []models.Item into the token-light ItemSummary shape,
|
|
// so we do the same via the shared cli.ToItemSummaries to keep the remote
|
|
// resource identical to stdio.
|
|
func (f *HTTPResourceFetcher) fetchItemList(ctx context.Context, user *models.User, ws string, flags map[string]string) (string, error) {
|
|
q := url.Values{}
|
|
// readItems asks for one row past the cap (BUG-3793), as the CLI's
|
|
// --limit does on stdio.
|
|
limit := itemListResourceLimit
|
|
if n, err := strconv.Atoi(flags["limit"]); err == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
if _, all := flags["all"]; !all {
|
|
// Match `pad item list` (cmd/pad/cmd_item.go): the default hides
|
|
// terminal-status items via non_terminal; --all lifts ONLY that
|
|
// filter. Critically, --all does NOT set include_archived — the CLI
|
|
// keeps soft-deleted items (deleted_at IS NULL) hidden either way, so
|
|
// we must not surface them here (the resource always passes --all).
|
|
q.Set("non_terminal", "true")
|
|
}
|
|
body, err := f.getJSON(ctx, user, "/api/v1/workspaces/"+url.PathEscape(ws)+"/items?"+q.Encode())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var items []models.Item
|
|
if err := json.Unmarshal(body, &items); err != nil {
|
|
return "", fmt.Errorf("item list: decode items: %w", err)
|
|
}
|
|
enc, err := json.Marshal(cli.ToItemSummaries(items))
|
|
if err != nil {
|
|
return "", fmt.Errorf("item list: encode summaries: %w", err)
|
|
}
|
|
return string(enc), nil
|
|
}
|
|
|
|
// workspaceListEntry mirrors the projection cmd/pad/cmd_workspace.go emits
|
|
// for `pad workspace list --format json`. The `default` flag the CLI adds
|
|
// is CWD-derived (local stdio only), so it has no meaning on the remote
|
|
// transport and is intentionally omitted.
|
|
type workspaceListEntry struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// fetchWorkspaceList reproduces `pad workspace list --format json`: the CLI
|
|
// projects []models.Workspace down to {slug, name, updated_at}, so we match.
|
|
func (f *HTTPResourceFetcher) fetchWorkspaceList(ctx context.Context, user *models.User) (string, error) {
|
|
body, err := f.getJSON(ctx, user, "/api/v1/workspaces")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var wss []models.Workspace
|
|
if err := json.Unmarshal(body, &wss); err != nil {
|
|
return "", fmt.Errorf("workspace list: decode workspaces: %w", err)
|
|
}
|
|
// Honor the OAuth token's consent allow-list: the /api/v1/workspaces
|
|
// handler returns every membership, so — unlike per-workspace routes —
|
|
// nothing scopes it to the workspaces the token was granted. Filter here
|
|
// with the canonical set builder (nil/wildcard → no filter, so an
|
|
// unpinned PAT and local stdio are unaffected, while a pinned PAT carries
|
|
// the one-slug list, BUG-3982; a specific allow-list → intersect) so
|
|
// this resource can't enumerate the slugs of workspaces the user never
|
|
// consented to expose.
|
|
allowSet := server.TokenAllowedWorkspaceSet(ctx)
|
|
entries := make([]workspaceListEntry, 0, len(wss))
|
|
for _, ws := range wss {
|
|
if allowSet != nil {
|
|
if _, ok := allowSet[ws.Slug]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
// Always emit updated_at (RFC3339) — the CLI's `pad workspace list`
|
|
// does the same, even for a zero timestamp — so a consumer that
|
|
// sorts on the field sees it on both transports.
|
|
entries = append(entries, workspaceListEntry{
|
|
Slug: ws.Slug,
|
|
Name: ws.Name,
|
|
UpdatedAt: ws.UpdatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
enc, err := json.Marshal(entries)
|
|
if err != nil {
|
|
return "", fmt.Errorf("workspace list: encode entries: %w", err)
|
|
}
|
|
return string(enc), nil
|
|
}
|
|
|
|
// fetchAttachmentMetadata reproduces `pad attachment show --format json`.
|
|
// That command issues a HEAD (no body) and synthesizes JSON from the
|
|
// response headers, so we do the same. readAttachment reads only `mime`
|
|
// and `size`, but we include the full set for shape parity with the CLI
|
|
// and dispatchAttachmentShow.
|
|
func (f *HTTPResourceFetcher) fetchAttachmentMetadata(ctx context.Context, user *models.User, flags map[string]string, pos []string) (string, error) {
|
|
ws, id := flags["workspace"], nthPositional(pos, 2)
|
|
if ws == "" || id == "" {
|
|
return "", fmt.Errorf("attachment show: workspace and id required")
|
|
}
|
|
urlPath := attachmentVariantPath(ws, id, flags["variant"])
|
|
rec := httptest.NewRecorder()
|
|
if err := f.serve(ctx, user, http.MethodHead, urlPath, rec); err != nil {
|
|
return "", err
|
|
}
|
|
if rec.Code >= 400 {
|
|
return "", resourceHTTPError(urlPath, rec.Code, rec.Body.Bytes())
|
|
}
|
|
// Shared with the pad_attachment tool's dispatchAttachmentShow so the two
|
|
// surfaces for this HEAD-derived shape stay in lockstep.
|
|
enc, err := json.Marshal(synthesizeAttachmentMetadata(rec.Result().Header, id))
|
|
if err != nil {
|
|
return "", fmt.Errorf("attachment show: encode metadata: %w", err)
|
|
}
|
|
return string(enc), nil
|
|
}
|
|
|
|
// getJSON serves a GET in-process and returns the response body, mapping
|
|
// any 4xx/5xx to a Go error (the resource protocol surfaces these as
|
|
// JSON-RPC errors).
|
|
func (f *HTTPResourceFetcher) getJSON(ctx context.Context, user *models.User, urlPath string) ([]byte, error) {
|
|
rec := httptest.NewRecorder()
|
|
if err := f.serve(ctx, user, http.MethodGet, urlPath, rec); err != nil {
|
|
return nil, err
|
|
}
|
|
if rec.Code >= 400 {
|
|
return nil, resourceHTTPError(urlPath, rec.Code, rec.Body.Bytes())
|
|
}
|
|
return rec.Body.Bytes(), nil
|
|
}
|
|
|
|
// serve builds an authed in-process request (reusing the dispatcher's
|
|
// scope/consent/verified-email perimeter) and runs it through the handler.
|
|
func (f *HTTPResourceFetcher) serve(ctx context.Context, user *models.User, method, urlPath string, w http.ResponseWriter) error {
|
|
req, err := f.d.buildAuthedRequest(ctx, method, urlPath, nil, user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.d.Handler.ServeHTTP(w, req)
|
|
return nil
|
|
}
|
|
|
|
// attachmentVariantPath builds the attachment blob URL with an optional
|
|
// ?variant= query (the resource layer always requests thumb-md).
|
|
func attachmentVariantPath(ws, id, variant string) string {
|
|
p := "/api/v1/workspaces/" + url.PathEscape(ws) + "/attachments/" + url.PathEscape(id)
|
|
if variant != "" {
|
|
p += "?variant=" + url.QueryEscape(variant)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// resourceHTTPError renders a non-2xx in-process response as a Go error,
|
|
// trimming the body so an oversized error page can't blow up the message.
|
|
func resourceHTTPError(urlPath string, status int, body []byte) error {
|
|
msg := strings.TrimSpace(string(body))
|
|
if len(msg) > 500 {
|
|
msg = msg[:500] + "…"
|
|
}
|
|
if msg == "" {
|
|
msg = http.StatusText(status)
|
|
}
|
|
return fmt.Errorf("GET %s: HTTP %d: %s", urlPath, status, msg)
|
|
}
|
|
|
|
// resourceCmdKey identifies the pad command from the positional tokens of
|
|
// a resource-fetch arg vector. The resource handlers in resources.go are
|
|
// the only callers, so the leading tokens are a fixed, closed set.
|
|
func resourceCmdKey(pos []string) string {
|
|
if len(pos) == 0 {
|
|
return ""
|
|
}
|
|
switch pos[0] {
|
|
case "item", "project", "collection", "workspace", "attachment":
|
|
if len(pos) >= 2 {
|
|
return pos[0] + " " + pos[1]
|
|
}
|
|
}
|
|
return pos[0]
|
|
}
|
|
|
|
// nthPositional returns pos[n] or "" when absent.
|
|
func nthPositional(pos []string, n int) string {
|
|
if n < len(pos) {
|
|
return pos[n]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// splitResourceArgs separates a resource-fetch arg vector into flag
|
|
// key/values and bare positionals. The input space is closed (only the
|
|
// resources.go handlers call the fetcher), so a simple `--flag value` /
|
|
// `--bareflag` scan is sufficient: `--all` becomes a valueless flag,
|
|
// `--workspace docapp` / `--variant thumb-md` / `--format json` become
|
|
// key/values, and subcommands / refs / ids (including the `-` stdout sink)
|
|
// land in positionals.
|
|
func splitResourceArgs(args []string) (flags map[string]string, pos []string) {
|
|
flags = map[string]string{}
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
if strings.HasPrefix(a, "--") {
|
|
name := strings.TrimPrefix(a, "--")
|
|
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
|
|
flags[name] = args[i+1]
|
|
i++
|
|
} else {
|
|
flags[name] = ""
|
|
}
|
|
continue
|
|
}
|
|
pos = append(pos, a)
|
|
}
|
|
return flags, pos
|
|
}
|
|
|
|
// cappedResponseWriter is an http.ResponseWriter that retains at most
|
|
// `limit` bytes of the response body (silently discarding the rest so the
|
|
// handler's io.Copy keeps draining) and records whether it overflowed. Used
|
|
// for the attachment-download read so an oversized blob can't balloon memory
|
|
// in the shared cloud process. It reuses ExecResourceFetcher's cappedWriter
|
|
// for the byte-capping algorithm — the same 1 MiB download bound — and only
|
|
// adds the http.ResponseWriter shims, so the cap logic lives in one place.
|
|
type cappedResponseWriter struct {
|
|
header http.Header
|
|
status int
|
|
wroteHdr bool
|
|
body bytes.Buffer
|
|
capped *cappedWriter
|
|
}
|
|
|
|
// newCappedResponseWriter wires the writer's body buffer into a cappedWriter
|
|
// bounded at limit. Must be used (not a bare struct literal) so `capped` is
|
|
// non-nil before the first Write.
|
|
func newCappedResponseWriter(limit int64) *cappedResponseWriter {
|
|
w := &cappedResponseWriter{}
|
|
w.capped = &cappedWriter{buf: &w.body, limit: limit}
|
|
return w
|
|
}
|
|
|
|
func (w *cappedResponseWriter) Header() http.Header {
|
|
if w.header == nil {
|
|
w.header = http.Header{}
|
|
}
|
|
return w.header
|
|
}
|
|
|
|
func (w *cappedResponseWriter) WriteHeader(status int) {
|
|
if !w.wroteHdr {
|
|
w.status = status
|
|
w.wroteHdr = true
|
|
}
|
|
}
|
|
|
|
func (w *cappedResponseWriter) Write(p []byte) (int, error) {
|
|
if !w.wroteHdr {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
return w.capped.Write(p)
|
|
}
|
|
|
|
// exceededCap reports whether the response body overran the byte limit.
|
|
func (w *cappedResponseWriter) exceededCap() bool { return w.capped.exceeded }
|