openapi: 3.0.3
info:
  title: Galaxy Backend REST API
  version: v1
  description: |
    This specification documents the consolidated `galaxy/backend` REST surface
    consumed by `gateway` over the trusted internal network. It covers five
    route families:

    - `/api/v1/public/*` — unauthenticated public endpoints (only auth in MVP);
    - `/healthz`, `/readyz` — unauthenticated infrastructure probes;
    - `/api/v1/user/*` — authenticated end-user endpoints; the trusted
      `X-User-ID` header injected by gateway is the sole identity input;
    - `/api/v1/admin/*` — administrative endpoints gated by HTTP Basic Auth
      against the `admin_accounts` table;
    - `/api/v1/internal/*` — gateway-only server-to-server endpoints; trusted
      as part of the user surface in MVP (no extra auth).

    Every endpoint emits a JSON envelope of the shape
    `{"error":{"code":"...","message":"..."}}` on failure. The closed set of
    `code` values is enumerated under `components.schemas.ErrorBody`. JSON
    field names use `snake_case` everywhere on the wire.
servers:
  - url: http://backend.internal
    description: |
      Backend internal listener reachable only from gateway. The actual
      address is configured by `BACKEND_HTTP_LISTEN_ADDR`.
tags:
  - name: Public
    description: Unauthenticated public endpoints (registration and login).
  - name: Probes
    description: Liveness and readiness probes used by infrastructure tooling.
  - name: User
    description: Authenticated end-user endpoints; the trusted `X-User-ID` header carries identity.
  - name: Admin
    description: Administrator endpoints gated by HTTP Basic Auth.
  - name: Internal
    description: Gateway-only server-to-server endpoints used to lookup and revoke device sessions.
paths:
  /healthz:
    get:
      tags: [Probes]
      operationId: getHealthz
      summary: Liveness probe
      description: Returns `200` for as long as the process is alive.
      security: []
      responses:
        "200":
          description: Process is alive.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthzResponse"
        "500":
          $ref: "#/components/responses/InternalError"
  /readyz:
    get:
      tags: [Probes]
      operationId: getReadyz
      summary: Readiness probe
      description: |
        Returns `200` once the Postgres pool is open, embedded migrations are
        applied, and the gRPC push listener is bound. Returns `503` until all
        of those hold.
      security: []
      responses:
        "200":
          description: Process is ready to serve traffic.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReadyzResponse"
        "503":
          $ref: "#/components/responses/ServiceUnavailableError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/public/auth/send-email-code:
    post:
      tags: [Public]
      operationId: publicAuthSendEmailCode
      summary: Issue an e-mail login challenge
      description: |
        Requests a six-digit login code be e-mailed to the supplied address.
        Returns the same opaque `challenge_id` shape regardless of whether the
        target account exists, so callers cannot use this endpoint to enumerate
        user accounts. Permanently blocked addresses are rejected with `400`.
      security: []
      parameters:
        - $ref: "#/components/parameters/AcceptLanguage"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublicAuthSendEmailCodeRequest"
      responses:
        "200":
          description: Challenge accepted; an e-mail will be delivered out-of-band.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicAuthSendEmailCodeResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/public/auth/confirm-email-code:
    post:
      tags: [Public]
      operationId: publicAuthConfirmEmailCode
      summary: Confirm an e-mail login challenge
      description: |
        Confirms a previously issued `challenge_id` by submitting the delivered
        verification `code` together with the client's Ed25519 public key and
        IANA time zone. On success the backend creates a device session and
        returns its identifier.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublicAuthConfirmEmailCodeRequest"
      responses:
        "200":
          description: Device session created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicAuthConfirmEmailCodeResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/account:
    get:
      tags: [User]
      operationId: userAccountGet
      summary: Get the current user account aggregate
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Current account aggregate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/account/profile:
    patch:
      tags: [User]
      operationId: userAccountUpdateProfile
      summary: Update the caller's mutable profile fields
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateProfileRequest"
      responses:
        "200":
          description: Updated account aggregate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/account/settings:
    patch:
      tags: [User]
      operationId: userAccountUpdateSettings
      summary: Update the caller's settings fields
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateSettingsRequest"
      responses:
        "200":
          description: Updated account aggregate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/account/delete:
    post:
      tags: [User]
      operationId: userAccountDelete
      summary: Soft-delete the caller's account
      description: |
        Marks the caller's account `deleted_at` and triggers the documented
        in-process cascade across lobby, notification, and geo modules.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "204":
          description: Account scheduled for soft delete.
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games:
    get:
      tags: [User]
      operationId: userLobbyGamesList
      summary: List public lobby games with paging
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of public games.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GameSummaryPage"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [User]
      operationId: userLobbyGamesCreate
      summary: Create a new private lobby game owned by the caller
      description: |
        Always emits a `private` game owned by `X-User-ID`. Public games
        are created via `POST /api/v1/admin/games`.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LobbyGameCreateRequest"
      responses:
        "201":
          description: Game created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}:
    get:
      tags: [User]
      operationId: userLobbyGamesGet
      summary: Get the lobby game detail
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Lobby game detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    patch:
      tags: [User]
      operationId: userLobbyGamesUpdate
      summary: Update mutable lobby game fields (owner only)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LobbyGameUpdateRequest"
      responses:
        "200":
          description: Updated lobby game detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/open-enrollment:
    post:
      tags: [User]
      operationId: userLobbyGamesOpenEnrollment
      summary: Move a draft game into `enrollment_open`
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Enrollment opened.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/ready-to-start:
    post:
      tags: [User]
      operationId: userLobbyGamesReadyToStart
      summary: Mark a game `ready_to_start`
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Game transitioned to `ready_to_start`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/start:
    post:
      tags: [User]
      operationId: userLobbyGamesStart
      summary: Start the engine container for the game
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "202":
          description: Start request accepted; runtime job queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/pause:
    post:
      tags: [User]
      operationId: userLobbyGamesPause
      summary: Pause a running game
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Game paused.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/resume:
    post:
      tags: [User]
      operationId: userLobbyGamesResume
      summary: Resume a paused game
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Game resumed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/cancel:
    post:
      tags: [User]
      operationId: userLobbyGamesCancel
      summary: Cancel a game (owner)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Game cancelled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/retry-start:
    post:
      tags: [User]
      operationId: userLobbyGamesRetryStart
      summary: Retry a failed start
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "202":
          description: Retry queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/applications:
    post:
      tags: [User]
      operationId: userLobbyApplicationsSubmit
      summary: Submit an application to join a game
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LobbyApplicationSubmitRequest"
      responses:
        "201":
          description: Application created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyApplicationDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/applications/{application_id}/approve:
    post:
      tags: [User]
      operationId: userLobbyApplicationsApprove
      summary: Approve an application (owner or admin)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/ApplicationID"
      responses:
        "200":
          description: Application approved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyApplicationDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/applications/{application_id}/reject:
    post:
      tags: [User]
      operationId: userLobbyApplicationsReject
      summary: Reject an application (owner or admin)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/ApplicationID"
      responses:
        "200":
          description: Application rejected.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyApplicationDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/invites:
    post:
      tags: [User]
      operationId: userLobbyInvitesIssue
      summary: Issue an invite to join a private game
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LobbyInviteIssueRequest"
      responses:
        "201":
          description: Invite created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyInviteDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/redeem:
    post:
      tags: [User]
      operationId: userLobbyInvitesRedeem
      summary: Redeem an invite to create a membership
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/InviteID"
      responses:
        "200":
          description: Invite redeemed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyInviteDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/decline:
    post:
      tags: [User]
      operationId: userLobbyInvitesDecline
      summary: Decline an invite (recipient)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/InviteID"
      responses:
        "200":
          description: Invite declined.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyInviteDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/invites/{invite_id}/revoke:
    post:
      tags: [User]
      operationId: userLobbyInvitesRevoke
      summary: Revoke an invite (issuer)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/InviteID"
      responses:
        "200":
          description: Invite revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyInviteDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/memberships:
    get:
      tags: [User]
      operationId: userLobbyMembershipsList
      summary: List memberships for a game
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Memberships for the game.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyMembershipList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/memberships/{membership_id}/remove:
    post:
      tags: [User]
      operationId: userLobbyMembershipsRemove
      summary: Remove a membership (owner or self)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/MembershipID"
      responses:
        "200":
          description: Membership removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyMembershipDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/games/{game_id}/memberships/{membership_id}/block:
    post:
      tags: [User]
      operationId: userLobbyMembershipsBlock
      summary: Block a membership (owner)
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/MembershipID"
      responses:
        "200":
          description: Membership blocked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyMembershipDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/my/games:
    get:
      tags: [User]
      operationId: userLobbyMyGames
      summary: List games the caller participates in
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Caller's games.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MyGamesListResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/my/applications:
    get:
      tags: [User]
      operationId: userLobbyMyApplications
      summary: List the caller's applications
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Caller's applications.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyApplicationList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/my/invites:
    get:
      tags: [User]
      operationId: userLobbyMyInvites
      summary: List the caller's invites
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Caller's invites.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyInviteList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/my/race-names:
    get:
      tags: [User]
      operationId: userLobbyMyRaceNames
      summary: List the caller's race names
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Caller's race-name records.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RaceNameList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/race-names/register:
    post:
      tags: [User]
      operationId: userLobbyRaceNamesRegister
      summary: Promote a `pending_registration` to `registered`
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RaceNameRegisterRequest"
      responses:
        "200":
          description: Race name promoted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RaceNameDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/commands:
    post:
      tags: [User]
      operationId: userGamesCommands
      summary: Forward an engine command batch
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EngineCommand"
      responses:
        "200":
          description: Engine command result passed through.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassthroughObject"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/orders:
    post:
      tags: [User]
      operationId: userGamesOrders
      summary: Forward an engine order batch
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EngineOrder"
      responses:
        "200":
          description: |
            Engine order validation result passed through. Body is the
            engine's `UserGamesOrder` shape — game_id, updatedAt, and
            the per-command `cmd[]` list with `cmdApplied` /
            `cmdErrorCode` populated by the engine.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassthroughObject"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    get:
      tags: [User]
      operationId: userGamesGetOrders
      summary: Read the player's stored order for a turn
      description: |
        Forwards `GET /api/v1/order` against the engine container.
        The caller always knows the current turn from the lobby
        record at game boot, so `turn` is required.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - name: turn
          in: query
          required: true
          description: Turn number whose stored order to fetch. Non-negative.
          schema:
            type: integer
            format: int32
            minimum: 0
      responses:
        "200":
          description: |
            Engine returned the stored order for this player + turn.
            Body is the engine's `UserGamesOrder` shape.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassthroughObject"
        "204":
          description: No order has been stored for this player on this turn.
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/reports/{turn}:
    get:
      tags: [User]
      operationId: userGamesReport
      summary: Read an engine turn report
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/Turn"
      responses:
        "200":
          description: Engine report passed through.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassthroughObject"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}:
    get:
      tags: [User]
      operationId: userGamesBattle
      summary: Read one engine battle report
      description: |
        Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. The
        engine response body is passed through verbatim. `404 Not Found`
        is returned when the battle does not exist for the supplied
        `turn` / `battle_id` pair.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/Turn"
        - name: battle_id
          in: path
          required: true
          description: Battle identifier (RFC 4122 UUID).
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Engine battle report passed through.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PassthroughObject"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/messages:
    post:
      tags: [User]
      operationId: userMailSendPersonal
      summary: Send a personal diplomatic mail message
      description: |
        Sends a replyable personal message from the authenticated user
        to another active member of the same game. Both sender and
        recipient must be active members. Body is plain UTF-8 text
        (no HTML processing on the server); `subject` is optional.
        Body length is capped at `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
        (default 4096) and subject length at
        `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256).
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserMailSendRequest"
      responses:
        "201":
          description: Personal message accepted and persisted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailMessageDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/broadcast:
    post:
      tags: [User]
      operationId: userMailSendBroadcast
      summary: Send a paid-tier personal broadcast to a game's active members
      description: |
        Paid-tier players (`entitlement.is_paid == true`) may send one
        personal message that fans out to every other active member of
        the game. Free-tier callers receive 403. The resulting rows
        carry `kind="personal"`, `sender_kind="player"`,
        `broadcast_scope="game_broadcast"`. Recipients reply through
        the regular personal-send endpoint; the reply targets the
        broadcaster only.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserMailSendBroadcastRequest"
      responses:
        "201":
          description: Personal broadcast accepted; receipt carries the recipient count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailBroadcastReceipt"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/admin:
    post:
      tags: [User]
      operationId: userMailSendAdmin
      summary: Send a non-replyable admin notification (owner only)
      description: |
        Owner-only: the caller must be the owner of the private game.
        `target="user"` requires `recipient_user_id`; `target="all"`
        accepts an optional `recipients` scope (`active` by default,
        plus `active_and_removed` and `all_members`). The message
        carries `kind="admin"` and is therefore non-replyable.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserMailSendAdminRequest"
      responses:
        "201":
          description: Admin message persisted; broadcasts return a fan-out receipt.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/UserMailMessageDetail"
                  - $ref: "#/components/schemas/UserMailBroadcastReceipt"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/messages/{message_id}:
    get:
      tags: [User]
      operationId: userMailGet
      summary: Read one diplomatic mail message
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/MessageID"
      responses:
        "200":
          description: Message addressed to the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailMessageDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    delete:
      tags: [User]
      operationId: userMailDelete
      summary: Soft-delete a previously-read message
      description: |
        Marks the caller's recipient row for the message as deleted.
        The underlying message stays persisted (admin / system mail is
        retained for the lifetime of the game). The recipient row must
        have `read_at` set first; otherwise the call returns 409.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/MessageID"
      responses:
        "200":
          description: Message soft-deleted for the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailRecipientState"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/messages/{message_id}/read:
    post:
      tags: [User]
      operationId: userMailMarkRead
      summary: Mark a diplomatic mail message as read
      description: |
        Idempotent. Sets `read_at` on the caller's recipient row when
        it is still unread; a second call on an already-read row is a
        no-op and the existing state is returned.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
        - $ref: "#/components/parameters/MessageID"
      responses:
        "200":
          description: Recipient state after the mark-read.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailRecipientState"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/inbox:
    get:
      tags: [User]
      operationId: userMailInbox
      summary: List the caller's inbox for a game
      description: |
        Returns every non-soft-deleted mail row addressed to the
        caller in the given game, newest first. Includes the
        per-recipient read state. Soft access: the caller may not be
        an active member if every visible row carries
        `kind="admin"`.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Inbox entries for the caller in the given game.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailInboxList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/games/{game_id}/mail/sent:
    get:
      tags: [User]
      operationId: userMailSent
      summary: List the caller's sent personal messages in a game
      description: |
        Returns personal messages authored by the caller in the given
        game, newest first. Admin / system messages are not listed
        (they have no `sender_user_id`).
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Sent personal messages by the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailSentList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/lobby/mail/unread-counts:
    get:
      tags: [User]
      operationId: userMailUnreadCounts
      summary: Per-game and total unread mail counts for the caller
      description: |
        Drives the lobby badge: returns one entry per game the caller
        has any unread mail in, plus the global total. The response
        is empty (and `total == 0`) when there is nothing unread.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Per-game unread counts addressed to the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserMailUnreadCountsResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/sessions:
    get:
      tags: [User]
      operationId: userSessionsList
      summary: List the caller's active device sessions
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Caller's active device sessions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserSessionList"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/sessions/revoke-all:
    post:
      tags: [User]
      operationId: userSessionsRevokeAll
      summary: Revoke every device session belonging to the caller
      description: |
        Logout from every device. Subsequent authenticated requests on
        any of the caller's sessions are rejected. Each revocation is
        recorded in `session_revocations` with `actor_kind=user_self`.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
      responses:
        "200":
          description: Caller's sessions revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceSessionRevocationSummary"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/user/sessions/{device_session_id}/revoke:
    post:
      tags: [User]
      operationId: userSessionsRevoke
      summary: Revoke one of the caller's device sessions
      description: |
        Logout from a single device. The target `device_session_id`
        must belong to the caller; otherwise the endpoint returns
        `404 not_found` (the same shape as a missing session) so the
        endpoint cannot be used to probe foreign session ids. The
        revocation is recorded in `session_revocations` with
        `actor_kind=user_self`.
      security:
        - UserHeader: []
      parameters:
        - $ref: "#/components/parameters/XUserID"
        - $ref: "#/components/parameters/DeviceSessionID"
      responses:
        "200":
          description: Device session revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceSession"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/admin-accounts:
    get:
      tags: [Admin]
      operationId: adminAdminAccountsList
      summary: List admin accounts
      security:
        - AdminBasicAuth: []
      responses:
        "200":
          description: Admin accounts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccountList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Admin]
      operationId: adminAdminAccountsCreate
      summary: Create an admin account
      security:
        - AdminBasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminAccountCreateRequest"
      responses:
        "201":
          description: Admin account created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccount"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/admin-accounts/{username}:
    get:
      tags: [Admin]
      operationId: adminAdminAccountsGet
      summary: Get an admin account
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Username"
      responses:
        "200":
          description: Admin account.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccount"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/admin-accounts/{username}/disable:
    post:
      tags: [Admin]
      operationId: adminAdminAccountsDisable
      summary: Disable an admin account
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Username"
      responses:
        "200":
          description: Admin account disabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccount"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/admin-accounts/{username}/enable:
    post:
      tags: [Admin]
      operationId: adminAdminAccountsEnable
      summary: Enable an admin account
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Username"
      responses:
        "200":
          description: Admin account enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccount"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/admin-accounts/{username}/reset-password:
    post:
      tags: [Admin]
      operationId: adminAdminAccountsResetPassword
      summary: Reset an admin account password
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Username"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminAccountResetPasswordRequest"
      responses:
        "200":
          description: Password reset; the new value is delivered out-of-band.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminAccount"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/users:
    get:
      tags: [Admin]
      operationId: adminUsersList
      summary: List users
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of users.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminUserList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/users/{user_id}:
    get:
      tags: [Admin]
      operationId: adminUsersGet
      summary: Get a user account aggregate
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      responses:
        "200":
          description: Account aggregate.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/users/{user_id}/sanctions:
    post:
      tags: [Admin]
      operationId: adminUsersAddSanction
      summary: Apply a sanction to a user
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminUserSanctionRequest"
      responses:
        "200":
          description: Sanction applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/users/{user_id}/limits:
    post:
      tags: [Admin]
      operationId: adminUsersAddLimit
      summary: Apply a per-user limit override
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminUserLimitRequest"
      responses:
        "200":
          description: Limit applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/users/{user_id}/entitlements:
    post:
      tags: [Admin]
      operationId: adminUsersAddEntitlement
      summary: Update a user's entitlement
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminUserEntitlementRequest"
      responses:
        "200":
          description: Entitlement updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/users/{user_id}/soft-delete:
    post:
      tags: [Admin]
      operationId: adminUsersSoftDelete
      summary: Soft-delete a user (admin)
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      responses:
        "204":
          description: User scheduled for soft delete.
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/games:
    get:
      tags: [Admin]
      operationId: adminGamesList
      summary: List games for administration
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of games.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminGameList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Admin]
      operationId: adminGamesCreate
      summary: Create a public lobby game (admin-only)
      description: |
        Creates a public game owned collectively by administrators
        (`visibility=public`, `owner_user_id=NULL`). The user-facing
        `POST /api/v1/user/lobby/games` only creates private games.
      security:
        - AdminBasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminGameCreateRequest"
      responses:
        "201":
          description: Public game created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/games/{game_id}:
    get:
      tags: [Admin]
      operationId: adminGamesGet
      summary: Get an admin-side game detail
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Game detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameDetail"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/games/{game_id}/force-start:
    post:
      tags: [Admin]
      operationId: adminGamesForceStart
      summary: Force-start a game
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      responses:
        "202":
          description: Force-start queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/games/{game_id}/force-stop:
    post:
      tags: [Admin]
      operationId: adminGamesForceStop
      summary: Force-stop a game
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Force-stop accepted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyGameStateChange"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/games/{game_id}/ban-member:
    post:
      tags: [Admin]
      operationId: adminGamesBanMember
      summary: Ban a member from a game
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminGameBanMemberRequest"
      responses:
        "200":
          description: Member banned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LobbyMembershipDetail"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/broadcast:
    post:
      tags: [Admin]
      operationId: adminDiplomailBroadcast
      summary: Multi-game admin broadcast
      description: |
        Fans out one admin-kind broadcast across the games selected
        by `scope`. `scope="selected"` requires `game_ids`;
        `scope="all_running"` enumerates every game whose status is
        non-terminal. Recipients are resolved per-game via the same
        scope vocabulary as the per-game admin send. A recipient
        appearing in multiple addressed games receives one
        independently-deletable inbox entry per game.
      security:
        - AdminBasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminDiplomailBroadcastRequest"
      responses:
        "201":
          description: Broadcast accepted; per-game message ids and total recipient count.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminDiplomailBroadcastResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/cleanup:
    post:
      tags: [Admin]
      operationId: adminDiplomailCleanup
      summary: Bulk-purge diplomail messages from old finished games
      description: |
        Removes every `diplomail_messages` row whose game finished
        more than `older_than_years` years ago. Cascading FKs prune
        the recipient and translation tables in the same transaction.
        `older_than_years` must be >= 1.
      security:
        - AdminBasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AdminDiplomailCleanupRequest"
      responses:
        "200":
          description: Cleanup result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminDiplomailCleanupResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/messages:
    get:
      tags: [Admin]
      operationId: adminDiplomailList
      summary: Paginated admin view of diplomail messages
      description: |
        Returns the canonical message rows for admin observability.
        Optional filters: `game_id`, `kind` (personal / admin),
        `sender_kind` (player / admin / system). Pagination via
        `page` and `page_size`.
      security:
        - AdminBasicAuth: []
      parameters:
        - name: page
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
        - name: page_size
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
        - name: game_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
        - name: kind
          in: query
          required: false
          schema:
            type: string
            enum: [personal, admin]
        - name: sender_kind
          in: query
          required: false
          schema:
            type: string
            enum: [player, admin, system]
      responses:
        "200":
          description: Paginated diplomail messages.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminDiplomailListResponse"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/games/{game_id}/mail:
    post:
      tags: [Admin]
      operationId: adminDiplomailSend
      summary: Send a diplomatic-mail admin notification to one game
      description: |
        Site-admin send for the diplomatic-mail subsystem. Body shape
        mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin`
        endpoint. `target="user"` requires `recipient_user_id`;
        `target="all"` accepts an optional `recipients` scope
        (`active` / `active_and_removed` / `all_members`). The
        authenticated admin username is persisted as `sender_username`.
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserMailSendAdminRequest"
      responses:
        "201":
          description: Admin message persisted; broadcasts return a fan-out receipt.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/UserMailMessageDetail"
                  - $ref: "#/components/schemas/UserMailBroadcastReceipt"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "403":
          $ref: "#/components/responses/ForbiddenError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/runtimes/{game_id}:
    get:
      tags: [Admin]
      operationId: adminRuntimesGet
      summary: Read the runtime record for a game
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: Runtime record.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeRecord"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/runtimes/{game_id}/restart:
    post:
      tags: [Admin]
      operationId: adminRuntimesRestart
      summary: Restart the engine container for a game
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      responses:
        "202":
          description: Restart queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeOperation"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/runtimes/{game_id}/patch:
    post:
      tags: [Admin]
      operationId: adminRuntimesPatch
      summary: Patch the engine version (semver-patch only)
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RuntimePatchRequest"
      responses:
        "202":
          description: Patch queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeOperation"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/runtimes/{game_id}/force-next-turn:
    post:
      tags: [Admin]
      operationId: adminRuntimesForceNextTurn
      summary: Schedule a one-shot extra turn tick
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/GameID"
      responses:
        "200":
          description: One-shot tick scheduled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RuntimeOperation"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/engine-versions:
    get:
      tags: [Admin]
      operationId: adminEngineVersionsList
      summary: List engine versions
      security:
        - AdminBasicAuth: []
      responses:
        "200":
          description: Engine versions.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngineVersionList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Admin]
      operationId: adminEngineVersionsCreate
      summary: Register a new engine version
      security:
        - AdminBasicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EngineVersionCreateRequest"
      responses:
        "201":
          description: Engine version registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngineVersion"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/engine-versions/{id}:
    patch:
      tags: [Admin]
      operationId: adminEngineVersionsUpdate
      summary: Update an engine version record
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/EngineVersionID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EngineVersionUpdateRequest"
      responses:
        "200":
          description: Engine version updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngineVersion"
        "400":
          $ref: "#/components/responses/InvalidRequestError"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/engine-versions/{id}/disable:
    post:
      tags: [Admin]
      operationId: adminEngineVersionsDisable
      summary: Disable an engine version
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/EngineVersionID"
      responses:
        "200":
          description: Engine version disabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EngineVersion"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/deliveries:
    get:
      tags: [Admin]
      operationId: adminMailListDeliveries
      summary: List mail deliveries
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of mail deliveries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailDeliveryList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/deliveries/{delivery_id}:
    get:
      tags: [Admin]
      operationId: adminMailGetDelivery
      summary: Get a mail delivery
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/DeliveryID"
      responses:
        "200":
          description: Mail delivery.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailDelivery"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/deliveries/{delivery_id}/attempts:
    get:
      tags: [Admin]
      operationId: adminMailListDeliveryAttempts
      summary: List mail delivery attempts
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/DeliveryID"
      responses:
        "200":
          description: Mail delivery attempts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailAttemptList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/deliveries/{delivery_id}/resend:
    post:
      tags: [Admin]
      operationId: adminMailResendDelivery
      summary: Resend a non-sent mail delivery
      description: |
        Re-arms a delivery for another attempt cycle. Allowed states are
        `pending`, `retrying`, and `dead_lettered`. Resend on a `sent`
        delivery returns `409 Conflict` to prevent operators from
        accidentally dispatching a duplicate copy of an already-delivered
        mail.
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/DeliveryID"
      responses:
        "202":
          description: Resend scheduled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailDelivery"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "409":
          $ref: "#/components/responses/ConflictError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/mail/dead-letters:
    get:
      tags: [Admin]
      operationId: adminMailListDeadLetters
      summary: List mail dead-letters
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of dead-letters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailDeadLetterList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/notifications:
    get:
      tags: [Admin]
      operationId: adminNotificationsList
      summary: List notifications
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of notifications.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/notifications/{notification_id}:
    get:
      tags: [Admin]
      operationId: adminNotificationsGet
      summary: Get a notification
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/NotificationID"
      responses:
        "200":
          description: Notification.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationDetail"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/notifications/dead-letters:
    get:
      tags: [Admin]
      operationId: adminNotificationsListDeadLetters
      summary: List notification dead-letters
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of notification dead-letters.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationDeadLetterList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/notifications/malformed:
    get:
      tags: [Admin]
      operationId: adminNotificationsListMalformed
      summary: List malformed notification intents
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PageSize"
      responses:
        "200":
          description: Page of malformed intents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotificationMalformedList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/admin/geo/users/{user_id}/countries:
    get:
      tags: [Admin]
      operationId: adminGeoListUserCountries
      summary: List per-country counters for a user
      security:
        - AdminBasicAuth: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      responses:
        "200":
          description: Per-country counters for the user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GeoCountryCounterList"
        "401":
          $ref: "#/components/responses/UnauthorizedError"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/internal/sessions/{device_session_id}:
    get:
      tags: [Internal]
      operationId: internalSessionsGet
      summary: Look up a device session (gateway-only)
      security: []
      parameters:
        - $ref: "#/components/parameters/DeviceSessionID"
      responses:
        "200":
          description: Device session record.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeviceSession"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
  /api/v1/internal/users/{user_id}/account-internal:
    get:
      tags: [Internal]
      operationId: internalUsersGetAccountInternal
      summary: Server-to-server fetch of an account aggregate (gateway-only)
      security: []
      parameters:
        - $ref: "#/components/parameters/UserID"
      responses:
        "200":
          description: Account aggregate enriched for gateway flows.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AccountResponse"
        "404":
          $ref: "#/components/responses/NotFoundError"
        "501":
          $ref: "#/components/responses/NotImplementedError"
        "500":
          $ref: "#/components/responses/InternalError"
components:
  parameters:
    AcceptLanguage:
      name: Accept-Language
      in: header
      required: false
      description: |
        Optional RFC 9110 Accept-Language header forwarded by gateway. Backend
        uses it as a fallback locale source when the request body does not
        carry an explicit `locale` field.
      schema:
        type: string
    XUserID:
      name: X-User-ID
      in: header
      required: true
      description: |
        Trusted UUID identifying the calling user. Injected by gateway after
        request signature verification. Backend never re-derives identity from
        the request body on the user surface.
      schema:
        type: string
        format: uuid
    GameID:
      name: game_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    ApplicationID:
      name: application_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    InviteID:
      name: invite_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    MembershipID:
      name: membership_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    MessageID:
      name: message_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    NotificationID:
      name: notification_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DeliveryID:
      name: delivery_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    UserID:
      name: user_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    DeviceSessionID:
      name: device_session_id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    EngineVersionID:
      name: id
      in: path
      required: true
      schema:
        type: string
    Username:
      name: username
      in: path
      required: true
      schema:
        type: string
    Turn:
      name: turn
      in: path
      required: true
      schema:
        type: integer
        minimum: 0
    Page:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        default: 1
    PageSize:
      name: page_size
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
  securitySchemes:
    UserHeader:
      type: apiKey
      in: header
      name: X-User-ID
      description: |
        The trusted UUID forwarded by gateway after the device-session
        signature has been verified.
    AdminBasicAuth:
      type: http
      scheme: basic
      description: |
        Basic Auth credentials checked against `admin_accounts` with bcrypt
        cost 12. Failed authentication returns `401` with
        `WWW-Authenticate: Basic realm="galaxy-admin"`.
  schemas:
    HealthzResponse:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]
    ReadyzResponse:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status:
          type: string
          enum: [ready, starting]
    ErrorBody:
      type: object
      additionalProperties: false
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            Stable machine-readable failure marker. The closed set is
            `not_implemented`, `invalid_request`, `unauthorized`,
            `forbidden`, `not_found`, `conflict`, `method_not_allowed`,
            `internal_error`, `service_unavailable`,
            `turn_already_closed`, `game_paused`.
          enum:
            - not_implemented
            - invalid_request
            - unauthorized
            - forbidden
            - not_found
            - conflict
            - method_not_allowed
            - internal_error
            - service_unavailable
            - turn_already_closed
            - game_paused
        message:
          type: string
          description: Human-readable client-safe failure description.
    ErrorResponse:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          $ref: "#/components/schemas/ErrorBody"
    PublicAuthSendEmailCodeRequest:
      type: object
      additionalProperties: false
      required: [email]
      properties:
        email:
          type: string
          format: email
        locale:
          type: string
          description: |
            Optional BCP 47 locale tag preferred for the delivered code.
            Read by the gateway in preference to the request
            `Accept-Language` header so Safari clients (which silently
            drop JS-set `Accept-Language`) can still pick a non-system
            mail language. Empty / malformed values fall back to the
            header, which in turn falls back to `en`.
    PublicAuthSendEmailCodeResponse:
      type: object
      additionalProperties: false
      required: [challenge_id]
      properties:
        challenge_id:
          type: string
          description: Opaque identifier of the issued challenge.
    PublicAuthConfirmEmailCodeRequest:
      type: object
      additionalProperties: false
      required: [challenge_id, code, client_public_key, time_zone]
      properties:
        challenge_id:
          type: string
        code:
          type: string
          description: Verification code delivered by mail.
        client_public_key:
          type: string
          description: Standard base64-encoded raw 32-byte Ed25519 public key.
        time_zone:
          type: string
          description: IANA time-zone identifier provided by the client.
    PublicAuthConfirmEmailCodeResponse:
      type: object
      additionalProperties: false
      required: [device_session_id]
      properties:
        device_session_id:
          type: string
          format: uuid
    ActorRef:
      type: object
      additionalProperties: false
      required: [type]
      properties:
        type:
          type: string
        id:
          type: string
    EntitlementSnapshot:
      type: object
      additionalProperties: false
      required:
        - plan_code
        - is_paid
        - source
        - actor
        - reason_code
        - starts_at
        - max_registered_race_names
        - updated_at
      properties:
        plan_code:
          type: string
          enum: [free, monthly, yearly, permanent]
          description: |
            Closed tier vocabulary. The wire field name is `plan_code`;
            the storage column is `entitlement_snapshots.tier`.
        is_paid:
          type: boolean
        source:
          type: string
        actor:
          $ref: "#/components/schemas/ActorRef"
        reason_code:
          type: string
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          nullable: true
        max_registered_race_names:
          type: integer
          description: |
            Derived from the tier policy table. `free` accounts get 1;
            `monthly`, `yearly`, and `permanent` accounts get 5 in MVP.
        updated_at:
          type: string
          format: date-time
    ActiveSanction:
      type: object
      additionalProperties: false
      required: [sanction_code, scope, reason_code, actor, applied_at]
      properties:
        sanction_code:
          type: string
        scope:
          type: string
        reason_code:
          type: string
        actor:
          $ref: "#/components/schemas/ActorRef"
        applied_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
    ActiveLimit:
      type: object
      additionalProperties: false
      required: [limit_code, value, reason_code, actor, applied_at]
      properties:
        limit_code:
          type: string
        value:
          type: integer
        reason_code:
          type: string
        actor:
          $ref: "#/components/schemas/ActorRef"
        applied_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
          nullable: true
    Account:
      type: object
      additionalProperties: false
      required:
        - user_id
        - email
        - user_name
        - preferred_language
        - time_zone
        - entitlement
        - active_sanctions
        - active_limits
        - created_at
        - updated_at
      properties:
        user_id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        user_name:
          type: string
        display_name:
          type: string
        preferred_language:
          type: string
        time_zone:
          type: string
        declared_country:
          type: string
        entitlement:
          $ref: "#/components/schemas/EntitlementSnapshot"
        active_sanctions:
          type: array
          items:
            $ref: "#/components/schemas/ActiveSanction"
        active_limits:
          type: array
          items:
            $ref: "#/components/schemas/ActiveLimit"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    AccountResponse:
      type: object
      additionalProperties: false
      required: [account]
      properties:
        account:
          $ref: "#/components/schemas/Account"
    UpdateProfileRequest:
      type: object
      additionalProperties: false
      properties:
        display_name:
          type: string
          description: Replacement display name; an empty value clears the field.
    UpdateSettingsRequest:
      type: object
      additionalProperties: false
      properties:
        preferred_language:
          type: string
        time_zone:
          type: string
    GameSummary:
      type: object
      additionalProperties: false
      required:
        - game_id
        - game_name
        - game_type
        - status
        - min_players
        - max_players
        - enrollment_ends_at
        - created_at
        - updated_at
        - current_turn
      properties:
        game_id:
          type: string
          format: uuid
        game_name:
          type: string
        game_type:
          type: string
          enum: [public, private]
          description: |
            Wire alias for `visibility`; values match the storage column
            `games.visibility`. `public` games are admin-created and
            carry `owner_user_id IS NULL`; `private` games are owned by
            the calling user.
        status:
          type: string
          enum:
            - draft
            - enrollment_open
            - ready_to_start
            - starting
            - start_failed
            - running
            - paused
            - finished
            - cancelled
        owner_user_id:
          type: string
          format: uuid
          nullable: true
          description: |
            Owner user_id for private games; `null` for public games whose
            ownership is collective and managed by administrators.
        min_players:
          type: integer
          minimum: 1
        max_players:
          type: integer
          minimum: 1
        enrollment_ends_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        current_turn:
          type: integer
          description: |
            Most recent turn number observed by backend's runtime
            projection. Zero before the engine produces its first
            snapshot. The user surface uses it to fetch the matching
            `user.games.report` without a separate state query.
    GameSummaryPage:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/GameSummary"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    MyGamesListResponse:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/GameSummary"
    LobbyGameCreateRequest:
      type: object
      additionalProperties: false
      required:
        - game_name
        - visibility
        - min_players
        - max_players
        - start_gap_hours
        - start_gap_players
        - enrollment_ends_at
        - turn_schedule
        - target_engine_version
      properties:
        game_name:
          type: string
          minLength: 1
        visibility:
          type: string
          enum: [private]
          description: |
            User-facing game creation always emits `private` games. Public
            games are created by admins via `POST /api/v1/admin/games`.
        description:
          type: string
        min_players:
          type: integer
          minimum: 1
        max_players:
          type: integer
          minimum: 1
        start_gap_hours:
          type: integer
          minimum: 0
        start_gap_players:
          type: integer
          minimum: 0
        enrollment_ends_at:
          type: string
          format: date-time
        turn_schedule:
          type: string
          description: Five-field cron expression accepted by `pkg/cronutil.Parse`.
        target_engine_version:
          type: string
          description: |
            Engine version label (semver). Cross-checked against
            `engine_versions` at start time; rejected if no enabled row
            matches.
    LobbyGameUpdateRequest:
      type: object
      additionalProperties: false
      description: |
        Mutable lobby game fields (owner-only patch). Status transitions are
        driven through dedicated endpoints (`open-enrollment`,
        `ready-to-start`, `start`, `pause`, `resume`, `cancel`,
        `retry-start`).
      properties:
        game_name:
          type: string
          minLength: 1
        description:
          type: string
        enrollment_ends_at:
          type: string
          format: date-time
        turn_schedule:
          type: string
        target_engine_version:
          type: string
        min_players:
          type: integer
          minimum: 1
        max_players:
          type: integer
          minimum: 1
        start_gap_hours:
          type: integer
          minimum: 0
        start_gap_players:
          type: integer
          minimum: 0
    AdminGameCreateRequest:
      type: object
      additionalProperties: false
      required:
        - game_name
        - min_players
        - max_players
        - start_gap_hours
        - start_gap_players
        - enrollment_ends_at
        - turn_schedule
        - target_engine_version
      description: |
        Admin-side public-game creation. The `visibility` of the created
        record is hard-coded to `public` and `owner_user_id` is `NULL`.
      properties:
        game_name:
          type: string
          minLength: 1
        description:
          type: string
        min_players:
          type: integer
          minimum: 1
        max_players:
          type: integer
          minimum: 1
        start_gap_hours:
          type: integer
          minimum: 0
        start_gap_players:
          type: integer
          minimum: 0
        enrollment_ends_at:
          type: string
          format: date-time
        turn_schedule:
          type: string
        target_engine_version:
          type: string
    LobbyGameDetail:
      allOf:
        - $ref: "#/components/schemas/GameSummary"
        - type: object
          additionalProperties: false
          required:
            - visibility
            - turn_schedule
            - target_engine_version
            - start_gap_hours
            - start_gap_players
            - runtime_status
          properties:
            visibility:
              type: string
              enum: [public, private]
            description:
              type: string
            turn_schedule:
              type: string
            target_engine_version:
              type: string
            start_gap_hours:
              type: integer
            start_gap_players:
              type: integer
            runtime_status:
              type: string
            engine_health:
              type: string
            started_at:
              type: string
              format: date-time
              nullable: true
            finished_at:
              type: string
              format: date-time
              nullable: true
    LobbyGameStateChange:
      type: object
      additionalProperties: false
      required: [game_id, status]
      properties:
        game_id:
          type: string
          format: uuid
        status:
          type: string
        runtime_status:
          type: string
    LobbyApplicationSubmitRequest:
      type: object
      additionalProperties: false
      required: [race_name]
      properties:
        race_name:
          type: string
          minLength: 1
    LobbyApplicationDetail:
      type: object
      additionalProperties: false
      required:
        - application_id
        - game_id
        - applicant_user_id
        - race_name
        - status
        - created_at
      properties:
        application_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        applicant_user_id:
          type: string
          format: uuid
        race_name:
          type: string
        status:
          type: string
          enum: [pending, approved, rejected]
        created_at:
          type: string
          format: date-time
        decided_at:
          type: string
          format: date-time
          nullable: true
    LobbyApplicationList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LobbyApplicationDetail"
    LobbyInviteIssueRequest:
      type: object
      additionalProperties: false
      description: |
        Issues either a user-bound invite (when `invited_user_id` is set)
        or a one-shot code-based invite (when omitted). The server
        generates the redemption `code` for the latter.
      properties:
        invited_user_id:
          type: string
          format: uuid
        race_name:
          type: string
          minLength: 1
        expires_at:
          type: string
          format: date-time
    LobbyInviteDetail:
      type: object
      additionalProperties: false
      required:
        - invite_id
        - game_id
        - inviter_user_id
        - status
        - race_name
        - created_at
        - expires_at
      properties:
        invite_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        inviter_user_id:
          type: string
          format: uuid
        invited_user_id:
          type: string
          format: uuid
          nullable: true
        code:
          type: string
          nullable: true
        race_name:
          type: string
        status:
          type: string
          enum: [pending, redeemed, declined, revoked, expired]
        created_at:
          type: string
          format: date-time
        expires_at:
          type: string
          format: date-time
        decided_at:
          type: string
          format: date-time
          nullable: true
    LobbyInviteList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LobbyInviteDetail"
    LobbyMembershipDetail:
      type: object
      additionalProperties: false
      required:
        - membership_id
        - game_id
        - user_id
        - race_name
        - canonical_key
        - status
        - joined_at
      properties:
        membership_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        race_name:
          type: string
        canonical_key:
          type: string
        status:
          type: string
          enum: [active, removed, blocked]
        joined_at:
          type: string
          format: date-time
        removed_at:
          type: string
          format: date-time
          nullable: true
    LobbyMembershipList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LobbyMembershipDetail"
    RaceNameDetail:
      type: object
      additionalProperties: false
      required: [name, canonical, status, owner_user_id]
      properties:
        name:
          type: string
        canonical:
          type: string
        status:
          type: string
          enum: [registered, reservation, pending_registration]
        owner_user_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
          nullable: true
        source_game_id:
          type: string
          format: uuid
          nullable: true
        reserved_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
        registered_at:
          type: string
          format: date-time
          nullable: true
    RaceNameList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/RaceNameDetail"
    RaceNameRegisterRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
    EngineCommand:
      type: object
      additionalProperties: true
      description: |
        Engine command request body. The schema is permissive because the
        engine proxy passes the body through verbatim; the typed shape
        lives in `pkg/model/rest.Command` and is enforced by
        `internal/engineclient` before the engine call leaves backend.
    EngineOrder:
      type: object
      additionalProperties: true
      description: |
        Engine order request body. Permissive on the wire; typed shape
        lives in `pkg/model/order.Order`.
    PassthroughObject:
      type: object
      additionalProperties: true
      description: |
        Permissive placeholder used for engine pass-through responses
        (`pkg/model/{rest,report}` types are the authoritative shape).
    AdminAccount:
      type: object
      additionalProperties: false
      required: [username, created_at]
      properties:
        username:
          type: string
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
        disabled_at:
          type: string
          format: date-time
          nullable: true
    AdminAccountList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/AdminAccount"
    AdminAccountCreateRequest:
      type: object
      additionalProperties: false
      required: [username, password]
      properties:
        username:
          type: string
        password:
          type: string
          format: password
    AdminAccountResetPasswordRequest:
      type: object
      additionalProperties: false
      required: [password]
      properties:
        password:
          type: string
          format: password
    AdminUserList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/Account"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    AdminUserSanctionRequest:
      type: object
      additionalProperties: false
      required: [sanction_code, scope, reason_code, actor]
      properties:
        sanction_code:
          type: string
          enum: [permanent_block]
          description: |
            Closed MVP set; only `permanent_block` is supported. Applying
            it triggers the in-process cascade (revoke all sessions,
            release lobby memberships and Race Name Directory entries).
        scope:
          type: string
        reason_code:
          type: string
        actor:
          $ref: "#/components/schemas/ActorRef"
        expires_at:
          type: string
          format: date-time
          nullable: true
    AdminUserLimitRequest:
      type: object
      additionalProperties: false
      required: [limit_code, value, reason_code, actor]
      properties:
        limit_code:
          type: string
        value:
          type: integer
        reason_code:
          type: string
        actor:
          $ref: "#/components/schemas/ActorRef"
        expires_at:
          type: string
          format: date-time
          nullable: true
    AdminUserEntitlementRequest:
      type: object
      additionalProperties: false
      required: [tier, source, actor]
      properties:
        tier:
          type: string
          enum: [free, monthly, yearly, permanent]
        source:
          type: string
        actor:
          $ref: "#/components/schemas/ActorRef"
        reason_code:
          type: string
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
          nullable: true
    AdminGameList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/LobbyGameDetail"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    AdminGameBanMemberRequest:
      type: object
      additionalProperties: false
      required: [user_id, reason]
      properties:
        user_id:
          type: string
          format: uuid
        reason:
          type: string
          minLength: 1
    RuntimeRecord:
      type: object
      additionalProperties: true
      required: [game_id, status]
      properties:
        game_id:
          type: string
          format: uuid
        status:
          type: string
        current_container_id:
          type: string
        image_ref:
          type: string
        started_at:
          type: string
          format: date-time
          nullable: true
        last_observed_at:
          type: string
          format: date-time
          nullable: true
    RuntimeOperation:
      type: object
      additionalProperties: true
      required: [operation_id, game_id, op, status, started_at]
      properties:
        operation_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        op:
          type: string
        status:
          type: string
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true
        error:
          type: string
    RuntimePatchRequest:
      type: object
      additionalProperties: false
      required: [target_version]
      properties:
        target_version:
          type: string
          description: Semver-patch target inside the same major/minor line.
    EngineVersion:
      type: object
      additionalProperties: false
      required: [version, image_ref, enabled, created_at]
      properties:
        version:
          type: string
        image_ref:
          type: string
        enabled:
          type: boolean
        created_at:
          type: string
          format: date-time
    EngineVersionList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/EngineVersion"
    EngineVersionCreateRequest:
      type: object
      additionalProperties: false
      required: [version, image_ref]
      properties:
        version:
          type: string
        image_ref:
          type: string
        enabled:
          type: boolean
    EngineVersionUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        image_ref:
          type: string
        enabled:
          type: boolean
    MailDelivery:
      type: object
      additionalProperties: true
      required: [delivery_id, template_id, status, attempts, created_at]
      properties:
        delivery_id:
          type: string
          format: uuid
        template_id:
          type: string
        idempotency_key:
          type: string
        status:
          type: string
        attempts:
          type: integer
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
    MailDeliveryList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/MailDelivery"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    MailAttempt:
      type: object
      additionalProperties: true
      required: [attempt_id, delivery_id, attempt_no, started_at]
      properties:
        attempt_id:
          type: string
          format: uuid
        delivery_id:
          type: string
          format: uuid
        attempt_no:
          type: integer
        started_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true
        outcome:
          type: string
        error:
          type: string
    MailAttemptList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/MailAttempt"
    MailDeadLetter:
      type: object
      additionalProperties: true
      required: [dead_letter_id, delivery_id, archived_at]
      properties:
        dead_letter_id:
          type: string
          format: uuid
        delivery_id:
          type: string
          format: uuid
        archived_at:
          type: string
          format: date-time
        reason:
          type: string
    MailDeadLetterList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/MailDeadLetter"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    NotificationDetail:
      type: object
      additionalProperties: true
      required: [notification_id, kind, idempotency_key, created_at]
      properties:
        notification_id:
          type: string
          format: uuid
        kind:
          type: string
        idempotency_key:
          type: string
        user_id:
          type: string
          format: uuid
        payload:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
    NotificationList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/NotificationDetail"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    NotificationDeadLetter:
      type: object
      additionalProperties: true
      required: [dead_letter_id, notification_id, archived_at]
      properties:
        dead_letter_id:
          type: string
          format: uuid
        notification_id:
          type: string
          format: uuid
        archived_at:
          type: string
          format: date-time
        reason:
          type: string
    NotificationDeadLetterList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/NotificationDeadLetter"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    NotificationMalformed:
      type: object
      additionalProperties: true
      required: [id, received_at]
      properties:
        id:
          type: string
          format: uuid
        received_at:
          type: string
          format: date-time
        payload:
          type: object
          additionalProperties: true
        reason:
          type: string
    NotificationMalformedList:
      type: object
      additionalProperties: false
      required: [items, page, page_size, total]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/NotificationMalformed"
        page:
          type: integer
        page_size:
          type: integer
        total:
          type: integer
    GeoCountryCounter:
      type: object
      additionalProperties: false
      required: [country, count]
      properties:
        country:
          type: string
          description: ISO 3166-1 alpha-2 country code.
        count:
          type: integer
        last_seen_at:
          type: string
          format: date-time
          nullable: true
    GeoCountryCounterList:
      type: object
      additionalProperties: false
      required: [user_id, items]
      properties:
        user_id:
          type: string
          format: uuid
        items:
          type: array
          items:
            $ref: "#/components/schemas/GeoCountryCounter"
    DeviceSession:
      type: object
      additionalProperties: false
      required: [device_session_id, user_id, status, created_at]
      properties:
        device_session_id:
          type: string
          format: uuid
        user_id:
          type: string
          format: uuid
        status:
          type: string
        client_public_key:
          type: string
          description: Standard base64-encoded raw 32-byte Ed25519 public key.
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
          nullable: true
        last_seen_at:
          type: string
          format: date-time
          nullable: true
    DeviceSessionRevocationSummary:
      type: object
      additionalProperties: false
      required: [user_id, revoked_count]
      properties:
        user_id:
          type: string
          format: uuid
        revoked_count:
          type: integer
    UserSessionList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/DeviceSession"
    UserMailSendRequest:
      type: object
      additionalProperties: false
      required: [body]
      properties:
        recipient_user_id:
          type: string
          format: uuid
          description: |
            Either `recipient_user_id` or `recipient_race_name` must
            be supplied; supplying both is rejected as
            `invalid_request`.
        recipient_race_name:
          type: string
          description: |
            Resolves to the active member with this race name in the
            game. Mutually exclusive with `recipient_user_id`. The
            server returns `forbidden` when the matching member is no
            longer active (lobby-removed / blocked).
        subject:
          type: string
          description: |
            Optional subject. Empty string and missing field are
            treated the same.
        body:
          type: string
          description: Plain UTF-8 body. HTML is not parsed on the server.
    UserMailSendAdminRequest:
      type: object
      additionalProperties: false
      required: [target, body]
      properties:
        target:
          type: string
          enum: [user, all]
        recipient_user_id:
          type: string
          format: uuid
          description: |
            One of `recipient_user_id` and `recipient_race_name` is
            required when `target="user"`. Identifies the recipient
            of the personal admin message by uuid; the recipient may
            be in any membership status (admin notifications can
            reach kicked players when addressed by user_id).
        recipient_race_name:
          type: string
          description: |
            Optional alternative to `recipient_user_id` when
            `target="user"`. Resolves to the active member with this
            race name in the game; lobby-removed and blocked members
            cannot be reached through the race-name shortcut.
        recipients:
          type: string
          enum: [active, active_and_removed, all_members]
          description: |
            Optional scope when `target="all"`. Defaults to `active`.
        subject:
          type: string
        body:
          type: string
    UserMailBroadcastReceipt:
      type: object
      additionalProperties: false
      required:
        - message_id
        - game_id
        - kind
        - sender_kind
        - body
        - body_lang
        - broadcast_scope
        - created_at
        - recipient_count
      properties:
        message_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        game_name:
          type: string
        kind:
          type: string
          enum: [personal, admin]
        sender_kind:
          type: string
          enum: [player, admin, system]
        subject:
          type: string
        body:
          type: string
        body_lang:
          type: string
        broadcast_scope:
          type: string
          enum: [single, game_broadcast, multi_game_broadcast]
        created_at:
          type: string
          format: date-time
        recipient_count:
          type: integer
          minimum: 0
    UserMailSendBroadcastRequest:
      type: object
      additionalProperties: false
      required: [body]
      properties:
        subject:
          type: string
        body:
          type: string
    AdminDiplomailBroadcastRequest:
      type: object
      additionalProperties: false
      required: [scope, body]
      properties:
        scope:
          type: string
          enum: [selected, all_running]
        game_ids:
          type: array
          items:
            type: string
            format: uuid
        recipients:
          type: string
          enum: [active, active_and_removed, all_members]
        subject:
          type: string
        body:
          type: string
    AdminDiplomailBroadcastResponse:
      type: object
      additionalProperties: false
      required: [recipient_count, messages]
      properties:
        recipient_count:
          type: integer
          minimum: 0
        messages:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [message_id, game_id]
            properties:
              message_id:
                type: string
                format: uuid
              game_id:
                type: string
                format: uuid
              game_name:
                type: string
    AdminDiplomailCleanupRequest:
      type: object
      additionalProperties: false
      required: [older_than_years]
      properties:
        older_than_years:
          type: integer
          minimum: 1
    AdminDiplomailCleanupResponse:
      type: object
      additionalProperties: false
      required: [messages_deleted, game_ids]
      properties:
        messages_deleted:
          type: integer
          minimum: 0
        game_ids:
          type: array
          items:
            type: string
            format: uuid
    AdminDiplomailMessage:
      type: object
      additionalProperties: false
      required:
        - message_id
        - game_id
        - kind
        - sender_kind
        - body
        - body_lang
        - broadcast_scope
        - created_at
      properties:
        message_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        game_name:
          type: string
        kind:
          type: string
          enum: [personal, admin]
        sender_kind:
          type: string
          enum: [player, admin, system]
        sender_user_id:
          type: string
          format: uuid
          nullable: true
        sender_username:
          type: string
          nullable: true
        sender_ip:
          type: string
        subject:
          type: string
        body:
          type: string
        body_lang:
          type: string
        broadcast_scope:
          type: string
          enum: [single, game_broadcast, multi_game_broadcast]
        created_at:
          type: string
          format: date-time
    AdminDiplomailListResponse:
      type: object
      additionalProperties: false
      required: [total, page, page_size, items]
      properties:
        total:
          type: integer
          minimum: 0
        page:
          type: integer
          minimum: 1
        page_size:
          type: integer
          minimum: 1
        items:
          type: array
          items:
            $ref: "#/components/schemas/AdminDiplomailMessage"
    UserMailMessageDetail:
      type: object
      additionalProperties: false
      required:
        - message_id
        - game_id
        - kind
        - sender_kind
        - body
        - body_lang
        - broadcast_scope
        - created_at
        - recipient_user_id
      properties:
        message_id:
          type: string
          format: uuid
        game_id:
          type: string
          format: uuid
        game_name:
          type: string
        kind:
          type: string
          enum: [personal, admin]
        sender_kind:
          type: string
          enum: [player, admin, system]
        sender_user_id:
          type: string
          format: uuid
          nullable: true
        sender_username:
          type: string
          nullable: true
        sender_race_name:
          type: string
          nullable: true
          description: |
            Snapshot of the sender's race name in this game at send
            time. Populated when `sender_kind="player"` and the
            sender had an active membership at send time; nil for
            admin and system messages, and for player messages sent
            by a private-game owner who was not an active member at
            send time. The in-game UI keys per-race threading on this
            field.
        subject:
          type: string
        body:
          type: string
        body_lang:
          type: string
          description: BCP 47 tag. `und` until Stage D adds detection.
        broadcast_scope:
          type: string
          enum: [single, game_broadcast, multi_game_broadcast]
        created_at:
          type: string
          format: date-time
        recipient_user_id:
          type: string
          format: uuid
        recipient_user_name:
          type: string
        recipient_race_name:
          type: string
          nullable: true
        read_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
        translated_subject:
          type: string
          description: |
            Subject rendered into the caller's preferred_language by
            the translation cache. Absent when the caller's language
            matches `body_lang` or the translator could not produce
            a rendering.
        translated_body:
          type: string
          description: |
            Body rendered into the caller's preferred_language. Same
            absence semantics as `translated_subject`.
        translation_lang:
          type: string
          description: BCP 47 tag of the rendered translation.
        translator:
          type: string
          description: Identifier of the translation engine that produced the cached row.
    UserMailInboxList:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/UserMailMessageDetail"
    UserMailSentList:
      description: |
        Sender-side listing of personal messages authored by the
        caller. Each item carries the same shape as inbox entries
        (including the recipient snapshot); single sends contribute
        one row per message, broadcasts contribute one row per
        addressee so the in-game UI can collapse them by
        `message_id` into a single stand-alone item.
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: "#/components/schemas/UserMailMessageDetail"
    UserMailUnreadCount:
      type: object
      additionalProperties: false
      required: [game_id, unread]
      properties:
        game_id:
          type: string
          format: uuid
        game_name:
          type: string
        unread:
          type: integer
          minimum: 0
    UserMailUnreadCountsResponse:
      type: object
      additionalProperties: false
      required: [total, items]
      properties:
        total:
          type: integer
          minimum: 0
        items:
          type: array
          items:
            $ref: "#/components/schemas/UserMailUnreadCount"
    UserMailRecipientState:
      type: object
      additionalProperties: false
      required: [message_id]
      properties:
        message_id:
          type: string
          format: uuid
        read_at:
          type: string
          format: date-time
          nullable: true
        deleted_at:
          type: string
          format: date-time
          nullable: true
  responses:
    NotImplementedError:
      description: Endpoint is documented but not implemented yet.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            placeholder:
              value:
                error:
                  code: not_implemented
                  message: endpoint is not implemented yet
    InvalidRequestError:
      description: Request body or field values are invalid.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            invalidRequest:
              value:
                error:
                  code: invalid_request
                  message: request payload is invalid
    UnauthorizedError:
      description: Basic authentication credentials are missing or rejected.
      headers:
        WWW-Authenticate:
          description: Basic challenge advertised on rejected admin requests.
          schema:
            type: string
          example: Basic realm="galaxy-admin"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            unauthorized:
              value:
                error:
                  code: unauthorized
                  message: basic authentication is required
    ForbiddenError:
      description: Caller is authenticated but not allowed to perform the action.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            forbidden:
              value:
                error:
                  code: forbidden
                  message: caller is not authorized for this action
    NotFoundError:
      description: The requested resource was not found.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            notFound:
              value:
                error:
                  code: not_found
                  message: resource was not found
    ConflictError:
      description: The request conflicts with the current state of the target resource.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            conflict:
              value:
                error:
                  code: conflict
                  message: resource already exists
    MethodNotAllowedError:
      description: Request method is not allowed for the target route.
      headers:
        Allow:
          description: Comma-separated list of accepted methods.
          schema:
            type: string
          example: GET
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            methodNotAllowed:
              value:
                error:
                  code: method_not_allowed
                  message: request method is not allowed for this route
    InternalError:
      description: Internal backend error while processing the request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            internalError:
              value:
                error:
                  code: internal_error
                  message: internal server error
    ServiceUnavailableError:
      description: Backend is starting up or temporarily cannot serve the request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            unavailable:
              value:
                error:
                  code: service_unavailable
                  message: backend is not ready
