openapi: 3.1.0
info:
  title: Shade Prekey Server
  description: |
    Signal Protocol prekey distribution server. Stores identity public keys
    and prekey bundles. Any language can implement a client — this spec
    documents the wire contract.

    **Security model:** Write operations (register, replenish, delete) are
    authenticated by Ed25519 signatures over the request body. Bundle fetches
    are anonymous. See the `SignedPayload` schema for the signing format.
  version: "4.0.0"
  license:
    name: MIT
    url: https://gt.zyon.no/Stian/Shade/raw/branch/main/LICENSE

servers:
  - url: https://shade.example.com
    description: Replace with your project's prekey server URL

paths:
  /health:
    get:
      summary: Health check
      description: Returns 200 if the server and its storage backend are reachable.
      tags: [Infrastructure]
      responses:
        '200':
          description: Healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '503':
          description: Unhealthy

  /metrics:
    get:
      summary: Prometheus metrics
      description: Counter, histogram, and gauge metrics in Prometheus text format.
      tags: [Infrastructure]
      responses:
        '200':
          description: Metrics
          content:
            text/plain:
              schema:
                type: string
                example: |
                  # HELP shade_requests_total Total HTTP requests
                  # TYPE shade_requests_total counter
                  shade_requests_total{route="/v1/keys/register",status="200"} 42

  /openapi.yaml:
    get:
      summary: This OpenAPI spec
      description: Serves the YAML spec itself, so clients can auto-download it.
      tags: [Infrastructure]
      responses:
        '200':
          description: OpenAPI spec
          content:
            application/yaml:
              schema:
                type: string

  /v1/keys/register:
    post:
      summary: Register identity and upload prekey bundle
      description: |
        Register a new identity with the prekey server, or update an existing
        one. The request body must be signed with the identity's own Ed25519
        signing key (TOFU — first signature establishes the identity).
      tags: [Keys]
      security:
        - Ed25519Signature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SignedPayload'
                - type: object
                  required: [address, identitySigningKey, identityDHKey, signedPreKey]
                  properties:
                    address:
                      $ref: '#/components/schemas/Address'
                    identitySigningKey:
                      type: string
                      description: Ed25519 public key, base64
                    identityDHKey:
                      type: string
                      description: X25519 public key, base64
                    signedPreKey:
                      $ref: '#/components/schemas/SignedPreKeyEntry'
                    oneTimePreKeys:
                      type: array
                      items:
                        $ref: '#/components/schemas/OneTimePreKeyEntry'
      responses:
        '200':
          description: Registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/keys/bundle/{address}:
    get:
      summary: Fetch a prekey bundle (anonymous)
      description: |
        Fetch an identity's prekey bundle so Alice can start an X3DH session
        with Bob. Each call consumes one one-time prekey (FIFO) if any are
        available — if not, returns a bundle without the one-time prekey.
      tags: [Keys]
      parameters:
        - name: address
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/Address'
      responses:
        '200':
          description: Prekey bundle
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PreKeyBundle'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/keys/count/{address}:
    get:
      summary: Get remaining one-time prekey count (anonymous)
      tags: [Keys]
      parameters:
        - name: address
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/Address'
      responses:
        '200':
          description: Count
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer, minimum: 0 }

  /v1/keys/replenish:
    post:
      summary: Upload more one-time prekeys (signed)
      tags: [Keys]
      security:
        - Ed25519Signature: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SignedPayload'
                - type: object
                  required: [address, oneTimePreKeys]
                  properties:
                    address:
                      $ref: '#/components/schemas/Address'
                    oneTimePreKeys:
                      type: array
                      items:
                        $ref: '#/components/schemas/OneTimePreKeyEntry'
      responses:
        '200':
          description: Replenished
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  remaining: { type: integer, minimum: 0 }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/keys/{address}:
    delete:
      summary: Unregister an identity (signed)
      tags: [Keys]
      security:
        - Ed25519Signature: []
      parameters:
        - name: address
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/Address'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignedPayload'
      responses:
        '200':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'

  /v1/kt/log_id:
    get:
      summary: Key Transparency log identity (V3.12)
      description: |
        Returns the operator's STH-signing public key and derived log_id
        (`SHA-256(public_key)`). Clients fetch this once during bootstrap
        to confirm they pinned the right key out-of-band. Available when
        the server is started with `keyTransparency` configured.
      tags: [KeyTransparency]
      responses:
        '200':
          description: Log identity
          content:
            application/json:
              schema:
                type: object
                required: [logId, publicKey]
                properties:
                  logId: { type: string, description: 'base64 of sha256(publicKey)' }
                  publicKey: { type: string, description: 'base64 Ed25519 public key (32 bytes)' }

  /v1/kt/sth:
    get:
      summary: Latest Signed Tree Head (V3.12)
      tags: [KeyTransparency]
      responses:
        '200':
          description: Most recent STH
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/STH'

  /v1/kt/sth/{treeSize}:
    get:
      summary: Historical STH at a specific tree size (V3.12)
      tags: [KeyTransparency]
      parameters:
        - name: treeSize
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
      responses:
        '200':
          description: STH at requested tree_size
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/STH'
        '404':
          description: No STH at that tree_size

  /v1/kt/consistency:
    get:
      summary: Consistency proof between two tree sizes (V3.12)
      description: |
        RFC 6962 §2.1.2 consistency proof from `from` to `to`. Used by
        clients and witnesses to verify that the log between two STHs
        is an honest extension, not a re-write.
      tags: [KeyTransparency]
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: integer, minimum: 0 }
        - name: to
          in: query
          required: false
          schema: { type: integer, minimum: 0 }
      responses:
        '200':
          description: Consistency proof
          content:
            application/json:
              schema:
                type: object
                required: [fromTreeSize, toTreeSize, proof]
                properties:
                  fromTreeSize: { type: integer }
                  toTreeSize: { type: integer }
                  proof:
                    type: array
                    items: { type: string, description: 'base64 of 32-byte node hash' }

  /v1/transfer/health:
    get:
      summary: Peer transfer-route reachability probe
      description: |
        Lightweight probe served by **the receiver-side peer** (mounted via
        `Shade.transferRoute()` or `createTransferRoutes()`). The prekey
        server itself does not serve `/v1/transfer/*` — it is documented
        here so any-language clients can generate transfer clients from
        the same OpenAPI contract.
      tags: [Transfer]
      responses:
        '200':
          description: Peer is reachable
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }

  /v1/transfer/{streamId}/chunk:
    post:
      summary: Upload a stream-chunk envelope (signed by sender)
      description: |
        Per-chunk POST: the sender uploads a wire-encoded `0x11`
        stream-chunk envelope, signed via `ShadeTransferAuthenticator`.
        The receiver verifies the signature, ratifies lane + seq, and
        returns an `{ok, lastSeqAcked}` ACK.

        Body is `application/octet-stream`. Body size is bounded by the
        receiver's `maxChunkBytes` option (default ≈ 16 MiB + header);
        anything over the limit returns `413` before decryption.
      tags: [Transfer]
      security:
        - ShadeTransferAuthenticator: []
      parameters:
        - name: streamId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/StreamId'
        - name: X-Shade-Lane-Id
          in: header
          required: true
          description: Lane index (`u32`, 0-based) the chunk belongs to.
          schema:
            type: integer
            minimum: 0
        - name: X-Shade-Seq
          in: header
          required: true
          description: Per-lane chunk sequence number (`u64` decimal).
          schema:
            type: string
            pattern: '^[0-9]+$'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: Wire-encoded `0x11` stream-chunk envelope.
      responses:
        '200':
          description: Chunk accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChunkAck'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Replay or out-of-order chunk
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: Chunk exceeds receiver's `maxChunkBytes`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/transfer/{streamId}/state:
    get:
      summary: Read resume-state for a stream (signed)
      description: |
        Sender-side resume probe: returns the receiver's per-lane
        `lastSeqAcked` so the sender can skip already-ack'd chunks.
        Authentication is the same `ShadeTransferAuthenticator` shape
        as `/chunk`, but signed over the control canonical form
        (method + path) rather than chunk bodies.
      tags: [Transfer]
      security:
        - ShadeTransferAuthenticator: []
      parameters:
        - name: streamId
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/StreamId'
      responses:
        '200':
          description: Resume state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResumeState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: No state recorded for this streamId
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/transfer/control:
    post:
      summary: Deliver a control-plane ratchet envelope
      description: |
        SDK-internal endpoint that receives a wire-encoded `0x02`
        Double-Ratchet envelope from a peer. The body is the raw
        envelope bytes; the sender's address is supplied via the
        `X-Shade-Sender-Address` header. The receiver decrypts on the
        ratchet, parses the control message, and dispatches to the
        transfer engine. **Note:** `0x02` envelopes are already
        ratchet-authenticated, so this endpoint does not require a
        separate signature header.
      tags: [Transfer]
      parameters:
        - name: X-Shade-Sender-Address
          in: header
          required: true
          schema:
            $ref: '#/components/schemas/Address'
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: Wire-encoded `0x02` ratchet envelope.
      responses:
        '200':
          description: Control envelope accepted
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
        '400':
          $ref: '#/components/responses/ValidationError'

  /v1/inbox/register:
    post:
      summary: Bind an address to a signing key (TOFU) — inbox relay (V3.6)
      description: |
        Claims a recipient address on the inbox relay. The first
        successful registration binds the address to the supplied
        signing key; subsequent fetch / ack / unregister calls must
        be Ed25519-signed by the same key. A different key claiming
        an existing address is rejected with 401.
      tags: [Inbox]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SignedPayload'
                - type: object
                  required: [address, signingPublicKey]
                  properties:
                    address: { $ref: '#/components/schemas/Address' }
                    signingPublicKey:
                      type: string
                      description: Ed25519 public key, base64
      responses:
        '200':
          description: Address bound to signing key
        '401':
          description: Wrong signing key for an existing registration
        '409':
          description: Stale signedAt (replay-window)
        '400':
          $ref: '#/components/responses/ValidationError'

  /v1/inbox/register/{address}:
    delete:
      summary: Release an inbox registration
      description: |
        Signed unregister — drops the address ↔ signing-key binding
        and any queued blobs for the address.
      tags: [Inbox]
      parameters:
        - name: address
          in: path
          required: true
          schema: { $ref: '#/components/schemas/Address' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignedPayload'
      responses:
        '200': { description: Registration removed }
        '401': { description: Bad signature for the address }

  /v1/inbox/{address}:
    post:
      summary: Deposit a ciphertext blob for a recipient
      description: |
        Signed PUT carrying an opaque ciphertext blob plus
        `msgId = lower-hex(sha256(ciphertext))`. Idempotent on
        `(address, msgId)` — replays return `200 { idempotent: true }`.
        The relay rejects bodies past `maxBlobBytes` (default 1 MiB)
        and per-recipient quota (default 1000 blobs).
      tags: [Inbox]
      parameters:
        - name: address
          in: path
          required: true
          schema: { $ref: '#/components/schemas/Address' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SignedPayload'
                - type: object
                  required: [msgId, ciphertext, expiresAt, senderPublicKey]
                  properties:
                    msgId:
                      type: string
                      description: Lower-hex SHA-256 of ciphertext bytes.
                    ciphertext:
                      type: string
                      description: Base64-encoded ratchet envelope (`0x02`).
                    expiresAt:
                      type: integer
                      format: int64
                      description: Unix epoch ms; clamped to `[minTtl, maxTtl]`.
                    senderPublicKey:
                      type: string
                      description: Per-PUT Ed25519 public key, base64. TOFU-bound for the duration of this blob.
      responses:
        '200':
          description: Blob accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  idempotent: { type: boolean }
        '401': { description: Bad sender signature }
        '409': { description: Stale signedAt or msgId mismatch }
        '413': { description: Body past maxBlobBytes }
        '429': { description: Per-address quota exceeded }

  /v1/inbox/{address}/fetch:
    post:
      summary: Cursor-paginated fetch of queued blobs
      description: |
        Signed challenge that returns up to `limit` blobs newer than the
        supplied cursor. Cursors are server-issued opaque strings.
      tags: [Inbox]
      parameters:
        - name: address
          in: path
          required: true
          schema: { $ref: '#/components/schemas/Address' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/SignedPayload'
                - type: object
                  properties:
                    cursor: { type: string, nullable: true }
                    limit: { type: integer, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: Blob page
          content:
            application/json:
              schema:
                type: object
                required: [items, nextCursor]
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      required: [msgId, ciphertext, receivedAt, expiresAt]
                      properties:
                        msgId: { type: string }
                        ciphertext: { type: string }
                        receivedAt: { type: integer, format: int64 }
                        expiresAt: { type: integer, format: int64 }
                  nextCursor:
                    type: string
                    nullable: true
        '401': { description: Bad signature }

  /v1/inbox/{address}/{msgId}:
    delete:
      summary: Acknowledge (delete) a delivered blob
      tags: [Inbox]
      parameters:
        - name: address
          in: path
          required: true
          schema: { $ref: '#/components/schemas/Address' }
        - name: msgId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SignedPayload'
      responses:
        '200': { description: Blob acknowledged and removed }
        '401': { description: Bad signature for this address }
        '404': { description: Blob not found (already acked or expired) }

  /v1/bridge/stream:
    get:
      summary: Server-Sent Events feed of pending envelopes (V3.7)
      description: |
        Signed query string (no headers — `EventSource` strips them) drives
        an SSE feed. Each delivered envelope is emitted as
        `event: envelope` with a JSON payload. Heartbeats are sent every
        15 s as `: ping` SSE comments. Resume on reconnect via
        `Last-Event-ID`. `kind=stream` is bound into the canonical signed
        payload to prevent cross-endpoint replay.
      tags: [Bridge]
      parameters:
        - in: query
          name: address
          required: true
          schema: { $ref: '#/components/schemas/Address' }
        - in: query
          name: signedAt
          required: true
          schema: { type: integer, format: int64 }
        - in: query
          name: signature
          required: true
          schema: { type: string }
        - in: query
          name: kind
          required: true
          schema: { type: string, enum: [stream] }
      responses:
        '200':
          description: SSE stream
          content:
            text/event-stream:
              schema:
                type: string
        '401': { description: Bad signature }

  /v1/bridge/poll:
    get:
      summary: Long-poll fallback for environments without WS or SSE (V3.7)
      description: |
        Returns at most one batch of pending envelopes; holds the
        connection open up to `timeoutMs` (default 25 s, hard cap 55 s)
        if nothing is queued. `kind=poll` is bound into the canonical
        signed payload.
      tags: [Bridge]
      parameters:
        - in: query
          name: address
          required: true
          schema: { $ref: '#/components/schemas/Address' }
        - in: query
          name: signedAt
          required: true
          schema: { type: integer, format: int64 }
        - in: query
          name: signature
          required: true
          schema: { type: string }
        - in: query
          name: kind
          required: true
          schema: { type: string, enum: [poll] }
        - in: query
          name: timeoutMs
          schema: { type: integer, minimum: 0, maximum: 55000 }
      responses:
        '200':
          description: Pending envelope batch (possibly empty after timeout).
          content:
            application/json:
              schema:
                type: object
                required: [envelopes]
                properties:
                  envelopes:
                    type: array
                    items:
                      type: object
                      required: [from, bytes, receivedAt]
                      properties:
                        from: { $ref: '#/components/schemas/Address' }
                        bytes: { type: string, description: 'Base64 envelope' }
                        receivedAt: { type: integer, format: int64 }
                        msgId: { type: string, nullable: true }
        '401': { description: Bad signature }

  /v1/bridge/ws:
    get:
      summary: WebSocket bridge (Bun WS upgrade) (V3.7)
      description: |
        Upgrade to a WebSocket. Each delivered envelope is one JSON frame.
        Authentication is the same signed-query pattern as the SSE and
        long-poll endpoints (`kind=ws`).
      tags: [Bridge]
      parameters:
        - in: query
          name: address
          required: true
          schema: { $ref: '#/components/schemas/Address' }
        - in: query
          name: signedAt
          required: true
          schema: { type: integer, format: int64 }
        - in: query
          name: signature
          required: true
          schema: { type: string }
        - in: query
          name: kind
          required: true
          schema: { type: string, enum: [ws] }
      responses:
        '101': { description: Switching Protocols (WS upgrade) }
        '401': { description: Bad signature }

  /metrics:
    get:
      summary: Prometheus metrics
      description: |
        Plaintext Prometheus exposition. Counters for HTTP requests,
        rate-limit rejections, KT publish events, inbox PUT/FETCH/ACK,
        bridge transports, and inbox prune cycles.
      tags: [Operations]
      responses:
        '200':
          description: Prometheus exposition.
          content:
            text/plain:
              schema:
                type: string

  /healthz:
    get:
      summary: Liveness probe
      tags: [Operations]
      responses:
        '200': { description: Process alive }

  /ready:
    get:
      summary: Readiness probe
      description: |
        Returns 200 only when the storage backend, the inbox store (if
        configured) and any KT log store have all reported ready.
      tags: [Operations]
      responses:
        '200': { description: All backends ready }
        '503': { description: At least one backend not ready }

  /shade-observer/api/state:
    get:
      summary: Current observer snapshot (optional)
      description: |
        Returns the aggregated state visible to the dashboard. Only available
        if the server was started with `SHADE_OBSERVER_TOKEN` set.
      tags: [Observer]
      security:
        - ObserverBearerToken: []
      responses:
        '200':
          description: Observer state snapshot
        '401':
          description: Invalid or missing token

components:
  schemas:
    Address:
      type: string
      description: |
        An address on the prekey server. Alphanumeric plus `:_-.` characters,
        max 256 chars. NFKC-normalized. Format is typically
        `user@domain:deviceId` or `device:uuid`.
      pattern: '^[a-zA-Z0-9][a-zA-Z0-9:_\-.]{0,255}$'
      example: "alice@example.com:phone"

    SignedPayload:
      type: object
      description: |
        Base type for all signed request bodies. The `signature` field is
        an Ed25519 signature over the canonical JSON of the payload
        (all fields sorted, `signature` omitted) using the identity's
        signing private key. `signedAt` must be within ±5 minutes of
        server time.
      required: [signedAt, signature]
      properties:
        signedAt:
          type: integer
          format: int64
          description: Unix epoch milliseconds
        signature:
          type: string
          description: Ed25519 signature, base64

    SignedPreKeyEntry:
      type: object
      required: [keyId, publicKey, signature]
      properties:
        keyId:
          type: integer
          minimum: 0
        publicKey:
          type: string
          description: X25519 public key, base64
        signature:
          type: string
          description: Ed25519 signature over `publicKey`, base64

    OneTimePreKeyEntry:
      type: object
      required: [keyId, publicKey]
      properties:
        keyId:
          type: integer
          minimum: 0
        publicKey:
          type: string
          description: X25519 public key, base64

    STH:
      type: object
      description: |
        Signed Tree Head — the operator-signed commitment to the current
        state of the Key Transparency log (V3.12). Clients verify the
        signature against a pinned `logPublicKey` and use `rootHash`
        plus an audit path to validate inclusion proofs.
      required: [treeSize, timestampMs, rootHash, indexRoot, logId, signature]
      properties:
        treeSize:
          type: integer
          minimum: 0
          description: Number of leaves in the log
        timestampMs:
          type: integer
          format: int64
          description: Unix epoch ms; clients reject STHs older than `maxStaleMs` (default 24h)
        rootHash:
          type: string
          description: 32-byte Merkle root hash, base64
        indexRoot:
          type: string
          description: 32-byte commitment to the address-index, base64
        logId:
          type: string
          description: 32-byte SHA-256 of the operator's signing public key, base64
        signature:
          type: string
          description: Ed25519 signature over canonical bytes (DOMAIN_STH || treeSize || timestampMs || rootHash || indexRoot || logId), base64

    KTProof:
      type: object
      description: |
        Combined proof attached to bundle responses when KT is active.
        See `@shade/key-transparency` for full structure of the `body`
        variant (inclusion / tombstone / absence).
      required: [sth, body]
      properties:
        sth:
          $ref: '#/components/schemas/STH'
        body:
          type: object
          description: Variant-specific proof body

    PreKeyBundle:
      type: object
      required: [identitySigningKey, identityDHKey, signedPreKey]
      properties:
        identitySigningKey:
          type: string
          description: Ed25519 public key, base64
        identityDHKey:
          type: string
          description: X25519 public key, base64
        signedPreKey:
          $ref: '#/components/schemas/SignedPreKeyEntry'
        oneTimePreKey:
          $ref: '#/components/schemas/OneTimePreKeyEntry'

    HealthResponse:
      type: object
      properties:
        status:
          type: string
          enum: [ok, error]
        service:
          type: string

    StreamId:
      type: string
      description: |
        URL-safe base64 encoding of the 16-byte streamId allocated by
        the sender. Always 22 characters, no padding.
      pattern: '^[A-Za-z0-9_-]{22}$'
      example: "Tg9k0lZc7r2wQwUYP3KX9A"

    ChunkAck:
      type: object
      required: [ok, lastSeqAcked]
      properties:
        ok:
          type: boolean
        lastSeqAcked:
          type: string
          description: |
            Highest contiguous `seq` accepted by the receiver for this
            lane, encoded as a decimal string (`u64` does not fit in a
            JSON number).
          pattern: '^[0-9]+$'
          example: "42"

    ResumeState:
      type: object
      description: |
        Per-stream resume snapshot: each lane's last accepted seq, plus
        the stream status. Returned by `GET /v1/transfer/{streamId}/state`.
      required: [streamId, status, lanes]
      properties:
        streamId:
          $ref: '#/components/schemas/StreamId'
        status:
          type: string
          enum: [active, paused, finished, aborted]
        lanes:
          type: array
          items:
            type: object
            required: [laneId, lastSeqAcked]
            properties:
              laneId:
                type: integer
                minimum: 0
              lastSeqAcked:
                type: string
                pattern: '^[0-9]+$'

    ErrorResponse:
      type: object
      properties:
        name:
          type: string
        code:
          type: string
          pattern: '^SHADE_'
        message:
          type: string

  responses:
    ValidationError:
      description: Invalid request body
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Signature verification failed or replay window exceeded
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Address not registered
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Rate limit exceeded
      headers:
        Retry-After:
          schema:
            type: integer
            description: Seconds until the client can retry
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  securitySchemes:
    Ed25519Signature:
      type: apiKey
      in: header
      name: X-Shade-Signature-Info
      description: |
        NOT an actual header. Signature is carried inside the JSON request
        body as the `signature` and `signedAt` fields (see SignedPayload).
        The canonical form signed is the request body with all keys sorted
        and the `signature` field omitted. Use your Ed25519 signing key.
    ObserverBearerToken:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <SHADE_OBSERVER_TOKEN>`. The observer also
        accepts the token via `?token=...` query string for SSE endpoints
        that can't set headers.
    ShadeTransferAuthenticator:
      type: apiKey
      in: header
      name: X-Shade-Signature
      description: |
        Per-request Ed25519 signature over a canonical byte string. Three
        headers must be sent together:

          - `X-Shade-Sender-Address: <address>`
          - `X-Shade-Signed-At: <unix-ms>` (must be within ±5 minutes of
            receiver clock, same window as the prekey server)
          - `X-Shade-Signature: <base64-of-sig>`

        Canonical message for `/v1/transfer/{streamId}/chunk`:

            chunk\0
            addr=<address>\0
            at=<signedAt>\0
            sid=<streamId>\0
            lane=<laneId>\0
            seq=<seq decimal>\0
            bodyHash=<hex(sha256(body))>\0

        Canonical message for `/v1/transfer/{streamId}/state`:

            control\0
            addr=<address>\0
            at=<signedAt>\0
            sid=<streamId>\0
            method=GET\0
            path=/v1/transfer/<streamId>/state\0

        The receiver verifies using the public Ed25519 signing key it
        fetched from the prekey server's `/v1/keys/bundle/{address}`
        endpoint. SDK reference implementation:
        `packages/shade-sdk/src/streams-bridge.ts` (`ShadeTransferAuthenticator`).

        OpenAPI 3.1 has no native multi-header security scheme, so this
        scheme is declared as `apiKey` on `X-Shade-Signature` with the
        sibling headers documented in the description. Generated clients
        SHOULD treat all three headers as mandatory.
