{
  "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\n`POST /cluster/enroll`, once, when you set up a Self-hosted relay. The\nrest are documented so you can see what your relay is doing and make\nsense of its errors.\n\nA relay proves who it is with a credential in the URL, as `jwt=`. A\nSelf-hosted relay uses its Node key (`sbn_...`). A request without one is\na 401.\n\nThese endpoints work with stock `moqdev/moq-relay` 0.15 or later. The\nrequest and response shapes of `/cluster/connect` and `/auth` are\nmoq-relay's, not ours.\n"
  },
  "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\n`POST /v1/organizations/{id}/enrollments` and returns the Node, its\nNode key, and the values to put in the relay's `relay.toml`. Run it\nonce per relay, by hand or from your provisioning script.\n\nThe token is the only credential, and it works once. A second exchange\nis a 401. Keep the Node key. A relay that loses it has to be enrolled\nagain as a new Node.\n",
        "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\nURLs below (already filled in), and the file that\n`[cluster] token` points to.\n",
                      "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\ntoken still works once that Node is deleted.\n",
            "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\nfrom enrollment, and dials every URL in the list. Each pair of peers is\ndialed from one side only, so two relays in the same Region don't both\nappear in each other's lists.\n\nA relay that stops polling is dropped from its peers' lists after 90\nseconds.\n\nIf Switchboard can't be reached, the relay keeps the peers it has.\n",
        "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\nthe Node was enrolled with.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "region",
            "in": "query",
            "description": "If present, it has to match the Region the Node was enrolled in. A\nNode's Region can't change. To move a relay, enroll it again.\n",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "client",
            "in": "query",
            "description": "If present, it has to match the `client_url` the Node was enrolled\nwith, character for character. The relay can repeat its Client URL\nbut can't set or change it. The poll is a 400 if the Node was\nenrolled without a `client_url`, or if the value is empty or\ndoesn't start with `http://` or `https://`.\n\nA 400 here repeats on every poll, so a relay with a wrong `client=`\ngets no peers and, after 90 seconds, drops out of its peers' lists\ntoo. Leave it out unless you want the poll to check it.\n",
            "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\nusually means the Node was deleted, and the relay has to be\nenrolled again.\n",
            "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\nrelay) connects, again every 30 seconds while it stays connected, and\nonce when it ends. It uses the `[auth] url` from enrollment.\n\nSwitchboard reads the Client's credential from the `query` in the\nrequest body, usually a Client token as `jwt=`, and answers with a\nGrant: what the session may publish and subscribe to, and when it\nlapses.\n\nEvery Grant a Self-hosted relay gets is limited to its Organization's\nNamespaces, whatever the Client's credential says.\n\nSwitchboard answers from the location nearest the relay, and keeps\nanswering while the rest of Switchboard is down. If the relay can't\nreach it at all, sessions already running continue until their\nGrant's `expires`, which is at most 5 minutes away. New sessions are\nrefused.\n",
        "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\nignores the rest.\n",
                "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\nit to its log. The Client sees a 401.\n",
            "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\nnames the owner. The relay still carries all of the\nOrganization's Namespaces.\n"
          },
          "region": {
            "type": "string"
          },
          "gateway": {
            "type": "boolean"
          },
          "client_url": {
            "type": "string",
            "description": "The address Clients, such as a player page, use to reach this\nrelay, when it differs from `url`, the address its peers dial. On\na private network, `url` can be a name no Client can resolve, so\nwhatever hands relay addresses to your Clients should read this\nfield instead. It's fixed at enrollment and omitted when the Node\nhas none. `/cluster/connect` never returns it, because that list\nis only for peers to dial.\n"
          },
          "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\nthe dialed path: `**` is everything, `live/**` is `live` and everything\nunder it.\n",
        "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\nmost 5 minutes out, and never later than the Client token's own\nexpiry.\n"
          },
          "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\nappears only when the session is another relay (`peer` is true)\nand Switchboard is configured with a peer tier. When it's absent,\nthe relay counts the session in the default tier, with Client\ntraffic.\n",
            "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
      }
    }
  }
}
