SPEC-0024: Outbound Todo Notify Hooks
SPEC · SPEC-0024 · Status · approved · Date · 2026-09-22 · Implements · ADR-0029 · Requires · SPEC-0003, SPEC-0006, SPEC-0007, SPEC-0011
Overview
A notify hook is an outbound HTTPS URL that an endpoint registers for itself. When a push-eligible todo owned by that endpoint becomes ready, Switchboard POSTs a signed notification to the URL. The notification carries identifiers and a one-line summary, never the todo's payload. It is the doorbell of ADR-0013 on a second transport. It exists for consumers that have no live MCP session to ring: one-shot runs, scheduled sweeps, CI jobs, and supervisors that start a worker on demand. See ADR-0029, which this spec implements.
The queue stays the ledger. A hook that is never configured, fails, times out, or is disabled loses
nothing: the todo is pending and claim_next returns it. The consumer gets the work by
claiming it, never from the notification body.
This spec turns ADR-0029's decisions into requirements. It also settles the ADR's two open questions:
- Presence. Hooks respect endpoint presence by default. A per-hook
ignore_presenceflag opts an on-demand dispatcher out. When an endpoint returns fromout, a hook that was held gets one backlog notification. See REQ-9. - Digest. A burst is not coalesced in v1. Each ready todo produces its own notification,
deduplicable by
webhook-id. The delivery counters in REQ-11 are how a burst problem would be measured before coalescing is designed. This question stays open, with a measurement plan, in design.md.
Terms:
- Hook: one registered outbound URL, owned by exactly one endpoint.
- Notification: one logical message about one event (for example, one todo becoming ready).
- Attempt: one HTTP request carrying a notification. A notification has at most three.
- Delivery: the outcome of all attempts for one notification:
deliveredorfailed.
Requirements
REQ-1: Hook Ownership and Scope
Every hook MUST belong to exactly one endpoint (endpoint_id), and MUST inherit that endpoint's
owner scope. There MUST NOT be an instance-wide hook, an environment-configured hook URL, or any
hook that is not owned by an endpoint.
A hook MUST fire only for todos whose endpoint_id equals the hook's endpoint_id and whose
queue is in the hook's effective queue set. The effective queue set MUST be the hook's queues
list intersected with the queues the endpoint's scope grants at fire time. An empty queues
list MUST mean "every queue the endpoint's scope grants". A queue that was removed from the scope
after the hook was created MUST stop matching without any change to the hook row.
A todo that no endpoint owns (a system todo) MUST NOT fire any hook.
Each endpoint MUST be limited to a ceiling on its hook count. The default is 5, and the operator
MAY lower or raise it instance-wide. Creating a hook past the ceiling MUST fail with
ceiling_exceeded and persist nothing. A ceiling of 0 MUST also stop dispatch to every existing
hook, and Switchboard MUST log one startup line saying notify hooks are disabled.
Scenario: A hook fires only for its own endpoint's todos
- GIVEN endpoints A and B belong to different humans, both drain a queue named
inbox, and A has a hook - WHEN a push-eligible todo owned by B lands on
inbox - THEN A's hook is not called, and nothing about B's todo appears in any request to A's URL
Scenario: A hook filtered to one queue
- GIVEN endpoint A drains
inboxandreviews, and has a hook withqueues = ["reviews"] - WHEN a push-eligible todo lands on A's
inbox - THEN the hook is not called
Scenario: Scope shrink stops a hook without editing it
- GIVEN a hook with
queues = []on an endpoint whose scope grantedinboxandreviews - WHEN
reviewsis removed from the endpoint's scope, and a push-eligible todo later lands onreviewsthrough a route that still targets the endpoint - THEN the hook is not called for that todo
Scenario: Ceiling reached
- GIVEN an endpoint that already has 5 hooks and the default ceiling
- WHEN it calls
create_notify_hook - THEN the call fails with
ceiling_exceeded, and no hook row, secret or URL is stored
REQ-2: Management Verbs
Switchboard MUST expose four MCP verbs in the webhook family:
create_notify_hook {url, queues?, ignore_presence?}validates the URL (REQ-3), mints a secret (REQ-4) and stores the hook. It returns{hook_id, url, queues, ignore_presence, enabled, signing_secret}. This is the only time the secret is revealed.list_notify_hooks {}returns the endpoint's hooks with their health (REQ-8) and the ceiling (max,used). It MUST NOT return any secret.rotate_notify_hook {hook_id}mints a new secret, returns it once, and re-enables a hook that REQ-8 disabled. It starts the dual-signing grace period REQ-4 defines.delete_notify_hook {hook_id}removes the hook and its secrets. An in-flight attempt MAY complete. No attempt may start after the delete commits.
These four verbs MUST be grantable per endpoint like any other verb. They MUST be listed by the
vend wizard, the quick-vend grant list and the MCP OAuth consent screen, grouped and labeled as
granting outbound HTTP calls. They MUST NOT be part of any default or "all verbs" grant. An
endpoint vended before this spec does not hold them until its human grants them. An endpoint that
lacks a verb MUST receive the same forbidden as for any other unscoped verb.
Every verb MUST act only on the caller's own endpoint. A hook_id that belongs to another
endpoint, including another endpoint of the same human, MUST be answered exactly like an unknown
id: not_found.
Scenario: Create reveals the secret once
- WHEN an endpoint holding
create_notify_hookcreates a hook forhttps://dispatch.example.com/sb - THEN the result carries
signing_secret, and a followinglist_notify_hooksreturns the hook with no secret field
Scenario: Endpoint without the verb
- GIVEN an endpoint whose scope does not include
create_notify_hook - WHEN it calls
create_notify_hook - THEN the call fails with
forbidden, and no outbound request is ever made
Scenario: Another endpoint's hook id
- GIVEN hook
nh_1belongs to endpoint A - WHEN endpoint B, owned by the same human, calls
delete_notify_hook {"hook_id": "nh_1"} - THEN the call fails with
not_found, andnh_1is unchanged
Scenario: Not in the default grant
- WHEN a human quick-vends an endpoint with the default grant
- THEN its scope contains none of the four notify-hook verbs
REQ-3: Target Validation (SSRF Guard)
Switchboard MUST validate a hook URL with the shared internal/push.Validator:
- once at
create_notify_hook, failing fast withinvalid_argumentand persisting nothing; - again immediately before every attempt.
The URL MUST be https. http MUST be accepted only when the operator has set the existing
SWITCHBOARD_PUSH_ALLOW_HTTP opt-in. The URL MUST NOT carry userinfo (user:pass@). It MUST be at
most 2048 bytes. It MUST NOT resolve to a loopback, private, link-local, unique-local,
unspecified, multicast or carrier-grade NAT address, or to one of Switchboard's own listen
addresses.
The operator MAY permit specific ranges instance-wide with an explicit CIDR allowlist
(SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS). This is an operator bound for single-tenant or homelab
installs. The default MUST be empty. A listed range MUST exempt the private, unique-local and
carrier-grade NAT addresses it covers. Loopback and link-local addresses MUST be exempted only by an
entry that lies wholly inside those ranges (for example 127.0.0.1/32), never by a broader entry
such as 0.0.0.0/0. Well-known cloud metadata addresses outside link-local (Alibaba Cloud's
100.100.100.200, AWS's IPv6 fd00:ec2::254) MUST be exempted only by an entry for exactly that
address, never by a covering range. Switchboard's own listen address and port MUST stay rejected
even when listed; when the listen host is a name rather than an IP, its port MUST be rejected on
every allowlisted address. The self-hosting guide MUST say that the allowlist exposes those ranges
to every tenant, and that exempting link-local exposes cloud metadata services.
The connection MUST be made to an IP address from the same resolution that passed validation. A second, unvalidated lookup between validation and dial MUST NOT be possible. TLS MUST verify the certificate against the URL's hostname.
Switchboard MUST NOT follow redirects. A 3xx response MUST be recorded as a failed attempt with
the status code, and its Location MUST NOT be dialled.
Scenario: Private target refused at create
- WHEN an endpoint calls
create_notify_hookwith a URL whose host resolves to10.0.0.5 - THEN the call fails with
invalid_argumentnaming the rejected address class, and nothing is stored
Scenario: Same-host receiver when the operator opts in
- GIVEN
SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS=127.0.0.1/32, and Switchboard listening on127.0.0.1:8080 - WHEN an endpoint creates a hook whose host resolves to
127.0.0.1on port9443 - THEN the hook is stored, while a hook targeting
127.0.0.1:8080is still refused
Scenario: A broad entry does not open loopback or metadata
- GIVEN
SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS=0.0.0.0/0 - WHEN a hook's host resolves to
169.254.169.254or to127.0.0.1 - THEN the call fails with
invalid_argument
Scenario: A covering range does not open a metadata service
- GIVEN
SWITCHBOARD_NOTIFY_HOOK_ALLOW_CIDRS=100.64.0.0/10,fc00::/7 - WHEN a hook's host resolves to
100.100.100.200or tofd00:ec2::254 - THEN the call fails with
invalid_argument, while100.100.100.201is accepted
Scenario: DNS rebinding between create and delivery
- GIVEN a hook whose host resolved to a public address at create time
- WHEN the host resolves to
127.0.0.1at delivery time (simulated by the injected resolver) - THEN no connection is opened, the attempt is recorded as
rejected_ssrf, and it counts toward REQ-8's consecutive failures
Scenario: Redirect is a failure
- WHEN a hook's receiver answers
302withLocation: http://169.254.169.254/ - THEN the attempt is recorded as failed with status 302, and no request is made to the
Location
Scenario: Plain HTTP without the opt-in
- GIVEN
SWITCHBOARD_PUSH_ALLOW_HTTPis unset - WHEN an endpoint calls
create_notify_hookwithhttp://dispatch.example.com/sb - THEN the call fails with
invalid_argument
REQ-4: Signing (Standard Webhooks)
Switchboard MUST mint each hook's secret itself from a CSPRNG: 32 random bytes, presented as
whsec_ followed by their padded standard base64 encoding. The HMAC key MUST be the
decoded bytes, never the literal string.
The hook minter MUST NOT reuse the inbound webhook minter (mintWebhookSecret, which returns
whsec_ followed by hex). Hex is valid base64, so a conforming Standard Webhooks verifier
would decode a hex secret without error, derive the wrong key, and reject every delivery, silently
on both sides.
Switchboard MUST store the secret only through the internal/cred envelope, like every other held
secret. A caller MUST NOT be able to supply its own secret.
Every attempt MUST carry the Standard Webhooks headers:
webhook-id: the notification id (msg_<26-char ULID>), identical across all attempts of one notification;webhook-timestamp: the attempt time, in integer Unix seconds;webhook-signature:v1,<base64 HMAC-SHA256>, computed over<webhook-id>.<webhook-timestamp>.<raw body>with the decoded secret.
Each attempt MUST also carry content-type: application/json and
user-agent: switchboard/<version>, where <version> is the stamped server version.
After rotate_notify_hook, Switchboard MUST sign every attempt with both the new and the previous
secret for a grace period (default 24 hours), as two space-separated v1, entries in one
webhook-signature header. When the grace period ends, the previous secret MUST be destroyed.
Scenario: A receiver verifies a notification
- WHEN a receiver computes HMAC-SHA256 over
webhook-id + "." + webhook-timestamp + "." + bodywith the secretcreate_notify_hookreturned - THEN the result equals the base64 value after
v1,inwebhook-signature
Scenario: A stock Standard Webhooks library verifies it
- WHEN a receiver passes the returned
signing_secretto an off-the-shelf Standard Webhooks verifier library, with no Switchboard-specific handling - THEN every notification from that hook verifies. A test in Switchboard's suite asserts this against a reference implementation, so a hex-encoded secret cannot pass it.
Scenario: Retries keep the id
- WHEN a notification's first attempt times out and a second attempt is sent
- THEN both attempts carry the same
webhook-id, and each carries its ownwebhook-timestampand signature, so a receiver enforcing a 5-minute timestamp tolerance accepts every attempt
Scenario: Rotation grace
- GIVEN a hook rotated 1 hour ago
- WHEN it sends an attempt
- THEN
webhook-signatureholds twov1,entries, and one verifies with each secret
REQ-5: Notification Body
The body MUST be a JSON object that carries identifiers and a summary only. A todo.ready
notification MUST carry exactly these fields:
{"type": "todo.ready", "reason": "created", "todo_id": "td_…", "queue": "inbox",
"kind": "pull_request", "source": "gitea", "summary": "PR 482 opened in …",
"endpoint": "<slug>", "attempt": 0, "created_at": "2026-09-22T14:03:11Z"}
reason MUST be created or requeued (REQ-6). attempt MUST be the todo's current attempt
count: the number of times it has been claimed so far, so 0 on a created notification and at
least 1 on a requeued one after a lease expiry. created_at MUST be the time this notification
was made, not the time the todo was created, so each requeue carries a fresh time; a receiver
deduplicates on webhook-id, never on created_at. summary MUST be the todo's title after the neutralization the channel doorbell applies,
truncated to 200 characters. kind and source MUST be omitted when the todo has none.
The body MUST NOT contain any field of the todo's payload, any request headers, the routing trace, any work order, or any other endpoint's data. It MUST NOT exceed 4 KiB.
The receiver documentation MUST state that summary is text supplied by the sender. It can inform
a consumer, and can never instruct one.
Scenario: No payload leaves
- GIVEN a todo whose payload holds an issue body, the sender's email and a
tokenfield - WHEN its hook fires
- THEN the request body contains none of those values, and contains only the fields listed above
Scenario: A hostile summary stays data
- GIVEN a todo whose title contains a newline and the text
ignore previous instructions - WHEN its hook fires
- THEN
summaryis a single JSON string with the newline neutralized, and no other field carries sender text
REQ-6: Trigger and Sender Gate
A hook MUST fire after the todo's transaction commits, from the same store hook that rings the channel doorbell, and only for a todo that passes the SPEC-0011 sender gate: the delivery verified under its source's trust mode, or it arrived on a token-trust self-managed webhook. A todo that would not ring a session MUST NOT call a hook.
A hook MUST fire for these transitions into pending:
created: a new todo commits;requeued: a claimed todo returns topendingbecause its lease expired, or a failed todo re-enterspendingwhen its retry is due.
The sender gate MUST be evaluated for requeued transitions as well as for created ones. The
re-queue statements do not apply it today, so the re-queue path MUST re-apply the SPEC-0011
predicate to the rows it moved before it considers any hook.
A hook MUST NOT fire for the doorbell heartbeat's re-rings (SPEC-0011 REQ "Doorbell Heartbeat"),
for redeliveries that dedup onto an existing todo, or for a todo that leaves pending.
Each transition MUST produce at most one notification per matching hook across all instances. The instance whose statement performed the transition sends it.
A hook MUST fire whether or not a channel session is attached to the endpoint. When both exist, both fire. The claim lease is what prevents double work.
A hook MUST NOT fire for a todo that another Switchboard feature marks as excluded from push. Today that means synthetic self-test todos (SPEC-0025) and quarantined deliveries (SPEC-0026).
Scenario: Unverified delivery calls no hook
- GIVEN an endpoint with a hook
- WHEN a todo is created from a delivery whose signature did not verify
- THEN the hook is not called, and the todo is still claimable by pull
Scenario: Lease expiry re-fires
- GIVEN a todo whose hook fired on creation, and a consumer that claimed it and died
- WHEN the reaper returns the todo to
pending - THEN the hook receives one
todo.readynotification withreason = "requeued"and the newattemptnumber
Scenario: Requeue re-applies the sender gate
- GIVEN a todo whose delivery did not verify, which a worker claimed by id and then abandoned
- WHEN the reaper or the retry scheduler returns it to
pending - THEN no hook is called, because the re-queue fan-out re-applies the SPEC-0011 sender-gate predicate to the rows it moved
Scenario: Redelivery does not re-fire
- WHEN a producer redelivers an event that dedups onto a live todo
- THEN no hook is called
Scenario: Two instances, one notification
- GIVEN two Switchboard instances
- WHEN a todo is created through instance 1
- THEN each matching hook receives exactly one notification for that transition, sent by instance 1
REQ-7: Best-Effort Delivery Off the Ingest Path
Hook delivery MUST NOT block or slow ingest, todo creation, or any MCP verb. Notifications MUST be handed to a bounded in-process queue on each instance. When that queue is full, the notification MUST be dropped, logged and counted (REQ-11), and the todo MUST be unaffected.
Each attempt MUST time out after 5 seconds, covering the connection, TLS and response headers.
Switchboard MUST read at most 64 KiB of the response body and then discard it. A 2xx status MUST
count as delivered.
A notification MUST have at most 3 attempts, with backoff of about 1 second and then about 5
seconds, jittered. A network error, a timeout, 408, 429 or a 5xx MUST be retried. A hook host
that fails to resolve at attempt time is a network error (or a timeout, when the lookup times out)
and nothing is dialled; only an address the SSRF guard rejects is rejected_ssrf, and that is
never retried. Any other
status, including every 3xx and every 4xx except 408 and 429, MUST end the notification
without retry.
Pending attempts MUST NOT be persisted. A restart loses in-flight notifications and nothing else.
The ADR-0013 contract holds: the todo stays pending and claimable.
Scenario: Unreachable receiver
- GIVEN an endpoint whose hook URL accepts no connections
- WHEN a verified delivery creates a todo
- THEN the ingest response time is unchanged, the todo is
pendingand claimable, and the hook records a failed delivery after 3 attempts
Scenario: Permanent client error is not retried
- WHEN a receiver answers the first attempt with
401 - THEN no second attempt is sent, and the delivery is recorded as failed with status 401
Scenario: Queue overflow
- GIVEN the delivery queue on an instance is full
- WHEN another notification is produced
- THEN it is dropped, one warning is logged with the hook id (or, when it is dropped before any hook is matched, the endpoint and todo ids) and never the URL, the dropped counter increments, and no todo changes state
A sustained overflow MAY coalesce these warnings to one line per reason per short window, provided the line reports how many drops it stands for and every drop is still counted.
REQ-8: Hook Health and Auto-Disable
Each hook MUST record last_attempt_at, last_status (the HTTP status, or null for a network
error), last_error (a short classified reason such as timeout, tls, rejected_ssrf or
redirect, never response body text), and consecutive_failures (failed deliveries in a row).
A delivered notification MUST reset consecutive_failures to 0. When consecutive_failures
reaches 10, the hook MUST be disabled with disabled_reason = "consecutive_failures" and
disabled_at set, and a warning MUST be logged. A disabled hook MUST NOT be called.
list_notify_hooks MUST show each hook's health and disabled state. A hook MUST be re-enabled only
by rotate_notify_hook, or by its owning human from the endpoint card (REQ-10).
Scenario: Dead receiver is disabled
- GIVEN a hook whose receiver has been down for 10 consecutive notifications
- WHEN an 11th todo becomes ready
- THEN the hook is not called, and
list_notify_hooksshowsenabled = falseanddisabled_reason = "consecutive_failures"
Scenario: Recovery resets the count
- GIVEN a hook with
consecutive_failures = 7 - WHEN its next notification is delivered with
204 - THEN
consecutive_failuresis 0
REQ-9: Presence
This requirement applies once SPEC-0022 (endpoint presence) is implemented. Until then, every
endpoint's effective presence is in, and hooks fire under REQ-6 alone.
A hook with ignore_presence = false (the default) MUST NOT fire while its endpoint's effective
presence is out. Todos created while the endpoint is out stay pending, as SPEC-0022 requires.
When the endpoint's presence changes from out to in and it has at least one push-eligible
pending todo in the hook's effective queue set, each such hook MUST receive one
todos.backlog notification. It MUST be decided by the same conditional presence_seen update
that decides the SPEC-0022 digest, so a transition produces one notification per hook across all
instances and restarts. Its body MUST carry only counts, queue names, and the oldest pending age:
{"type": "todos.backlog", "reason": "clock_in", "endpoint": "<slug>", "pending": 7,
"queues": ["lane-m", "reviews"], "oldest_pending_seconds": 5400, "created_at": "…"}
reason MUST be one of the digest reasons defined in SPEC-0011 REQ "Push Notification Shape"
(clock_in, operator, shift_start, override_end), excluding reconnect, which is a session
event and does not apply to hooks. The body MUST NOT contain any todo id, title or payload.
A hook with ignore_presence = true MUST fire under REQ-6 regardless of presence, and MUST NOT
receive todos.backlog.
Scenario: Clocked-out endpoint holds its hook
- GIVEN an endpoint that is
out, with a hook that does not ignore presence - WHEN 3 push-eligible todos are created
- THEN the hook is not called, and at clock-in it receives one
todos.backlogwithpending = 3
Scenario: An on-demand dispatcher ignores presence
- GIVEN an endpoint that is
out, with a hook whereignore_presence = true - WHEN a push-eligible todo is created
- THEN the hook receives
todo.readyimmediately, and notodos.backlogat clock-in
REQ-10: Operator Web UI
The endpoint card MUST list the endpoint's hooks to its owning human. For each hook it MUST show the URL's scheme, host and path, with the query string redacted, plus the queues, enabled state, last status, last attempt time, consecutive failures and disabled reason. The owning human MUST be able to disable, re-enable and delete a hook from the card. Only the endpoint's owner scope may see or change its hooks. The instance operator role grants no view of another tenant's hooks.
Scenario: Human disables a noisy hook
- WHEN the owning human disables a hook from the endpoint card
- THEN the hook stops firing at once, and
list_notify_hooksshowsenabled = falsewithdisabled_reason = "operator"
Scenario: Foreign human
- WHEN a signed-in human requests the hook controls for an endpoint another human owns
- THEN the response is not-found, and nothing changes
REQ-11: Observability
Switchboard MUST add these series to the SPEC-0023 registry, with bounded labels only:
switchboard_notify_hook_notifications_total{type,outcome} counter
# type: todo.ready|todos.backlog; outcome: delivered|failed|dropped
switchboard_notify_hook_attempts_total{result} counter
# result: 2xx|3xx|4xx|5xx|timeout|network|tls|rejected_ssrf
switchboard_notify_hooks_disabled_total{reason} counter
# reason: consecutive_failures|operator
Hook ids, endpoint ids, URLs and hosts MUST NOT be labels.
Every attempt MUST produce a structured log line with the hook id, the notification id, the attempt number, the result, and the duration. No log line, error, trace or API response may contain a hook secret, a signature, or the URL's query string.
Scenario: Secret never logged
- WHEN a hook's attempts fail and succeed across a rotation
- THEN no log line contains either secret, any
webhook-signaturevalue, or the URL's query string
REQ-12: Error Handling Standards
Every error from validation, signing, dialling or storage MUST be wrapped with the hook id and the stage that failed. A delivery failure MUST NOT be returned to, or surface on, any ingest or MCP call path. Sentinel errors MUST distinguish SSRF rejection, redirect, timeout, and permanent client error, so that REQ-8 and REQ-11 can classify them. Nothing may be silently swallowed: each error MUST be returned, recorded on the hook row, or logged with a documented reason.
Scenario: Store outage during health update
- WHEN recording a hook's health fails because the database is unavailable
- THEN the failure is logged with the hook id and notification id, and the delivery worker continues with the next notification
REQ-13: Concurrency and Database Standards
The delivery queue and its workers MUST start and stop with the server's lifecycle context, drain
or abandon in-flight attempts on shutdown within a bounded time, and MUST be race-free under
go test -race. Hook creation MUST enforce the ceiling in the same transaction that inserts the
row. Health updates MUST be single parameterized statements, and MUST NOT take row locks that
ingest or claim paths wait on.
Scenario: Concurrent creates at the ceiling
- GIVEN an endpoint with 4 hooks and a ceiling of 5
- WHEN two
create_notify_hookcalls race - THEN exactly one succeeds, and the other fails with
ceiling_exceeded
Security Requirements
Authentication
The four verbs MUST require the endpoint's bearer credential or OAuth access token, as every MCP
verb does (SPEC-0007), plus the per-verb grant in REQ-2. The web UI controls MUST require a signed-in
human who owns the endpoint (auth.RequireHuman plus the ownership check that revoke uses).
| Surface | Auth | Description |
|---|---|---|
MCP create_notify_hook, list_notify_hooks, rotate_notify_hook, delete_notify_hook | Required | Endpoint credential and per-verb grant |
| Web UI hook controls on the endpoint card | Required | Owning human session with CSRF token |
| Outbound POST to the hook URL | n/a (outbound) | Signed with the hook secret (REQ-4) |
Rate Limiting
The verbs MUST sit behind the existing per-endpoint MCP rate limiter. Each hook MUST additionally
be limited to 120 notifications per minute. Notifications over the limit MUST be dropped and
counted as dropped, so that a hostile or runaway producer cannot turn Switchboard into a request
amplifier against the receiver.
Security Headers
Web UI responses MUST carry the existing secureHeaders middleware set. Outbound requests MUST
NOT forward any inbound header, cookie or credential.
Request Body Size Limits
MCP request bodies stay capped at 1 MiB (SPEC-0014). The outbound body MUST NOT exceed 4 KiB (REQ-5). At most 64 KiB of a receiver's response MUST be read (REQ-7).
CSRF Protection
The web UI's disable, re-enable and delete actions MUST be POST requests that carry the existing CSRF token, and MUST be rejected without it.
Redirect Validation
Outbound attempts MUST NOT follow redirects (REQ-3). The web UI's actions MUST redirect only to the same-origin endpoint card.