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

    ## Two 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).
    - **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://github.com/joestump/switchboard/blob/main/LICENSE
  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.

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' }

  /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:<name>`, `redis:<channel>`).
      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 }

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

# 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: []
