openapi: 3.0.3
info:
  title: Metered Signalling Server — REST control plane
  version: "1.0.0"
  description: |
    Server-side REST control plane for the Metered Signalling product.
    Authenticated with sk_ keys (Bearer header). pk_ keys are NOT
    accepted on this surface — they're shipped to browsers and must
    not be able to mint tokens or kick peers.

    The wire protocol that customer peers speak (subscribe / publish /
    send / presence) is documented separately in PROTOCOL.md and is
    NOT part of this OpenAPI spec.

    **Rate limiting:** every endpoint under `/v1/` is per-IP rate
    limited (default: 600 requests / minute / IP). Excess requests
    return `429 rate_limited`. The limiter runs BEFORE authentication
    so a flooder can't burn auth-API capacity. If a leaked sk_ key
    is being abused, the per-IP limit is the first defense; rotate
    the key from the dashboard to fully revoke. Rate-limit applies
    to authenticated and unauthenticated callers identically.

servers:
  - url: https://rms.metered.ca
    description: Production
  - url: http://localhost:9292
    description: Local development

security:
  - BearerSk: []

components:
  securitySchemes:
    BearerSk:
      type: http
      scheme: bearer
      bearerFormat: sk_live_*
      description: |
        Customer's secret key, distributed only to backend processes.
        Created in the dashboard at /dashboard/signalling/keys.

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Stable machine-readable code (snake_case).
          example: unauthorized
        message:
          type: string
          description: Human-readable detail.

    MintTokenRequest:
      type: object
      required: [peerId]
      properties:
        peerId:
          type: string
          maxLength: 128
          description: |
            Identifier for the peer this token represents. Constraints
            match the WS protocol's sub-claim rules (printable ≤128
            chars).
        channels:
          type: array
          maxItems: 100
          items:
            type: string
          description: |
            Channels the token authorizes for this peer. MUST be a
            subset of the sk_ key's configured channelPatterns (403
            channel_not_authorized otherwise).
        permissions:
          type: array
          maxItems: 16
          items:
            type: string
          description: |
            Actions (publish/subscribe/presence/send) the token
            authorizes. MUST be a subset of the sk_ key's actions
            (403 action_not_permitted otherwise). Defaults to the
            full set the key has.
        metadata:
          type: object
          description: |
            Arbitrary customer-supplied JSON, propagated unchanged to
            `welcome.metadata` on the WebSocket connection. signalling-
            server never reads its contents — it's an opaque pass-
            through.

            **WebRTC convention:** customers shipping TURN credentials
            nest them under `metadata.iceServers`. The `peer-lib`
            package reads from there by default.

            **Sanitization is the consumer's job.** Anything a sk_
            holder puts here lands verbatim in welcome.metadata; if
            your client SDK renders fields as HTML, builds URLs, or
            probes endpoints, validate there. This is the same
            principle as treating untrusted user-input — sk_ keys are
            customer-controlled credentials, so what they ship is
            "internal" but still data that ends up in clients.

            Budget: 8 KB JSON-encoded.
        peerMetadata:
          type: object
          description: |
            Per-peer identity bag. Becomes the JWT's `peerMetadata`
            claim and the signalling-server STAMPS it onto outbound
            events visible to OTHER peers:
              - presence.joined / presence.left entries
              - direct.fromMetadata (always)
              - message.fromMetadata (when subscriber opted in via
                subscribe.includeSenderMetadata = true)

            Use for `userId`, `username`, `profilePic`, role tags —
            anything other peers should see without a separate backend
            lookup. **Distinct from `metadata`:** `metadata` is
            connection-private (welcome only); `peerMetadata` is
            broadcast to other peers.

            Budget: 4 KB JSON-encoded (same cap the verifier applies
            to self-minted tokens — REST and self-mint are
            wire-equivalent).
        expiresInSec:
          type: integer
          minimum: 1
          maximum: 86400
          default: 3600
          description: |
            Token lifetime in seconds. Capped at 24h — long-lived
            tokens reduce the effectiveness of the customer's
            revocation window.

    MintTokenResponse:
      type: object
      required: [token, expiresAt]
      properties:
        token:
          type: string
          description: HS256-signed JWT.
        expiresAt:
          type: integer
          description: Unix timestamp (seconds) of expiry.

    PresencePeer:
      type: object
      required: [peerId]
      properties:
        peerId:
          type: string
        metadata:
          type: object
          description: |
            The peer's `peerMetadata` JWT claim, stamped server-side
            from auth context at subscribe time. Omitted for peers
            whose JWT did not include `peerMetadata`.
          additionalProperties: true

    ListPeersResponse:
      type: object
      required: [channel, peers]
      properties:
        channel:
          type: string
        peers:
          type: array
          items:
            $ref: "#/components/schemas/PresencePeer"

    PublishRequest:
      type: object
      required: [data]
      properties:
        data:
          description: |
            Anything JSON-serializable. Capped at 32 KB encoded.
        from:
          type: string
          maxLength: 128
          default: server
          description: |
            Origin field surfaced to subscribers. Defaults to "server"
            so receivers can distinguish server-side sends from
            peer-to-peer messages. Customers MAY override with any
            value the WS-layer peerId validator accepts — same
            constraints apply (printable, ≤128 chars) so a value that
            round-trips through REST can also round-trip through WS.

            **Trust model:** `from` is asserted by the sk_ credential
            and is NOT verified against any peer's identity. Anyone
            holding the sk_ key can attribute messages to any peer ID
            in their app. Treat `from` as advisory; for cryptographic
            peer attestation, sign payloads in your application layer.
            The sk_ key is a server-side admin credential by design —
            this matches Stream Chat / Twilio Conversations / Ably
            "publish on behalf of" semantics.

    PublishResponse:
      type: object
      required: [channel, delivered]
      properties:
        channel:
          type: string
        delivered:
          type: integer
          description: Count of subscribers the message reached on this instance.

    DeletePeerResponse:
      type: object
      required: [peerId, closed]
      properties:
        peerId:
          type: string
        closed:
          type: integer
          description: |
            Count of WS connections closed. 0 is success-with-no-effect
            (peer wasn't online). >1 is possible (multi-tab user with
            the same peerId on multiple sockets).

    UsageResponse:
      type: object
      required:
        - periodStartUnix
        - periodEndUnix
        - concurrentNow
        - peakConcurrent
        - messagesUsed
        - overageMessages
        - overageConnections
        - plan
      properties:
        periodStartUnix:
          type: integer
          nullable: true
        periodEndUnix:
          type: integer
          nullable: true
        concurrentNow:
          type: integer
        peakConcurrent:
          type: integer
        messagesUsed:
          type: integer
          description: |
            Plan-allowance message attempts this period. Includes
            soft-dropped over-quota attempts; billing is driven from
            overageMessages, not this counter.
        overageMessages:
          type: integer
          description: |
            Cumulative overage messages billed this period. Lags by up
            to one flush interval (~60s).
        overageConnections:
          type: integer
        plan:
          nullable: true
          type: object
          properties:
            name:
              type: string
              nullable: true
            maxConcurrentConnections:
              type: integer
            maxMessagesPerPeriod:
              type: integer
            overagesAllowed:
              type: boolean
            overagesEnabled:
              type: boolean

paths:
  /v1/tokens:
    post:
      summary: Mint a JWT for a peer
      description: |
        Server-side mint of a connect token. Alternative to customers
        signing JWTs themselves with their sk_ secret. Authorization
        scope of the minted token is the intersection of the sk_ key's
        configured limits and the request body — sk_ holders cannot
        elevate above their dashboard config.
      operationId: mintToken
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MintTokenRequest"
      responses:
        "200":
          description: Token minted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MintTokenResponse"
        "400":
          description: Invalid request body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing / invalid sk_ key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: |
            channel_not_authorized — requested channel outside
            keyInfo.channelPatterns. action_not_permitted — requested
            permission not in keyInfo.actions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/channels/{id}/peers:
    get:
      summary: List peers subscribed to a channel
      operationId: listChannelPeers
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Peer list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListPeersResponse"
        "400":
          description: Reserved channel name (`_metered/`, `_internal/`, `_system/` prefixes)
        "401":
          description: Missing / invalid sk_ key
        "403":
          description: Channel outside key's patterns OR key lacks 'presence' action

  /v1/channels/{id}/publish:
    post:
      summary: Server-side publish to a channel
      description: |
        Publish without opening a WS connection. Recipients see the
        message identically to a peer-authored publish, except `from`
        defaults to "server" so subscribers can branch on origin.

        REST publishes count 1:1 with WS publishes against the
        customer's `messagesUsed` counter and overage billing — there
        is no quota difference between the surfaces. A customer at
        plan max with overages off will get 402 from REST publish
        just as they get an `over_message_quota` error from WS publish.
      operationId: publishToChannel
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublishRequest"
      responses:
        "200":
          description: Published — `delivered` reports the recipient count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublishResponse"
        "400":
          description: Reserved channel name OR missing data field
        "401":
          description: Missing / invalid sk_ key
        "402":
          description: |
            `over_message_quota` — customer is over plan
            messagesPerPeriod and either overages are disabled or
            account balance is exhausted with no auto-recharge. Path
            forward is enable overages, top up balance, or wait for
            period rollover. Retrying without changing billing state
            won't help (in contrast to 429, which signals back-off).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Channel outside key's patterns OR key lacks 'publish' action
        "413":
          description: data exceeds 32 KB cap

  /v1/peers/{id}:
    delete:
      summary: Forcibly disconnect every WS for a peer
      description: |
        Closes every WS connection owned by (sk_ key's appId, peerId)
        with WS close code 4020 AdminDisconnect. Distinct from 1001
        (process shutdown) and 4012 (account suspended) so client
        SDKs can branch on intent.

        Returns 200 with `closed: 0` when the peer isn't online — the
        request was well-formed and processed, the result is just
        zero sockets. NOT a 404.
      operationId: deletePeer
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Kicked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeletePeerResponse"
        "400":
          description: Invalid peer id (empty, too long, non-printable)
        "401":
          description: Missing / invalid sk_ key

  /v1/usage:
    get:
      summary: Per-customer concurrent + period stats
      description: |
        Composite read of live counters and plan limits. Cumulative
        overage values lag by up to ~60s (one flush interval); other
        values are read live.
      operationId: getUsage
      responses:
        "200":
          description: Usage snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsageResponse"
        "401":
          description: Missing / invalid sk_ key
        "503":
          description: |
            Plan cache lookup failed (auth-API unreachable). Retry
            after a short backoff; live signalling traffic is NOT
            affected by this error.
