openapi: 3.0.3
info:
  title: TFCL Play API — Multi-League Platform Extension (v2)
  version: v2
  description: |
    REST API for TFCL Play (https://play.tfcleague.com) — v2 multi-tenant
    platform extension.

    This is the same product as the **[TFCL Play API (v1)](/api-docs/v1)**:
    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). v1 is the API the
    original TFCL Play league is built on — it is TFCL-specific.

    The TFCL Play premium-tier surface area in v1 (/api/premium/* — PayPal orders,
    gift codes, premium API keys, `premiumApiKey` auth) is a paid-membership feature
    within TFCL Play itself and is unrelated to the TFCL Prime league product above.

    v2 is what we ship when we want the **same deployment to host other
    leagues alongside TFCL Play**. TFCL Prime is itself one such league — it
    lives on its own subdomain (prime.tfcleague.com / currently alpha.tfcleague.com)
    and is the first major tenant we onboard onto the platform beyond the original
    TFCL Play league. Future tenants will follow the same pattern: seasonal
    spin-offs, regional variants, cup-specific leagues, partner communities
    we onboard (e.g. "Aussie Highlander Open", "EU Cup Series"), and dev/test
    leagues.

    ## What changed vs v1

    Everything in v1 still works. v1 endpoints are unchanged and continue
    to serve the original TFCL Play league as `leagueId = "tfcl"`. v2 adds
    an explicit top-level **League** entity and scoped endpoints under
    `/api/leagues/{leagueId}/...` so a single deployment can host many
    separate leagues (without cross-league contamination):

    - **v1 clients**: keep calling `/api/lobbies/`, `/api/teams/`, etc.
      Internally these are aliased to `/api/leagues/{tfcl}/...`.
      No breaking changes.
    - **v2 clients**: call `/api/leagues/{leagueId}/lobbies/`, etc.
      Required `leagueId` in path; resource shape otherwise identical to v1.

    ## Authentication

    v1's four credential shapes still work everywhere:
      - **sessionCookie** — HttpOnly cookie set by `/auth/steam/callback`.
      - **premiumApiKey** — `Authorization: Bearer tfcl_pk_<43 chars>`.
      - **adminApiKey** — `Authorization: Bearer tfcl_ak_<43 chars>`.
      - **pluginSecret** — `X-TFCL-Plugin-Secret: <RELAY_SECRET>`.

    v2 adds three league-scoped bearer-key shapes:
      - **leagueAdminApiKey**    — `Authorization: Bearer tfcl_lak_<43 chars>`.
        Operates only on resources whose `leagueId` matches the key's bound `leagueId`.
      - **leagueModeratorApiKey**— `Authorization: Bearer tfcl_lmk_<43 chars>`.
        Mutual with `leagueAdminApiKey`, but scopes are restricted (no ban / ELO
        override / points adjust — see `LeagueModeratorApiKey.scopes`).
      - **leaguePremiumApiKey**  — `Authorization: Bearer tfcl_lpk_<43 chars>`.
        Per-league premium feature grant when a user's global premium is not enough.

    All keys are stored as SHA-256 hash in `api_keys.key_hash`. The raw key is
    shown to the user exactly once on issuance.

    ## Authorization scope resolution (read this; it's the whole auth model)

    For every v2 endpoint under `/api/leagues/{leagueId}/...`, the effective
    auth tier is computed once per request, then the route handler applies its
    individual rule. Tiers (highest to lowest):

      1. **platform_admin** — `adminApiKey` belonging to a `users.is_admin=1` user.
         Operates on ANY `leagueId`, including those the user is not enrolled in.
      2. **league_admin**   — `leagueAdminApiKey` whose `api_keys.league_id` =
         `{leagueId}` AND `api_keys.scope='league_admin'`.
      3. **league_moderator**— `leagueModeratorApiKey` whose `api_keys.league_id`
         = `{leagueId}` AND `api_keys.scope='league_moderator'`. Scopes further
         restricted to moderator allowlist (see `LeagueModeratorApiKey.scopes`).
      4. **global_premium** — `premiumApiKey` whose user's `premium_expires_at`
         is active. Premium feature checks apply per league; a global premium
         user has PREMIUM on every league they are a member of.
      5. **authenticated**   — `sessionCookie` present, valid, not expired.
      6. **public**          — no auth required (only public-league endpoints).

    Cross-league data leakage is prevented by **two** enforced gates:
      (a) `api_keys.league_id` must equal path `{leagueId}` (for tier 2/3 keys).
      (b) Every SQL query under a league-scoped handler JOINs the resource table
          with `WHERE league_id = ?` parameterized by the path `{leagueId}`.
    There is no public proxy, no client-side filtering.

    ## Cross-league rules

    - **Users** are global. A user is "in" a league after their first interaction
      with it (creating a team, joining a lobby, posting an announcement, etc.).
      Membership is recorded in `league_user_profile` (one row per user per league).
    - **ELO, stats, points wallet, premium** are per-league. See `LeagueUserProfile`.
    - **A user can be captain of team A in league 1 and team B in league 2**;
      team membership and roles are league-scoped.
    - **Public league** endpoints respond to anyone; **private league** endpoints
      require tier 1-3 or a `league_user_profile.is_member=1` row in the league.

    ## Rate limits

    Per-key sliding window of 60 req/min. HTTP 429 with `Retry-After`. Cross-league
    API calls are NOT rate-limit-exempt; league-scoped keys count separately from
    global keys, so league-1 traffic can't starve league-2.

    ## Errors

    All error responses share `Error`: `{ "error": "message" }`. 400 (parse),
    401 (no/invalid auth), 403 (auth ok but not allowed for this tier OR
    `leagueId` mismatch OR no membership in private league),
    404 (resource-not-found OR `leagueId` not found),
    409 (state conflict), 429 (rate-limited).
  contact:
    name: TFCL Play · API Team
    url: https://play.tfcleague.com
  x-fuentes:
    - TFCL Play (community site, https://play.tfcleague.com) — the original league's API; also hosts the TFCL Play premium tier (/api/premium/*)
    - TFCL Prime (seasonal league structure, https://prime.tfcleague.com — currently https://alpha.tfcleague.com) — first off-platform tenant
    - Future tenants (seasonal spin-offs, regional variants, partner-league communities, dev/test leagues) hosted on the TFCL Play Platform
    - 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: Leagues
  description: Top-level management of leagues (create, list, inspect, update).
- name: Leagues·Public
  description: Anonymous-safe read endpoints scoped to a single league (live leaderboard, public lobbies, public announcements, recent matches). Private leagues hide these from non-members.
- name: League·Users
  description: Inspect a user's per-league profile (ELO, stats, league-specific roles, league-specific rewards).
- name: League·Lobbies
  description: Pickup-game lobby lifecycle scoped to a single league.
- name: League·Parties
  description: Pre-game parties in a league.
- name: League·Teams
  description: Team CRUD and management scoped to a league.
- name: League·Tournaments
  description: Tournament drafts, signups, brackets, and lifecycle within a league.
- name: League·Cups
  description: Single-elimination cups with paid entry within a league.
- name: League·Servers
  description: Server provisioning and reservation scoped to a league.
- name: League·Matchmaking
  description: Per-league matchmaking search (uses league map pool + formats).
- name: League·Points
  description: Per-league points wallet balance and transactions.
- name: League·Premium
  description: Per-league premium status (when league-level premium differs from global).
- name: League·Announcements
  description: League-scoped announcement board.
- name: League·Admin
  description: 'League-admin-only endpoints: bans, ELO overrides, points adjust, tournament approve/reject.'
- name: League·Admin·Keys
  description: Manage league-scoped admin/moderator API keys.
- name: League·Auth
  description: v2-only authentication endpoints (e.g. switch active league).

x-tfcl-v1base:
  description: |
    Reference URL for v1 endpoints. v2 clients that must remain compatible
    with v1 endpoints can use these path pointers in client codebases.

paths:
# ─────────────────────────────────────────────────────────────────────────────
# LEAGUES — Top-level resource
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/:
    get:
      tags: [Leagues]
      operationId: listLeagues
      summary: List leagues (public + admin visibility filter)
      description: |
        Returns leagues matching the `visibility` filter. Anonymous requests only
        see public leagues. Admins (tier 1) see all. Pagination via `limit`/`page`.
      security:
        - sessionCookie: []
        - adminApiKey: []
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
        - in: query
          name: visibility
          schema:
            type: string
            enum: [public, private, all]
            default: public
          description: Filter by visibility. `all` requires admin auth.
      responses:
        '200':
          description: Array of leagues (summary form).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeagueSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Leagues]
      operationId: createLeague
      summary: Create a new league (platform admin only)
      description: |
        Tier 1 (platform admin) only. Slug must be globally unique and match
        `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`. `id` is server-assigned (UUID v7).
      security:
        - adminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeagueCreate' }
      responses:
        '201':
          description: League created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/League' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: Slug already taken.

  /api/leagues/{leagueId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [Leagues]
      operationId: getLeague
      summary: Get one league by id
      description: |
        Public visibility check: if league is `private`, only tier 1-3 callers
        OR a `league_user_profile.is_member=1` row of the calling user may read.
      responses:
        '200':
          description: League detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/League' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Leagues, League·Admin]
      operationId: updateLeague
      summary: Update league metadata/settings (league admin or platform admin)
      description: |
        Tier 1 OR tier 2 (with matching `leagueId`). Partial update; only supplied
        fields are changed. Cannot change `id` or `slug` after creation.
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeagueUpdate' }
      responses:
        '200':
          description: Updated league.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/League' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            Caller is not a platform admin AND not a league admin for this
            `leagueId` (or their key's `league_id` does not match).
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Leagues]
      operationId: deleteLeague
      summary: Archive a league (platform admin only)
      description: |
        Tier 1 only. Default behavior is **archive** (reversible): setting
        `visibility='private'` and `archived_at=now()`. Optional `?hard=true`
        performs physical deletion of all rows tagged by this league. Hard delete
        is permanent; a confirmation token in request header `X-Confirm-Hard-Delete`
        is required for `hard=true`.
      security:
        - adminApiKey: []
      parameters:
        - in: query
          name: hard
          schema: { type: boolean, default: false }
        - in: header
          name: X-Confirm-Hard-Delete
          schema: { type: string }
          description: Required only when `hard=true`. Must equal the literal string "YES-I-UNDERSTAND".
      responses:
        '204': { description: Archived (or hard-deleted). }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE ADMIN KEYS (per-league auth credentials)
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/admin/keys/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Admin·Keys]
      operationId: listLeagueAdminKeys
      summary: List admin/moderator API keys issued for this league
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Array of league admin/moderator keys (no raw key material).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeagueApiKeySummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [League·Admin·Keys]
      operationId: createLeagueAdminKey
      summary: Issue a new league admin or moderator API key
      description: |
        Tier 1 OR tier 2 (with matching `leagueId`). The response includes the
        raw key ONCE; the server stores only the SHA-256 hash.
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeagueApiKeyCreate' }
      responses:
        '201':
          description: Issued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeagueApiKeyIssue' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/admin/keys/{keyId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: keyId
        required: true
        schema: { type: integer, format: int64 }
    delete:
      tags: [League·Admin·Keys]
      operationId: revokeLeagueAdminKey
      summary: Revoke a league admin/moderator API key
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '204': { description: Revoked. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE PUBLIC ENDPOINTS (anonymous-safe reads)
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/public/leaderboard:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [Leagues·Public]
      operationId: publicLeaderboard
      summary: Top users by ELO within this league
      description: |
        Equivalent to v1's `GET /api/public/leaderboard`, but scoped. Uses
        `league.settings.eloConfig` for any league-specific ranking rules.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Leaderboard rows.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LeaderboardRow' }
        '403':
          description: League is private and caller is not a member.
        '404': { $ref: '#/components/responses/NotFound' }

  /api/leagues/{leagueId}/public/lobbies:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [Leagues·Public]
      operationId: publicLobbies
      summary: Active lobbies in this league
      responses:
        '200':
          description: Active lobby codes + summary.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LobbyPublicSummary' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/leagues/{leagueId}/public/matches:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [Leagues·Public]
      operationId: publicMatches
      summary: Recent completed matches in this league
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Match summary array.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/MatchSummary' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/public/announcements:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [Leagues·Public]
      operationId: publicAnnouncements
      summary: Public announcements pinned in this league
      responses:
        '200':
          description: Announcement array.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Announcement' }
        '403': { $ref: '#/components/responses/Forbidden' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE-USER PROFILES
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/users/{userId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - $ref: '#/components/parameters/SteamId'
    get:
      tags: [League·Users]
      operationId: getLeagueUserProfile
      summary: Per-league profile for a user
      responses:
        '200':
          description: Profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeagueUserProfile' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: User has no profile in this league yet (caller may join for them via lobby/team action).
    patch:
      tags: [League·Users, League·Admin]
      operationId: updateLeagueUserProfile
      summary: Update per-league roles / metadata (league admin or platform admin)
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
        - leagueModeratorApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeagueUserProfileUpdate' }
      responses:
        '200':
          description: Updated profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeagueUserProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE-SCOPED LOBBIES
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/lobbies/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Lobbies]
      operationId: listLeagueLobbies
      summary: Lobbies in this league
      parameters:
        - in: query
          name: state
          schema: { type: string, enum: [draft, open, full, started, completed, cancelled] }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Lobby list (summary form).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/LobbySummary' }
    post:
      tags: [League·Lobbies]
      operationId: createLeagueLobby
      summary: Create a lobby in this league (mirrors v1 /api/lobbies/)
      description: |
        Tier 4+ to actually create. Requires authenticated tier 4 or higher.
        Format / map must come from `league.settings.mapPool` and
        `league.settings.formats`, otherwise 400.
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LobbyCreate' }
      responses:
        '201':
          description: Lobby created (status=open).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lobby' }
        '400':
          description: |
            Format / map not in league.settings.mapPool, or other validation
            failure.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/lobbies/{lobbyId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: lobbyId
        required: true
        schema: { type: string }
    get:
      tags: [League·Lobbies]
      operationId: getLeagueLobby
      summary: Get one lobby in this league
      responses:
        '200':
          description: Lobby detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lobby' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [League·Lobbies]
      operationId: updateLeagueLobby
      summary: Update mutable fields of a lobby (captain / moderator / admin)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LobbyUpdate' }
      responses:
        '200':
          description: Updated lobby.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Lobby' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [League·Lobbies]
      operationId: cancelLeagueLobby
      summary: Cancel a lobby (captain or admin)
      responses:
        '204': { description: Cancelled. }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE TEAMS
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/teams/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Teams]
      operationId: listLeagueTeams
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
        - in: query
          name: search
          schema: { type: string }
      responses:
        '200':
          description: Team list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TeamSummary' }
    post:
      tags: [League·Teams]
      operationId: createLeagueTeam
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TeamCreate' }
      responses:
        '201':
          description: Team created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Team' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/teams/{teamId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: teamId
        required: true
        schema: { type: integer, format: int64 }
    get:
      tags: [League·Teams]
      operationId: getLeagueTeam
      responses:
        '200':
          description: Team detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Team' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [League·Teams]
      operationId: updateLeagueTeam
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TeamUpdate' }
      responses:
        '200':
          description: Updated team.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Team' }
        '403': { $ref: '#/components/responses/Forbidden' }
    delete:
      tags: [League·Teams]
      operationId: deleteLeagueTeam
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '204': { description: Deleted. }
        '403': { $ref: '#/components/responses/Forbidden' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE TOURNAMENTS (and cups)
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/tournaments/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Tournaments]
      operationId: listLeagueTournaments
      parameters:
        - in: query
          name: state
          schema:
            type: string
            enum: [draft, published, running, completed, archived]
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Tournament list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TournamentSummary' }
    post:
      tags: [League·Tournaments]
      operationId: createLeagueTournament
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TournamentCreate' }
      responses:
        '201':
          description: Tournament (state=draft).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tournament' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/tournaments/{tournamentId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: tournamentId
        required: true
        schema: { type: integer, format: int64 }
    get:
      tags: [League·Tournaments]
      operationId: getLeagueTournament
      responses:
        '200':
          description: Tournament detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tournament' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/leagues/{leagueId}/tournaments/{tournamentId}/start:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: tournamentId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Tournaments]
      operationId: startLeagueTournament
      description: Promote draft → running (state change).
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '200':
          description: Tournament now in state=running.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tournament' }
        '409': { description: State conflict (wrong initial state). }

  /api/leagues/{leagueId}/tournaments/{tournamentId}/complete:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: tournamentId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Tournaments]
      operationId: completeLeagueTournament
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '200':
          description: Tournament now completed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tournament' }

  /api/leagues/{leagueId}/tournaments/{tournamentId}/disqualify/{teamId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: tournamentId
        required: true
        schema: { type: integer, format: int64 }
      - in: path
        name: teamId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Tournaments, League·Admin]
      operationId: disqualifyLeagueTournamentTeam
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
        - leagueModeratorApiKey: []
      responses:
        '200': { description: Team disqualified. }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/leagues/{leagueId}/cups/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Cups]
      operationId: listLeagueCups
      responses:
        '200':
          description: Cup list.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/CupSummary' }
    post:
      tags: [League·Cups]
      operationId: createLeagueCup
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CupCreate' }
      responses:
        '201':
          description: Cup created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Cup' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/cups/{cupId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: cupId
        required: true
        schema: { type: integer, format: int64 }
    get:
      tags: [League·Cups]
      operationId: getLeagueCup
      responses:
        '200':
          description: Cup detail.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Cup' }
        '404': { $ref: '#/components/responses/NotFound' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE SERVERS
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/servers/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Servers]
      operationId: listLeagueServers
      parameters:
        - in: query
          name: state
          schema: { type: string, enum: [idle, reserved, active, retired] }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Server list scoped to this league.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ServerSummary' }

  /api/leagues/{leagueId}/servers/provision:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    post:
      tags: [League·Servers]
      operationId: provisionLeagueServer
      description: |
        Server quotas are read from `league.settings.serverConfig`. A league
        restricting itself to 2 servers will reject the 3rd provision with 409.
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ServerProvisionRequest' }
      responses:
        '201':
          description: Server provisioning in progress.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Server' }

  /api/leagues/{leagueId}/servers/schedule:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    post:
      tags: [League·Servers]
      operationId: scheduleLeagueServer
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ServerScheduleRequest' }
      responses:
        '201':
          description: Reservation created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServerReservation' }

  /api/leagues/{leagueId}/servers/{serverId}:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: serverId
        required: true
        schema: { type: integer, format: int64 }
    delete:
      tags: [League·Servers]
      operationId: destroyLeagueServer
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '204': { description: Destroy request accepted. }
        '403': { $ref: '#/components/responses/Forbidden' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE MATCHMAKING
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/matchmaking/search:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    post:
      tags: [League·Matchmaking]
      operationId: startLeagueMatchmaking
      description: |
        Begins a search in this league. Uses `league.settings.mapPool`,
        `league.settings.formats`, and ready-check config from this league's settings.
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MatchmakingRequest' }
      responses:
        '202':
          description: Searching. Lobby will appear once match found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MatchmakingSearchHandle' }

  /api/leagues/{leagueId}/matchmaking/cancel:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    post:
      tags: [League·Matchmaking]
      operationId: cancelLeagueMatchmaking
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MatchmakingCancelRequest' }
      responses:
        '204': { description: Search cancelled. }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE POINTS / PREMIUM / ANNOUNCEMENTS
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/points/balance:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Points]
      operationId: getLeaguePointsBalance
      description: |
        Returns the caller's balance in this league. To inspect another user's
        balance, use tier 1-3 keys.
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      responses:
        '200':
          description: League points wallet.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsWallet' }

  /api/leagues/{leagueId}/points/transactions:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Points]
      operationId: getLeaguePointsTransactions
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Transaction log.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PointsTransaction' }

  /api/leagues/{leagueId}/premium/status:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Premium]
      operationId: getLeaguePremiumStatus
      description: |
        Returns this user's league-specific premium state. A user with global
        premium has premium access in ALL leagues; a user with league-specific
        premium (granted by a league admin or ticket) has access only in that
        league.
      security:
        - sessionCookie: []
        - premiumApiKey: []
        - leaguePremiumApiKey: []
      responses:
        '200':
          description: Premium status.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaguePremiumStatus' }

  /api/leagues/{leagueId}/announcements/:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    get:
      tags: [League·Announcements]
      operationId: listLeagueAnnouncements
      parameters:
        - in: query
          name: pinnedOnly
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: Announcement list (all if member, pinned-only if anonymous on public league).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Announcement' }
    post:
      tags: [League·Announcements, League·Admin]
      operationId: postLeagueAnnouncement
      security:
        - sessionCookie: []
        - adminApiKey: []
        - leagueAdminApiKey: []
        - leagueModeratorApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AnnouncementCreate' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Announcement' }
        '403': { $ref: '#/components/responses/Forbidden' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE ADMIN ACTIONS
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/admin/users/{userId}/ban:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - $ref: '#/components/parameters/SteamId'
    post:
      tags: [League·Admin]
      operationId: banLeagueUser
      description: |
        Bans the user from this league ONLY. Does not affect their global account
        or other league memberships.
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/LeagueBanRequest' }
      responses:
        '204': { description: Banned from this league. }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/admin/users/{userId}/unban:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - $ref: '#/components/parameters/SteamId'
    post:
      tags: [League·Admin]
      operationId: unbanLeagueUser
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '204': { description: Unbanned. }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/admin/tournaments/{tournamentId}/approve:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: tournamentId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Admin]
      operationId: approveLeagueTournament
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '200': { description: Tournament approved (state published). }

  /api/leagues/{leagueId}/admin/tournaments/{tournamentId}/reject:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: tournamentId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Admin]
      operationId: rejectLeagueTournament
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200': { description: Rejected. }

  /api/leagues/{leagueId}/admin/cups/{cupId}/approve:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: cupId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Admin]
      operationId: approveLeagueCup
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '200': { description: Cup approved. }

  /api/leagues/{leagueId}/admin/cups/{cupId}/reject:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - in: path
        name: cupId
        required: true
        schema: { type: integer, format: int64 }
    post:
      tags: [League·Admin]
      operationId: rejectLeagueCup
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      responses:
        '200': { description: Cup rejected. }

  /api/leagues/{leagueId}/admin/points/{userId}/adjust:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - $ref: '#/components/parameters/SteamId'
    post:
      tags: [League·Admin, League·Points]
      operationId: adjustLeaguePoints
      description: |
        Adjust the user's points wallet in this league (positive credit, negative
        debit, or zero for a no-op log entry). Records the reason and actor in
        the transaction log with `kind=admin_adjust`.
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PointsAdjustmentRequest' }
      responses:
        '200':
          description: New balance + transaction row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PointsWallet' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/leagues/{leagueId}/admin/rewards/{userId}/grant:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - $ref: '#/components/parameters/SteamId'
    post:
      tags: [League·Admin]
      operationId: grantLeagueReward
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RewardGrantRequest' }
      responses:
        '200':
          description: Granted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeagueReward' }

  /api/leagues/{leagueId}/admin/elo/{userId}/set:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
      - $ref: '#/components/parameters/SteamId'
    post:
      tags: [League·Admin]
      operationId: setLeagueElo
      description: |
        League-scoped ELO override. Does not touch global stats or
        other-league ELO.
      security:
        - adminApiKey: []
        - leagueAdminApiKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EloSetRequest' }
      responses:
        '200':
          description: New ELO + adjustment row.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeagueUserProfile' }

# ─────────────────────────────────────────────────────────────────────────────
# LEAGUE-AUTH HELPERS
# ─────────────────────────────────────────────────────────────────────────────

  /api/leagues/{leagueId}/auth/switch:
    parameters:
      - $ref: '#/components/parameters/LeagueId'
    post:
      tags: [League·Auth]
      operationId: switchActiveLeague
      summary: Switch the session's active league context
      description: |
        Sets a short-lived cookie `tfcl_active_league=<leagueId>` so multi-league
        clients with a v1 session can opt into which league's endpoints should
        be preferred when path is ambiguous. The session stays global; this
        only changes where the UI defaults to.
      security:
        - sessionCookie: []
      responses:
        '204': { description: Switched. }
        '404': { description: League not found or user has no profile. }

components:

  # ─── Reusable parameters ──────────────────────────────────────────────────

  parameters:
    LeagueId:
      in: path
      name: leagueId
      required: true
      schema: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$' }
      description: |
        Stable league identifier. Use `"tfcl"` to hit the canonical/legacy league.

    SteamId:
      in: path
      name: userId
      required: true
      schema: { type: string }
      description: SteamID64 of the target user.

    Limit:
      in: query
      name: limit
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }

    Page:
      in: query
      name: page
      schema: { type: integer, minimum: 1, default: 1 }

  # ─── Reusable responses ───────────────────────────────────────────────────

  responses:
    BadRequest:
      description: Invalid input.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: No valid auth credential.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Auth ok but caller is not allowed for this endpoint / tier / league.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource or league not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  # ─── Security schemes ─────────────────────────────────────────────────────

  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: tfcl_session
      description: HttpOnly cookie set by `/auth/steam/callback`. Tier 5.
    premiumApiKey:
      type: http
      scheme: bearer
      bearerFormat: tfcl_pk_<43 base64url>
      description: |
        Premium API key, prefixed `tfcl_pk_`. Stored as SHA-256 hash of the raw
        key. Tier 4, scope activated only while `users.premium_expires_at > now()`.
    adminApiKey:
      type: http
      scheme: bearer
      bearerFormat: tfcl_ak_<43 base64url>
      description: |
        Platform admin API key, prefixed `tfcl_ak_`. Tier 1 — cross-league access.
    pluginSecret:
      type: apiKey
      in: header
      name: X-TFCL-Plugin-Secret
      description: Shared secret for in-game plugin relay endpoints.
    leagueAdminApiKey:
      type: http
      scheme: bearer
      bearerFormat: tfcl_lak_<43 base64url>
      description: |
        Per-league admin key, prefixed `tfcl_lak_`. `api_keys.league_id` MUST
        match the path `{leagueId}` or the request is 403 (cross-league leakage guard).
    leagueModeratorApiKey:
      type: http
      scheme: bearer
      bearerFormat: tfcl_lmk_<43 base64url>
      description: |
        Per-league moderator key, prefixed `tfcl_lmk_`. Same `league_id` gate as
        `leagueAdminApiKey`. Scope-restricted to moderator allowlist.
    leaguePremiumApiKey:
      type: http
      scheme: bearer
      bearerFormat: tfcl_lpk_<43 base64url>
      description: |
        Per-league premium key, prefixed `tfcl_lpk_`. Bank this on a per-league
        table when a league offers league-only premium flavours.

  # ─── Schemas ──────────────────────────────────────────────────────────────

  schemas:

    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string }

    League:
      type: object
      required: [id, slug, name, visibility, settings, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid, example: '0190a1b6-2f47-7c93-9d56-1cc7e2a4d6b3' }
        slug: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$' }
        name: { type: string, example: TFCL }
        description: { type: string, nullable: true }
        visibility:
          type: string
          enum: [public, private]
          default: public
        settings: { $ref: '#/components/schemas/LeagueSettings' }
        archivedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    LeagueSummary:
      allOf:
        - $ref: '#/components/schemas/League'
        - type: object
          properties:
            memberCount: { type: integer }
            lobbyCount: { type: integer }

    LeagueSettings:
      type: object
      properties:
        ruleset:
          type: object
          properties:
            defaultFormat: { type: string, example: '6v6' }
            winCondition: { type: string, enum: [round_limit, time_limit, score_limit] }
            critMatchesAreCoop: { type: boolean }
        mapPool:
          type: array
          items: { type: string }
          example: ['koth_product', 'cp_badlands', 'cp_granary', 'cp_process_final']
        formats:
          type: array
          items: { type: string }
          example: ['6v6', '9v9', 'highlander']
        eloConfig:
          type: object
          properties:
            kFactor: { type: number, example: 24 }
            startingElo: { type: number, example: 1500 }
            inactivityDecayPerWeek: { type: number, example: 5 }
        serverConfig:
          type: object
          properties:
            regions:
              type: array
              items: { type: string }
              example: ['na', 'eu', 'apac']
            quotaPerRegion: { type: integer, example: 2 }
            maxActiveReservationsPerUser: { type: integer, example: 2 }
            rateLimits:
              type: object
              properties:
                provisionPerDay: { type: integer, example: 4 }

    LeagueCreate:
      type: object
      required: [slug, name, visibility, settings]
      properties:
        slug: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$' }
        name: { type: string }
        description: { type: string }
        visibility: { type: string, enum: [public, private], default: public }
        settings: { $ref: '#/components/schemas/LeagueSettings' }

    LeagueUpdate:
      type: object
      properties:
        name: { type: string }
        description: { type: string }
        visibility: { type: string, enum: [public, private] }
        settings: { $ref: '#/components/schemas/LeagueSettings' }

    LeagueApiKeySummary:
      type: object
      required: [id, leagueId, userId, scope, keyPrefix, createdAt]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        userId: { type: string }
        scope: { type: string, enum: [league_admin, league_moderator, league_premium] }
        label: { type: string, nullable: true }
        keyPrefix: { type: string, description: "Visible 8-char prefix, NOT the raw key" }
        scopes: { type: array, items: { type: string } }
        createdAt: { type: string, format: date-time }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }

    LeagueApiKeyCreate:
      type: object
      required: [userId, scope]
      properties:
        userId: { type: string, description: SteamID64 }
        scope: { type: string, enum: [league_admin, league_moderator, league_premium] }
        label: { type: string, example: 'NA-East maple league bot' }
        scopes:
          type: array
          items: { type: string }
          description: |
            Optional narrow scope list. When omitted, server uses scope defaults:
              - league_admin    -> all league-scoped write actions
              - league_moderator-> ban/unban/approve/reject/disqualify/lobby-moderate
              - league_premium  -> premium-feature endpoints only

    LeagueApiKeyIssue:
      allOf:
        - $ref: '#/components/schemas/LeagueApiKeySummary'
        - type: object
          required: [rawKey]
          properties:
            rawKey:
              type: string
              description: |
                Shown EXACTLY ONCE in this response. Future reads of the key
                resource return only `keyPrefix` and metadata.

    LeagueUserProfile:
      type: object
      required: [leagueId, userId, elo, stats, roles]
      properties:
        leagueId: { type: string }
        userId: { type: string }
        elo: { type: number, example: 1500 }
        wins: { type: integer }
        losses: { type: integer }
        draws: { type: integer }
        stats:
          type: object
          properties:
            kills: { type: integer }
            deaths: { type: integer }
            assists: { type: integer }
            matchesPlayed: { type: integer }
        roles:
          type: array
          items:
            type: string
            enum: [member, captain, league_moderator, league_admin]
        rewards:
          type: array
          items: { $ref: '#/components/schemas/LeagueReward' }
        joinedAt: { type: string, format: date-time }
        banned:
          type: boolean
          description: True if currently banned in this league.
        banReason: { type: string, nullable: true }
        banExpiresAt: { type: string, format: date-time, nullable: true }
        leaguePremium:
          $ref: '#/components/schemas/LeaguePremiumStatus'

    LeagueUserProfileUpdate:
      type: object
      properties:
        roles:
          type: array
          items: { type: string, enum: [member, captain, league_moderator, league_admin] }
        leaguePremium:
          $ref: '#/components/schemas/LeaguePremiumStatus'

    LeaguePremiumStatus:
      type: object
      required: [active, source]
      properties:
        active: { type: boolean }
        source:
          type: string
          enum: [global, league_grant, league_purchase, none]
          description: |
            `global`       — global premium grant (active in all leagues).
            `league_grant` — granted by a league admin within this league.
            `league_purchase` — purchased via the league-specific store.
            `none` — no premium in this league.
        expiresAt: { type: string, format: date-time, nullable: true }

    LeagueBanRequest:
      type: object
      required: [reason]
      properties:
        reason: { type: string }
        expiresAt: { type: string, format: date-time, nullable: true, description: "Null = permanent" }

    LeagueReward:
      type: object
      required: [id, kind, label]
      properties:
        id: { type: integer }
        kind: { type: string, enum: [cosmetic, badge, points, item] }
        label: { type: string }
        awardedAt: { type: string, format: date-time }

    RewardGrantRequest:
      type: object
      required: [kind, label]
      properties:
        kind: { type: string, enum: [cosmetic, badge, points, item] }
        label: { type: string }
        amount: { type: integer, description: Only when kind=points }

    LeaderboardRow:
      type: object
      required: [rank, userId, displayName, elo]
      properties:
        rank: { type: integer }
        userId: { type: string }
        displayName: { type: string }
        elo: { type: number }
        wins: { type: integer }
        losses: { type: integer }

    LobbyPublicSummary:
      type: object
      required: [code, map, format, state]
      properties:
        code: { type: string }
        map: { type: string }
        format: { type: string }
        state: { type: string }
        playerCount: { type: integer }
        maxPlayers: { type: integer }
        startedAt: { type: string, format: date-time, nullable: true }

    LobbySummary:
      allOf:
        - $ref: '#/components/schemas/LobbyPublicSummary'
        - type: object
          properties:
            leagueId: { type: string }
            createdAt: { type: string, format: date-time }

    Lobby:
      type: object
      required: [id, code, leagueId, state, map, format, players, createdAt, updatedAt]
      properties:
        id: { type: string }
        code: { type: string }
        leagueId: { type: string }
        state: { type: string, enum: [draft, open, full, started, completed, cancelled] }
        map: { type: string }
        format: { type: string }
        region: { type: string, nullable: true }
        serverId: { type: integer, nullable: true }
        captainSteamId: { type: string }
        players:
          type: array
          items: { $ref: '#/components/schemas/LobbyPlayer' }
        maxPlayers: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    LobbyPlayer:
      type: object
      required: [steamId, team, ready]
      properties:
        steamId: { type: string }
        team: { type: string, enum: [BLU, RED, spectators] }
        ready: { type: boolean }
        joinedAt: { type: string, format: date-time }

    LobbyCreate:
      type: object
      required: [map, format]
      properties:
        map:
          type: string
          description: 'Must be in league.settings.mapPool, else 400.'
        format:
          type: string
          description: 'Must be in league.settings.formats, else 400.'
        region: { type: string }
        maxPlayers: { type: integer, default: 12 }
        inviteOnly: { type: boolean, default: false }

    LobbyUpdate:
      type: object
      properties:
        region: { type: string }
        serverId: { type: integer }
        state: { type: string, enum: [draft, open, cancelled] }

    MatchSummary:
      type: object
      required: [id, map, leagueId, startedAt, completedAt]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        map: { type: string }
        format: { type: string }
        bluScore: { type: integer }
        redScore: { type: integer }
        winnerTeam: { type: string, enum: [BLU, RED, draw, none] }
        startedAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time }

    TeamSummary:
      type: object
      required: [id, leagueId, slug, name, tag]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        slug: { type: string }
        name: { type: string }
        tag: { type: string, maxLength: 6 }
        memberCount: { type: integer }

    Team:
      allOf:
        - $ref: '#/components/schemas/TeamSummary'
        - type: object
          properties:
            members:
              type: array
              items: { $ref: '#/components/schemas/TeamMember' }
            createdAt: { type: string, format: date-time }
            updatedAt: { type: string, format: date-time }

    TeamMember:
      type: object
      required: [userId, role]
      properties:
        userId: { type: string }
        role: { type: string, enum: [captain, player, sub] }
        joinedAt: { type: string, format: date-time }

    TeamCreate:
      type: object
      required: [name, tag]
      properties:
        slug: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$' }
        name: { type: string }
        tag: { type: string, maxLength: 6 }

    TeamUpdate:
      type: object
      properties:
        name: { type: string }
        tag: { type: string, maxLength: 6 }
        members: { type: array, items: { $ref: '#/components/schemas/TeamMember' } }

    TournamentSummary:
      type: object
      required: [id, leagueId, name, state]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        name: { type: string }
        state: { type: string, enum: [draft, published, running, completed, archived] }
        format: { type: string }
        teamCount: { type: integer }
        startDate: { type: string, format: date-time, nullable: true }

    Tournament:
      allOf:
        - $ref: '#/components/schemas/TournamentSummary'
        - type: object
          properties:
            ruleset: { $ref: '#/components/schemas/LeagueSettings/properties/ruleset' }
            bracket: { type: object, description: "Freeform JSON bracket." }
            signupClosesAt: { type: string, format: date-time, nullable: true }

    TournamentCreate:
      type: object
      required: [name, format]
      properties:
        name: { type: string }
        format: { type: string, description: Must be in league.settings.formats }
        ruleset: { $ref: '#/components/schemas/LeagueSettings/properties/ruleset' }
        signupClosesAt: { type: string, format: date-time }

    CupSummary:
      type: object
      required: [id, leagueId, name, state]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        name: { type: string }
        state: { type: string, enum: [draft, published, running, completed, cancelled] }
        entryFee: { type: number }
        prizePool: { type: number }
        teamCount: { type: integer }

    Cup:
      allOf:
        - $ref: '#/components/schemas/CupSummary'
        - type: object
          properties:
            format: { type: string }
            roster: { type: array, items: { $ref: '#/components/schemas/TeamSummary' } }
            matches: { type: array, items: { $ref: '#/components/schemas/MatchSummary' } }
            createdAt: { type: string, format: date-time }

    CupCreate:
      type: object
      required: [name, format, entryFee]
      properties:
        name: { type: string }
        format: { type: string }
        entryFee: { type: number, minimum: 0 }
        prizePool: { type: number, minimum: 0 }

    ServerSummary:
      type: object
      required: [id, leaseId, leagueId, region, state]
      properties:
        id: { type: integer }
        leaseId: { type: string }
        leagueId: { type: string }
        ownerUserId: { type: string }
        region: { type: string }
        state: { type: string, enum: [idle, reserved, active, retired] }
        hostName: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time, nullable: true }

    Server:
      allOf:
        - $ref: '#/components/schemas/ServerSummary'
        - type: object
          properties:
            map: { type: string, nullable: true }
            format: { type: string, nullable: true }
            readyCheckConfig: { type: object, nullable: true }

    ServerProvisionRequest:
      type: object
      required: [region]
      properties:
        region: { type: string, description: Must be in league.settings.serverConfig.regions }
        map: { type: string }
        format: { type: string }

    ServerScheduleRequest:
      type: object
      required: [resourceType, resourceId, startsAt]
      properties:
        resourceType: { type: string, enum: [lobby, tournament, cup, match] }
        resourceId: { type: string }
        startsAt: { type: string, format: date-time }
        durationMinutes: { type: integer, default: 90 }

    ServerReservation:
      type: object
      required: [id, serverId, startsAt, durationMinutes]
      properties:
        id: { type: integer }
        serverId: { type: integer }
        leagueId: { type: string }
        resourceType: { type: string, enum: [lobby, tournament, cup, match] }
        resourceId: { type: string }
        startsAt: { type: string, format: date-time }
        durationMinutes: { type: integer }

    MatchmakingRequest:
      type: object
      required: [mode]
      properties:
        mode: { type: string, enum: [pug_6v6, pug_9v9, highlander, casual] }
        preferredRegion: { type: string, nullable: true }
        allowCrossRegion: { type: boolean, default: false }

    MatchmakingCancelRequest:
      type: object
      properties:
        searchHandle: { type: string, description: "Returned from start endpoint, if absent, cancels all of caller's searches in this league." }

    MatchmakingSearchHandle:
      type: object
      required: [searchHandle, leagueId]
      properties:
        searchHandle: { type: string }
        leagueId: { type: string }
        expiresAt: { type: string, format: date-time }

    PointsWallet:
      type: object
      required: [leagueId, userId, balance]
      properties:
        leagueId: { type: string }
        userId: { type: string }
        balance: { type: integer }
        lifetimeEarned: { type: integer }
        lifetimeSpent: { type: integer }

    PointsTransaction:
      type: object
      required: [id, leagueId, userId, delta, balanceAfter, kind, createdAt]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        userId: { type: string }
        delta: { type: integer }
        balanceAfter: { type: integer }
        kind:
          type: string
          enum: [purchase, spend, refund, earn, admin_adjust]
        reason: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }

    PointsAdjustmentRequest:
      type: object
      required: [delta, reason]
      properties:
        delta:
          type: integer
          description: 'Positive credit; negative debit.'
        reason: { type: string }

    EloSetRequest:
      type: object
      required: [elo, reason]
      properties:
        elo: { type: number }
        reason: { type: string }

    Announcement:
      type: object
      required: [id, leagueId, title, body, createdAt, createdBy]
      properties:
        id: { type: integer }
        leagueId: { type: string }
        title: { type: string }
        body: { type: string }
        pinned: { type: boolean, default: false }
        createdAt: { type: string, format: date-time }
        createdBy: { type: string, description: SteamID64 }
        expiresAt: { type: string, format: date-time, nullable: true }

    AnnouncementCreate:
      type: object
      required: [title, body]
      properties:
        title: { type: string }
        body: { type: string }
        pinned: { type: boolean, default: false }
        expiresAt: { type: string, format: date-time, nullable: true }
