openapi: 3.1.0
info:
  title: Switchboard Account API
  version: "1"
  description: |
    Manage your Organizations on Switchboard: their Namespaces, the Signing
    keys and Client tokens for each Namespace, their API keys, and the
    Self-hosted relays they enroll.

    An Organization is a customer, and a Namespace is a path root it owns,
    like an S3 bucket. Routes name what they act on. Everything that serves
    the whole Organization (API keys, Nodes, enrollments) is under
    `/v1/organizations/{id}`. Everything inside one Namespace (Signing keys,
    Client tokens, its settings) is under `/v1/namespaces/{namespace}`. That
    path needs no Organization, because Namespace names are unique.

    Every request except signup, login, logout and the health check needs a
    credential. Send an API key as `Authorization: Bearer sb_...`. An API key
    belongs to one Organization and reaches only that Organization and its
    Namespaces. You can limit a key to some of those Namespaces, so a staging
    CI key can't read prod's Signing keys. A limited key gets a 403 outside
    its Namespaces and on every `/v1/organizations/{id}` route. The dashboard
    uses a session cookie instead, which reaches every Organization the
    signed-in person is a Member of. Anything you can't reach is a 404,
    whether or not it exists.

    On a server that logs people in through WorkOS, such as
    app.caprid.com, there are no passwords. You sign in at `/app/` in
    a browser, and create API keys there for your scripts. On that server,
    `POST /v1/login` returns 410, and `POST /v1/signup` works only as the
    last step of signing in through the browser.

    An AI agent can use the API without you creating an API key for it, on
    a server set up for Agents. The Agent registers with WorkOS AuthKit, by
    following the steps at `/auth.md`, and sends the access token AuthKit
    gives it, its Agent token, as `Authorization: Bearer ...`. Once you claim the Agent at AuthKit, it acts
    as your Account, so it can do anything your dashboard session can,
    including creating API keys for itself. If you haven't signed up yet, the
    Agent gets a 403 on every route until you do.

    An Agent nobody has claimed can start right away. Its first request gives
    it a Trial Organization with one Namespace, named `trial-` and a random
    tag, so it can create Signing keys, mint Client tokens for Platform
    relays, and create API keys for itself, with no person involved. A trial
    has no Members. It can't enroll Self-hosted relays, create or delete a
    Namespace, create an Organization, or manage Members and Invitations, and
    those routes return 403 to the Agent and to the trial's API keys alike.
    Starting trials is rate-limited per address, like signup, so an Agent's
    first request can return 429.

    The trial lapses 7 days after it starts, at its `trial_expires_at`. From
    then on its API keys get a 401, and the Agent gets a 403 that says the
    trial is over. Within about 10 minutes Switchboard deletes the trial's
    Namespace and its Signing keys, so relays stop admitting its Client
    tokens, and nobody can take the name again.

    Claim the Agent before then to keep what it made. Its first request after
    you claim it makes you the trial's Owner, and the Organization stops
    being a trial. It no longer lapses, its limits lift, and its Namespace,
    Signing keys and API keys keep working. It doesn't count against the
    Organizations you may create. If you claim the Agent before you sign up,
    the trial waits for you until it lapses. A trial that lapsed before the
    claim stays lapsed.

    Paths you send (`path`, `publish`, `subscribe`) are relative to the
    Namespace, so `path: "room/42"` sent to `/v1/namespaces/acme/tokens` is
    `acme/room/42`.

    The routes from before Organizations (`/v1/keys`, `/v1/tokens`,
    `/v1/enrollments`, `/v1/nodes`, `/v1/account/api-keys`, and `PATCH
    /v1/account`) still work with an API key, acting in its Organization.
    `/v1/keys`, `/v1/tokens` and `PATCH /v1/account` work only while the key
    reaches exactly one Namespace: its Organization's only one, or the only
    one in its limit. The others act on the whole Organization, so a limited
    key gets a 403. With a session, they return 400 and name the route to use.

    Request bodies are JSON. An empty body is the same as `{}`, and unknown
    fields are ignored. Times are Unix seconds. Errors are always
    `{"error": "..."}`, with a message meant for a person to read.
  contact:
    name: Mux
    url: https://www.mux.com
servers:
  - url: https://app.caprid.com
security:
  - apiKey: []
  - session: []
  - agentToken: []
tags:
  - name: Account
    description: Signing up, logging in, and who you are.
  - name: Organizations
    description: The customers you manage, and their names.
  - name: Members
    description: |
      Who belongs to an Organization. Owners manage Members, signed in with a
      session. Any Member can leave.
  - name: Invitations
    description: |
      How people join an Organization. An Owner invites an email address and
      sends the link themselves. Only the person with that email can accept.
  - name: Namespaces
    description: The path roots an Organization owns, and their settings.
  - name: API keys
    description: |
      `sb_...` keys that authenticate calls to this API. A relay never
      accepts one. A key can be limited to some of its Organization's
      Namespaces when it is created.
  - name: Signing keys
    description: |
      Organization signing keys, which sign Client tokens. You can hold the secret
      and sign Client tokens in your own backend without calling Switchboard.
  - name: Client tokens
    description: |
      Tokens a Client presents to a relay to publish or subscribe. Minting one
      here is a convenience. Anyone holding the Signing key can mint the same
      token offline.
  - name: Self-hosted relays
    description: |
      Enrolling relays you run yourself into your Organization, and removing
      them. A Self-hosted relay carries all of its Organization's Namespaces.
  - name: Before Organizations
    description: |
      The routes from before Organizations, kept for API keys. Each one does
      what a newer route does, in the API key's own Organization.
  - name: Agents
    description: |
      How an AI agent that finds this server learns where to register, on a
      server set up for Agents. Neither route needs a credential.
  - name: Health
    description: Liveness, for load balancers and monitoring.
paths:
  /v1/signup:
    get:
      tags: [Account]
      operationId: getPendingSignup
      summary: Show a signup in progress
      description: |
        Only with WorkOS login. After a first sign-in, the person has proved
        who they are but has no Account yet. This route returns the email
        WorkOS verified, so the dashboard can ask for a Namespace. It also
        lists the pending Invitations to that email, so the dashboard can
        offer to join one of those Organizations instead. The login callback
        sets a cookie that holds the signup in progress for 15 minutes.
      security: []
      responses:
        "200":
          description: The signup in progress.
          content:
            application/json:
              schema:
                type: object
                required: [email, invitations]
                properties:
                  email:
                    type: string
                    format: email
                  invitations:
                    type: array
                    items:
                      $ref: "#/components/schemas/Invitation"
              example:
                email: alice@acme.example
                invitations:
                  - id: 3f9c1a2b7d4e
                    organization_id: 4
                    organization_name: Acme
                    email: alice@acme.example
                    role: member
                    created_at: 1790294400
                    expires_at: 1790899200
        "404":
          description: No signup is in progress, or this server doesn't use WorkOS login.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      tags: [Account]
      operationId: signup
      summary: Create an Account
      description: |
        Creates an Account. Every Account starts by doing one of two things:

        - **Creating an Organization.** Send `namespace`. The Organization is
          named after the Namespace (you can rename it later), you are its
          Owner, and the response carries its first API key. This is the
          only time that key's secret is shown, so store it now.
        - **Joining one.** Send `invitation` instead, and you become a Member
          of the Organization that invited you, with the role it invited you
          as. You get no API key, because the Organization already has its
          own. Without WorkOS login, send the secret from the Invitation's
          link, because nothing has verified your email. With WorkOS login,
          send the Invitation's `id` from `GET /v1/signup`.

        The response also sets a session cookie, for a signup that came from
        the dashboard.

        Password signups are limited to 20 an hour from each client address.

        With WorkOS login, the body needs only `namespace` or `invitation`.
        The email is the one WorkOS verified, there is no password, and the
        request has to carry the cookie the login callback set.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: |
                Send `namespace` or `invitation`. `email` and `password` are
                required too, unless the server uses WorkOS login.
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
                  description: Stored lowercased. `Alice@example.com` and `alice@example.com` are the same Account.
                password:
                  type: string
                  minLength: 12
                  maxLength: 1024
                  description: Length is the only rule. Counted in characters, not bytes.
                namespace:
                  $ref: "#/components/schemas/NamespaceName"
                invitation:
                  type: string
                  description: |
                    Join the Organization this Invitation is for, instead of
                    creating one. Send the secret from its link, or with
                    WorkOS login, its `id`. Your email has to be the one
                    invited.
            examples:
              create:
                summary: Create an Organization
                value:
                  email: dev@acme.example
                  password: correct horse battery staple
                  namespace: acme
              join:
                summary: Join with an Invitation's link
                value:
                  email: alice@acme.example
                  password: correct horse battery staple
                  invitation: sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1qW
      responses:
        "201":
          description: The Account, and when it created an Organization, that Organization's first API key with its secret.
          headers:
            Set-Cookie:
              $ref: "#/components/headers/SessionCookie"
          content:
            application/json:
              schema:
                type: object
                required: [account]
                properties:
                  account:
                    $ref: "#/components/schemas/Account"
                  api_key:
                    $ref: "#/components/schemas/NewAPIKey"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: With WorkOS login, there is no signup in progress. Sign in at `/app/` first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The Invitation is for another email address.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No such Invitation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The email or the Namespace is already taken.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          $ref: "#/components/responses/InvitationSpent"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/login:
    get:
      tags: [Account]
      operationId: startLogin
      summary: Sign in with WorkOS
      description: |
        Only with WorkOS login. Open this in a browser. It redirects to the
        WorkOS sign-in page, which sends the browser back to
        `/v1/login/callback`. The dashboard's sign-in button links here.
      security: []
      responses:
        "302":
          description: Redirect to WorkOS.
        "404":
          $ref: "#/components/responses/NoWorkOS"
    post:
      tags: [Account]
      operationId: login
      summary: Log in
      description: |
        Exchanges an email and password for a session cookie. Sessions last 14
        days. Scripts should use an API key instead.

        Failed logins are limited to 10 per 15 minutes, counted separately per
        email and per client address.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
      responses:
        "200":
          description: Logged in.
          headers:
            Set-Cookie:
              $ref: "#/components/headers/SessionCookie"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountEnvelope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: The email and password don't match an Account. The response is the same whether or not the email exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: This server logs people in through WorkOS, and passwords no longer work.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /v1/login/callback:
    get:
      tags: [Account]
      operationId: finishLogin
      summary: Finish signing in with WorkOS
      description: |
        WorkOS sends the browser back here. You never call it yourself.

        A returning person gets a session cookie. A first sign-in whose
        verified email matches an existing Account is linked to it, once and
        for good. Anyone else gets a signup in progress, and the dashboard asks
        them for a Namespace.

        Either way the browser lands on `/app/`. When signing in didn't work,
        `?login=` says why: `failed`, `unverified` (WorkOS hasn't verified the
        email), or `conflict` (the email's Account is linked to a different
        WorkOS user).
      security: []
      parameters:
        - name: code
          in: query
          schema:
            type: string
        - name: state
          in: query
          schema:
            type: string
      responses:
        "302":
          description: Redirect to the dashboard.
        "404":
          $ref: "#/components/responses/NoWorkOS"

  /v1/logout:
    post:
      tags: [Account]
      operationId: logout
      summary: Log out
      description: |
        Ends the calling session and clears its cookie. Works with an expired
        cookie, or none.

        A session that came from WorkOS also has a WorkOS session to end, which
        only the browser can do. The response says where to send it.
      security: []
      responses:
        "200":
          description: Logged out here. Send the browser to `logout_url` to end the WorkOS session too.
          content:
            application/json:
              schema:
                type: object
                required: [logout_url]
                properties:
                  logout_url:
                    type: string
                    format: uri
        "204":
          description: Logged out.

  /v1/account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Get the caller
      description: |
        Who you are and which Organizations you can manage. With a session,
        that's every Organization you're a Member of. An Agent you claimed
        gets the same answer as your session. With an API key, it's
        the key's own Organization, and there's no email, because an API key
        belongs to the Organization rather than to a person. Instead,
        `api_key` describes the key itself, with the Namespaces it is limited
        to.
      responses:
        "200":
          description: The caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountEnvelope"
              examples:
                session:
                  summary: A session that belongs to two Organizations
                  value:
                    account:
                      email: alice@acme.example
                      created_at: 1760000000
                      organizations:
                        - {id: 4, name: Acme, role: owner, created_at: 1760000000}
                        - {id: 9, name: Globex, role: member, created_at: 1760500000}
                api-key:
                  summary: An API key whose Organization has one Namespace
                  value:
                    account:
                      api_key: {id: 3f9a1c07be42, label: initial, preview: R2x0Z9, created_at: 1760000000, last_used_at: 1760600000}
                      organizations:
                        - {id: 4, name: Acme, created_at: 1760000000}
                      namespace: acme
                      platform_peering: false
                limited-api-key:
                  summary: An API key limited to two Namespaces
                  value:
                    account:
                      api_key:
                        id: 8c21d4e09a7f
                        label: deploys
                        preview: Qm9vTx
                        created_at: 1760700000
                        last_used_at: 1760800000
                        namespaces: [acme-prod, acme-staging]
                      organizations:
                        - {id: 4, name: Acme, created_at: 1760000000}
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/AgentRefused"
    patch:
      tags: [Before Organizations]
      operationId: updateAccountLegacy
      summary: Change the Namespace's settings
      deprecated: true
      description: "`PATCH /v1/namespaces/{namespace}` for the API key's only Namespace, answered with the caller."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NamespaceSettings"
      responses:
        "200":
          description: The caller, with the change applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountEnvelope"
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"

  /v1/organizations:
    get:
      tags: [Organizations]
      operationId: listOrganizations
      summary: List your Organizations
      description: |
        Every Organization your session is a Member of, with your role in
        each, oldest membership first. An API key lists only its own
        Organization, with no role.
      responses:
        "200":
          description: The Organizations.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Organization"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/AgentRefused"
    post:
      tags: [Organizations]
      operationId: createOrganization
      summary: Create an Organization
      description: |
        Creates another Organization, the way signup created your first. Use
        one to keep a separate customer, or a project you'll hand over, apart
        from the rest. It has its own Namespaces, API keys, Members and
        relays.

        You become its Owner. It is named after its Namespace unless you give
        it a `name`, which you can change later. The response holds its first
        API key, and this is the only time that key's secret is shown, so
        store it now.

        This takes a signed-in session. An API key acts for its own
        Organization, so it can't create another.

        An Account may create 5 Organizations. Organizations you joined by
        invitation don't count. If you need more, ask Mux to raise your cap.
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [namespace]
              properties:
                namespace:
                  $ref: "#/components/schemas/NamespaceName"
                name:
                  type: string
                  maxLength: 100
                  description: For display. Defaults to the Namespace.
            example:
              namespace: acme-labs
              name: Acme Labs
      responses:
        "201":
          description: The new Organization, its Namespace, and its first API key.
          content:
            application/json:
              schema:
                type: object
                required: [organization, namespace, api_key]
                properties:
                  organization:
                    $ref: "#/components/schemas/Organization"
                  namespace:
                    $ref: "#/components/schemas/Namespace"
                  api_key:
                    $ref: "#/components/schemas/NewAPIKey"
              example:
                organization:
                  id: 12
                  name: Acme Labs
                  role: owner
                  created_at: 1792900000
                namespace:
                  name: acme-labs
                  organization_id: 12
                  platform_peering: false
                  created_at: 1792900000
                api_key:
                  id: 3f9a1c07be42
                  label: initial
                  preview: R2x0Z9
                  created_at: 1792900000
                  secret: sb_R2x0Z9kQ7mVt3xLp8wNc1bYh5sDf2gJr6uEa0zWiTq4
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            Sent with an API key, or you have already created as many
            Organizations as you may. The message says which.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: an Account may create 5 Organizations, and you have already; ask whoever runs this Switchboard to raise your cap
        "409":
          description: The Namespace is taken. Namespace names are never reused, even after one is deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/organizations/{id}:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [Organizations]
      operationId: getOrganization
      summary: Get an Organization
      responses:
        "200":
          description: The Organization.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Organizations]
      operationId: updateOrganization
      summary: Rename an Organization
      description: |
        The name is only for display, so any Member can change it, and so
        can the Organization's API keys. Fields you leave out are left alone.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: Leading and trailing spaces are trimmed.
            example:
              name: Acme, Inc.
      responses:
        "200":
          description: The Organization, renamed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/organizations/{id}/namespaces:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [Namespaces]
      operationId: listNamespaces
      summary: List an Organization's Namespaces
      description: Oldest first.
      responses:
        "200":
          description: The Namespaces.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Namespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Namespaces]
      operationId: createNamespace
      summary: Create a Namespace
      description: |
        Gives the Organization another Namespace, such as `acme-staging`
        beside `acme`, so that staging's Signing keys and Client tokens can't
        reach prod. Any Member can create one, and so can an API key that
        isn't limited to some Namespaces.

        Names follow the same rules as at signup, and are unique across
        Switchboard for good. A name that any Namespace has ever had,
        including a deleted one, is never issued again.

        An Organization can have 10 live Namespaces. Deleted ones don't
        count. If you need more, ask Mux to raise the cap.

        A new Namespace starts with `platform_peering` off.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [namespace]
              properties:
                namespace:
                  $ref: "#/components/schemas/NamespaceName"
            example:
              namespace: acme-staging
      responses:
        "201":
          description: The new Namespace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Namespace"
              example:
                name: acme-staging
                organization_id: 7
                platform_peering: false
                created_at: 1790294400
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The Organization already has as many Namespaces as it may, or you
            sent an API key limited to some Namespaces. The message says which.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "an Organization may have 10 Namespaces, and this one has; delete one, or ask whoever runs this Switchboard to raise the cap"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The name is taken, or was once.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "the name acme-staging is taken, or was once: a Namespace's name is never reissued"

  /v1/organizations/{id}/members:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [Members]
      operationId: listMembers
      summary: List an Organization's Members
      description: |
        Everyone who belongs to the Organization, with their role and when
        they joined, longest-standing first. Any Member can list them, and so
        can the Organization's API keys.
      responses:
        "200":
          description: The Members.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Member"
              example:
                - account_id: 7
                  email: bob@acme.example
                  role: owner
                  joined_at: 1790294400
                - account_id: 12
                  email: alice@acme.example
                  role: member
                  joined_at: 1790380800
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/organizations/{id}/members/{account}:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
      - name: account
        in: path
        required: true
        description: The Member's `account_id`, from the list.
        schema:
          type: integer
        example: 12
    patch:
      tags: [Members]
      operationId: updateMember
      summary: Change a Member's role
      description: |
        Owners only, signed in with a session. An API key can never manage
        Members, so a leaked one can't be used to let its holder in.

        An Organization always has an Owner. To step down as the last one,
        make someone else an Owner first.
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [owner, member]
            example:
              role: owner
      responses:
        "200":
          description: The Member, with the new role.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Member"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: You aren't an Owner, or you sent an API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "only an Owner can manage this Organization's Members"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: This would leave the Organization without an Owner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "an Organization must always have an Owner; make someone else an Owner first"
    delete:
      tags: [Members]
      operationId: deleteMember
      summary: Remove a Member, or leave
      description: |
        Owners can remove anyone, and any Member can remove themselves, which
        is how you leave an Organization. Either way, it takes a session. An
        API key can never manage Members.

        The last Owner can't leave or be removed. Make someone else an Owner
        first.

        API keys the Member created keep working. They belong to the
        Organization, so CI doesn't break when someone leaves. Revoke them
        separately if you need to.
      security:
        - session: []
      responses:
        "204":
          description: Removed. The Account no longer sees the Organization.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: You aren't an Owner and this isn't you, or you sent an API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "only a session can manage Members, never an API key"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: You are removing the last Owner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "an Organization must always have an Owner; make someone else an Owner first"

  /v1/organizations/{id}/invitations:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [Invitations]
      operationId: listInvitations
      summary: List an Organization's pending Invitations
      description: |
        Invitations that are still pending, oldest first. A pending
        Invitation hasn't been accepted, revoked or expired. Their links aren't listed, because a link's secret is shown
        only once, when you create the Invitation.

        Owners only, signed in with a session.
      security:
        - session: []
      responses:
        "200":
          description: The pending Invitations.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Invitation"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/NotAnOwner"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Invitations]
      operationId: createInvitation
      summary: Invite someone
      description: |
        Invites one email address into the Organization, and returns a link
        for that person. Switchboard sends no email, so copy the link and send
        it yourself. Only the person with that email can use the link, so it's
        safe if someone forwards it by mistake.

        The link works once, and expires after 7 days. Its secret is shown
        only in this response.

        Owners only, signed in with a session. An API key can never invite
        anyone, so a leaked one can't be used to let its holder in.
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  description: Matched without regard to case.
                role:
                  type: string
                  enum: [owner, member]
                  default: member
                  description: The role they'll have once they accept.
            example:
              email: alice@acme.example
              role: member
      responses:
        "201":
          description: The Invitation, with its link.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewInvitation"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/NotAnOwner"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: That email already belongs to a Member.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "that email already belongs to a Member of this organization"

  /v1/organizations/{id}/invitations/{invitation}:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
      - name: invitation
        in: path
        required: true
        description: The Invitation's `id`, from the list.
        schema:
          type: string
        example: 3f9c1a2b7d4e
    delete:
      tags: [Invitations]
      operationId: revokeInvitation
      summary: Revoke an Invitation
      description: |
        Its link stops working. Owners only, signed in with a session.
      security:
        - session: []
      responses:
        "204":
          description: Revoked.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/NotAnOwner"
        "404":
          description: No such pending Invitation. One already accepted, revoked or expired is a 404 too.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/invitations:
    get:
      tags: [Invitations]
      operationId: listMyInvitations
      summary: List the Invitations to your email
      description: |
        Only with WorkOS login, which has verified your email. Lists the
        pending Invitations to it, from every Organization, so you can
        accept one without its link. The dashboard shows them when you sign
        up.
      security:
        - session: []
      responses:
        "200":
          description: The pending Invitations to your email.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Invitation"
              example:
                - id: 3f9c1a2b7d4e
                  organization_id: 4
                  organization_name: Acme
                  email: alice@acme.example
                  role: member
                  created_at: 1790294400
                  expires_at: 1790899200
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: Sent with an API key, or with a session whose email WorkOS hasn't verified.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NoWorkOS"

  /v1/invitations/accept:
    post:
      tags: [Invitations]
      operationId: acceptInvitation
      summary: Accept an Invitation
      description: |
        Makes you a Member of the Organization, with the role the Invitation
        names. Your email has to be the one invited, even if you have the
        link. Each Invitation can be accepted once.

        Send `secret`, the last part of the link (`/app/invitations/{secret}`).
        With WorkOS login, which has verified your email, you can send the
        Invitation's `id` instead. Without WorkOS, nothing has verified your
        email, so only the link works.

        This takes a session. An API key belongs to an Organization, not a
        person.
      security:
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                secret:
                  type: string
                  description: The secret from the Invitation's link.
                id:
                  type: string
                  description: The Invitation's id. Only with WorkOS login.
            example:
              secret: sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1qW
      responses:
        "200":
          description: The Organization you joined.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Organization"
              example:
                id: 4
                name: Acme
                role: member
                created_at: 1790294400
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: |
            The Invitation is for another email address, you sent an `id`
            on a server that hasn't verified your email, or you sent an API
            key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "this invitation is for another email address: sign in as the address it was sent to"
        "404":
          description: No such Invitation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: You're already a Member of that Organization.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          $ref: "#/components/responses/InvitationSpent"

  /v1/namespaces/{namespace}:
    parameters:
      - $ref: "#/components/parameters/Namespace"
    get:
      tags: [Namespaces]
      operationId: getNamespace
      summary: Get a Namespace
      responses:
        "200":
          description: The Namespace.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Namespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Namespaces]
      operationId: updateNamespace
      summary: Change a Namespace's settings
      description: Fields you leave out are left alone.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/NamespaceSettings"
            example:
              platform_peering: true
      responses:
        "200":
          description: The Namespace, with the change applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Namespace"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Namespaces]
      operationId: deleteNamespace
      summary: Delete a Namespace
      description: |
        Deletes the Namespace and its Signing keys. Client tokens they signed
        stop working, and relays stop carrying the Namespace's tracks when
        they next check a session, within about a minute.

        This can't be undone, and the name can never be used again, by you
        or anyone else.

        Owners only, signed in with a session. An API key can never delete a
        Namespace, so a leaked one can't destroy something you can't get
        back.

        Your Nodes, Regions and enrollments belong to the Organization and
        stay as they are. You can delete the Organization's last Namespace.
        The Organization stays, with no Namespaces, until you create another.
      security:
        - session: []
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          description: You aren't an Owner, or you sent an API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: "only an Owner can delete a Namespace"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/organizations/{id}/api-keys:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [API keys]
      operationId: listAPIKeys
      summary: List API keys
      description: Oldest first. Secrets are never listed, only a preview of each.
      responses:
        "200":
          description: The Organization's API keys, expired ones included.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/APIKey"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [API keys]
      operationId: createAPIKey
      summary: Create an API key
      description: The response is the only time the secret is shown. Switchboard keeps only a hash of it.
      requestBody:
        $ref: "#/components/requestBodies/CreateAPIKey"
      responses:
        "201":
          description: The new API key, with its secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewAPIKey"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/organizations/{id}/api-keys/{key}:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    delete:
      tags: [API keys]
      operationId: deleteAPIKey
      summary: Revoke an API key
      description: The key stops working on the next request that presents it.
      parameters:
        - name: key
          in: path
          required: true
          description: The key's `id` from the list. Not its secret.
          schema:
            type: string
      responses:
        "204":
          description: Revoked.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/namespaces/{namespace}/keys:
    parameters:
      - $ref: "#/components/parameters/Namespace"
    get:
      tags: [Signing keys]
      operationId: listSigningKeys
      summary: List Signing keys
      description: Oldest first, without their secrets. Get a key by its `kid` to see its secret.
      responses:
        "200":
          description: The Namespace's Signing keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SigningKeySummary"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Signing keys]
      operationId: createSigningKey
      summary: Create a Signing key
      description: |
        Creates an Organization signing key in this Namespace and returns it
        as a JWK, secret included. Keep the secret in your backend to sign
        Client tokens there without calling Switchboard.

        The key's scope is fixed when you create it and can't be widened
        later. By default, a key covers your whole Namespace for both
        publishing and subscribing. Use `path` to root it lower down, and
        `publish` or `subscribe` to limit what the tokens it signs may grant.
        If you send only one of `publish` and `subscribe`, the key can't sign
        tokens for the other role.

        Relays look keys up by `kid` through Switchboard, never from the Client,
        so a relay accepts a token signed with this key only within its scope.
      requestBody:
        $ref: "#/components/requestBodies/CreateSigningKey"
      responses:
        "201":
          description: The new key, secret included.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JWK"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: The Namespace already has a key with this label.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/namespaces/{namespace}/keys/{kid}:
    parameters:
      - $ref: "#/components/parameters/Namespace"
      - name: kid
        in: path
        required: true
        description: The key's `kid`, which is `<namespace>_<label>`.
        schema:
          type: string
          example: acme_backend
    get:
      tags: [Signing keys]
      operationId: getSigningKey
      summary: Get a Signing key
      description: Returns the full JWK, secret included. A `kid` that isn't one of this Namespace's is a 404.
      responses:
        "200":
          description: The key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JWK"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Signing keys]
      operationId: deleteSigningKey
      summary: Delete a Signing key
      description: |
        Every Client token the key signed stops working. Sessions already
        running on those tokens end within about a minute, the next time
        their relay checks them.
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/namespaces/{namespace}/tokens:
    parameters:
      - $ref: "#/components/parameters/Namespace"
    post:
      tags: [Client tokens]
      operationId: mintClientToken
      summary: Mint a Client token
      description: |
        Signs a Client token with one of the Namespace's Signing keys. Use it
        when you'd rather not hold a Signing key's secret in your own backend.

        A token gets no grants by default. Name what it may publish, subscribe
        to, or both. A request that names neither is a 400. Whatever you ask
        for has to fit inside the Signing key's scope.
      requestBody:
        $ref: "#/components/requestBodies/MintClientToken"
      responses:
        "200":
          description: The signed token and what it grants.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MintedToken"
        "400":
          description: |
            The request can't be signed: it grants nothing, it asks for more
            than the key's scope, it reaches outside the Namespace, or it names
            no key while the Namespace has zero or several.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The Namespace isn't one you can reach, or the `kid` isn't one of its keys.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/namespaces/{namespace}/tokens/verify:
    parameters:
      - $ref: "#/components/parameters/Namespace"
    post:
      tags: [Client tokens]
      operationId: verifyClientToken
      summary: Check a Client token
      description: |
        Verifies a token against this Namespace's Signing keys and returns its
        claims. Use it to debug a token a relay refuses. A token signed by any
        other Namespace's key gets a 401.
      requestBody:
        $ref: "#/components/requestBodies/VerifyClientToken"
      responses:
        "200":
          description: The token is valid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerifiedToken"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          description: |
            The token is malformed, expired, has a bad signature, or wasn't
            signed by one of this Namespace's keys. Also returned when the caller
            isn't authenticated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/organizations/{id}/enrollments:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [Self-hosted relays]
      operationId: listEnrollments
      summary: List pending enrollments
      description: Relay enrollment tokens that haven't been used or expired yet. Oldest first.
      responses:
        "200":
          description: The pending enrollments.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Enrollment"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [Self-hosted relays]
      operationId: createEnrollment
      summary: Create a Relay enrollment token
      description: |
        Issues a one-time token that a relay you run exchanges for its Node key,
        with `POST /cluster/enroll`. You decide everything about the Node
        here: its URL, its Region, whether it can be a Gateway, and its Client
        URL. The relay can't change any of it later.

        The token is shown once. It expires after `expires_in` seconds, or
        when it's used, whichever comes first.
      requestBody:
        $ref: "#/components/requestBodies/CreateEnrollment"
      responses:
        "201":
          description: The pending enrollment, with its token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewEnrollment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: A Node already exists at this URL. Delete it first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/organizations/{id}/enrollments/{enrollment}:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    delete:
      tags: [Self-hosted relays]
      operationId: deleteEnrollment
      summary: Revoke a pending enrollment
      description: The token stops working. A Node that already enrolled with it stays enrolled.
      parameters:
        - name: enrollment
          in: path
          required: true
          description: The enrollment's `id`, from the list. Not the token.
          schema:
            type: string
      responses:
        "204":
          description: Revoked.
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/organizations/{id}/nodes:
    parameters:
      - $ref: "#/components/parameters/OrganizationID"
    get:
      tags: [Self-hosted relays]
      operationId: listNodes
      summary: List your Nodes
      description: Every Node the Organization has enrolled, running or not.
      responses:
        "200":
          description: The Nodes.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Node"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Self-hosted relays]
      operationId: deleteNode
      summary: Delete a Node
      description: |
        Deletes the Node and its Node key. Its relay is refused from its next
        poll, and its peers drop it. To bring the relay back, enroll it again
        with a new Relay enrollment token.
      parameters:
        - name: url
          in: query
          required: true
          description: The Node's URL, as listed.
          schema:
            type: string
            format: uri
      responses:
        "204":
          description: Deleted.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # The routes from before Organizations. Each is the route it names, for an
  # API key in its own Organization.
  /v1/account/api-keys:
    get:
      tags: [Before Organizations]
      operationId: listAPIKeysLegacy
      summary: List API keys
      deprecated: true
      description: "`GET /v1/organizations/{id}/api-keys` for the API key's Organization."
      responses:
        "200":
          description: The Organization's API keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/APIKey"
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Before Organizations]
      operationId: createAPIKeyLegacy
      summary: Create an API key
      deprecated: true
      description: "`POST /v1/organizations/{id}/api-keys` for the API key's Organization."
      requestBody:
        $ref: "#/components/requestBodies/CreateAPIKey"
      responses:
        "201":
          description: The new API key, with its secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewAPIKey"
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"

  /v1/account/api-keys/{key}:
    delete:
      tags: [Before Organizations]
      operationId: deleteAPIKeyLegacy
      summary: Revoke an API key
      deprecated: true
      description: "`DELETE /v1/organizations/{id}/api-keys/{key}` for the API key's Organization."
      parameters:
        - name: key
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Revoked.
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/keys:
    get:
      tags: [Before Organizations]
      operationId: listSigningKeysLegacy
      summary: List Signing keys
      deprecated: true
      description: "`GET /v1/namespaces/{namespace}/keys` for the API key's only Namespace."
      responses:
        "200":
          description: The Namespace's Signing keys.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/SigningKeySummary"
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Before Organizations]
      operationId: createSigningKeyLegacy
      summary: Create a Signing key
      deprecated: true
      description: "`POST /v1/namespaces/{namespace}/keys` for the API key's only Namespace."
      requestBody:
        $ref: "#/components/requestBodies/CreateSigningKey"
      responses:
        "201":
          description: The new key, secret included.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JWK"
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: The Namespace already has a key with this label.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/keys/{kid}:
    parameters:
      - name: kid
        in: path
        required: true
        schema:
          type: string
    get:
      tags: [Before Organizations]
      operationId: getSigningKeyLegacy
      summary: Get a Signing key
      deprecated: true
      description: "`GET /v1/namespaces/{namespace}/keys/{kid}` for the API key's only Namespace."
      responses:
        "200":
          description: The key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JWK"
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      tags: [Before Organizations]
      operationId: deleteSigningKeyLegacy
      summary: Delete a Signing key
      deprecated: true
      description: "`DELETE /v1/namespaces/{namespace}/keys/{kid}` for the API key's only Namespace."
      responses:
        "204":
          description: Deleted.
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/tokens:
    post:
      tags: [Before Organizations]
      operationId: mintClientTokenLegacy
      summary: Mint a Client token
      deprecated: true
      description: "`POST /v1/namespaces/{namespace}/tokens` for the API key's only Namespace."
      requestBody:
        $ref: "#/components/requestBodies/MintClientToken"
      responses:
        "200":
          description: The signed token and what it grants.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MintedToken"
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/tokens/verify:
    post:
      tags: [Before Organizations]
      operationId: verifyClientTokenLegacy
      summary: Check a Client token
      deprecated: true
      description: "`POST /v1/namespaces/{namespace}/tokens/verify` for the API key's only Namespace."
      requestBody:
        $ref: "#/components/requestBodies/VerifyClientToken"
      responses:
        "200":
          description: The token is valid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VerifiedToken"
        "400":
          $ref: "#/components/responses/LegacyNamespace"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"

  /v1/enrollments:
    get:
      tags: [Before Organizations]
      operationId: listEnrollmentsLegacy
      summary: List pending enrollments
      deprecated: true
      description: "`GET /v1/organizations/{id}/enrollments` for the API key's Organization."
      responses:
        "200":
          description: The pending enrollments.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Enrollment"
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Before Organizations]
      operationId: createEnrollmentLegacy
      summary: Create a Relay enrollment token
      deprecated: true
      description: "`POST /v1/organizations/{id}/enrollments` for the API key's Organization."
      requestBody:
        $ref: "#/components/requestBodies/CreateEnrollment"
      responses:
        "201":
          description: The pending enrollment, with its token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NewEnrollment"
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: A Node already exists at this URL. Delete it first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/enrollments/{enrollment}:
    delete:
      tags: [Before Organizations]
      operationId: deleteEnrollmentLegacy
      summary: Revoke a pending enrollment
      deprecated: true
      description: "`DELETE /v1/organizations/{id}/enrollments/{enrollment}` for the API key's Organization."
      parameters:
        - name: enrollment
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Revoked.
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/nodes:
    get:
      tags: [Before Organizations]
      operationId: listNodesLegacy
      summary: List your Nodes
      deprecated: true
      description: "`GET /v1/organizations/{id}/nodes` for the API key's Organization."
      responses:
        "200":
          description: The Nodes.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Node"
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      tags: [Before Organizations]
      operationId: deleteNodeLegacy
      summary: Delete a Node
      deprecated: true
      description: "`DELETE /v1/organizations/{id}/nodes` for the API key's Organization."
      parameters:
        - name: url
          in: query
          required: true
          schema:
            type: string
            format: uri
      responses:
        "204":
          description: Deleted.
        "400":
          $ref: "#/components/responses/Legacy"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /auth.md:
    get:
      tags: [Agents]
      operationId: getAgentInstructions
      summary: Instructions for an Agent
      description: |
        The steps an AI agent follows to register with this server's WorkOS
        AuthKit domain, get an Agent token, and use it here. Point an agent at
        this page and it can start a trial with no help from you.

        The first part is AuthKit's own instructions, copied from AuthKit at
        most a minute ago, so they follow how AuthKit is set up. The last part
        says what to call here with the token: list the trial, find its
        Namespace, and create an API key.
      security: []
      responses:
        "200":
          description: The instructions, as Markdown.
          content:
            text/plain:
              schema:
                type: string
              example: |
                # auth.md — agent registration

                You are an agent. This service allows you to register for an identity and exchange it for credentials.
                ...

                ## 7. Use the access token with Switchboard
                ...
        "404":
          $ref: "#/components/responses/NoAgents"
        "502":
          description: AuthKit didn't answer, or answered with an error.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /.well-known/oauth-protected-resource:
    get:
      tags: [Agents]
      operationId: getProtectedResourceMetadata
      summary: Where an Agent registers
      description: |
        This server's OAuth protected resource metadata (RFC 9728). It names
        the WorkOS AuthKit domain where an Agent registers, so an agent or
        OAuth client that finds this server can get an Agent token without
        being told where. A 401 from any other route points here too, in its
        `WWW-Authenticate` header.

        `resource` is this server's public origin, the same whichever
        hostname you fetched this from. This server accepts a token issued
        for its WorkOS client, which is what an Agent following `/auth.md`
        gets, or for exactly that resource (RFC 8707). AuthKit doesn't issue
        the second kind: it answers `invalid_target` to a token request that
        names a `resource`, whatever the URL, so leave it out.
      security: []
      responses:
        "200":
          description: The metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProtectedResource"
              example:
                resource: https://app.caprid.com
                authorization_servers: [https://auth.caprid.com]
        "404":
          $ref: "#/components/responses/NoAgents"
  /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:
    apiKey:
      type: http
      scheme: bearer
      description: An API key, `sb_...`. Create one in the dashboard or with `POST /v1/organizations/{id}/api-keys`.
    session:
      type: apiKey
      in: cookie
      name: __Host-switchboard_session
      description: |
        The dashboard's session cookie, set by signup and login. Only accepted
        on same-origin requests, so it can't be used from another site. Over
        HTTPS it's named `__Host-switchboard_session`, which browsers accept
        only from this host, so no other host under the same domain can plant
        one. A server you run over plain HTTP names it `switchboard_session`.
    agentToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        The AuthKit access token an AI agent sends, on a server set up for
        Agents. The Agent registers with this server's WorkOS AuthKit domain
        and exchanges its assertion for the token, which lasts about five
        minutes. Until a person claims the Agent, the token acts in the
        Agent's Trial Organization. Once a person claims it, the token acts as
        that person's Account, with everything their session can do, and its
        first request makes them the trial's Owner. The server
        checks the token's signature against AuthKit's published keys, and
        that AuthKit issued it for this server's WorkOS client or for this
        server as a resource, the `resource` in
        `/.well-known/oauth-protected-resource`.

  parameters:
    OrganizationID:
      name: id
      in: path
      required: true
      description: The Organization's `id`, from `GET /v1/organizations`.
      schema:
        type: integer
      example: 4
    Namespace:
      name: namespace
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/NamespaceName"

  requestBodies:
    CreateAPIKey:
      content:
        application/json:
          schema:
            type: object
            properties:
              label:
                allOf:
                  - $ref: "#/components/schemas/Label"
                description: A name to tell the key apart from others. Defaults to `api`.
              expires_in:
                type: integer
                minimum: 0
                default: 0
                description: Seconds until the key stops working. `0` means never.
              namespaces:
                type: array
                minItems: 1
                items:
                  $ref: "#/components/schemas/NamespaceName"
                description: |
                  Limits the key to these Namespaces, each a live Namespace of
                  the Organization. Leave it out for a key that reaches the
                  whole Organization. A limited key manages only the Signing
                  keys, Client tokens and settings of its Namespaces, and
                  can't create API keys. You can't change the limit later.
          example:
            label: staging-ci
            expires_in: 7776000
            namespaces: [acme-staging]

    CreateSigningKey:
      content:
        application/json:
          schema:
            type: object
            properties:
              label:
                allOf:
                  - $ref: "#/components/schemas/Label"
                description: |
                  Becomes part of the `kid`, which is `<namespace>_<label>`.
                  Labels are unique within a Namespace. Defaults to a random
                  12-character hex string.
              path:
                $ref: "#/components/schemas/Path"
              publish:
                $ref: "#/components/schemas/Prefixes"
              subscribe:
                $ref: "#/components/schemas/Prefixes"
          examples:
            whole-namespace:
              summary: A key for the whole Namespace
              value:
                label: backend
            publish-only:
              summary: A key that can only sign tokens that publish under `live`
              value:
                label: ingest
                publish: live

    MintClientToken:
      content:
        application/json:
          schema:
            type: object
            properties:
              kid:
                type: string
                description: The `kid` of the Signing key to sign with. You can leave it out when the Namespace has exactly one.
              path:
                allOf:
                  - $ref: "#/components/schemas/Path"
                description: |
                  Where the token is rooted, relative to the Namespace. The
                  token's `root` claim is `<namespace>/<path>`.
              publish:
                $ref: "#/components/schemas/Prefixes"
              subscribe:
                $ref: "#/components/schemas/Prefixes"
              expires_in:
                type: integer
                minimum: 0
                default: 3600
                description: Seconds until the token expires. `0` means never.
          examples:
            viewer:
              summary: Watch one room for an hour
              value:
                path: room/42
                subscribe: ""
            host:
              summary: Publish your own broadcast and watch everyone else's
              value:
                path: room/42
                publish: alice
                subscribe: ""
                expires_in: 14400

    VerifyClientToken:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [token]
            properties:
              token:
                type: string

    CreateEnrollment:
      required: true
      content:
        application/json:
          schema:
            type: object
            required: [url]
            properties:
              url:
                type: string
                format: uri
                description: |
                  The URL peers will dial this relay on, and Clients too
                  unless `client_url` names another. It needs a publicly
                  trusted certificate for its host.
              region:
                type: string
                maxLength: 64
                default: default
                description: |
                  Which of your Regions the Node joins. Relays in the same
                  Region all peer with each other. Region names belong to
                  you, so your `iad` is unrelated to anyone else's. Can't
                  contain `/`.
              gateway:
                type: boolean
                default: false
                description: Whether this Node may be elected its Region's Gateway, which carries the Region's traffic to other Regions.
              client_url:
                type: string
                format: uri
                description: |
                  The Client URL, which is where Clients, such as a player
                  page, reach this relay when that isn't `url`. Use it when `url`
                  is a name only your private network resolves, and Clients
                  come in through a public hostname or a load balancer.
                  Switchboard doesn't route anything with it. It reports the
                  value on the Node, for whatever hands relay addresses to
                  your Clients.

                  It has to start with `http://` or `https://` and can't
                  contain a username or password. Switchboard trims it and
                  stores it as you send it. Leave it out, or send `""`, for
                  none. The relay can't change it. If the relay's
                  `connect_api` URL carries `client=`, that value has to
                  match.
              expires_in:
                type: integer
                minimum: 0
                maximum: 86400
                default: 3600
                description: Seconds until the token expires. `0` means the default. It always expires.
          example:
            url: https://relay1.internal.acme.example:4443/
            region: iad
            gateway: true
            client_url: https://live.acme.example/

  headers:
    SessionCookie:
      description: "`__Host-switchboard_session` over HTTPS, `switchboard_session` over plain HTTP: HttpOnly, SameSite=Lax, Path=/, valid for 14 days."
      schema:
        type: string

  responses:
    NoWorkOS:
      description: This server doesn't use WorkOS login.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NoAgents:
      description: This server isn't set up for Agents.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadRequest:
      description: The request is malformed or a field is invalid. The message says which.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthenticated:
      description: |
        No valid credential. Missing, expired and revoked credentials all get
        the same response, and so does an API key whose trial has lapsed. An
        Agent token from before a claim gets a 401 too, once the claim has
        made the trial a person's, with a message saying to get a new token.
      headers:
        WWW-Authenticate:
          schema:
            type: string
            example: Bearer realm="switchboard"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: |
        No such thing, or not yours. Another customer's Organization or
        Namespace is a 404 too, so nobody can tell which ones exist.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: |
        Sent with an API key limited to some Namespaces, to a Namespace
        outside its limit, or to a route that acts on the Organization as a
        whole. A limited key can never create API keys.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "this API key is limited to the Namespaces acme-staging, and acme isn't one of them"
    AgentRefused:
      description: |
        Sent on every route with the token of an Agent that has nothing to act
        in. An Agent whose person hasn't signed up gets this until they sign
        up at `/app/`, and its next request after that acts as their new
        Account. An Agent nobody has claimed gets this once its trial lapses,
        because a registration gets one trial. It can register again to start
        a new one, or a person can claim it so it acts as their Account.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            no-account:
              summary: The person who claimed the Agent hasn't signed up
              value:
                error: "the person who claimed this Agent has no Switchboard Account: once they sign up at /app/, the Agent acts as their Account"
            trial-over:
              summary: The Agent's trial has lapsed
              value:
                error: "this Agent's trial is over: register again to start a new one, or have a person with a Switchboard Account claim this Agent"
    NotAnOwner:
      description: You aren't an Owner of this Organization, or you sent an API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "only an Owner can manage this Organization's Invitations"
    InvitationSpent:
      description: The Invitation has already been used, was revoked, or has expired. Ask for a new one.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "this invitation has expired"
    Legacy:
      description: |
        Sent with a session, which may belong to several Organizations. Use the
        route the message names instead. This route also returns 400 for
        anything that route would refuse with 400.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "this route is for API keys; use /v1/organizations/{id}/api-keys"
    LegacyNamespace:
      description: |
        Sent with a session, or with an API key that doesn't reach exactly
        one Namespace. Use the route the message names instead. This route
        also returns 400 for anything that route would refuse with 400.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "this API key reaches 2 Namespaces, so this route can't tell which one you mean; use /v1/namespaces/{namespace}/keys"
    TooManyRequests:
      description: Rate limited. Try again later.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          example: "the requested grants exceed the key's scope"

    NamespaceName:
      type: string
      minLength: 3
      maxLength: 32
      pattern: "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$"
      description: |
        A Namespace's name, which is the root of every path signed for it.
        It never changes. Use lowercase letters, digits and hyphens, and start
        and end with a letter or digit. Some names, like `admin` and
        `default`, are reserved, and so is every name that starts with
        `trial-`, which only Trial Organizations get.
      example: acme

    Label:
      type: string
      pattern: "^[A-Za-z0-9_-]{1,64}$"

    Path:
      type: string
      default: ""
      description: |
        A path relative to your Namespace, like `room/42`. Leading, trailing
        and repeated slashes are ignored. Segments can't be `.` or `..`, or
        contain whitespace or control characters.

    Prefixes:
      description: |
        Path prefixes, relative to `path`. `""` means everything under `path`.
        Matching is by whole segment: `live` covers `live/cam1` but not
        `lively`. A single string is the same as a one-item array.
      oneOf:
        - type: string
        - type: array
          items:
            type: string
      example: [live]

    Account:
      type: object
      required: [organizations]
      properties:
        email:
          type: string
          format: email
          description: The person signed in. Absent for an API key.
        created_at:
          type: integer
          description: When the person signed up. Absent for an API key.
        api_key:
          allOf:
            - $ref: "#/components/schemas/APIKey"
          description: |
            The API key making the call, with its `namespaces` limit if it
            has one. A limited key can't list its Organization's Namespaces,
            so this is how it finds the ones it can reach. Absent for a
            session.
        organizations:
          type: array
          items:
            $ref: "#/components/schemas/Organization"
        namespace:
          allOf:
            - $ref: "#/components/schemas/NamespaceName"
          deprecated: true
          description: |
            The one Namespace you can reach, as this reported before
            Organizations. Absent unless you reach exactly one.
        platform_peering:
          type: boolean
          deprecated: true
          description: That Namespace's `platform_peering`. Absent along with `namespace`.

    Organization:
      type: object
      description: |
        A customer of Switchboard. It owns its Namespaces, and the API keys,
        Nodes and enrollments that serve them. Its Members manage all of it.
      required: [id, name, created_at]
      properties:
        id:
          type: integer
          description: Identifies the Organization in routes. It never changes.
          example: 4
        name:
          type: string
          description: For display. Any Member can change it.
          example: Acme
        role:
          type: string
          enum: [owner, member]
          description: Your role in it. Absent for an API key, which has none.
        created_at:
          type: integer
        trial_expires_at:
          type: integer
          description: |
            When a Trial Organization lapses, so its Agent knows how long it
            has to get a person to claim it. Absent for every other
            Organization.
          example: 1760604800

    Invitation:
      type: object
      description: An offer of membership in an Organization to one email address.
      required: [id, organization_id, email, role, created_at, expires_at]
      properties:
        id:
          type: string
          description: Identifies the Invitation in routes. Not a secret.
          example: 3f9c1a2b7d4e
        organization_id:
          type: integer
          example: 4
        organization_name:
          type: string
          description: The Organization's name, for someone who isn't in it yet.
          example: Acme
        email:
          type: string
          description: Who may accept it. Stored lowercased.
          example: alice@acme.example
        role:
          type: string
          enum: [owner, member]
          description: The role they'll have once they accept.
        created_at:
          type: integer
          example: 1790294400
        expires_at:
          type: integer
          description: When the link stops working, 7 days after it was created.
          example: 1790899200

    NewInvitation:
      description: An Invitation just created, with its link. The link's secret is shown only here.
      allOf:
        - $ref: "#/components/schemas/Invitation"
        - type: object
          required: [secret, link]
          properties:
            secret:
              type: string
              description: The secret the link carries. Send it to `POST /v1/invitations/accept`.
              example: sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1qW
            link:
              type: string
              description: The dashboard page that accepts it. Send this to the person you invited.
              example: https://app.caprid.com/app/invitations/sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1qW

    Member:
      type: object
      description: An Account that belongs to an Organization.
      required: [account_id, email, role, joined_at]
      properties:
        account_id:
          type: integer
          description: Identifies the Member in routes.
          example: 12
        email:
          type: string
          example: alice@acme.example
        role:
          type: string
          enum: [owner, member]
          description: |
            Every Member manages everything the Organization owns. An Owner
            can also manage its Members and Invitations.
        joined_at:
          type: integer
          description: When the Account became a Member.

    Namespace:
      type: object
      required: [name, organization_id, platform_peering, created_at]
      properties:
        name:
          $ref: "#/components/schemas/NamespaceName"
        organization_id:
          type: integer
          description: The Organization that owns it, for good.
        platform_peering:
          type: boolean
          description: |
            Whether its tracks may travel between your Self-hosted relays and
            Platform relays, the relays Mux runs.
        created_at:
          type: integer

    NamespaceSettings:
      type: object
      properties:
        platform_peering:
          type: boolean
          description: |
            Whether the Namespace's tracks may travel between your Self-hosted
            relays and Platform relays, the relays Mux runs. A change takes
            effect within about a minute. Your Regions can't link to Mux's yet, so for now this
            changes nothing.

    AccountEnvelope:
      type: object
      required: [account]
      properties:
        account:
          $ref: "#/components/schemas/Account"

    APIKey:
      type: object
      required: [id, label, preview, created_at]
      properties:
        id:
          type: string
          description: Identifies the key for revoking it. Not a secret.
          example: 3f9a1c07be42
        label:
          type: string
        preview:
          type: string
          description: The first 6 characters of the secret after `sb_`, so you can tell keys apart.
        created_at:
          type: integer
        expires_at:
          type: integer
          description: Absent for a key that never expires.
        last_used_at:
          type: integer
          description: Absent for a key that has never been used.
        namespaces:
          type: array
          items:
            $ref: "#/components/schemas/NamespaceName"
          description: |
            The Namespaces the key is limited to, by name. Absent for a key
            with no limit. A deleted Namespace drops out, so a key whose
            Namespaces have all been deleted has `[]` here and can do nothing.
          example: [acme-staging]

    NewAPIKey:
      allOf:
        - $ref: "#/components/schemas/APIKey"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: The API key itself. Shown only in this response.
              example: sb_R2x0Z9kQ7mVt3xLp8wNc1bYh5sDf2gJr6uEa0zWiTq4

    Scope:
      type: object
      description: |
        What a Signing key may sign. `put` is publish and `get` is subscribe,
        as in the token claims. Both are relative to `root`.
      properties:
        root:
          type: string
          example: acme
        put:
          type: array
          items:
            type: string
        get:
          type: array
          items:
            type: string

    JWK:
      type: object
      description: |
        A Signing key in JSON Web Key form, with the `scope` extension moq
        relays understand. `k` is the secret. Anyone holding it can sign
        tokens within the scope.
      required: [alg, key_ops, kty, k, kid, scope]
      properties:
        alg:
          type: string
          const: HS256
        key_ops:
          type: array
          items:
            type: string
          example: [sign, verify]
        kty:
          type: string
          const: oct
        k:
          type: string
          description: The HMAC secret, base64url without padding.
        kid:
          type: string
          example: acme_backend
        scope:
          $ref: "#/components/schemas/Scope"

    SigningKeySummary:
      type: object
      required: [kid, alg, scope, namespace, created_at]
      properties:
        kid:
          type: string
        alg:
          type: string
          const: HS256
        scope:
          $ref: "#/components/schemas/Scope"
        namespace:
          type: string
        created_at:
          type: integer

    Claims:
      type: object
      description: |
        A Client token's payload. `root` is absolute and starts with your
        Namespace. `put` (publish) and `get` (subscribe) are prefixes relative
        to `root`.
      properties:
        root:
          type: string
          example: acme/room/42
        put:
          type: array
          items:
            type: string
        get:
          type: array
          items:
            type: string
          example: [""]
        exp:
          type: integer
          description: When the token expires. Absent for a token that never expires.
        iat:
          type: integer
          description: When the token was issued.

    MintedToken:
      type: object
      required: [token, kid, claims]
      properties:
        token:
          type: string
          description: A JWT. Give it to the Client, which passes it to the relay as `?jwt=`.
        kid:
          type: string
        claims:
          $ref: "#/components/schemas/Claims"

    VerifiedToken:
      type: object
      required: [kid, claims]
      properties:
        kid:
          type: string
        claims:
          $ref: "#/components/schemas/Claims"
        expires_in:
          type: integer
          description: Seconds left. Absent for a token that never expires.

    NewEnrollment:
      allOf:
        - $ref: "#/components/schemas/Enrollment"
        - type: object
          required: [token]
          properties:
            token:
              type: string
              description: The Relay enrollment token. Shown only in this response.
              example: sbe_Vq3mK8nR5tWb2xYc7dLp0sHf4jGu9eAz1oQi6vTk3wM

    Enrollment:
      type: object
      required: [id, account, url, canonical, region, gateway, preview, created_at, expires_at]
      properties:
        id:
          type: string
          description: Identifies the enrollment for revoking it. Not the token.
        account:
          type: [string, "null"]
          description: |
            The Organization's oldest Namespace, kept from before
            Organizations. The Node belongs to the whole Organization and
            carries all of its Namespaces. `null` when the Organization has no
            Namespaces.
        url:
          type: string
          description: The URL as you sent it.
        canonical:
          type: string
          description: The URL in the normalized form Switchboard uses to tell Nodes apart.
        region:
          type: string
        gateway:
          type: boolean
        client_url:
          type: string
          description: The Client URL the Node will have. Omitted when it has none.
        preview:
          type: string
          description: The first 6 characters of the token after `sbe_`.
        created_at:
          type: integer
        expires_at:
          type: integer

    Node:
      type: object
      required: [url, canonical, source, account, region, gateway, enrolled, first_seen_at, last_seen_at, live]
      properties:
        url:
          type: string
          description: The URL the Node's peers dial, as it was enrolled.
        canonical:
          type: string
          description: The URL in the normalized form Switchboard uses to tell Nodes apart.
        source:
          type: string
          enum: [poll, static]
        account:
          type: [string, "null"]
          description: |
            The Organization's oldest Namespace, kept from before
            Organizations. The Node belongs to the whole Organization and
            carries all of its Namespaces. `null` when the Organization has no
            Namespaces.
        region:
          type: string
        gateway:
          type: boolean
        client_url:
          type: string
          description: |
            The Client URL, which is where Clients, such as a player page,
            reach this relay when that isn't `url`. On a private network, `url` is often
            a name no Client can resolve, so whatever hands relay addresses to
            Clients reads this instead. It is fixed when the Node is enrolled,
            and omitted when the Node has none. Relays never see it, because
            peer discovery hands out only `url`.
        enrolled:
          type: boolean
          description: Whether the Node came from a Relay enrollment token, so its relay has a Node key.
        first_seen_at:
          type: integer
        last_seen_at:
          type: integer
          description: When its relay last polled, or when it enrolled if it hasn't polled since.
        live:
          type: boolean
          description: Whether its relay has polled in the last 90 seconds.

    ProtectedResource:
      type: object
      required: [resource, authorization_servers]
      properties:
        resource:
          type: string
          format: uri
          description: This server's public origin, the one resource its Agent tokens may name.
        authorization_servers:
          type: array
          items:
            type: string
            format: uri
          description: The WorkOS AuthKit domain an Agent registers with.
    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok, unavailable]
      additionalProperties: true
