openapi: 3.1.0 # switchboard — HTTP surface: webhook ingestion + local web UI. # Authored by hand (see ADR-0001): the endpoints are webhook receivers (raw body + HMAC) # and server-rendered HTML, neither of which is model-driven, so there is no framework # generating this spec. It is the contract the code session implements against. # # Trust model (ADR-0003): signed providers are verified-or-401; the generic endpoint is # a shared-secret token (or open); Redis is a separate pull ingestion path (see # docs/reference/asyncapi.yaml, not here — it has no HTTP endpoint). # # Live-update stream schema (SSE): docs/reference/asyncapi.yaml. # MCP tool contract: docs/openspec/specs/mcp-tools/spec.md. Vended MCP endpoints are served over # Streamable HTTP at /mcp/{endpoint} (ADR-0017; SPEC-0014), not described here. info: title: switchboard HTTP API version: 0.1.0 description: | Inbound webhook ingestion and the local-only web UI for **switchboard**. ## Three groups of endpoints - **Webhook ingestion** (`/webhooks/*`): providers POST here. Signed providers are verified-or-rejected (401, not persisted); the generic endpoint uses a shared-secret token (or open). - **Operator API** (`/api/v1/*`): OAuth-guarded (operator grants) — register agents, vend endpoints, list what you own. The `switchboard` CLI is its reference client. - **Web UI** (`/`, `/log`, `/providers`, `/settings`) + the SSE stream (`/events`): server-rendered HTML (html/template + HTMX) plus a `text/event-stream` for live updates. ## Authentication posture (ADR-0001, brief §8) The service is **localhost-bound by default and ships no in-app auth for the UI**. If it is ever exposed on the homelab LAN it MUST sit behind Caddy `forward_auth` — auth is provided by the reverse proxy, not built into this app for the MVP. The UI/SSE/health endpoints below are therefore marked `security: []` (no in-app auth) **by design**, not by oversight. For **webhook** endpoints, "authentication" means per-provider signature verification (`signed` providers) or a shared-secret token that authenticates the caller (`generic`). See ADR-0003. license: name: MIT url: https://opensource.org/license/mit contact: name: Joe Stump servers: - url: http://127.0.0.1:8080 description: Local, loopback-bound default (MVP) - url: https://webhooks.stump.rocks description: Optional homelab exposure — MUST be behind Caddy forward_auth (ADR-0001) tags: - name: ingestion description: Inbound webhook receivers. See ADR-0003 for the trust model. - name: ui description: Server-rendered web UI (html/template + HTMX). HTML responses. - name: sse description: Live-update event stream (Server-Sent Events). Payload schema in asyncapi.yaml. - name: ops description: Health/readiness. - name: operator-api description: >- The /api/v1 operator surface (ADR-0023): register agents and vend endpoints as the signed-in human. Every request requires an OPERATOR OAuth bearer — an OAuth token minted by the authorization-code + PKCE flow with resource = `/api` (ADR-0019), which the `switchboard` CLI obtains gh-style. There is no static API token. paths: /webhooks/{provider}: post: tags: [ingestion] operationId: ingestSignedWebhook summary: Receive a signed provider webhook (GitHub / Stripe / Slack) description: | Receiver for a **signed** provider. The raw request body is read and the provider's signature is verified **before any parsing** (a re-serialize would break the HMAC). - **Verification is mandatory.** A missing, malformed, or failing signature returns **401** and the payload is **NOT persisted** — only a redacted rejection is logged (ADR-0003). - On success the event is normalized, persisted (`verified=true`, `trust_mode=signed`), broadcast over SSE, and exposed to the MCP tools. - Signature comparison is constant-time; Stripe/Slack additionally enforce a timestamp freshness window (default 300s) to blunt replay. Provider-specific handshakes: Slack `url_verification` returns **200** echoing the `challenge` value instead of persisting an event. security: [] parameters: - name: provider in: path required: true description: The signed provider slug. schema: type: string enum: [github, stripe, slack] requestBody: required: true description: Raw provider payload (bytes preserved for HMAC verification). content: application/json: schema: type: object additionalProperties: true application/x-www-form-urlencoded: schema: type: object additionalProperties: true responses: '202': description: Verified and persisted. content: application/json: schema: $ref: '#/components/schemas/IngestAck' '200': description: Provider handshake handled (e.g. Slack `url_verification` challenge echo). content: application/json: schema: type: object additionalProperties: true '400': description: Malformed request (unreadable body, missing required headers). content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } '401': description: | Signature missing, malformed, or failed verification. **Payload not persisted.** content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } '403': description: Provider is configured but disabled. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } '404': description: Unknown/unconfigured provider slug. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } '413': description: Body exceeds the configured maximum size. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } /webhooks/generic/{name}: post: tags: [ingestion] operationId: ingestGenericWebhook summary: Receive a token-authenticated (or open) generic webhook — incl. Docker Hub description: | Catch-all receiver for senders with **no signature scheme at all** — self-hosted / homelab-internal tooling and **Docker Hub** (which has no native webhook signing). - **Shared-secret token (or open).** Events are persisted with `verified=false`, `trust_mode=token`, `verify_detail="token ok"`, and are labeled per their trust level everywhere in the UI and API (ADR-0003). - **Opt-in and disabled by default.** A generic provider does not exist until an operator explicitly creates one; an unknown `{name}` returns 404. - Requires a configured **shared-secret token** the caller presents — `Authorization: Bearer` (or a header) preferred, `?token=`/path token as a URL fallback (Docker Hub). It authenticates the caller but **not** the body; constant-time compare; required by default. A bad/missing token returns 403 (ADR-0003). - Appropriate **only on a trusted network** already isolated by Caddy `forward_auth` / UniFi segmentation. security: [] parameters: - name: name in: path required: true description: Operator-assigned generic provider name (e.g. `dockerhub`, `homelab`). schema: { type: string, pattern: '^[a-z0-9][a-z0-9_-]{0,63}$' } - name: token in: query required: false description: Shared-secret token (URL fallback; authenticates the caller, not the body). May instead be a path token. schema: { type: string } requestBody: required: true content: application/json: schema: type: object additionalProperties: true responses: '202': description: Accepted and persisted (token-authenticated). content: application/json: schema: { $ref: '#/components/schemas/IngestAck' } '403': description: Shared token missing or incorrect, or provider disabled. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } '404': description: No generic provider configured under this name. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } '413': description: Body exceeds the configured maximum size. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } /: get: tags: [ui] operationId: getDashboard summary: Dashboard / status screen (UI screen 1) description: | Server-rendered dashboard: connection/health strip (live via SSE — `● receiving` / `○ idle`), recent event count by provider, and last-event timestamp per provider. security: [] responses: '200': description: HTML page. content: text/html: schema: { type: string } /events: get: tags: [sse] operationId: streamEvents summary: Server-Sent Events stream for live UI updates description: | `text/event-stream` consumed by the browser via `htmx-ext-sse`. Emits named SSE events (`event-received`, `status`) plus keep-alive comments. **Message payload schemas are defined in `docs/reference/asyncapi.yaml`**, not here. Reconnection cadence is advertised via the SSE `retry:` field (configurable — `sse_retry_ms`, Settings screen). security: [] parameters: - name: Last-Event-ID in: header required: false description: Standard SSE resume header; server may replay missed events since this id. schema: { type: string } responses: '200': description: An open event stream. content: text/event-stream: schema: type: string description: See asyncapi.yaml for the `event-received` and `status` message shapes. /log: get: tags: [ui] operationId: getEventLog summary: Webhook log / history (UI screen 2) description: | Paginated table of received events (provider icon, event type, timestamp, verify status, payload size). Rendered as a full HTML page, or — when requested by HTMX (`HX-Request: true`) — as a table-fragment for cursor pagination. Ordering is `received_at DESC, id DESC`. security: [] parameters: - { $ref: '#/components/parameters/FilterProvider' } - { $ref: '#/components/parameters/FilterEventType' } - { $ref: '#/components/parameters/FilterSince' } - { $ref: '#/components/parameters/Cursor' } - { $ref: '#/components/parameters/Limit' } responses: '200': description: HTML page or HTMX fragment. content: text/html: schema: { type: string } /log/{id}: get: tags: [ui] operationId: getEventDetail summary: Event detail (UI screen 2, row → detail) description: | Full detail for one event: raw payload, **sanitized** headers (signature/secret headers redacted — ADR-0002/ADR-0003), and verification result. Rendered as HTML. security: [] parameters: - name: id in: path required: true schema: { type: integer, format: int64 } responses: '200': description: HTML detail view. content: text/html: schema: { type: string } '404': description: No event with that id. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } /providers: get: tags: [ui] operationId: getProviders summary: Provider config (UI screen 3) description: | Lists configured providers with family (`webhook` / `queue`) and trust_mode (`signed` / `token` / `open` / `queue`), the webhook URL path or Redis channel, secret/verification status (`configured` / `missing` / `none-by-design` — the secret itself is **never** shown), and an enable/disable toggle. HTML page. security: [] responses: '200': description: HTML page. content: text/html: schema: { type: string } /providers/{name}/enabled: post: tags: [ui] operationId: setProviderEnabled summary: Enable/disable a provider (HTMX toggle) description: Flips `providers.enabled`. Returns the re-rendered toggle fragment (HTMX). security: [] parameters: - name: name in: path required: true schema: { type: string } requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object properties: enabled: { type: boolean } required: [enabled] responses: '200': description: Re-rendered toggle fragment (HTML). content: text/html: schema: { type: string } '404': description: No such provider. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } /settings: get: tags: [ui] operationId: getSettings summary: Settings (UI screen 4) description: Retention policy, SSE reconnect behavior, and general app config. security: [] responses: '200': description: HTML page. content: text/html: schema: { type: string } post: tags: [ui] operationId: updateSettings summary: Update settings (HTMX) description: | Persists retention + SSE settings to the `settings` table. Values take effect on the next prune cycle / SSE reconnect. Returns the re-rendered settings fragment. security: [] requestBody: required: true content: application/x-www-form-urlencoded: schema: { $ref: '#/components/schemas/Settings' } responses: '200': description: Re-rendered settings fragment (HTML). content: text/html: schema: { type: string } '422': description: Invalid setting value. content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } /api/v1/agents: get: tags: [operator-api] operationId: listAgents summary: List the authenticated operator's registered agents security: - operatorOAuth: [] responses: '200': description: The operator's agents, creation order unspecified. content: application/json: schema: type: array items: { $ref: '#/components/schemas/AgentOut' } '401': description: Missing, expired, revoked, or non-operator bearer. content: application/json: schema: { $ref: '#/components/schemas/Problem' } /api/v1/endpoints: post: tags: [operator-api] operationId: vendEndpoint summary: Register an agent and vend its endpoint in one call description: | The ADR-0023 one-call mint: agent + scoped endpoint (MCP URL + bearer credential) + queue + token-trust ingestion webhook, returned in one response. The credential appears exactly once, here — it is never retrievable again (SPEC-0007). security: - operatorOAuth: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/VendEndpointIn' } responses: '201': description: Vended — every credential the operator needs in one response. content: application/json: schema: { $ref: '#/components/schemas/VendEndpointOut' } '400': description: Invalid body or missing name. content: application/json: schema: { $ref: '#/components/schemas/Problem' } '401': description: Missing, expired, revoked, or non-operator bearer. content: application/json: schema: { $ref: '#/components/schemas/Problem' } get: tags: [operator-api] operationId: listEndpoints summary: List the authenticated operator's vended endpoints (never credentials) security: - operatorOAuth: [] responses: '200': description: The operator's vended endpoints. Tokens are NOT included — they are revealed once at mint. content: application/json: schema: type: array items: { $ref: '#/components/schemas/EndpointOut' } '401': description: Missing, expired, revoked, or non-operator bearer. content: application/json: schema: { $ref: '#/components/schemas/Problem' } /api/v1/endpoints/{ref}/todos: post: tags: [operator-api] operationId: pushTodo summary: Hand an endpoint you own a todo and ring its doorbell description: | The operator's hand (ADR-0026): mints one todo on the named endpoint and rings its doorbell exactly as a verified delivery does. The hand-off is recorded as a delivery event of source, family and trust mode `operator`, verified, whose `verify_detail` names the authenticated human — so history and the board show who asked. Only the endpoint's owner may push, only onto one of its vended queues; the title and payload reach the agent as untrusted content like any todo's. security: - operatorOAuth: [] parameters: - name: ref in: path required: true description: The endpoint's slug (as `endpoint list` prints it) or its id. schema: { type: string } example: my-agent-k3x9 requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/PushTodoIn' } responses: '201': description: | The todo. `created` is false when `key` matched a live todo on the endpoint: the existing todo is returned, nothing was minted, nothing was rung. content: application/json: schema: { $ref: '#/components/schemas/PushTodoOut' } '400': description: Invalid body, empty or over-long title, a queue outside the endpoint's vended scope, or no queue on an endpoint that drains several. content: application/json: schema: { $ref: '#/components/schemas/Problem' } '401': description: Missing, expired, revoked, or non-operator bearer. content: application/json: schema: { $ref: '#/components/schemas/Problem' } '404': description: No endpoint by that slug or id belongs to the caller (unknown and another operator's are the same answer). content: application/json: schema: { $ref: '#/components/schemas/Problem' } '409': description: The endpoint is revoked; nothing could ever drain the work. content: application/json: schema: { $ref: '#/components/schemas/Problem' } /healthz: get: tags: [ops] operationId: getHealth summary: Liveness/readiness probe description: | JSON health check — process up, DB reachable, ingestion paths' status. Public (no auth) — required so a supervisor/reverse proxy can probe without credentials. security: [] responses: '200': description: Healthy. content: application/json: schema: { $ref: '#/components/schemas/Health' } '503': description: Unhealthy (e.g. DB unreachable). content: application/problem+json: schema: { $ref: '#/components/schemas/Problem' } components: parameters: FilterProvider: name: provider in: query required: false description: Filter by provider (`github`, `stripe`, `slack`, `generic:`, `redis:`). schema: { type: string } FilterEventType: name: event_type in: query required: false description: Filter by provider-declared event type (e.g. `push`, `payment_intent.succeeded`). schema: { type: string } FilterSince: name: since in: query required: false description: Lower time bound — ISO-8601 timestamp or an event id. schema: oneOf: - { type: string, format: date-time } - { type: integer, format: int64 } Cursor: name: cursor in: query required: false description: Opaque pagination cursor encoding the last `(received_at, id)` seen. schema: { type: string } Limit: name: limit in: query required: false description: Max rows to return. schema: { type: integer, minimum: 1, maximum: 200, default: 50 } schemas: TrustMode: type: string description: >- How the event's trust is established (ADR-0003). `signed` = HMAC-verified webhook; `token` = shared-secret-authenticated webhook (caller, not body); `open` = unchecked webhook (trusted-network only); `queue` = pull adapter, trust = broker connection. enum: [signed, token, open, queue] ProviderFamily: type: string description: Ingestion family (ADR-0003) — webhook (push) or queue (pull). enum: [webhook, queue] SecretStatus: type: string description: Runtime secret status (configured/missing/none-by-design). Never the secret value. enum: [configured, missing, none-by-design] EventSummary: type: object description: Compact event row (list views, SSE `event-received`, MCP `list`). No payload/headers. required: [id, provider, trust_mode, verified, payload_size, received_at] properties: id: { type: integer, format: int64, example: 4213 } provider: { type: string, example: github } event_type: { type: [string, "null"], example: push } trust_mode: { $ref: '#/components/schemas/TrustMode' } verified: type: boolean description: true ONLY for a signed provider whose signature passed. example: true payload_size: { type: integer, description: Raw body size in bytes, example: 8421 } received_at: { type: string, format: date-time, example: '2026-07-05T18:03:21.442Z' } EventDetail: allOf: - $ref: '#/components/schemas/EventSummary' - type: object description: Full event record (detail view, MCP `get`). properties: verify_detail: type: [string, "null"] example: hmac-sha256 ok external_id: type: [string, "null"] description: Provider delivery id used for idempotency. example: 8f6c...-de content_type: { type: [string, "null"], example: application/json } source_ip: { type: [string, "null"], description: Null for Redis-ingested events. } headers: type: object description: | SANITIZED request headers. Signature/secret headers (X-Hub-Signature-256, Stripe-Signature, X-Slack-Signature, Authorization, token) are redacted to `«redacted»`. additionalProperties: { type: string } payload: type: string description: Raw body exactly as received (bytes preserved for replay). ProviderStatus: type: object required: [name, kind, trust_mode, enabled, secret_status] properties: name: { type: string, example: 'generic:dockerhub' } family: { $ref: '#/components/schemas/ProviderFamily' } trust_mode: { $ref: '#/components/schemas/TrustMode' } enabled: { type: boolean } secret_status: { $ref: '#/components/schemas/SecretStatus' } path: type: [string, "null"] description: Webhook route path for HTTP providers (null for redis). example: /webhooks/generic/dockerhub channel: type: [string, "null"] description: Redis channel/stream for redis providers (null for HTTP). example: deploys IngestAck: type: object description: Acknowledgement returned to the provider on a successfully received event. required: [id, provider, trust_mode, verified, received_at] properties: id: { type: integer, format: int64 } provider: { type: string } trust_mode: { $ref: '#/components/schemas/TrustMode' } verified: { type: boolean } received_at: { type: string, format: date-time } Settings: type: object description: Mutable app settings (Settings screen). Persisted in the `settings` table. properties: retention_max_age_days: { type: integer, minimum: 0, default: 30 } retention_max_rows: { type: integer, minimum: 0, default: 50000 } sse_retry_ms: { type: integer, minimum: 250, default: 3000 } replay_default_target: type: [string, "null"] format: uri description: Default target for MCP `replay_webhook_event` when no target_url is given. Health: type: object required: [status] properties: status: { type: string, enum: [ok, degraded, error] } db: { type: string, enum: [ok, error] } redis: { type: string, enum: [ok, disabled, error] } database: { type: string, enum: [ok, error] } version: { type: string } Problem: type: object description: RFC 9457 problem detail. `detail` never contains secret or full-signature values. properties: type: { type: string, format: uri, default: about:blank } title: { type: string } status: { type: integer } detail: { type: string } instance: { type: string } VendEndpointIn: type: object required: [name] properties: name: type: string description: The agent name; the endpoint slug derives from it. example: my-agent queue: type: string default: inbox description: The single scoped queue the endpoint drains and the webhook routes to. example: inbox VendEndpointOut: type: object required: [agent_name, slug, mcp_url, token, queue, verbs, webhook] properties: agent_name: { type: string, example: my-agent } slug: { type: string, example: my-agent-k3x9 } mcp_url: { type: string, format: uri, example: 'https://sb.example.com/mcp/my-agent-k3x9' } token: type: string description: The vended endpoint bearer credential (sbk_…). Shown ONCE — store it now. queue: { type: string, example: inbox } verbs: type: array items: { type: string } description: The endpoint's verb allowlist (drain + webhook + event verbs). webhook: { $ref: '#/components/schemas/VendWebhookOut' } mcp_json: type: object description: >- The ready-to-paste `.mcp.json` stanza wiring an MCP client to `mcp_url` with `token` embedded as the bearer — byte-identical to the web reveal's wiring. Present on every successful vend. additionalProperties: true expires_at: { type: [string, "null"], format: date-time } VendWebhookOut: type: object required: [webhook_id, ingest_url, trust_mode] properties: webhook_id: { type: string } ingest_url: { type: string, format: uri } trust_mode: type: string enum: [token] description: Always token — the unguessable ingest URL is the credential (ADR-0003). AgentOut: type: object required: [id, name] properties: id: { type: string } name: { type: string, example: my-agent } description: { type: string } PushTodoIn: type: object required: [title] properties: title: type: string maxLength: 200 description: The doorbell's one line. Detail belongs in `payload`. example: look at PR 7 queue: type: string description: One of the endpoint's vended queues. Optional when the endpoint drains exactly one. example: reviews kind: type: string default: operator description: What `list_todos` reports as the todo's kind. payload: description: Any JSON, handed to the agent verbatim. example: { pr: 7 } key: type: string maxLength: 256 description: Idempotency key, scoped to the endpoint — the same key again returns the existing live todo. example: pr-7 PushTodoOut: type: object required: [id, endpoint_id, slug, queue, state, created, event_id] properties: id: { type: string, example: td_5f1c… } endpoint_id: { type: string } slug: { type: string, example: my-agent-k3x9 } queue: { type: string, example: reviews } state: { type: string, example: pending } created: type: boolean description: False when `key` matched a live todo — nothing minted, nothing rung. event_id: type: integer description: The `operator` delivery event the todo was born from. EndpointOut: type: object description: A vended endpoint WITHOUT credentials — tokens are revealed once at mint (SPEC-0007). required: [slug, agent_name, state, queues, verbs] properties: slug: { type: string, example: my-agent-k3x9 } agent_name: { type: string, example: my-agent } state: { type: string, enum: [active, revoked], example: active } queues: { type: array, items: { type: string } } verbs: { type: array, items: { type: string } } expires_at: { type: [string, "null"], format: date-time } securitySchemes: # Documented for clarity only — these are per-provider signature headers, verified by the app # (ADR-0003), NOT bearer credentials the client obtains. UI/SSE endpoints have no in-app auth # by design (localhost + Caddy forward_auth). See the info.description security posture. githubSignature: type: apiKey in: header name: X-Hub-Signature-256 description: HMAC-SHA256 of the raw body, `sha256=` prefixed. stripeSignature: type: apiKey in: header name: Stripe-Signature description: '`t=` timestamp + `v1=` HMAC-SHA256 over "{t}.{body}".' slackSignature: type: apiKey in: header name: X-Slack-Signature description: '`v0=` HMAC-SHA256 over "v0:{ts}:{body}"; paired with X-Slack-Request-Timestamp.' genericToken: type: apiKey in: query name: token description: Shared-secret token — authenticates the caller, not the body (ADR-0003). operatorOAuth: type: http scheme: bearer bearerFormat: opaque description: | An OPERATOR OAuth grant (ADR-0019/ADR-0023): an opaque access token minted by the authorization-code + PKCE flow with resource = `/api`, bound to the signed-in human. Endpoint-bound tokens and static sbk_ credentials do NOT resolve here. The `switchboard` CLI performs this flow (gh-style) and stores the rotating pair locally. # No global security requirement: webhook auth is per-provider signature verification (above), # and UI/SSE/health endpoints are intentionally unauthenticated at the app layer (ADR-0001). security: []