openapi: 3.1.0

info:
  title: LookCam API
  version: 2.9.0
  description: |
    REST + WebSocket API for LookCam — a live-streaming platform (gaming, IRL,
    music, cameras) in the style of Twitch/Kick. Served by `lookcam-api` (Go/Gin).

    LookCam owns everything **product / community / identity**: users & profiles,
    follows & blocks, chat & moderation, categories & discovery, clips metadata,
    raids/hosts, notifications, search and SEO. The **generic streaming
    infrastructure** (ingest, transcode, HLS manifests, edge selection, secure
    playback links, recordings, thumbnail capture) is delegated to **Streamway**
    (`streamway.openapi.yaml`); LookCam stores only the references it needs and
    proxies playback through signed Streamway URLs.

    Boundary rules enforced by this contract:
      * The Next.js front talks ONLY to lookcam-api (HTTP + WS). It never touches
        a database directly.
      * Only lookcam-api accesses LookCam's datastores (PostgreSQL, Redis,
        ClickHouse, Firebase Cloud Messaging).
      * Streaming media bits flow through Streamway; lookcam-api calls Streamway
        server-to-server and exposes only product-shaped views to the front.

    CONVENTION NOTE: This spec is the SOURCE OF TRUTH and intentionally adopts the
    WeDeploy platform conventions (snake_case JSON fields, bare resource objects on
    success, the shared `Error` envelope on non-2xx, `PaginationMeta`-wrapped list
    responses). The current lookcam-api uses camelCase and a `{data,success,message}`
    wrapper; it will be aligned to this contract. Where field-level detail is not yet
    pinned down, objects are marked `additionalProperties: true`.

servers:
  - url: https://api.lookcam.com
    description: Production
  - url: http://localhost:8082
    description: Local development

tags:
  - name: auth
    description: Login, logout, token refresh, social login and API tokens
  - name: users
    description: Profiles, account settings, avatars/banners, devices
  - name: streams
    description: Stream metadata, playback (delegated to Streamway), qualities, stats
  - name: discovery
    description: Browse, categories, recommended/suggested feeds, search
  - name: social
    description: Follows, blocks, social feed
  - name: chat
    description: Live chat (WebSocket), moderation, settings, emotes, polls, whispers
  - name: clips
    description: Clip creation, listing, trending and engagement
  - name: raids
    description: Raids and host/auto-host
  - name: notifications
    description: In-app/push notifications and preferences
  - name: viewers
    description: Realtime viewer counts (WebSocket) and stream events
  - name: videos
    description: VOD / past broadcasts (media stored in Streamway, always proxied by lookcam-api)
  - name: monetization
    description: Channel subscriptions and tips — payments are processed by the central platform-api (Stripe); lookcam-api only initiates checkout and stores entitlements
  - name: points
    description: Channel points, rewards and redemptions (viewer loyalty)
  - name: schedule
    description: Creator broadcast schedule
  - name: predictions
    description: Stream predictions (channel-points betting)
  - name: safety
    description: Moderation audit log, reports and two-factor authentication
  - name: seo
    description: Sitemaps and structured data (server-rendered SEO)
  - name: analytics
    description: Creator dashboard analytics — revenue, subscriptions, points, follows, videos and viewership aggregates
  - name: system
    description: Server-to-server (Streamway webhooks, RTMP hooks, edge config)

# ── Security ───────────────────────────────────────────────────────────────────

security:
  - bearerAuth: []
  - cookieAuth: []

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT access token in the `Authorization: Bearer` header. Used by NATIVE
        clients (the Expo mobile app), which authenticate in **bearer mode**:
        send `X-Auth-Mode: bearer` on token-issuing requests and the API returns
        `access_token`/`refresh_token` in the JSON body (stored in the device
        keychain). Cookies are NOT set in bearer mode.
    cookieAuth:
      type: apiKey
      in: cookie
      name: lc_access_token
      description: |
        JWT access token in an HttpOnly+Secure+SameSite=Lax cookie
        (`lc_access_token`, Domain `.lookcam.com`), the DEFAULT for the web app
        (**cookie mode**). On login/register/2FA/social/SSO/refresh the API sets
        `lc_access_token` + `lc_refresh_token` + `lc_device_id` via `Set-Cookie`
        and OMITS the raw tokens from the response body — they never reach JS or
        URLs. Cookie-authenticated state-changing requests (POST/PUT/PATCH/DELETE)
        must carry a non-empty `X-Lookcam-Client` header (CSRF defense). The
        access cookie is also accepted by all `bearerAuth` endpoints.
    apiTokenAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Personal API token (created via /v1/auth/tokens) for programmatic access.
    systemAuth:
      type: apiKey
      in: header
      name: X-System-Key
      description: |
        Server-to-server key for system endpoints (webhooks, edge/RTMP
        configuration). Streamway webhooks are additionally verified with an
        HMAC-SHA256 signature header.

  # ── Reusable schemas ─────────────────────────────────────────────────────────

  schemas:

    # ── Primitives / enums ─────────────────────────────────────────────────────

    UUID:
      type: string
      format: uuid
      example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"

    Timestamp:
      type: string
      format: date-time
      example: "2026-06-11T12:00:00Z"

    UserStatus:
      type: string
      enum: [active, banned, deleted]
      example: active

    ClipVisibility:
      type: string
      enum: [public, unlisted, private]
      example: public

    ClipStatus:
      type: string
      enum: [processing, ready, failed]
      example: ready

    QualityStatus:
      type: string
      enum: [processing, ready, failed]
      example: ready

    RaidStatus:
      type: string
      enum: [pending, active, completed, cancelled]
      example: active

    NotificationType:
      type: string
      enum: [stream_live, mention, follower, raid, clip, subscription, donation]
      example: follower

    VideoVisibility:
      type: string
      enum: [public, unlisted, private]
      example: public

    SubscriptionStatus:
      type: string
      enum: [active, past_due, cancelled, expired]
      example: active

    TipStatus:
      type: string
      enum: [pending, completed, refunded]
      example: completed

    RedemptionStatus:
      type: string
      enum: [pending, fulfilled, refunded]
      example: pending

    PredictionStatus:
      type: string
      enum: [active, locked, resolved, cancelled]
      example: active

    ModerationActionType:
      type: string
      enum:
        [
          ban, unban, timeout, untimeout, mod, unmod, vip, unvip, clear,
          delete_message, slow_on, slow_off, emote_only_on, emote_only_off,
          followers_only_on, followers_only_off, shield_on, shield_off,
        ]
      example: timeout

    ReportTargetType:
      type: string
      enum: [user, stream, clip, chat_message]
      example: user

    ReportReasonCode:
      type: string
      enum: [spam, harassment, hate, sexual_content, violence, self_harm, illegal, other]
      example: harassment

    ReportStatus:
      type: string
      enum: [open, reviewing, actioned, dismissed]
      example: open

    # ── Domain models ──────────────────────────────────────────────────────────

    SocialLinks:
      type: object
      description: Optional social profile links for a creator.
      properties:
        instagram: { type: string }
        twitter: { type: string }
        facebook: { type: string }
        discord: { type: string }
        tiktok: { type: string }
        youtube: { type: string }
        website: { type: string, format: uri }

    SEOTags:
      type: object
      description: Per-user SEO metadata used by server-rendered pages and sitemaps.
      properties:
        title: { type: string }
        description: { type: string }
        keywords:
          type: array
          items: { type: string }
        location: { type: string }
        category: { type: string }
        language: { type: string }
        country: { type: string }
        city: { type: string }
        last_modified: { $ref: "#/components/schemas/Timestamp" }
      additionalProperties: true

    UserSummary:
      type: object
      description: Compact creator reference embedded in cards, follows and clips.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        username: { type: string, example: "novastream" }
        username_title:
          type: string
          nullable: true
          description: Display name (preserves original casing).
          example: "NovaStream"
        image_url:
          type: string
          format: uri
          nullable: true
      required: [id, username]

    User:
      type: object
      description: Full LookCam user identity (self view includes private fields).
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        username: { type: string, example: "novastream" }
        username_title: { type: string, nullable: true, example: "NovaStream" }
        email: { type: string, format: email, nullable: true, description: "Self view only." }
        email_verified: { $ref: "#/components/schemas/Timestamp" }
        image_url: { type: string, format: uri, nullable: true }
        banner_url: { type: string, format: uri, nullable: true }
        bio: { type: string, nullable: true }
        is_verified: { type: boolean, example: false }
        status: { $ref: "#/components/schemas/UserStatus" }
        social_links: { $ref: "#/components/schemas/SocialLinks" }
        seo_tags: { $ref: "#/components/schemas/SEOTags" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
        updated_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, username, status]

    PublicProfile:
      type: object
      description: Public channel/profile view as consumed by the watch/profile page.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        username: { type: string }
        username_title: { type: string, nullable: true }
        image_url: { type: string, format: uri, nullable: true }
        banner_url: { type: string, format: uri, nullable: true }
        bio: { type: string, nullable: true }
        is_verified: { type: boolean }
        social_links: { $ref: "#/components/schemas/SocialLinks" }
        seo_tags:
          allOf:
            - $ref: "#/components/schemas/SEOTags"
          description: >
            Channel SEO metadata (keywords/title/description) for server-rendered
            profile/watch pages. Present only when the channel has SEO tags set.
        follower_count: { type: integer, example: 12045 }
        is_following:
          type: boolean
          description: Whether the authenticated caller follows this user (false if anonymous).
        stream: { $ref: "#/components/schemas/StreamSummary" }
      required: [id, username]

    Stream:
      type: object
      description: A creator's live channel and its chat/ingest configuration.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        user_id: { $ref: "#/components/schemas/UUID" }
        name: { type: string, example: "Late night VALORANT ranked" }
        thumbnail_url: { type: string, format: uri, nullable: true }
        category_id: { type: string, nullable: true, example: "valorant" }
        is_live: { type: boolean, example: true }
        realtime_viewer_count: { type: integer, example: 142 }
        ingress_id:
          { $ref: "#/components/schemas/UUID" }
        streamway_stream_id:
          allOf:
            - $ref: "#/components/schemas/UUID"
          nullable: true
          description: >
            The corresponding stream ID in Streamway. lookcam-api owns and stores this
            mapping; the front receives the identifier (e.g. for embeds/correlation) but
            never calls Streamway directly — all media access is proxied via lookcam-api.
        server_url:
          type: string
          description: RTMP ingest endpoint (owner only).
          example: "rtmp://rtmp.lookcam.com/stream"
        stream_key:
          type: string
          description: Secret ingest key — returned only to the owner.
          example: "sk_live_9f8a7b6c5d4e"
        ingress_pull_url:
          type: string
          format: uri
          nullable: true
          description: Optional pull-ingest source URL (owner only).
        is_chat_enabled: { type: boolean, default: true }
        is_chat_followers_only: { type: boolean, default: false }
        chat_slow_mode: { type: boolean, default: false }
        chat_slow_mode_seconds: { type: integer, default: 30 }
        chat_emote_only: { type: boolean, default: false }
        chat_followers_only_minutes: { type: integer, default: 0 }
        chat_unique_chat: { type: boolean, default: false }
        chat_shield_mode: { type: boolean, default: false }
        language:
          type: string
          nullable: true
          description: Broadcast language (ISO 639-1 code).
          example: "pl"
        is_mature: { type: boolean, default: false }
        tags:
          type: array
          maxItems: 10
          items: { type: string }
          example: ["ranked", "polish", "chill"]
        created_at: { $ref: "#/components/schemas/Timestamp" }
        updated_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, user_id, name, is_live]

    StreamSummary:
      type: object
      description: Stream subset embedded in profiles and follow lists.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        name: { type: string }
        thumbnail_url: { type: string, format: uri, nullable: true }
        is_live: { type: boolean }
        realtime_viewer_count: { type: integer }
        is_chat_enabled: { type: boolean }
        is_chat_followers_only: { type: boolean }
        category: { $ref: "#/components/schemas/CategorySummary" }
      required: [id, is_live]

    StreamCard:
      type: object
      description: Card shape for browse/feed grids (home, category, search results).
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        name: { type: string }
        thumbnail_url: { type: string, format: uri, nullable: true }
        is_live: { type: boolean }
        realtime_viewer_count: { type: integer }
        updated_at: { $ref: "#/components/schemas/Timestamp" }
        user: { $ref: "#/components/schemas/UserSummary" }
        category: { $ref: "#/components/schemas/CategorySummary" }
        language:
          type: string
          nullable: true
          description: Broadcast language (ISO 639-1 code), surfaced for filtering/labels.
          example: "pl"
        is_mature:
          type: boolean
          default: false
          description: Whether the stream is flagged 18+ (so the grid can badge/blur it).
        tags:
          type: array
          items: { type: string }
          description: >
            Discovery tags. Populated on cards built from the Stream model
            (browse/feed); omitted on sparse projections.
          example: ["ranked", "polish", "chill"]
      required: [id, name, is_live, user]

    PlaybackSource:
      type: object
      description: |
        Signed playback descriptor resolved by lookcam-api from Streamway. The front
        feeds `manifest_url` to hls.js; the URL is a short-lived secure link served by
        the Streamway edge. lookcam-api never exposes the raw origin or signing secret.
      properties:
        stream_id: { $ref: "#/components/schemas/UUID" }
        manifest_url:
          type: string
          format: uri
          description: Signed HLS (.m3u8) URL on the Streamway edge.
          example: "https://edge.lookcam.com/abc123/manifest.m3u8?expires=1750000000&token=..."
        protocol:
          type: string
          enum: [hls, ll-hls]
          default: hls
        expires_at:
          { $ref: "#/components/schemas/Timestamp" }
        live_since:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
          description: >-
            Authoritative go-live time, read live from Streamway (Stream.live_since)
            — the source of truth for when the stream actually started publishing.
            Drives the player's uptime clock. Null when the stream is offline.
        qualities:
          type: array
          items: { $ref: "#/components/schemas/StreamQuality" }
      required: [stream_id, manifest_url, expires_at]

    StreamQuality:
      type: object
      description: An available transcode rendition (produced by Streamway).
      properties:
        id: { type: integer, example: 3 }
        stream_id: { $ref: "#/components/schemas/UUID" }
        label: { type: string, example: "1080p60" }
        width: { type: integer, example: 1920 }
        height: { type: integer, example: 1080 }
        fps: { type: integer, example: 60 }
        bitrate: { type: integer, description: "kbps", example: 6000 }
        url: { type: string, format: uri }
        is_default: { type: boolean }
        status: { $ref: "#/components/schemas/QualityStatus" }
      required: [stream_id, label, height]

    CategorySummary:
      type: object
      properties:
        id: { type: string, example: "valorant" }
        name: { type: string, example: "VALORANT" }
      required: [id, name]

    Category:
      type: object
      description: A browseable content category (box-art tile).
      properties:
        id: { type: string, example: "valorant" }
        name: { type: string, example: "VALORANT" }
        cover_image: { type: string, format: uri, nullable: true }
        parent_id: { $ref: "#/components/schemas/UUID" }
        tags:
          type: array
          items: { type: string }
        live_viewer_count:
          type: integer
          description: Aggregate live viewers across streams in this category.
          example: 53120
      required: [id, name]

    Clip:
      type: object
      description: A short VOD clip captured from a stream (media stored via Streamway).
      properties:
        id: { type: string, example: "clip_8fa72c" }
        stream_id: { $ref: "#/components/schemas/UUID" }
        user_id: { $ref: "#/components/schemas/UUID" }
        title: { type: string }
        description: { type: string }
        video_url: { type: string, format: uri }
        thumbnail_url: { type: string, format: uri }
        duration: { type: number, format: float, description: "seconds", example: 28.5 }
        start_time: { type: number, format: float, description: "offset into source, seconds" }
        end_time: { type: number, format: float, description: "offset into source, seconds" }
        visibility: { $ref: "#/components/schemas/ClipVisibility" }
        views_count: { type: integer, example: 4210 }
        likes_count: { type: integer, example: 318 }
        status: { $ref: "#/components/schemas/ClipStatus" }
        streamer: { $ref: "#/components/schemas/UserSummary" }
        liked:
          type: boolean
          description: >
            Whether the current viewer has liked this clip. Present only on
            authenticated single-clip and list responses; omitted for anonymous
            requests (the web treats absent as not-liked).
        created_at: { $ref: "#/components/schemas/Timestamp" }
        updated_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, stream_id, user_id, title, status]

    Follow:
      type: object
      description: A follow edge, expanded with the followed channel for list views.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        follower_id: { $ref: "#/components/schemas/UUID" }
        following_id: { $ref: "#/components/schemas/UUID" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
        following:
          allOf:
            - $ref: "#/components/schemas/UserSummary"
            - type: object
              properties:
                stream: { $ref: "#/components/schemas/StreamSummary" }
      required: [id, follower_id, following_id]

    Notification:
      type: object
      properties:
        id: { type: integer, example: 90211 }
        user_id: { $ref: "#/components/schemas/UUID" }
        type: { $ref: "#/components/schemas/NotificationType" }
        title: { type: string }
        message: { type: string }
        link: { type: string }
        image_url: { type: string, format: uri }
        is_read: { type: boolean, default: false }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, user_id, type, title, is_read]

    NotificationPreferences:
      type: object
      properties:
        stream_live: { type: boolean, default: true }
        mentions: { type: boolean, default: true }
        followers: { type: boolean, default: true }
        raids: { type: boolean, default: true }
        clips: { type: boolean, default: true }
        subscriptions: { type: boolean, default: true }
        donations: { type: boolean, default: true }
        quiet_hours_start: { $ref: "#/components/schemas/Timestamp" }
        quiet_hours_end: { $ref: "#/components/schemas/Timestamp" }
        email_enabled: { type: boolean, default: false }
        push_enabled: { type: boolean, default: true }

    Raid:
      type: object
      properties:
        id: { type: string }
        source_user_id: { $ref: "#/components/schemas/UUID" }
        target_user_id: { $ref: "#/components/schemas/UUID" }
        source_stream_id: { $ref: "#/components/schemas/UUID" }
        target_stream_id: { $ref: "#/components/schemas/UUID" }
        viewer_count: { type: integer }
        status: { $ref: "#/components/schemas/RaidStatus" }
        message: { type: string }
        started_at: { $ref: "#/components/schemas/Timestamp" }
        completed_at: { $ref: "#/components/schemas/Timestamp" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, source_user_id, target_user_id, status]

    HostUserSummary:
      type: object
      description: Compact user reference embedded in host/auto-host responses.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        username: { type: string }
        avatar_url: { type: string, format: uri }
      required: [id, username]

    HostInfo:
      type: object
      description: An active host session (the caller hosting another channel).
      properties:
        id: { type: integer, example: 311 }
        target_user: { $ref: "#/components/schemas/HostUserSummary" }
        is_auto_host: { type: boolean, default: false }
        viewer_count: { type: integer, example: 0 }
        started_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, target_user, is_auto_host]

    ChatMessage:
      type: object
      description: A single chat message (also delivered over the chat WebSocket).
      properties:
        uuid: { type: string, format: uuid }
        user_id: { $ref: "#/components/schemas/UUID" }
        username: { type: string }
        room_id: { type: integer }
        content: { type: string }
        type: { type: string, default: "message", example: "message" }
        timestamp: { type: integer, format: int64, description: "Unix ms" }
        is_deleted: { type: boolean, default: false }
        reply_to_id: { type: string, format: uuid, nullable: true }
        reply_to_user: { type: string, nullable: true }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [uuid, user_id, username, room_id, content]

    ChatBlockedTerm:
      type: object
      description: A per-channel banned word / regex (AutoMod-lite, SQ-433).
      properties:
        id: { type: integer, format: int64 }
        channel_id: { type: string }
        term: { type: string }
        is_regex: { type: boolean, default: false }
        action: { type: string, enum: [block, hold, allow, block_links, verified_only], default: block }
        created_by: { $ref: "#/components/schemas/UUID" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, term, action]

    HeldMessage:
      type: object
      description: A chat message diverted to the moderator review queue (SQ-433).
      properties:
        id: { type: string }
        user_id: { $ref: "#/components/schemas/UUID" }
        username: { type: string }
        content: { type: string }
        matched_term: { type: string }
        timestamp: { type: integer, format: int64, description: "Unix ms" }
      required: [id, user_id, username, content]

    ChatActionResult:
      type: object
      description: Simple acknowledgement returned by chat moderation commands.
      properties:
        success: { type: boolean, example: true }
      required: [success]

    ChatSettingsResult:
      type: object
      description: Acknowledgement returned by chat settings toggles.
      properties:
        success: { type: boolean, example: true }
        message: { type: string, example: "Settings saved and broadcast sent" }
        saved_to_db: { type: boolean }
        saved_to_redis: { type: boolean }
        broadcast_sent: { type: boolean }
        connections:
          type: integer
          description: Live WebSocket connections in the room at broadcast time.
      required: [success]

    ChatModes:
      type: object
      description: |
        Active chat modes for a room. `slow` and `followers_only` carry the
        configured value (slow-mode seconds / minimum follow minutes) when the
        mode is enabled and are null when disabled.
      properties:
        chat_enabled: { type: boolean, default: true }
        slow:
          type: integer
          nullable: true
          description: Slow-mode interval in seconds (null when disabled).
        emote_only: { type: boolean, default: false }
        followers_only:
          type: integer
          nullable: true
          description: Minimum follow age in minutes (null when disabled).
        unique_chat: { type: boolean, default: false }
        shield_mode: { type: boolean, default: false }

    ChatEmote:
      type: object
      description: An emote usable in chat (global or channel).
      properties:
        id: { type: string }
        name: { type: string, example: "Kappa" }
        start_pos:
          type: integer
          description: Start position in message content (only when embedded in a message).
        end_pos: { type: integer }
        url: { type: string, format: uri }
        is_global: { type: boolean }
      required: [id, name, url]

    ChatPollOption:
      type: object
      properties:
        id: { type: string }
        text: { type: string, example: "Yes" }
        order: { type: integer, description: "Display order (0-based)." }
      required: [id, text]

    ChatPoll:
      type: object
      description: A chat poll (active or finished).
      properties:
        id: { type: string }
        question: { type: string, example: "Which game next?" }
        options:
          type: array
          items: { $ref: "#/components/schemas/ChatPollOption" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
        ends_at: { $ref: "#/components/schemas/Timestamp" }
        duration_sec: { type: integer, example: 120 }
        creator_id: { type: string }
      required: [id, question, options, ends_at]

    ChatBadge:
      type: object
      description: A chat badge definition (global, channel or special).
      properties:
        id: { type: string }
        name: { type: string, example: "Moderator" }
        image_url: { type: string, format: uri }
        color: { type: string, description: "Hex color.", example: "#00AD03" }
        type: { type: string, enum: [global, channel, special], default: global }
        channel_id: { type: string, nullable: true }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, name, image_url]

    UserBadge:
      type: object
      description: A badge granted to a user.
      properties:
        id: { type: integer, example: 9120 }
        user_id: { $ref: "#/components/schemas/UUID" }
        badge_id: { type: string }
        granted_at: { $ref: "#/components/schemas/Timestamp" }
        expires_at:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
        badge: { $ref: "#/components/schemas/ChatBadge" }
      required: [id, user_id, badge_id]

    RoomMember:
      type: object
      description: A user entry in a room's moderators/VIPs/banned lists.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        username: { type: string }
      required: [id, username]

    Whisper:
      type: object
      description: A direct (private) message between two users.
      properties:
        id: { type: integer, example: 4211 }
        sender_id: { $ref: "#/components/schemas/UUID" }
        recipient_id: { $ref: "#/components/schemas/UUID" }
        content: { type: string, maxLength: 500 }
        is_read: { type: boolean, default: false }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, sender_id, recipient_id, content, is_read]

    WhisperConversation:
      type: object
      description: A whisper conversation summary (inbox row).
      properties:
        user_id: { $ref: "#/components/schemas/UUID" }
        username: { type: string }
        avatar_url: { type: string, format: uri }
        last_message:
          type: object
          properties:
            content: { type: string }
            timestamp: { $ref: "#/components/schemas/Timestamp" }
            is_read: { type: boolean }
          required: [content, timestamp, is_read]
        unread_count: { type: integer, example: 2 }
      required: [user_id, username, last_message, unread_count]

    Device:
      type: object
      description: A registered push-notification device.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        device_token: { type: string }
        device_type: { type: string, enum: [ios, android] }
        notifications_enabled: { type: boolean, default: true }
        last_active: { $ref: "#/components/schemas/Timestamp" }
      required: [device_token, device_type]

    ViewerHeartbeatRequest:
      type: object
      description: |
        Presence ping for a stream. Authenticated callers are identified by their
        `user_id` and may omit `device_id`; anonymous callers MUST supply a stable
        `device_id` so they are deduplicated per device rather than per request.
      properties:
        stream_id:
          type: string
          description: Stream/room being watched.
        device_id:
          type: string
          description: |
            Stable per-device identifier. Required for anonymous callers; ignored
            when the request is authenticated (the `user_id` is used instead).
      required: [stream_id]

    ViewerCount:
      type: object
      description: Current live viewer count for a single stream.
      properties:
        viewer_count:
          type: integer
          description: Live viewers within the presence window.
      required: [viewer_count]

    AuthResult:
      type: object
      description: |
        Authentication response.

        Transport depends on client mode: in **cookie mode** (web, default) the
        API sets `lc_access_token`/`lc_refresh_token`/`lc_device_id` HttpOnly
        cookies and `access_token`/`refresh_token` are OMITTED from this body. In
        **bearer mode** (`X-Auth-Mode: bearer`, native apps) the tokens are
        present in the body and no cookies are set.

        2FA variant: when the account has two-factor authentication enabled,
        `requires_2fa` is `true`, the tokens/user are NOT usable yet, and the
        client must complete login by posting `two_factor_token` + a TOTP code
        to `/v1/auth/2fa`, which returns the final `AuthResult`.
      properties:
        access_token:
          type: string
          description: Present only in bearer mode; in cookie mode it is set as an HttpOnly cookie instead.
        refresh_token:
          type: string
          description: Present only in bearer mode; in cookie mode it is set as an HttpOnly cookie instead.
        device_id:
          type: string
          description: Opaque device identifier bound to the refresh-token family.
        user: { $ref: "#/components/schemas/User" }
        requires_2fa: { type: boolean, default: false }
        two_factor_token:
          type: string
          nullable: true
          description: Short-lived token to complete login via /v1/auth/2fa.

    WSTicket:
      type: object
      description: |
        A short-lived, single-use WebSocket authentication ticket issued by
        `POST /v1/auth/ws-ticket`. The opaque `ticket` is appended as the
        `ticket` query parameter to a WS connect endpoint and consumed atomically
        on connect.
      properties:
        ticket:
          type: string
          description: Opaque single-use ticket; pass as `?ticket=` on a WS connect URL.
        expires_in:
          type: integer
          description: Ticket lifetime in seconds (~30).
          example: 30
      required: [ticket, expires_in]

    APIToken:
      type: object
      description: A personal API token. The secret is returned only on creation.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        name: { type: string }
        prefix: { type: string, example: "lc_a1b2" }
        token:
          type: string
          description: Full secret, returned only once at creation.
        last_used_at: { $ref: "#/components/schemas/Timestamp" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, name]

    # ── VOD ────────────────────────────────────────────────────────────────────

    Video:
      type: object
      description: |
        A VOD / past broadcast. The media itself is recorded and stored by
        Streamway; lookcam-api stores only the reference and always proxies
        media access — the front never calls Streamway directly.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        user_id: { $ref: "#/components/schemas/UUID" }
        stream_id:
          allOf:
            - $ref: "#/components/schemas/UUID"
          nullable: true
        streamway_recording_id:
          allOf:
            - $ref: "#/components/schemas/UUID"
          nullable: true
          description: >
            The corresponding recording ID in Streamway. lookcam-api owns and stores
            this mapping; media access is always proxied via lookcam-api.
        title: { type: string, example: "Late night VALORANT ranked — full VOD" }
        thumbnail_url: { type: string, format: uri, nullable: true }
        duration_seconds: { type: integer, example: 7421 }
        view_count: { type: integer, example: 1532 }
        visibility: { $ref: "#/components/schemas/VideoVisibility" }
        is_mature: { type: boolean, default: false }
        streamer: { $ref: "#/components/schemas/UserSummary" }
        published_at:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, user_id, title, visibility, created_at]

    # ── Monetization (payments processed by platform-api / Stripe) ─────────────

    ChannelSubscription:
      type: object
      description: |
        A paid channel subscription. Checkout and recurring billing are processed
        by the central platform-api (Stripe); lookcam-api only initiates checkout
        and stores the resulting entitlement, updated via /v1/webhooks/billing.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        subscriber: { $ref: "#/components/schemas/UserSummary" }
        channel_user_id: { $ref: "#/components/schemas/UUID" }
        channel:
          allOf:
            - $ref: "#/components/schemas/UserSummary"
          description: >
            The subscribed channel, expanded so the web can link/cancel without
            re-resolving channel_user_id. Present on the subscriber's own list
            (/v1/users/me/subscriptions); omitted on the creator's subscriber list.
        tier:
          type: integer
          enum: [1, 2, 3]
          example: 1
        status: { $ref: "#/components/schemas/SubscriptionStatus" }
        is_gift: { type: boolean, default: false }
        started_at: { $ref: "#/components/schemas/Timestamp" }
        renews_at:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
        cancelled_at:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
      required: [id, subscriber, channel_user_id, tier, status, started_at]

    CheckoutSession:
      type: object
      description: |
        Returned when initiating a subscription or tip payment. The payment itself
        is processed by the central platform-api (Stripe) — lookcam-api only
        initiates the checkout and stores the entitlement once platform-api
        confirms completion via the billing webhook. The front either redirects to
        `checkout_url` or confirms in-place with `client_secret`.
      properties:
        checkout_url: { type: string, format: uri, nullable: true }
        client_secret: { type: string, nullable: true }
        provider:
          type: string
          enum: [stripe]
          example: stripe
        expires_at: { $ref: "#/components/schemas/Timestamp" }
      required: [provider, expires_at]

    Tip:
      type: object
      description: |
        A one-off tip to a creator. Payment is processed by the central
        platform-api (Stripe); lookcam-api records the tip and its status.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        from_user:
          allOf:
            - $ref: "#/components/schemas/UserSummary"
          nullable: true
          description: Null for anonymous tips.
        to_user_id: { $ref: "#/components/schemas/UUID" }
        amount_cents: { type: integer, format: int64, example: 500 }
        currency: { type: string, example: "usd" }
        message: { type: string, nullable: true }
        status: { $ref: "#/components/schemas/TipStatus" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, to_user_id, amount_cents, currency, status, created_at]

    # ── Channel points / loyalty ────────────────────────────────────────────────

    PointsBalance:
      type: object
      description: The caller's channel-points balance in a specific channel.
      properties:
        channel_user_id: { $ref: "#/components/schemas/UUID" }
        balance: { type: integer, format: int64, example: 12500 }
        lifetime_earned: { type: integer, format: int64, example: 84200 }
      required: [channel_user_id, balance, lifetime_earned]

    ChannelReward:
      type: object
      description: A creator-defined channel-points reward.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        channel_user_id: { $ref: "#/components/schemas/UUID" }
        title: { type: string, example: "Hydrate!" }
        description: { type: string, nullable: true }
        cost: { type: integer, format: int64, example: 500 }
        is_enabled: { type: boolean, default: true }
        requires_input: { type: boolean, default: false }
        cooldown_seconds: { type: integer, nullable: true }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, channel_user_id, title, cost, is_enabled, requires_input]

    Redemption:
      type: object
      description: A viewer's redemption of a channel-points reward.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        reward: { $ref: "#/components/schemas/ChannelReward" }
        redeemer: { $ref: "#/components/schemas/UserSummary" }
        user_input: { type: string, nullable: true }
        status: { $ref: "#/components/schemas/RedemptionStatus" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, reward, redeemer, status, created_at]

    # ── Schedule ────────────────────────────────────────────────────────────────

    ScheduleEntry:
      type: object
      description: A planned broadcast slot on a creator's schedule.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        user_id: { $ref: "#/components/schemas/UUID" }
        title: { type: string, example: "Ranked grind + viewer games" }
        category_id: { type: string, nullable: true, example: "valorant" }
        starts_at: { $ref: "#/components/schemas/Timestamp" }
        ends_at:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
        is_recurring: { type: boolean, default: false }
        recurrence_weekday:
          type: integer
          minimum: 0
          maximum: 6
          nullable: true
          description: Weekday for recurring entries (0 = Sunday).
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, user_id, title, starts_at]

    # ── Predictions ─────────────────────────────────────────────────────────────

    PredictionOutcome:
      type: object
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        title: { type: string, example: "Win" }
        color: { type: string, nullable: true, example: "#1E90FF" }
        points_pool: { type: integer, format: int64, example: 48200 }
        bettor_count: { type: integer, example: 312 }
      required: [id, title, points_pool, bettor_count]

    Prediction:
      type: object
      description: A channel-points prediction attached to a live stream.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        stream_id: { $ref: "#/components/schemas/UUID" }
        title: { type: string, example: "Will we win this game?" }
        status: { $ref: "#/components/schemas/PredictionStatus" }
        locks_at:
          allOf:
            - $ref: "#/components/schemas/Timestamp"
          nullable: true
        resolved_outcome_id:
          allOf:
            - $ref: "#/components/schemas/UUID"
          nullable: true
        outcomes:
          type: array
          items: { $ref: "#/components/schemas/PredictionOutcome" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, stream_id, title, status, outcomes]

    # ── Safety ──────────────────────────────────────────────────────────────────

    ModerationAction:
      type: object
      description: An entry in a channel's moderation audit log.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        room_id: { type: string }
        actor: { $ref: "#/components/schemas/UserSummary" }
        target:
          allOf:
            - $ref: "#/components/schemas/UserSummary"
          nullable: true
          description: Null for room-level actions (e.g. slow_on, clear).
        action: { $ref: "#/components/schemas/ModerationActionType" }
        reason: { type: string, nullable: true }
        duration_seconds: { type: integer, nullable: true }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, room_id, actor, action, created_at]

    Report:
      type: object
      description: A user/content report submitted for trust & safety review.
      properties:
        id: { $ref: "#/components/schemas/UUID" }
        reporter:
          allOf:
            - $ref: "#/components/schemas/UserSummary"
          nullable: true
          description: Null for anonymous reports.
        target_type: { $ref: "#/components/schemas/ReportTargetType" }
        target_id: { type: string }
        reason_code: { $ref: "#/components/schemas/ReportReasonCode" }
        details: { type: string, nullable: true }
        status: { $ref: "#/components/schemas/ReportStatus" }
        created_at: { $ref: "#/components/schemas/Timestamp" }
      required: [id, target_type, target_id, reason_code, status, created_at]

    TwoFactorSetup:
      type: object
      description: |
        TOTP enrolment material. Shown only once; 2FA becomes active after the
        first code is confirmed via /v1/users/me/2fa/totp/verify.
      properties:
        secret: { type: string, example: "JBSWY3DPEHPK3PXP" }
        otpauth_url:
          type: string
          format: uri
          example: "otpauth://totp/LookCam:novastream?secret=JBSWY3DPEHPK3PXP&issuer=LookCam"
        recovery_codes:
          type: array
          items: { type: string }
          description: >
            One-time recovery codes, returned in plaintext exactly once at setup so
            the web can display them. They are never returned again.
          example: ["a1b2-c3d4", "e5f6-g7h8"]
      required: [secret, otpauth_url]

    # ── Creator analytics ───────────────────────────────────────────────────────

    CreatorAnalytics:
      type: object
      description: |
        Aggregated creator-dashboard analytics for the authenticated channel over a
        rolling period. Revenue/subscriptions/points/follows/videos are aggregated
        from LookCam's PostgreSQL; viewership is aggregated from ClickHouse and
        degrades gracefully (`viewership.source` = "unavailable", zeros) when the
        analytics store is offline. Money fields are integer cents.
      properties:
        period:
          type: string
          enum: [7d, 30d, 90d]
          description: Rolling window the figures are aggregated over.
          example: 30d
        revenue:
          type: object
          description: Gross revenue over the period, in integer cents.
          properties:
            subscription_cents:
              type: integer
              format: int64
              description: Revenue attributed to active subscriptions (tier price × active count).
              example: 49850
            tip_cents:
              type: integer
              format: int64
              description: Sum of completed tips received in the period.
              example: 12000
            total_cents:
              type: integer
              format: int64
              description: subscription_cents + tip_cents.
              example: 61850
            by_tier:
              type: array
              description: Subscription revenue broken down by tier.
              items:
                type: object
                properties:
                  tier:
                    type: integer
                    enum: [1, 2, 3]
                    example: 1
                  active_count:
                    type: integer
                    description: Active subscriptions at this tier.
                    example: 100
                  revenue_cents:
                    type: integer
                    format: int64
                    description: tier_price_cents × active_count.
                    example: 49900
                required: [tier, active_count, revenue_cents]
          required: [subscription_cents, tip_cents, total_cents, by_tier]
        subscriptions:
          type: object
          properties:
            active:
              type: integer
              description: Currently active subscriptions.
              example: 142
            new_in_period:
              type: integer
              description: Subscriptions started within the period.
              example: 23
            cancelled_in_period:
              type: integer
              description: Subscriptions cancelled within the period.
              example: 7
            churn_rate:
              type: number
              format: double
              description: cancelled_in_period / (active + cancelled_in_period), 0 when no base.
              example: 0.047
          required: [active, new_in_period, cancelled_in_period, churn_rate]
        points:
          type: object
          properties:
            earned:
              type: integer
              format: int64
              description: Channel points credited to viewers in the period (sum of positive ledger deltas).
              example: 84200
            spent:
              type: integer
              format: int64
              description: Channel points spent by viewers in the period (absolute sum of negative ledger deltas).
              example: 51000
            top_redemptions:
              type: array
              description: Most-redeemed rewards in the period, by count.
              items:
                type: object
                properties:
                  reward_title:
                    type: string
                    example: "Hydrate!"
                  count:
                    type: integer
                    example: 38
                required: [reward_title, count]
          required: [earned, spent, top_redemptions]
        follows:
          type: object
          properties:
            gained:
              type: integer
              description: Follows gained in the period.
              example: 312
            lost:
              type: integer
              description: Follows lost in the period (0 when unfollows are not tracked).
              example: 0
            net:
              type: integer
              description: gained - lost.
              example: 312
          required: [gained, lost, net]
        videos:
          type: object
          properties:
            total_views:
              type: integer
              format: int64
              description: Sum of view counts across the channel's videos.
              example: 15320
            count:
              type: integer
              description: Number of videos on the channel.
              example: 18
          required: [total_views, count]
        viewership:
          type: object
          description: |
            Viewership aggregated from ClickHouse over the creator's stream(s). When
            ClickHouse is unavailable the fields are zero and `source` is "unavailable".
          properties:
            peak_viewers:
              type: integer
              description: Peak concurrent viewers in the period.
              example: 842
            unique_viewers:
              type: integer
              format: int64
              description: Distinct viewers in the period.
              example: 12045
            watch_time_minutes:
              type: integer
              format: int64
              description: Total watch time across all streams, in minutes.
              example: 305120
            source:
              type: string
              description: Origin of the viewership figures ("clickhouse" when live, "unavailable" when the store is offline).
              example: clickhouse
          required: [peak_viewers, unique_viewers, watch_time_minutes, source]
      required: [period, revenue, subscriptions, points, follows, videos, viewership]

    # ── Pagination ──────────────────────────────────────────────────────────────

    PaginationMeta:
      type: object
      properties:
        page: { type: integer, minimum: 1, example: 1 }
        per_page: { type: integer, minimum: 1, maximum: 100, example: 24 }
        total: { type: integer, example: 512 }
        has_more: { type: boolean, example: true }
      required: [page, per_page, total]

    PaginatedStreamCards:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/StreamCard" }
          required: [items]

    PaginatedClips:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/Clip" }
          required: [items]

    PaginatedNotifications:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/Notification" }
          required: [items]

    PaginatedVideos:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/Video" }
          required: [items]

    PaginatedChannelSubscriptions:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/ChannelSubscription" }
          required: [items]

    PaginatedTips:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/Tip" }
          required: [items]

    PaginatedRedemptions:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/Redemption" }
          required: [items]

    PaginatedModerationActions:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/ModerationAction" }
          required: [items]

    PaginatedUserSummaries:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/UserSummary" }
          required: [items]

    PaginatedPredictions:
      allOf:
        - $ref: "#/components/schemas/PaginationMeta"
        - type: object
          properties:
            items:
              type: array
              items: { $ref: "#/components/schemas/Prediction" }
          required: [items]

    SearchResults:
      type: object
      description: Unified search across channels, streams and clips.
      properties:
        streams:
          type: array
          items: { $ref: "#/components/schemas/StreamCard" }
        users:
          type: array
          items: { $ref: "#/components/schemas/UserSummary" }
        categories:
          type: array
          items: { $ref: "#/components/schemas/CategorySummary" }
        clips:
          type: array
          items: { $ref: "#/components/schemas/Clip" }

    # ── Error ─────────────────────────────────────────────────────────────────

    Error:
      type: object
      description: Standard error envelope returned on all non-2xx responses.
      properties:
        code: { type: string, example: "validation_failed" }
        message: { type: string, example: "name is required" }
        details:
          type: object
          additionalProperties: true
      required: [code, message]

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

  responses:
    Unauthorized:
      description: Authentication required or token invalid.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "auth_required", message: "authentication required" }
    Forbidden:
      description: Authenticated but not authorised for this resource.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "forbidden", message: "access denied" }
    NotFound:
      description: The requested resource was not found.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "not_found", message: "resource not found" }
    BadRequest:
      description: The request body or parameters were invalid.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "validation_failed", message: "name is required" }
    Conflict:
      description: The request conflicts with existing state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "username_taken", message: "that username is taken" }
    TooManyRequests:
      description: Rate limit exceeded.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "rate_limited", message: "too many requests" }
    InternalError:
      description: An unexpected server error occurred.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { code: "internal", message: "an unexpected error occurred" }

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

  parameters:
    PageParam:
      name: page
      in: query
      description: Page number (1-based).
      schema: { type: integer, minimum: 1, default: 1 }
    PerPageParam:
      name: per_page
      in: query
      description: Items per page (max 100).
      schema: { type: integer, minimum: 1, maximum: 100, default: 24 }
    UsernamePath:
      name: username
      in: path
      required: true
      description: Channel username.
      schema: { type: string }
    StreamIdPath:
      name: stream_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }
    UserIdPath:
      name: user_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }
    ClipIdPath:
      name: clip_id
      in: path
      required: true
      schema: { type: string }
    VideoIdPath:
      name: video_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }
    RewardIdPath:
      name: reward_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }
    RedemptionIdPath:
      name: redemption_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }
    EntryIdPath:
      name: entry_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }
    PredictionIdPath:
      name: prediction_id
      in: path
      required: true
      schema: { $ref: "#/components/schemas/UUID" }

# ── Paths ──────────────────────────────────────────────────────────────────────

paths:

  # ============================================================
  # Health / meta
  # ============================================================

  /healthz:
    get:
      operationId: healthCheck
      summary: Liveness probe
      tags: [system]
      security: []
      responses:
        "200":
          description: Service healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: "ok" }

  /v1/app/min-version:
    get:
      operationId: getMinAppVersion
      summary: Minimum supported mobile app version
      tags: [system]
      security: []
      responses:
        "200":
          description: Minimum version gate.
          content:
            application/json:
              schema:
                type: object
                properties:
                  min_version: { type: string, example: "2.4.0" }
                  force_update: { type: boolean }

  # ============================================================
  # Auth
  # ============================================================

  /v1/auth/register:
    post:
      operationId: register
      summary: Register a new account with email and password
      description: |
        Creates a new user with an email/password credential, auto-provisions a
        default stream and preferences, and returns access + refresh tokens
        (same shape as social-login). The front holds NO database adapter.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username: { type: string, maxLength: 100 }
                email: { type: string, format: email }
                password: { type: string, format: password, minLength: 8 }
                device_id:
                  type: string
                  description: Opaque client device identifier (sent by the mobile app), bound to the issued refresh token.
              required: [username, email, password]
      responses:
        "201":
          description: Account created; session established.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: Email or username already in use.
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /v1/auth/login:
    post:
      operationId: login
      summary: Log in with email/username and password
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                identifier:
                  type: string
                  description: Email or username.
                password: { type: string, format: password }
                device_id:
                  type: string
                  description: Opaque client device identifier (sent by the mobile app), bound to the issued refresh token.
              required: [identifier, password]
      responses:
        "200":
          description: |
            Login successful; cookies set. If the account has 2FA enabled the
            response instead carries `requires_2fa: true` + `two_factor_token`
            and login must be completed via /v1/auth/2fa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /v1/auth/social-login:
    post:
      operationId: socialLogin
      summary: Exchange an OAuth provider token for a LookCam session
      description: |
        Used by NextAuth in the front after a Google/Apple/Facebook/Discord/Twitter
        sign-in. Upserts the user in lookcam-api (the front holds NO database adapter).
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  type: string
                  enum: [google, apple, facebook, discord, twitter, wedeploy]
                id_token: { type: string }
                access_token: { type: string }
                device_id:
                  type: string
                  description: Opaque client device identifier (sent by the mobile app), bound to the issued refresh token.
              required: [provider]
      responses:
        "200":
          description: |
            Session established; user upserted. If the account has 2FA enabled the
            response instead carries `requires_2fa: true` + `two_factor_token`
            and login must be completed via /v1/auth/2fa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/oauth/exchange:
    post:
      operationId: oauthExchange
      summary: Exchange a one-time OAuth code for a session (native deep-link flow)
      description: |
        Completes the provider OAuth round-trip for NATIVE clients without ever
        putting tokens in a URL. After the provider callback, the API redirects
        the mobile app to its deep link with a single `code` query param; the app
        exchanges that code here for tokens. The code is single-use and expires
        in 60 seconds. (The web app does not use this endpoint — its OAuth
        callback sets HttpOnly cookies and redirects to a clean URL.)
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  description: The one-time code from the deep-link redirect.
              required: [code]
      responses:
        "200":
          description: Session established; tokens returned (bearer semantics).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/refresh-token:
    post:
      operationId: refreshToken
      summary: Rotate tokens using the refresh token
      description: |
        Exchanges a valid refresh token for a fresh access + refresh token pair.
        The refresh token is validated against the token stored for the given
        `device_id`, so both fields are required.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                refresh_token: { type: string }
                device_id:
                  type: string
                  description: Device identifier the refresh token was issued for.
              required: [refresh_token, device_id]
      responses:
        "200":
          description: New token pair issued (refresh token is rotated).
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  refresh_token: { type: string }
                  device_id:
                    type: string
                    description: Echo of the device the tokens are bound to.
                required: [access_token, refresh_token]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/logout:
    post:
      operationId: logout
      summary: Log out the current session
      tags: [auth]
      responses:
        "200":
          description: Logged out; cookies cleared.

  /v1/auth/tokens:
    get:
      operationId: listApiTokens
      summary: List personal API tokens
      tags: [auth]
      responses:
        "200":
          description: Tokens (secrets redacted).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/APIToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createApiToken
      summary: Create a personal API token
      tags: [auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
              required: [name]
      responses:
        "201":
          description: Created. The full secret is returned only here.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIToken" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/tokens/{id}:
    delete:
      operationId: deleteApiToken
      summary: Revoke a personal API token
      tags: [auth]
      parameters:
        - name: id
          in: path
          required: true
          schema: { $ref: "#/components/schemas/UUID" }
      responses:
        "204": { description: Revoked. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/forgot-password:
    post:
      operationId: forgotPassword
      summary: Request a password-reset email
      description: |
        Always returns 204 regardless of whether the email matches an account
        (prevents user enumeration). When it does, a short-lived reset token
        is e-mailed.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email }
              required: [email]
      responses:
        "204": { description: Accepted (whether or not the email exists). }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /v1/auth/reset-password:
    post:
      operationId: resetPassword
      summary: Reset password using an e-mailed token
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string, description: "Reset token from the e-mail." }
                new_password: { type: string, format: password, minLength: 8 }
              required: [token, new_password]
      responses:
        "204": { description: Password reset; existing sessions revoked. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Token invalid or expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "invalid_token", message: "reset token invalid or expired" }

  /v1/auth/verify-email:
    post:
      operationId: verifyEmail
      summary: Verify an e-mail address using an e-mailed token
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
              required: [token]
      responses:
        "204": { description: E-mail verified. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Token invalid or expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "invalid_token", message: "verification token invalid or expired" }

  /v1/auth/resend-verification:
    post:
      operationId: resendVerification
      summary: Re-send the e-mail verification message
      tags: [auth]
      responses:
        "204": { description: Verification e-mail queued. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: E-mail already verified.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "already_verified", message: "email already verified" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /v1/auth/ws-ticket:
    post:
      operationId: issueWsTicket
      summary: Issue a short-lived, single-use WebSocket auth ticket
      description: |
        Issues an opaque, short-lived (~30s), **single-use** ticket that
        authenticates a realtime WebSocket connection without exposing a
        long-lived JWT to JavaScript. Intended for **cookie-mode (web)** clients:
        the browser holds its access token only in an HttpOnly cookie
        (`lc_access_token`) and so cannot put a `token` on the WS connect URL.
        Native/bearer clients hold their JWT and can keep using `token` directly,
        but may also use this endpoint.

        The caller must be authenticated — via the `lc_access_token` cookie
        (cookie-mode) **or** an `Authorization: Bearer <jwt>` header
        (bearer-mode). The returned `ticket` is appended as the `ticket` query
        parameter to a WS connect endpoint (`/v1/chat/connect`,
        `/v1/viewer/connect`, `/v1/events/connect`); the server consumes it
        atomically on connect (single use — a second connect with the same ticket
        is treated as anonymous). Lightly rate-limited per IP.
      tags: [auth]
      security:
        - bearerAuth: []
        - cookieAuth: []
      responses:
        "200":
          description: Ticket issued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WSTicket" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "503":
          description: Ticket store (Redis) unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/auth/google/callback:
    get:
      operationId: googleOauthCallback
      summary: Google OAuth redirect callback (browser redirect, not a JSON API)
      description: |
        Browser-redirect endpoint hit by Google after consent. Exchanges `code`
        for the provider token, upserts the user and 302-redirects to the front:
        `{FRONTEND_URL}/auth/callback?access_token=...&refresh_token=...&device_id=...`.
      tags: [auth]
      security: []
      parameters:
        - name: code
          in: query
          required: true
          schema: { type: string }
        - name: device_id
          in: query
          schema: { type: string }
      responses:
        "302": { description: Redirect to the front with session tokens. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/facebook/callback:
    get:
      operationId: facebookOauthCallback
      summary: Facebook OAuth redirect callback (browser redirect, not a JSON API)
      description: |
        Browser-redirect endpoint hit by Facebook after consent. Exchanges `code`
        for the provider token, upserts the user and 302-redirects to the front:
        `{FRONTEND_URL}/auth/callback?access_token=...&refresh_token=...&device_id=...`.
      tags: [auth]
      security: []
      parameters:
        - name: code
          in: query
          required: true
          schema: { type: string }
        - name: device_id
          in: query
          schema: { type: string }
      responses:
        "302": { description: Redirect to the front with session tokens. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/apple/callback:
    get:
      operationId: appleOauthCallback
      summary: Apple OAuth redirect callback (browser redirect, not a JSON API)
      description: |
        Browser-redirect endpoint hit by Apple after consent. Exchanges `code`
        for the provider token, upserts the user and 302-redirects to the front:
        `{FRONTEND_URL}/auth/callback?access_token=...&refresh_token=...&device_id=...`.
      tags: [auth]
      security: []
      parameters:
        - name: code
          in: query
          required: true
          schema: { type: string }
        - name: device_id
          in: query
          schema: { type: string }
      responses:
        "302": { description: Redirect to the front with session tokens. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ============================================================
  # Users / account
  # ============================================================

  /v1/users/me:
    get:
      operationId: getMe
      summary: Get the authenticated user
      tags: [users]
      responses:
        "200":
          description: Current user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/profile:
    put:
      operationId: updateProfile
      summary: Update own profile (bio, display name, social links)
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username_title: { type: string }
                bio: { type: string }
                social_links: { $ref: "#/components/schemas/SocialLinks" }
      responses:
        "200":
          description: Updated user.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/profile/avatar:
    post:
      operationId: uploadAvatar
      summary: Upload a new avatar image
      tags: [users]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
              required: [file]
      responses:
        "200":
          description: Uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  image_url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/profile/banner:
    post:
      operationId: uploadBanner
      summary: Upload a new channel banner image
      tags: [users]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
              required: [file]
      responses:
        "200":
          description: Uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  banner_url: { type: string, format: uri }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/password:
    put:
      operationId: changePassword
      summary: Change own password
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                current_password: { type: string, format: password }
                new_password: { type: string, format: password, minLength: 8 }
              required: [current_password, new_password]
      responses:
        "200": { description: Password changed. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/delete-account:
    post:
      operationId: deleteAccount
      summary: Soft-delete own account
      tags: [users]
      responses:
        "200": { description: Account scheduled for deletion. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/{username}/clips:
    get:
      operationId: getUserClips
      summary: List a channel's published clips
      tags: [clips]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated clips.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedClips" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/devices:
    get:
      operationId: listDevices
      summary: List registered push devices
      tags: [users]
      responses:
        "200":
          description: Devices.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Device" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: registerDevice
      summary: Register a push device token
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                device_token: { type: string }
                device_type: { type: string, enum: [ios, android] }
              required: [device_token, device_type]
      responses:
        "201": { description: Registered. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/devices/{token}:
    delete:
      operationId: unregisterDevice
      summary: Unregister a push device token
      tags: [users]
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        "204": { description: Unregistered. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/devices/update:
    post:
      operationId: updateDevice
      summary: Update a registered push device's settings
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                device_token: { type: string }
                notifications_enabled: { type: boolean }
              required: [device_token]
      responses:
        "200":
          description: Device updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Device settings updated successfully" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ============================================================
  # Channels / profile (public)
  # ============================================================

  /v1/channels/{username}:
    get:
      operationId: getChannelByUsername
      summary: Get a public channel/profile by username
      description: Primary SSR fetch for the watch/profile page. `is_following` reflects the caller.
      tags: [users]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Public profile.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicProfile" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ============================================================
  # Streams
  # ============================================================

  /v1/streams/{stream_id}:
    get:
      operationId: getStream
      summary: Get stream metadata
      tags: [streams]
      security: []
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "200":
          description: Stream (owner fields included only for the owner).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stream" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateStream
      summary: Update own stream (title, category, chat settings)
      tags: [streams]
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                category_id: { type: string }
                is_chat_enabled: { type: boolean }
                is_chat_followers_only: { type: boolean }
                chat_slow_mode: { type: boolean }
                chat_slow_mode_seconds: { type: integer }
                chat_emote_only: { type: boolean }
                chat_followers_only_minutes: { type: integer }
                chat_unique_chat: { type: boolean }
                chat_shield_mode: { type: boolean }
                language:
                  type: string
                  nullable: true
                  description: Broadcast language (ISO 639-1 code).
                is_mature: { type: boolean }
                tags:
                  type: array
                  maxItems: 10
                  items: { type: string }
      responses:
        "200":
          description: Updated stream.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stream" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/streams/{stream_id}/playback:
    get:
      operationId: getStreamPlayback
      summary: Resolve a signed playback source (delegated to Streamway)
      description: |
        Returns a short-lived signed HLS manifest URL. lookcam-api requests a secure
        link from Streamway and returns only the product-shaped descriptor. The front
        feeds `manifest_url` to hls.js. Replaces direct manifest exposure.
      tags: [streams]
      security: []
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "200":
          description: Signed playback descriptor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlaybackSource" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/streams/{stream_id}/manifest:
    get:
      operationId: getStreamManifest
      summary: Redirect to the signed HLS manifest (convenience)
      description: 302 redirect to the Streamway edge secure link. Prefer /playback for hls.js.
      tags: [streams]
      security: []
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "302": { description: Redirect to signed manifest URL. }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/streams/{stream_id}/qualities:
    get:
      operationId: getStreamQualities
      summary: List available transcode renditions
      tags: [streams]
      security: []
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "200":
          description: Qualities.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/StreamQuality" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/streams/{stream_id}/key/regenerate:
    post:
      operationId: regenerateStreamKey
      summary: Regenerate the stream's ingest key
      description: Issues a fresh stream key and re-syncs it to Streamway.
      tags: [streams]
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "200":
          description: New key (owner only).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Stream" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ============================================================
  # Discovery — browse, categories, feeds, search
  # ============================================================

  /v1/browse/streams:
    get:
      operationId: browseStreams
      summary: Browse live streams (optionally filtered)
      tags: [discovery]
      security: []
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: category_id
          in: query
          schema: { type: string }
        - name: language
          in: query
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string, enum: [viewers, recent, recommended], default: viewers }
        - name: tag
          in: query
          description: >
            Filter to streams whose tags[] contains this value. Repeatable, or a
            comma-separated list (matches streams overlapping any of the tags).
          schema: { type: string }
          example: "ranked"
        - name: include_mature
          in: query
          description: >
            Include 18+ (is_mature) streams. Default false: mature streams are
            excluded unless the caller explicitly opts in. Legacy alias: `mature`.
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Paginated stream cards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedStreamCards" }

  /v1/browse/languages:
    get:
      operationId: getBrowseLanguages
      summary: List languages that currently have live streams
      tags: [discovery]
      security: []
      responses:
        "200":
          description: Languages.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    code: { type: string, example: "pl" }
                    name: { type: string, example: "Polski" }
                    live_count: { type: integer }

  /v1/streams/recommended:
    get:
      operationId: getRecommendedStreams
      summary: Recommended/top streams (home rail)
      tags: [discovery]
      security: []
      responses:
        "200":
          description: Recommended cards.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/StreamCard" }

  /v1/streams/suggested:
    get:
      operationId: getSuggestedStreams
      summary: Suggested streams (home grid / sidebar)
      tags: [discovery]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated suggested cards (personalised when authenticated).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedStreamCards" }

  /v1/categories:
    get:
      operationId: listCategories
      summary: List browseable categories
      tags: [discovery]
      security: []
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Categories.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Category" }

  /v1/categories/{id}:
    get:
      operationId: getCategory
      summary: Get a category and its live streams
      tags: [discovery]
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Category with paginated streams.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Category"
                  - type: object
                    properties:
                      streams: { $ref: "#/components/schemas/PaginatedStreamCards" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/search:
    get:
      operationId: search
      summary: Unified search across channels, streams and clips
      tags: [discovery]
      security: []
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: type
          in: query
          description: Restrict to a single result type.
          schema: { type: string, enum: [all, streams, users, categories, clips], default: all }
      responses:
        "200":
          description: Search results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SearchResults" }

  # ============================================================
  # Social — follows / blocks
  # ============================================================

  /v1/social/following:
    get:
      operationId: getFollowing
      summary: List channels the caller follows (with live status)
      tags: [social]
      responses:
        "200":
          description: Followed channels.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Follow" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/followers:
    get:
      operationId: getFollowers
      summary: List the caller's followers
      tags: [social]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Followers.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/UserSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/feed:
    get:
      operationId: getSocialFeed
      summary: Live channels from people the caller follows
      tags: [social]
      responses:
        "200":
          description: Live followed channels.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/StreamCard" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/follow:
    post:
      operationId: followUser
      summary: Follow a channel
      tags: [social]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { $ref: "#/components/schemas/UUID" }
              required: [user_id]
      responses:
        "200": { description: Followed. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/unfollow:
    post:
      operationId: unfollowUser
      summary: Unfollow a channel
      tags: [social]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { $ref: "#/components/schemas/UUID" }
              required: [user_id]
      responses:
        "200": { description: Unfollowed. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/is-following/{user_id}:
    get:
      operationId: isFollowing
      summary: Check whether the caller follows a channel
      tags: [social]
      parameters:
        - $ref: "#/components/parameters/UserIdPath"
      responses:
        "200":
          description: Follow status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  is_following: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/block:
    post:
      operationId: blockUser
      summary: Block a user
      tags: [social]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { $ref: "#/components/schemas/UUID" }
              required: [user_id]
      responses:
        "200": { description: Blocked. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/unblock:
    post:
      operationId: unblockUser
      summary: Unblock a user
      tags: [social]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { $ref: "#/components/schemas/UUID" }
              required: [user_id]
      responses:
        "200": { description: Unblocked. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/social/blocked:
    get:
      operationId: listBlockedUsers
      summary: List the users the caller has blocked
      description: >
        Paginated list of users the authenticated caller has blocked, for the
        dashboard Community "Blocked" tab. Most recent first.
      tags: [social]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated blocked users.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedUserSummaries" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ============================================================
  # Chat (WebSocket + REST control plane)
  # ============================================================

  /v1/chat/connect:
    get:
      operationId: chatConnect
      summary: Open the chat WebSocket
      description: |
        WebSocket upgrade via plain GET — a successful handshake answers
        `101 Switching Protocols`. Query `room_id` (required) selects the room.
        The connection is authenticated by one of (precedence as listed):
        `ticket` — a short-lived single-use ticket from `POST /v1/auth/ws-ticket`,
        for **cookie-mode (web)** clients that hold no JWT in JS; consumed
        atomically on connect; or `token` — a long-lived JWT, for
        **bearer/native** clients. With neither, the connection is anonymous and
        **read-only** (the server still pushes history, room config, user list
        and live messages, but `chat_message` frames from the client are
        rejected). An invalid/expired/already-consumed `ticket` falls back to
        anonymous read-only; an invalid or expired `token` is rejected with 401
        before the upgrade.

        Message frames are JSON; the server pushes `ChatMessage` frames (see the
        `ChatMessage` schema description) plus moderation/system/poll events and
        JSON `{"type":"ping"}` keep-alives; clients send `chat_message`,
        `mod_action`, `poll_vote`, `pin_message`, `delete_message` and
        `ping`/`pong` frames.
      tags: [chat]
      security: []
      parameters:
        - name: room_id
          in: query
          required: true
          description: Room/stream ID. (`roomID` is accepted as a legacy alias.)
          schema: { type: string }
        - name: ticket
          in: query
          description: |
            Short-lived single-use ticket from `POST /v1/auth/ws-ticket`
            (cookie-mode web auth). Takes precedence over `token`. Invalid/expired/
            already-consumed → anonymous read-only.
          schema: { type: string }
        - name: token
          in: query
          description: |
            Optional JWT (bearer/native auth). Used only when `ticket` is absent;
            omit both for an anonymous read-only connection.
          schema: { type: string }
      responses:
        "101": { description: Switching Protocols (WebSocket). }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/chat/history:
    get:
      operationId: getChatHistory
      summary: Recent chat messages for a room (paginated)
      tags: [chat]
      security: []
      parameters:
        - name: room_id
          in: query
          required: true
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 50, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: Recent messages with pagination info.
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items: { $ref: "#/components/schemas/ChatMessage" }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
                  has_more: { type: boolean }
                  timestamp: { type: integer, format: int64, description: "Unix seconds." }
                required: [messages, total, has_more]
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/chat/settings/{room_id}:
    get:
      operationId: getChatSettings
      summary: Get a room's chat settings
      tags: [chat]
      security: []
      parameters:
        - name: room_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Chat settings (active modes).
          content:
            application/json:
              schema:
                type: object
                properties:
                  chat_modes: { $ref: "#/components/schemas/ChatModes" }
                required: [chat_modes]
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/settings/enabled:
    post:
      operationId: chatSetEnabled
      summary: Enable or disable chat for a room (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                enabled: { type: boolean }
              required: [room_id, enabled]
      responses:
        "200":
          description: Saved and broadcast to the room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatSettingsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/slow:
    post:
      operationId: chatSetSlowMode
      summary: Enable or disable slow mode (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                enabled: { type: boolean }
                seconds:
                  type: integer
                  default: 30
                  description: Interval between messages; defaults to 30 when omitted.
              required: [room_id, enabled]
      responses:
        "200":
          description: Saved and broadcast to the room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatSettingsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/emoteonly:
    post:
      operationId: chatSetEmoteOnly
      summary: Enable or disable emote-only mode (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                enabled: { type: boolean }
              required: [room_id, enabled]
      responses:
        "200":
          description: Saved and broadcast to the room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatSettingsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/followersonly:
    post:
      operationId: chatSetFollowersOnly
      summary: Enable or disable followers-only mode (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                enabled: { type: boolean }
                minutes:
                  type: integer
                  default: 0
                  minimum: 0
                  description: Minimum follow age in minutes (0 = any follower).
              required: [room_id, enabled]
      responses:
        "200":
          description: Saved and broadcast to the room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatSettingsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/uniquechat:
    post:
      operationId: chatSetUniqueChat
      summary: Enable or disable unique-chat (r9k) mode (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                enabled: { type: boolean }
              required: [room_id, enabled]
      responses:
        "200":
          description: Saved and broadcast to the room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatSettingsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/shield:
    post:
      operationId: chatSetShieldMode
      summary: Enable or disable shield mode (moderator)
      description: |
        Shield mode is a panic switch — enabling it also turns on
        followers-only (10 min) and slow mode (5 s).
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                enabled: { type: boolean }
              required: [room_id, enabled]
      responses:
        "200":
          description: Saved and broadcast to the room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatSettingsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/pin:
    post:
      operationId: chatPinMessage
      summary: Pin a message in a room (moderator)
      description: |
        Either `message_id` (pin an existing chat message) or `content`
        (pin ad-hoc text authored by the moderator) must be provided.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                message_id: { type: string }
                content: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Pinned and broadcast.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/settings/unpin:
    post:
      operationId: chatUnpinMessage
      summary: Unpin the room's pinned message (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Unpinned and broadcast.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/ban:
    post:
      operationId: chatBanUser
      summary: Ban a user from a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
                reason: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Banned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/unban:
    post:
      operationId: chatUnbanUser
      summary: Lift a user's ban in a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Unbanned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/timeout:
    post:
      operationId: chatTimeoutUser
      summary: Time out a user in a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
                duration: { type: integer, description: "Timeout duration in seconds.", example: 600 }
                reason: { type: string }
              required: [room_id, duration]
      responses:
        "200":
          description: Timed out.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/mod:
    post:
      operationId: chatModUser
      summary: Grant a user the moderator role in a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Moderator role granted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/unmod:
    post:
      operationId: chatUnmodUser
      summary: Remove a user's moderator role in a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Moderator role removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/vip:
    post:
      operationId: chatVipUser
      summary: Grant a user the VIP role in a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id]
      responses:
        "200":
          description: VIP role granted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/unvip:
    post:
      operationId: chatUnvipUser
      summary: Remove a user's VIP role in a room (moderator)
      description: Either `user_id` or `username` must be provided to identify the target.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id]
      responses:
        "200":
          description: VIP role removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/clear:
    post:
      operationId: chatClearChat
      summary: Clear all messages in a room (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
              required: [room_id]
      responses:
        "200":
          description: Chat cleared and broadcast.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/mod/message:
    delete:
      operationId: chatDeleteMessage
      summary: Delete a chat message (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                message_id: { type: string, format: uuid }
              required: [room_id, message_id]
      responses:
        "200":
          description: Deleted (soft-delete, broadcast to the room).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/poll/create:
    post:
      operationId: createChatPoll
      summary: Create a chat poll (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                question: { type: string }
                options:
                  type: array
                  minItems: 2
                  items:
                    type: object
                    properties:
                      id: { type: string, description: "Optional; generated when omitted." }
                      text: { type: string }
                    required: [text]
                duration_sec:
                  type: integer
                  default: 120
                  description: Poll duration in seconds; defaults to 120 when omitted.
              required: [room_id, question, options]
      responses:
        "200":
          description: Poll created and broadcast to the room.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  poll_id: { type: string }
                required: [success, poll_id]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/poll/vote:
    post:
      operationId: voteChatPoll
      summary: Vote in an active poll
      description: Re-voting moves the caller's vote to the new option.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                poll_id: { type: string }
                option: { type: string, description: "The chosen option's ID." }
              required: [room_id, poll_id, option]
      responses:
        "200":
          description: Vote recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/poll/results/{poll_id}:
    get:
      operationId: getChatPollResults
      summary: Current results for a poll
      tags: [chat]
      parameters:
        - name: poll_id
          in: path
          required: true
          schema: { type: string }
        - name: room_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Live vote counts keyed by option ID.
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll_id: { type: string }
                  question: { type: string }
                  options:
                    type: array
                    items: { $ref: "#/components/schemas/ChatPollOption" }
                  results:
                    type: object
                    description: Vote counts keyed by option ID.
                    additionalProperties: { type: integer }
                  total_votes: { type: integer }
                  ends_at: { $ref: "#/components/schemas/Timestamp" }
                required: [poll_id, question, options, results, total_votes]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/poll/end:
    post:
      operationId: endChatPoll
      summary: End an active poll early (moderator)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                poll_id: { type: string }
              required: [room_id, poll_id]
      responses:
        "200":
          description: Poll ended; final counts keyed by option ID.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  results:
                    type: object
                    description: Final vote counts keyed by option ID.
                    additionalProperties: { type: integer }
                required: [success, results]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/poll/active/{room_id}:
    get:
      operationId: getActiveChatPoll
      summary: Get the room's currently active poll
      tags: [chat]
      security: []
      parameters:
        - name: room_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Active poll.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatPoll" }
        "404":
          description: No active poll in this room.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "not_found", message: "no active poll" }

  /v1/chat/emotes/global:
    get:
      operationId: getGlobalEmotes
      summary: List global emotes
      tags: [chat]
      security: []
      responses:
        "200":
          description: Global emotes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  emotes:
                    type: array
                    items: { $ref: "#/components/schemas/ChatEmote" }
                required: [emotes]

  /v1/chat/emotes/channel/{channel_id}:
    get:
      operationId: getChannelEmotes
      summary: List a channel's emotes
      tags: [chat]
      security: []
      parameters:
        - name: channel_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Channel emotes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  emotes:
                    type: array
                    items: { $ref: "#/components/schemas/ChatEmote" }
                required: [emotes]
    post:
      operationId: addChannelEmote
      summary: Upload a new channel emote (channel owner)
      tags: [chat]
      parameters:
        - name: channel_id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 25
                  pattern: "^[a-zA-Z0-9]+$"
                file: { type: string, format: binary, description: "Emote image (max 1 MB)." }
              required: [name, file]
      responses:
        "200":
          description: Emote uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  emote:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      url: { type: string, format: uri }
                    required: [id, name, url]
                required: [success, emote]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/emotes/{emote_id}:
    delete:
      operationId: deleteChannelEmote
      summary: Delete a channel emote (uploader/channel owner)
      tags: [chat]
      parameters:
        - name: emote_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Emote deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatActionResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/pinned/{channel_id}:
    get:
      operationId: getPinnedMessage
      summary: Get the pinned message for a channel
      tags: [chat]
      security: []
      parameters:
        - name: channel_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Pinned message (`pinned_message` is null when nothing is pinned).
          content:
            application/json:
              schema:
                type: object
                properties:
                  pinned_message:
                    allOf:
                      - $ref: "#/components/schemas/ChatMessage"
                    nullable: true
                required: [pinned_message]

  /v1/chat/rooms/{room_id}/moderators:
    get:
      operationId: getRoomModerators
      summary: List a room's moderators
      description: Includes offline users granted the role (excludes the room owner).
      tags: [chat]
      parameters:
        - name: room_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Moderators.
          content:
            application/json:
              schema:
                type: object
                properties:
                  moderators:
                    type: array
                    items: { $ref: "#/components/schemas/RoomMember" }
                required: [moderators]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/rooms/{room_id}/vips:
    get:
      operationId: getRoomVips
      summary: List a room's VIPs
      tags: [chat]
      parameters:
        - name: room_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: VIPs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  vips:
                    type: array
                    items: { $ref: "#/components/schemas/RoomMember" }
                required: [vips]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/rooms/{room_id}/banned:
    get:
      operationId: getRoomBannedUsers
      summary: List a room's banned users
      tags: [chat]
      parameters:
        - name: room_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Banned users.
          content:
            application/json:
              schema:
                type: object
                properties:
                  banned:
                    type: array
                    items: { $ref: "#/components/schemas/RoomMember" }
                required: [banned]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ── Badges ──

  /v1/badges/user/{user_id}:
    get:
      operationId: getUserBadges
      summary: List a user's chat badges
      tags: [chat]
      security: []
      parameters:
        - $ref: "#/components/parameters/UserIdPath"
      responses:
        "200":
          description: Badges granted to the user.
          content:
            application/json:
              schema:
                type: object
                properties:
                  badges:
                    type: array
                    items: { $ref: "#/components/schemas/UserBadge" }
                required: [badges]

  /v1/badges/available:
    get:
      operationId: getAvailableBadges
      summary: List available (global) badge definitions
      tags: [chat]
      security: []
      responses:
        "200":
          description: Global badge definitions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  badges:
                    type: array
                    items: { $ref: "#/components/schemas/ChatBadge" }
                required: [badges]

  /v1/chat/ignore:
    post:
      operationId: chatIgnoreUser
      summary: Ignore another user's messages (persistent, per-account)
      description: >
        Hides the target's messages for the caller. Persisted globally
        (survives reconnects); the caller's full ignore set is also pushed on
        chat connect via the `ignore_list` WebSocket frame. Either `user_id` or
        `username` identifies the target; `user_id` is authoritative.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id, user_id]
      responses:
        "200":
          description: Ignored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ignored: { type: boolean }
                required: [ignored]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/chat/unignore:
    post:
      operationId: chatUnignoreUser
      summary: Stop ignoring a previously ignored user
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                user_id: { $ref: "#/components/schemas/UUID" }
                username: { type: string }
              required: [room_id, user_id]
      responses:
        "200":
          description: No longer ignored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ignored: { type: boolean }
                required: [ignored]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/chat/ignored:
    get:
      operationId: chatListIgnored
      summary: List the caller's ignored users (SQ-456)
      description: >
        Returns the caller's persisted ignore set with resolved usernames so the
        list can be reviewed and cleared from a UI (the chat dropdown only lets
        you unignore a user when they post again). The set is global per account.
      tags: [chat]
      parameters:
        - name: room_id
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Ignore list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ignored:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id: { $ref: "#/components/schemas/UUID" }
                        username: { type: string }
                      required: [user_id, username]
                required: [ignored]
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/chat/blocklist:
    get:
      operationId: chatListBlockedTerms
      summary: List the channel blocklist (AutoMod-lite, moderators only)
      tags: [chat]
      parameters:
        - name: room_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Blocklist.
          content:
            application/json:
              schema:
                type: object
                properties:
                  terms:
                    type: array
                    items: { $ref: "#/components/schemas/ChatBlockedTerm" }
                required: [terms]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: chatAddBlockedTerm
      summary: Add a banned word / regex to the channel blocklist (moderators only)
      description: >
        `action` is `block` (reject the message on send) or `hold` (divert it to
        the moderator review queue). `is_regex` terms are matched
        case-insensitively with Go RE2.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                term: { type: string }
                is_regex: { type: boolean }
                action: { type: string, enum: [block, hold, allow, block_links, verified_only] }
              required: [room_id, term]
      responses:
        "200":
          description: Term added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  term: { $ref: "#/components/schemas/ChatBlockedTerm" }
                required: [term]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/blocklist/{id}:
    delete:
      operationId: chatDeleteBlockedTerm
      summary: Remove a term from the channel blocklist (moderators only)
      tags: [chat]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: room_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Term removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                required: [deleted]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/held:
    get:
      operationId: chatListHeldMessages
      summary: List the moderator held-message review queue (moderators only)
      tags: [chat]
      parameters:
        - name: room_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Held messages awaiting review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  held:
                    type: array
                    items: { $ref: "#/components/schemas/HeldMessage" }
                required: [held]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/held/approve:
    post:
      operationId: chatApproveHeldMessage
      summary: Release a held message into the chat (moderators only)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                held_id: { type: string }
              required: [room_id, held_id]
      responses:
        "200":
          description: Approved and broadcast.
          content:
            application/json:
              schema:
                type: object
                properties:
                  approved: { type: boolean }
                required: [approved]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/held/reject:
    post:
      operationId: chatRejectHeldMessage
      summary: Discard a held message without broadcasting (moderators only)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id: { type: string }
                held_id: { type: string }
              required: [room_id, held_id]
      responses:
        "200":
          description: Rejected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  rejected: { type: boolean }
                required: [rejected]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/chat/badges/grant:
    post:
      operationId: grantBadge
      summary: Grant a badge to a user (admin/streamer)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { $ref: "#/components/schemas/UUID" }
                badge_id: { type: string }
                expires_at:
                  allOf:
                    - $ref: "#/components/schemas/Timestamp"
                  nullable: true
              required: [user_id, badge_id]
      responses:
        "201":
          description: Badge granted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Badge granted" }
                  user_badge: { $ref: "#/components/schemas/UserBadge" }
                required: [user_badge]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/chat/badges/revoke:
    delete:
      operationId: revokeBadge
      summary: Revoke a badge from a user (admin/streamer)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user_id: { $ref: "#/components/schemas/UUID" }
                badge_id: { type: string }
              required: [user_id, badge_id]
      responses:
        "200":
          description: Badge revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Badge revoked" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Whispers (direct messages) ──

  /v1/whispers:
    post:
      operationId: sendWhisper
      summary: Send a whisper (direct message)
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                recipient_id: { $ref: "#/components/schemas/UUID" }
                content: { type: string, minLength: 1, maxLength: 500 }
              required: [recipient_id, content]
      responses:
        "201":
          description: Whisper sent.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Whisper" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    get:
      operationId: getWhispers
      summary: Get the whisper conversation with another user
      description: Returns up to 100 most recent messages and marks them as read.
      tags: [chat]
      parameters:
        - name: with
          in: query
          required: true
          description: The other participant's user ID.
          schema: { $ref: "#/components/schemas/UUID" }
      responses:
        "200":
          description: Conversation messages.
          content:
            application/json:
              schema:
                type: object
                properties:
                  whispers:
                    type: array
                    items: { $ref: "#/components/schemas/Whisper" }
                required: [whispers]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/whispers/conversations:
    get:
      operationId: getWhisperConversations
      summary: List whisper conversations (inbox)
      tags: [chat]
      responses:
        "200":
          description: Conversations (newest first) plus total unread count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations:
                    type: array
                    items: { $ref: "#/components/schemas/WhisperConversation" }
                  unread_count: { type: integer }
                required: [conversations, unread_count]
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/whispers/unread-count:
    get:
      operationId: getWhispersUnreadCount
      summary: Total unread whisper count
      tags: [chat]
      responses:
        "200":
          description: Unread whisper count across all conversations.
          content:
            application/json:
              schema:
                type: object
                properties:
                  unread_count: { type: integer }
                required: [unread_count]
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/whispers/settings:
    get:
      operationId: getWhisperSettings
      summary: Get the caller's DM privacy setting (SQ-446)
      tags: [chat]
      responses:
        "200":
          description: DM privacy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  privacy: { type: string, enum: [all, following] }
                required: [privacy]
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      operationId: updateWhisperSettings
      summary: Set the caller's DM privacy (SQ-446)
      description: >
        `following` restricts incoming DMs to users the caller follows (blocks
        strangers); `all` lets anyone message them.
      tags: [chat]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                privacy: { type: string, enum: [all, following] }
              required: [privacy]
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  privacy: { type: string, enum: [all, following] }
                required: [privacy]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/whispers/{conversation_user_id}/read:
    post:
      operationId: markWhisperConversationRead
      summary: Mark a whisper conversation as read
      tags: [chat]
      parameters:
        - name: conversation_user_id
          in: path
          required: true
          description: The other participant's user ID.
          schema: { $ref: "#/components/schemas/UUID" }
      responses:
        "204": { description: Conversation marked read. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ============================================================
  # Clips
  # ============================================================

  /v1/clips/trending:
    get:
      operationId: getTrendingClips
      summary: Trending clips
      tags: [clips]
      security: []
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Trending clips.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedClips" }

  /v1/clips/search:
    get:
      operationId: searchClips
      summary: Search clips
      tags: [clips]
      security: []
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Matching clips.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedClips" }

  /v1/clips/{clip_id}:
    get:
      operationId: getClip
      summary: Get a clip
      tags: [clips]
      security: []
      parameters:
        - $ref: "#/components/parameters/ClipIdPath"
      responses:
        "200":
          description: Clip.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Clip" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateClip
      summary: Update a clip (title, description, visibility)
      tags: [clips]
      parameters:
        - $ref: "#/components/parameters/ClipIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                description: { type: string }
                visibility: { $ref: "#/components/schemas/ClipVisibility" }
      responses:
        "200":
          description: Updated clip.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Clip" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    delete:
      operationId: deleteClip
      summary: Delete a clip
      tags: [clips]
      parameters:
        - $ref: "#/components/parameters/ClipIdPath"
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/clips:
    post:
      operationId: createClip
      summary: Create a clip from a stream's recent window
      description: |
        lookcam-api persists clip metadata and requests the actual VOD cut from
        Streamway's clip pipeline; the clip becomes `ready` when Streamway finishes.
      tags: [clips]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                stream_id: { $ref: "#/components/schemas/UUID" }
                title: { type: string }
                start_time: { type: number, format: float }
                end_time: { type: number, format: float }
              required: [stream_id, start_time, end_time]
      responses:
        "201":
          description: Clip created (status=processing).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Clip" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/clips/{clip_id}/like:
    post:
      operationId: toggleClipLike
      summary: Toggle a like on a clip
      tags: [clips]
      parameters:
        - $ref: "#/components/parameters/ClipIdPath"
      responses:
        "200":
          description: New like state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  liked: { type: boolean }
                  likes_count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/clips/{clip_id}/view:
    post:
      operationId: trackClipView
      summary: Track a clip view
      tags: [clips]
      security: []
      parameters:
        - $ref: "#/components/parameters/ClipIdPath"
      responses:
        "204": { description: Recorded. }

  # ============================================================
  # Raids & hosts
  # ============================================================

  /v1/raids/start:
    post:
      operationId: startRaid
      summary: Start a raid to another channel
      tags: [raids]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_user_id: { $ref: "#/components/schemas/UUID" }
                message: { type: string }
              required: [target_user_id]
      responses:
        "201":
          description: Raid started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Raid" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/raids/{raid_id}/cancel:
    post:
      operationId: cancelRaid
      summary: Cancel an in-progress raid
      tags: [raids]
      parameters:
        - name: raid_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Cancelled. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/raids/history/{user_id}:
    get:
      operationId: getRaidHistory
      summary: Raid history for a user
      tags: [raids]
      security: []
      parameters:
        - $ref: "#/components/parameters/UserIdPath"
      responses:
        "200":
          description: Raids.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Raid" }

  /v1/raids/active:
    get:
      operationId: getActiveRaid
      summary: Get the caller's outgoing active raid
      tags: [raids]
      responses:
        "200":
          description: The caller's pending/active outgoing raid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Raid" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No active outgoing raid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "not_found", message: "no active raid" }

  /v1/host/list:
    get:
      operationId: listAutoHosts
      summary: List the caller's auto-host targets
      tags: [raids]
      responses:
        "200":
          description: Auto-host targets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  auto_hosts:
                    type: array
                    items: { $ref: "#/components/schemas/HostUserSummary" }
                required: [auto_hosts]
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/host/add:
    post:
      operationId: addAutoHost
      summary: Add an auto-host target
      tags: [raids]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_username: { type: string }
                priority: { type: integer, default: 0, description: "Auto-host ordering priority." }
              required: [target_username]
      responses:
        "201":
          description: Added.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Auto-host added" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/host/remove/{username}:
    delete:
      operationId: removeAutoHost
      summary: Remove an auto-host target
      tags: [raids]
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Auto-host removed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/host/start:
    post:
      operationId: startHost
      summary: Start hosting another channel
      tags: [raids]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_username: { type: string }
              required: [target_username]
      responses:
        "201":
          description: Host session started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HostInfo" }
        "400":
          description: Invalid body, or already hosting another channel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "already_hosting", message: "already hosting another channel" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/host/stop:
    post:
      operationId: stopHost
      summary: Stop the current host session
      tags: [raids]
      responses:
        "200":
          description: Hosting stopped.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Hosting stopped" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Not hosting any channel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "not_found", message: "not hosting any channel" }

  # ============================================================
  # Notifications
  # ============================================================

  /v1/notifications:
    get:
      operationId: listNotifications
      summary: List notifications
      tags: [notifications]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated notifications.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedNotifications" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/notifications/unread-count:
    get:
      operationId: getUnreadCount
      summary: Unread notification count
      tags: [notifications]
      responses:
        "200":
          description: Unread count.
          content:
            application/json:
              schema:
                type: object
                properties:
                  unread: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/notifications/{id}/read:
    patch:
      operationId: markNotificationRead
      summary: Mark a notification as read
      tags: [notifications]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200": { description: Marked read. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/notifications/read-all:
    patch:
      operationId: markAllNotificationsRead
      summary: Mark all notifications as read
      tags: [notifications]
      responses:
        "200": { description: All marked read. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/notifications/subscribe:
    post:
      operationId: subscribeWebPush
      summary: Register a Web Push subscription
      tags: [notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                endpoint: { type: string, format: uri, description: "Web Push endpoint URL." }
                p256dh: { type: string, description: "Client public key (Web Push)." }
                auth: { type: string, description: "Auth secret (Web Push)." }
                user_agent: { type: string }
              required: [endpoint, p256dh, auth]
      responses:
        "201":
          description: Subscribed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: "Subscribed to push notifications" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/notifications/preferences:
    get:
      operationId: getNotificationPreferences
      summary: Get notification preferences
      tags: [notifications]
      responses:
        "200":
          description: Preferences.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationPreferences" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    put:
      operationId: updateNotificationPreferences
      summary: Update notification preferences
      tags: [notifications]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationPreferences" }
      responses:
        "200": { description: Updated. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ============================================================
  # Viewers & events (WebSocket)
  # ============================================================

  /v1/viewer/connect:
    get:
      operationId: viewerConnect
      summary: Open the realtime viewer-count WebSocket
      description: |
        WebSocket upgrade via plain GET — a successful handshake answers
        `101 Switching Protocols`. Query `room_id` (required) selects the stream.
        The connection itself counts as a viewer (heartbeat-based counting in
        Redis). Optionally identify the viewer for unified per-person presence
        (SQ-384) via `ticket` (short-lived single-use, cookie-mode web; consumed
        atomically), `token` (bearer JWT, native), or `device_id`; with a stable
        identity the same viewer counts once across tabs/devices.

        Message frames are JSON: the server pushes viewer-count updates and JSON
        `{"type":"ping"}` keep-alives; clients only need to answer with
        `{"type":"pong"}`. Counts originate from Streamway (Pub/Sub) and are
        relayed by lookcam-api; the front never reads Streamway directly.
      tags: [viewers]
      security: []
      parameters:
        - name: room_id
          in: query
          required: true
          description: Room/stream ID. (`roomID` is accepted as a legacy alias.)
          schema: { type: string }
        - name: ticket
          in: query
          description: |
            Optional short-lived single-use ticket from `POST /v1/auth/ws-ticket`
            (cookie-mode web). Resolves the viewer identity for unified presence.
            Takes precedence over `token`.
          schema: { type: string }
        - name: token
          in: query
          description: Optional JWT (bearer/native); used only when `ticket` is absent.
          schema: { type: string }
        - name: device_id
          in: query
          description: Optional stable device id used for presence when no ticket/token is supplied.
          schema: { type: string }
      responses:
        "101": { description: Switching Protocols (WebSocket). }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/viewer/status:
    get:
      operationId: getViewerCounts
      summary: Current viewer counts for one or more streams
      tags: [viewers]
      security: []
      parameters:
        - name: stream_ids
          in: query
          description: Comma-separated stream IDs.
          schema: { type: string }
      responses:
        "200":
          description: Viewer counts keyed by stream ID.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: { type: integer }

  /v1/viewer/heartbeat:
    post:
      operationId: viewerHeartbeat
      summary: Report viewer presence (HTTP fallback for players without a WS)
      description: |
        Records the caller as a live viewer of a stream in the unified presence
        model (a Redis sorted-set keyed by stable viewer identity). HTTP-only
        players (e.g. HLS, no viewer WebSocket) call this on a fixed cadence
        (~45s) to stay counted; clients with the viewer WebSocket open are
        counted automatically and do not need this endpoint.

        Viewer identity is deduplicated **per person/device**, not per
        connection: an authenticated request uses the caller's `user_id`, an
        anonymous request must supply a stable `device_id`. The same identity
        across multiple tabs/devices counts once within the sliding window.

        Idempotent — repeated calls within the window refresh the last-seen
        timestamp. Returns the current live viewer count for the stream.
      tags: [viewers]
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ViewerHeartbeatRequest" }
      responses:
        "200":
          description: Presence recorded; current live viewer count returned.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ViewerCount" }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/events/connect:
    get:
      operationId: eventsConnect
      summary: Open the stream-events WebSocket (raids, go-live, etc.)
      description: |
        WebSocket upgrade via plain GET — a successful handshake answers
        `101 Switching Protocols`. Query `room_id` (required) selects the stream.
        The connection is optionally authenticated by `ticket` (short-lived
        single-use, cookie-mode web; consumed atomically; takes precedence) or
        `token` (bearer JWT, native). Anonymous connections receive all room
        events; an authenticated connection is additionally subscribed to the
        user's personal whisper channel (`whisper` frames).

        Message frames are JSON: the server pushes stream events (raid, go-live,
        whisper, …) as `{"type": "...", "data": {...}}` plus JSON
        `{"type":"ping"}` keep-alives; clients only send `ping`/`pong` frames.
      tags: [viewers]
      security: []
      parameters:
        - name: room_id
          in: query
          required: true
          description: Room/stream ID. (`roomID` is accepted as a legacy alias.)
          schema: { type: string }
        - name: ticket
          in: query
          description: |
            Optional short-lived single-use ticket from `POST /v1/auth/ws-ticket`
            (cookie-mode web); enables personal whisper delivery. Precedence over `token`.
          schema: { type: string }
        - name: token
          in: query
          description: Optional JWT (bearer/native); used only when `ticket` is absent. Enables personal whisper delivery.
          schema: { type: string }
      responses:
        "101": { description: Switching Protocols (WebSocket). }
        "400": { $ref: "#/components/responses/BadRequest" }

  /v1/metrics/playback:
    post:
      operationId: submitPlaybackMetrics
      summary: Submit client-side playback metric events
      description: |
        Ingest endpoint for player metric events (session start/end, heartbeats,
        buffering, HLS/video errors). Events are deduplicated (per
        `room_id` + `event_data.clientId` + `event_type` within a short window)
        and sampled server-side before being published to Pub/Sub, so a `200`
        with `sampled: false` is still a success. This is the canonical
        replacement for the deprecated `/v1/analytics/playback`.
      tags: [streams]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                room_id:
                  type: string
                  description: Room/stream ID the playback event belongs to.
                event_type:
                  type: string
                  description: >
                    Player event name, e.g. `player_session_start`,
                    `player_session_end`, `player_heartbeat`,
                    `player_buffer_start`, `player_buffer_end`,
                    `player_hls_error`, `player_video_error`.
                event_data:
                  description: >
                    Free-form event payload. `clientId` (string) is used for
                    deduplication; `errorFatal` (boolean) controls sampling of
                    HLS errors.
                  type: object
                  additionalProperties: true
              required: [room_id, event_type]
      responses:
        "200":
          description: Event accepted (published, sampled out, or deduplicated).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sampled:
                    type: boolean
                    description: Whether the event was actually published.
                  reason:
                    type: string
                    description: Present when skipped, e.g. `duplicate`.
                required: [success, sampled]
        "400": { $ref: "#/components/responses/BadRequest" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/analytics/playback:
    post:
      operationId: trackPlayback
      summary: Report client-side playback metrics (deprecated)
      deprecated: true
      description: |
        DEPRECATED — use `POST /v1/metrics/playback` instead, which is the
        endpoint actually served by lookcam-api for player metric events.
        Bitrate/FPS/latency/buffering samples from the player, for analytics.
      tags: [streams]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                stream_id: { $ref: "#/components/schemas/UUID" }
                event: { type: string, enum: [start, stall, quality_change, error, heartbeat] }
                bitrate_kbps: { type: integer }
                dropped_frames: { type: integer }
                latency_ms: { type: integer }
              required: [stream_id, event]
      responses:
        "204": { description: Recorded. }

  /v1/analytics/stream/{stream_id}:
    get:
      operationId: getStreamAnalytics
      summary: Creator analytics for a stream (dashboard)
      tags: [streams]
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "200":
          description: Analytics summary and timeseries.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ============================================================
  # SEO (server-rendered)
  # ============================================================

  /v1/seo/sitemap.xml:
    get:
      operationId: getSitemapIndex
      summary: Sitemap index
      tags: [seo]
      security: []
      responses:
        "200":
          description: Sitemap index XML.
          content:
            application/xml: {}

  /v1/seo/structured-data/stream/{username}:
    get:
      operationId: getStreamStructuredData
      summary: JSON-LD structured data for a channel/stream
      tags: [seo]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: JSON-LD (schema.org VideoObject / BroadcastEvent).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/seo/structured-data/clip/{id}:
    get:
      operationId: getClipStructuredData
      summary: JSON-LD structured data for a clip
      tags: [seo]
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: JSON-LD (schema.org VideoObject).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

  # ============================================================
  # Videos (VOD / past broadcasts)
  # ============================================================

  /v1/channels/{username}/videos:
    get:
      operationId: getChannelVideos
      summary: List a channel's videos (VODs / past broadcasts)
      description: Only `public` videos are returned to non-owners.
      tags: [videos]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated videos.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedVideos" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/videos/{video_id}:
    get:
      operationId: getVideo
      summary: Get a video
      description: |
        Visibility is enforced server-side — `unlisted` is reachable by direct
        link, `private` only by the owner (authenticate to access own private VODs).
      tags: [videos]
      security: []
      parameters:
        - $ref: "#/components/parameters/VideoIdPath"
      responses:
        "200":
          description: Video.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Video" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateVideo
      summary: Update own video (title, visibility, mature flag)
      tags: [videos]
      parameters:
        - $ref: "#/components/parameters/VideoIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                visibility: { $ref: "#/components/schemas/VideoVisibility" }
                is_mature: { type: boolean }
      responses:
        "200":
          description: Updated video.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Video" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteVideo
      summary: Delete own video
      description: Removes the VOD and asks Streamway to delete the recording.
      tags: [videos]
      parameters:
        - $ref: "#/components/parameters/VideoIdPath"
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/videos/{video_id}/playback:
    get:
      operationId: getVideoPlayback
      summary: Resolve a signed VOD playback source (delegated to Streamway)
      description: |
        Returns a short-lived signed VOD manifest URL. lookcam-api requests a
        secure link from Streamway and returns only the product-shaped
        descriptor — the front NEVER calls Streamway directly. Visibility is
        enforced server-side (`private` requires owner authentication).
      tags: [videos]
      security: []
      parameters:
        - $ref: "#/components/parameters/VideoIdPath"
      responses:
        "200":
          description: Signed VOD playback descriptor.
          content:
            application/json:
              schema:
                type: object
                properties:
                  manifest_url:
                    type: string
                    format: uri
                    description: Signed HLS (.m3u8) URL on the Streamway edge.
                  expires_at: { $ref: "#/components/schemas/Timestamp" }
                required: [manifest_url, expires_at]
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ============================================================
  # Monetization — subscriptions & tips
  # (payments processed by the central platform-api / Stripe;
  #  lookcam-api only initiates checkout and stores entitlements)
  # ============================================================

  /v1/channels/{username}/subscription:
    get:
      operationId: getChannelSubscription
      summary: Get the caller's subscription to a channel
      tags: [monetization]
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Active (or past-due/cancelled) subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelSubscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: The caller has no subscription to this channel.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "not_found", message: "no subscription" }
    delete:
      operationId: cancelChannelSubscription
      summary: Cancel the caller's subscription (at period end)
      description: |
        Marks the subscription to cancel at the end of the current billing
        period. The actual Stripe cancellation is executed by platform-api;
        the final state lands via /v1/webhooks/billing.
      tags: [monetization]
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Subscription marked as cancelling at period end.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelSubscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/channels/{username}/subscribe:
    post:
      operationId: subscribeToChannel
      summary: Initiate a channel subscription checkout
      description: |
        Initiates checkout only — the payment is processed by the central
        platform-api (Stripe). The subscription becomes `active` once
        platform-api confirms payment via /v1/webhooks/billing; until then the
        caller has no entitlement.
      tags: [monetization]
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tier:
                  type: integer
                  enum: [1, 2, 3]
              required: [tier]
      responses:
        "200":
          description: Checkout initiated; completion is asynchronous (platform-api webhook).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/me/subscriptions:
    get:
      operationId: listMySubscriptions
      summary: List the caller's channel subscriptions
      tags: [monetization]
      responses:
        "200":
          description: Subscriptions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ChannelSubscription" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/channel/subscribers:
    get:
      operationId: listChannelSubscribers
      summary: List the caller's channel subscribers (creator dashboard)
      tags: [monetization]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated subscribers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedChannelSubscriptions" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/channels/{username}/tips:
    post:
      operationId: createTip
      summary: Initiate a tip checkout
      description: |
        Initiates checkout only — the payment is processed by the central
        platform-api (Stripe); the tip becomes `completed` via
        /v1/webhooks/billing. Authentication is optional: anonymous tips are
        allowed, authenticated tips are attributed to the caller.
      tags: [monetization]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount_cents:
                  type: integer
                  format: int64
                  minimum: 100
                currency: { type: string, default: "usd" }
                message: { type: string, maxLength: 280 }
              required: [amount_cents]
      responses:
        "200":
          description: Checkout initiated; completion is asynchronous (platform-api webhook).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/me/channel/tips:
    get:
      operationId: listChannelTips
      summary: List tips received by the caller's channel (creator dashboard)
      tags: [monetization]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated tips.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedTips" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/channel/analytics:
    get:
      operationId: getCreatorAnalytics
      summary: Get aggregated creator-dashboard analytics for the caller's channel
      description: |
        Returns revenue, subscriptions, points, follows, videos and viewership
        aggregates for the authenticated channel over a rolling period. Money is in
        integer cents. Viewership is sourced from ClickHouse and degrades gracefully
        (`viewership.source` = "unavailable", zeros) when the analytics store is
        offline; the rest is aggregated from PostgreSQL. The assembled result is
        cached server-side for 60s per (user, period).
      tags: [analytics]
      parameters:
        - name: period
          in: query
          description: Rolling window to aggregate over.
          schema:
            type: string
            enum: [7d, 30d, 90d]
            default: 30d
      responses:
        "200":
          description: Creator analytics for the requested period.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreatorAnalytics" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ============================================================
  # Channel points / loyalty
  # ============================================================

  /v1/channels/{username}/points:
    get:
      operationId: getChannelPoints
      summary: Get the caller's points balance in a channel
      tags: [points]
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Points balance.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PointsBalance" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/channels/{username}/rewards:
    get:
      operationId: listChannelRewards
      summary: List a channel's enabled rewards
      tags: [points]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Rewards.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ChannelReward" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/me/channel/rewards:
    get:
      operationId: listMyChannelRewards
      summary: List the caller's own channel rewards (creator, includes disabled)
      description: >
        Returns the caller's channel rewards INCLUDING disabled ones (creator
        management). The public /v1/channels/{username}/rewards returns only
        enabled rewards.
      tags: [points]
      responses:
        "200":
          description: Rewards (including disabled).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ChannelReward" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createChannelReward
      summary: Create a channel-points reward (creator)
      tags: [points]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                description: { type: string }
                cost: { type: integer, format: int64, minimum: 1 }
                is_enabled: { type: boolean, default: true }
                requires_input: { type: boolean, default: false }
                cooldown_seconds: { type: integer }
              required: [title, cost]
      responses:
        "201":
          description: Reward created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelReward" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/channel/rewards/{reward_id}:
    patch:
      operationId: updateChannelReward
      summary: Update a channel-points reward (creator)
      tags: [points]
      parameters:
        - $ref: "#/components/parameters/RewardIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                description: { type: string }
                cost: { type: integer, format: int64, minimum: 1 }
                is_enabled: { type: boolean }
                requires_input: { type: boolean }
                cooldown_seconds: { type: integer, nullable: true }
      responses:
        "200":
          description: Updated reward.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChannelReward" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteChannelReward
      summary: Delete a channel-points reward (creator)
      tags: [points]
      parameters:
        - $ref: "#/components/parameters/RewardIdPath"
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/rewards/{reward_id}/redeem:
    post:
      operationId: redeemReward
      summary: Redeem a channel-points reward
      description: Deducts the reward cost from the caller's balance in that channel.
      tags: [points]
      parameters:
        - $ref: "#/components/parameters/RewardIdPath"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                user_input:
                  type: string
                  description: Required when the reward has `requires_input`.
      responses:
        "201":
          description: Redemption created (status=pending).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Redemption" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/me/channel/redemptions:
    get:
      operationId: listChannelRedemptions
      summary: List redemptions in the caller's channel (creator queue)
      tags: [points]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/RedemptionStatus" }
      responses:
        "200":
          description: Paginated redemptions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedRedemptions" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/channel/redemptions/{redemption_id}:
    patch:
      operationId: updateRedemption
      summary: Fulfil or refund a redemption (creator)
      description: Refunding returns the points to the redeemer's balance.
      tags: [points]
      parameters:
        - $ref: "#/components/parameters/RedemptionIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [fulfilled, refunded]
              required: [status]
      responses:
        "200":
          description: Updated redemption.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Redemption" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ============================================================
  # Schedule
  # ============================================================

  /v1/channels/{username}/schedule:
    get:
      operationId: getChannelSchedule
      summary: Get a channel's broadcast schedule
      tags: [schedule]
      security: []
      parameters:
        - $ref: "#/components/parameters/UsernamePath"
      responses:
        "200":
          description: Schedule entries (upcoming + recurring).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/ScheduleEntry" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/me/schedule:
    post:
      operationId: createScheduleEntry
      summary: Add an entry to own schedule
      tags: [schedule]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                category_id: { type: string }
                starts_at: { $ref: "#/components/schemas/Timestamp" }
                ends_at: { $ref: "#/components/schemas/Timestamp" }
                is_recurring: { type: boolean, default: false }
                recurrence_weekday:
                  type: integer
                  minimum: 0
                  maximum: 6
              required: [title, starts_at]
      responses:
        "201":
          description: Entry created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScheduleEntry" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/schedule/{entry_id}:
    patch:
      operationId: updateScheduleEntry
      summary: Update a schedule entry
      tags: [schedule]
      parameters:
        - $ref: "#/components/parameters/EntryIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                category_id: { type: string, nullable: true }
                starts_at: { $ref: "#/components/schemas/Timestamp" }
                ends_at: { $ref: "#/components/schemas/Timestamp" }
                is_recurring: { type: boolean }
                recurrence_weekday:
                  type: integer
                  minimum: 0
                  maximum: 6
                  nullable: true
      responses:
        "200":
          description: Updated entry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScheduleEntry" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteScheduleEntry
      summary: Delete a schedule entry
      tags: [schedule]
      parameters:
        - $ref: "#/components/parameters/EntryIdPath"
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ============================================================
  # Predictions (channel-points betting)
  # ============================================================

  /v1/streams/{stream_id}/predictions/active:
    get:
      operationId: getActivePrediction
      summary: Get the stream's active (or locked) prediction
      tags: [predictions]
      security: []
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      responses:
        "200":
          description: Active prediction.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Prediction" }
        "404":
          description: No active prediction on this stream.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { code: "not_found", message: "no active prediction" }

  /v1/streams/{stream_id}/predictions:
    post:
      operationId: createPrediction
      summary: Start a prediction (owner/moderator)
      tags: [predictions]
      parameters:
        - $ref: "#/components/parameters/StreamIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                outcomes:
                  type: array
                  minItems: 2
                  maxItems: 10
                  items:
                    type: object
                    properties:
                      title: { type: string }
                      color: { type: string }
                    required: [title]
                locks_in_seconds:
                  type: integer
                  description: Auto-lock betting after this many seconds.
              required: [title, outcomes]
      responses:
        "201":
          description: Prediction started.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Prediction" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/predictions/{prediction_id}/bets:
    post:
      operationId: placePredictionBet
      summary: Bet channel points on an outcome
      tags: [predictions]
      parameters:
        - $ref: "#/components/parameters/PredictionIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                outcome_id: { $ref: "#/components/schemas/UUID" }
                points:
                  type: integer
                  format: int64
                  minimum: 1
              required: [outcome_id, points]
      responses:
        "200":
          description: Bet placed; updated pools and remaining balance.
          content:
            application/json:
              schema:
                type: object
                properties:
                  prediction: { $ref: "#/components/schemas/Prediction" }
                  balance: { $ref: "#/components/schemas/PointsBalance" }
                required: [prediction, balance]
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/predictions/{prediction_id}/lock:
    post:
      operationId: lockPrediction
      summary: Lock betting on a prediction (owner/moderator)
      tags: [predictions]
      parameters:
        - $ref: "#/components/parameters/PredictionIdPath"
      responses:
        "200":
          description: Prediction locked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Prediction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/predictions/{prediction_id}/resolve:
    post:
      operationId: resolvePrediction
      summary: Resolve a prediction and pay out (owner/moderator)
      tags: [predictions]
      parameters:
        - $ref: "#/components/parameters/PredictionIdPath"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                outcome_id: { $ref: "#/components/schemas/UUID" }
              required: [outcome_id]
      responses:
        "200":
          description: Prediction resolved; winning bets paid out.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Prediction" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/predictions/{prediction_id}/cancel:
    post:
      operationId: cancelPrediction
      summary: Cancel a prediction and refund all bets (owner/moderator)
      tags: [predictions]
      parameters:
        - $ref: "#/components/parameters/PredictionIdPath"
      responses:
        "200":
          description: Prediction cancelled; all bets refunded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Prediction" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/users/me/channel/predictions:
    get:
      operationId: listMyChannelPredictions
      summary: List the caller's channel predictions history (creator)
      description: >
        Paginated history of the caller's channel predictions, newest first,
        including resolved/cancelled predictions with their outcomes; the result
        is carried by `status` and `resolved_outcome_id`. Returns an empty page
        when the caller has no channel stream yet.
      tags: [predictions]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
      responses:
        "200":
          description: Paginated predictions (history).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedPredictions" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ============================================================
  # Safety — moderation audit, reports, 2FA
  # ============================================================

  /v1/users/me/channel/moderation/audit-log:
    get:
      operationId: getModerationAuditLog
      summary: Moderation audit log for the caller's channel (creator/moderator)
      tags: [safety]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/PerPageParam"
        - name: action
          in: query
          schema: { $ref: "#/components/schemas/ModerationActionType" }
        - name: actor_id
          in: query
          schema: { $ref: "#/components/schemas/UUID" }
      responses:
        "200":
          description: Paginated moderation actions.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedModerationActions" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /v1/reports:
    post:
      operationId: createReport
      summary: Report a user, stream, clip or chat message
      description: |
        Authentication is optional — anonymous reports are accepted;
        authenticated reports are attributed to the caller.
      tags: [safety]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                target_type: { $ref: "#/components/schemas/ReportTargetType" }
                target_id: { type: string }
                reason_code: { $ref: "#/components/schemas/ReportReasonCode" }
                details: { type: string }
              required: [target_type, target_id, reason_code]
      responses:
        "201":
          description: Report submitted (status=open).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Report" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /v1/users/me/2fa/totp/setup:
    post:
      operationId: setupTotp
      summary: Begin TOTP 2FA enrolment
      description: |
        Generates a TOTP secret and otpauth URL. 2FA is NOT active until a code
        is confirmed via /v1/users/me/2fa/totp/verify.
      tags: [safety]
      responses:
        "200":
          description: Enrolment material (shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TwoFactorSetup" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/2fa/totp/verify:
    post:
      operationId: verifyTotp
      summary: Confirm a TOTP code and enable 2FA
      tags: [safety]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  pattern: "^[0-9]{6}$"
              required: [code]
      responses:
        "204": { description: 2FA enabled. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/users/me/2fa:
    delete:
      operationId: disableTwoFactor
      summary: Disable 2FA (requires a current TOTP code)
      tags: [safety]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  pattern: "^[0-9]{6}$"
              required: [code]
      responses:
        "204": { description: 2FA disabled. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/2fa:
    post:
      operationId: completeTwoFactorLogin
      summary: Complete a 2FA login challenge
      description: |
        Exchanges the short-lived `two_factor_token` (returned by /v1/auth/login
        or /v1/auth/social-login when `requires_2fa` is true) plus a TOTP code
        for the final session.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                two_factor_token: { type: string }
                code:
                  type: string
                  pattern: "^[0-9]{6}$"
              required: [two_factor_token, code]
      responses:
        "200":
          description: Login completed; cookies set.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  # ============================================================
  # System (server-to-server)
  # ============================================================

  /v1/webhooks/streamway:
    post:
      operationId: streamwayWebhook
      summary: Inbound Streamway webhook (stream lifecycle, recordings, health)
      description: |
        Receives Streamway events (stream.started, stream.ended, recording.ready,
        clip.ready, stream.health_issue, ...). Verified by HMAC-SHA256 signature.
        lookcam-api updates `is_live`, thumbnail and clip status, and fans out to the
        chat/viewer/events WebSockets.
      tags: [system]
      security:
        - systemAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, example: "stream.started" }
                timestamp: { $ref: "#/components/schemas/Timestamp" }
                data:
                  type: object
                  additionalProperties: true
              required: [event, data]
      responses:
        "200": { description: Processed. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/webhooks/billing:
    post:
      operationId: billingWebhook
      summary: Inbound platform-api billing webhook (subscriptions, tips)
      description: |
        Server-to-server only. The central platform-api (which processes all
        payments via Stripe) notifies lookcam-api of billing outcomes:
        `subscription.activated`, `subscription.renewed`, `subscription.cancelled`,
        `tip.completed`. lookcam-api updates the stored entitlements
        (`ChannelSubscription`/`Tip` status) accordingly. Verified by the
        X-System-Key header plus an HMAC-SHA256 signature, like the Streamway
        webhook.
      tags: [system]
      security:
        - systemAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                event_type:
                  type: string
                  enum:
                    [
                      subscription.activated,
                      subscription.renewed,
                      subscription.cancelled,
                      tip.completed,
                    ]
                  example: "subscription.activated"
                data:
                  type: object
                  additionalProperties: true
              required: [event_type, data]
      responses:
        "204": { description: Processed. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/edge/configuration:
    get:
      operationId: getEdgeConfiguration
      summary: Edge configuration for media nodes
      description: |
        Server-to-server. Edge nodes identify themselves with their registered
        `hash` (IP hash) and receive their assigned hostname.
      tags: [system]
      security:
        - systemAuth: []
      parameters:
        - name: hash
          in: query
          required: true
          description: The requesting edge node's registered IP hash.
          schema: { type: string }
      responses:
        "200":
          description: Edge configuration.
          content:
            application/json:
              schema:
                type: object
                properties:
                  hostname: { type: string, example: "edge-waw-1.lookcam.com" }
                required: [hostname]
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
