openapi: 3.1.0
info:
  title: tsrelay
  summary: Deliver HTTP callbacks from your tailnet to apps running locally.
  description: |
    Every resource has one URL. A `.json` or `.html` extension on the path
    picks its representation; otherwise the `Accept` header does, and JSON
    is the default. `application/vnd.tsrelay.v1+json` is a versioned alias
    of JSON. Resources marked with an event stream also answer
    `Accept: text/event-stream` with server-sent events (see the `Event`
    schema). A representation a resource doesn't have gets `406`.

    Callers are identified by their tailnet identity; there are no tokens.
    Every response carries `Tsrelay-Version`. Errors are
    `application/problem+json` and carry `Tsrelay-Error`. API responses are
    `Cache-Control: no-store`. Every write without a `Sec-Fetch-Site` header
    must send `Content-Type: application/json`, even without a body (415
    otherwise).

    Within a major version, changes are additive only. Clients must ignore
    unknown fields, enumeration values, event types, control types and problem
    codes, and fall back to the HTTP status for a code they don't know. A
    request field the relay doesn't know is refused with `422` and the code
    `unsupported_field`. `GET /` lists optional capabilities in `features`.
    See https://tsrelay.mazetti.me/compatibility.
  license:
    name: Apache-2.0
    identifier: Apache-2.0
  version: v1
externalDocs:
  url: https://github.com/Radiergummi/tsrelay/blob/main/docs/DESIGN.md
paths:
  /:
    get:
      operationId: getEntry
      summary: Who you are, what the relay allows, and where its resources are
      description: Browsers asking for HTML are redirected to `/hooks`.
      responses:
        "200":
          description: The entry document
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "302":
          description: Redirect to `/hooks`, for HTML
        "403": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks:
    get:
      operationId: listHooks
      summary: Your hooks
      description: As events, changes to your hooks and their calls. Event streams need a role; a node may hold 8 open, and each is authorized again while open.
      parameters:
        - name: all
          in: query
          description: Admins only; every user's hooks in the admin's channels, as metadata.
          schema: { type: boolean }
        - name: channel
          in: query
          description: Only the hooks in this channel. Doesn't apply to the event stream.
          schema: { type: string }
      responses:
        "200":
          description: The hooks, or a stream of their changes
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HookList" }
            text/event-stream:
              schema: { $ref: "#/components/schemas/Event" }
            text/html: {}
        "403": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
    post:
      operationId: createHook
      summary: Create a hook
      parameters:
        - $ref: "#/components/parameters/dryRun"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateRequest" }
      responses:
        "201":
          description: The new hook
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Hook" }
        "200":
          description: With `dryRun`, the hook as it would be created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Hook" }
        "400": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks/{id}:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      operationId: getHook
      summary: One hook
      description: As events, changes to this hook and its calls.
      responses:
        "200":
          description: The hook, or a stream of its changes
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Hook" }
            text/event-stream:
              schema: { $ref: "#/components/schemas/Event" }
            text/html: {}
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
    patch:
      operationId: updateHook
      summary: Change a hook; only the given fields change
      parameters:
        - $ref: "#/components/parameters/dryRun"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateRequest" }
      responses:
        "200":
          description: The changed hook
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Hook" }
        "400": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
    delete:
      operationId: deleteHook
      summary: Delete a hook
      responses:
        "204":
          description: Deleted
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks/{id}/rotate-public-url:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      operationId: rotatePublicURL
      summary: Give a public hook a new public URL; the old one stops working
      responses:
        "200":
          description: The hook, with its new public URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Hook" }
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks/{id}/calls:
    parameters:
      - $ref: "#/components/parameters/id"
    get:
      operationId: listCalls
      summary: A hook's recent calls, newest first
      description: As events, this hook's calls as they arrive and complete.
      responses:
        "200":
          description: The calls, or a stream of them
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallList" }
            text/event-stream:
              schema: { $ref: "#/components/schemas/Event" }
            text/html: {}
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        "429": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks/{id}/calls/{callID}:
    parameters:
      - $ref: "#/components/parameters/id"
      - $ref: "#/components/parameters/callID"
    get:
      operationId: getCall
      summary: One call, with its captured headers and bodies
      description: Admins who don't own the hook get its metadata only.
      responses:
        "200":
          description: The call
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallDetail" }
            text/html: {}
        "404": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks/{id}/calls/{callID}/replay:
    parameters:
      - $ref: "#/components/parameters/id"
      - $ref: "#/components/parameters/callID"
    post:
      operationId: replayCall
      summary: Send a captured call through the relay to your app again
      responses:
        "201":
          description: The finished replay
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallDetail" }
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }
        "500": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /hooks/{id}/test:
    parameters:
      - $ref: "#/components/parameters/id"
    post:
      operationId: testCall
      summary: Send a test request through the relay to your app
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TestRequest" }
      responses:
        "201":
          description: The finished test call
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CallDetail" }
        "400": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "422": { $ref: "#/components/responses/Problem" }
        "500": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /channels:
    get:
      operationId: listChannels
      summary: Your channel patterns, and the channels your hooks are in
      description: |
        The relay knows only your own grants, so it can't list who else
        may send on a channel; that is in the tailnet policy.
      parameters:
        - name: all
          in: query
          description: Admins only; every channel in use, with hook counts but no hooks.
          schema: { type: boolean }
      responses:
        "200":
          description: The channels
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Channels" }
            text/html: {}
        "403": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /channels/{name}:
    parameters:
      - name: name
        in: path
        required: true
        description: A channel name, such as `prod.payments`.
        schema: { type: string }
    get:
      operationId: getChannel
      summary: A channel's hooks, and whether you may receive and send on it
      description: A valid name without hooks is not an error; an invalid one is `404`.
      parameters:
        - name: all
          in: query
          description: Admins only; every user's hooks in the channel, as metadata.
          schema: { type: boolean }
      responses:
        "200":
          description: The channel
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelDetail" }
            text/html: {}
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /tunnel:
    get:
      operationId: attachTunnel
      summary: Attach a tunnel serving one or more of your hooks
      description: |
        Only answers an HTTP upgrade (`Connection: Upgrade`,
        `Upgrade: tsrelay-tunnel/1`); the CLI speaks the tunnel protocol.
      parameters:
        - name: hook
          in: query
          required: true
          description: A hook to serve; repeat for several.
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
        - name: target
          in: query
          description: The local target, shown with the hook's tunnel.
          schema: { type: string }
        - name: session
          in: query
          schema: { type: string }
        - name: resume
          in: query
          schema: { type: string, enum: ["1"] }
      responses:
        "101":
          description: Switching to the tunnel protocol
        "400": { $ref: "#/components/responses/Problem" }
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        "409": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /openapi:
    get:
      operationId: getOpenAPI
      summary: This document, as JSON (also at `/openapi.json`)
      responses:
        "200":
          description: The OpenAPI document
          content:
            application/json: {}
        default: { $ref: "#/components/responses/Problem" }
  /openapi.yaml:
    get:
      operationId: getOpenAPIYAML
      summary: This document, as YAML
      responses:
        "200":
          description: The OpenAPI document
          content:
            application/yaml: {}
        default: { $ref: "#/components/responses/Problem" }
  /licenses:
    get:
      operationId: getLicenses
      summary: The open-source components built into the relay and their licenses
      description: Needs no role.
      responses:
        "200":
          description: The licenses document, projected from `/sbom.cdx.json`
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Licenses" }
            text/html: {}
        "403": { $ref: "#/components/responses/Problem" }
        "406": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /licenses/{textId}:
    parameters:
      - name: textId
        in: path
        required: true
        description: A component's `textId` or `noticeId`.
        schema: { type: string }
    get:
      operationId: getLicenseText
      summary: One license or notice text
      description: The ID is a hash of the text, so responses are cached as immutable. Needs no role.
      responses:
        "200":
          description: The text
          content:
            text/plain: {}
        "403": { $ref: "#/components/responses/Problem" }
        "404": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /notices.txt:
    get:
      operationId: getNotices
      summary: The relay's NOTICE, then every component's license and notice texts
      description: Needs no role.
      responses:
        "200":
          description: The notices
          content:
            text/plain: {}
        "403": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
  /sbom.cdx.json:
    get:
      operationId: getSBOM
      summary: The relay's software bill of materials, as CycloneDX 1.6
      description: Needs no role.
      responses:
        "200":
          description: The SBOM
          content:
            application/vnd.cyclonedx+json: {}
        "403": { $ref: "#/components/responses/Problem" }
        default: { $ref: "#/components/responses/Problem" }
components:
  parameters:
    id:
      name: id
      in: path
      required: true
      description: The hook's ID.
      schema: { type: string }
    callID:
      name: callID
      in: path
      required: true
      schema: { type: string }
    dryRun:
      name: dryRun
      in: query
      description: Validate only; nothing is saved.
      schema: { type: boolean }
  responses:
    Problem:
      description: An RFC 9457 problem
      headers:
        Tsrelay-Error:
          description: The problem's code
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  schemas:
    Duration:
      type: string
      description: A Go duration, such as `30s` or `2m0s`.
      examples: ["30s"]
    Millis:
      type: number
      minimum: 0
      description: A measured duration in milliseconds, with microsecond precision (`0.412` is 412 µs).
      examples: [42, 0.412]
    Role:
      type: string
      enum: [receiver, sender, admin]
    RateLimit:
      type: object
      required: [perSecond, burst]
      properties:
        perSecond: { type: number }
        burst: { type: integer }
    Settings:
      type: object
      description: A hook's overrides of the relay defaults; a missing field uses the default.
      properties:
        attachPolicy: { type: string, enum: [takeover, reject] }
        hostHeader: { type: string, enum: [preserve, rewrite] }
        holdTimeout: { $ref: "#/components/schemas/Duration" }
        responseTimeout: { $ref: "#/components/schemas/Duration" }
        idleTimeout: { $ref: "#/components/schemas/Duration" }
        maxBodyBytes: { type: integer, description: "0 is unlimited" }
        rateLimit: { $ref: "#/components/schemas/RateLimit" }
        capture: { type: boolean }
    SettingsPatch:
      type: object
      description: Settings to change; `null` resets one to the relay default.
      properties:
        attachPolicy: { type: [string, "null"], enum: [takeover, reject, null] }
        hostHeader: { type: [string, "null"], enum: [preserve, rewrite, null] }
        holdTimeout: { type: [string, "null"] }
        responseTimeout: { type: [string, "null"] }
        idleTimeout: { type: [string, "null"] }
        maxBodyBytes: { type: [integer, "null"] }
        rateLimit:
          oneOf:
            - $ref: "#/components/schemas/RateLimit"
            - type: "null"
        capture: { type: [boolean, "null"] }
    Effective:
      type: object
      description: A hook's settings with every default filled in.
      required: [attachPolicy, hostHeader, holdTimeout, responseTimeout, idleTimeout, maxBodyBytes, rateLimit, capture]
      properties:
        attachPolicy: { type: string, enum: [takeover, reject] }
        hostHeader: { type: string, enum: [preserve, rewrite] }
        holdTimeout: { $ref: "#/components/schemas/Duration" }
        responseTimeout: { $ref: "#/components/schemas/Duration" }
        idleTimeout: { $ref: "#/components/schemas/Duration" }
        maxBodyBytes: { type: integer }
        rateLimit: { $ref: "#/components/schemas/RateLimit" }
        capture: { type: boolean }
    Me:
      type: object
      required: [node, roles, limits, hookCount, capName, version, policy, publicPatterns, funnel, features, links]
      properties:
        login: { type: string, description: "Empty for tagged nodes" }
        node: { type: string }
        tags:
          type: array
          items: { type: string }
        slug: { type: string, description: "The caller's URL prefix: their hooks are at `/~{slug}/…`. Absent for tagged nodes" }
        roles:
          type: object
          description: Each role the caller has, with its channel patterns.
          propertyNames: { $ref: "#/components/schemas/Role" }
          additionalProperties:
            type: array
            items: { type: string }
        limits:
          type: object
          required: [maxHooks, maxTunnels, maxHookTtl]
          properties:
            maxHooks: { type: integer }
            maxTunnels: { type: integer }
            maxHookTtl: { $ref: "#/components/schemas/Duration", description: "`0s` means no limit" }
        hookCount: { type: integer, description: "Hooks the caller owns" }
        capName: { type: string }
        version: { type: string }
        commit: { type: string, description: "The revision the relay was built from, when known" }
        latestVersion: { type: string, description: "Admins only: a tsrelay release newer than `version`, unless the relay runs with --update-check=false" }
        releaseUrl: { type: string, format: uri, description: "Admins only: the release notes of `latestVersion`" }
        policy:
          type: object
          required: [defaults, max, callsPerHook]
          properties:
            defaults: { $ref: "#/components/schemas/Effective" }
            max:
              type: object
              properties:
                holdTimeout: { $ref: "#/components/schemas/Duration" }
                responseTimeout: { $ref: "#/components/schemas/Duration" }
                idleTimeout: { $ref: "#/components/schemas/Duration" }
                maxBodyBytes: { type: integer }
                rateLimit: { $ref: "#/components/schemas/RateLimit" }
            callsPerHook: { type: integer }
            noBodies: { type: boolean, description: "The relay keeps no bodies, whatever hooks set (--capture-bodies=false)" }
        publicPatterns: { type: array, items: { type: string }, description: "Channel patterns in which the caller may make hooks public; empty when none" }
        funnel: { type: boolean, description: "The relay serves public hooks on the internet" }
        features: { type: array, items: { type: string }, description: "Optional behaviors this relay has beyond the API's baseline, by name; empty at 1.0" }
        links:
          type: object
          description: "`licenses` only in builds that carry an SBOM, as releases do"
          required: [hooks, channels, tunnel, openapi]
          additionalProperties: { type: string }
    HookList:
      type: object
      required: [hooks]
      properties:
        hooks: { type: array, items: { $ref: "#/components/schemas/Hook" } }
    CallList:
      type: object
      required: [calls]
      properties:
        calls: { type: array, items: { $ref: "#/components/schemas/Summary" } }
    Hook:
      type: object
      required: [id, url, owner, path, channel, ephemeral, public, settings, effective, createdAt]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        owner: { type: string }
        path: { type: string }
        channel: { type: string }
        description: { type: string }
        ephemeral: { type: boolean }
        public: { type: boolean }
        publicUrl: { type: string, format: uri, description: "The owner's only, while the relay serves public hooks" }
        expiresAt: { type: string, format: date-time }
        settings: { $ref: "#/components/schemas/Settings" }
        effective: { $ref: "#/components/schemas/Effective" }
        createdAt: { type: string, format: date-time }
        tunnel:
          type: object
          required: [id, node]
          properties:
            id: { type: string }
            node: { type: string }
            target: { type: string }
        lastCall:
          $ref: "#/components/schemas/Summary"
          description: The newest call that was not refused.
    Channels:
      type: object
      required: [receive, send, channels]
      properties:
        receive:
          type: array
          description: Your receiver role's channel patterns.
          items: { type: string }
        send:
          type: array
          description: Your sender role's channel patterns.
          items: { type: string }
        channels:
          type: array
          description: The channels in use, by name.
          items: { $ref: "#/components/schemas/Channel" }
    Channel:
      type: object
      required: [name, hookCount, receive]
      properties:
        name: { type: string }
        hookCount: { type: integer }
        hooks:
          type: array
          description: Your hooks in the channel; left out with `all`.
          items: { $ref: "#/components/schemas/Hook" }
        receive:
          type: array
          description: Your receive patterns that match the channel.
          items: { type: string }
    ChannelDetail:
      type: object
      required: [name, canReceive, canSend, hooks]
      properties:
        name: { type: string }
        canReceive: { type: boolean }
        canSend: { type: boolean }
        hooks:
          type: array
          items: { $ref: "#/components/schemas/Hook" }
    CreateRequest:
      type: object
      properties:
        path: { type: string, description: "Empty: three random words" }
        channel: { type: string, description: "Empty: `default`" }
        description: { type: string }
        ephemeral: { type: boolean }
        public: { type: boolean }
        expiresAt: { type: [string, "null"], format: date-time }
        settings: { $ref: "#/components/schemas/Settings" }
    UpdateRequest:
      type: object
      description: |
        A partial update: a field left out stays as it is. `null` resets a
        field where that means something, and is refused (422) where it
        doesn't.
      properties:
        path: { type: string, description: "Moves the hook, which changes its URL. `null` is refused." }
        channel: { type: [string, "null"], description: "`null` moves the hook back to `default`." }
        description: { type: [string, "null"], description: "`null` clears it." }
        ephemeral: { type: boolean, description: "`null` is refused." }
        public: { type: boolean, description: "`false` stops serving the hook on the internet and drops its public URL; `null` is refused." }
        expiresAt: { type: [string, "null"], format: date-time, description: "`null` removes the expiry." }
        settings: { $ref: "#/components/schemas/SettingsPatch", description: "Each setting set to `null` goes back to the relay's default; `settings: null` changes nothing." }
    Sender:
      type: object
      properties:
        node: { type: string }
        tags:
          type: array
          items: { type: string }
        login: { type: string }
        ip: { type: string }
        public: { type: boolean, description: "The sender is on the internet; only ip is known" }
    Summary:
      type: object
      description: A call without headers and bodies.
      required: [id, hookId, receivedAt, kind, sender, method, path, outcome, durationMs]
      properties:
        id: { type: string }
        hookId: { type: string }
        receivedAt: { type: string, format: date-time }
        kind: { type: string, enum: [sender, public, replay, test] }
        replayOf: { type: string }
        refused:
          type: boolean
          description: >-
            The sender may not use the hook's channel and was answered as if
            there were no hook. Refused calls are kept apart (the last 10 per
            hook), never evict delivered calls, and publish no events.
        sender: { $ref: "#/components/schemas/Sender" }
        method: { type: string }
        path: { type: string }
        outcome:
          type: string
          description: "`pending`, `ok`, `sender_canceled` (the sender went away before the call finished), or a `Tsrelay-Error` code"
        status: { type: integer }
        durationMs: { $ref: "#/components/schemas/Millis" }
    Capture:
      type: object
      required: [size, captured]
      properties:
        body: { type: string, contentEncoding: base64 }
        size: { type: integer }
        truncated: { type: boolean }
        captured: { type: boolean }
    Headers:
      type: object
      additionalProperties:
        type: array
        items: { type: string }
    Attempt:
      type: object
      required: [id, startedAt, waitedForMs, ttfbMs, durationMs, responseBody, bytesIn, bytesOut]
      properties:
        id: { type: string }
        startedAt: { type: string, format: date-time }
        tunnelId: { type: string }
        node: { type: string }
        waitedForMs: { $ref: "#/components/schemas/Millis" }
        ttfbMs: { $ref: "#/components/schemas/Millis" }
        durationMs: { $ref: "#/components/schemas/Millis" }
        status: { type: integer }
        responseHeaders: { $ref: "#/components/schemas/Headers" }
        redacted:
          type: array
          items: { type: string }
        responseBody: { $ref: "#/components/schemas/Capture" }
        bytesIn: { type: integer, description: "Bytes of the request body read from the sender" }
        bytesOut: { type: integer, description: "Bytes of the response body sent back to the sender" }
        error: { type: string }
    CallDetail:
      allOf:
        - $ref: "#/components/schemas/Summary"
        - type: object
          required: [requestBody, attempts]
          properties:
            query: { type: string }
            queryTruncated: { type: boolean }
            requestHeaders: { $ref: "#/components/schemas/Headers" }
            redacted:
              type: array
              items: { type: string }
            requestBody: { $ref: "#/components/schemas/Capture" }
            attempts:
              type: array
              items: { $ref: "#/components/schemas/Attempt" }
            replay:
              type: object
              description: The hook's owner only; whether the call can be replayed.
              required: [allowed, reason]
              properties:
                allowed: { type: boolean }
                reason: { type: string }
    TestRequest:
      type: object
      properties:
        method: { type: string, description: "Default POST" }
        path: { type: string, description: "Below the hook's URL, query allowed; default `/`" }
        headers: { $ref: "#/components/schemas/Headers" }
        body: { type: string }
    Event:
      description: |
        A server-sent event. `event: call` carries a call `Summary` when a
        call arrives and again when it completes, except for refused calls;
        `event: hook` carries the ID of a hook that was created, changed,
        deleted or expired, or whose tunnel attached or detached. A stream that falls 64 events behind is
        closed; reconnect and refetch.
      oneOf:
        - $ref: "#/components/schemas/Summary"
        - type: object
          required: [id]
          properties:
            id: { type: string }
    Licenses:
      type: object
      required: [components]
      properties:
        components:
          type: array
          items:
            type: object
            required: [name, ecosystem, licenses]
            properties:
              name: { type: string }
              version: { type: string }
              description: { type: string }
              ecosystem: { type: string, enum: [go, npm, other] }
              licenses:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, description: "An SPDX identifier or expression" }
                    name: { type: string }
                    url: { type: string }
              homepage: { type: string }
              repository: { type: string }
              textId: { type: string, description: "The license text, at `/licenses/{textId}`" }
              noticeId: { type: string, description: "The NOTICE text, at `/licenses/{textId}`" }
    Problem:
      type: object
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code: { type: string }
        errors:
          type: array
          description: For `422`, one entry per invalid field, or per field the relay does not support (`unsupported_field`).
          items:
            type: object
            required: [field, message]
            properties:
              field: { type: string }
              message: { type: string }
        hook:
          type: string
          description: For a refused tunnel attach, the hook it was refused for.
