Skip to main content

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 & PathPurposeAuth
POST /v1/hooksCreate a webhook endpoint (owner); returns id, ingress URL, mcp://cairn/hook/<id>Required
GET /v1/hooks/{id}Endpoint metadata + recent request bufferLink-cap
GET /v1/hooks/{id}/requestsList 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}/bodyRaw captured body bytes (lazy, checksum-verifiable)Link-cap
GET /v1/hooks/{id}/streamSSE: 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 methodPublic

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 webhook) 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​

WHEN

an owner creates a webhook endpoint

THEN

the response MUST include the human cairn.stump.wtf/<id> URL, the HTTP ingress URL, and the mcp://cairn/hook/<id> handle for the same endpoint

Scenario: Expired endpoint stops capturing​

WHEN

an endpoint passes its expires_at

THEN

the ingress MUST stop accepting requests and the captured stream, records, and bodies MUST be dropped with the artifact

Requirement: Request Capture and Storage​

Each accepted inbound request MUST become one request record with metadata in PostgreSQL — 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​

WHEN

the inspector computes the status mix and method counts

THEN

it MUST read only PostgreSQL request rows, never the object-storage bodies

Scenario: Body stored verbatim and content-addressed​

WHEN

a JSON payload is captured

THEN

the bytes MUST be stored unchanged, referenced by SHA-256 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 200 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​

WHEN

a payload attempts to specify a response status or redirect

THEN

the ingress MUST ignore it and return the fixed configured response, recording that status on the record

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.

Scenario: Overflow evicts the oldest​

WHEN

an endpoint at its N-request cap captures request N+1

THEN

the oldest record MUST be evicted and its body blob dereferenced (subject to ADR-0008 refcounted GC)

Scenario: Bounded footprint under flood​

WHEN

one endpoint receives far more than N requests

THEN

its retained record and body footprint MUST stay bounded by N

Requirement: One Stream, Two Transports (Live Fan-out)​

Capture MUST write the record, then fan the new seq 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​

WHEN

a browser (SSE) and an agent (MCP) watch the same endpoint while requests arrive

THEN

both MUST observe the identical seq-ordered stream

Scenario: Late joiner sees history then tail​

WHEN

an inspector opens on an endpoint that already captured requests

THEN

it MUST load the recent buffer and then receive subsequent captures live

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.

Scenario: Inspect a captured request​

WHEN

a user opens a captured request

THEN

the inspector MUST show its method, status, headers, and highlighted body, loading the body lazily

Scenario: Status mix reflects the buffer​

WHEN

the buffer contains a distribution of response statuses

THEN

the status-mix summary MUST reflect that distribution, computed from request rows

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 { 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​

WHEN

an authenticated actor reacts 👀 to a captured request

THEN

the reaction MUST persist against a webhook_request anchor carrying that request's id

Scenario: Comment on a request refused​

WHEN

any actor attempts to comment on a webhook request or the webhook artifact

THEN

the server MUST refuse it (the comment capability set is empty), exposing no comment-thread affordance

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.

Scenario: No stream filtering in v1​

WHEN

the inspector is inspected for filter controls

THEN

it MUST NOT offer status/event filtering of the stream 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 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​

WHEN

capture fails internally (e.g. object-storage write error)

THEN

the ingress MUST return a benign fixed response, and the wrapped error MUST be logged with the request_id, never surfaced to the anonymous caller

Scenario: Distinct sentinel for oversize​

WHEN

a body exceeds the hard size cap

THEN

the service MUST return a distinct body-too-large sentinel that maps to payload_too_large, not a generic error

Requirement: Concurrency Safety​

Every core service method MUST take a context.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​

WHEN

many requests hit one endpoint concurrently

THEN

each captured record MUST receive a distinct, gap-free seq

Scenario: Disconnected inspector is reaped​

WHEN

an inspector's SSE connection drops

THEN

its subscription MUST be torn down and its resources released via context cancellation

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).

Scenario: Capture-and-evict is atomic​

WHEN

a capture at the cap inserts a new record and evicts the oldest

THEN

both changes MUST commit in one transaction so the buffer never exceeds N nor loses ordering

Scenario: Attacker-controlled fields are parameterized​

WHEN

an inbound request's method, path, or headers are persisted

THEN

they MUST be bound as query parameters, never interpolated into SQL

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.

Scenario: Payload is never executed​

WHEN

a request body contains code, a script, or a serialized object

THEN

Cairn MUST store it as inert bytes, index only its metadata, and never execute or deserialize-to-execute it

Requirement: Authentication & Authorization​

Endpoint creation and management (POST /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​

WHEN

an unauthenticated client calls POST /v1/hooks or changes an endpoint's TTL

THEN

the server MUST respond 401 and make no change

Scenario: Ingress grants no read​

WHEN

an anonymous caller posts to the ingress

THEN

it MUST be able to submit a request but MUST NOT thereby read the captured stream or list any endpoint

Requirement: Rate Limiting​

The ingress endpoint MUST be rate-limited per-endpoint and per-source-IP; excess requests MUST receive a 429 (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​

WHEN

a source exceeds the configured ingress rate on an endpoint

THEN

the server MUST respond 429 and capture nothing from the throttled requests

Requirement: Security Headers​

Responses MUST set a strict Content-Security-Policy, X-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​

WHEN

a captured body contains active content (script/HTML)

THEN

the inspector MUST render it as inert escaped text and the CSP MUST prevent it running in Cairn's origin

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 413, 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​

WHEN

an inbound request exceeds the configured body-size cap

THEN

the ingress MUST reject it with 413 (or truncate and flag it) without buffering the whole body or persisting a partial blob

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.

Scenario: Cross-site management post​

WHEN

a state-changing management request arrives on a session-auth route without a valid CSRF token

THEN

the server MUST reject it

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.

Scenario: Captured URL is not fetched​

WHEN

a captured body or header contains a URL

THEN

Cairn MUST store it as inert data and MUST NOT issue any server-side request to it

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.

Scenario: Enumeration attempt​

WHEN

a client probes sequential or guessed ids

THEN

each unknown id MUST return a uniform 404 and no listing of others' endpoints MUST be available

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.

Scenario: Hop-by-hop headers dropped​

WHEN

an inbound request carries hop-by-hop or sensitive headers

THEN

the stored record MUST normalize or drop them, not persist a replayable credential set

Scenario: Abused endpoint self-heals​

WHEN

an endpoint is flooded and then reaches its TTL

THEN

it MUST stop capturing and all its records and bodies MUST be removed

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.

Scenario: Status is not color-only​

WHEN

a request's response status is shown by badge color

THEN

an equivalent text/shape cue (e.g. the numeric status or a labeled shape) MUST also be present

Requirement: Icon-Only Controls​

Every icon-only control (copy link, ◆ mcp affordance, Share, react +, panel toggle, request expand/collapse) MUST have an aria-label describing its action.

Scenario: Icon button​

WHEN

the react + control has no visible text label

THEN

it MUST expose an aria-label describing its action

Requirement: Dynamic Content Regions​

The live request list is an SSE/HTMX-updated region and MUST use aria-live (polite for normal arrivals) so a newly captured request is announced to assistive technology.

Scenario: New streamed request​

WHEN

a new webhook request streams into the inspector

THEN

it MUST be announced via an 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.

Scenario: Keyboard-only reaction​

WHEN

a keyboard user opens the reaction picker on a request

THEN

focus MUST move into it, cycle within it, and return to the trigger on close