openapi: 3.1.0
info:
  title: Switchboard Relay API
  version: "1"
  description: |
    The endpoints a moq-relay calls. The only one you call yourself is
    `POST /cluster/enroll`, once, when you set up a Self-hosted relay. The
    rest are documented so you can see what your relay is doing and make
    sense of its errors.

    A relay proves who it is with a credential in the URL, as `jwt=`. A
    Self-hosted relay uses its Node key (`sbn_...`). A request without one is
    a 401.

    These endpoints work with stock `moqdev/moq-relay` 0.15 or later. The
    request and response shapes of `/cluster/connect` and `/auth` are
    moq-relay's, not ours.
servers:
  - url: https://switchboard.mux.global
security:
  - nodeKey: []
tags:
  - name: Enrollment
    description: Turning a Relay enrollment token into a Node key, once per relay.
  - name: Peer discovery
    description: How a relay finds the other relays it should connect to.
  - name: Admission
    description: How a relay decides whether a Client may publish or subscribe.
  - name: Health
    description: Liveness, for load balancers and monitoring.
paths:
  /cluster/enroll:
    post:
      tags: [Enrollment]
      operationId: enroll
      summary: Exchange a Relay enrollment token for a Node key
      description: |
        Spends a Relay enrollment token from
        `POST /v1/organizations/{id}/enrollments` and returns the Node, its
        Node key, and the values to put in the relay's `relay.toml`. Run it
        once per relay, by hand or from your provisioning script.

        The token is the only credential, and it works once. A second exchange
        is a 401. Keep the Node key. A relay that loses it has to be enrolled
        again as a new Node.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  example: sbe_Vq3m...
      responses:
        "201":
          description: Enrolled.
          content:
            application/json:
              schema:
                type: object
                required: [node, node_key, cluster, auth]
                properties:
                  node:
                    $ref: "#/components/schemas/Node"
                  node_key:
                    type: string
                    description: |
                      The Node key. It goes in three places: the `jwt=` of both
                      URLs below (already filled in), and the file that
                      `[cluster] token` points to.
                    example: sbn_9KfQ...
                  cluster:
                    type: object
                    description: The relay's `[cluster]` settings.
                    required: [node, connect_api]
                    properties:
                      node:
                        type: string
                        example: https://relay1.acme.example:4443/
                      connect_api:
                        type: string
                        example: https://switchboard.mux.global/cluster/connect?jwt=sbn_9KfQ...
                  auth:
                    type: object
                    description: The relay's `[auth]` settings.
                    required: [url]
                    properties:
                      url:
                        type: string
                        example: https://switchboard.mux.global/auth?jwt=sbn_9KfQ...
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: The token is unknown, expired, or already used.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: |
            Another Node took this token's URL after the token was issued. The
            token still works once that Node is deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /cluster/connect:
    get:
      tags: [Peer discovery]
      operationId: connect
      summary: Get the relay's peers
      description: |
        The relay polls this about once a second, using the `connect_api` URL
        from enrollment, and dials every URL in the list. Each pair of peers is
        dialed from one side only, so two relays in the same Region don't both
        appear in each other's lists.

        A relay that stops polling is dropped from its peers' lists after 90
        seconds.

        If Switchboard can't be reached, the relay keeps the peers it has.
      parameters:
        - name: jwt
          in: query
          required: true
          description: The Node key.
          schema:
            type: string
        - name: node
          in: query
          description: |
            Added by the relay from `[cluster] node`. It has to match the URL
            the Node was enrolled with.
          schema:
            type: string
        - name: region
          in: query
          description: |
            If present, it has to match the Region the Node was enrolled in. A
            Node's Region can't change. To move a relay, enroll it again.
          schema:
            type: string
        - name: client
          in: query
          description: |
            If present, it has to match the `client_url` the Node was enrolled
            with, character for character. The relay can repeat its Client URL
            but can't set or change it. The poll is a 400 if the Node was
            enrolled without a `client_url`, or if the value is empty or
            doesn't start with `http://` or `https://`.

            A 400 here repeats on every poll, so a relay with a wrong `client=`
            gets no peers and, after 90 seconds, drops out of its peers' lists
            too. Leave it out unless you want the poll to check it.
          schema:
            type: string
        - name: If-None-Match
          in: header
          schema:
            type: string
      responses:
        "200":
          description: The URLs this relay should dial.
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
                example: max-age=15
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
                  format: uri
              example:
                - https://relay2.acme.example:4443/
        "304":
          description: The list hasn't changed since the ETag sent in If-None-Match.
        "400":
          description: "`node`, `region` or `client` doesn't match what the Node was enrolled with."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: |
            No credential, or the Node key is unknown. An unknown Node key
            usually means the Node was deleted, and the relay has to be
            enrolled again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /auth:
    post:
      tags: [Admission]
      operationId: admit
      summary: Decide whether a session may start
      description: |
        The relay asks this for every session: once when a Client (or a peer
        relay) connects, again every 30 seconds while it stays connected, and
        once when it ends. It uses the `[auth] url` from enrollment.

        Switchboard reads the Client's credential from the `query` in the
        request body, usually a Client token as `jwt=`, and answers with a
        Grant: what the session may publish and subscribe to, and when it
        lapses.

        Every Grant a Self-hosted relay gets is limited to its Organization's
        Namespaces, whatever the Client's credential says.

        Switchboard answers from the location nearest the relay, and keeps
        answering while the rest of Switchboard is down. If the relay can't
        reach it at all, sessions already running continue until their
        Grant's `expires`, which is at most 5 minutes away. New sessions are
        refused.
      parameters:
        - name: jwt
          in: query
          required: true
          description: The relay's own Node key, not the Client's token.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Sent by the relay. Switchboard reads the fields below and
                ignores the rest.
              required: [event]
              properties:
                id:
                  type: string
                  description: The session's ID.
                event:
                  type: string
                  enum: [connect, revalidate, end]
                transport:
                  type: string
                  example: quic
                path:
                  type: string
                  description: The path the Client dialed.
                  example: acme/room/42
                query:
                  type: string
                  description: The Client's raw query string. Its last `jwt=` is the credential.
                  example: jwt=eyJhbGciOiJIUzI1NiIs...
                tls:
                  type: object
                  description: Present when the Client presented a certificate the relay verified.
                  properties:
                    expires:
                      type: integer
      responses:
        "200":
          description: Admitted, with this Grant.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Grant"
        "204":
          description: Answer to an `end` event.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: The relay's own credential is missing or unknown.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: |
            The session is refused. The message says why, and the relay writes
            it to its log. The Client sees a 401.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                expired:
                  value:
                    error: the token has expired
                outside:
                  value:
                    error: the grant lies outside this relay's Namespaces, acme, acme-staging

  /health:
    get:
      tags: [Health]
      operationId: health
      summary: Health check
      security: []
      responses:
        "200":
          description: Up.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: Up, but not ready to serve.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

components:
  securitySchemes:
    nodeKey:
      type: apiKey
      in: query
      name: jwt
      description: The Node key (`sbn_...`) from `POST /cluster/enroll`.

  responses:
    BadRequest:
      description: The request is malformed. The message says how.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string

    Node:
      type: object
      required: [url, canonical, source, account, region, gateway, enrolled, first_seen_at, last_seen_at, live]
      properties:
        url:
          type: string
        canonical:
          type: string
        source:
          type: string
          enum: [poll, static]
        account:
          type: string
          description: |
            The oldest Namespace of the Organization that owns the Node. It
            names the owner. The relay still carries all of the
            Organization's Namespaces.
        region:
          type: string
        gateway:
          type: boolean
        client_url:
          type: string
          description: |
            The address Clients, such as a player page, use to reach this
            relay, when it differs from `url`, the address its peers dial. On
            a private network, `url` can be a name no Client can resolve, so
            whatever hands relay addresses to your Clients should read this
            field instead. It's fixed at enrollment and omitted when the Node
            has none. `/cluster/connect` never returns it, because that list
            is only for peers to dial.
        enrolled:
          type: boolean
        first_seen_at:
          type: integer
        last_seen_at:
          type: integer
        live:
          type: boolean

    Grant:
      type: object
      description: |
        moq-relay's Grant. `publish` and `subscribe` are patterns relative to
        the dialed path: `**` is everything, `live/**` is `live` and everything
        under it.
      properties:
        publish:
          type: array
          items:
            type: string
          example: ["alice/**"]
        subscribe:
          type: array
          items:
            type: string
          example: ["**"]
        expires:
          type: integer
          description: |
            When the session ends if no revalidation succeeds before then. At
            most 5 minutes out, and never later than the Client token's own
            expiry.
        revalidate:
          type: integer
          description: Seconds until the relay asks again. Currently 30.
          example: 30
        tier:
          type: string
          description: |
            The stats tier the relay counts this session's traffic under. It
            appears only when the session is another relay (`peer` is true)
            and Switchboard is configured with a peer tier. When it's absent,
            the relay counts the session in the default tier, with Client
            traffic.
          example: mesh
        peer:
          type: boolean
          description: The session is another relay, not a Client.

    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, unavailable]
      additionalProperties: true
