openapi: 3.0.3
info:
  title: TFCL Play API
  version: v1
  description: |
    REST API for the TFCL Play platform (https://play.tfcleague.com) — the in-house
    API our team runs and consumes to power both **TFCL Play** (the community-facing
    site at https://play.tfcleague.com) and **TFCL Prime** (the seasonal league
    structure at https://prime.tfcleague.com, currently in alpha at
    https://alpha.tfcleague.com). This API is specific to the TFCL league and the
    TFCL Play platform; it is the API we run and consume.

    Note: there is /api/premium/* surface area in this spec that exposes the
    **TFCL Play premium tier** — a paid membership feature of TFCL Play (PayPal
    orders, gift codes, premium API keys, `premiumApiKey` auth). That is a feature
    within TFCL Play and is unrelated to the TFCL Prime league product described
    above, which is a separate seasonal league running on its own subdomain.

    Multi-tenant Platform API (v2) is a sibling that lets the same deployment host
    other leagues in addition to TFCL Play itself — useful when we onboard partner
    communities or run our own future sub-leagues (e.g. seasonal spin-offs,
    regional variants). See `/api-docs/architecture-v2` for the multi-tenant design.

    ## Authentication

    Four mutually-acceptable credential shapes:

    - **sessionCookie** — HttpOnly cookie set by `/auth/steam/callback`.
    - **premiumApiKey** — `Authorization: Bearer tfcl_pk_<43 chars>`, rate-limited 60 req/min/key.
      (Premium-tier bearer for TFCL Play paid features — not related to TFCL Prime.)
    - **adminApiKey** — `Authorization: Bearer tfcl_ak_<43 chars>`, rate-limited 60 req/min/key.
    - **pluginSecret** — `X-TFCL-Plugin-Secret: <RELAY_SECRET>`, for in-game plugin calls only.

    ## Rate limits

    Per-key sliding window of 60 requests/min. Backed off with HTTP **429** and a `Retry-After` header.

    ## Errors

    All error responses share the `Error` schema: `{ "error": "message" }`. Body parser failures
    return **400**; auth failures return **401**; auth-success-but-not-allowed return **403**;
    resource-not-found returns **404**; state-conflict (already-cancelled, full team, etc.)
    returns **409**.
  contact:
    name: TFCL Play · API Team
    url: https://play.tfcleague.com
  x-fuentes:
    - TFCL Play (community site, https://play.tfcleague.com) — also hosts the TFCL Play premium tier (/api/premium/*)
    - TFCL Prime (seasonal league structure, https://prime.tfcleague.com — currently https://alpha.tfcleague.com)
    - TFCL Plugin (in-game relay, X-TFCL-Plugin-Secret auth)

servers:
- url: https://play.tfcleague.com
  description: Production
- url: http://localhost:3000
  description: Local dev (wrangler pages dev)
tags:
- name: Public
  description: Anonymous-safe read endpoints (stats, leaderboard, active lobbies, announcements).
- name: Users
  description: Profile, ELO, online status, custom avatar/frame.
- name: Lobbies
  description: Pickup-game lobby lifecycle (create / draft / score / cancel).
- name: Parties
  description: Pre-game party formation before joining a lobby.
- name: Servers
  description: On-demand Vultr TF2 server provisioning (free + premium tiers).
- name: Servers·Plugin
  description: In-game plugin calls authenticated by X-TFCL-Plugin-Secret.
- name: Maps
  description: Community-uploaded custom maps (.bsp).
- name: Premium
  description: 'TFCL Premium membership: PayPal purchase, gift codes, bearer API keys.'
- name: Points
  description: Points wallet, rewards, PayPal-funded topups, referrals.
- name: Community
  description: Forum (bug reports / discussion / feedback).
- name: Tournaments
  description: Multi-day community-bracket tournaments.
- name: Teams
  description: Persistent team rosters.
- name: Cups
  description: Paid-entry cups (PayPal-funded prize pool).
- name: Logs
  description: Client-side telemetry sinks (activity / error / share-log).
- name: Admin
  description: Admin-only (bans, ELO overrides, announcements, approvals).
- name: Auth
  description: Steam OpenID + Discord OAuth flows. HTML redirects only.
paths:
  /api/public/summary:
    get:
      tags:
      - Public
      summary: Aggregate public stats
      security: &id001 []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  stats:
                    $ref: '#/components/schemas/GlobalStats'
  /api/public/lobbies:
    get:
      tags:
      - Public
      summary: Currently active public lobbies
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LobbySummary'
  /api/public/leaderboard:
    get:
      tags:
      - Public
      summary: Top 100 users by ELO
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LeaderRow'
  /api/public/matches:
    get:
      tags:
      - Public
      summary: Recent matches (paginated)
      security: *id001
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: integer
        description: default 50; max 200
      - name: page
        in: query
        required: false
        schema:
          type: integer
        description: 1-indexed; default 1
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  matches:
                    type: array
                    items:
                      $ref: '#/components/schemas/MatchSummary'
                  total:
                    type: integer
                  page:
                    type: integer
                  limit:
                    type: integer
  /api/public/match/{id}:
    get:
      tags:
      - Public
      summary: Match summary by id
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MatchSummary'
        '404': &id002
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/public/match/{id}/players:
    get:
      tags:
      - Public
      summary: Per-player stats for a match
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MatchPlayer'
        '404': *id002
  /api/public/changelog:
    get:
      tags:
      - Public
      summary: Last 100 changelog entries
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ChangelogEntry'
  /api/public/announcements/active:
    get:
      tags:
      - Public
      summary: Currently-displayed announcement
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Announcement'
  /auth/steam:
    get:
      tags:
      - Auth
      summary: Kick off Steam OpenID login (302 to Steam)
      security: *id001
      responses:
        '302':
          description: Redirect to https://steamcommunity.com/openid/login
  /auth/steam/callback:
    get:
      tags:
      - Auth
      summary: Steam OpenID verification + session cookie set
      security: *id001
      responses:
        '302':
          description: Redirect to /?logged_in=1 (sets session cookie)
  /auth/logout:
    get:
      tags:
      - Auth
      summary: Clear session cookie
      security: *id001
      responses:
        '302':
          description: Redirect to /
  /auth/me:
    get:
      tags:
      - Auth
      summary: Currently-authenticated user
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSelf'
  /auth/discord:
    get:
      tags:
      - Auth
      summary: Kick off Discord OAuth (302)
      security: *id001
      responses:
        '302':
          description: Redirect to discord.com/oauth2/authorize
  /auth/discord/callback:
    get:
      tags:
      - Auth
      summary: Discord OAuth callback
      security: *id001
      responses:
        '302':
          description: Redirect to /
  /auth/discord/unlink:
    post:
      tags:
      - Auth
      summary: Unlink Discord from current account
      security: &id003
      - sessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': &id004
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403': &id005
          description: Forbidden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/users/me:
    get:
      tags:
      - Users
      summary: Full profile of the currently-signed-in user
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSelf'
        '401': *id004
        '403': *id005
  /api/users/leaderboard:
    get:
      tags:
      - Users
      summary: Top-N users by ELO (server-validated)
      security: *id001
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/UserPublic'
  /api/users/stats/global:
    get:
      tags:
      - Users
      summary: Global platform stats (same as /api/public/summary)
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GlobalStats'
  /api/users/online:
    get:
      tags:
      - Users
      summary: Currently online users (last 5 min)
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/UserPublic'
  /api/users/card/{userId}:
    get:
      tags:
      - Users
      summary: Rich user-card payload for the profile popover
      security: *id001
      parameters:
      - name: userId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserCard'
        '404': *id002
  /api/users/set-custom-avatar:
    post:
      tags:
      - Users
      summary: Spend points to set a custom avatar URL
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetCustomAvatarBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '400': &id006
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: Not enough points
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/users/set-profile-makeover:
    post:
      tags:
      - Users
      summary: Set name_color and/or profile_frame from owned rewards
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetProfileMakeoverBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '400': *id006
        '404': *id002
  /api/users/{steamId}:
    get:
      tags:
      - Users
      summary: Public profile by SteamID64
      security: *id001
      parameters:
      - name: steamId
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserPublic'
        '404': *id002
  /api/lobbies/:
    post:
      tags:
      - Lobbies
      summary: Create a lobby (optionally pinned to one of your server reservations)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LobbyCreateBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                  lobby:
                    $ref: '#/components/schemas/LobbyDetail'
        '401': *id004
        '403': *id005
        '400': *id006
        '409': &id007
          description: Conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/lobbies/{code}:
    delete:
      tags:
      - Lobbies
      summary: Cancel/delete lobby (host only)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '204':
          description: No content
        '401': *id004
        '403':
          description: Not host
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/lobbies/{code}/votes:
    get:
      tags:
      - Lobbies
      summary: Current map vote tallies
      security: *id001
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  votes:
                    type: object
                    additionalProperties:
                      type: integer
        '404': *id002
  /api/lobbies/{code}/vote-map:
    post:
      tags:
      - Lobbies
      summary: Cast your map vote
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VoteMapBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/my-vote:
    get:
      tags:
      - Lobbies
      summary: Your current map vote
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  map:
                    type: string
                    nullable: true
        '404': *id002
  /api/lobbies/{code}/chat:
    get:
      tags:
      - Lobbies
      summary: Last 200 chat messages
      security: *id001
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items:
                      $ref: '#/components/schemas/LobbyChatMessage'
        '404': *id002
    post:
      tags:
      - Lobbies
      summary: Post a chat message
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - message
              properties:
                message:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/join:
    post:
      tags:
      - Lobbies
      summary: Join a lobby (free-agent slot)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409': *id007
  /api/lobbies/{code}/party-join:
    post:
      tags:
      - Lobbies
      summary: Join a lobby with your whole party
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409': *id007
  /api/lobbies/{code}/pick-slot:
    post:
      tags:
      - Lobbies
      summary: Captain picks a free slot (team + index)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PickSlotBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409': *id007
  /api/lobbies/{code}/leave:
    post:
      tags:
      - Lobbies
      summary: Leave the lobby (free up your slot)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/kick:
    post:
      tags:
      - Lobbies
      summary: Captain kicks a player (must be on opposing team)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not captain / wrong team
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/lobbies/{code}/set-captain:
    post:
      tags:
      - Lobbies
      summary: Force-promote a player to captain (host only)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/start-draft:
    post:
      tags:
      - Lobbies
      summary: Host transitions lobby from gathering → drafting
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/pick:
    post:
      tags:
      - Lobbies
      summary: Draft-pick a player to your team (caps only)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/unpick:
    post:
      tags:
      - Lobbies
      summary: Send a drafted player back to the pool (reverse last pick)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/keep-ready:
    post:
      tags:
      - Lobbies
      summary: Keep your ready-check flag set (auto-decay counter)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/drop-to-pool:
    post:
      tags:
      - Lobbies
      summary: Captain drops a drafted player back into the free pool
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/ready:
    post:
      tags:
      - Lobbies
      summary: Mark yourself ready (drafting / ready_check phase)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/force-start:
    post:
      tags:
      - Lobbies
      summary: Host force-starts the match (skips ready-check)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/set-class:
    post:
      tags:
      - Lobbies
      summary: Pick your TF2 class for this match
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetClassBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/report:
    post:
      tags:
      - Lobbies
      summary: Report the match final score (caps / host)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  match_id:
                    type: integer
        '401': *id004
        '403': *id005
        '404': *id002
        '409': *id007
  /api/lobbies/{code}/rcon-exec:
    post:
      tags:
      - Lobbies
      summary: Run an RCON command on the lobby's server (host / admin)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RconExecBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  output:
                    type: string
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/log-status:
    get:
      tags:
      - Lobbies
      summary: S3 log-upload status for this match
      security: *id001
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                    - pending
                    - uploading
                    - ready
                    - failed
                  log_url:
                    type: string
                    format: uri
                    nullable: true
  /api/lobbies/{code}/fetch-log:
    post:
      tags:
      - Lobbies
      summary: Tell the in-game plugin to fetch the match log
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FetchLogBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/submit-log:
    post:
      tags:
      - Lobbies
      summary: Submit the match log directly from the caller (plugin fallback)
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitLogBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/lobbies/{code}/check-server:
    post:
      tags:
      - Lobbies
      summary: Ping the lobby's attached server; returns STATUSHEALTH
      security: *id003
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  online:
                    type: boolean
                  players:
                    type: integer
                  max_players:
                    type: integer
                  map:
                    type: string
                    nullable: true
        '401': *id004
        '403': *id005
        '404': *id002
  /api/parties/me:
    get:
      tags:
      - Parties
      summary: My current party (or null)
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  party:
                    type: object
                    nullable: true
                    properties:
                      id:
                        type: integer
                      code:
                        type: string
                      leader_id:
                        type: integer
                      members:
                        type: array
                        items:
                          $ref: '#/components/schemas/UserPublic'
                      invites:
                        type: array
                        items:
                          type: object
                          properties:
                            id:
                              type: integer
                            to_steam_id:
                              type: string
                            from_user_id:
                              type: integer
  /api/parties/create:
    post:
      tags:
      - Parties
      summary: Create a party (and become its leader)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartyCreateBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
        '401': *id004
        '403': *id005
        '400': *id006
        '409': *id007
  /api/parties/invite:
    post:
      tags:
      - Parties
      summary: Invite a player to your party
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartyInviteBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/parties/invite/{inviteId}/accept:
    post:
      tags:
      - Parties
      summary: Accept a pending party invite
      security: *id003
      parameters:
      - name: inviteId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/parties/invite/{inviteId}/decline:
    post:
      tags:
      - Parties
      summary: Decline a pending party invite
      security: *id003
      parameters:
      - name: inviteId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/parties/leave:
    post:
      tags:
      - Parties
      summary: Leave your current party
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
  /api/parties/kick:
    post:
      tags:
      - Parties
      summary: Kick a member (leader only)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartyKickBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/parties/disband:
    post:
      tags:
      - Parties
      summary: Disband the party (leader only)
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
  /api/servers/regions:
    get:
      tags:
      - Servers
      summary: List Vultr regions with availability
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/VultrRegionWithStats'
  /api/servers/region-stats:
    get:
      tags:
      - Servers
      summary: Per-region free-tier availability for scheduling
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  regions:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        name:
                          type: string
                        available:
                          type: integer
  /api/servers/maps:
    get:
      tags:
      - Servers
      summary: Maps valid for server provisioning
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
  /api/servers/capacity:
    get:
      tags:
      - Servers
      summary: Free-tier capacity snapshot per region
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  regions:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        available:
                          type: integer
  /api/servers/mine:
    get:
      tags:
      - Servers
      summary: My active + recent reservations
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ServerReservation'
        '401': *id004
        '403': *id005
  /api/servers/:
    post:
      tags:
      - Servers
      summary: Provision a free or premium tier reservation
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServerCreateBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerReservation'
        '401': *id004
        '403': *id005
        '400': *id006
        '402':
          description: Premium tier requires TFCL Premium
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409': *id007
  /api/servers/schedule:
    post:
      tags:
      - Servers
      summary: Schedule a future-dated server (premium only)
      security:
      - premiumApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ServerScheduleBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerReservation'
        '401':
          description: Missing/invalid premium bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Token does not have premium scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
  /api/servers/scheduled/mine:
    get:
      tags:
      - Servers
      summary: My pending scheduled reservations
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ServerReservation'
        '401': *id004
        '403': *id005
  /api/servers/scheduled/{id}:
    delete:
      tags:
      - Servers
      summary: Cancel a pending scheduled reservation
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401': *id004
        '403': *id005
        '404': *id002
  /api/servers/{id}:
    delete:
      tags:
      - Servers
      summary: Destroy reservation immediately (owner / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401': *id004
        '403': *id005
        '404': *id002
        '409': *id007
  /api/servers/plugin/extend:
    post:
      tags:
      - Servers·Plugin
      summary: Plugin extends an active reservation by N minutes
      security: &id008
      - pluginSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PluginExtendBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  new_expires_at:
                    type: string
                    format: date-time
        '401':
          description: Missing or invalid X-TFCL-Plugin-Secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/servers/plugin/who:
    post:
      tags:
      - Servers·Plugin
      summary: Plugin asks 'who is on this server right now?'
      security: *id008
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PluginWhoBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginWhoResponse'
        '401':
          description: Missing or invalid X-TFCL-Plugin-Secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/servers/plugin/report-match:
    post:
      tags:
      - Servers·Plugin
      summary: Plugin reports a match log URL once the match ends
      security: *id008
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PluginReportMatchBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing or invalid X-TFCL-Plugin-Secret
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/maps/:
    get:
      tags:
      - Maps
      summary: List active uploaded maps
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MapEntry'
  /api/maps/names:
    get:
      tags:
      - Maps
      summary: Just display names (autocomplete)
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
  /api/maps/can-upload:
    get:
      tags:
      - Maps
      summary: Caller is allowed to upload maps (premium/admin)
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  allowed:
                    type: boolean
        '401': *id004
        '403': *id005
  /api/maps/upload:
    post:
      tags:
      - Maps
      summary: Register a new map (.bsp) for the upload pipeline
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MapUploadBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MapEntry'
        '401': *id004
        '403':
          description: Caller cannot upload maps
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
        '413':
          description: .bsp exceeds size limit
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/maps/{id}:
    delete:
      tags:
      - Maps
      summary: Remove an uploaded map (uploader / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401': *id004
        '403':
          description: Not uploader or admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/premium/status:
    get:
      tags:
      - Premium
      summary: Premium state for current session user
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PremiumStatus'
        '401': *id004
        '403': *id005
  /api/premium/create-order:
    post:
      tags:
      - Premium
      summary: Create PayPal order (3-mo / 6-mo / 12-mo plan)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PremiumCreateOrderBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_id:
                    type: string
                  approval_url:
                    type: string
                    format: uri
        '401': *id004
        '403': *id005
        '400': *id006
        '502':
          description: PayPal upstream failure
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/premium/capture-order:
    post:
      tags:
      - Premium
      summary: Capture an approved PayPal order → grant premium
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayPalCaptureBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PremiumStatus'
        '401': *id004
        '403': *id005
        '400': *id006
        '402':
          description: PayPal did not approve the order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/premium/history:
    get:
      tags:
      - Premium
      summary: Premium grant history for current user
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    plan:
                      $ref: '#/components/schemas/Plan'
                    source:
                      type: string
                    granted_at:
                      type: string
                      format: date-time
                    expires_at:
                      type: string
                      format: date-time
                      nullable: true
        '401': *id004
        '403': *id005
  /api/premium/api-keys:
    post:
      tags:
      - Premium
      summary: Issue a new premium bearer key (raw_key shown once)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApiKeyIssueBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyIssueResponse'
        '401': *id004
        '403': *id005
        '400': *id006
    get:
      tags:
      - Premium
      summary: List your API keys (no raw values, only prefix + audit)
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ApiKeyRecord'
        '401': *id004
        '403': *id005
  /api/premium/api-keys/{id}:
    delete:
      tags:
      - Premium
      summary: Revoke a premium API key
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401': *id004
        '403': *id005
        '404': *id002
  /api/premium/gift-codes:
    get:
      tags:
      - Premium
      summary: My owned gift codes (codes I bought or received)
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GiftCode'
        '401': *id004
        '403': *id005
  /api/premium/gift-codes/capture-paypal:
    post:
      tags:
      - Premium
      summary: Capture PayPal after buyer approved the gift-code order
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayPalCaptureBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GiftCode'
        '401': *id004
        '403': *id005
        '400': *id006
  /api/premium/gift-codes/redeem:
    post:
      tags:
      - Premium
      summary: Redeem a gift code (XXXX-XXXX-XXXX-XXXX) → grant premium
      security: *id001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - code
              properties:
                code:
                  type: string
                  pattern: ^[A-Z0-9]{4}(-[A-Z0-9]{4}){3}$
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PremiumStatus'
        '401':
          description: Login required to redeem
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
        '404': *id002
        '410':
          description: Code already redeemed / revoked / expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/points/balance:
    get:
      tags:
      - Points
      summary: Current point wallet
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PointBalance'
        '401': *id004
        '403': *id005
  /api/points/transactions:
    get:
      tags:
      - Points
      summary: Recent ledger
      security: *id003
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PointTransaction'
        '401': *id004
        '403': *id005
  /api/points/rewards:
    get:
      tags:
      - Points
      summary: Catalogue of rewards you can spend points on
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PointReward'
  /api/points/redeem:
    post:
      tags:
      - Points
      summary: Spend points to unlock a reward
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RedeemRewardBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '402':
          description: Insufficient points
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/points/redemptions:
    get:
      tags:
      - Points
      summary: My redemption history
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    reward_id:
                      type: integer
                    reward_name:
                      type: string
                    cost:
                      type: integer
                    redeemed_at:
                      type: string
                      format: date-time
        '401': *id004
        '403': *id005
  /api/points/leaderboard:
    get:
      tags:
      - Points
      summary: Top point earners
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    rank:
                      type: integer
                    user_id:
                      type: integer
                    username:
                      type: string
                    balance:
                      type: integer
  /api/points/admin/award:
    post:
      tags:
      - Points
      summary: Admin-award points to a user (writes audit log)
      security: &id009
      - adminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PointsAdminAwardBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Token does not have admin scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
  /api/points/set-name-color:
    post:
      tags:
      - Points
      summary: Equip a name_color you previously redeemed
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetNameColorBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '400': *id006
        '404': *id002
  /api/points/set-profile-frame:
    post:
      tags:
      - Points
      summary: Equip a profile_frame you previously redeemed
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - frame
              properties:
                frame:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '400': *id006
        '404': *id002
  /api/points/admin/user/{userId}:
    get:
      tags:
      - Points
      summary: Admin view of any user's point wallet
      security: *id009
      parameters:
      - name: userId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PointBalance'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/points/refer/code:
    get:
      tags:
      - Points
      summary: Your referral code
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
        '401': *id004
        '403': *id005
  /api/points/refer/claim:
    post:
      tags:
      - Points
      summary: Claim a referral code (1 reward per account)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReferClaimBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '400': *id006
        '404': *id002
        '409':
          description: Already claimed a referral
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/points/refer/stats:
    get:
      tags:
      - Points
      summary: How many people used your referral code
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                  claims:
                    type: integer
                  points_earned:
                    type: integer
        '401': *id004
        '403': *id005
  /api/points/create-order:
    post:
      tags:
      - Points
      summary: Create PayPal order to buy a points pack
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - pack_id
              properties:
                pack_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  order_id:
                    type: string
                  approval_url:
                    type: string
                    format: uri
        '401': *id004
        '403': *id005
        '400': *id006
  /api/points/capture-order:
    post:
      tags:
      - Points
      summary: Capture PayPal → credit points to your wallet
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayPalCaptureBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PointBalance'
        '401': *id004
        '403': *id005
        '400': *id006
  /api/points/purchase-history:
    get:
      tags:
      - Points
      summary: Your PayPal points-purchase history
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PayPalOrderSummary'
        '401': *id004
        '403': *id005
  /api/community/posts:
    post:
      tags:
      - Community
      summary: Create a post
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - category
              - title
              - body
              properties:
                category:
                  type: string
                  enum:
                  - discussion
                  - bug_report
                  - feedback
                  - question
                title:
                  type: string
                  maxLength: 120
                body:
                  type: string
                  maxLength: 10000
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommunityPost'
        '401': *id004
        '403': *id005
        '400': *id006
  /api/community/posts/{id}:
    delete:
      tags:
      - Community
      summary: Delete a post (author / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401': *id004
        '403':
          description: Not author or admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/community/posts/{id}/vote:
    post:
      tags:
      - Community
      summary: Upvote a post (idempotent — calling again unsets your vote)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  vote_count:
                    type: integer
        '401': *id004
        '403': *id005
        '404': *id002
  /api/community/posts/{id}/comments:
    post:
      tags:
      - Community
      summary: Add a comment (parent_id for replies)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - body
              properties:
                body:
                  type: string
                  maxLength: 4000
                parent_id:
                  type: integer
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommunityComment'
        '401': *id004
        '403': *id005
        '400': *id006
        '404': *id002
  /api/community/comments/{id}:
    delete:
      tags:
      - Community
      summary: Delete a comment (author / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401': *id004
        '403':
          description: Not author or admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/community/posts/{id}/status:
    patch:
      tags:
      - Community
      summary: Author or admin sets post status (closed/resolved/…)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - status
              properties:
                status:
                  type: string
                  enum:
                  - open
                  - resolved
                  - wontfix
                  - duplicate
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/community/posts/{id}/pin:
    patch:
      tags:
      - Community
      summary: Pin/unpin a post (admin)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - pinned
              properties:
                pinned:
                  type: boolean
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/tournaments/:
    post:
      tags:
      - Tournaments
      summary: Submit a tournament draft (requires admin approval before going live)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TournamentCreateBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TournamentDetail'
        '401': *id004
        '403': *id005
        '400': *id006
  /api/tournaments/me/stats:
    get:
      tags:
      - Tournaments
      summary: My tournament placement + wins summary
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tournaments_entered:
                    type: integer
                  first_place:
                    type: integer
                  second_place:
                    type: integer
                  third_place:
                    type: integer
        '401': *id004
        '403': *id005
  /api/tournaments/mine:
    get:
      tags:
      - Tournaments
      summary: Tournaments I created
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TournamentDetail'
        '401': *id004
        '403': *id005
  /api/tournaments/{id}:
    get:
      tags:
      - Tournaments
      summary: Tournament detail (signups, matches, bracket)
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TournamentDetail'
        '404': *id002
  /api/tournaments/{id}/audit:
    get:
      tags:
      - Tournaments
      summary: Tournament audit log (creator / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TournamentAuditEntry'
        '401': *id004
        '403':
          description: Not creator or admin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/tournaments/{id}/publish:
    post:
      tags:
      - Tournaments
      summary: Creator submits a draft for admin approval
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not creator
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
        '409': *id007
  /api/tournaments/{id}/join:
    post:
      tags:
      - Tournaments
      summary: Join a tournament (signs YOU up, currently requires solo signup)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409':
          description: Already joined / tournament full / closed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tournaments/{id}/leave:
    post:
      tags:
      - Tournaments
      summary: Drop out of a tournament before it starts
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/tournaments/{id}/teams:
    get:
      tags:
      - Tournaments
      summary: Signed-up teams (or participants, for singles)
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    name:
                      type: string
                    captain_id:
                      type: integer
                    checked_in:
                      type: boolean
  /api/tournaments/{id}/approve:
    post:
      tags:
      - Tournaments
      summary: Admin approves a pending tournament draft
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/tournaments/{id}/reject:
    post:
      tags:
      - Tournaments
      summary: Admin rejects a pending tournament draft
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 200
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/tournaments/{id}/start:
    post:
      tags:
      - Tournaments
      summary: Admin starts a tournament (locks signups, generates bracket)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
        '409': *id007
  /api/tournaments/{id}/matches:
    get:
      tags:
      - Tournaments
      summary: Bracket matches for a tournament
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    round:
                      type: integer
                    team_a:
                      type: string
                      nullable: true
                    team_b:
                      type: string
                      nullable: true
                    winner:
                      type: string
                      nullable: true
                    score_a:
                      type: integer
                    score_b:
                      type: integer
                    server_lobby_code:
                      type: string
                      nullable: true
                    scheduled_for:
                      type: string
                      format: date-time
                      nullable: true
  /api/tournaments/{id}/matches/{matchId}/result:
    post:
      tags:
      - Tournaments
      summary: Submit match result from a linked lobby
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: matchId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/tournaments/{id}/servers/{serverId}/rcon-exec:
    post:
      tags:
      - Tournaments
      summary: RCON-exec on a tournament-pinned server
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: serverId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RconExecBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  output:
                    type: string
        '401': *id004
        '403': *id005
        '404': *id002
  /api/tournaments/{id}/check-in:
    post:
      tags:
      - Tournaments
      summary: Mark yourself checked-in (within check-in window)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409':
          description: Outside check-in window
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tournaments/{id}/complete:
    post:
      tags:
      - Tournaments
      summary: Admin marks the tournament completed and computes rewards
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
        '409': *id007
  /api/tournaments/{id}/cancel:
    post:
      tags:
      - Tournaments
      summary: Cancel a tournament (creator / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/tournaments/{id}/disqualify:
    post:
      tags:
      - Tournaments
      summary: Admin disqualifies a participant (sets their matches to walkover)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                reason:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/teams/:
    post:
      tags:
      - Teams
      summary: Create a team (you become captain)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamCreateBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamDetail'
        '401': *id004
        '403': *id005
        '400': *id006
        '409':
          description: Tag taken
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/teams/mine:
    get:
      tags:
      - Teams
      summary: Teams I am a member of
      security: *id003
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TeamDetail'
        '401': *id004
        '403': *id005
  /api/teams/{id}:
    get:
      tags:
      - Teams
      summary: Team detail (members, role)
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamDetail'
        '404': *id002
  /api/teams/{id}/join:
    post:
      tags:
      - Teams
      summary: Join a team (password optional for protected teams)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                password:
                  type: string
                  nullable: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Wrong password
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403': *id005
        '404': *id002
        '409':
          description: Already a member / team full
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/teams/{id}/leave:
    post:
      tags:
      - Teams
      summary: Leave the team
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409':
          description: Captain cannot leave; transfer first
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/teams/{id}/disband:
    post:
      tags:
      - Teams
      summary: Disband the team (captain only)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not captain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/teams/{id}/edit:
    post:
      tags:
      - Teams
      summary: Edit team name / tag / password (captain only)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamEditBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/teams/{id}/password:
    post:
      tags:
      - Teams
      summary: Set or clear the join password (captain only)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                password:
                  type: string
                  maxLength: 60
                  nullable: true
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/teams/{id}/members/{userId}/role:
    post:
      tags:
      - Teams
      summary: Promote / demote a member (captain only)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: userId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamRoleBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/teams/{id}/members/{userId}/kick:
    post:
      tags:
      - Teams
      summary: Kick a member (captain only)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: userId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/teams/{id}/transfer:
    post:
      tags:
      - Teams
      summary: Transfer captaincy (captain only)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TeamTransferBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/cups/:
    post:
      tags:
      - Cups
      summary: Create a new paid cup (PayPal-funded prize pool)
      security: *id003
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - name
              - format
              - entry_fee_cents
              - max_teams
              - starts_at
              properties:
                name:
                  type: string
                  maxLength: 100
                format:
                  type: string
                  enum:
                  - singles
                  - doubles
                  - sixes
                entry_fee_cents:
                  type: integer
                  minimum: 100
                max_teams:
                  type: integer
                  minimum: 2
                  maximum: 64
                starts_at:
                  type: string
                  format: date-time
                rules:
                  type: string
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CupDetail'
        '401': *id004
        '403': *id005
        '400': *id006
  /api/cups/{id}:
    get:
      tags:
      - Cups
      summary: Cup detail (teams, matches, status)
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CupDetail'
        '404': *id002
  /api/cups/{id}/teams:
    get:
      tags:
      - Cups
      summary: Teams signed up for this cup
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    team_id:
                      type: integer
                    name:
                      type: string
                    captain:
                      $ref: '#/components/schemas/UserPublic'
                    checked_in:
                      type: boolean
                    paid:
                      type: boolean
  /api/cups/{id}/teams/{teamId}:
    get:
      tags:
      - Cups
      summary: Single team entry detail (roster)
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  team:
                    $ref: '#/components/schemas/TeamDetail'
                  checked_in:
                    type: boolean
                  paid:
                    type: boolean
        '404': *id002
  /api/cups/{id}/matches:
    get:
      tags:
      - Cups
      summary: Cup bracket matches
      security: *id001
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    round:
                      type: integer
                    team_a_name:
                      type: string
                      nullable: true
                    team_b_name:
                      type: string
                      nullable: true
                    score_a:
                      type: integer
                    score_b:
                      type: integer
                    winner_team_id:
                      type: integer
                      nullable: true
  /api/cups/{id}/audit:
    get:
      tags:
      - Cups
      summary: Cup audit log (creator / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    at:
                      type: string
                      format: date-time
                    actor_id:
                      type: integer
                      nullable: true
                    action:
                      type: string
                    metadata:
                      type: object
                      additionalProperties: true
        '401': *id004
        '403': *id005
        '404': *id002
  /api/cups/{id}/approve:
    post:
      tags:
      - Cups
      summary: Admin approves a pending cup draft
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/cups/{id}/reject:
    post:
      tags:
      - Cups
      summary: Admin rejects a pending cup draft
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 200
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/cups/{id}/cancel:
    post:
      tags:
      - Cups
      summary: Cancel a cup (creator / admin)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/cups/{idOrSlug}:
    patch:
      tags:
      - Cups
      summary: Edit cup draft (creator only, pre-approve)
      security: *id003
      parameters:
      - name: idOrSlug
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                rules:
                  type: string
                starts_at:
                  type: string
                  format: date-time
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409':
          description: Cup already locked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/cups/{id}/bracket/generate:
    post:
      tags:
      - Cups
      summary: Lock cup signups and generate the bracket
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
        '409': *id007
  /api/cups/{id}/withdraw:
    post:
      tags:
      - Cups
      summary: Captain withdraws the team from this cup
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/cups/{id}/teams/{teamId}/manage:
    get:
      tags:
      - Cups
      summary: Captain's detailed management view of their team in this cup
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  team:
                    $ref: '#/components/schemas/TeamDetail'
                  paid:
                    type: boolean
                  checked_in:
                    type: boolean
                  captain_user_id:
                    type: integer
        '401': *id004
        '403':
          description: Not captain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/cups/{id}/teams/{teamId}/edit:
    post:
      tags:
      - Cups
      summary: Captain edits the team's cup entry (roster, name)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 60
                  nullable: true
                remove_user_ids:
                  type: array
                  items:
                    type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not captain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/cups/{id}/teams/{teamId}/withdraw:
    post:
      tags:
      - Cups
      summary: Captain-withdraw-with-team (alias of /api/cups/{id}/withdraw)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/cups/{id}/teams/{teamId}/players/{playerUserId}/remove:
    post:
      tags:
      - Cups
      summary: Captain removes a player from their cup team
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      - name: playerUserId
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not captain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/cups/{id}/teams/{teamId}/players/add:
    post:
      tags:
      - Cups
      summary: Captain adds a player to their cup team (by steam_id)
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - steam_id
              properties:
                steam_id:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not captain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
        '409':
          description: Team full / player already on team
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/cups/{id}/teams/{teamId}/pay-player:
    post:
      tags:
      - Cups
      summary: Captain pays for a teammate's seat via PayPal
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: teamId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - player_user_id
              properties:
                player_user_id:
                  type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403':
          description: Not captain
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
        '402':
          description: PayPal charge failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/cups/{id}/register:
    post:
      tags:
      - Cups
      summary: Register a new (paid) team entry in this cup
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - team_name
              properties:
                team_name:
                  type: string
                  maxLength: 60
                roster_user_ids:
                  type: array
                  items:
                    type: integer
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  team_id:
                    type: integer
        '401': *id004
        '403': *id005
        '400': *id006
        '402':
          description: PayPal charge failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/cups/{id}/register/capture:
    post:
      tags:
      - Cups
      summary: Capture PayPal for a pending register-team transaction
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PayPalCaptureBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '400': *id006
  /api/cups/{id}/matches/{matchId}/result:
    post:
      tags:
      - Cups
      summary: Submit match result for a cup-bracket match
      security: *id003
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      - name: matchId
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401': *id004
        '403': *id005
        '404': *id002
  /api/cups/{id}/disburse:
    post:
      tags:
      - Cups
      summary: Admin disburse prize pool to winners via PayPal Payouts
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
        '409': *id007
  /api/logs/activity:
    post:
      tags:
      - Logs
      summary: Client activity telemetry (fire-and-forget)
      security: *id001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ActivityLogBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivityLogResponse'
        '400': *id006
  /api/logs/error:
    post:
      tags:
      - Logs
      summary: Client error telemetry (fire-and-forget)
      security: *id001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ErrorLogBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorLogResponse'
        '400': *id006
  /api/logs/share-log:
    post:
      tags:
      - Logs
      summary: Client uploads a match log text for the team-shared-log pool
      security: *id001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareLogBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  view_token:
                    type: string
        '400': *id006
  /api/admin/stats:
    get:
      tags:
      - Admin
      summary: Admin platform stats (DB size, R2 usage, …)
      security: *id009
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  users_total:
                    type: integer
                  users_online:
                    type: integer
                  lobbies_active:
                    type: integer
                  servers_total:
                    type: integer
                  servers_active:
                    type: integer
                  log_volume_mb_24h:
                    type: number
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/users:
    get:
      tags:
      - Admin
      summary: List users (filter, search)
      security: *id009
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      - name: banned
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items:
                      $ref: '#/components/schemas/UserSelf'
                  total:
                    type: integer
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/users/{id}/ban:
    post:
      tags:
      - Admin
      summary: Ban a user (writes audit)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserBanBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/users/{id}/unban:
    post:
      tags:
      - Admin
      summary: Unban a user
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/users/{id}/set-elo:
    post:
      tags:
      - Admin
      summary: Override a user's ELO
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetEloBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
        '404': *id002
  /api/admin/users/{id}/grant-premium:
    post:
      tags:
      - Admin
      summary: Grant TFCL Premium to a user
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - plan
              - days
              properties:
                plan:
                  $ref: '#/components/schemas/Plan'
                days:
                  type: integer
                  minimum: 1
                  maximum: 3650
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
        '404': *id002
  /api/admin/users/{id}/revoke-premium:
    post:
      tags:
      - Admin
      summary: Revoke a user's TFCL Premium
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/sessions:
    get:
      tags:
      - Admin
      summary: List active sessions (filterable by user)
      security: *id009
      parameters:
      - name: user_id
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    user_id:
                      type: integer
                    ip_hash:
                      type: string
                    created_at:
                      type: string
                      format: date-time
                    expires_at:
                      type: string
                      format: date-time
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/sessions/count:
    get:
      tags:
      - Admin
      summary: Number of active sessions
      security: *id009
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/sessions/{id}/revoke:
    post:
      tags:
      - Admin
      summary: Revoke a single session (force-logout that user)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/lobbies:
    get:
      tags:
      - Admin
      summary: Browse all lobbies (any status)
      security: *id009
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LobbySummary'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/lobbies/{id}:
    delete:
      tags:
      - Admin
      summary: Force-cancel any lobby
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/lobbies/{code}/add-bot:
    post:
      tags:
      - Admin
      summary: Add a bot to a specific slot
      security: *id009
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - team
              - slot_index
              properties:
                team:
                  type: integer
                  enum:
                  - 0
                  - 1
                slot_index:
                  type: integer
                  minimum: 0
                class:
                  type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/lobbies/{code}/fill-bots:
    post:
      tags:
      - Admin
      summary: Fill all empty slots of both teams with bots
      security: *id009
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/lobbies/{code}/add-bots:
    post:
      tags:
      - Admin
      summary: Add N bots spread across free slots
      security: *id009
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - count
              properties:
                count:
                  type: integer
                  minimum: 1
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/lobbies/{code}/remove-bots:
    post:
      tags:
      - Admin
      summary: Remove all bots from a lobby
      security: *id009
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/lobbies/{code}/force-complete:
    post:
      tags:
      - Admin
      summary: Force-complete a lobby without a real match (audit-logged)
      security: *id009
      parameters:
      - name: code
        in: path
        required: true
        schema:
          type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 200
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/servers:
    get:
      tags:
      - Admin
      summary: All reservations across all users
      security: *id009
      parameters:
      - name: status
        in: query
        required: false
        schema:
          type: string
      - name: user_id
        in: query
        required: false
        schema:
          type: integer
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ServerReservation'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/matches:
    get:
      tags:
      - Admin
      summary: All matches (filter by map/format)
      security: *id009
      parameters:
      - name: map
        in: query
        required: false
        schema:
          type: string
      - name: format
        in: query
        required: false
        schema:
          type: string
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MatchSummary'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/matches/{id}:
    delete:
      tags:
      - Admin
      summary: Hard-delete a match (audit logged)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/creators:
    post:
      tags:
      - Admin
      summary: Grant a creator free premium
      security: *id009
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - steam_id
              properties:
                steam_id:
                  type: string
                note:
                  type: string
                  maxLength: 200
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
  /api/admin/creators/{steamId}:
    delete:
      tags:
      - Admin
      summary: Revoke creator-program premium
      security: *id009
      parameters:
      - name: steamId
        in: path
        required: true
        schema:
          type: string
      responses:
        '204':
          description: No content
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/creators/check/{steamId}:
    get:
      tags:
      - Admin
      summary: Check if a SteamID is a creator-program member
      security: *id009
      parameters:
      - name: steamId
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  is_creator:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/logs/activity:
    get:
      tags:
      - Admin
      summary: Read activity logs (paginated)
      security: *id009
      parameters:
      - name: user_id
        in: query
        required: false
        schema:
          type: integer
      - name: kind
        in: query
        required: false
        schema:
          type: string
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    user_id:
                      type: integer
                      nullable: true
                    kind:
                      type: string
                    data:
                      type: object
                      additionalProperties: true
                    at:
                      type: string
                      format: date-time
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/logs/errors:
    get:
      tags:
      - Admin
      summary: Read frontend error logs (paginated)
      security: *id009
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: integer
      - name: page
        in: query
        required: false
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    message:
                      type: string
                    stack:
                      type: string
                      nullable: true
                    url:
                      type: string
                      format: uri
                      nullable: true
                    at:
                      type: string
                      format: date-time
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/logs/counts:
    get:
      tags:
      - Admin
      summary: Last-24h log volume by kind
      security: *id009
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  activity:
                    type: integer
                  errors:
                    type: integer
                  share_logs:
                    type: integer
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/admins:
    post:
      tags:
      - Admin
      summary: Grant admin or mod role to a user
      security: *id009
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AdminCreateBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
  /api/admin/users/search:
    get:
      tags:
      - Admin
      summary: Quick username search (autocomplete for admin tools)
      security: *id009
      parameters:
      - name: q
        in: query
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    username:
                      type: string
                    steam_id:
                      type: string
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/admins/{id}:
    delete:
      tags:
      - Admin
      summary: Revoke admin/mod role
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/api-keys:
    post:
      tags:
      - Admin
      summary: Issue an admin-scope API key for a chosen user
      security: *id009
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - user_id
              properties:
                user_id:
                  type: integer
                label:
                  type: string
                  maxLength: 60
                  nullable: true
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyIssueResponse'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
  /api/admin/api-keys/{id}:
    delete:
      tags:
      - Admin
      summary: Revoke any API key
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/gift-codes:
    get:
      tags:
      - Admin
      summary: List all gift codes (any status)
      security: *id009
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GiftCode'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/admin/gift-codes/{id}:
    delete:
      tags:
      - Admin
      summary: Revoke a gift code (mark revoked)
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '204':
          description: No content
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/announcements:
    post:
      tags:
      - Admin
      summary: Create an announcement (will display site-wide)
      security: *id009
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnnouncementCreateBody'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Announcement'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '400': *id006
  /api/admin/announcements/{id}:
    put:
      tags:
      - Admin
      summary: Update an existing announcement
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnnouncementCreateBody'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Announcement'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/announcements/{id}/deactivate:
    post:
      tags:
      - Admin
      summary: Force-hide an announcement immediately
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/announcements/{id}/reactivate:
    post:
      tags:
      - Admin
      summary: Bring an announcement back online
      security: *id009
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404': *id002
  /api/admin/approvals:
    get:
      tags:
      - Admin
      summary: Pending tournaments + cups awaiting admin approval
      security: *id009
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tournaments:
                    type: array
                    items:
                      $ref: '#/components/schemas/TournamentDetail'
                  cups:
                    type: array
                    items:
                      $ref: '#/components/schemas/CupSummary'
        '401':
          description: Missing/invalid admin bearer key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/serveme/maps:
    get:
      tags:
      - Public
      summary: Cached proxy to https://na.serveme.tf/api/maps
      security: *id001
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  maps:
                    type: array
                    items:
                      type: string
        '502':
          description: serveme.tf unreachable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: session
      description: HttpOnly session cookie set by Steam login.
    premiumApiKey:
      type: http
      scheme: bearer
      bearerFormat: TFCL-PK
      description: tfcl_pk_<43 base64url chars> (premium scope).
    adminApiKey:
      type: http
      scheme: bearer
      bearerFormat: TFCL-AK
      description: tfcl_ak_<43 base64url chars> (admin scope).
    pluginSecret:
      type: apiKey
      in: header
      name: X-TFCL-Plugin-Secret
      description: Shared RELAY_SECRET for the in-game plugin.
  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
      required:
      - error
    Paginated:
      type: object
      properties:
        items:
          type: array
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer
      required:
      - items
      - total
      - page
      - limit
    GlobalStats:
      type: object
      properties:
        total_users:
          type: integer
        total_matches:
          type: integer
        active_lobbies:
          type: integer
        top_elo:
          type: integer
        online_users:
          type: integer
    LeaderRow:
      type: object
      properties:
        rank:
          type: integer
        id:
          type: integer
        steam_id:
          type: string
        username:
          type: string
        elo:
          type: integer
        avatar:
          type: string
          nullable: true
          format: uri
        wins:
          type: integer
        losses:
          type: integer
    MatchSummary:
      type: object
      properties:
        id:
          type: integer
        code:
          type: string
        map:
          type: string
        format:
          type: string
          enum:
          - sixes
          - ultiduo
        team_a_score:
          type: integer
        team_b_score:
          type: integer
        winning_team:
          type: integer
          nullable: true
          description: 0=A, 1=B, null=draw
        started_at:
          type: string
          format: date-time
        ended_at:
          type: string
          format: date-time
    MatchPlayer:
      type: object
      properties:
        user_id:
          type: integer
        steam_id:
          type: string
        username:
          type: string
        team:
          type: integer
          enum:
          - 0
          - 1
        class:
          type: string
          enum:
          - scout
          - soldier
          - pyro
          - demoman
          - heavy
          - engineer
          - medic
          - sniper
          - spy
        kills:
          type: integer
        assists:
          type: integer
        deaths:
          type: integer
        damage:
          type: integer
        healing:
          type: integer
    LobbySummary:
      type: object
      properties:
        id:
          type: integer
        code:
          type: string
        name:
          type: string
        map:
          type: string
        format:
          type: string
          enum:
          - sixes
          - ultiduo
        region:
          type: string
        status:
          type: string
          enum:
          - gathering
          - drafting
          - ready_check
          - active
          - completed
          - cancelled
        player_count:
          type: integer
        max_players:
          type: integer
        created_at:
          type: string
          format: date-time
    LobbyDetail:
      type: object
      properties:
        id:
          type: integer
        code:
          type: string
        name:
          type: string
        map:
          type: string
          nullable: true
        format:
          type: string
          enum:
          - sixes
          - ultiduo
        region:
          type: string
        server_id:
          type: integer
          nullable: true
        status:
          type: string
        host_id:
          type: integer
        captain_a_id:
          type: integer
          nullable: true
        captain_b_id:
          type: integer
          nullable: true
        team_a:
          type: array
          items:
            $ref: '#/components/schemas/LobbyPlayer'
        team_b:
          type: array
          items:
            $ref: '#/components/schemas/LobbyPlayer'
        spectators:
          type: array
          items:
            $ref: '#/components/schemas/LobbyPlayer'
        created_at:
          type: string
          format: date-time
    LobbyPlayer:
      type: object
      properties:
        user_id:
          type: integer
        username:
          type: string
        steam_id:
          type: string
        team:
          type: integer
          enum:
          - 0
          - 1
          - 2
          description: 0=A, 1=B, 2=spectator
        class:
          type: string
          enum:
          - scout
          - soldier
          - pyro
          - demoman
          - heavy
          - engineer
          - medic
          - sniper
          - spy
          nullable: true
        ready:
          type: boolean
    LobbyChatMessage:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
          nullable: true
        username:
          type: string
          nullable: true
        message:
          type: string
        system:
          type: boolean
        created_at:
          type: string
          format: date-time
    ChangelogEntry:
      type: object
      properties:
        id:
          type: integer
        version:
          type: string
        category:
          type: string
          enum:
          - added
          - changed
          - fixed
          - removed
          - security
        title:
          type: string
        body:
          type: string
        author_id:
          type: integer
          nullable: true
        published_at:
          type: string
          format: date-time
    Announcement:
      type: object
      properties:
        id:
          type: integer
        title:
          type: string
        body:
          type: string
        severity:
          type: string
          enum:
          - info
          - warning
          - critical
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          nullable: true
    UserPublic:
      type: object
      properties:
        id:
          type: integer
        steam_id:
          type: string
        username:
          type: string
        avatar:
          type: string
          format: uri
          nullable: true
        elo:
          type: integer
        profile_url:
          type: string
          format: uri
          nullable: true
    UserSelf:
      type: object
      properties:
        id:
          type: integer
        steam_id:
          type: string
        username:
          type: string
        avatar:
          type: string
          format: uri
          nullable: true
        elo:
          type: integer
        discord_id:
          type: string
          nullable: true
        discord_username:
          type: string
          nullable: true
        is_admin:
          type: boolean
        is_mod:
          type: boolean
        is_banned:
          type: boolean
        is_premium:
          type: boolean
        premium_until:
          type: string
          format: date-time
          nullable: true
        refer_code:
          type: string
          nullable: true
        is_online:
          type: boolean
        last_seen:
          type: string
          format: date-time
          nullable: true
        custom_avatar_url:
          type: string
          format: uri
          nullable: true
        name_color:
          type: string
          nullable: true
          description: Hex color or null
        profile_frame:
          type: string
          nullable: true
        point_balance:
          type: integer
    UserCard:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/UserPublic'
        stats:
          type: object
          properties:
            wins:
              type: integer
            losses:
              type: integer
            elo:
              type: integer
            rank:
              type: integer
            favorite_class:
              type: string
              nullable: true
        discord:
          type: object
          nullable: true
          properties:
            username:
              type: string
            avatar_url:
              type: string
              format: uri
              nullable: true
        recent_matches:
          type: array
          items:
            $ref: '#/components/schemas/MatchSummary'
    VultrRegion:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        country:
          type: string
        continent:
          type: string
        available:
          type: boolean
        capacity_remaining:
          type: integer
    VultrRegionWithStats:
      type: object
      properties:
        region:
          $ref: '#/components/schemas/VultrRegion'
        active_servers:
          type: integer
        queued_provisioning:
          type: integer
    ServerTier:
      type: string
      enum:
      - free
      - premium
    ServerStatus:
      type: string
      enum:
      - scheduled
      - provisioning
      - active
      - error
      - destroyed
    ServerReservation:
      type: object
      properties:
        id:
          type: integer
        user_id:
          type: integer
        tier:
          $ref: '#/components/schemas/ServerTier'
        region:
          type: string
        vultr_instance_id:
          type: string
          nullable: true
        label:
          type: string
        status:
          $ref: '#/components/schemas/ServerStatus'
        ip:
          type: string
          nullable: true
        ip_and_port:
          type: string
          nullable: true
          description: connect string
        password:
          type: string
          nullable: true
          description: sv_password clients use
        rcon:
          type: string
          nullable: true
          description: RCON password
        error_message:
          type: string
          nullable: true
        scheduled_for:
          type: string
          format: date-time
          nullable: true
        schedule_label:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
        destroyed_at:
          type: string
          format: date-time
          nullable: true
        map:
          type: string
          nullable: true
        config_preset:
          type: string
        custom_cfg:
          type: string
          nullable: true
        config_applied:
          type: integer
          enum:
          - 0
          - 1
        ready_at:
          type: string
          format: date-time
          nullable: true
        extension_count:
          type: integer
        plugin_secret:
          type: string
          nullable: true
    ServerCreateBody:
      type: object
      properties:
        tier:
          $ref: '#/components/schemas/ServerTier'
        region:
          type: string
          description: Region id from /api/servers/regions
        map:
          type: string
          description: TF2 map file_name
        config_preset:
          type: string
          default: tfcl_scrim
          description: tfcl_scrim | rgl | ugc | esa | etf2l | …
        custom_cfg:
          type: string
          nullable: true
          description: Match [A-Za-z0-9_-]{1,80}
      required:
      - region
      - map
    ServerScheduleBody:
      type: object
      properties:
        region:
          type: string
        map:
          type: string
        scheduled_for:
          type: string
          format: date-time
          description: ISO-8601 UTC
        schedule_label:
          type: string
          maxLength: 80
        tier:
          $ref: '#/components/schemas/ServerTier'
        config_preset:
          type: string
          default: tfcl_scrim
        custom_cfg:
          type: string
          nullable: true
      required:
      - region
      - map
      - scheduled_for
    MapEntry:
      type: object
      properties:
        id:
          type: integer
        display_name:
          type: string
        file_name:
          type: string
        size_bytes:
          type: integer
        uploader_id:
          type: integer
        uploader_username:
          type: string
        status:
          type: string
          enum:
          - active
          - removed
          - pending
        uploaded_at:
          type: string
          format: date-time
    Plan:
      type: string
      enum:
      - threemonth
      - sixmonth
      - year
    ApiKeyRecord:
      type: object
      properties:
        id:
          type: integer
        prefix:
          type: string
          description: first 12 chars of the key
        label:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
        scope:
          type: string
          enum:
          - premium
          - admin
        audit:
          type: array
          items:
            $ref: '#/components/schemas/ApiKeyAuditRow'
    ApiKeyAuditRow:
      type: object
      properties:
        endpoint:
          type: string
        method:
          type: string
        status:
          type: integer
        ip_hash:
          type: string
        at:
          type: string
          format: date-time
    ApiKeyIssueBody:
      type: object
      properties:
        label:
          type: string
          maxLength: 60
          nullable: true
        scope:
          type: string
          enum:
          - premium
          - admin
          default: premium
      required:
      - scope
    ApiKeyIssueResponse:
      type: object
      properties:
        key:
          $ref: '#/components/schemas/ApiKeyRecord'
        raw_key:
          type: string
          description: 'Shown once. Format: tfcl_pk_<43 chars> or tfcl_ak_<43 chars>.'
      required:
      - raw_key
    GiftCode:
      type: object
      properties:
        id:
          type: integer
        code:
          type: string
          description: XXXX-XXXX-XXXX-XXXX
        plan:
          $ref: '#/components/schemas/Plan'
        status:
          type: string
          enum:
          - active
          - redeemed
          - revoked
          - expired
        created_at:
          type: string
          format: date-time
        redeemed_at:
          type: string
          format: date-time
          nullable: true
        redeemed_by:
          type: integer
          nullable: true
        created_by:
          type: integer
    GiftCodeCreateBody:
      type: object
      properties:
        plan:
          $ref: '#/components/schemas/Plan'
        count:
          type: integer
          minimum: 1
          maximum: 100
          default: 1
      required:
      - plan
    PremiumStatus:
      type: object
      properties:
        is_premium:
          type: boolean
        premium_until:
          type: string
          format: date-time
          nullable: true
        plan:
          $ref: '#/components/schemas/Plan'
        source:
          type: string
          enum:
          - purchase
          - gift
          - admin_grant
    PointBalance:
      type: object
      properties:
        balance:
          type: integer
        lifetime_earned:
          type: integer
        lifetime_spent:
          type: integer
    PointTransaction:
      type: object
      properties:
        id:
          type: integer
        delta:
          type: integer
        reason:
          type: string
        metadata:
          type: object
          additionalProperties: true
        at:
          type: string
          format: date-time
    PointReward:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
        cost:
          type: integer
        kind:
          type: string
          enum:
          - name_color
          - profile_frame
          - custom
    RedeemRewardBody:
      type: object
      properties:
        reward_id:
          type: integer
      required:
      - reward_id
    SetNameColorBody:
      type: object
      properties:
        color:
          type: string
          nullable: true
          description: Hex color (#rrggbb) or null to clear
    PayPalOrderSummary:
      type: object
      properties:
        id:
          type: integer
        order_id:
          type: string
        amount_cents:
          type: integer
        points_awarded:
          type: integer
          nullable: true
        status:
          type: string
          enum:
          - created
          - approved
          - captured
          - error
        created_at:
          type: string
          format: date-time
    CommunityPost:
      type: object
      properties:
        id:
          type: integer
        category:
          type: string
          enum:
          - discussion
          - bug_report
          - feedback
          - question
        title:
          type: string
        body:
          type: string
        status:
          type: string
          enum:
          - open
          - resolved
          - wontfix
          - duplicate
        pinned:
          type: boolean
        author_id:
          type: integer
        author_username:
          type: string
        vote_count:
          type: integer
        comment_count:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CommunityComment:
      type: object
      properties:
        id:
          type: integer
        post_id:
          type: integer
        parent_id:
          type: integer
          nullable: true
        author_id:
          type: integer
        author_username:
          type: string
        body:
          type: string
        created_at:
          type: string
          format: date-time
    TournamentDetail:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        format:
          type: string
          enum:
          - singles
          - doubles
          - sixes
        status:
          type: string
          enum:
          - draft
          - pending_approval
          - approved
          - rejected
          - cancelled
          - running
          - completed
        max_participants:
          type: integer
        participant_count:
          type: integer
        starts_at:
          type: string
          format: date-time
        creator_id:
          type: integer
        signup_open:
          type: boolean
        rules:
          type: string
          nullable: true
    TournamentAuditEntry:
      type: object
      properties:
        id:
          type: integer
        tournament_id:
          type: integer
        actor_id:
          type: integer
          nullable: true
        action:
          type: string
        metadata:
          type: object
          additionalProperties: true
        at:
          type: string
          format: date-time
    TeamDetail:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        tag:
          type: string
        member_count:
          type: integer
        captain_id:
          type: integer
        captain_username:
          type: string
        password_protected:
          type: boolean
        created_at:
          type: string
          format: date-time
        members:
          type: array
          items:
            $ref: '#/components/schemas/UserPublic'
    CupSummary:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        format:
          type: string
          enum:
          - singles
          - doubles
          - sixes
        entry_fee_cents:
          type: integer
        prize_pool_cents:
          type: integer
        status:
          type: string
          enum:
          - draft
          - open
          - locked
          - running
          - completed
          - cancelled
        starts_at:
          type: string
          format: date-time
        team_count:
          type: integer
        max_teams:
          type: integer
    CupDetail:
      type: object
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        format:
          type: string
          enum:
          - singles
          - doubles
          - sixes
        entry_fee_cents:
          type: integer
        prize_pool_cents:
          type: integer
        status:
          type: string
        starts_at:
          type: string
          format: date-time
        team_count:
          type: integer
        max_teams:
          type: integer
        creator_id:
          type: integer
        rules:
          type: string
          nullable: true
        teams:
          type: array
          items:
            $ref: '#/components/schemas/TeamDetail'
    ActivityLogResponse:
      type: object
      properties:
        logged:
          type: boolean
        id:
          type: integer
          nullable: true
    ErrorLogResponse:
      type: object
      properties:
        logged:
          type: boolean
    PluginExtendBody:
      type: object
      properties:
        secret:
          type: string
        reservation_id:
          type: integer
        extension_minutes:
          type: integer
          minimum: 1
          maximum: 240
          default: 30
        reason:
          type: string
          nullable: true
      required:
      - secret
      - reservation_id
    PluginWhoBody:
      type: object
      properties:
        secret:
          type: string
        reservation_id:
          type: integer
      required:
      - secret
      - reservation_id
    PluginWhoResponse:
      type: object
      properties:
        reservation_id:
          type: integer
        active:
          type: boolean
        connected_players:
          type: array
          items:
            type: object
            properties:
              steam_id:
                type: string
              username:
                type: string
              connected_at:
                type: string
                format: date-time
        time_remaining_seconds:
          type: integer
    PluginReportMatchBody:
      type: object
      properties:
        secret:
          type: string
        reservation_id:
          type: integer
        match_id:
          type: integer
          nullable: true
        log_url:
          type: string
          format: uri
      required:
      - secret
      - reservation_id
      - log_url
    LobbyCreateBody:
      type: object
      properties:
        name:
          type: string
          maxLength: 80
        format:
          type: string
          enum:
          - sixes
          - ultiduo
        region:
          type: string
        max_players:
          type: integer
          minimum: 2
          maximum: 12
          default: 12
        map:
          type: string
        server_reservation_id:
          type: integer
          nullable: true
      required:
      - format
      - region
      - max_players
      - map
    VoteMapBody:
      type: object
      properties:
        map:
          type: string
      required:
      - map
    PickSlotBody:
      type: object
      properties:
        slot_index:
          type: integer
          minimum: 0
          maximum: 11
        team:
          type: integer
          enum:
          - 0
          - 1
      required:
      - slot_index
      - team
    SetClassBody:
      type: object
      properties:
        class:
          type: string
          enum:
          - scout
          - soldier
          - pyro
          - demoman
          - heavy
          - engineer
          - medic
          - sniper
          - spy
      required:
      - class
    ReportBody:
      type: object
      properties:
        winner:
          type: integer
          enum:
          - 0
          - 1
        team_a_score:
          type: integer
        team_b_score:
          type: integer
        notes:
          type: string
          nullable: true
        demo_url:
          type: string
          format: uri
          nullable: true
      required:
      - winner
    RconExecBody:
      type: object
      properties:
        command:
          type: string
          maxLength: 500
      required:
      - command
    FetchLogBody:
      type: object
      properties:
        log_url:
          type: string
          format: uri
      required:
      - log_url
    SubmitLogBody:
      type: object
      properties:
        log_text:
          type: string
        url:
          type: string
          format: uri
          nullable: true
      required:
      - log_text
    PartyCreateBody:
      type: object
      properties:
        name:
          type: string
          maxLength: 80
          nullable: true
    PartyInviteBody:
      type: object
      properties:
        to_steam_id:
          type: string
      required:
      - to_steam_id
    PartyKickBody:
      type: object
      properties:
        steam_id:
          type: string
      required:
      - steam_id
    SetCustomAvatarBody:
      type: object
      properties:
        avatar_url:
          type: string
          format: uri
      required:
      - avatar_url
    SetProfileMakeoverBody:
      type: object
      properties:
        name_color:
          type: string
          nullable: true
        profile_frame:
          type: string
          nullable: true
    UserBanBody:
      type: object
      properties:
        reason:
          type: string
          maxLength: 200
      required:
      - reason
    SetEloBody:
      type: object
      properties:
        elo:
          type: integer
          minimum: 0
          maximum: 5000
      required:
      - elo
    PointsAdminAwardBody:
      type: object
      properties:
        user_id:
          type: integer
        delta:
          type: integer
        reason:
          type: string
          maxLength: 200
      required:
      - user_id
      - delta
      - reason
    ReferClaimBody:
      type: object
      properties:
        code:
          type: string
      required:
      - code
    TournamentCreateBody:
      type: object
      properties:
        name:
          type: string
          maxLength: 100
        format:
          type: string
          enum:
          - singles
          - doubles
          - sixes
        max_participants:
          type: integer
          minimum: 2
          maximum: 256
        starts_at:
          type: string
          format: date-time
        rules:
          type: string
          nullable: true
      required:
      - name
      - format
      - max_participants
      - starts_at
    AdminCreateBody:
      type: object
      properties:
        user_id:
          type: integer
        role:
          type: string
          enum:
          - admin
          - mod
      required:
      - user_id
      - role
    TeamCreateBody:
      type: object
      properties:
        name:
          type: string
          maxLength: 60
        tag:
          type: string
          maxLength: 6
          minLength: 2
        password:
          type: string
          maxLength: 60
          nullable: true
      required:
      - name
      - tag
    TeamEditBody:
      type: object
      properties:
        name:
          type: string
          maxLength: 60
          nullable: true
        tag:
          type: string
          maxLength: 6
          nullable: true
        password:
          type: string
          maxLength: 60
          nullable: true
    TeamRoleBody:
      type: object
      properties:
        role:
          type: string
          enum:
          - captain
          - player
      required:
      - role
    TeamTransferBody:
      type: object
      properties:
        new_captain_user_id:
          type: integer
      required:
      - new_captain_user_id
    AnnouncementCreateBody:
      type: object
      properties:
        title:
          type: string
          maxLength: 100
        body:
          type: string
        severity:
          type: string
          enum:
          - info
          - warning
          - critical
          default: info
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          nullable: true
      required:
      - title
      - body
      - starts_at
    MapUploadBody:
      type: object
      properties:
        display_name:
          type: string
          maxLength: 80
        file_name:
          type: string
          pattern: ^[A-Za-z0-9_.\-]+\.bsp$
      required:
      - display_name
      - file_name
    PremiumCreateOrderBody:
      type: object
      properties:
        plan:
          $ref: '#/components/schemas/Plan'
      required:
      - plan
    PayPalCaptureBody:
      type: object
      properties:
        order_id:
          type: string
      required:
      - order_id
    ActivityLogBody:
      type: object
      properties:
        kind:
          type: string
          maxLength: 40
        data:
          type: object
          additionalProperties: true
      required:
      - kind
    ErrorLogBody:
      type: object
      properties:
        message:
          type: string
          maxLength: 500
        stack:
          type: string
          nullable: true
        url:
          type: string
          format: uri
          nullable: true
      required:
      - message
    ShareLogBody:
      type: object
      properties:
        log_text:
          type: string
        view_token:
          type: string
      required:
      - log_text
      - view_token
