openapi: 3.1.0
info:
  title: GAVEL host API
  version: 0.1.0
  summary: HTTP API of the GAVEL auction host (sandbox only).
  description: |
    GAVEL is an auction where every bid is backed by a PayPal hold and only the winner pays.
    This document describes the public HTTP API of the host (`packages/server`). It is written
    by hand from the route files in `packages/server/src/http/` and the schemas in
    `packages/shared/src/`, and a test (`scripts/tests/openapi-routes.test.mjs`) fails when a
    route in the code is missing here or a path here has no route in the code.

    **Sandbox only.** The host talks to the PayPal sandbox. No real money moves.

    ## Authentication

    Several kinds of credential exist. None of them is a real value in this document.

    | Credential | How it is sent | Who gets it |
    | --- | --- | --- |
    | Operator key | header `X-Admin-Key` | the operator who runs the host (environment setting `ADMIN_KEY`) |
    | Auctioneer token | `Authorization: Bearer <token>`, valid 12 hours | returned as a link when an auction is created or rotated |
    | Screen token | `Authorization: Bearer <token>`, valid 12 hours | returned as a link when an auction is created |
    | Session (bidder) token | `Authorization: Bearer <token>`, valid 4 hours | returned by the join routes |
    | Receipt token | `Authorization: Bearer <token>`, valid 4 hours | returned by the receipt-link route |
    | Reviewer token | `Authorization: Bearer <token>`, valid 12 hours | returned as `reviewerToken` and `reviewerLink` when a judge room is created; read only, a screen's view plus the room ledger |
    | Judge key | header `X-Judge-Key` | a limited key for judges, `gvl_judge_<id>_<secret>` (per-judge entries in `JUDGE_KEYS`, or the shared `JUDGE_KEY`; never the operator key); optional, only on the judge-room create and key-check routes |
    | Invite token | field `invite` in the body of the join route, valid 24 hours, single use | minted by the auctioneer |

    Every token is bound to one auction (its `aud`) and to one kind. A token of the wrong kind
    or for another auction answers 401. A token whose role may not do the thing answers 403.
    Tokens are opaque strings: do not parse them. A reviewer token can read the snapshot, the
    event log and the room ledger of its own judge room, and nothing else. Judge rooms need no
    key; the judge key only lifts the public limits when a host has one set (see
    `POST /api/judge/key`). The judge key is sent only in the `X-Judge-Key` header, never in a
    URL or body.

    Links the host returns carry the token in the URL fragment (`#token=`) so it never reaches
    a server log.

    ## Errors

    Every error is JSON. The fields vary slightly by route family because the host grew in
    layers; read them like this and you are safe on every route:

    - `message`: text for a person. Always present on `/api/**` errors.
    - `code`: the stable error code (see `ErrorCode`). Present on the core routes (auctions,
      control, invites, read, receipts, auctioneer tokens) and on judge routes.
    - `error`: the same stable code on the hold routes, the judge routes and the operator
      routes. Judge routes also send `code`. Operator routes send only `error`.
    - `detail`: optional extra text, for example the field paths that failed validation.

    A client should read `code ?? error`. See the `Error` schema.

    Messages never contain tokens or stack traces (they are scrubbed on the server).

    Status codes in use: 400 bad request, 401 missing or bad credential, 403 not allowed,
    404 not found, 409 conflict, 410 gone (a judge room that ended), 413 body over 64 KiB,
    415 body is not `application/json`, 429 rate limited, 500 internal, 503 unavailable.

    ## Rate limits

    Limits are published as the vendor extension `x-rate-limit` on the operations they apply
    to, and as `x-rate-limits` at the top of this document for the host-wide defaults. A 429
    always carries a `Retry-After` header in seconds. The numbers are the defaults and can be
    changed by the operator through environment settings.

    ## Request ids and CORS

    Send `X-Request-Id` (1 to 64 characters of `A-Z a-z 0-9 _ -`) to correlate a request;
    every response carries `X-Request-Id`, and the server mints one when you send none or an
    invalid one. Browsers are allowed only from the origins the operator lists; other origins
    get no CORS headers. Allowed request headers are `Authorization`, `Content-Type`,
    `X-Request-Id`, `X-Admin-Key` and `X-Judge-Key`; readable response headers are
    `X-Request-Id` and `Retry-After` (also on early 429, 404 and 500 answers).

    ## Not in this document

    - **WebSocket protocol.** Live bidding runs on `wss://<host>/ws` and OpenAPI cannot model
      it. See the section "WebSocket protocol" below and
      https://github.com/harshbhx/gavel-auction/blob/main/docs/API.md.
    - **PayPal webhook.** `POST /webhooks/paypal` is called by PayPal only, is verified by
      PayPal's signature, and is not part of the client API.
    - **Demo routes.** `/api/demo/reset`, `/api/demo/seed` and `/api/demo/rooms` exist only
      when the host runs with `DEMO_MODE=1` on the fake payments adapter and never in a
      production-like configuration. They answer 404 otherwise.

    ## WebSocket protocol

    Endpoint: `wss://<host>/ws`. The browser must send an `Origin` that the operator allows;
    a request without one is refused. A plain HTTP request to `/ws` answers 426. Frames are
    JSON text, at most 16 KiB from the client (a larger or binary frame closes the socket) and
    at most 256 KiB from the server. Every frame is `{"v":1,"t":"<type>", ...}`; an unknown
    type, extra field or wrong version is `bad_message` (a `join` with another version is
    `unsupported_version`).

    Client to server, the only three types accepted:

    | `t` | Purpose | Fields |
    | --- | --- | --- |
    | `join` | First frame. Opens the session. | `token` (session, auctioneer, screen or reviewer token; a reviewer joins with a screen's view), `auctionId`, optional `lastSeq` (resume after this sequence number), optional `client` ({name, version}) |
    | `bid` | Place a bid. | `mid` (id you mint, for idempotency), `amount` (cents), `source` (`voice`, `qr` or `manual`), optional `bidderId`, `transcript`, `confidence`, `ambiguous`, `audioTsMs`, `audioEndMs`, `clientTs` |
    | `confirm_response` | Answer a spoken-bid confirmation. | `confirmId`, `accept` |

    Server to client:

    | `t` | Purpose |
    | --- | --- |
    | `registered` | Your bidder id, paddle and role after `join`. |
    | `state` | Full room snapshot (same shape as `RoomSnapshot`), sent after `join` and when resyncing. |
    | `price` | New price, leader, end time and next minimum. |
    | `bid_accepted` / `bid_rejected` | Result of your `bid` (match on `mid`); a rejection carries `reason` (an `ErrorCode`), `retryable` and optional `retryAfterMs`. |
    | `confirm_request` | A spoken bid needs the bidder's yes or no. |
    | `hammer` | Lot ended: winner, price, ranking. |
    | `settlement_update` | Settlement progress (holds captured or released). |
    | `audit_event` | One audit row, filtered by role. |
    | `error` | `code`, `message`, optional `retryAfterMs`; may close the socket. |

    Socket close codes follow `ErrorCode`: 1008 bad message, 4002 unsupported version,
    4003 not joined, 4401 unauthorized, 4009 room or auction full, 4010 conflict or host
    fenced, 4429 rate limited (frame flood), 1011 internal. Limits: bids 3 per second (burst
    5), frames 20 per second (burst 40), at most 20 open sockets per source address.

    The full protocol, with every field, is in the repository file
    `docs/specs/realtime/SPEC-realtime.md` and on the docs site page "WebSocket protocol".
  contact:
    name: GAVEL on GitHub
    url: https://github.com/harshbhx/gavel-auction
  license:
    name: MIT
    identifier: MIT
servers:
  - url: https://gavel-auction-z2nm.onrender.com
    description: Live sandbox host (PayPal sandbox only).
  - url: http://localhost:8080
    description: Local host for development.
tags:
  - name: Auctions
    description: Create an auction and read its public facts.
  - name: Bidding access
    description: Invites, join and session tokens.
  - name: Control
    description: Auctioneer controls and the Stop button.
  - name: Read
    description: Snapshot and event log, filtered by the caller's role.
  - name: Holds
    description: PayPal hold orders for a bidder session.
  - name: Judge rooms
    description: Key-less sandbox rooms for reviewers.
  - name: Receipts
    description: Shareable read-only receipts.
  - name: Export
    description: Event log and ledger export.
  - name: Health
    description: Liveness, readiness and build version.
  - name: Operator
    description: Routes that need the operator key.
x-rate-limits:
  http:
    description: >-
      Per source address, applied to every write (POST, DELETE) on every route except
      /healthz, /readyz, /version and /webhooks/**. Token bucket.
    perMinute: 60
    burst: 60
    setting: RATE_HTTP_PER_MIN, RATE_HTTP_BURST
  httpRead:
    description: >-
      Per source address, applied to reads (GET, HEAD, OPTIONS). Looser, so judges who share
      one address can browse. Same exemptions as `http`.
    perMinute: 300
    burst: 300
    setting: RATE_HTTP_READ_PER_MIN, RATE_HTTP_READ_BURST
  join:
    description: Per source address on POST /api/auctions/{id}/join. Token bucket.
    perMinute: 10
    burst: 40
    setting: RATE_JOIN_PER_MIN, RATE_JOIN_BURST
  codeLookup:
    description: >-
      Guess protection for the 6 character join code on GET /api/auctions/{id} and the judge
      join route. Counted per source address.
    judgeJoinAttemptsPerMinute: 10
    codeLookupAttemptsPerMinute: 30
    wrongCodesBeforeLockout: 5
    lockoutMinutes: 15
    note: A wrong judge key has a lockout of its own (5 wrong, 15 minutes, per source address); it never blocks joins.
  judgeKey:
    description: Creates made with a valid judge key, counted for the whole host.
    createsPerHour: 100
    liveRoomsMax: 10
    setting: JUDGE_KEY_CREATES_PER_HOUR, JUDGE_KEY_ROOMS_MAX
  websocket:
    bidsPerSecond: 3
    bidBurst: 5
    framesPerSecond: 20
    frameBurst: 40
    openSocketsPerSource: 20
security: []
paths:
  /api/auctions:
    post:
      tags: [Auctions]
      operationId: createAuction
      summary: Create an auction
      description: |
        Operator only. Creates the auction, reserves a 6 character join code, and returns links
        for the auctioneer console and the projector screen. The tokens are inside the links
        (fragment `#token=`), valid 12 hours.
      security:
        - adminKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAuctionRequest'
            example:
              title: Charity quilt
              config:
                mode: english
                currency: USD
                reserve: 10000
                minIncrement: 500
                endTs: 1767225600000
                softCloseWindowMs: 30000
                softCloseExtensionMs: 30000
                maxExtensions: 5
                ceilingPresets: [10000, 25000, 50000]
                confirmConfidenceThreshold: 0.85
              fundraiserGoalCents: 500000
      responses:
        '201':
          description: Auction created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateAuctionResponse'
              example:
                auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
                joinCode: K7M2QX
                lotTitle: Charity quilt
                auctioneerLink: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/console#token=AUCTIONEER_TOKEN
                screenLink: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/screen#token=SCREEN_TOKEN
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
      x-rate-limit:
        scope: source address
        ref: '#/x-rate-limits/http'
  /api/auctions/{id}:
    get:
      tags: [Auctions]
      operationId: getAuction
      summary: Public facts of an auction
      description: |
        No credential. `id` is the auction id or its join code (any case; spaces, dashes and a
        `GVL-` prefix are ignored). Looking up by code is guess-protected: after 5 wrong codes
        a source address is locked out for 15 minutes (429 `code_locked`).
      parameters:
        - $ref: '#/components/parameters/AuctionIdOrCode'
      responses:
        '200':
          description: The auction.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuctionPublic'
              example:
                auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
                title: Sandbox demo lot
                joinCode: K7M2QX
                judgeRoom: true
                status: OPEN
                endTs: 1767225600000
                bidderCount: 2
                currentCents: 1500
                leaderPaddle: 2
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
      x-rate-limit:
        scope: source address
        ref: '#/x-rate-limits/codeLookup'
  /api/auctions/{id}/auctioneer/rotate:
    post:
      tags: [Auctions]
      operationId: rotateAuctioneerToken
      summary: Rotate the auctioneer link
      description: |
        Operator only. Kills every auctioneer token issued before now and returns a fresh link.
        A leaked auctioneer link cannot do this, by design.
      security:
        - adminKey: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: New auctioneer link.
          content:
            application/json:
              schema:
                type: object
                required: [auctioneerLink]
                properties:
                  auctioneerLink:
                    type: string
                    format: uri
              example:
                auctioneerLink: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/console#token=AUCTIONEER_TOKEN
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/auctioneer/revoke-all:
    post:
      tags: [Auctions]
      operationId: revokeAllAuctioneerTokens
      summary: Revoke every auctioneer token
      description: Operator only. Kills every auctioneer token of the auction and issues no new one.
      security:
        - adminKey: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          $ref: '#/components/responses/Revoked'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/auctioneer/revoke:
    post:
      tags: [Auctions]
      operationId: revokeAuctioneerToken
      summary: Revoke one auctioneer token by jti
      description: Operator only. For a lost or leaked link when you know its token id.
      security:
        - adminKey: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [jti]
              properties:
                jti:
                  $ref: '#/components/schemas/Ulid'
            example:
              jti: 01JC5ZQ0A1B2C3D4E5F6G7H8J9
      responses:
        '200':
          $ref: '#/components/responses/Revoked'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/control:
    post:
      tags: [Control]
      operationId: controlAuction
      summary: Run an auctioneer control
      description: |
        Auctioneer token only (a session or screen token authenticates but is refused with 403).
        Idempotent by `controlId`: repeat the same id with the same action and you get the same
        answer. A `hammer` needs `args.closeNow: 1` while the lot is still open; it is refused
        with 409 when the price or leader changed since the console last looked.
      security:
        - auctioneerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ControlRequest'
            examples:
              pause:
                value:
                  controlId: ctl-001
                  action: pause
              extend:
                value:
                  controlId: ctl-002
                  action: extend
                  args:
                    extraMs: 60000
              hammer:
                value:
                  controlId: ctl-003
                  action: hammer
                  args:
                    closeNow: 1
      responses:
        '200':
          description: The control was applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ControlResponse'
              example:
                ok: true
                controlId: ctl-003
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/kill:
    post:
      tags: [Control]
      operationId: killAuction
      summary: Stop the lot (unsold hammer)
      description: |
        Auctioneer token only. Ends the lot as an unsold hammer so settlement voids every hold.
        A repeat, or a lot that is already over, is a no-op that answers 200 with the current
        state. The body must carry `confirm: "STOP"`.
      security:
        - auctioneerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [confirm]
              properties:
                controlId:
                  type: string
                  minLength: 1
                  maxLength: 64
                confirm:
                  type: string
                  const: STOP
            example:
              confirm: STOP
      responses:
        '200':
          description: The lot is stopped.
          content:
            application/json:
              schema:
                type: object
                required: [ok, state]
                properties:
                  ok:
                    type: boolean
                    const: true
                  controlId:
                    type: string
                  state:
                    type: object
                    required: [status, stopped, registrationClosed]
                    properties:
                      status:
                        $ref: '#/components/schemas/AuctionStatus'
                      stopped:
                        type: boolean
                      registrationClosed:
                        type: boolean
              example:
                ok: true
                state:
                  status: DONE
                  stopped: true
                  registrationClosed: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/invites:
    post:
      tags: [Bidding access]
      operationId: createInvites
      summary: Mint bidder invites
      description: |
        Auctioneer token only. Each invite is a single-use link, valid 24 hours, for one new
        bidder id. Between 1 and 50 per call.
      security:
        - auctioneerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                count:
                  type: integer
                  minimum: 1
                  maximum: 50
                  default: 1
            example:
              count: 2
      responses:
        '201':
          description: Invites minted.
          content:
            application/json:
              schema:
                type: object
                required: [invites]
                properties:
                  invites:
                    type: array
                    items:
                      $ref: '#/components/schemas/Invite'
              example:
                invites:
                  - bidderId: 01JC5ZR1B2C3D4E5F6G7H8J9K0
                    expiresAt: 1767312000000
                    link: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/register#token=INVITE_TOKEN
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/bidders/{bidderId}/invite:
    post:
      tags: [Bidding access]
      operationId: reissueInvite
      summary: Re-issue an invite for an existing bidder
      description: |
        Auctioneer token only. A bidder who lost their tab gets a fresh single-use invite for
        the same bidder id, so their hold and bids stay theirs. Answers 404 for a bidder who
        has no hold in the room.
      security:
        - auctioneerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
        - name: bidderId
          in: path
          required: true
          description: Id of the registered bidder to re-invite.
          example: 01J9ZZ0000000000000000000B
          schema:
            $ref: '#/components/schemas/Ulid'
      responses:
        '201':
          description: New invite.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Invite'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/join:
    post:
      tags: [Bidding access]
      operationId: joinAuction
      summary: Exchange an invite for a session token
      description: |
        No header credential: the invite token goes in the body. `id` may be the auction id or
        its join code. The invite is burned on use; a second use answers 409 `invite_used`.
        Returns the session token for bidding and the WebSocket address.
      parameters:
        - $ref: '#/components/parameters/AuctionIdOrCode'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [invite]
              properties:
                invite:
                  type: string
                  minLength: 1
                  maxLength: 2048
                  description: The invite token from an invite link.
            example:
              invite: INVITE_TOKEN
      responses:
        '200':
          description: Session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JoinResponse'
              example:
                sessionToken: SESSION_TOKEN
                bidderId: 01JC5ZR1B2C3D4E5F6G7H8J9K0
                wsUrl: wss://gavel-auction-z2nm.onrender.com/ws
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
      x-rate-limit:
        scope: source address
        ref: '#/x-rate-limits/join'
  /api/auctions/{id}/snapshot:
    get:
      tags: [Read]
      operationId: getSnapshot
      summary: Room snapshot for the caller's role
      description: |
        The same snapshot the WebSocket sends in a `state` frame, filtered by role: a bidder
        sees only their own ceiling and hold, a screen sees no private fields, the auctioneer
        sees the full audit rows. A reviewer token gets the screen's view.
      security:
        - auctioneerAuth: []
        - screenAuth: []
        - sessionAuth: []
        - reviewerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: The state frame.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StateFrame'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/events:
    get:
      tags: [Read]
      operationId: listEvents
      summary: Page through the event log
      description: |
        Auctioneer, screen or reviewer token (a reviewer sees what a screen sees). Events after sequence `after`, at most `limit`. A screen
        sees full rows only for public event types; every other event is a bare `{seq}` so the
        log stays whole. Ceilings, transcripts, hold references and the auctioneer subject are
        never returned. Pass `next` back as `after` to continue.
      security:
        - auctioneerAuth: []
        - screenAuth: []
        - reviewerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
        - name: after
          in: query
          description: Return events after this sequence number. Pass the `next` of the previous page to continue.
          example: 0
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: limit
          in: query
          description: Most events to return in one page.
          example: 100
          schema:
            type: integer
            minimum: 1
            maximum: 1000
            default: 100
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                type: object
                required: [events, next]
                properties:
                  events:
                    type: array
                    items:
                      $ref: '#/components/schemas/RoomEvent'
                  next:
                    type: integer
                    minimum: 0
              example:
                events:
                  - seq: 1
                    type: auction_created
                next: 1
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/receipt-link:
    post:
      tags: [Receipts]
      operationId: createReceiptLink
      summary: Make a shareable receipt link
      description: |
        Session token only. Returns a receipt token (valid 4 hours) that opens exactly one
        read-only route, `GET /api/auctions/{id}/receipt`, for this bidder.
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: Receipt link.
          content:
            application/json:
              schema:
                type: object
                required: [token, link, expiresAt]
                properties:
                  token:
                    type: string
                  link:
                    type: string
                    format: uri
                  expiresAt:
                    $ref: '#/components/schemas/EpochMs'
              example:
                token: RECEIPT_TOKEN
                link: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/receipt#token=RECEIPT_TOKEN
                expiresAt: 1767240000000
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/receipt:
    get:
      tags: [Receipts]
      operationId: getReceipt
      summary: Read a receipt
      description: |
        Receipt token only. A cut-down snapshot: the lot, the hammer outcome, the viewer's own
        roster row and own hold. The winner keeps a paddle but loses the name, every other
        bidder is dropped, and the ceiling, the join code and the audit rows are removed.
      security:
        - receiptAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: The receipt.
          content:
            application/json:
              schema:
                type: object
                required: [bidderId, snapshot, expiresAt]
                properties:
                  bidderId:
                    $ref: '#/components/schemas/Ulid'
                  snapshot:
                    $ref: '#/components/schemas/RoomSnapshot'
                  expiresAt:
                    $ref: '#/components/schemas/EpochMs'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/auctions/{id}/export:
    get:
      tags: [Export]
      operationId: exportEvents
      summary: Stream the event log as NDJSON
      description: |
        Auctioneer token or operator key. One JSON object per line, in sequence order, with
        personal fields removed for everyone but the operator. If the store has a gap the
        connection is cut instead of ending quietly, so a short file never looks complete.
        This route is served by the host directly: its errors are `{"error": "<code>"}` only.
      security:
        - auctioneerAuth: []
        - adminKey: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: NDJSON stream.
          content:
            application/x-ndjson:
              schema:
                type: string
                description: One RoomEvent JSON object per line.
              example: |
                {"seq":1,"type":"auction_created"}
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/holds:
    post:
      tags: [Holds]
      operationId: createHold
      summary: Create a PayPal hold order
      description: |
        Session token only. The bidder is the one in the token; a body that names a bidder is
        rejected. The ceiling must be one of the auction's offered presets. Errors on the hold
        routes are `{error, message}` with the codes in `HoldErrorCode`.

        <!-- FEAT-61 begin: raise a limit -->
        A bidder who already has a HELD hold can raise the limit with this same route: send a
        `ceilingCents` above the held one and up to the server cap. The answer is a new order
        that replaces the old hold once it is approved; the old authorization is voided only
        after the room has switched to the new one, so the bidder always has a hold. The same
        request twice returns the same order. A ceiling that is not higher answers 409
        `hold_exists`; one above the cap answers 400 `ceiling_out_of_range`; once the lot is
        hammered the route answers 409 `closed`. Still held, not charged (sandbox only).
        <!-- FEAT-61 end -->
      security:
        - sessionAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [auctionId, ceilingCents]
              properties:
                auctionId:
                  $ref: '#/components/schemas/Ulid'
                ceilingCents:
                  type: integer
                  minimum: 1
                  description: Highest amount this bidder allows, in cents.
            example:
              auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
              ceilingCents: 25000
      responses:
        '201':
          description: Hold order created; approve it with PayPal, then call the approve route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateHoldResponse'
              example:
                holdId: hold_01JC5ZS2C3D4E5F6G7H8J9K0M1
                orderId: ORDER-EXAMPLE
                approveUrl: https://www.sandbox.paypal.com/checkoutnow?token=ORDER-EXAMPLE
        '400':
          $ref: '#/components/responses/HoldBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/HoldNotFound'
        '409':
          $ref: '#/components/responses/HoldConflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/HoldUnavailable'
  /api/holds/{holdId}/approve:
    post:
      tags: [Holds]
      operationId: approveHold
      summary: Authorize an approved hold order
      description: |
        Session token of the hold's owner. Another bidder's hold answers 404, exactly like an
        unknown hold. When `name` is sent, the bidder is also registered in the room once the
        hold is authorized (idempotent).
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/HoldId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [orderId]
              properties:
                orderId:
                  type: string
                  minLength: 1
                name:
                  $ref: '#/components/schemas/BidderName'
            example:
              orderId: ORDER-EXAMPLE
              name: Asha
      responses:
        '200':
          description: Hold authorized.
          content:
            application/json:
              schema:
                type: object
                required: [holdId, status, ceilingCents]
                properties:
                  holdId:
                    type: string
                  status:
                    type: string
                    const: HELD
                  ceilingCents:
                    type: integer
                    minimum: 1
              example:
                holdId: hold_01JC5ZS2C3D4E5F6G7H8J9K0M1
                status: HELD
                ceilingCents: 25000
        '400':
          $ref: '#/components/responses/HoldBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/HoldNotFound'
        '409':
          $ref: '#/components/responses/HoldConflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          $ref: '#/components/responses/HoldUnavailable'
  /api/holds/{holdId}:
    get:
      tags: [Holds]
      operationId: getHold
      summary: Read your own hold
      description: Session token of the hold's owner. Anyone else's hold answers 404.
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/HoldId'
      responses:
        '200':
          description: The hold.
          content:
            application/json:
              schema:
                type: object
                required: [holdId, status, ceilingCents]
                properties:
                  holdId:
                    type: string
                  status:
                    $ref: '#/components/schemas/HoldStatus'
                  ceilingCents:
                    type: integer
                    minimum: 1
              example:
                holdId: hold_01JC5ZS2C3D4E5F6G7H8J9K0M1
                status: HELD
                ceilingCents: 25000
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/HoldNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
    delete:
      tags: [Holds]
      operationId: abandonHold
      summary: Abandon an unapproved hold
      description: |
        Session token of the hold's owner. Idempotent. A hold that is already authorized is
        not released: it answers 409 `hold_exists`.
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/HoldId'
      responses:
        '200':
          description: Released (now or before).
          content:
            application/json:
              schema:
                type: object
                required: [holdId, released]
                properties:
                  holdId:
                    type: string
                  released:
                    type: boolean
                    const: true
              example:
                holdId: hold_01JC5ZS2C3D4E5F6G7H8J9K0M1
                released: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/HoldNotFound'
        '409':
          $ref: '#/components/responses/HoldConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/judge/capabilities:
    get:
      tags: [Judge rooms]
      operationId: getJudgeCapabilities
      summary: What judge rooms this host offers
      description: No credential. Tells a client whether it can create a room right now.
      responses:
        '200':
          description: Capabilities.
          content:
            application/json:
              schema:
                type: object
                required: [judgeRooms, accepting, simHolds]
                properties:
                  judgeRooms:
                    type: boolean
                    description: Judge rooms are switched on for this host.
                  accepting:
                    type: boolean
                    description: Rooms are on, not paused and under the live-room cap.
                  simHolds:
                    type: boolean
                    description: The simulated-hold route is available.
                  keyed:
                    type: boolean
                    const: true
                    description: Present and true only when the host has a judge key set.
              example:
                judgeRooms: true
                accepting: true
                simHolds: true
                keyed: true
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/judge/rooms:
    post:
      tags: [Judge rooms]
      operationId: createJudgeRoom
      summary: Create a judge room
      description: |
        No credential needed. Creates a short sandbox auction (10 minute lot, small ceilings)
        that ends after 30 minutes. The creator gets auctioneer, screen and reviewer tokens for
        that room only; no operator route opens. Answers 404 when judge rooms are off.

        With the optional `X-Judge-Key` header the request is judged by the key alone: the
        public limits do not apply, the room does not use a public slot, and a wrong key is
        401 `judge_key_invalid` (it never falls back to the public path). An empty header is
        401 `judge_key_invalid` without a strike. Wrong keys have a lockout of their own, separate from the room-code lockout:
        5 wrong keys, 15 minutes, per source address (429 `judge_key_locked`); it has no effect
        on joins. The response then carries `unlocked: true`.
      security:
        - {}
        - judgeKey: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                title:
                  type: string
                  minLength: 1
                  maxLength: 60
                  default: Sandbox demo lot
                fundraiserGoalCents:
                  type: integer
                  minimum: 1
                  maximum: 100000000000
                  description: Optional goal; the projector shows a thermometer only when it is set.
            example:
              title: Sandbox demo lot
      responses:
        '201':
          description: Room created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JudgeRoom'
              example:
                auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
                joinCode: K7M2QX
                lotTitle: Sandbox demo lot
                auctioneerToken: AUCTIONEER_TOKEN
                screenToken: SCREEN_TOKEN
                auctioneerLink: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/console#token=AUCTIONEER_TOKEN
                screenLink: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/screen#token=SCREEN_TOKEN
                reviewerToken: REVIEWER_TOKEN
                reviewerLink: https://gavel-web.example/r/01JC5ZP4M7Q8R9S0T1V2W3X4Y5/audit#token=REVIEWER_TOKEN
                expiresAt: 1767227400000
                biddingEndsAt: 1767226200000
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/JudgeKeyInvalid'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          description: >-
            Too many creates from this address or from the host, or from this key
            (`judge_rate_limited`; `Retry-After` is the time until the oldest create leaves the
            hour window), the live-room cap is reached (`judge_room_cap`, 60 s), a keyed create
            hit its own room cap (`judge_key_room_cap`; `Retry-After` is the time until the
            soonest keyed room ends), or the source is locked out of keyed calls
            (`judge_key_locked`). The body is `{error, code, message}`; `code` repeats `error`.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: judge_rate_limited
                code: judge_rate_limited
                message: Too many tries from this network. Wait a few minutes and try again.
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          description: >-
            Judge rooms are paused (`judge_rooms_paused`, retry after 60 s), or the host-wide
            bound on keyed rooms or keyed creates per hour is reached (`judge_host_busy`;
            `Retry-After` is the time until a keyed room ends or the hour window frees a slot).
            Unlike `judge_key_room_cap`, this is not the caller's own limit.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: judge_rooms_paused
                code: judge_rooms_paused
                message: Demo rooms are paused right now. Try again in a minute.
      x-rate-limit:
        scope: source address and whole host
        perSourcePerHour: 12
        hostPerHour: 40
        liveRoomsMax: 15
        roomLifetimeMinutes: 30
        keyedCreatesPerHour: 100
        keyedLiveRoomsMax: 10
        settings: JUDGE_ROOMS_PER_IP_HOUR, JUDGE_ROOMS_PER_HOUR, JUDGE_ROOMS_MAX, JUDGE_ROOM_TTL_MS, JUDGE_KEY_ROOMS_MAX, JUDGE_KEY_CREATES_PER_HOUR
  /api/judge/key:
    post:
      tags: [Judge rooms]
      operationId: checkJudgeKey
      summary: Check a judge key
      description: |
        Send the key in the `X-Judge-Key` header, with no body. Creates nothing; it lets a
        panel say whether the key works. A wrong key is 401 `judge_key_invalid` and counts
        toward a lockout of its own, separate from the room-code lockout (5 wrong keys, 15
        minutes, per source address: 429 `judge_key_locked`, even with the right key; joins are
        not affected). A blank key is 401 without a strike. When the host has no judge key set,
        every key answers as wrong. Answers 404 when judge rooms are off.
      security:
        - judgeKey: []
      responses:
        '200':
          description: The key is accepted.
          content:
            application/json:
              schema:
                type: object
                required: [ok, roomsMax, roomsLive]
                properties:
                  ok:
                    type: boolean
                    const: true
                  keyId:
                    type: string
                    description: The id of the key (`legacy` for the shared host key). Never the secret.
                  roomsMax:
                    type: integer
                    description: Most rooms this key may keep alive at once.
                  roomsLive:
                    type: integer
                    description: Rooms of this key alive now.
                  expiresAt:
                    type: string
                    format: date-time
                    description: When the key stops working. Absent for the legacy shared key.
              example:
                ok: true
                keyId: a1b2c3d4
                roomsMax: 2
                roomsLive: 0
                expiresAt: '2026-11-30T23:59:00Z'
        '401':
          $ref: '#/components/responses/JudgeKeyInvalid'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
      x-rate-limit:
        scope: source address
        wrongKeysBeforeLockout: 5
        lockoutMinutes: 15
  /api/judge/rooms/{id}/ledger:
    get:
      tags: [Judge rooms, Export]
      operationId: getJudgeRoomLedger
      summary: Verifiable ledger of a judge room
      description: |
        Reviewer or auctioneer token of that room. The room's hash-chained ledger as a replay
        bundle with the private fields removed. The private chain and the public chain are
        both checked before anything is sent: a broken chain answers 409 `ledger_unverified`
        instead of a bundle that looks fine. 404 before the ledger has any entry, for an id
        that is not a judge room, or when judge rooms are off.
      security:
        - reviewerAuth: []
        - auctioneerAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: The bundle.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReplayBundle'
              example:
                version: 1
                exportVersion: 4
                auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
                generatedAt: 1767226300000
                count: 0
                headHash: null
                events: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The ledger does not verify (`ledger_unverified`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: ledger_unverified
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/judge/join:
    post:
      tags: [Judge rooms]
      operationId: joinJudgeRoom
      summary: Join a judge room with its code
      description: |
        No credential. A bidder joins a judge room with its 6 character code and a display
        name and gets a session token. Only judge rooms: the code of any other auction answers
        exactly like an unknown code (404). Wrong codes are counted; 5 wrong codes lock a
        source address out for 15 minutes (429 `judge_join_locked`). At most 20 bidders join
        one room (429 `judge_room_full`). An ended room answers 410.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [code, displayName]
              properties:
                code:
                  type: string
                  minLength: 1
                  maxLength: 40
                  description: The join code, in any case; spaces, dashes and a GVL- prefix are ignored.
                displayName:
                  $ref: '#/components/schemas/BidderName'
            example:
              code: gvl-k7m 2qx
              displayName: Asha
      responses:
        '200':
          description: Joined.
          content:
            application/json:
              schema:
                type: object
                required: [auctionId, sessionToken, bidderId, displayName, wsUrl]
                properties:
                  auctionId:
                    $ref: '#/components/schemas/Ulid'
                  sessionToken:
                    type: string
                  bidderId:
                    $ref: '#/components/schemas/Ulid'
                  displayName:
                    type: string
                  wsUrl:
                    type: string
                    format: uri
              example:
                auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
                sessionToken: SESSION_TOKEN
                bidderId: 01JC5ZR1B2C3D4E5F6G7H8J9K0
                displayName: Asha
                wsUrl: wss://gavel-auction-z2nm.onrender.com/ws
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          $ref: '#/components/responses/Gone'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
      x-rate-limit:
        scope: source address
        attemptsPerMinute: 10
        wrongCodesBeforeLockout: 5
        lockoutMinutes: 15
        biddersPerRoom: 20
  /api/judge/rooms/{id}/sim-hold:
    post:
      tags: [Judge rooms]
      operationId: createSimulatedHold
      summary: Place a simulated hold in a judge room
      description: |
        Session token of a bidder in that judge room. Writes an already-authorized hold with
        no PayPal call and puts the bidder in the room, so the whole path can be tried without
        a PayPal login. The ceiling is any whole number of cents from 100 up to the room's
        ceiling cap (the top preset); the presets are only quick picks, and a value outside the
        range is 400 `bad_message`. Any other auction answers 404. Answers 404 when simulated
        holds are off.
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ceilingCents, name]
              properties:
                ceilingCents:
                  type: integer
                  minimum: 100
                  description: Whole cents, at least 100 and at most the room's ceiling cap.
                name:
                  $ref: '#/components/schemas/BidderName'
            example:
              ceilingCents: 5000
              name: Asha
      responses:
        '200':
          description: Simulated hold placed.
          content:
            application/json:
              schema:
                type: object
                required: [holdId, simulated, ceilingCents]
                properties:
                  holdId:
                    type: string
                  simulated:
                    type: boolean
                    const: true
                  ceilingCents:
                    type: integer
              example:
                holdId: sim-hold-01JC5ZR1B2C3D4E5F6G7H8J9K0
                simulated: true
                ceilingCents: 5000
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '410':
          $ref: '#/components/responses/Gone'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/judge/pause:
    post:
      tags: [Judge rooms, Operator]
      operationId: pauseJudgeRooms
      summary: Pause or resume judge-room creation
      description: Operator only. A global kill switch for the create route; the flag survives a restart.
      security:
        - adminKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [paused]
              properties:
                paused:
                  type: boolean
            example:
              paused: true
      responses:
        '200':
          description: New state.
          content:
            application/json:
              schema:
                type: object
                required: [paused]
                properties:
                  paused:
                    type: boolean
              example:
                paused: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  # --- FEAT-60 judge API keys: begin (separate block, keep edits here apart from lint passes) ---
  /api/judge/rooms/{id}/bids:
    post:
      tags: [Judge rooms]
      operationId: placeJudgeBid
      summary: Place a bid in a judge room over HTTP
      description: |
        Bid with plain HTTP, for curl and generated clients that cannot open the WebSocket (the
        socket needs a browser `Origin`; this route does not, and the socket rules are
        unchanged). Send the session token from `POST /api/judge/join` as a bearer token, after
        the bidder has a hold (`POST /api/judge/rooms/{id}/sim-hold`). The bid takes the same
        path as a socket bid: the per-bidder bid limit, the registered check and the auction
        engine all apply. `amount` is whole cents. `mid` is optional; send the same `mid` again
        to retry safely, the room answers with the original result and records nothing twice.

        A bid the engine refuses (too low, over your ceiling, lot closed) is 409 with
        `accepted: false` and a `reason`. Sending a `mid` again with a different `amount` is 409
        `conflict` (`{error, code, message}`). Bids sent too fast are 429 `rate_limited` with
        `Retry-After`. When the room cannot take the bid (`auction_full`, `store_unavailable`,
        `room_full`) the answer is 503. Only session tokens work here, and only for judge
        rooms: any other auction answers 404. A judge key is not needed on this route.
      security:
        - sessionAuth: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [amount]
              properties:
                amount:
                  type: integer
                  minimum: 1
                  maximum: 99999999999
                  description: Bid in cents.
                mid:
                  type: string
                  pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
                  description: Optional message id (ULID) that makes a retry safe.
            example:
              amount: 1500
      responses:
        '200':
          description: The bid was accepted.
          content:
            application/json:
              schema:
                type: object
                required: [accepted, mid, bidId, amount, seq]
                properties:
                  accepted:
                    type: boolean
                    const: true
                  mid:
                    type: string
                  bidId:
                    type: string
                  amount:
                    type: integer
                    description: The accepted bid in cents.
                  seq:
                    type: integer
                    description: Position of the bid in the room's event log.
              example:
                accepted: true
                mid: 01JC5ZR1B2C3D4E5F6G7H8J9K0
                bidId: 01JC5ZR1B2C3D4E5F6G7H8J9K1
                amount: 1500
                seq: 7
        '202':
          description: The room is holding the bid for the bidder to confirm; nothing was placed yet.
          content:
            application/json:
              schema:
                type: object
                required: [accepted, pending, mid]
                properties:
                  accepted:
                    type: boolean
                    const: false
                  pending:
                    type: boolean
                    const: true
                  mid:
                    type: string
              example:
                accepted: false
                pending: true
                mid: 01JC5ZR1B2C3D4E5F6G7H8J9K0
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The engine refused the bid (`bid_rejected`: `accepted` is false and `reason` says
            why), or the same `mid` was reused with a different amount (`conflict`).
          content:
            application/json:
              schema:
                anyOf:
                  - type: object
                    required: [accepted, error, mid, reason, retryable]
                    properties:
                      accepted:
                        type: boolean
                        const: false
                      error:
                        type: string
                        const: bid_rejected
                      mid:
                        type: string
                      reason:
                        $ref: '#/components/schemas/ErrorCode'
                      retryable:
                        type: boolean
                  - $ref: '#/components/schemas/Error'
              examples:
                rejected:
                  summary: The engine refused the bid
                  value:
                    accepted: false
                    error: bid_rejected
                    mid: 01JC5ZR1B2C3D4E5F6G7H8J9K0
                    reason: below_increment
                    retryable: false
                conflict:
                  summary: The same mid with another amount
                  value:
                    error: conflict
                    code: conflict
                    message: That bid id was already used with a different amount. Send a new bid id.
        '410':
          $ref: '#/components/responses/Gone'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          description: >-
            The room could not take the bid (`auction_full`, `store_unavailable`, `room_full`).
            Retry after a moment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: store_unavailable
                code: store_unavailable
                message: Bid refused
  /api/judge/keys:
    get:
      tags: [Judge rooms, Operator]
      operationId: listJudgeKeys
      summary: List judge keys
      description: |
        Operator only (`X-Admin-Key`). Lists every judge key by id with its label, limits and
        state. The secret and its hash are never returned. A judge key on this route is 401.
      security:
        - adminKey: []
      responses:
        '200':
          description: The keys.
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      required: [id, label, maxRooms, perHour, roomsLive, revoked, expired]
                      properties:
                        id:
                          type: string
                        label:
                          type: string
                        maxRooms:
                          type: integer
                        perHour:
                          type: integer
                        roomsLive:
                          type: integer
                        revoked:
                          type: boolean
                        expired:
                          type: boolean
                        expiresAt:
                          type: string
                          format: date-time
              example:
                keys:
                  - id: a1b2c3d4
                    label: Asha
                    maxRooms: 2
                    perHour: 10
                    roomsLive: 1
                    revoked: false
                    expired: false
                    expiresAt: '2026-11-30T23:59:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /api/judge/keys/{keyId}/revoke:
    post:
      tags: [Judge rooms, Operator]
      operationId: revokeJudgeKey
      summary: Revoke a judge key
      description: |
        Operator only (`X-Admin-Key`). The key is refused from now on (401 `judge_key_revoked`)
        and stays revoked after a restart. Rooms it already opened run until they end. A judge
        key on this route is 401.
      security:
        - adminKey: []
      parameters:
        - name: keyId
          in: path
          required: true
          description: The 8 character id inside the key (`gvl_judge_<id>_<secret>`), or `legacy`.
          schema:
            type: string
      responses:
        '200':
          description: The key is revoked.
          content:
            application/json:
              schema:
                type: object
                required: [id, revoked]
                properties:
                  id:
                    type: string
                  revoked:
                    type: boolean
                    const: true
              example:
                id: a1b2c3d4
                revoked: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '503':
          description: >-
            The revoke could not be saved (`store_unavailable`). Nothing changed and the key is
            still usable; try again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: store_unavailable
                code: store_unavailable
                message: the revoke could not be saved, try again
  # --- FEAT-60 judge API keys: end ---
  /healthz:
    get:
      tags: [Health]
      operationId: getHealth
      summary: Liveness
      description: Always 200 while the process runs. Never rate limited.
      responses:
        '200':
          description: Alive.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
              example:
                status: ok
  /readyz:
    get:
      tags: [Health]
      operationId: getReadiness
      summary: Readiness
      description: 200 when every migration is applied and the store answers; 503 otherwise. Never rate limited.
      responses:
        '200':
          description: Ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
              example:
                status: ok
        '503':
          description: Not ready.
          content:
            application/json:
              schema:
                type: object
                required: [status, reason]
                properties:
                  status:
                    type: string
                    const: unavailable
                  reason:
                    type: string
                    enum: [migrations, store]
              example:
                status: unavailable
                reason: store
  /version:
    get:
      tags: [Health]
      operationId: getVersion
      summary: Deployed build
      description: The commit the running build was made from, or `unknown`. Never rate limited.
      responses:
        '200':
          description: Build sha.
          content:
            application/json:
              schema:
                type: object
                required: [sha]
                properties:
                  sha:
                    type: string
              example:
                sha: '1e516633'
  /warmup:
    get:
      tags: [Health]
      operationId: warmup
      summary: Warm the host's dependencies
      description: No credential. Runs the warm-up steps in parallel within a 3 second budget; 200 when all are warm, 503 otherwise.
      responses:
        '200':
          description: All steps warm.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WarmupReport'
        '503':
          description: A step failed or the budget ran out.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WarmupReport'
        '429':
          $ref: '#/components/responses/RateLimited'
  /admin/export:
    get:
      tags: [Operator, Export]
      operationId: adminExport
      summary: Private ledger and payment state export
      description: |
        Operator only. Output carries every bidder's ceiling, hold reference and bid
        transcript: never publish it. Modes by query: `auction=<id>` (the hash-chained ledger
        as NDJSON, last line `{head}`); `list=1` (all auction ids); `kind=payments&auction=<id>`
        (settlement journal and holds); `kind=payments-global` (webhook event ids and kill
        switch); `kind=roomlog&auction=<id>` (room events as NDJSON, last line `{end}`).
        Secret-looking keys are replaced with `[redacted]` and webhook bodies are dropped.
        Errors are `{"error": "<code>"}`.
      security:
        - adminKey: []
      parameters:
        - name: auction
          in: query
          schema:
            type: string
            pattern: '^[0-9A-Za-z_-]{1,64}$'
          description: Auction id (`auctionId` is accepted as an alias).
        - name: list
          in: query
          description: Send `1` to list every auction id instead of exporting one auction.
          example: '1'
          schema:
            type: string
            const: '1'
        - name: kind
          in: query
          description: Which export to return. Without it, `auction` returns the ledger.
          example: payments
          schema:
            type: string
            enum: [payments, payments-global, roomlog]
      responses:
        '200':
          description: JSON or NDJSON depending on the mode.
          content:
            application/x-ndjson:
              schema:
                type: string
            application/json:
              schema:
                type: object
                additionalProperties: true
              example:
                auctions:
                  - 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
        '400':
          $ref: '#/components/responses/OperatorBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
        '501':
          description: The host does not support this export mode.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: payments_unsupported
  /admin/replay-bundle:
    get:
      tags: [Operator, Export]
      operationId: adminReplayBundle
      summary: Replay bundle of one auction
      description: |
        Operator only. A self-contained bundle of the auction's ledger for replay and
        verification. Query `auctionId` is required. 409 when the hash chain is broken.
      security:
        - adminKey: []
      parameters:
        - name: auctionId
          in: query
          required: true
          description: Id of the auction to bundle.
          example: 01J9ZZ0000000000000000000A
          schema:
            type: string
            pattern: '^[0-9A-Za-z_-]{1,64}$'
      responses:
        '200':
          description: The bundle (format defined in `packages/shared/src/replay-bundle.ts`).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/OperatorBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/Internal'
  /metrics:
    get:
      tags: [Operator]
      operationId: getMetrics
      summary: Metrics snapshot (JSON)
      description: |
        Operator only: this route is not public. Own limit of 60 requests per minute on top of
        the host-wide limit.
      security:
        - adminKey: []
      responses:
        '200':
          description: The collector snapshot.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '429':
          $ref: '#/components/responses/RateLimited'
      x-rate-limit:
        scope: whole route
        perMinute: 60
  /metrics/prometheus:
    get:
      tags: [Operator]
      operationId: getMetricsPrometheus
      summary: Metrics in Prometheus text format
      description: Operator only.
      security:
        - adminKey: []
      responses:
        '200':
          description: Prometheus exposition text.
          content:
            text/plain:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /ops/summary:
    get:
      tags: [Operator]
      operationId: getOpsSummary
      summary: Operator summary
      description: Operator only. Recent logs, settlement sagas, PayPal webhook outcomes and metrics.
      security:
        - adminKey: []
      responses:
        '200':
          description: Summary.
          content:
            application/json:
              schema:
                type: object
                required: [logs, sagas, paypal, metrics]
                properties:
                  logs:
                    type: array
                    items:
                      type: string
                  sagas:
                    type: array
                    items:
                      type: object
                      required: [auctionId, lotId, state]
                      properties:
                        auctionId:
                          type: string
                        lotId:
                          type: string
                        state:
                          type: string
                  paypal:
                    type: object
                    additionalProperties:
                      type: integer
                  metrics:
                    type: object
                    additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /ops/rooms:
    get:
      tags: [Operator]
      operationId: listOpsRooms
      summary: Rooms the host has loaded
      description: Operator only.
      security:
        - adminKey: []
      responses:
        '200':
          description: Loaded rooms.
          content:
            application/json:
              schema:
                type: object
                required: [rooms]
                properties:
                  rooms:
                    type: array
                    items:
                      type: object
                      required: [auctionId, seq, status, connections, mailboxDepth]
                      properties:
                        auctionId:
                          type: string
                        seq:
                          type: integer
                        status:
                          type: string
                        connections:
                          type: integer
                        mailboxDepth:
                          type: integer
              example:
                rooms:
                  - auctionId: 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
                    seq: 12
                    status: OPEN
                    connections: 3
                    mailboxDepth: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '429':
          $ref: '#/components/responses/RateLimited'
  /ops/auctions/{id}/void-all:
    post:
      tags: [Operator]
      operationId: opsVoidAll
      summary: Void every uncaptured hold of a hammered auction
      description: |
        Operator only. For example while the kill switch keeps a capture waiting. Audited with
        the actor `admin`. 409 `busy` while a settlement run holds the lease, 409
        `no_settlement` before the lot was hammered.
      security:
        - adminKey: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: Done.
          content:
            application/json:
              schema:
                type: object
                required: [status, holds]
                properties:
                  status:
                    type: string
                    const: done
                  holds:
                    type: object
                    additionalProperties:
                      type: string
                    description: Hold id to the outcome the payments package recorded.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '409':
          $ref: '#/components/responses/OperatorConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /ops/auctions/{id}/release-review:
    post:
      tags: [Operator]
      operationId: opsReleaseReview
      summary: Release the holds a settlement review keeps
      description: |
        Operator only. Releases the fallback candidates' holds of a settlement in review.
        409 with `busy`, `no_settlement` or `not_in_review`.
      security:
        - adminKey: []
      parameters:
        - $ref: '#/components/parameters/AuctionId'
      responses:
        '200':
          description: Released.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    const: released
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '405':
          $ref: '#/components/responses/MethodNotAllowed'
        '409':
          $ref: '#/components/responses/OperatorConflict'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  securitySchemes:
    adminKey:
      type: apiKey
      in: header
      name: X-Admin-Key
      description: >-
        The operator key, set by the person who runs the host. Compared in constant time. Never
        put it in a browser or a public repository. No real value appears in this document.
    auctioneerAuth:
      type: http
      scheme: bearer
      description: >-
        Auctioneer token (12 hours), carried in the auctioneer link fragment. Bound to one
        auction. Revoked by rotation.
    screenAuth:
      type: http
      scheme: bearer
      description: Screen token (12 hours) for the projector page. Read only, no private fields.
    reviewerAuth:
      type: http
      scheme: bearer
      description: >-
        Reviewer token (12 hours), returned by judge-room creation. Read only: the screen's
        view of the snapshot and event log, and the room ledger. Bound to one judge room.
    judgeKey:
      type: apiKey
      in: header
      name: X-Judge-Key
      description: >-
        A limited per-judge key, `gvl_judge_<id>_<secret>` (set by the host operator, never the
        operator key). Only the judge-room create and key-check routes read it; it never opens an
        operator route. It has an expiry, a room cap and an hourly cap of its own. No real value
        appears in this document.
    sessionAuth:
      type: http
      scheme: bearer
      description: >-
        Session (bidder) token (4 hours) returned by the join routes. Names one bidder in one
        auction.
    receiptAuth:
      type: http
      scheme: bearer
      description: >-
        Receipt token (4 hours) returned by the receipt-link route. Opens only
        GET /api/auctions/{id}/receipt.
  parameters:
    AuctionId:
      name: id
      in: path
      required: true
      description: Auction id.
      schema:
        $ref: '#/components/schemas/Ulid'
    AuctionIdOrCode:
      name: id
      in: path
      required: true
      description: Auction id, or its 6 character join code.
      schema:
        type: string
        examples:
          - 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
          - K7M2QX
    HoldId:
      name: holdId
      in: path
      required: true
      description: Id of the hold, as returned by the create-hold route.
      example: hold_01
      schema:
        type: string
        minLength: 1
  headers:
    RetryAfter:
      description: Seconds to wait before trying again.
      schema:
        type: integer
        minimum: 1
  schemas:
    Ulid:
      type: string
      pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'
      description: 26 character id (Crockford base 32).
      examples:
        - 01JC5ZP4M7Q8R9S0T1V2W3X4Y5
    EpochMs:
      type: integer
      description: Milliseconds since the Unix epoch.
    Cents:
      type: integer
      minimum: 0
      description: Money in integer US cents. There are no decimals anywhere in the API.
    BidderName:
      type: string
      minLength: 1
      maxLength: 64
      description: >-
        Display name. Control characters, invisible direction marks and angle brackets are
        refused.
    ErrorCode:
      type: string
      enum:
        - bad_message
        - unsupported_version
        - not_joined
        - unauthorized
        - forbidden
        - invite_used
        - rate_limited
        - room_full
        - auction_full
        - room_cap
        - unregistered
        - below_increment
        - over_ceiling
        - below_reserve
        - closed
        - already_leading
        - bad_amount
        - lot_not_open
        - ceiling_revoked
        - unconfirmed
        - not_higher_than_own_max
        - already_bid
        - limit_exceeded
        - auction_paused
        - store_unavailable
        - conflict
        - host_fenced
        - not_found
        - internal
      description: >-
        Stable codes. HTTP status per code: 400 bad_message, unsupported_version, not_joined,
        bad_amount; 401 unauthorized; 403 forbidden, unregistered, ceiling_revoked; 404
        not_found; 409 invite_used, closed, already_leading, lot_not_open, unconfirmed,
        already_bid, auction_paused, conflict, host_fenced; 422 below_increment, over_ceiling,
        below_reserve, not_higher_than_own_max, limit_exceeded (these arise on the socket, in
        `bid_rejected`); 429 rate_limited; 500 internal; 503 room_full, auction_full,
        room_cap, store_unavailable.
    Error:
      type: object
      description: >-
        The error body. `message` is always there. The stable code is in `code` on the core
        and judge routes and in `error` on the hold, judge and operator routes: read
        `code ?? error`. 413 and 415 use `bad_message`.
      properties:
        error:
          example: 'order_not_found'
          type: string
          description: Stable code (hold, judge and operator routes).
        code:
          example: 'bad_message'
          type: string
          description: Stable code (core and judge routes). One of ErrorCode.
        message:
          description: Text for a person. Never contains a token or a stack trace.
          example: 'invalid request body'
          type: string
        detail:
          example: 'title: Expected string, received number'
          type: string
          description: 'Optional extra text, for example the failing field path and reason.'
      anyOf:
        - required: [message, code]
        - required: [message, error]
        - required: [error]
      examples:
        - code: bad_message
          message: invalid request body
          detail: 'title: Expected string, received number'
        - error: order_not_found
          message: no such hold
        - error: unauthorized
    HoldErrorCode:
      type: string
      enum:
        - ceiling_out_of_range
        - hold_exists
        - order_not_found
        - order_not_approved
        - paypal_unavailable
        - kill_switch_engaged
        - payments_not_sandbox
      description: >-
        400 ceiling_out_of_range; 404 order_not_found; 409 hold_exists, order_not_approved;
        503 paypal_unavailable, kill_switch_engaged, payments_not_sandbox.
    Status:
      type: object
      required: [status]
      properties:
        status:
          type: string
          const: ok
          description: Always `ok` when the host answers.
    WarmupReport:
      type: object
      required: [ok, elapsedMs, steps]
      properties:
        ok:
          description: True when every warm-up step succeeded.
          example: true
          type: boolean
        elapsedMs:
          description: Total time of the warm-up in milliseconds.
          example: 420
          type: integer
        steps:
          description: One entry per warm-up step, in the order they ran.
          type: array
          items:
            type: object
            required: [name, ok, ms]
            properties:
              name:
                description: Name of the warm-up step.
                example: 'database'
                type: string
              ok:
                description: True when this step succeeded.
                example: true
                type: boolean
              ms:
                description: Time this step took in milliseconds.
                example: 120
                type: integer
    AuctionStatus:
      type: string
      description: Lifecycle state of the auction, from SCHEDULED to DONE.
      enum: [SCHEDULED, OPEN, PAUSED, CLOSING, SETTLING, DONE]
    HoldStatus:
      type: string
      enum: [PENDING_APPROVAL, HELD, CAPTURED, VOIDED, EXPIRED, DENIED, UNKNOWN]
    AuctionConfig:
      type: object
      description: Settings of one auction. Amounts are in cents, durations in milliseconds.
      additionalProperties: false
      required:
        - currency
        - reserve
        - minIncrement
        - endTs
        - softCloseWindowMs
        - softCloseExtensionMs
        - maxExtensions
        - ceilingPresets
        - confirmConfidenceThreshold
      properties:
        mode:
          description: Bidding mode. Defaults to english.
          type: string
          enum: [english, proxy, second_price]
          default: english
        currency:
          description: Currency of every amount. Only USD is accepted.
          type: string
          const: USD
        reserve:
          example: 500
          type: integer
          minimum: 100
          description: Reserve price in cents (at least 100).
        minIncrement:
          example: 100
          type: integer
          minimum: 1
          description: Smallest raise in cents.
        endTs:
          $ref: '#/components/schemas/EpochMs'
        softCloseWindowMs:
          example: 30000
          type: integer
          minimum: 0
          maximum: 600000
          description: A bid this close to the end extends the lot.
        softCloseExtensionMs:
          description: How long a soft close extends the lot, in milliseconds.
          example: 30000
          type: integer
          minimum: 0
          maximum: 600000
        maxExtensions:
          description: Most times the lot can be extended by soft close.
          example: 5
          type: integer
          minimum: 0
          maximum: 50
        ceilingPresets:
          example: [1000, 2500, 5000]
          type: array
          minItems: 1
          maxItems: 8
          items:
            type: integer
            minimum: 1
          description: Strictly ascending ceiling amounts in cents, none below the reserve.
        confirmConfidenceThreshold:
          example: 0.8
          type: number
          minimum: 0
          maximum: 1
          description: Spoken bids below this confidence need the bidder to confirm.
        autoHammerDelayMs:
          description: Milliseconds after closing begins before the host hammers the lot by itself. When absent, the auctioneer hammers by hand.
          example: 5000
          type: integer
          minimum: 0
        holdScope:
          description: Whether one hold covers the whole event or a single lot. The engine default is event.
          type: string
          enum: [event, lot]
        incrementLadder:
          example:
            [
              { 'fromCents': 0, 'incrementCents': 100 },
              { 'fromCents': 5000, 'incrementCents': 500 },
            ]
          type: array
          minItems: 1
          maxItems: 16
          description: Tiered increments. Must start at fromCents 0 and strictly ascend.
          items:
            type: object
            additionalProperties: false
            required: [fromCents, incrementCents]
            properties:
              fromCents:
                description: Price in cents from which this increment applies.
                example: 0
                type: integer
                minimum: 0
              incrementCents:
                description: Smallest raise in cents at this price tier.
                example: 100
                type: integer
                minimum: 1
    CreateAuctionRequest:
      type: object
      additionalProperties: false
      required: [config]
      properties:
        title:
          description: Lot title shown to bidders.
          example: 'Vintage typewriter'
          type: string
          minLength: 1
          maxLength: 120
          default: Untitled auction
        config:
          $ref: '#/components/schemas/AuctionConfig'
        fundraiserGoalCents:
          example: 500000
          type: integer
          minimum: 1
          maximum: 100000000000
          description: Optional goal; the projector shows a thermometer only when it is set.
    CreateAuctionResponse:
      type: object
      required: [auctionId, joinCode, lotTitle, auctioneerLink, screenLink]
      properties:
        auctionId:
          $ref: '#/components/schemas/Ulid'
        joinCode:
          $ref: '#/components/schemas/JoinCode'
        lotTitle:
          description: Title of the lot, as given in the request.
          example: 'Vintage typewriter'
          type: string
        auctioneerLink:
          description: Link to the auctioneer console. The auctioneer token is in the URL fragment.
          type: string
          format: uri
        screenLink:
          description: Link to the projector screen. The screen token is in the URL fragment.
          type: string
          format: uri
    JoinCode:
      type: string
      pattern: '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'
      description: Six letters and digits without look-alikes (no 0, O, 1, I, L).
    AuctionPublic:
      type: object
      required: [auctionId, title, status, endTs, bidderCount]
      properties:
        auctionId:
          $ref: '#/components/schemas/Ulid'
        title:
          description: Lot title.
          example: 'Vintage typewriter'
          type: string
        joinCode:
          $ref: '#/components/schemas/JoinCode'
        judgeRoom:
          example: true
          type: boolean
          description: Present and true for a judge room.
        registrationClosed:
          example: false
          type: boolean
          description: Present and true once the room no longer takes registrations.
        stopped:
          example: false
          type: boolean
          description: Present and true once the auctioneer ended the lot.
        status:
          $ref: '#/components/schemas/AuctionStatus'
        endTs:
          $ref: '#/components/schemas/EpochMs'
        bidderCount:
          description: Number of registered bidders.
          example: 3
          type: integer
          minimum: 0
        currentCents:
          $ref: '#/components/schemas/Cents'
          description: Current price in cents; absent until the first accepted bid.
        leaderPaddle:
          example: 7
          type: integer
          minimum: 1
          maximum: 9999
          description: Paddle number of the current leader; absent when there is none.
    ControlRequest:
      type: object
      additionalProperties: false
      required: [controlId, action]
      properties:
        controlId:
          example: 'ctl-0001'
          type: string
          minLength: 1
          maxLength: 64
          description: Idempotency key you mint. The same id with the same action is a repeat.
        action:
          description: The control to apply.
          type: string
          enum: [open, pause, resume, extend, hammer]
        args:
          example: { 'extraMs': 30000 }
          type: object
          additionalProperties:
            type: number
          description: >-
            `extend` needs `extraMs` (whole number, 1 to 600000). `hammer` needs `closeNow: 1`
            while the lot is open.
    ControlResponse:
      type: object
      required: [ok, controlId]
      properties:
        ok:
          description: Always true on success.
          type: boolean
          const: true
        controlId:
          description: The controlId you sent.
          example: 'ctl-0001'
          type: string
        hammer:
          type: object
          additionalProperties: true
          description: The hammer frame, present when the control ended the lot.
    Invite:
      type: object
      required: [bidderId, expiresAt, link]
      properties:
        bidderId:
          $ref: '#/components/schemas/Ulid'
        expiresAt:
          $ref: '#/components/schemas/EpochMs'
        link:
          type: string
          format: uri
          description: Registration link with the invite token in the fragment.
    JoinResponse:
      type: object
      required: [sessionToken, bidderId, wsUrl]
      properties:
        sessionToken:
          type: string
          description: Session token for `Authorization` and for the WebSocket `join` frame.
        bidderId:
          $ref: '#/components/schemas/Ulid'
        wsUrl:
          type: string
          format: uri
          description: WebSocket address, `wss://<host>/ws`.
    JudgeRoom:
      type: object
      required:
        - auctionId
        - joinCode
        - lotTitle
        - auctioneerToken
        - screenToken
        - auctioneerLink
        - screenLink
        - expiresAt
        - biddingEndsAt
      properties:
        auctionId:
          $ref: '#/components/schemas/Ulid'
        joinCode:
          $ref: '#/components/schemas/JoinCode'
        lotTitle:
          description: Title of the lot in the room.
          example: 'Sandbox lot'
          type: string
        auctioneerToken:
          description: Auctioneer token for this room (12 hours). Send it as a bearer token.
          type: string
        screenToken:
          description: Screen token for this room (12 hours). Read only.
          type: string
        auctioneerLink:
          description: Link to the auctioneer console with the token in the URL fragment.
          type: string
          format: uri
        screenLink:
          description: Link to the projector screen with the token in the URL fragment.
          type: string
          format: uri
        reviewerToken:
          type: string
          description: Read-only reviewer token for this room (12 hours).
        reviewerLink:
          type: string
          format: uri
          description: Audit page link with the reviewer token in the fragment.
        expiresAt:
          $ref: '#/components/schemas/EpochMs'
        biddingEndsAt:
          $ref: '#/components/schemas/EpochMs'
        unlocked:
          type: boolean
          const: true
          description: Present and true when the room was made with a valid judge key.
    ReplayBundle:
      type: object
      description: >-
        A verifiable export of the ledger (`packages/shared/src/replay-bundle.ts`). Hash with
        `verifyPublicExport` and compare `headHash`.
      required: [version, exportVersion, auctionId, generatedAt, count, headHash, events]
      properties:
        version:
          description: Version of the bundle format.
          example: 1
          type: integer
        exportVersion:
          description: Version of the export rules used to build the bundle.
          example: 1
          type: integer
        auctionId:
          description: Id of the exported auction.
          example: '01J9ZZ0000000000000000000A'
          type: string
        generatedAt:
          $ref: '#/components/schemas/EpochMs'
        count:
          example: 12
          type: integer
          minimum: 0
          description: Number of events; equals the last event seq.
        headHash:
          type:
            - string
            - 'null'
          description: Hash of the last public event, or null for an empty ledger.
        closed:
          example: true
          type: boolean
          description: True when the last event closes the auction and names the event before it.
        events:
          description: The public events in order, one object per event.
          type: array
          items:
            type: object
            additionalProperties: true
    CreateHoldResponse:
      type: object
      required: [holdId, orderId]
      properties:
        holdId:
          description: Id of the hold on this host.
          example: 'hold_01'
          type: string
        orderId:
          description: PayPal sandbox order id for the hold.
          example: '5O190127TN364715T'
          type: string
        approveUrl:
          type: string
          format: uri
          description: PayPal's own approval page, for when the popup cannot open.
    RoomEvent:
      type: object
      description: >-
        One logged event. `seq` is always there. The other fields depend on `type`
        (`auction_created`, `bidder_registered`, `ceiling_raised`, `bid_accepted`, `control`,
        `state_changed`, `hammered` and others); a screen sees only `seq` for non-public types.
      required: [seq]
      properties:
        seq:
          description: Position of the event in the ledger, starting at 1.
          example: 1
          type: integer
          minimum: 1
        type:
          description: Event type, for example auction_created or bid_accepted. Absent for non-public events seen by a screen.
          example: 'auction_created'
          type: string
      additionalProperties: true
    RoomSnapshot:
      type: object
      description: >-
        Room state for one viewer. Fields not listed here may appear; clients must ignore what
        they do not know. Private fields (`you`, `yourHold`, ceilings) appear only for the
        bidder they belong to.
      required: [auctionId, config, status, price, leaderId, endTs, minNext, roster, recentAudit]
      properties:
        auctionId:
          $ref: '#/components/schemas/Ulid'
        config:
          $ref: '#/components/schemas/AuctionConfig'
        status:
          $ref: '#/components/schemas/AuctionStatus'
        price:
          description: Current price in cents, or null before the first accepted bid.
          oneOf:
            - $ref: '#/components/schemas/Cents'
            - type: 'null'
        leaderId:
          description: Bidder id of the current leader, or null when there is none.
          oneOf:
            - $ref: '#/components/schemas/Ulid'
            - type: 'null'
        endTs:
          $ref: '#/components/schemas/EpochMs'
        minNext:
          $ref: '#/components/schemas/Cents'
        paused:
          description: True while the auctioneer has paused the lot.
          example: false
          type: boolean
        remainingMs:
          description: Milliseconds left until the lot ends.
          example: 45000
          type: integer
          minimum: 0
        stopped:
          description: True once the auctioneer used the Stop button.
          example: false
          type: boolean
        registrationClosed:
          description: True once the room no longer takes registrations.
          example: false
          type: boolean
        hammer:
          description: Result of the lot, present once it was hammered.
          type: object
          required: [winnerId, price]
          properties:
            winnerId:
              description: Bidder id of the winner, or null when the lot did not sell.
              oneOf:
                - $ref: '#/components/schemas/Ulid'
                - type: 'null'
            price:
              $ref: '#/components/schemas/Cents'
        lotTitle:
          description: Title of the lot.
          example: 'Vintage typewriter'
          type: string
        joinCode:
          $ref: '#/components/schemas/JoinCode'
        fundraiser:
          description: Fundraiser goal, present only when one was set.
          type: object
          required: [goalCents]
          properties:
            goalCents:
              description: Goal in cents.
              example: 500000
              type: integer
              minimum: 1
        roster:
          description: Registered bidders, at most 200.
          type: array
          maxItems: 200
          items:
            type: object
            required: [bidderId, paddle, name, hold]
            properties:
              bidderId:
                $ref: '#/components/schemas/Ulid'
              paddle:
                description: Paddle number of the bidder.
                example: 7
                type: integer
                minimum: 1
                maximum: 9999
              name:
                description: Display name of the bidder.
                example: 'Bidder 7'
                type: string
              hold:
                description: HELD when the bidder has a hold, otherwise NONE.
                type: string
                enum: [HELD, NONE]
        recentAudit:
          description: The most recent audit rows visible to this viewer, at most 50.
          type: array
          maxItems: 50
          items:
            type: object
            additionalProperties: true
        settlement:
          description: Settlement progress, present once the lot ended.
          type: object
          additionalProperties: true
        you:
          type: object
          additionalProperties: true
          description: The viewing bidder's own ceiling and high bid. Bidder sessions only.
      additionalProperties: true
    StateFrame:
      type: object
      description: The `state` frame of the WebSocket protocol, returned over HTTP.
      required: [v, t, snapshot]
      properties:
        v:
          description: Protocol version of the frame.
          type: integer
          const: 1
        t:
          description: Frame type. Always state.
          type: string
          const: state
        seq:
          description: Sequence number of the last event the snapshot includes.
          example: 12
          type: integer
        snapshot:
          $ref: '#/components/schemas/RoomSnapshot'
      additionalProperties: true
  responses:
    BadRequest:
      description: >-
        The body, query or path is not valid (`bad_message`). `detail` lists the failing fields.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: bad_message
            message: invalid request body
            detail: 'config.reserve: reserve must be at least 100 cents'
    OperatorBadRequest:
      description: A bad query on an operator route (`bad_auction_id`, `bad_kind`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: bad_auction_id
    Unauthorized:
      description: The credential is missing, expired, revoked, of the wrong kind or for another auction.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: unauthorized
            message: access token required
    Forbidden:
      description: The credential is valid but its role may not do this (`forbidden`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: forbidden
            message: not allowed
    NotFound:
      description: No such auction, bidder, room or route (`not_found`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: not_found
            message: no such auction
    MethodNotAllowed:
      description: Wrong method for a route the host serves directly. The `Allow` header names the right one.
      headers:
        Allow:
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: method_not_allowed
    Conflict:
      description: >-
        The request conflicts with the state of the lot or the credential: `conflict` (for
        example a hammer refused because the price or leader changed), `closed`,
        `invite_used`, `lot_not_open`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: conflict
            message: The lot has already ended
    OperatorConflict:
      description: A settlement run holds the lease (`busy`), there is no settlement (`no_settlement`) or the settlement is not in review (`not_in_review`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: busy
    JudgeKeyInvalid:
      description: >-
        The judge key was not accepted (`judge_key_invalid`), it has expired
        (`judge_key_expired`), it was revoked (`judge_key_revoked`), or the host has none set.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: judge_key_invalid
            code: judge_key_invalid
            message: That key was not accepted.
    Gone:
      description: The judge room ended (`judge_room_expired` or `judge_room_ended`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: judge_room_ended
            code: judge_room_ended
            message: That room has ended
    PayloadTooLarge:
      description: The body is larger than 64 KiB (`bad_message`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: bad_message
            message: request body too large
    UnsupportedMediaType:
      description: A body was sent that is not `application/json` (`bad_message`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: bad_message
            message: content-type must be application/json
    RateLimited:
      description: >-
        Too many requests (`rate_limited`), a locked-out source (`code_locked`,
        `judge_join_locked`), a full judge room (`judge_room_full`), or a judge-room cap or
        create limit. Wait `Retry-After` seconds.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: rate_limited
            message: Too many tries. Wait a minute and try again.
    Internal:
      description: Something failed on the host (`internal`). The message never carries internals.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: internal
            message: internal error
    Revoked:
      description: Revoked.
      content:
        application/json:
          schema:
            type: object
            required: [revoked]
            properties:
              revoked:
                type: boolean
                const: true
          example:
            revoked: true
    HoldBadRequest:
      description: Invalid body (`bad_message`) or a ceiling the auction does not offer (`ceiling_out_of_range`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: ceiling_out_of_range
            message: ceiling is not one of the offered amounts
    HoldNotFound:
      description: No such hold, or it belongs to another bidder (`order_not_found`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: order_not_found
            message: no such hold
    HoldConflict:
      description: A hold already exists or is authorized (`hold_exists`), the order is not approved yet (`order_not_approved`) or the auction no longer takes holds (`closed`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: order_not_approved
            message: the order is not approved
    HoldUnavailable:
      description: PayPal is unreachable (`paypal_unavailable`), the kill switch is on (`kill_switch_engaged`) or the host is not in sandbox mode (`payments_not_sandbox`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: paypal_unavailable
            message: PayPal is not reachable
