SPEC-0005: Webhook Inspector
Overview
The webhook share type (badge HK, URL cairn.stump.wtf/<id>) is a live requestbin: an
endpoint an agent or a service points at, whose inbound HTTP requests stream into a web
inspector (highlighted JSON, method/status, a status mix) and are readable by agents over
MCP as the same stream (mcp://cairn/hook/<id>). Unlike every other share type its body
is not pushed by its owner — it is filled by unauthenticated third parties hitting an open
ingress URL. Captured requests land in a capped, seq-ordered ring buffer: metadata in
PostgreSQL, bodies in object storage. One capture log fans out to browsers over SSE and to
agents over MCP. Requests are reactable but NOT comment-threaded — discussion moves to
the artifacts they produce.
This spec realizes ADR-0010 (live webhook endpoints and real-time stream capture). It builds on SPEC-0002 for the artifact envelope, provenance, access, and expiry; stores request bodies in ADR-0008 object storage; constrains annotations per ADR-0006 / SPEC-0006 (reactions only, no threads); and delivers live over the SSE transport of ADR-0012.
Security note. The ingress endpoint is an anonymous-write, internet-facing attack surface. Its Security Requirements below are CRITICAL, not routine: no code execution ever, strict size limits, rate limiting, an unguessable id, and retention caps are load-bearing parts of the design.
HTTP endpoints
Auth values: Required = authenticated owner/actor (session for web, OAuth 2.1 bearer for
API/MCP/CLI per ADR-0004); Link-cap = the ADR-0007 read capability (possession of the
unguessable cairn.stump.wtf/<id> link grants read, no reader account); Public = open ingress,
justified per-row.
| Method & Path | Purpose | Auth |
|---|---|---|
POST /v1/hooks | Create a webhook endpoint (owner); returns id, ingress URL, mcp://cairn/hook/<id> | Required |
GET /v1/hooks/{id} | Endpoint metadata + recent request buffer | Link-cap |
GET /v1/hooks/{id}/requests | List captured requests (keyset paginated, seq order) | Link-cap |
GET /v1/hooks/{id}/requests/{seq} | Full detail of one captured request (metadata + body ref) | Link-cap |
GET /v1/hooks/{id}/requests/{seq}/body | Raw captured body bytes (lazy, checksum-verifiable) | Link-cap |
GET /v1/hooks/{id}/stream | SSE: live captured requests (ADR-0010/ADR-0012) | Link-cap |
ANY /h/{id} (also hook.cairn.stump.wtf/{id}) | Ingress: capture an inbound request of any method | Public |
The ingress row is the single Public endpoint in Cairn. Justification: a requestbin's
entire purpose is to receive requests from anonymous third parties; it is defended not by
authentication but by an unguessable id, strict rate/size limits, header hygiene, no payload
execution, and retention caps (see Security Requirements). Reactions on a captured request
are served by the generic annotation endpoint of SPEC-0002/SPEC-0006
(/v1/artifacts/{id}/reactions); comments are structurally rejected (below).
Requirements
Requirement: Webhook Endpoint and Two Addresses
A webhook endpoint MUST be created by its owner over the web, CLI, or MCP and MUST be an
ordinary Cairn artifact (share type an owner creates a webhook endpoint the response MUST include the human an endpoint passes its the ingress MUST stop accepting requests and the captured stream, records, and bodies MUST be dropped with the artifactwebhook) with provenance, a link-based access policy,
and a default TTL (ADR-0007). It MUST expose two addresses for the same endpoint: a public
HTTP ingress URL (hook.cairn.stump.wtf/<id> or cairn.stump.wtf/h/<id>) that accepts inbound
requests of any method, and an mcp://cairn/hook/<id> handle agents use to read the
captured stream. The <id> MUST be the short, opaque, unguessable base62 id of ADR-0005.Scenario: Endpoint exposes both addresses
cairn.stump.wtf/<id> URL, the HTTP ingress URL, and the mcp://cairn/hook/<id> handle for the same endpointScenario: Expired endpoint stops capturing
expires_at
Requirement: Request Capture and Storage
Each accepted inbound request MUST become one request record with metadata in
PostgreSQL — the inspector computes the status mix and method counts it MUST read only PostgreSQL request rows, never the object-storage bodies a JSON payload is captured the bytes MUST be stored unchanged, referenced by SHA-256 webhook_id, a monotonic seq (stream order), received_at, method,
path, query, selected/normalized headers, the fixed status Cairn returned,
content_type, and body_size — and its raw body written to ADR-0008 object storage,
content-addressed by SHA-256 and referenced from the record by a body_ref (hash + size).
Bodies MUST be stored verbatim (no transformation on ingest; syntax highlighting is a
render-time concern) and MUST dedup by hash. Bodies MUST be fetched lazily when a request is
expanded.Scenario: Metadata queryable without reading the body
Scenario: Body stored verbatim and content-addressed
body_ref, and an identical body MUST reference the same blob
Requirement: Fixed Benign Response
The ingress MUST always return a fixed, benign response (a a payload attempts to specify a response status or redirect the ingress MUST ignore it and return the fixed configured response, recording that status on the record200 with a short
acknowledgment by default). The response Cairn returns MUST be data it records, never
behavior the payload can steer. Cairn MUST NOT evaluate, deserialize-to-execute, follow, or
otherwise act on the payload.Scenario: Response is not payload-controlled
Requirement: Ring-Buffer Retention and Caps
A webhook endpoint MUST be a bounded live buffer enforced two ways: a per-endpoint request
cap (a ring buffer of at most the last N requests, e.g. N = 500) whose overflow evicts the
oldest record and dereferences its body blob, and the endpoint TTL (ADR-0007) that
bounds lifetime. On overflow the oldest record MUST be evicted so an anonymous flood can at
worst churn one endpoint's buffer, never exhaust global storage. an endpoint at its N-request cap captures request N+1 the oldest record MUST be evicted and its body blob dereferenced (subject to ADR-0008 refcounted GC) one endpoint receives far more than N requests its retained record and body footprint MUST stay bounded by NScenario: Overflow evicts the oldest
Scenario: Bounded footprint under flood
Requirement: One Stream, Two Transports (Live Fan-out)
Capture MUST write the record, then fan the new a browser (SSE) and an agent (MCP) watch the same endpoint while requests arrive both MUST observe the identical an inspector opens on an endpoint that already captured requests it MUST load the recent buffer and then receive subsequent captures liveseq out to live subscribers off one
seq-ordered log: to browsers over SSE (GET /v1/hooks/{id}/stream, ADR-0012) and to
agents over MCP (mcp://cairn/hook/<id>), yielding the identical ordered records. A new
subscriber MUST first receive the recent buffer, then subscribe to tail updates, so a freshly
opened inspector shows history and live arrivals. SSE reconnection MUST resume from
Last-Event-ID.Scenario: Human and agent see the same order
seq-ordered streamScenario: Late joiner sees history then tail
Requirement: Inspector Viewer
The web inspector MUST render a live request list (newest prepended), a full request
inspect view (method, path, the fixed response status, headers, and the body with
render-time JSON syntax highlighting), and a status mix summarizing the response-status
distribution across the buffer. Captured payloads MUST be displayed as inert escaped text
(see Security). Large bodies MUST load lazily on expand. a user opens a captured request the inspector MUST show its method, status, headers, and highlighted body, loading the body lazily the buffer contains a distribution of response statuses the status-mix summary MUST reflect that distribution, computed from request rowsScenario: Inspect a captured request
Scenario: Status mix reflects the buffer
Requirement: Reactions-Only Annotation
A captured request MUST be reactable but NOT comment-threaded, enforced structurally by
the webhook registry entry (ADR-0006 / SPEC-0006): the reaction capability set is
an authenticated actor reacts 👀 to a captured request the reaction MUST persist against a any actor attempts to comment on a webhook request or the webhook artifact the server MUST refuse it (the comment capability set is empty), exposing no comment-thread affordance{ artifact, webhook_request } and the comment capability set is empty. A reaction on a
webhook_request anchor MUST be accepted; any comment on any webhook anchor MUST be refused.
This is a deliberate steer — durable discussion happens on the artifacts a stream produces,
not on the live buffer.Scenario: React on a single request
webhook_request anchor carrying that request's idScenario: Comment on a request refused
Requirement: v1 Non-Goals Are Absent
The v1 inspector MUST NOT implement a first-request empty-state affordance or stream
filtering by status/event. These "try next" features MUST be additive read-side features and
MUST NOT change the capture model. the inspector is inspected for filter controls it MUST NOT offer status/event filtering of the stream in v1Scenario: No stream filtering in v1
Requirement: Error Handling Standards
Every layer boundary (ingress/adapter → core service → PostgreSQL / object storage) MUST wrap
errors with context preserving the underlying error, so a handler maps a domain failure to a
stable error capture fails internally (e.g. object-storage write error) the ingress MUST return a benign fixed response, and the wrapped error MUST be logged with the a body exceeds the hard size cap the service MUST return a distinct body-too-large sentinel that maps to code (ADR-0012) without string-matching. Sentinel errors MUST be defined for
domain failures callers distinguish — endpoint-not-found, endpoint-expired, body-too-large,
and rate-limited. Errors MUST NOT be silently swallowed; every rejection (oversize, throttle,
expired) MUST be recorded with structured (key-value) logging carrying the request_id. A
capture failure MUST NOT leak an internal error or stack to the anonymous ingress caller.Scenario: Ingress error is opaque to the caller
request_id, never surfaced to the anonymous callerScenario: Distinct sentinel for oversize
payload_too_large, not a generic error
Requirement: Concurrency Safety
Every core service method MUST take a many requests hit one endpoint concurrently each captured record MUST receive a distinct, gap-free an inspector's SSE connection drops its subscription MUST be torn down and its resources released via context cancellationcontext.Context first argument, and cancellation and
timeout MUST propagate across all concurrent boundaries (ingress → capture → SSE fan-out). The
per-endpoint fan-out MUST have an explicit worker lifecycle (subscribe on connect, guaranteed
unsubscribe on disconnect or endpoint expiry) and MUST access shared subscriber state
race-safely. Concurrent captures on one endpoint MUST assign seq monotonically without gaps
or collisions, and ring-buffer eviction MUST be race-safe against concurrent captures and
reads. Race detection MUST run in CI.Scenario: Concurrent captures keep seq monotonic
seqScenario: Disconnected inspector is reaped
Requirement: Database Operation Standards
Multi-step mutations — inserting a request record while evicting an overflow record and
adjusting counts — MUST execute in a single transaction so the buffer is never observed in a
partial state. Database access MUST use an explicit connection lifecycle with timeouts
propagated from the request context. All SQL MUST use bound parameters; no query may be
assembled by concatenating caller input (especially the anonymous ingress path, whose
method/path/headers are attacker-controlled). a capture at the cap inserts a new record and evicts the oldest both changes MUST commit in one transaction so the buffer never exceeds N nor loses ordering an inbound request's method, path, or headers are persisted they MUST be bound as query parameters, never interpolated into SQLScenario: Capture-and-evict is atomic
Scenario: Attacker-controlled fields are parameterized
Security Requirements
This capability is web-facing and exposes an open, anonymous-write ingress endpoint. The following are MANDATORY and CRITICAL.
Requirement: No Payload Execution / Inert Capture
Cairn MUST NEVER execute, evaluate, deserialize-to-execute, shell out on, follow, or
otherwise act on a captured payload. The ingress MUST parse only enough to store and index
(content-type, size); the payload is stored bytes and indexed metadata, nothing more. The
inspector MUST render payloads as inert escaped text. a request body contains code, a script, or a serialized object Cairn MUST store it as inert bytes, index only its metadata, and never execute or deserialize-to-execute itScenario: Payload is never executed
Requirement: Authentication & Authorization
Endpoint creation and management ( an unauthenticated client calls the server MUST respond 401 and make no change an anonymous caller posts to the ingress it MUST be able to submit a request but MUST NOT thereby read the captured stream or list any endpointPOST /v1/hooks, TTL/sharing changes) MUST require
authentication (session for web, OAuth 2.1 bearer for API/MCP/CLI per ADR-0004). Read
endpoints MUST enforce the ADR-0007 link-capability policy, returning a uniform 404 for
unknown, unauthorized, or expired ids alike. The ingress endpoint is intentionally
anonymous-write (justified above) but confers no read access and no ability to enumerate or
manage endpoints. Agents MUST NOT exceed their human's permissions and MUST NOT receive a
sharing-management scope.Scenario: Unauthenticated management call
POST /v1/hooks or changes an endpoint's TTLScenario: Ingress grants no read
Requirement: Rate Limiting
The ingress endpoint MUST be rate-limited per-endpoint and per-source-IP; excess requests
MUST receive a a source exceeds the configured ingress rate on an endpoint the server MUST respond 429 and capture nothing from the throttled requests429 (with Retry-After) and MUST NOT be captured, protecting the ring
buffer and the fan-out. The read/management endpoints MUST be rate-limited per identity/IP as
well.Scenario: Flood on the open ingress
Requirement: Security Headers
Responses MUST set a strict Content-Security-Policy, a captured body contains active content (script/HTML) the inspector MUST render it as inert escaped text and the CSP MUST prevent it running in Cairn's originX-Content-Type-Options: nosniff, a
Referrer-Policy, and (over HTTPS) HSTS. Captured request bodies and headers are untrusted
attacker-supplied content and MUST be served/rendered so they cannot execute in Cairn's
origin (inert, escaped, and served with a non-executable content-type / isolated context).Scenario: Untrusted HTML/script body
Requirement: Request Body Size Limits
Every captured request MUST be bounded by a hard body-size cap (e.g. a few MB). A request
above the cap MUST be rejected with an inbound request exceeds the configured body-size cap the ingress MUST reject it with 413 (or truncate and flag it) without buffering the whole body or persisting a partial blob413, or truncated with a truncation flag set on the
record, before the full body is buffered — so one request cannot blow past object-storage
tiering or memory, and no partial blob is persisted on rejection.Scenario: Oversize inbound body
Requirement: CSRF Protection
Session-authenticated state-changing requests (creating or managing an endpoint from the web
app) MUST be CSRF-protected via token or SameSite strategy. Token-authenticated API/MCP/CLI
management is exempt (no ambient cookie credential). The anonymous ingress is deliberately
cross-origin (it receives webhooks from anywhere) and MUST NOT rely on cookies or ambient
credentials, so it is not — and must never become — a CSRF-sensitive, cookie-authenticated
surface. a state-changing management request arrives on a session-auth route without a valid CSRF token the server MUST reject itScenario: Cross-site management post
Requirement: Redirect & SSRF Validation
Cairn MUST NOT follow or fetch any URL contained in a captured request (headers, body, or
query) — a captured payload is stored data, never a server-side fetch Cairn performs. Any
redirect in the web app MUST target an allow-listed internal path; no user- or
attacker-supplied absolute URL is honored for redirects. a captured body or header contains a URL Cairn MUST store it as inert data and MUST NOT issue any server-side request to itScenario: Captured URL is not fetched
Requirement: Unguessable ID & No Enumeration
Endpoint ids MUST be the short, opaque, unguessable base62 ids of ADR-0005 — unguessability
is the ingress endpoint's first line of defense. There MUST be no listing of endpoints one
does not own, and id resolution MUST return a uniform 404 for unknown ids so probing leaks
nothing. a client probes sequential or guessed ids each unknown id MUST return a uniform 404 and no listing of others' endpoints MUST be availableScenario: Enumeration attempt
Requirement: Header Hygiene & Ephemerality as Containment
Sensitive and hop-by-hop headers MUST be normalized or dropped on capture, so a stored record
is a sanitized projection rather than a replayable credential dump. The endpoint's default TTL
(ADR-0007) MUST bound its lifetime so an abused endpoint self-heals on expiry, removing all
its records and bodies. an inbound request carries hop-by-hop or sensitive headers the stored record MUST normalize or drop them, not persist a replayable credential set an endpoint is flooded and then reaches its TTL it MUST stop capturing and all its records and bodies MUST be removedScenario: Hop-by-hop headers dropped
Scenario: Abused endpoint self-heals
Accessibility Requirements
This capability renders user-facing UI. WCAG 2.1 AA is the minimum target.
Requirement: WCAG 2.1 AA & Semantics
All inspector UI MUST meet WCAG 2.1 AA. Page structure MUST use ARIA landmarks (banner,
navigation, main, contentinfo). Request status and method MUST NOT be conveyed by color
alone: status badges (2xx/4xx/5xx) and method badges (GET/POST/…) and the status mix MUST
also carry a text or shape cue. a request's response status is shown by badge color an equivalent text/shape cue (e.g. the numeric status or a labeled shape) MUST also be presentScenario: Status is not color-only
Requirement: Icon-Only Controls
Every icon-only control (copy link, the react it MUST expose an ◆ mcp affordance, Share, react +, panel toggle,
request expand/collapse) MUST have an aria-label describing its action.Scenario: Icon button
+ control has no visible text labelaria-label describing its action
Requirement: Dynamic Content Regions
The live request list is an SSE/HTMX-updated region and MUST use a new webhook request streams into the inspector it MUST be announced via an aria-live (polite for
normal arrivals) so a newly captured request is announced to assistive technology.Scenario: New streamed request
aria-live region
Requirement: Keyboard Navigation & Focus Management
All interactive elements — request rows, the inspect/expand control, the reaction picker, the
share dialog, the collapsible panel — MUST be keyboard-operable (logical tab order;
Enter/Space activate; Escape dismisses popovers/dialogs; arrow keys within the request list).
The reaction picker and share dialog MUST trap focus while open and restore it to the trigger
on close. a keyboard user opens the reaction picker on a request focus MUST move into it, cycle within it, and return to the trigger on closeScenario: Keyboard-only reaction