openapi: 3.0.3
info:
  title: Schotten Totten Public API
  version: "1.1.0"
  description: >
    Public JSON API for Schotten Totten (border-stones).

    Read endpoints under `/api/v1/` are OPEN (no auth) and serve PUBLIC,
    redacted data only — the same redaction and k-anon posture as the export /
    recap surfaces (hidden hands and `deckSeed`/`deckOrder` are never exposed).

    Private data (`/api/v1/me`) requires a Personal Access Token (PAT). PATs are
    opaque, hashed (sha256) server-side, scoped, and created by a signed-in user
    in Account settings; the raw token is shown exactly once. Send it as
    `Authorization: Bearer st_pat_...`. Token management (`/api/auth/tokens`) is
    SESSION-gated (cookie), never token-gated.

    Scopes: `read` (public data + your own account) and `bot:play` (submit moves
    in external bot games). A `bot:play` token is only accepted on an account
    that has been upgraded to a BOT account (`POST /api/auth/bot-account/upgrade`);
    the scope and the `is_bot` flag must agree. External bot games are ALWAYS
    unrated. The Board API (`/api/bot/...`) mirrors Lichess: a server→bot SSE
    stream for state, plus a POST to submit each move; every move is validated
    server-side through the same engine + turn checks as a human move, and a bot
    can only stream/act on games where its own account holds a seat (404 otherwise
    — the endpoint never confirms a game to a non-participant).
servers:
  - url: https://schottentotten.com
components:
  securitySchemes:
    patBearer:
      type: http
      scheme: bearer
      bearerFormat: st_pat
      description: Personal Access Token, prefix `st_pat_`.
    botPlayBearer:
      type: http
      scheme: bearer
      bearerFormat: st_pat
      description: >
        Personal Access Token with the `bot:play` scope on an `is_bot` account.
        The scope and the account flag must agree.
    sessionCookie:
      type: apiKey
      in: cookie
      name: st_session
      description: Browser session cookie (for token management only).
  responses:
    RateLimited:
      description: Too many requests. Retry after the window.
      headers:
        Retry-After:
          schema: { type: integer, example: 60 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthenticated:
      description: Missing or invalid credentials (uniform — no reason reflected).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        ok: { type: boolean, example: false }
        errorCode: { type: string, example: unauthenticated }
        message: { type: string }
    PublicUser:
      type: object
      description: Public, redacted player profile (no PII, no per-viewer fields).
      properties:
        id: { type: string, format: uuid }
        displayName: { type: string }
        createdAt: { type: string, format: date-time }
        rating:
          type: object
          nullable: true
          properties:
            rating: { type: integer }
            games: { type: integer }
            wins: { type: integer }
            losses: { type: integer }
        ratingHistory:
          type: array
          items:
            type: object
            properties:
              rating: { type: integer }
              at: { type: string, format: date-time }
              variant: { type: string }
              delta: { type: integer }
        tournamentsPlayed: { type: integer }
        tournaments:
          type: array
          items: { type: object }
        followersCount: { type: integer }
        followingCount: { type: integer }
        friendsCount: { type: integer }
        trophies: { type: object }
        insights: { type: object, nullable: true }
        activity: { type: object }
    MeUser:
      allOf:
        - $ref: "#/components/schemas/PublicUser"
        - type: object
          properties:
            email: { type: string, format: email }
            preferences: { type: object }
    GamesPage:
      type: object
      properties:
        games:
          type: array
          items: { type: object }
        hasMore: { type: boolean }
    TokenSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        scopes: { type: array, items: { type: string } }
        label: { type: string }
        createdAt: { type: string, format: date-time }
        lastUsedAt: { type: string, format: date-time, nullable: true }
        revokedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
paths:
  /api/v1/user/{id}:
    get:
      summary: Public player profile
      description: Open. Rate limited per IP. Redacted allowlist — no PII or per-viewer fields.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The public user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/PublicUser" }
        "400": { description: id is not a UUID }
        "404": { description: Player not found }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/user/{id}/games:
    get:
      summary: A player's completed games (newest first)
      description: Open. Keyset pagination via `before`. Deck seed / hidden hands are stripped.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: before
          in: query
          schema: { type: string, format: date-time }
        - name: variant
          in: query
          schema: { type: string, enum: [base, tactics] }
        - name: result
          in: query
          schema: { type: string, enum: [win, loss] }
      responses:
        "200":
          description: A page of games.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/GamesPage" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/me:
    get:
      summary: The authenticated user's own account (private)
      description: Requires a PAT with the `read` scope. Rate limited per token.
      security:
        - patBearer: []
      responses:
        "200":
          description: The caller's account.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/MeUser" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { description: Token lacks the required scope (insufficient_scope). }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/v1/embed/game/{matchId}:
    get:
      summary: Iframe-able read-only board view
      description: >
        Open HTML page embedding the locked replay sandbox with the REDACTED
        public certificate. `X-Robots-Tag: noindex`, no cookie, `frame-ancestors *`.
      parameters:
        - name: matchId
          in: path
          required: true
          schema: { type: string, pattern: "^[0-9a-f]{32}$" }
      responses:
        "200": { description: HTML board page. }
        "400": { description: Invalid match id. }
  /api/auth/tokens:
    get:
      summary: List your Personal Access Tokens
      description: Session-gated. Never returns token hashes or raw tokens.
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Your tokens.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tokens:
                    type: array
                    items: { $ref: "#/components/schemas/TokenSummary" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
    post:
      summary: Create a Personal Access Token
      description: >
        Session-gated. The owner is always the session user (any body `userId`
        is ignored). The raw token is returned EXACTLY ONCE.
      security:
        - sessionCookie: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, maxLength: 64 }
                scopes:
                  type: array
                  items: { type: string, enum: [read, "bot:play"] }
      responses:
        "201":
          description: The created token (raw shown once).
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, example: st_pat_xxx }
                  id: { type: string, format: uuid }
                  scopes: { type: array, items: { type: string } }
                  label: { type: string }
                  createdAt: { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "409": { description: Token limit reached (token_limit). }
  /api/auth/tokens/{id}:
    delete:
      summary: Revoke one of your tokens
      description: Session-gated. Scoped to the owner (IDOR-safe).
      security:
        - sessionCookie: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { description: Revoked. }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { description: No such token. }
  /api/auth/bot-account/upgrade:
    post:
      summary: Upgrade the signed-in account to a bot account
      description: >
        Session-gated. Marks the account `is_bot` so it can hold external bot
        seats and be issued `bot:play` tokens. Refused if the account has ANY
        rated-game history (409 already_rated) — no rating laundering onto a bot.
        Idempotent.
      security:
        - sessionCookie: []
      responses:
        "200":
          description: Upgraded (or already a bot).
          content:
            application/json:
              schema:
                type: object
                properties:
                  upgraded: { type: boolean, example: true }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "409": { description: Account has rated games (already_rated). }
  /api/bot/game/stream/{gameId}:
    get:
      summary: Stream a bot game's state (SSE, redacted per seat)
      description: >
        Requires a `bot:play` PAT on the seated bot account. Returns
        `text/event-stream`: a first `event: full`, then `event: state` on every
        change, then `event: gameState` + close on game end. Each event carries
        `viewForPlayer` for the bot's OWN seat — the opponent's hand is only a
        count and the deck order / `deckSeed` are never present. IDOR / non-bot
        seat → 404. One stream per seat; concurrent streams per token are capped.
      security:
        - botPlayBearer: []
      parameters:
        - name: gameId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: SSE stream (text/event-stream).
          content:
            text/event-stream:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { description: No such game (also returned for IDOR / non-bot seat). }
        "409": { description: The seat is already streaming (already_streaming). }
        "429": { $ref: "#/components/responses/RateLimited" }
  /api/bot/game/{gameId}/move:
    post:
      summary: Submit a move in a bot game (server-authoritative)
      description: >
        Requires a `bot:play` PAT on the seated bot account. The move is
        validated SERVER-SIDE through the same engine, legality, and turn checks
        as a human move — the client's move/state is never trusted. Supply an
        idempotency key (`X-Idempotency-Key` header or body `commandId`) to make
        a retransmit safe: a replay returns the current state without re-applying.
        IDOR / non-bot seat → 404.
      security:
        - botPlayBearer: []
      parameters:
        - name: gameId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: X-Idempotency-Key
          in: header
          required: false
          schema: { type: string, pattern: "^[A-Za-z0-9_-]{8,64}$" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [cardId]
                  properties:
                    cardId: { type: string, minLength: 1, maxLength: 40 }
                    stoneIndex: { type: integer, minimum: 0, maximum: 8 }
                    commandId: { type: string }
                - type: object
                  required: [pass]
                  properties:
                    pass: { type: boolean, enum: [true] }
                    commandId: { type: string }
      responses:
        "200":
          description: Applied (or an idempotent replay). Body carries the bot's redacted view.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  replayed: { type: boolean }
                  view: { type: object }
        "400": { description: Malformed body or idempotency key (bad_request). }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "404": { description: No such game (also returned for IDOR / non-bot seat). }
        "409": { description: Out of turn (not_your_turn) or the game is over (game_over). }
        "422": { description: Illegal move (illegal_move / engine_error). }
        "429": { $ref: "#/components/responses/RateLimited" }
