openapi: 3.1.0
info:
  title: TriggersAPI
  version: "0.1.0"
  description: >
    A prototype event ingestion and delivery API in the style of Zapier's
    TriggersAPI concept. Producers POST events; TriggersAPI stores them,
    fans them out to registered webhook endpoints with retries and
    backoff, and exposes a pull-style inbox for consumers who would
    rather poll than receive a webhook.
servers:
  - url: http://localhost:3000
    description: Local dev server

tags:
  - name: events
    description: Ingesting events
  - name: inbox
    description: Pull-style retrieval of undelivered events
  - name: endpoints
    description: Registering webhook consumers
  - name: deliveries
    description: Delivery status and manual retry
  - name: stats
    description: Observability
  - name: demo
    description: Demo webhook receiver, used to exercise retries locally
  - name: admin
    description: Admin gate status and unlock

paths:
  /api/events:
    get:
      tags: [events]
      summary: List events with delivery summaries
      description: >
        Read API for the Explorer UI: all events (acked or not), newest
        first, each with a count of its deliveries by status.
      parameters:
        - name: source
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Events, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/Event"
                        - type: object
                          properties:
                            deliveryStatuses:
                              type: object
                              description: Delivery count per status for this event.
                              additionalProperties: { type: integer }
    post:
      tags: [events]
      summary: Ingest an event
      description: >
        Accepts a new event for a source. Fans it out to every active
        endpoint as a pending delivery. Send an `Idempotency-Key` header to
        make retries of the same logical event safe — the same key from the
        same source will not create a second event or a second set of
        deliveries.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 200
          description: >
            Caller-supplied key, unique per source. Re-sending the same
            (source, key) pair returns the original event instead of
            creating a duplicate. Keys over 200 characters are rejected
            with 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, payload]
              properties:
                type:
                  type: string
                  maxLength: 200
                  description: Event type, e.g. "order.created". At most 200 characters.
                  example: order.created
                payload:
                  type: object
                  description: Arbitrary JSON payload for the event.
                  additionalProperties: true
      responses:
        "202":
          description: Event accepted and fanned out to active endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, description: ULID of the stored event }
                  status: { type: string, enum: [accepted] }
                  deliveries:
                    type: integer
                    description: Number of deliveries created (one per active endpoint).
        "200":
          description: Duplicate of a previous event with the same Idempotency-Key.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [duplicate] }
        "400":
          description: >
            Invalid JSON body, failed schema validation, or an
            Idempotency-Key over 200 characters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or invalid bearer API key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "413":
          description: >
            Payload exceeds the 64 KB limit, or the request declares a
            Content-Length over the 80 KB whole-body limit (checked before
            the body is read).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/inbox:
    get:
      tags: [inbox]
      summary: List inbox events
      description: >
        A single shared inbox for the prototype: every consumer sees the
        same list, filterable by ack status, source, and type. By default
        only not-yet-acked events are returned.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, acked, all]
            default: pending
          description: >
            pending = not yet acked (default), acked = already consumed,
            all = both.
        - name: source
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Matching events, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/Event" }
        "400":
          description: status is not one of pending, acked, all.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/inbox/{eventId}/ack:
    post:
      tags: [inbox]
      summary: Acknowledge an event
      description: Marks an event as consumed so it drops out of the inbox listing.
      parameters:
        - name: eventId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Event acked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  acked: { type: boolean }
        "404":
          description: Event not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Event was already acked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/events/{id}:
    get:
      tags: [events]
      summary: Get event detail with delivery timeline
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Event, its deliveries, and each delivery's attempt history.
          content:
            application/json:
              schema:
                type: object
                properties:
                  event: { $ref: "#/components/schemas/Event" }
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryDetail" }
        "404":
          description: Event not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/events/stream:
    get:
      tags: [events]
      summary: Live event stream (Server-Sent Events)
      description: >
        Streams new events as they are ingested, as Server-Sent Events.
        Each message's `id:` field is the event's ULID and its `data:` field
        is a JSON object with `id`, `source`, `type`, and `receivedAt`. The
        payload is deliberately left out; fetch it from `GET
        /api/events/{id}` when you need it. Without a resume point the
        stream starts at "now" and only new events arrive. To resume after
        a drop, send the last ULID you saw in either the standard
        `Last-Event-ID` header (browsers' EventSource does this on its own)
        or a `?since=` query parameter, and the stream replays everything
        after it, in order, at most 100 events per second. Honesty note:
        the server checks for new events once per second, so messages
        arrive with up to one second of lag. A `: ping` comment goes out
        every 15 seconds to keep proxies from closing an idle connection.
      parameters:
        - name: since
          in: query
          required: false
          schema: { type: string }
          description: >
            Event ULID to resume after. Everything ingested after this
            event is replayed before live tailing continues.
        - name: Last-Event-ID
          in: header
          required: false
          schema: { type: string }
          description: >
            Same as `since`; the header wins when both are present. Sent
            automatically by EventSource when it reconnects.
      responses:
        "200":
          description: >
            An unending `text/event-stream` body. One message per event,
            oldest first.
          content:
            text/event-stream:
              schema:
                type: string
                description: >
                  Messages of the form "id: <ulid>" then "data: <json>",
                  separated by blank lines, where <json> is
                  {"id","source","type","receivedAt"}.
                example: |
                  id: 01JGZ0A1B2C3D4E5F6G7H8J9KM
                  data: {"id":"01JGZ0A1B2C3D4E5F6G7H8J9KM","source":"demo","type":"order.created","receivedAt":1754680000000}

  /api/endpoints:
    get:
      tags: [endpoints]
      summary: List registered endpoints with delivery counts
      description: >
        Webhook URLs are treated as secrets (they often embed tokens), so
        `url` here is masked to origin + first path segment, e.g.
        `https://api.example.com/hooks/…`. `urlOrigin` gives the unmasked
        scheme+host+port, and `urlMasked` is the masked string itself
        (always masked, even for the demo receiver). The one exception is
        `url` for the built-in demo receiver endpoint, which is shown in
        full since it always points back at this app.
      responses:
        "200":
          description: All endpoints (active and deactivated), each with counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/EndpointWithStats" }
    post:
      tags: [endpoints]
      summary: Register a webhook endpoint
      description: >
        Registers a URL to receive future deliveries, subscribed to one
        source and a named set of event types. Nothing outside that
        subscription is ever delivered, and an endpoint cannot subscribe to
        every source: `eventTypes` accepts "*" (all types from this source)
        but `source` has no wildcard.


        Authentication decides the source. With a bearer API key, `source`
        is taken from the key and sending it in the body is a 400, so a
        caller can only subscribe to events they were already trusted to
        send. With the admin token instead (how the Explorer UI registers
        endpoints), `source` is required in the body.


        The URL is checked against an SSRF blocklist (loopback, private, and
        link-local ranges) both here and again at send time, since DNS
        answers can change between registration and delivery.
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url, eventTypes]
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                eventTypes:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string }
                  description: >
                    Event types this endpoint receives, or ["*"] for every
                    type from its source. Cannot be empty: an endpoint that
                    asked for nothing receives nothing.
                  example: ["order.created", "payment.failed"]
                source:
                  type: string
                  description: >
                    Only for an admin-token caller registering on a source's
                    behalf. Rejected with 400 when an API key is presented,
                    since the key already determines the source.
      responses:
        "201":
          description: Endpoint registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Endpoint" }
        "400":
          description: >
            Invalid body. Also returned when an API-key caller sends
            `source` (it comes from the key), when neither a key nor a body
            `source` identifies one, or when `eventTypes` is empty.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: >
            No valid API key, and the admin gate refused the request. The
            gate is on for deployed instances (TRIGGERS_ADMIN_TOKEN set)
            and off locally, where registering without a key works.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: URL blocked (SSRF guard rejected it). `error` holds the reason.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/endpoints/{id}:
    delete:
      tags: [endpoints]
      summary: Deactivate an endpoint (soft delete)
      description: >
        Marks the endpoint inactive so it stops receiving new deliveries,
        and cancels its still-queued deliveries (status `pending` or
        `delivering` becomes `canceled`) so nothing sits stuck forever.
        Finished history (delivered and dead rows, and all attempts) is
        kept.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Endpoint deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  active: { type: boolean, enum: [false] }
                  canceledDeliveries:
                    type: integer
                    description: How many queued deliveries were canceled.
        "401":
          $ref: "#/components/responses/AdminLocked"
        "404":
          description: Endpoint not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    patch:
      tags: [endpoints]
      summary: Reactivate or deactivate an endpoint
      description: >
        `{ "active": true }` reactivates a soft-deleted endpoint so it
        receives new deliveries again, and is what unblocks replaying a
        dead delivery whose endpoint was deactivated (the retry route
        returns 409 until then). Deliveries canceled while it was off stay
        canceled — `canceled` is terminal.
        `{ "active": false }` is DELETE by another name: same deactivation
        and same queued-delivery cancellation.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [active]
              properties:
                active: { type: boolean }
      responses:
        "200":
          description: >
            The updated endpoint row with a masked url. Unlike GET
            /api/endpoints, no delivery counts are included. Deactivating
            adds canceledDeliveries; reactivating returns just the
            endpoint.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/MaskedEndpoint"
                  - type: object
                    properties:
                      canceledDeliveries:
                        type: integer
                        description: >
                          How many queued deliveries were canceled. Present
                          only when deactivating.
        "400":
          description: Invalid JSON body or missing/!boolean `active`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/AdminLocked"
        "404":
          description: Endpoint not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/demo/self-check:
    get:
      tags: [demo]
      summary: Identify this app to itself
      description: >
        Returns a nonce stored in this app's own database. Used only by the
        origin-recording probe in src/lib/self-origin.ts: because a request's
        Host header is client-controlled, a candidate origin is trusted for
        the SSRF demo exception only after it answers this route with the
        matching nonce, proving it routes back here rather than to some other
        service listening on loopback.
      responses:
        "200":
          description: This app's self-identification nonce.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nonce: { type: string }

  /api/deliveries/{id}/retry:
    post:
      tags: [deliveries]
      summary: Retry a dead delivery
      description: >
        Re-queues a dead-lettered delivery (status "dead"), setting it back
        to "pending" with nextAttemptAt = now so the worker picks it up on
        its next poll. attemptCount is left untouched, since rewinding it
        would falsify the append-only attempt history; instead the
        delivery's own maxAttempts ceiling is raised to attemptCount + 1.
        The next claim therefore gets exactly ONE more real attempt,
        numbered one past the last, and a further failure dead-letters the
        delivery again. The response is the updated delivery plus
        `attemptsRemaining: 1`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Delivery re-queued with exactly one attempt remaining.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Delivery"
                  - type: object
                    properties:
                      attemptsRemaining:
                        type: integer
                        enum: [1]
                        description: Always 1. Retry grants exactly one more attempt.
        "401":
          $ref: "#/components/responses/AdminLocked"
        "404":
          description: Delivery not found.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: >
            Either the delivery is not in a "dead" state, or its endpoint
            has been deactivated (reactivate the endpoint before retrying).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/stats:
    get:
      tags: [stats]
      summary: Aggregate counters for the dashboard
      responses:
        "200":
          description: Event, delivery, and attempt counters.
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalEvents: { type: integer }
                  acked: { type: integer }
                  deliveriesByStatus:
                    type: object
                    properties:
                      pending: { type: integer }
                      delivering: { type: integer }
                      delivered: { type: integer }
                      dead: { type: integer }
                      canceled: { type: integer }
                  totalAttempts: { type: integer }
                  successRate:
                    type: number
                    description: delivered / (delivered + dead), 0 when nothing has finished yet.
                  attemptsHistogram:
                    type: object
                    description: Count of deliveries that took exactly N attempts (N = 1..6, 6 = "6 or more").
                    additionalProperties: { type: integer }
                  queueDepth:
                    type: integer
                    description: Deliveries currently pending or delivering.
                  workerPaused: { type: boolean }
                  claimBatchSize:
                    type: integer
                    description: >
                      The worker's per-tick claim cap (currently 20), so
                      the UI can say "processing N per tick" without
                      hardcoding it.
                  workerState:
                    type: string
                    enum: [healthy, paused, recovering]
                    description: >
                      "paused" mirrors workerPaused. "recovering" means at
                      least one delivery is currently past ~50s of its 60s
                      claim lease (about to be rescued). Otherwise
                      "healthy".
                  latency:
                    type: object
                    description: Ingest-to-delivered latency over the last 200 delivered deliveries.
                    properties:
                      p50Ms: { type: [integer, "null"] }
                      p95Ms: { type: [integer, "null"] }
                      sampleSize: { type: integer }
                  timeline:
                    type: array
                    description: Last 120s in 5s buckets, oldest first.
                    items:
                      type: object
                      properties:
                        t: { type: integer, description: bucket start, epoch ms }
                        ingested: { type: integer }
                        delivered: { type: integer }
                        failed: { type: integer }

  /api/demo/receiver:
    post:
      tags: [demo]
      summary: Demo webhook receiver
      description: >
        A stand-in "customer" webhook consumer, and the target of the
        built-in "Demo receiver" endpoint that is auto-registered on first
        use. Its behavior is set via /api/demo/receiver/toggle and has
        three modes: "ok" (default) accepts any JSON body and returns 200;
        "fail" returns 500 so retries and dead-lettering can be
        demonstrated; "hang" stalls for 15 seconds, past the worker's 10
        second request timeout, so the worker's fetch aborts and the
        delivery is recorded as a timeout failure before this route ever
        gets to respond 504. Reads X-Event-Id / X-Delivery-Id headers set
        by the worker for consumer-side dedup: it keeps its own log of
        X-Delivery-Id values it has already handled (see GET
        /api/demo/receiver/log) and flags repeats instead of reprocessing
        them, which is what makes the duplicate-delivery demo (see
        POST /api/demo/worker action "crash-after-send") visibly provable.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: >
            Accepted (mode is "ok"). The normal shape is
            {ok:true, receivedEventId}. When this X-Delivery-Id has already
            been recorded — the redelivery half of a genuine at-least-once
            duplicate, most reliably produced with
            POST /api/demo/worker {"action":"crash-after-send"} — the
            receiver instead returns {ok:true, duplicate:true, note}
            without treating the payload as new.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, enum: [true] }
                  receivedEventId: { type: [string, "null"] }
                  duplicate:
                    type: boolean
                    description: Present and true only on a repeat X-Delivery-Id.
                  note:
                    type: string
                    description: >
                      Present only when duplicate is true; a short
                      human-readable explanation of why this request was
                      recognized as a repeat.
        "500":
          description: Simulated failure (mode is "fail").
        "504":
          description: >
            Simulated hang (mode is "hang"). Sent after a 15s stall; the
            worker will already have timed out and recorded the delivery
            as failed by the time this response is sent.

  /api/demo/receiver/log:
    get:
      tags: [demo]
      summary: What the demo consumer saw
      description: >
        The demo receiver's own request log, newest first, including
        duplicates. `duplicate: true` means the receiver had already
        recorded that X-Delivery-Id and skipped reprocessing it — that's
        the consumer's own idempotency check firing, not a server-side
        dedup. It's the clearest evidence that at-least-once delivery can
        hand a consumer the same delivery twice, and that a well-behaved
        consumer shrugs it off instead of double-processing it.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Log entries, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  entries:
                    type: array
                    items: { $ref: "#/components/schemas/ReceiverLogEntry" }

  /api/demo/run:
    post:
      tags: [demo]
      summary: Start the guided reliability demo
      description: >
        The one-click way to see the whole system work. Clears what the
        previous guided run created, then drives a scripted walkthrough in
        the background: registers a healthy endpoint and a flaky one,
        ingests a realistic order.created event, makes the flaky one fail,
        recovers it, and leaves the final delivery timeline open for
        inspection. Poll GET /api/demo/run to follow along and drive a
        narrator panel.

        Deliberately public, alone among the mutating demo routes, because
        it is the front door of the demo. It is safe to leave open because
        of what it deletes: only rows a previous guided run created, matched
        on an internal flag, never events or endpoints a caller registered.
        The unscoped wipe is POST /api/demo/reset and stays behind the admin
        gate, along with burst, worker and receiver controls.

        The run ingests under the source "guided-demo" rather than "demo",
        so its event reaches exactly the two endpoints it registers and
        nobody else's subscription is disturbed. Starting a run claims its
        slot with a conditional write, so simultaneous callers cannot both
        start; the losers get the 409 below.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                scenario:
                  type: string
                  enum: [reliability]
                  default: reliability
      responses:
        "202":
          description: Run started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  runId: { type: string }
                  totalSteps: { type: integer }
        "409":
          description: A run is already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    get:
      tags: [demo]
      summary: Poll the guided demo run
      description: >
        Read-only status for the narrator panel driving the guided demo
        started by POST /api/demo/run.
      responses:
        "200":
          description: >
            Current (or most recently finished) run state, or the
            `{active: false}` inactive shape (all other fields null/0) if
            no run has ever been started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DemoRun" }

  /api/demo/reset:
    post:
      tags: [demo]
      summary: Clear demo data
      description: >
        Deletes events, deliveries, delivery_attempts, the demo receiver's
        log, and any run state, and deactivates/removes every endpoint
        EXCEPT the built-in demo receiver (id `demo-receiver`), which is
        re-registered/repaired rather than deleted, so there's always
        somewhere for the next demo's deliveries to go. Used to give both
        the guided demo (POST /api/demo/run) and the manual demo controls a
        clean slate.
      responses:
        "200":
          description: Counts of what was cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cleared:
                    type: object
                    properties:
                      events: { type: integer }
                      deliveries: { type: integer }
                      endpoints: { type: integer }
                      receiverLog: { type: integer }
        "401":
          $ref: "#/components/responses/AdminLocked"

  /api/demo/receiver/toggle:
    get:
      tags: [demo]
      summary: Read the demo receiver's current mode
      responses:
        "200":
          description: Current state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceiverToggle" }
    put:
      tags: [demo]
      summary: Set the demo receiver's mode
      description: >
        Accepts {mode} ("ok" = respond 200, "fail" = respond 500, "hang" =
        stall past the worker's 10s timeout) or the legacy {fail: boolean}.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [mode]
                  properties:
                    mode: { type: string, enum: [ok, fail, hang] }
                - type: object
                  required: [fail]
                  properties:
                    fail: { type: boolean }
      responses:
        "200":
          description: Updated state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReceiverToggle" }
        "401":
          $ref: "#/components/responses/AdminLocked"
        "400":
          description: Invalid JSON body, or body matches neither the mode nor legacy fail shape.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/demo/worker:
    get:
      tags: [demo]
      summary: Read worker demo-control state
      responses:
        "200":
          description: Current state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkerControl" }
    post:
      tags: [demo]
      summary: Control the delivery worker for demos
      description: >
        "pause"/"resume" stop and restart delivery ticks.

        "crash-next" arms a one-shot simulated worker crash that aborts
        BEFORE the POST goes out: the next claimed delivery is abandoned
        mid-attempt (stuck in `delivering` with an open attempt row, no
        request ever sent) and rescued by the 60-second lease. This proves
        lease recovery — a crash that happens before the consumer ever saw
        anything.

        "crash-after-send" is the other half of the story: also one-shot,
        but the worker crashes AFTER a successful POST and BEFORE recording
        that success. That's the true at-least-once window — the consumer
        already has the webhook, the lease still expires, and the delivery
        goes out a second time with the SAME X-Delivery-Id, producing a
        genuine duplicate delivery (see GET /api/demo/receiver/log and the
        `duplicate` flag on POST /api/demo/receiver).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action:
                  type: string
                  enum: [pause, resume, crash-next, crash-after-send]
      responses:
        "200":
          description: State after applying the action.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkerControl" }
        "401":
          $ref: "#/components/responses/AdminLocked"
        "400":
          description: Unknown action.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/demo/burst:
    post:
      tags: [demo]
      summary: Queue a burst of demo events
      description: >
        Inserts up to 500 events (type "burst.test") and their deliveries in
        one transaction, for load/drain demos. The worker drains them at its
        bounded rate (20 per 2s tick).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                count:
                  type: integer
                  minimum: 1
                  maximum: 500
                  default: 100
      responses:
        "202":
          description: Burst queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  deliveries: { type: integer }
        "400":
          description: Invalid JSON body or failed schema validation.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          $ref: "#/components/responses/AdminLocked"

  /api/deliveries:
    get:
      tags: [deliveries]
      summary: List deliveries
      description: >
        Newest first, joined with event {id, type, source} and endpoint
        {id, name}. The Explorer UI uses status=delivering plus claimedAt to
        render lease-rescue countdowns.
      parameters:
        - name: status
          in: query
          schema:
            { $ref: "#/components/schemas/DeliveryStatus" }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Matching deliveries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/DeliveryListItem" }
        "400":
          description: Invalid status filter.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/openapi:
    get:
      tags: [stats]
      summary: This OpenAPI document
      responses:
        "200":
          description: The OpenAPI 3.1 spec, as YAML.
          content:
            text/yaml:
              schema: { type: string }

  /api/admin/status:
    get:
      tags: [admin]
      summary: Admin gate status for this caller
      description: >
        Public. The dashboard polls this to decide whether to show demo
        controls as locked. `gateEnabled` says whether this deployment has
        an admin token configured at all; `isAdmin` says whether this
        caller has already cleared the gate. When the gate is off (local
        dev), `isAdmin` is always true. The token itself is never echoed.
      responses:
        "200":
          description: Gate status.
          content:
            application/json:
              schema:
                type: object
                required: [gateEnabled, isAdmin]
                properties:
                  gateEnabled: { type: boolean }
                  isAdmin: { type: boolean }

  /api/admin/unlock:
    get:
      tags: [admin]
      summary: Unlock admin controls in this browser
      description: >
        One-time unlock. The operator visits this URL with the
        deployment's admin token; on a match it sets a 30-day httpOnly
        cookie and redirects to the dashboard, and every admin-gated route
        accepts that cookie from then on. Refuses when the gate is off
        (local dev), since there is no token to check against.
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
          description: The deployment's TRIGGERS_ADMIN_TOKEN value.
      responses:
        "307":
          description: Token accepted. Cookie set, redirect to the dashboard.
        "400":
          description: The admin gate is off on this deployment; nothing to unlock.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Missing or wrong token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

components:
  responses:
    AdminLocked:
      description: >
        Locked by the admin gate. The gate is on for deployed instances
        (where TRIGGERS_ADMIN_TOKEN is set) and off in local dev, where
        these routes are open to everyone. Present the token as a bearer
        or X-Admin-Token header, or unlock this browser once via
        GET /api/admin/unlock.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        `Authorization: Bearer <api key>`. The demo key seeded on boot is
        `tapi_demo_5f2c9b1e`, source "demo".
  schemas:
    ReceiverToggle:
      type: object
      properties:
        mode: { type: string, enum: [ok, fail, hang] }
        fail:
          type: boolean
          description: Legacy view of mode — true when mode is not "ok".
    WorkerControl:
      type: object
      properties:
        paused: { type: boolean }
        crashNextArmed: { type: boolean }
        crashAfterSendArmed: { type: boolean }
    ReceiverLogEntry:
      type: object
      properties:
        id: { type: string }
        deliveryId: { type: string }
        eventId: { type: string }
        attemptNumber: { type: integer }
        receivedAt: { type: integer, description: epoch ms }
        duplicate:
          type: boolean
          description: >
            True when this X-Delivery-Id had already been recorded by the
            receiver — its own idempotency check, separate from any
            server-side retry/dedup logic.
        mode:
          type: string
          description: Receiver mode in effect when this request arrived (ok, fail, hang).
        responseStatus: { type: integer }
    DemoRun:
      type: object
      description: >
        {active: false} (every other field null/0) when no run has been
        started yet. Otherwise reflects the current, or most recently
        finished, guided demo run.
      properties:
        active: { type: boolean }
        scenario: { type: [string, "null"] }
        stepIndex:
          type: integer
          description: 0-based index of the current step.
        totalSteps: { type: integer }
        title:
          type: [string, "null"]
          description: Short step title, e.g. "Flaky consumer fails".
        narration:
          type: [string, "null"]
          description: One plain-language sentence for the narrator panel.
        status:
          type: [string, "null"]
          enum: [running, waiting, done, failed, null]
        context:
          type: object
          description: Ids the UI follows as the run progresses.
          properties:
            eventId: { type: [string, "null"] }
            healthyEndpointId: { type: [string, "null"] }
            flakyEndpointId: { type: [string, "null"] }
            deliveryIds:
              type: array
              items: { type: string }
        startedAt: { type: [integer, "null"], description: epoch ms }
        updatedAt: { type: [integer, "null"], description: epoch ms }
    Error:
      type: object
      properties:
        error: { type: string }
        hint:
          type: string
          description: Present on some errors; a short pointer to the fix.
        issues:
          type: array
          description: Present on 400s from zod validation failures.
          items: { type: object }
    Event:
      type: object
      properties:
        id: { type: string, description: ULID }
        source: { type: string }
        type: { type: string }
        payload: { type: object, additionalProperties: true }
        idempotencyKey: { type: [string, "null"] }
        receivedAt: { type: integer, description: epoch ms }
        ackedAt: { type: [integer, "null"], description: epoch ms, null if unacked }
    Endpoint:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        url: { type: string }
        source:
          type: string
          description: >
            The one source this endpoint receives from. Set from the API key
            that registered it, or named explicitly by an admin caller.
        eventTypes:
          type: array
          items: { type: string }
          description: >
            Event types this endpoint receives. ["*"] means every type from
            its source. An empty list means nothing is delivered.
        active: { type: boolean }
        createdAt: { type: integer, description: epoch ms }
    MaskedEndpoint:
      type: object
      description: >
        An endpoint row as the mutation routes return it: the Endpoint
        fields with `url` masked, plus `urlOrigin` and `urlMasked`, and no
        delivery counts. This is what PATCH /api/endpoints/{id} returns.
      properties:
        id: { type: string }
        name: { type: string }
        url:
          type: string
          description: >
            Masked to origin + first path segment, except the built-in
            demo receiver endpoint, which is shown in full.
        source:
          type: string
          description: The one source this endpoint receives from.
        eventTypes:
          type: array
          items: { type: string }
          description: Event types received, or ["*"] for all of them.
        urlOrigin:
          type: string
          description: Scheme + host (+ port) of the endpoint URL, unmasked.
        urlMasked:
          type: string
          description: >
            Always the masked form of the endpoint URL, including for the
            built-in demo receiver.
        active: { type: boolean }
        createdAt: { type: integer, description: epoch ms }
    EndpointWithStats:
      type: object
      description: >
        Shape returned by GET /api/endpoints. Unlike Endpoint (the shape
        returned when you register one), `url` here is masked.
      properties:
        id: { type: string }
        name: { type: string }
        url:
          type: string
          description: >
            Masked to origin + first path segment (e.g.
            "https://api.example.com/hooks/…"), except for the built-in
            demo receiver endpoint, which is shown in full.
        source:
          type: string
          description: The one source this endpoint receives from.
        eventTypes:
          type: array
          items: { type: string }
          description: Event types received, or ["*"] for all of them.
        urlOrigin:
          type: string
          description: Scheme + host (+ port) of the endpoint URL, unmasked.
        urlMasked:
          type: string
          description: >
            Always the masked form of the endpoint URL (origin + first path
            segment, as described for `url` above) — including for the
            built-in demo receiver, unlike `url`, which shows that one
            endpoint's URL in full.
        active: { type: boolean }
        createdAt: { type: integer, description: epoch ms }
        delivered: { type: integer }
        dead: { type: integer }
        pending: { type: integer, description: pending + delivering }
        successRate:
          type: number
          description: delivered / (delivered + dead) for this endpoint.
    DeliveryStatus:
      type: string
      enum: [pending, delivering, delivered, dead, canceled]
    Delivery:
      type: object
      properties:
        id: { type: string }
        eventId: { type: string }
        endpointId: { type: string }
        status: { $ref: "#/components/schemas/DeliveryStatus" }
        attemptCount: { type: integer }
        maxAttempts:
          type: integer
          description: >
            This delivery's own attempt ceiling. Starts at 6; a manual
            retry raises it to attemptCount + 1 instead of rewinding
            attemptCount.
        nextAttemptAt: { type: integer, description: epoch ms, when the worker will try next }
        claimedAt: { type: [integer, "null"] }
        claimToken: { type: [string, "null"] }
        deliveredAt: { type: [integer, "null"] }
        createdAt: { type: integer }
    DeliveryAttempt:
      type: object
      properties:
        id: { type: string }
        deliveryId: { type: string }
        attemptNumber: { type: integer }
        startedAt: { type: integer, description: epoch ms }
        finishedAt: { type: [integer, "null"] }
        httpStatus: { type: [integer, "null"] }
        error: { type: [string, "null"] }
        requestHeaders:
          type: [object, "null"]
          description: >
            Headers sent with this attempt's POST (e.g. X-Event-Id,
            X-Delivery-Id), shown in the event detail waterfall.
          additionalProperties: { type: string }
        requestBody:
          type: [string, "null"]
          description: The request body sent with this attempt, as sent.
        responseSnippet:
          type: [string, "null"]
          description: >
            A truncated snippet of the response body, for quick inspection
            without storing full responses.
        retryReason:
          type: [string, "null"]
          description: >
            Why this attempt is being retried, e.g. "500 response",
            "timeout", or "abandoned (lease expired)".
        nextAttemptDelayMs:
          type: [integer, "null"]
          description: Backoff delay in ms before the next attempt, if one is scheduled.
    DeliveryDetail:
      allOf:
        - $ref: "#/components/schemas/Delivery"
        - type: object
          properties:
            endpoint:
              oneOf:
                - type: object
                  properties:
                    id: { type: string }
                    name: { type: string }
                    url: { type: string }
                - type: "null"
            attempts:
              type: array
              items: { $ref: "#/components/schemas/DeliveryAttempt" }
    DeliveryListItem:
      description: >
        Shape returned by GET /api/deliveries: a Delivery joined with
        minimal event and endpoint context (used to render the dashboard's
        delivery table without a second round trip per row).
      allOf:
        - $ref: "#/components/schemas/Delivery"
        - type: object
          properties:
            event:
              oneOf:
                - type: object
                  properties:
                    id: { type: string }
                    type: { type: string }
                    source: { type: string }
                - type: "null"
            endpoint:
              oneOf:
                - type: object
                  properties:
                    id: { type: string }
                    name: { type: string }
                - type: "null"
