openapi: 3.1.0
info:
  title: Arkanis Public API
  description: Public REST surface for the Arkanis governance platform. Token-authenticated (Bearer); every endpoint scopes by guild_id. Phase 2 surfaces the read tier (§6 inventory minus list_notes).
  version: 0.1.0
servers:
  - url: https://api.arkanis.gg
  - url: http://localhost:4000
security:
  - bearerAuth: []
paths:
  /api/public/v1/guilds:
    get:
      operationId: list_guilds
      summary: List guilds the token has access to
      x-arkanis-tier: read
      x-arkanis-capability: (scope-only)
      security:
        - bearerAuth: []
      parameters: []
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GuildsEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Returns the guilds the bearer token has scope for, joined with the canonical guilds table. v1 returns at most one entry per token (each token currently scopes a single guild); next_cursor is always null.
  /api/public/v1/guilds/{guildId}/strikes:
    get:
      operationId: list_strikes
      summary: List strikes for a guild
      x-arkanis-tier: read
      x-arkanis-capability: enforcement_view
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: user_id
          in: query
          required: false
          schema:
            type: string
          description: Discord snowflake — filter strikes belonging to a single user.
        - name: include_inactive
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
          description: Set 'true' to include strikes that have been deactivated.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StrikesEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Paginated list of strikes in the guild. Default filter is `WHERE is_active = TRUE`; pass `include_inactive=true` to widen. Sorted by id DESC.
    post:
      operationId: strike_add
      summary: Issue a strike to a user
      x-arkanis-tier: destructive-write
      x-arkanis-capability: enforcement_strikes
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StrikeCreateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: 'Records a new strike against a player. Requires a valid sentinel (`_confirmation`) matching the canonical phrase and an open re-auth window. Returns 201 with the created strike summary. Capability: `enforcement_strikes`.'
      requestBody:
        required: true
        description: Strike details and destructive-write confirmation.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StrikeCreateRequest'
  /api/public/v1/guilds/{guildId}/strikes/{strikeId}:
    get:
      operationId: get_strike
      summary: Fetch a single strike by id
      x-arkanis-tier: read
      x-arkanis-capability: enforcement_view
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: strikeId
          in: path
          required: true
          schema:
            type: string
          description: Numeric strike id (string-encoded so client-side BIGINT handling is uniform).
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Strike'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
    delete:
      operationId: strike_remove
      summary: Remove (deactivate) a strike
      x-arkanis-tier: destructive-write
      x-arkanis-capability: enforcement_strikes
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: strikeId
          in: path
          required: true
          schema:
            type: string
          description: Numeric strike id (string-encoded for uniform BIGINT handling).
      responses:
        '204':
          description: No Content
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Deactivates the specified strike. Requires a valid sentinel (`_confirmation`) and an open re-auth window. Returns 204 No Content on success.
      requestBody:
        required: true
        description: Destructive-write confirmation.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfirmationOnlyRequest'
  /api/public/v1/guilds/{guildId}/tickets:
    get:
      operationId: list_tickets
      summary: List tickets for a guild
      x-arkanis-tier: read
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: user_id
          in: query
          required: false
          schema:
            type: string
          description: Discord snowflake — filter by ticket opener.
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - active
              - open
              - claimed
              - closed
              - all
          description: Ticket lifecycle filter. Default 'active' (open + claimed).
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketsEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Paginated list of tickets in the guild. Default filter is `status IN ('open', 'claimed')`; pass `status=closed`, `status=open`, `status=claimed`, or `status=all` to override. Sorted by id DESC.
  /api/public/v1/guilds/{guildId}/tickets/{ticketId}:
    get:
      operationId: get_ticket
      summary: Fetch a single ticket with transcript
      x-arkanis-tier: read
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: ticketId
          in: path
          required: true
          schema:
            type: string
          description: Numeric ticket id (string-encoded so client-side BIGINT handling is uniform).
        - name: include_full_transcript
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
          description: Set 'true' to fetch up to the hard-cap message count instead of the preview.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Returns a ticket plus its transcript. By default the transcript is preview-only (last N messages where N = PUBLIC_TRUNCATION_PREVIEW_MESSAGES, server-configured). Pass `include_full_transcript=true` to fetch up to PUBLIC_TRUNCATION_MAX_FULL_MESSAGES; the response carries a hard_cap_reached flag when that ceiling triggers.
    patch:
      operationId: ticket_set_status
      summary: Update ticket status
      x-arkanis-tier: reversible-write
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: ticketId
          in: path
          required: true
          schema:
            type: string
          description: Numeric ticket id.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketStatusUpdateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Transitions a ticket between `open` and `claimed` states. To close a ticket use `POST /close` — that has Discord-side effects (channel archive) that PATCH does not perform. Attempting to PATCH to `closed` returns 400.
      requestBody:
        required: true
        description: New status value (open or claimed only).
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketStatusUpdateRequest'
  /api/public/v1/guilds/{guildId}/bans:
    get:
      operationId: list_bans
      summary: List bans for a guild
      x-arkanis-tier: read
      x-arkanis-capability: enforcement_view
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: user_id
          in: query
          required: false
          schema:
            type: string
          description: Discord snowflake — filter bans for a single user.
        - name: in_game_id
          in: query
          required: false
          schema:
            type: string
          description: In-game identifier — filter bans against a specific in-game player.
        - name: include_lifted
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
          description: Set 'true' to include lifted (unbanned) entries.
        - name: sort
          in: query
          required: false
          schema:
            type: string
            enum:
              - id
              - banned_at
          description: Sort field. Default 'id'.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BansEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Paginated list of bans. Default filter is `WHERE unbanned_at IS NULL` (active bans only); pass `include_lifted=true` to widen. Default sort is id DESC; pass `sort=banned_at` to sort by ban timestamp (rows with identical banned_at may shuffle across pages — see Phase 2 plan note).
    post:
      operationId: ban_add
      summary: Ban a user
      x-arkanis-tier: destructive-write
      x-arkanis-capability: enforcement_bans
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BanCreateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Issues a ban against a player. Requires a valid sentinel (`_confirmation`) and an open re-auth window. Pass `duration_days` for a timed ban (auto-unbanned by the scheduler); omit or pass `null` for a permanent ban. Returns 201 with the ban reference.
      requestBody:
        required: true
        description: Ban details and destructive-write confirmation.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BanCreateRequest'
  /api/public/v1/guilds/{guildId}/mutes:
    get:
      operationId: list_mutes
      summary: List mutes for a guild
      x-arkanis-tier: read
      x-arkanis-capability: enforcement_view
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: user_id
          in: query
          required: false
          schema:
            type: string
          description: Discord snowflake — filter mutes for a single user.
        - name: in_game_id
          in: query
          required: false
          schema:
            type: string
          description: In-game identifier — filter mutes against a specific in-game player.
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum:
              - active
              - expired
              - removed
              - all
          description: Mute lifecycle filter. Default 'active'.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MutesEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Paginated list of mutes (rows in the `sanctions` table where `type='mute'`). Default filter is `status='active'`; pass `status=expired`, `status=removed`, or `status=all` to override. Sorted by starts_at DESC, id DESC.
    post:
      operationId: mute_add
      summary: Mute a user
      x-arkanis-tier: reversible-write
      x-arkanis-capability: enforcement_mute
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MuteCreateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Issues a mute (Discord timeout) against a player. Base tier is `reversible-write` for short durations (≤1440 minutes / 24 hours). When `duration_minutes > 1440` the endpoint promotes to `destructive-write` tier — in that case `_confirmation` is **required** and an open re-auth window must exist. For short durations `_confirmation` is accepted but not validated. Returns 201 with the created sanction summary.
      requestBody:
        required: true
        description: Mute details. `_confirmation` is required when `duration_minutes > 1440`.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MuteCreateRequest'
  /api/public/v1/guilds/{guildId}/audit-log:
    get:
      operationId: search_audit_log
      summary: List audit-log entries for a guild
      x-arkanis-tier: read
      x-arkanis-capability: enforcement_view
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: action_type
          in: query
          required: false
          schema:
            type: string
          description: Exact match on audit_log.action_type.
        - name: actor_source
          in: query
          required: false
          schema:
            type: string
            enum:
              - web
              - discord
              - bot
              - mcp
              - rest
              - system
              - unknown
              - discord_premium_apps
          description: Filter by the audit row's actor_source.
        - name: api_token_id
          in: query
          required: false
          schema:
            type: string
          description: ULID — filter audit rows attributed to a specific API token.
        - name: target_user_id
          in: query
          required: false
          schema:
            type: string
          description: Discord snowflake — filter rows whose target_user_id matches.
        - name: performed_by_id
          in: query
          required: false
          schema:
            type: string
          description: Discord snowflake — filter rows whose performed_by_id matches.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditLogEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Paginated read over the audit_log table. List items omit the `details` JSON column — use the per-entry endpoint to fetch it. `total_count` is ALWAYS null on this endpoint (R9 — audit_log can be millions of rows per guild; no COUNT(*) on the hot path).
  /api/public/v1/guilds/{guildId}/audit-log/{entryId}:
    get:
      operationId: get_audit_log_entry
      summary: Fetch a single audit-log entry with details payload
      x-arkanis-tier: read
      x-arkanis-capability: enforcement_view
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: entryId
          in: path
          required: true
          schema:
            type: string
          description: Numeric audit_log id (string-encoded for uniform BIGINT handling).
        - name: include_full_payload
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
          description: Set 'true' to fetch up to the hard-cap byte size instead of the preview.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditLogEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Returns one audit_log row including its `details` JSON column. The `details` field is truncated by default (preview = first N bytes where N = PUBLIC_TRUNCATION_PREVIEW_BYTES). Pass `include_full_payload=true` to fetch up to PUBLIC_TRUNCATION_MAX_FULL_BYTES; the response carries a hard_cap_reached flag when that ceiling triggers. `details` may be null (the column is nullable).
  /api/public/v1/guilds/{guildId}/players/lookup:
    get:
      operationId: lookup_player
      summary: Resolve a player by identifier
      x-arkanis-tier: read
      x-arkanis-capability: players_lookup
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: q
          in: query
          required: true
          schema:
            type: string
            minLength: 1
          description: 'Identifier to resolve: Discord snowflake, username, provider_id, or legacy alderon player_id.'
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Player'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: 'Canonical-first lookup: searches `player_identities.provider_id` first, then falls back to Discord snowflake (players.user_id), legacy alderon player_id (players.player_id), and Discord username (players.username). Returns a single Player object; no-match is 404.'
  /api/public/v1/guilds/{guildId}/players/{playerId}/history:
    get:
      operationId: get_player_history
      summary: Fetch a player's strike / ban / mute / ticket history
      x-arkanis-tier: read
      x-arkanis-capability: players_lookup
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: playerId
          in: path
          required: true
          schema:
            type: string
          description: Discord snowflake (BIGINT UNSIGNED).
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerHistory'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Returns up to 50 most-recent entries per list (strikes, bans, mutes, tickets) for the player. Joins on (guild_id, user_id) for tickets and on (guild_id, user_id) OR (guild_id, in_game_id) for strikes/bans/mutes so RCON-issued enforcement against unlinked players surfaces. Not paginated — for deeper history use the list endpoints with `user_id=`.
  /api/public/v1/guilds/{guildId}/servers:
    get:
      operationId: list_servers
      summary: List configured game servers for a guild
      x-arkanis-tier: read
      x-arkanis-capability: rcon_connection
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: include_inactive
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
              - 'false'
          description: Set 'true' to include disabled / soft-deleted / archived servers.
        - name: kind
          in: query
          required: false
          schema:
            type: string
            enum:
              - rcon
              - pterodactyl
          description: Restrict the list to a single source kind.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServersEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Returns the union of RCON-configured (`rcon_servers`) and Pterodactyl-configured (`pterodactyl_servers`) rows. Default filter requires `is_active = 1` and the per-kind not-deleted column; pass `include_inactive=true` to widen. Sorted DESC by id within each kind (rcon drained first, then pterodactyl).
  /api/public/v1/guilds/{guildId}/servers/{serverId}/status:
    get:
      operationId: get_server_status
      summary: Fetch live status for a configured server
      x-arkanis-tier: read
      x-arkanis-capability: rcon_connection
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: serverId
          in: path
          required: true
          schema:
            type: string
            pattern: ^(rcon|pterodactyl):[0-9]+$
          description: Composite server id of the form `${kind}:${internal_id}`.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerStatus'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Reaches the bot over its internal API for live state. For kind=rcon the response wraps the live player list into an api-owned envelope; for kind=pterodactyl the response is a thin proxy of the bot's server-info payload. Returns 503 UPSTREAM_UNAVAILABLE (retryable=true) when the bot is unreachable.
  /api/public/v1/guilds/{guildId}/servers/{serverId}/rcon/validate:
    post:
      operationId: validate_rcon_command
      summary: Validate an RCON command without executing it
      x-arkanis-tier: read
      x-arkanis-capability: rcon_announce
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: serverId
          in: path
          required: true
          schema:
            type: string
            pattern: ^(rcon|pterodactyl):[0-9]+$
          description: Composite server id (Pterodactyl ids are accepted but the validator is RCON-shaped).
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidateRconResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Dry-run validator. No RCON I/O, no bot invocation, no state change. Returns `{ valid, parsed?, reason? }` and writes an audit_log row (`action_type=validate_rcon_command`) with the redacted command shape per spec §5.5 / R6. Phase 2 surfaces validation only; execution is Phase 3+ destructive tier.
      requestBody:
        required: true
        description: Command to validate.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ValidateRconBody'
  /api/public/v1/guilds/{guildId}/servers/{serverId}/rcon/history:
    get:
      operationId: rcon_history
      summary: List past RCON commands for a server
      x-arkanis-tier: read
      x-arkanis-capability: rcon_announce
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: serverId
          in: path
          required: true
          schema:
            type: string
            pattern: ^(rcon|pterodactyl):[0-9]+$
          description: Composite server id. Pterodactyl serverIds yield an empty list.
        - name: cursor
          in: query
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page in next_cursor. HMAC-signed and token-bound; cannot be replayed across tokens.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Page size cap. Default 50, max 100.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RconHistoryEnvelope'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: 'Paginated read of `rcon_command_log` for the (guild_id, server_id). `command_data` is redacted server-side (password-style tokens stripped before the wire write per R6 / §5.5). `total_count` is null (R9-style: high-volume audit-like table — no COUNT(*) on the hot path). Pterodactyl servers return an empty envelope (RCON history is rcon-only).'
  /api/public/v1/guilds/{guildId}/bans/{userId}:
    delete:
      operationId: ban_remove
      summary: Lift (unban) a user
      x-arkanis-tier: destructive-write
      x-arkanis-capability: enforcement_bans
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: userId
          in: path
          required: true
          schema:
            type: string
          description: Discord snowflake of the banned user.
      responses:
        '204':
          description: No Content
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Lifts the active ban for the given Discord user id. Requires a valid sentinel (`_confirmation`) and an open re-auth window. Returns 204 No Content on success.
      requestBody:
        required: true
        description: Destructive-write confirmation and optional unban reason.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BanRemoveRequest'
  /api/public/v1/guilds/{guildId}/mutes/{userId}:
    delete:
      operationId: mute_remove
      summary: Lift a mute from a user
      x-arkanis-tier: destructive-write
      x-arkanis-capability: enforcement_mute
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: userId
          in: path
          required: true
          schema:
            type: string
          description: Discord snowflake of the muted user.
      responses:
        '204':
          description: No Content
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Removes the active mute for the given Discord user id. Requires a valid sentinel (`_confirmation`) and an open re-auth window. Returns 204 No Content on success.
      requestBody:
        required: true
        description: Destructive-write confirmation and player name.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MuteRemoveRequest'
  /api/public/v1/guilds/{guildId}/tickets/{ticketId}/claim:
    post:
      operationId: claim_ticket
      summary: Claim a ticket
      x-arkanis-tier: reversible-write
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: ticketId
          in: path
          required: true
          schema:
            type: string
          description: Numeric ticket id (string-encoded for uniform BIGINT handling).
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketActionResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Assigns the calling actor as the claimant of the ticket. Proxies to the bot's claim endpoint which updates Discord channel permissions. Returns the bot's response payload.
  /api/public/v1/guilds/{guildId}/tickets/{ticketId}/close:
    post:
      operationId: close_ticket
      summary: Close a ticket
      x-arkanis-tier: reversible-write
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: ticketId
          in: path
          required: true
          schema:
            type: string
          description: Numeric ticket id.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketActionResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Closes the ticket and triggers Discord-side cleanup (channel archive/delete per panel config). Requires a close reason. Proxies to the bot's close endpoint. Returns the bot's response payload.
      requestBody:
        required: true
        description: Close reason.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketCloseRequest'
  /api/public/v1/guilds/{guildId}/tickets/{ticketId}/members:
    post:
      operationId: add_ticket_member
      summary: Add a member to a ticket channel
      x-arkanis-tier: reversible-write
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: ticketId
          in: path
          required: true
          schema:
            type: string
          description: Numeric ticket id.
      responses:
        '201':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TicketAddMemberResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Adds a Discord user to the ticket's private channel. Proxies to the bot's add-user endpoint. Returns 201 with the added user id.
      requestBody:
        required: true
        description: Discord user id to add.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TicketAddMemberRequest'
  /api/public/v1/guilds/{guildId}/tickets/{ticketId}/members/{userId}:
    delete:
      operationId: remove_ticket_member
      summary: Remove a member from a ticket channel
      x-arkanis-tier: reversible-write
      x-arkanis-capability: tickets_manage
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: ticketId
          in: path
          required: true
          schema:
            type: string
          description: Numeric ticket id.
        - name: userId
          in: path
          required: true
          schema:
            type: string
          description: Discord snowflake of the user to remove.
      responses:
        '204':
          description: No Content
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Removes a Discord user from the ticket's private channel. Returns 204 No Content on success.
  /api/public/v1/guilds/{guildId}/mass-purge:
    post:
      operationId: mass_purge
      summary: Bulk-delete messages from a channel
      x-arkanis-tier: destructive-write
      x-arkanis-capability: moderation_clear
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MassPurgeResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Deletes up to 100 messages from the specified channel. Requires a valid sentinel (`_confirmation`) and an open re-auth window. The sentinel phrase is `PURGE {count} MESSAGES IN CHANNEL {channel_id} IN GUILD {guild_id}`. Returns the actual deleted count and channel name.
      requestBody:
        required: true
        description: Channel, message count, and destructive-write confirmation.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MassPurgeRequest'
  /api/public/v1/guilds/{guildId}/servers/{serverId}/rcon/run:
    post:
      operationId: rcon_run
      summary: Execute an RCON command on a server
      x-arkanis-tier: destructive-write
      x-arkanis-capability: rcon_announce
      security:
        - bearerAuth: []
      parameters:
        - name: guildId
          in: path
          required: true
          schema:
            type: string
          description: Discord guild snowflake.
        - name: serverId
          in: path
          required: true
          schema:
            type: string
            pattern: ^rcon:[0-9]+$
          description: Composite server id. Must be `rcon:N` — Pterodactyl servers are not supported by this endpoint.
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RconRunResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      description: Sends a raw RCON command to the specified server and returns the server's response text. Requires a valid sentinel (`_confirmation` = `RUN RCON ON SERVER {serverId} IN GUILD {guildId}`) and an open re-auth window. Only valid for `kind=rcon` servers. The `command` is redacted in the audit log — password-style tokens are stripped before writing.
      requestBody:
        required: true
        description: RCON command and destructive-write confirmation.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RconRunRequest'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: arkpat
      description: 'Arkanis-issued personal access token. Format: `arkpat_<base64url>`.'
  responses:
    BadRequest:
      description: Validation failed — bad parameters, malformed cursor, oversized request body, or unknown enum value.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Unauthorized:
      description: Missing, malformed, expired, or revoked Bearer token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    Forbidden:
      description: Capability check failed, re-auth required, or CORS denied for /api/public/v1/* browser-origin request.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    NotFound:
      description: Resource not found in this guild, or the resource id is malformed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
    RateLimited:
      description: Per-IP rate limit exceeded. Inspect X-RateLimit-* headers for the next-allowed time.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorBody'
  schemas:
    ErrorBody:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
            - retryable
            - request_id
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - FORBIDDEN
                - CAPABILITY_DENIED
                - INVALID_CONFIRMATION
                - RE_AUTH_REQUIRED
                - NOT_FOUND
                - VALIDATION_FAILED
                - RATE_LIMITED
                - UPSTREAM_UNAVAILABLE
                - TOKEN_EXPIRED
                - TOKEN_REVOKED
                - CORS_DENIED
                - INTERNAL_ERROR
              description: Canonical public error code. Mapped from internal AppError.code via publicCodeFor; unknown internal codes resolve to INTERNAL_ERROR.
            message:
              type: string
            retryable:
              type: boolean
              description: Hint to clients (including the MCP layer) whether retrying is meaningful. True for RATE_LIMITED, UPSTREAM_UNAVAILABLE, INTERNAL_ERROR.
            request_id:
              type:
                - string
                - 'null'
            details:
              type: object
              additionalProperties: true
              description: 'Optional structured payload (e.g. `{ missing: [...] }` for CAPABILITY_DENIED). Absent when the error has no extra context.'
    MessagePreview:
      description: Discriminated union returned by the message-truncation helper. Inspect `truncated` and `hard_cap_reached` to branch.
      oneOf:
        - type: object
          required:
            - truncated
            - messages
            - included_count
            - total_count
          properties:
            truncated:
              type: boolean
              enum:
                - false
            messages:
              type: array
              items:
                $ref: '#/components/schemas/TranscriptMessage'
            included_count:
              type: integer
            total_count:
              type: integer
        - type: object
          required:
            - truncated
            - hard_cap_reached
            - preview_messages
            - included_count
            - total_count
            - full_fetch_tool
            - full_fetch_args_hint
          properties:
            truncated:
              type: boolean
              enum:
                - true
            hard_cap_reached:
              type: boolean
              enum:
                - false
            preview_messages:
              type: array
              items:
                $ref: '#/components/schemas/TranscriptMessage'
            included_count:
              type: integer
            total_count:
              type: integer
            full_fetch_tool:
              type: string
            full_fetch_args_hint:
              type: object
              additionalProperties:
                type: boolean
        - type: object
          required:
            - truncated
            - hard_cap_reached
            - messages
            - preview_count
            - total_count
            - full_fetch_tool
          properties:
            truncated:
              type: boolean
              enum:
                - true
            hard_cap_reached:
              type: boolean
              enum:
                - true
            messages:
              type: array
              items:
                $ref: '#/components/schemas/TranscriptMessage'
            preview_count:
              type: integer
            total_count:
              type: integer
            full_fetch_tool:
              type: string
    BytesPreview:
      description: Discriminated union returned by the byte-truncation helper. Inspect `truncated` and `hard_cap_reached` to branch.
      oneOf:
        - type: object
          required:
            - truncated
            - payload
            - included_bytes
            - full_size_bytes
          properties:
            truncated:
              type: boolean
              enum:
                - false
            payload:
              type: string
            included_bytes:
              type: integer
            full_size_bytes:
              type: integer
        - type: object
          required:
            - truncated
            - hard_cap_reached
            - preview
            - included_bytes
            - full_size_bytes
            - full_fetch_tool
            - full_fetch_args_hint
          properties:
            truncated:
              type: boolean
              enum:
                - true
            hard_cap_reached:
              type: boolean
              enum:
                - false
            preview:
              type: string
            included_bytes:
              type: integer
            full_size_bytes:
              type: integer
            full_fetch_tool:
              type: string
            full_fetch_args_hint:
              type: object
              additionalProperties:
                type: boolean
        - type: object
          required:
            - truncated
            - hard_cap_reached
            - payload
            - included_bytes
            - full_size_bytes
            - full_fetch_tool
          properties:
            truncated:
              type: boolean
              enum:
                - true
            hard_cap_reached:
              type: boolean
              enum:
                - true
            payload:
              type: string
            included_bytes:
              type: integer
            full_size_bytes:
              type: integer
            full_fetch_tool:
              type: string
    Guild:
      type: object
      required:
        - guild_id
        - name
      properties:
        guild_id:
          type: string
        name:
          type: string
    GuildsEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Guild'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    Strike:
      type: object
      required:
        - id
        - user_id
        - player_name
        - in_game_id
        - reason
        - admin_id
        - admin_name
        - strike_number
        - is_active
        - created_at
      properties:
        id:
          type: string
        user_id:
          type:
            - string
            - 'null'
        player_name:
          type: string
        in_game_id:
          type:
            - string
            - 'null'
        reason:
          type: string
        admin_id:
          type: string
        admin_name:
          type: string
        strike_number:
          type: integer
        is_active:
          type: boolean
        created_at:
          type: string
          format: date-time
    StrikesEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Strike'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    Ticket:
      type: object
      required:
        - id
        - ticket_number
        - guild_id
        - user_id
        - username
        - status
        - opened_at
        - has_transcript
      properties:
        id:
          type: string
        ticket_number:
          type: integer
        guild_id:
          type: string
        channel_id:
          type:
            - string
            - 'null'
        user_id:
          type: string
        username:
          type: string
        panel_id:
          type:
            - integer
            - 'null'
        subject:
          type:
            - string
            - 'null'
        status:
          type: string
          enum:
            - open
            - claimed
            - closed
        claimed_by_id:
          type:
            - string
            - 'null'
        claimed_by_name:
          type:
            - string
            - 'null'
        claimed_at:
          type:
            - string
            - 'null'
          format: date-time
        closed_by_id:
          type:
            - string
            - 'null'
        closed_by_name:
          type:
            - string
            - 'null'
        close_reason:
          type:
            - string
            - 'null'
        opened_at:
          type: string
          format: date-time
        closed_at:
          type:
            - string
            - 'null'
          format: date-time
        has_transcript:
          type: boolean
        transcript_message_id:
          type:
            - string
            - 'null'
    TicketsEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Ticket'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    TranscriptMessage:
      type: object
      required:
        - id
        - message_id
        - user_id
        - username
        - created_at
      properties:
        id:
          type: string
        message_id:
          type: string
        user_id:
          type: string
        username:
          type: string
        content:
          type:
            - string
            - 'null'
        attachments:
          type:
            - array
            - 'null'
          items: {}
        embeds:
          type:
            - array
            - 'null'
          items: {}
        created_at:
          type: string
          format: date-time
        edited_at:
          type:
            - string
            - 'null'
          format: date-time
    TicketDetail:
      allOf:
        - $ref: '#/components/schemas/Ticket'
        - type: object
          required:
            - transcript
          properties:
            transcript:
              $ref: '#/components/schemas/MessagePreview'
    Ban:
      type: object
      required:
        - id
        - user_id
        - player_name
        - in_game_id
        - reason
        - banned_by_id
        - banned_by_name
        - banned_in_game
        - banned_at
        - unbanned_at
        - unbanned_by_id
      properties:
        id:
          type: string
        user_id:
          type:
            - string
            - 'null'
        player_name:
          type: string
        in_game_id:
          type:
            - string
            - 'null'
        reason:
          type: string
        banned_by_id:
          type: string
        banned_by_name:
          type: string
        banned_in_game:
          type: boolean
        banned_at:
          type: string
          format: date-time
        unbanned_at:
          type:
            - string
            - 'null'
          format: date-time
        unbanned_by_id:
          type:
            - string
            - 'null'
    BansEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Ban'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    Mute:
      type: object
      required:
        - id
        - player_name
        - type
        - duration_seconds
        - starts_at
        - ends_at
        - status
        - applied_by_id
        - applied_by_name
        - reason_text
        - trigger_source
        - discord_role_applied
      properties:
        id:
          type: string
        user_id:
          type:
            - string
            - 'null'
        player_name:
          type: string
        in_game_id:
          type:
            - string
            - 'null'
        reference_id:
          type:
            - string
            - 'null'
        type:
          type: string
          enum:
            - mute
        duration_seconds:
          type: integer
        starts_at:
          type: string
          format: date-time
        ends_at:
          type: string
          format: date-time
        status:
          type: string
          enum:
            - active
            - expired
            - removed
        applied_by_id:
          type: string
        applied_by_name:
          type: string
        reason_text:
          type: string
        trigger_source:
          type: string
        related_strike_id:
          type:
            - string
            - 'null'
        discord_role_applied:
          type: boolean
        removed_by_id:
          type:
            - string
            - 'null'
        removed_by_name:
          type:
            - string
            - 'null'
        removed_at:
          type:
            - string
            - 'null'
          format: date-time
    MutesEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Mute'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    AuditLogItem:
      type: object
      required:
        - id
        - action_type
        - performed_by_id
        - performed_by_name
        - created_at
        - actor_source
      properties:
        id:
          type: string
        action_type:
          type: string
        target_user_id:
          type:
            - string
            - 'null'
        target_player_name:
          type:
            - string
            - 'null'
        performed_by_id:
          type: string
        performed_by_name:
          type: string
        performed_by_display_name:
          type:
            - string
            - 'null'
        performed_by_authority_tier:
          type:
            - string
            - 'null'
        performed_by_top_role_name:
          type:
            - string
            - 'null'
        created_at:
          type: string
          format: date-time
        actor_source:
          type: string
          enum:
            - web
            - discord
            - bot
            - mcp
            - rest
            - system
            - unknown
            - discord_premium_apps
        tool_or_endpoint:
          type:
            - string
            - 'null'
        api_token_id:
          type:
            - string
            - 'null'
        mcp_client_name:
          type:
            - string
            - 'null'
        rest_user_agent:
          type:
            - string
            - 'null'
        confirmation_used:
          type:
            - string
            - 'null'
        reauth_window_id:
          type:
            - string
            - 'null'
    AuditLogEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/AuditLogItem'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    AuditLogEntry:
      allOf:
        - $ref: '#/components/schemas/AuditLogItem'
        - type: object
          required:
            - details
          properties:
            details:
              oneOf:
                - $ref: '#/components/schemas/BytesPreview'
                - type: 'null'
              description: Truncate-or-full JSON payload of the audit row's details column. Null when the column itself is null.
    PlayerIdentity:
      type: object
      required:
        - provider
        - provider_id
        - verified
        - created_at
        - updated_at
      properties:
        provider:
          type: string
          enum:
            - steam
            - xbox
            - epic
            - battlenet
            - riot
            - minecraft
            - alderon
        provider_id:
          type: string
        provider_name:
          type:
            - string
            - 'null'
        provider_avatar:
          type:
            - string
            - 'null'
        provider_profile_url:
          type:
            - string
            - 'null'
        verified:
          type: boolean
        verification_method:
          type:
            - string
            - 'null'
          enum:
            - manual
            - oauth
            - rcon
            - admin
            - null
        verified_at:
          type:
            - string
            - 'null'
          format: date-time
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Player:
      type: object
      required:
        - user_id
        - username
        - steam_verified
        - alderon_verified
        - discord_verified
        - created_at
        - updated_at
        - identities
      properties:
        user_id:
          type: string
        username:
          type: string
        player_id:
          type:
            - string
            - 'null'
        player_name:
          type:
            - string
            - 'null'
        steam_id:
          type:
            - string
            - 'null'
        steam_name:
          type:
            - string
            - 'null'
        steam_verified:
          type: boolean
        alderon_verified:
          type: boolean
        discord_verified:
          type: boolean
        verified_at:
          type:
            - string
            - 'null'
          format: date-time
        verification_method:
          type:
            - string
            - 'null'
          enum:
            - manual
            - oauth
            - rcon
            - admin
            - null
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        identities:
          type: array
          items:
            $ref: '#/components/schemas/PlayerIdentity'
    PlayerHistory:
      type: object
      required:
        - user_id
        - strikes
        - bans
        - mutes
        - tickets
      properties:
        user_id:
          type: string
        strikes:
          type: array
          items:
            $ref: '#/components/schemas/Strike'
        bans:
          type: array
          items:
            $ref: '#/components/schemas/Ban'
        mutes:
          type: array
          items:
            $ref: '#/components/schemas/Mute'
        tickets:
          type: array
          items:
            $ref: '#/components/schemas/Ticket'
    Server:
      type: object
      required:
        - id
        - kind
        - internal_id
        - server_name
        - is_default
        - is_active
        - created_at
      properties:
        id:
          type: string
          description: Composite id `${kind}:${internal_id}` — use this as the `:serverId` path param.
        kind:
          type: string
          enum:
            - rcon
            - pterodactyl
        internal_id:
          type: integer
        server_name:
          type: string
        game_type:
          type:
            - string
            - 'null'
        host:
          type:
            - string
            - 'null'
        port:
          type:
            - integer
            - 'null'
        pterodactyl_server_id:
          type:
            - string
            - 'null'
        pterodactyl_server_uuid:
          type:
            - string
            - 'null'
        is_default:
          type: boolean
        is_active:
          type: boolean
        last_connected_at:
          type:
            - string
            - 'null'
          format: date-time
        last_tested_at:
          type:
            - string
            - 'null'
          format: date-time
        last_error_code:
          type:
            - string
            - 'null'
        last_status:
          type:
            - string
            - 'null'
        last_synced_at:
          type:
            - string
            - 'null'
          format: date-time
        max_players:
          type:
            - integer
            - 'null'
        created_at:
          type: string
          format: date-time
    ServersEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Server'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    ServerStatus:
      type: object
      required:
        - id
        - kind
        - internal_id
        - status
      properties:
        id:
          type: string
        kind:
          type: string
          enum:
            - rcon
            - pterodactyl
        internal_id:
          type: integer
        status:
          description: 'Per-kind status payload. For kind=rcon the api owns the shape: `{ kind, server_id, online, player_count, players, last_checked_at }`. For kind=pterodactyl the bot owns the shape — passthrough of /bot/api/pterodactyl/server-info.'
    ValidateRconBody:
      type: object
      required:
        - command
      properties:
        command:
          type: string
          minLength: 1
          maxLength: 2000
    ValidateRconResponse:
      type: object
      required:
        - valid
      properties:
        valid:
          type: boolean
        reason:
          type: string
          description: Set when valid=false. Human-readable cause.
        parsed:
          type: object
          required:
            - command
            - args
          properties:
            command:
              type: string
            args:
              type: array
              items:
                type: string
          description: 'Set when valid=true. Whitespace-tokenized echo: first token is the command, rest are positional args.'
    RconHistoryItem:
      type: object
      required:
        - id
        - command_type
        - executed_by_id
        - command_data
        - success
        - executed_at
      properties:
        id:
          type: string
        server_id:
          type:
            - integer
            - 'null'
        command_type:
          type: string
        target_player_id:
          type:
            - string
            - 'null'
        executed_by_id:
          type: string
        command_data:
          description: JSON value with password-style tokens redacted server-side per spec §5.5.
        success:
          type: boolean
        response_message:
          type:
            - string
            - 'null'
        executed_at:
          type: string
          format: date-time
    RconHistoryEnvelope:
      type: object
      required:
        - items
        - next_cursor
        - total_count
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/RconHistoryItem'
        next_cursor:
          type:
            - string
            - 'null'
          description: Pass to the next request as `cursor=` to fetch the next page. Null when no more pages.
        total_count:
          type:
            - integer
            - 'null'
          description: Total matching rows (filter-applied, NOT page-applied). Null when the endpoint refuses to COUNT (audit_log, rcon_history).
    ConfirmationOnlyRequest:
      type: object
      required:
        - _confirmation
      properties:
        _confirmation:
          type: string
          description: Sentinel phrase matching the canonical destructive-write format for this action.
    StrikeCreateRequest:
      type: object
      required:
        - user_id
        - player_name
        - in_game_id
        - reason
        - category
        - severity
        - _confirmation
      properties:
        user_id:
          type: string
          description: Discord snowflake of the target user.
        player_name:
          type: string
          maxLength: 64
        in_game_id:
          type: string
          maxLength: 64
        reason:
          type: string
          minLength: 1
          maxLength: 500
        category:
          type: string
          minLength: 1
          maxLength: 64
        severity:
          type: string
          enum:
            - minor
            - major
        _confirmation:
          type: string
          description: Sentinel phrase for destructive-write gate.
    StrikeCreateResponse:
      type: object
      required:
        - id
        - reference_id
        - strike_number
        - user_id
        - player_name
        - reason
        - severity
      properties:
        id:
          type: string
          description: Numeric strike id (string-encoded).
        reference_id:
          type: string
        strike_number:
          type: integer
        user_id:
          type: string
        player_name:
          type: string
        reason:
          type: string
        severity:
          type: string
          enum:
            - minor
            - major
    BanCreateRequest:
      type: object
      required:
        - user_id
        - player_name
        - in_game_id
        - reason
        - category
        - _confirmation
      properties:
        user_id:
          type: string
          description: Discord snowflake of the target user.
        player_name:
          type: string
          maxLength: 64
        in_game_id:
          type: string
          maxLength: 64
        reason:
          type: string
          minLength: 1
          maxLength: 500
        category:
          type: string
          minLength: 1
          maxLength: 64
        duration_days:
          type:
            - integer
            - 'null'
          description: Timed ban duration in days. Omit or null for a permanent ban.
        in_game:
          type: boolean
          description: Whether to also issue an in-game RCON ban. Defaults to false.
        _confirmation:
          type: string
          description: Sentinel phrase for destructive-write gate.
    BanCreateResponse:
      type: object
      required:
        - reference_id
        - user_id
        - player_name
        - reason
        - duration_days
      properties:
        reference_id:
          type: string
        user_id:
          type: string
        player_name:
          type: string
        reason:
          type: string
        duration_days:
          type:
            - integer
            - 'null'
    BanRemoveRequest:
      type: object
      required:
        - _confirmation
      properties:
        _confirmation:
          type: string
          description: Sentinel phrase for destructive-write gate.
        reason:
          type: string
          maxLength: 500
          description: Optional unban reason recorded in the audit log.
    MuteCreateRequest:
      type: object
      required:
        - user_id
        - player_name
        - reason
        - duration_minutes
      properties:
        user_id:
          type: string
          description: Discord snowflake of the target user.
        player_name:
          type: string
          maxLength: 64
        reason:
          type: string
          minLength: 1
          maxLength: 500
        duration_minutes:
          type: integer
          minimum: 1
          maximum: 40320
          description: Mute duration in minutes (1–40320 = 28 days max per Discord). When > 1440 the endpoint promotes to destructive-write tier and `_confirmation` becomes required.
        _confirmation:
          type: string
          description: Required when `duration_minutes > 1440`. Sentinel phrase for destructive-write gate.
    MuteCreateResponse:
      type: object
      required:
        - sanction_id
        - user_id
        - player_name
        - duration_minutes
        - reversible
      properties:
        sanction_id:
          type: string
          description: Numeric sanction id (string-encoded).
        user_id:
          type: string
        player_name:
          type: string
        duration_minutes:
          type: integer
        reversible:
          type: boolean
          description: True when duration_minutes ≤ 1440 (reversible-write tier). False for longer mutes (destructive-write tier).
    MuteRemoveRequest:
      type: object
      required:
        - _confirmation
        - player_name
      properties:
        _confirmation:
          type: string
          description: Sentinel phrase for destructive-write gate.
        player_name:
          type: string
          maxLength: 64
    TicketActionResponse:
      type: object
      additionalProperties: true
      description: Passthrough of the bot's response payload. Shape varies by action.
    TicketCloseRequest:
      type: object
      required:
        - reason
      properties:
        reason:
          type: string
          minLength: 1
          maxLength: 500
    TicketStatusUpdateRequest:
      type: object
      required:
        - status
      properties:
        status:
          type: string
          enum:
            - open
            - claimed
          description: New ticket status. Use POST /close to close — PATCH does not perform Discord channel cleanup.
    TicketStatusUpdateResponse:
      type: object
      required:
        - id
        - guild_id
        - status
      properties:
        id:
          type: string
        guild_id:
          type: string
        status:
          type: string
          enum:
            - open
            - claimed
    TicketAddMemberRequest:
      type: object
      required:
        - user_id
      properties:
        user_id:
          type: string
          description: Discord snowflake of the user to add.
    TicketAddMemberResponse:
      type: object
      required:
        - user_id
      properties:
        user_id:
          type: string
      additionalProperties: true
      description: Added user id plus any additional fields from the bot's response.
    MassPurgeRequest:
      type: object
      required:
        - channel_id
        - count
        - _confirmation
      properties:
        channel_id:
          type: string
          description: Discord channel snowflake to purge messages from.
        count:
          type: integer
          minimum: 1
          maximum: 100
          description: Number of messages to delete (1–100).
        _confirmation:
          type: string
          description: 'Sentinel phrase: `PURGE {count} MESSAGES IN CHANNEL {channel_id} IN GUILD {guild_id}`.'
    MassPurgeResponse:
      type: object
      required:
        - deleted_count
        - channel_name
      properties:
        deleted_count:
          type: integer
        channel_name:
          type: string
    RconRunRequest:
      type: object
      required:
        - command
        - _confirmation
      properties:
        command:
          type: string
          minLength: 1
          maxLength: 2000
          description: Raw RCON command string. Password-style tokens are redacted in the audit log.
        _confirmation:
          type: string
          description: 'Sentinel phrase: `RUN RCON ON SERVER {serverId} IN GUILD {guildId}`.'
    RconRunResponse:
      type: object
      required:
        - result
        - latency_ms
      properties:
        result:
          type: string
          description: Server response text from the RCON connection.
        latency_ms:
          type: integer
          description: Round-trip latency in milliseconds (measured API-side).
