# Kiroku API — OpenAPI 3.1 description of the /v1 surface.
#
# SOURCE OF TRUTH. This file is the authoritative machine-readable contract.
# It is served on the developer site at /docs/openapi.yaml: the firebase.json
# hosting predeploy hook copies it to hosting/docs/openapi.yaml (a generated,
# git-ignored artifact) right before every Hosting deploy. Edit ONLY this file;
# never hand-edit the copy under hosting/.
#
# Where this file and the route code disagree, the code and its __tests__ win —
# see src/routes/MIGRATION.md (the contract document) and docs/api/reference.md.
openapi: 3.1.0

info:
  title: Kiroku API
  version: v1
  summary: First-party backend API for the Kiroku app.
  description: |
    The HTTPS API backing the [Kiroku](https://kiroku.cz) mobile/web app — a
    single Firebase Cloud Functions v2 function running an Express app under
    `/v1/*`.

    **First-party only.** There is no third-party API-key program; the only
    intended callers are the Kiroku client (authenticating with Firebase ID
    tokens), provider webhooks (shared secret), and links in Kiroku-sent
    emails (signed tokens). The contract documented here can change with the
    app that consumes it.

    ## The response envelope

    Every *business* endpoint (everything except health/demo, `/v1/admin/*`,
    `/v1/pusher/auth`, `/v1/email/unsubscribe`, and `/v1/webhooks/*`) returns
    the same envelope, modeled by the `OnyxResponse` schema: `jsonCode` plus an
    `onyxData` array of Onyx updates that the client merges directly into its
    local store. Success is HTTP 200 with `jsonCode: 200` (the `onyxData` array
    may legitimately be empty — a "no-op success"); validation and other
    failures mirror the HTTP status into `jsonCode` and carry a `message`.

    ## Token expiry: `jsonCode 407` inside HTTP 200

    When the Firebase ID token is **expired** (as opposed to missing, malformed
    or revoked), authenticated endpoints respond **HTTP 200** with the body
    `{"jsonCode": 407, "onyxData": []}`. This is deliberate: the client treats
    any non-2xx as a network error and would never inspect the body, so the
    expiry signal must ride a 200. On `jsonCode 407` the client refreshes its
    Firebase ID token and replays the request. There is **no actual HTTP 407**
    anywhere on this API. Missing/malformed/revoked tokens get a plain HTTP 401
    (a refresh would not help). This applies to every endpoint secured with
    `firebaseIdToken`, including `/v1/admin/*` and `/v1/pusher/auth`.

    ## Error model

    - `401` — no/invalid Firebase ID token (plain-text body).
    - `403` — valid token but missing the `admin: true` claim (admin subtree),
      a forbidden Pusher channel, or a blocked friend-request target.
    - `400` — failed input validation. Business routes return the envelope
      `{jsonCode: 400, onyxData: [], message}`; admin routes return
      `{error, code?}`.
    - `429` — per-user rate limit on the `/v1/users/*` read surface
      (approximate, per-instance; 100 requests / 60 s / uid).
    - `500` — anything thrown; a generic envelope (or `{error}` on admin
      routes) so internals never leak.

    ## Realtime

    Cross-user mutations return the **caller's** half of the change inline in
    `onyxData`; the counterpart's half is pushed over a private Pusher channel
    (`private-user-<uid>`, event `onyxApiUpdate`) authorized via
    `POST /v1/pusher/auth`. Responses on these paths are stamped with monotonic
    per-user `lastUpdateID`/`previousUpdateID`; gaps are backfilled with
    `GET /v1/updates`. Write requests may carry `pusherSocketID` so the sender's
    own socket is excluded from the broadcast (no echo of its optimistic write).
  contact:
    name: Kiroku
    url: https://kiroku.cz/en/support

servers:
  - url: https://api.kiroku.cz
    description: Production
  - url: https://api-dev.kiroku.cz
    description: Development

security:
  - firebaseIdToken: []

tags:
  - name: Health & demo
    description: Liveness and demonstration endpoints (not part of the app surface).
  - name: App
    description: App bootstrap (`OpenApp`) and the public pre-auth version gate.
  - name: Updates
    description: Reliable-updates backfill — replay missed Onyx updates from the per-user log.
  - name: Pusher
    description: Private-channel authorization for realtime pushes.
  - name: Friends
    description: Cross-user friendship mutations (request/accept/remove/block).
  - name: Users
    description: |
      Cross-user reads. Public tier (any authenticated user): profile, friends
      list, nickname lookup, search. Private tier (friends + visibility gated):
      sessions, preferences, status. A denied private read returns HTTP 200
      with an **eviction patch** (the relevant key merged to `null`) rather
      than a 403, and the deny payload is identical regardless of reason so it
      never reveals *why* access was denied. The whole subtree shares a
      per-user rate limit (100 requests / 60 s, approximate per instance).
  - name: Profile
    description: Profile mutations for the signed-in user (display name, legal name, photo).
  - name: Images
    description: Presigned direct-to-GCS image upload + server-side finalize (validation, moderation).
  - name: Preferences
    description: Validated partial update of the caller's preferences.
  - name: Onboarding
    description: Terms acceptance and onboarding progress.
  - name: Sessions
    description: Drinking-session upsert/delete — the offline-critical write path.
  - name: Session locations
    description: Per-session GPS capture, clear, and purge.
  - name: Status
    description: Presence / latest-session sync.
  - name: Privacy
    description: Data-visibility switches (hide from all friends, per-friend hide).
  - name: Feedback
    description: Feedback and bug reports (submit for any user; list/remove are admin-only).
  - name: Reports
    description: User-generated-content moderation reports (`ReportUser`).
  - name: Provisioning
    description: One-time creation of a new user's records right after Firebase Auth signup.
  - name: Account
    description: Account closure — high-authority cross-user cleanup.
  - name: Admin
    description: |
      Administrative operations. Every endpoint requires a Firebase ID token
      **and** the `admin: true` custom claim on that token (missing claim →
      `403`). Admin routes do **not** use the Onyx envelope: they return plain
      JSON, and errors use `{error, code?}` where `code` is a stable
      identifier (`USER_NOT_FOUND`, `INVALID_EMAIL`,
      `INVALID_MAINTENANCE_WINDOW`, `MAINTENANCE_CONFIG_NOT_FOUND`).
  - name: Email
    description: Public one-click email unsubscribe (HMAC-signed links).
  - name: Webhooks
    description: Inbound provider webhooks, authenticated by a per-provider shared secret.
  - name: Jobs
    description: |
      Internal maintenance jobs, authenticated by a shared secret in the
      `Authorization` header (Cloud Scheduler / kiroku-cli present a Google
      OIDC token, not a Firebase token, so these routes are **not** gated by
      `requireAdmin`). Not called by the Kiroku client.

paths:
  # ── Health & demo ────────────────────────────────────────────────────────
  /v1/healthz:
    get:
      tags: [Health & demo]
      summary: Liveness + build provenance
      description: |
        Returns `ok` plus the commit/environment/time baked into the running
        bundle at build time. Used to tell environments apart and verify
        rollbacks ("what's deployed right now?"). Only the versioned path is
        exposed — Cloud Run's frontend intercepts a bare `/healthz`.
      operationId: getHealthz
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                required: [ok, version, build]
                properties:
                  ok:
                    type: boolean
                    const: true
                  version:
                    type: string
                    description: API version prefix (normally `v1`).
                  build:
                    type: object
                    properties:
                      commit:
                        type: string
                        description: Git commit of the deployed bundle (`dev` for unbaked local builds).
                      env:
                        type: string
                        description: Build environment label (`local` for unbaked local builds).
                      time:
                        type: ["string", "null"]
                        description: Build timestamp, or `null` for local builds.
              example:
                ok: true
                version: v1
                build: { commit: "3631321", env: "dev", time: "2026-06-08T10:00:00Z" }

  /v1/public:
    get:
      tags: [Health & demo]
      summary: Public demo endpoint
      description: >
        Demonstration placeholder — returns a plain greeting string. Not part
        of the app surface; may be removed without notice.
      operationId: getPublicDemo
      security: []
      responses:
        "200":
          description: Greeting.
          content:
            text/html:
              schema:
                type: string

  /v1/protected:
    get:
      tags: [Health & demo]
      summary: Authenticated demo endpoint
      description: >
        Demonstration placeholder — returns a greeting containing the caller's
        `uid`. Not part of the app surface; may be removed without notice.
      operationId: getProtectedDemo
      responses:
        "200":
          description: Greeting with the caller's uid.
          content:
            text/html:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ── App ──────────────────────────────────────────────────────────────────
  /v1/app/min-version:
    get:
      tags: [App]
      summary: Minimum app version allowed to create an account (public)
      description: |
        **Unauthenticated** read of the single version-gate value
        `config/app_settings/min_user_creation_possible_version`. The client
        calls this during sign-up, *before* a Firebase Auth account (and
        therefore any ID token) exists. Nothing else from the app config is
        exposed on this unauthenticated surface. An absent gate returns
        `null`, which the client treats as "no minimum".
      operationId: getAppMinVersion
      security: []
      responses:
        "200":
          description: The gating version value (or `null` when unset).
          content:
            application/json:
              schema:
                type: object
                required: [min_user_creation_possible_version]
                properties:
                  min_user_creation_possible_version:
                    type: ["string", "null"]
              examples:
                set:
                  value: { min_user_creation_possible_version: "0.4.0" }
                unset:
                  value: { min_user_creation_possible_version: null }
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/app/open:
    get:
      tags: [App]
      summary: OpenApp bootstrap
      description: |
        The `OpenApp` aggregation read: everything the signed-in user needs to
        boot, in one envelope. Reads **caller data only** (authority is the
        verified token's uid). Emits, as applicable: `merge session
        {userID, email}` (a merge, never a `set`, so the client's locally-held
        token fields survive), `merge userDataList[uid]` (profile, onboarding,
        terms, `earliest_session_at`), `set private_userData`,
        `set nvp_termsAcceptedVersion`, `merge nvp_onboarding`, a per-uid clean
        replace of `cachedDrinkingSessions[uid]` (merge-null then merge),
        `set dataVisibility`, `set preferences`, `set nvp_preferredLocale`,
        `set preferredTheme`, and `set config` (the global app config).

        The response is stamped with the caller's current `lastUpdateID` — the
        baseline the reliable-updates layer arms gap detection against. No
        `previousUpdateID` is sent (this is a full load, not an incremental).
      operationId: openApp
      responses:
        "200":
          description: Aggregated bootstrap envelope (or `jsonCode 407` on an expired token — see the schema).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Updates ──────────────────────────────────────────────────────────────
  /v1/updates:
    get:
      tags: [Updates]
      summary: Backfill missed Onyx updates (GetMissingOnyxUpdates)
      description: |
        Replays the caller's durable update log for ids in `(from, to]`,
        ascending, as one concatenated `onyxData`. Stamped `lastUpdateID: to`
        so the client advances its applied pointer; `previousUpdateID` is
        omitted so the response never re-triggers gap detection. Missing ids in
        the range are skipped (self-healing).

        **Re-baseline signal:** the log retains only the most recent 500
        entries, so a requested span wider than that cannot be served. In that
        case the response is HTTP 200 with empty `onyxData` and **no
        `lastUpdateID`** — the client's pointer does not advance, the gap stays
        open, and the client falls back to a full `GET /v1/app/open`
        re-baseline. This is deliberately the opposite of the empty-but-in-range
        case, which does stamp `lastUpdateID: to`.

        Internal client-sync mechanics: the parameters and re-baseline behavior
        are coupled to the Kiroku client's reliable-updates layer and may
        change with it.
      operationId: getUpdates
      parameters:
        - name: from
          in: query
          description: The caller's last applied update id (exclusive lower bound).
          schema:
            type: integer
        - name: to
          in: query
          description: The target update id (inclusive upper bound), typically a received `previousUpdateID`.
          schema:
            type: integer
      responses:
        "200":
          description: Replayed updates, an empty in-range advance, or the re-baseline signal.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Pusher ───────────────────────────────────────────────────────────────
  /v1/pusher/auth:
    post:
      tags: [Pusher]
      summary: Authorize the caller's private realtime channel
      description: |
        Pusher private-channel authorizer. The Pusher client posts a
        form-encoded `socket_id` + `channel_name` before subscribing to the
        caller's own `private-user-<uid>` channel. A caller may only authorize
        **their own** channel — any other user's channel is rejected with
        `403`. Does **not** return the Onyx envelope on success: the body is
        Pusher's auth signature object.
      operationId: authorizePusherChannel
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [socket_id, channel_name]
              properties:
                socket_id:
                  type: string
                  description: The connecting socket's id, as supplied by the Pusher client library.
                channel_name:
                  type: string
                  description: Must be exactly `private-user-<caller uid>`.
      responses:
        "200":
          description: Pusher channel-auth signature.
          content:
            application/json:
              schema:
                type: object
                required: [auth]
                properties:
                  auth:
                    type: string
                    description: The Pusher auth token (`<key>:<signature>`).
                additionalProperties: true
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: The requested channel does not belong to the caller.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxFailure"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Friends ──────────────────────────────────────────────────────────────
  /v1/friends/request:
    post:
      tags: [Friends]
      summary: Send a friend request
      description: |
        Writes `sent`/`received` markers into both users' `friend_requests`.
        The caller gets `merge userDataList {[uid]: {friend_requests:
        {[toUserId]: "sent"}}}` inline; the target is pushed their mirror over
        realtime. Rejected with a neutral `403` when **either** side blocks the
        other — the response is identical in both directions so a blocked user
        cannot detect the block.
      operationId: sendFriendRequest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [toUserId]
              properties:
                toUserId:
                  type: string
                  description: Target user's uid. Must not be the caller.
                pusherSocketID:
                  $ref: "#/components/schemas/PusherSocketID"
      responses:
        "200":
          description: Caller's half of the mutation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: A block exists between the two users (reason never disclosed).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxFailure"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/friends/accept:
    post:
      tags: [Friends]
      summary: Accept a friend request
      description: |
        Clears both pending request markers and writes `friends: true` both
        directions in one atomic update. Caller gets
        `merge userDataList {[uid]: {friend_requests: {[fromUserId]: null},
        friends: {[fromUserId]: true}}}`; the counterpart is pushed their
        mirror over realtime.
      operationId: acceptFriendRequest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fromUserId]
              properties:
                fromUserId:
                  type: string
                  description: Uid of the user whose request is being accepted.
                pusherSocketID:
                  $ref: "#/components/schemas/PusherSocketID"
      responses:
        "200":
          description: Caller's half of the mutation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/friends/remove:
    post:
      tags: [Friends]
      summary: Remove a friend
      description: >
        Nulls the friendship in both users' `friends` maps. Caller gets
        `merge userDataList {[uid]: {friends: {[otherUserId]: null}}}`; the
        counterpart is pushed their mirror over realtime.
      operationId: removeFriend
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtherUserBody"
      responses:
        "200":
          description: Caller's half of the mutation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/friends/delete-request:
    post:
      tags: [Friends]
      summary: Withdraw or decline a pending friend request
      description: >
        Nulls the pending request marker in both users' `friend_requests`
        maps. Caller gets `merge userDataList {[uid]: {friend_requests:
        {[otherUserId]: null}}}`; the counterpart is pushed their mirror.
      operationId: deleteFriendRequest
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtherUserBody"
      responses:
        "200":
          description: Caller's half of the mutation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/friends/block:
    post:
      tags: [Friends]
      summary: Block a user
      description: |
        `BlockUser` — one atomic update that (a) records the block under the
        caller's private `blocked` map, (b) tears down the friendship in both
        directions, (c) clears pending friend requests in both directions, and
        (d) clears the caller's per-friend hide for the target (block
        supersedes hide). The caller's envelope mirrors (a)–(d); the target is
        pushed **only** the friendship/request teardown — indistinguishable
        from a plain unfriend, so they cannot tell they were blocked.
      operationId: blockUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtherUserBody"
      responses:
        "200":
          description: Caller's half of the mutation (block flag + teardown + hide clear).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/friends/unblock:
    post:
      tags: [Friends]
      summary: Unblock a user
      description: >
        Removes the caller's block entry only. Does **not** re-friend (a new
        friend request is required). Nothing is pushed to the target — they
        were never told about the block.
      operationId: unblockUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtherUserBody"
      responses:
        "200":
          description: Caller's block-map update.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Users ────────────────────────────────────────────────────────────────
  /v1/users/search:
    get:
      tags: [Users]
      summary: Prefix search for users by display name
      description: |
        Server-side port of the app's nickname search: the query is tokenized
        into cleaned name word-keys (tokens shorter than 2 characters are
        dropped), one prefix-range query runs off the longest token (capped at
        50 index buckets), and multi-word queries are AND-refined so every
        token must appear in the name. Banned users are removed from results.

        A pure read: `onyxData` is always empty and the matches ride in the
        `searchResults` sidecar (`{uid: displayName}`), which the client reads
        straight off the response. An empty or too-short query returns an empty
        result set.
      operationId: searchUsers
      parameters:
        - name: q
          in: query
          description: Free-text query (display-name words, order-independent).
          schema:
            type: string
      responses:
        "200":
          description: Matches in the `searchResults` sidecar.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OnyxResponse"
                  - type: object
                    required: [searchResults]
                    properties:
                      searchResults:
                        type: object
                        description: Matched users — uid → display name.
                        additionalProperties:
                          type: string
              example:
                jsonCode: 200
                onyxData: []
                searchResults: { "uid-abc": "Jane Doe" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/batch:
    get:
      tags: [Users]
      summary: Batched profile/status read for many users
      description: |
        Resolves the public profile and/or the privacy-gated status for up to
        100 uids in **one** request — the batched twin of
        `GET /v1/users/{uid}/profile` + `GET /v1/users/{uid}/status`,
        field-for-field, so the per-uid shape is identical to the single
        endpoints. Exists to collapse the friend-list fan-out (one
        profile + status request per friend) into a single request the
        rate limiter counts once.

        Per uid: a missing profile is **omitted** (the single endpoint 404s; one
        absent user must not fail the batch); `status` keeps the
        friends + visibility gate with the same silent eviction
        (`user_status: null`) regardless of deny reason; a block in either
        direction or an admin ban evicts every requested field. All uids merge
        into ONE `userDataList` patch. An empty `uids` list is a success with
        empty `onyxData`; more than 100 uids is a `400` (never a silent
        truncation).
      operationId: getUsersBatch
      parameters:
        - name: uids
          in: query
          description: Comma-separated uid list; entries are trimmed, empties dropped, duplicates removed. Max 100 distinct uids.
          schema:
            type: string
          example: uid-a,uid-b,uid-c
        - name: fields
          in: query
          description: Comma-separated subset of `profile,status`. Absent or empty means both. Unknown tokens are ignored.
          schema:
            type: string
          example: profile,status
      responses:
        "200":
          description: One `merge userDataList` patch covering every resolvable uid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/nickname/{key}:
    get:
      tags: [Users]
      summary: Resolve a nickname-index key
      description: >
        Resolves one `nickname_to_id` index key to the user(s) holding it and
        merges each found user's public profile into `userDataList`. No match
        is still a success — just an empty `onyxData`.
      operationId: getUsersByNickname
      parameters:
        - name: key
          in: path
          required: true
          description: A cleaned nickname index key (see the app's `getNicknameKeys`).
          schema:
            type: string
      responses:
        "200":
          description: Profiles of users holding the key (possibly none).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/{uid}/profile:
    get:
      tags: [Users]
      summary: Fetch a user's public profile
      description: |
        Public tier: any authenticated user may read another user's `profile`,
        `public_data`, and public supporter flag (`is_supporter` is emitted
        only when `true`). Emits `merge userDataList {[uid]: {profile,
        public_data?, is_supporter?}}`.

        A block in either direction returns HTTP 200 with an **eviction patch**
        (`profile/public_data/is_supporter` merged to `null`) instead of the
        data or an error, indistinguishable from a hidden/absent user.
      operationId: getUserProfile
      parameters:
        - $ref: "#/components/parameters/TargetUid"
      responses:
        "200":
          description: Profile merge, or a silent eviction patch when blocked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: The user has no profile.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxFailure"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/{uid}/friends:
    get:
      tags: [Users]
      summary: Fetch a user's friends list
      description: |
        Public tier (mirrors the legacy RTDB rule `users/$uid/friends`
        `.read: auth != null`); backs friend / common-friend counts. Kept
        separate from `/profile` so list reads don't drag a full friends map
        per row. Emits `merge userDataList {[uid]: {friends}}` (`null` when
        absent, or as a silent eviction when a block exists between the two
        users).
      operationId: getUserFriends
      parameters:
        - $ref: "#/components/parameters/TargetUid"
      responses:
        "200":
          description: Friends-map merge (or `friends:null` eviction).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/{uid}/sessions:
    get:
      tags: [Users]
      summary: Fetch a friend's drinking sessions (privacy-gated)
      description: |
        Private tier. A viewer may read iff they are the owner, **or** all of:
        the viewer is in the owner's friends list, the owner is not
        `hide_from_all`, and the owner has not hidden this viewer. Allowed →
        `merge cachedDrinkingSessions {[uid]: <sessions with start_time ≥
        from>}`. **Denied → HTTP 200 with an eviction patch**
        (`cachedDrinkingSessions {[uid]: null}`) so a viewer who lost access
        drops anything they cached while previously allowed; the deny payload
        is byte-identical regardless of reason.
      operationId: getUserSessions
      parameters:
        - $ref: "#/components/parameters/TargetUid"
        - name: from
          in: query
          description: >
            Window floor — only sessions with `start_time` ≥ this epoch-ms
            value are returned. Missing/non-numeric/non-positive means "from
            the beginning".
          schema:
            type: integer
      responses:
        "200":
          description: Windowed sessions merge, or a silent eviction patch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/{uid}/preferences:
    get:
      tags: [Users]
      summary: Fetch a friend's rendering preferences (privacy-gated)
      description: >
        Private tier, same gate as `/sessions` (preferences exist to render the
        friend's sessions, so if sessions are denied these are moot). Allowed →
        `merge userDataList {[uid]: {preferences}}`; denied → HTTP 200 with the
        `preferences: null` eviction patch.
      operationId: getUserPreferences
      parameters:
        - $ref: "#/components/parameters/TargetUid"
      responses:
        "200":
          description: Preferences merge, or a silent eviction patch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/users/{uid}/status:
    get:
      tags: [Users]
      summary: Fetch a friend's presence + latest session (privacy-gated)
      description: >
        Private tier, same gate as `/sessions` — `user_status.latest_session`
        embeds the friend's most recent drinking session, so it is the same
        private data. Allowed → `merge userDataList {[uid]: {user_status}}`;
        denied → HTTP 200 with the `user_status: null` eviction patch.
      operationId: getUserStatus
      parameters:
        - $ref: "#/components/parameters/TargetUid"
      responses:
        "200":
          description: Status merge, or a silent eviction patch.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Profile ──────────────────────────────────────────────────────────────
  /v1/profile/display-name:
    post:
      tags: [Profile]
      summary: Change display name (and reindex search)
      description: |
        Renames the caller and atomically rewrites their `nickname_to_id`
        search-index tokens (stale tokens removed, new ones added — derived
        from the *currently stored* name, never a client-supplied old name).
        The index is a search index, **not** a uniqueness constraint — names
        are not unique and this never returns `409`. A no-op rename (same
        name) returns success with empty `onyxData`. Display names are
        profanity-screened server-side; an objectionable name is a `400`.
      operationId: updateDisplayName
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [displayName]
              properties:
                displayName:
                  type: string
                  minLength: 1
      responses:
        "200":
          description: "`merge userDataList {[uid]: {profile: {display_name}}}` (empty for a no-op)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/profile/username:
    post:
      tags: [Profile]
      summary: Choose the post-signup username
      description: |
        Promotes the user's post-signup username choice: the same
        rename + nickname-reindex as `/display-name`, but additionally flips
        the `username_chosen` onboarding-routing gate in the **same** atomic
        update so a client can never observe the two out of sync. Unlike
        `/display-name` there is no unchanged-name short-circuit — even an
        identical name still sets `username_chosen`. Profanity-screened.
      operationId: chooseUsername
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties:
                username:
                  type: string
                  minLength: 1
      responses:
        "200":
          description: "`merge userDataList {[uid]: {profile: {display_name, username_chosen: true}}}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/profile/name:
    post:
      tags: [Profile]
      summary: Set legal first/last name
      description: At least one of `firstName` / `lastName` is required; only supplied fields are written.
      operationId: updateLegalName
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                firstName:
                  type: string
                lastName:
                  type: string
      responses:
        "200":
          description: "`merge userDataList {[uid]: {profile: {first_name?, last_name?}}}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/profile/photo:
    post:
      tags: [Profile]
      summary: Record a profile-photo URL (superseded)
      description: |
        **Superseded by the `/v1/images` pipeline** (`upload-url` +
        `finalize`), which mints and validates the URL server-side instead of
        trusting a client-supplied string. Retained for compatibility until the
        client cutover completes; new integrations should not use it. Persists
        `profile.photo_url` verbatim.
      operationId: setProfilePhotoUrl
      deprecated: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [photoURL]
              properties:
                photoURL:
                  type: string
                  minLength: 1
      responses:
        "200":
          description: "`merge userDataList {[uid]: {profile: {photo_url}}}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Images ───────────────────────────────────────────────────────────────
  /v1/images/upload-url:
    post:
      tags: [Images]
      summary: Mint a presigned upload URL
      description: |
        Validates the image kind and content type (JPEG/PNG/WebP) — and, for
        `kind: session`, that the caller owns `sessionId` — then mints a
        **5-minute v4 presigned PUT URL** at a deterministic per-kind object
        path (`avatars/<uid>/<uuid>.<ext>` or
        `session_images/<uid>/<sessionId>/<uuid>.<ext>`). The client PUTs the
        bytes **directly** to `uploadUrl` (the bytes never transit this API),
        then calls `/v1/images/finalize`. `onyxData` is always empty; the
        `uploadUrl`/`objectPath` sidecar carries the result.

        The `session` kind is reserved scaffolding — its consumption side has
        not shipped, so treat it as unstable.
      operationId: createImageUploadUrl
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, contentType]
              properties:
                kind:
                  $ref: "#/components/schemas/ImageKind"
                contentType:
                  type: string
                  enum: [image/jpeg, image/png, image/webp]
                sessionId:
                  type: string
                  description: Required when `kind` is `session`; must name a session the caller owns.
      responses:
        "200":
          description: Presigned URL + the object path to pass back to `finalize`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OnyxResponse"
                  - type: object
                    required: [uploadUrl, objectPath]
                    properties:
                      uploadUrl:
                        type: string
                        description: Short-lived presigned PUT URL (Content-Type-bound).
                      objectPath:
                        type: string
                        description: The deterministic object path the upload must land at.
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/images/finalize:
    post:
      tags: [Images]
      summary: Finalize an uploaded image (validate + moderate + apply)
      description: |
        The server-side gate of record for uploaded bytes (presigned/admin
        writes bypass Storage rules entirely). Re-validates the object — it
        must sit under the **caller's own** per-kind prefix with a safe
        filename, exist, be ≤ 5 MB, and carry an allowlisted content type —
        then runs SafeSearch moderation. Any validation or moderation failure
        **deletes the object** and returns a 4xx (`400` bad input / missing
        object / oversize / wrong type, `422` moderation reject).

        On pass, per kind:
        - `avatar` — the object is made public-read and `profile.photo_url` is
          persisted; emits the same `merge userDataList {[uid]: {profile:
          {photo_url}}}` as the legacy `/v1/profile/photo`.
        - `session` — kept private; persistence is a no-op stub until session
          images ship. Echoes the validated `objectPath` (unstable contract).
      operationId: finalizeImage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, objectPath]
              properties:
                kind:
                  $ref: "#/components/schemas/ImageKind"
                objectPath:
                  type: string
                  description: The exact `objectPath` returned by `upload-url`.
                sessionId:
                  type: string
                  description: Required when `kind` is `session`.
      responses:
        "200":
          description: >
            Avatar: the `photo_url` merge. Session: empty `onyxData` plus an
            `objectPath` sidecar echoing the stored reference.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OnyxResponse"
                  - type: object
                    properties:
                      objectPath:
                        type: string
                        description: Present for `kind:session` only.
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Image rejected by moderation; the uploaded object has been deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxFailure"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Preferences ──────────────────────────────────────────────────────────
  /v1/preferences:
    post:
      tags: [Preferences]
      summary: Partially update the caller's preferences
      description: |
        One validated write path covering the full `Preferences` key set plus
        `theme`, `locale`, and `timezone`. Each recognized, correctly-typed
        field is written; unrecognized or mistyped fields are **ignored** — but
        if nothing recognized remains, the request is a `400`. The validated
        patch is echoed as `merge preferences` (nested `null` deletes); `theme`
        / `locale` / `timezone` additionally fan out to `set preferredTheme` /
        `set nvp_preferredLocale` / `merge userDataList {[uid]: {timezone}}`.
      operationId: updatePreferences
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PreferencesPatch"
      responses:
        "200":
          description: The echoed preference updates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Onboarding ───────────────────────────────────────────────────────────
  /v1/onboarding/accept-terms:
    post:
      tags: [Onboarding]
      summary: Accept the current Terms & Conditions
      description: |
        Stamps `agreed_to_terms_at` (server time) and
        `agreed_to_terms_version`. The version is a **server constant** — the
        client cannot spoof what "accepted" means. Optionally records the
        onboarding resume path in the same write. Emits the `userDataList`
        merge + `set nvp_termsAcceptedVersion` (+ `merge nvp_onboarding` when a
        path was supplied).
      operationId: acceptTerms
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                onboardingPath:
                  type: string
                  description: Optional onboarding resume path to persist alongside the acceptance.
      responses:
        "200":
          description: Terms-acceptance updates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/onboarding/last-visited-path:
    post:
      tags: [Onboarding]
      summary: Persist the onboarding resume point
      description: >
        Stores `onboarding/last_visited_path` so a user who drops out mid-flow
        resumes where they left off. Emits `merge nvp_onboarding` +
        `merge userDataList {[uid]: {onboarding: {last_visited_path}}}`.
      operationId: setLastVisitedPath
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [path]
              properties:
                path:
                  type: string
                  minLength: 1
      responses:
        "200":
          description: Resume-point updates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/onboarding/complete:
    post:
      tags: [Onboarding]
      summary: Mark onboarding finished
      description: >
        Stamps `onboarding/completed_at` with the server time. Emits
        `merge nvp_onboarding {completed_at}` + the matching `userDataList`
        merge. No request body.
      operationId: completeOnboarding
      responses:
        "200":
          description: Completion updates.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Sessions ─────────────────────────────────────────────────────────────
  /v1/sessions/update:
    post:
      tags: [Sessions]
      summary: Upsert a drinking session
      description: |
        One upsert covering create, live update, and finalize/edit. Writes the
        whole session verbatim (the server reads only `start_time`); when
        `sessionIsLive` is true it also mirrors the caller's `user_status`
        (presence + latest session). The server **owns**
        `earliest_session_at`: a strict improvement lowers the floor in-batch,
        otherwise it is recomputed post-write, and a moved floor adds a
        `merge userDataList {[uid]: {earliest_session_at}}`.

        Normally echoes `merge cachedDrinkingSessions {[uid]: {[sessionId]:
        session}}`. Exception: a **live, still-ongoing** update
        (`sessionIsLive && session.ongoing`) does *not* echo the session —
        the in-progress session lives in the client's local buffer, and echoing
        every tap would jank occluded screens and accumulate stale drinks in
        the cache (merge semantics can't drop removed drinks). The finalize
        write (`ongoing: false`) echoes the real session. The update is also
        published to the caller's other devices (excluding `pusherSocketID`).
      operationId: updateSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId, session]
              properties:
                sessionId:
                  type: string
                  minLength: 1
                session:
                  $ref: "#/components/schemas/DrinkingSession"
                sessionIsLive:
                  type: boolean
                  description: When `true`, the caller's `user_status` mirrors this session as the live one.
                pusherSocketID:
                  $ref: "#/components/schemas/PusherSocketID"
      responses:
        "200":
          description: Session echo (unless live-and-ongoing) + floor update when it moved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/sessions/delete:
    post:
      tags: [Sessions]
      summary: Delete a drinking session
      description: |
        Nulls the session **and** its GPS locations in one batch; when
        `sessionIsLive` is true the caller's `user_status` is cleared too. The
        earliest-session floor is recomputed from what remains. Emits
        `merge cachedDrinkingSessions {[uid]: {[sessionId]: null}}` (Onyx
        merge-delete) plus the floor merge when it moved; also published to the
        caller's other devices.
      operationId: deleteSession
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId]
              properties:
                sessionId:
                  type: string
                  minLength: 1
                sessionIsLive:
                  type: boolean
                  description: When `true`, the caller's live `user_status` is cleared.
                pusherSocketID:
                  $ref: "#/components/schemas/PusherSocketID"
      responses:
        "200":
          description: Session removal + floor update when it moved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/sessions/recompute-earliest:
    post:
      tags: [Sessions]
      summary: Recompute the earliest-session floor (compatibility)
      description: |
        **Redundant** — `/update` and `/delete` keep `earliest_session_at`
        correct on every write; this standalone aggregation pass is retained
        only for callers that have not cut over. Recomputes the minimum
        `start_time` and persists it (`null` when the user has no sessions).
        No request body.
      operationId: recomputeEarliestSession
      deprecated: true
      responses:
        "200":
          description: "`merge userDataList {[uid]: {earliest_session_at}}`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Session locations ────────────────────────────────────────────────────
  /v1/session-locations/capture:
    post:
      tags: [Session locations]
      summary: Attach a GPS fix to a drink timestamp
      description: >
        Stores one `DrinkLocation` under
        `user_session_locations/$uid/$sessionId/$timestamp` and echoes
        `merge sessionLocations_<sessionId> {[timestamp]: location}`.
      operationId: captureSessionLocation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId, timestamp, location]
              properties:
                sessionId:
                  type: string
                  minLength: 1
                timestamp:
                  type: number
                  description: The drink timestamp (epoch ms) this fix belongs to.
                location:
                  $ref: "#/components/schemas/DrinkLocation"
      responses:
        "200":
          description: The location merge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/session-locations/clear:
    post:
      tags: [Session locations]
      summary: Drop one session's locations
      description: Removes every fix recorded for `sessionId` and emits `set sessionLocations_<sessionId> null`.
      operationId: clearSessionLocations
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sessionId]
              properties:
                sessionId:
                  type: string
                  minLength: 1
      responses:
        "200":
          description: The eviction `set`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/session-locations/purge:
    post:
      tags: [Session locations]
      summary: Wipe all of the caller's recorded locations
      description: >
        The privacy "clear location history" action — removes
        `user_session_locations/$uid` entirely. `onyxData` is empty by design:
        the app leaves its (inert) per-session Onyx caches in place and treats
        the database as authoritative. No request body.
      operationId: purgeSessionLocations
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Status ───────────────────────────────────────────────────────────────
  /v1/status/sync:
    post:
      tags: [Status]
      summary: Recompute and persist the caller's status
      description: >
        Recomputes `user_status/$uid` — `last_online` (server time) plus the
        latest session by `start_time`, derived from the caller's **own stored
        sessions** (never a client-supplied list) — and persists it. `onyxData`
        is empty: the signed-in user's own `user_status` has no top-level Onyx
        key; the write is a server-side mirror consumed by friends' status
        reads. No request body.
      operationId: syncStatus
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Privacy ──────────────────────────────────────────────────────────────
  /v1/privacy/hide-from-all:
    post:
      tags: [Privacy]
      summary: Hide the caller's data from all friends
      description: >
        Master visibility switch. Writes `true` to enable, `null` to disable —
        absence means fully visible (the grandfathered default). Emits
        `merge dataVisibility {hide_from_all: true|null}`.
      operationId: setHideFromAll
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hidden]
              properties:
                hidden:
                  type: boolean
      responses:
        "200":
          description: The visibility merge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/privacy/friend:
    post:
      tags: [Privacy]
      summary: Hide the caller's data from one friend
      description: >
        Per-friend visibility toggle. Writes `true` to hide from `friendUid`,
        `null` to unhide. Emits `merge dataVisibility {hidden_from:
        {[friendUid]: true|null}}`.
      operationId: setFriendHidden
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [friendUid, hidden]
              properties:
                friendUid:
                  type: string
                  minLength: 1
                hidden:
                  type: boolean
      responses:
        "200":
          description: The visibility merge.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Feedback ─────────────────────────────────────────────────────────────
  /v1/feedback:
    get:
      tags: [Feedback]
      summary: List all feedback (admin)
      description: |
        **Admin only** (requires the `admin: true` custom claim; `403`
        otherwise). Returns the whole feedback collection as plain JSON
        (`{}` when empty) — **not** the Onyx envelope; the admin screens fetch
        into local state.
      operationId: listFeedback
      responses:
        "200":
          description: The feedback collection, keyed by push id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Feedback]
      summary: Submit feedback
      description: >
        Stores `{submit_time, text, user_id}` under a server-generated
        chronological push id. Empty `onyxData` — the feedback collection is
        admin-only and never mirrored to the submitting client.
      operationId: submitFeedback
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FeedbackBody"
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/feedback/bug:
    get:
      tags: [Feedback]
      summary: List all bug reports (admin)
      description: |
        **Admin only** (requires the `admin: true` custom claim; `403`
        otherwise). Returns the whole bugs collection as plain JSON (`{}` when
        empty) — **not** the Onyx envelope.
      operationId: listBugs
      responses:
        "200":
          description: The bugs collection, keyed by push id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/InternalError"
    post:
      tags: [Feedback]
      summary: Report a bug
      description: Same contract as `POST /v1/feedback`, stored in the bugs collection.
      operationId: reportBug
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FeedbackBody"
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/feedback/{feedbackId}/remove:
    post:
      tags: [Feedback]
      summary: Delete a feedback item (admin)
      description: "**Admin only** (requires the `admin: true` custom claim; `403` otherwise). Nulls `feedback/$feedbackId`."
      operationId: removeFeedback
      parameters:
        - name: feedbackId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  /v1/feedback/bug/{bugId}/remove:
    post:
      tags: [Feedback]
      summary: Delete a bug item (admin)
      description: "**Admin only** (requires the `admin: true` custom claim; `403` otherwise). Nulls `bugs/$bugId`."
      operationId: removeBug
      parameters:
        - name: bugId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Reports ──────────────────────────────────────────────────────────────
  /v1/reports:
    post:
      tags: [Reports]
      summary: Report a user (UGC moderation)
      description: |
        `ReportUser` — files a moderation report against `otherUserId` into the
        admin-only `reports` collection (`{reporter_uid, reported_uid, reason,
        description?, submit_time, status: "open"}`). The reporter is always
        the authenticated caller; the target must be a real user (`404`
        otherwise) and not the caller. `reason` validation is deliberately
        **lenient**: the known values are `inappropriate_name`,
        `inappropriate_photo`, `harassment`, `other`, but any non-empty string
        is stored verbatim so future client reasons are never dropped. Empty
        `onyxData` — clients do not store reports.
      operationId: reportUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [otherUserId, reason]
              properties:
                otherUserId:
                  type: string
                  minLength: 1
                  description: The reported user's uid. Must not be the caller.
                reason:
                  type: string
                  minLength: 1
                  description: "Known values: `inappropriate_name`, `inappropriate_photo`, `harassment`, `other`. Unknown non-empty values are stored verbatim."
                description:
                  type: string
                  maxLength: 1000
                  description: Optional free-text detail.
      responses:
        "200":
          description: Success with empty `onyxData`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: The reported user does not exist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxFailure"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Provisioning ─────────────────────────────────────────────────────────
  /v1/provisioning:
    post:
      tags: [Provisioning]
      summary: Provision a brand-new user's records
      description: |
        The server-side half of signup, called immediately after Firebase Auth
        account creation (the Auth call itself stays on the client). Creates
        `users/$uid` (profile + role + timezone) through a create-if-absent
        transaction, then writes the presence seed, the server-default
        preferences overlaid with any client-supplied `preferences`, and the
        display name's search-index tokens. Authority is the verified token's
        uid — a user can only provision **their own** record; a duplicate or
        concurrent call returns `409` and never clobbers an established
        account. The display name is profanity-screened. Emits
        `merge userDataList {[uid]: userData}` + `set preferences` +
        `set preferredTheme`.
      operationId: provisionUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profile]
              properties:
                profile:
                  type: object
                  required: [display_name]
                  properties:
                    display_name:
                      type: string
                      minLength: 1
                    photo_url:
                      type: string
                    username_chosen:
                      type: boolean
                      description: Defaults to `false` (a brand-new user still picks a username during onboarding).
                timezone:
                  type: object
                  description: "Timezone object (e.g. `{automatic: true}` — the default when omitted)."
                  additionalProperties: true
                preferences:
                  type: object
                  description: Optional preference overrides merged over the server defaults.
                  additionalProperties: true
      responses:
        "200":
          description: The created user data + seeded preferences.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "400":
          $ref: "#/components/responses/EnvelopeBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: The user already exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxFailure"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Account ──────────────────────────────────────────────────────────────
  /v1/account/close:
    post:
      tags: [Account]
      summary: Close the caller's account (server-side cleanup)
      description: |
        The cross-user cleanup half of account deletion: nulls every subtree
        the user owns (profile, sessions, preferences, locations, visibility,
        update log/meta), removes them from every friend's and requester's
        maps and from the nickname index, and optionally records an exit
        reason. Each ex-friend/requester is pushed their removal mirror over
        realtime. The **Firebase Auth user deletion stays on the client** (it
        needs the user's fresh credential); refresh tokens are deliberately
        not revoked here so that client-side delete still works. Emits `clear`
        for `session`, `userDataList`, `private_userData`, and
        `cachedDrinkingSessions`.
      operationId: closeAccount
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reasonForLeaving:
                  description: Optional exit-survey answer, stored keyed by the closing uid.
      responses:
        "200":
          description: "`clear` updates for all user-scoped client state."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OnyxResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  # ── Admin ────────────────────────────────────────────────────────────────
  /v1/admin/ping:
    get:
      tags: [Admin]
      summary: Admin-subtree liveness
      description: "Requires the `admin: true` custom claim. Returns `{ok, uid}` for the calling admin."
      operationId: adminPing
      responses:
        "200":
          description: Liveness echo.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok:
                    type: boolean
                    const: true
                  uid:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"

  /v1/admin/users/{uid}/make-admin:
    post:
      tags: [Admin]
      summary: Grant the admin claim
      description: >
        Requires the `admin: true` custom claim. Sets `admin: true` on the
        target user. The path parameter accepts a Firebase UID **or** an email
        address.
      operationId: adminMakeAdmin
      parameters:
        - $ref: "#/components/parameters/AdminUserIdentifier"
      responses:
        "200":
          description: Claim applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminUserResult"
        "400":
          $ref: "#/components/responses/AdminBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "404":
          description: "No user matches the identifier (`code: USER_NOT_FOUND`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/users/{uid}/ban:
    post:
      tags: [Admin]
      summary: Ban (eject) a user
      description: |
        Requires the `admin: true` custom claim. Disables the target's Firebase
        Auth account **and** sets the RTDB ban flag in one audited operation
        (the acting admin is recorded as `banned_by`). The banned flag is also
        pushed to the user's client over realtime (best-effort) so the lockout
        lands within seconds. Banned users disappear from search and batch
        reads.
      operationId: adminBanUser
      parameters:
        - $ref: "#/components/parameters/AdminUserIdentifier"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional human-readable ban reason, stored with the flag.
      responses:
        "200":
          description: Ban applied.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminUserResult"
        "400":
          $ref: "#/components/responses/AdminBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "404":
          description: "No user matches the identifier (`code: USER_NOT_FOUND`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/users/{uid}/unban:
    post:
      tags: [Admin]
      summary: Unban a user
      description: >
        Requires the `admin: true` custom claim. Re-enables the target's
        Firebase Auth account and clears the ban flag; the clearance is pushed
        to the user's client over realtime (best-effort).
      operationId: adminUnbanUser
      parameters:
        - $ref: "#/components/parameters/AdminUserIdentifier"
      responses:
        "200":
          description: Ban lifted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminUserResult"
        "400":
          $ref: "#/components/responses/AdminBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "404":
          description: "No user matches the identifier (`code: USER_NOT_FOUND`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/users/{uid}/password-reset-link:
    post:
      tags: [Admin]
      summary: Generate a password-reset link
      description: >
        Requires the `admin: true` custom claim. Generates a Firebase
        password-reset link for the given **email address** (unlike the other
        user routes, the path parameter must be an email, not a UID).
      operationId: adminPasswordResetLink
      parameters:
        - name: uid
          in: path
          required: true
          description: The target user's email address.
          schema:
            type: string
            format: email
      responses:
        "200":
          description: Reset link generated.
          content:
            application/json:
              schema:
                type: object
                required: [email, link, applied]
                properties:
                  email:
                    type: string
                  link:
                    type: string
                  applied:
                    type: boolean
        "400":
          description: "Invalid email (`code: INVALID_EMAIL`) or bad identifier."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/email/send:
    post:
      tags: [Admin]
      summary: Send an email
      description: >
        Requires the `admin: true` custom claim. Sends an email through the
        configured SMTP transport (`SMTP_HOST` etc. must be set in the runtime
        environment; a missing SMTP config surfaces as `500`).
      operationId: adminSendEmail
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipients, email]
              properties:
                recipients:
                  type: array
                  minItems: 1
                  items:
                    type: string
                  description: Non-empty list of recipient addresses.
                email:
                  type: object
                  required: [subject, html]
                  properties:
                    subject:
                      type: string
                    html:
                      type: string
                    from:
                      type: string
                    text:
                      type: string
                    cc:
                      description: A single address or a list of addresses.
                      oneOf:
                        - type: string
                        - type: array
                          items:
                            type: string
                    bcc:
                      description: A single address or a list of addresses.
                      oneOf:
                        - type: string
                        - type: array
                          items:
                            type: string
      responses:
        "200":
          description: Send result.
          content:
            application/json:
              schema:
                type: object
                required: [recipients, sent, applied]
                properties:
                  recipients:
                    type: array
                    items:
                      type: string
                  sent:
                    type: integer
                  applied:
                    type: boolean
        "400":
          $ref: "#/components/responses/AdminBadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/maintenance/schedule:
    post:
      tags: [Admin]
      summary: Schedule a maintenance window
      description: >
        Requires the `admin: true` custom claim. Persists the window under the
        global `config` node and broadcasts the new config live on the public
        `config` Pusher channel (best-effort) so every client's maintenance
        gate flips without waiting for its next app-open.
      operationId: adminScheduleMaintenance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [startTime, endTime]
              properties:
                startTime:
                  type: number
                  description: Window start, epoch ms. Must be ≤ `endTime`.
                endTime:
                  type: number
                  description: Window end, epoch ms.
      responses:
        "200":
          description: Window persisted.
          content:
            application/json:
              schema:
                type: object
                required: [startTime, endTime, applied]
                properties:
                  startTime:
                    type: number
                  endTime:
                    type: number
                  applied:
                    type: boolean
        "400":
          description: "Non-numeric times or an invalid window (`code: INVALID_MAINTENANCE_WINDOW`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/maintenance/cancel:
    post:
      tags: [Admin]
      summary: Cancel the scheduled maintenance window
      description: >
        Requires the `admin: true` custom claim. Clears the window and
        broadcasts the updated config live (best-effort). No request body.
      operationId: adminCancelMaintenance
      responses:
        "200":
          description: Window cancelled.
          content:
            application/json:
              schema:
                type: object
                required: [cancelledAt, applied]
                properties:
                  cancelledAt:
                    type: number
                  applied:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/maintenance/status:
    get:
      tags: [Admin]
      summary: Report whether maintenance is active
      description: "Requires the `admin: true` custom claim."
      operationId: adminMaintenanceStatus
      responses:
        "200":
          description: Current maintenance state.
          content:
            application/json:
              schema:
                type: object
                required: [inMaintenance]
                properties:
                  inMaintenance:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "404":
          description: "No maintenance config exists (`code: MAINTENANCE_CONFIG_NOT_FOUND`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/terms/publish:
    post:
      tags: [Admin]
      summary: Publish updated Terms & Conditions
      description: >
        Requires the `admin: true` custom claim. Stamps
        `config/terms_last_updated` with the **current server time**
        (deliberately body-less so terms cannot be back- or forward-dated) and
        broadcasts the new config live. Every user whose acceptance predates
        the stamp is re-prompted.
      operationId: adminPublishTerms
      responses:
        "200":
          description: Stamp written.
          content:
            application/json:
              schema:
                type: object
                required: [termsLastUpdated, applied]
                properties:
                  termsLastUpdated:
                    type: number
                  applied:
                    type: boolean
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/terms/status:
    get:
      tags: [Admin]
      summary: Read the Terms & Conditions publish stamp
      description: "Requires the `admin: true` custom claim. `null` when terms were never published."
      operationId: adminTermsStatus
      responses:
        "200":
          description: Current stamp.
          content:
            application/json:
              schema:
                type: object
                required: [termsLastUpdated]
                properties:
                  termsLastUpdated:
                    type: ["number", "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  /v1/admin/app-version:
    post:
      tags: [Admin]
      summary: Set the client version gates
      description: >
        Requires the `admin: true` custom claim. Sets one or more version gates
        under `config/app_settings` (only the fields supplied are written) and
        broadcasts the new config live on the public `config` Pusher channel
        (best-effort) so every client's gate flips without waiting for its next
        app-open. Each value must be plain semver and `minSupportedVersion` must
        be ≤ `latestVersion` (enforced against the effective merged values).
      operationId: adminSetAppVersions
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                latestVersion:
                  type: string
                  description: Dismissible "update available" nudge, e.g. `"1.5.0"`.
                minSupportedVersion:
                  type: string
                  description: "Force-update kill switch. Must be ≤ `latestVersion`."
                minUserCreationVersion:
                  type: string
                  description: Signup gate, e.g. `"1.0.0"`.
      responses:
        "200":
          description: Gates persisted; returns the full resulting set.
          content:
            application/json:
              schema:
                type: object
                required: [latestVersion, minSupportedVersion, minUserCreationVersion, applied]
                properties:
                  latestVersion:
                    type: ["string", "null"]
                  minSupportedVersion:
                    type: ["string", "null"]
                  minUserCreationVersion:
                    type: ["string", "null"]
                  applied:
                    type: boolean
        "400":
          description: "Missing/non-string field, invalid semver, or `minSupportedVersion > latestVersion` (`code: INVALID_APP_VERSION`)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AdminError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"
    get:
      tags: [Admin]
      summary: Read the client version gates
      description: "Requires the `admin: true` custom claim. Each field is `null` when never set."
      operationId: adminGetAppVersions
      responses:
        "200":
          description: Current gates.
          content:
            application/json:
              schema:
                type: object
                required: [latestVersion, minSupportedVersion, minUserCreationVersion]
                properties:
                  latestVersion:
                    type: ["string", "null"]
                  minSupportedVersion:
                    type: ["string", "null"]
                  minUserCreationVersion:
                    type: ["string", "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AdminForbidden"
        "500":
          $ref: "#/components/responses/AdminInternalError"

  # ── Email ────────────────────────────────────────────────────────────────
  /v1/email/unsubscribe:
    get:
      tags: [Email]
      summary: One-click email unsubscribe (link target)
      description: |
        The target of every marketing email's Unsubscribe link. Mounted
        **outside** the Firebase auth chain (clicked from an email — no ID
        token); authenticity is proven by the HMAC token instead:
        `t = base64url(HMAC_SHA256(secret, uid))`, verified in constant time
        against the shared signing secret. A valid token writes an idempotent
        suppression record for the recipient and redirects (`302`) to the
        locale-appropriate kiroku.cz success page; a missing/forged token
        writes **nothing** and redirects to the same page with
        `?error=invalid`. This endpoint never returns an error status — the
        outcome is always a redirect.
      operationId: emailUnsubscribe
      security:
        - unsubscribeToken: []
      parameters:
        - $ref: "#/components/parameters/UnsubscribeUid"
        - $ref: "#/components/parameters/UnsubscribeToken"
      responses:
        "302":
          $ref: "#/components/responses/UnsubscribeRedirect"
    post:
      tags: [Email]
      summary: One-click email unsubscribe (RFC 8058)
      description: >
        The RFC 8058 `List-Unsubscribe-Post: List-Unsubscribe=One-Click` verb.
        Behaves identically to the GET — same token verification, same
        suppression write, same redirects.
      operationId: emailUnsubscribeOneClick
      security:
        - unsubscribeToken: []
      parameters:
        - $ref: "#/components/parameters/UnsubscribeUid"
        - $ref: "#/components/parameters/UnsubscribeToken"
      responses:
        "302":
          $ref: "#/components/responses/UnsubscribeRedirect"

  # ── Webhooks ─────────────────────────────────────────────────────────────
  /v1/webhooks/revenuecat:
    post:
      tags: [Webhooks]
      summary: RevenueCat subscription events
      description: |
        Inbound RevenueCat webhook. Authenticated by the shared secret in the
        `Authorization` header (configured verbatim in the RevenueCat
        dashboard and compared in constant time — **not** a Firebase token).

        Events carrying the `supporter` entitlement drive an atomic
        public-flag + private-record supporter-status write:
        `INITIAL_PURCHASE`, `RENEWAL`, `UNCANCELLATION`, `PRODUCT_CHANGE` →
        active; `CANCELLATION` → cancelled (access continues until expiry);
        `BILLING_ISSUE` → grace period; `EXPIRATION` → expired (public flag
        off). Other event types or entitlements are logged no-ops
        (`applied: false`). On a successful flip the new supporter state is
        pushed to the subscriber over realtime.

        **ACKs `200` even when the database write fails** (`applied: false,
        error: "write_failed"`): RevenueCat retries on 4xx/5xx, and the writes
        are idempotent state transitions keyed by `event.id`, so a logged
        failure beats a retry storm.
      operationId: revenuecatWebhook
      security:
        - revenuecatSharedSecret: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event]
              properties:
                event:
                  type: object
                  description: The RevenueCat event object (additional provider fields are accepted and ignored).
                  required: [id, type]
                  properties:
                    id:
                      type: string
                      description: Idempotency key for the event.
                    type:
                      type: string
                      description: RevenueCat event type (e.g. `INITIAL_PURCHASE`, `RENEWAL`, `EXPIRATION`).
                    app_user_id:
                      type: string
                      description: The subscriber's Firebase uid.
                    entitlement_ids:
                      type: array
                      items:
                        type: string
                    entitlement_id:
                      type: ["string", "null"]
                    product_id:
                      type: string
                    new_product_id:
                      type: string
                    store:
                      type: string
                    purchased_at_ms:
                      type: number
                    expiration_at_ms:
                      type: ["number", "null"]
                    event_timestamp_ms:
                      type: number
                  additionalProperties: true
      responses:
        "200":
          description: Acknowledged (also used for no-ops and logged write failures — see above).
          content:
            application/json:
              schema:
                type: object
                required: [ok, applied]
                properties:
                  ok:
                    type: boolean
                    const: true
                  applied:
                    type: boolean
                    description: Whether a supporter-status write was performed.
                  error:
                    type: string
                    description: Present only for the ACKed-failure case (`write_failed`).
        "400":
          description: Malformed event payload.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
        "401":
          description: Missing or wrong shared secret (or the secret is not configured server-side).
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string

  # ── Jobs (internal maintenance) ──────────────────────────────────────────
  /v1/jobs/close-stale-sessions:
    post:
      tags: [Jobs]
      summary: Auto-close stale ongoing drinking sessions
      description: |
        Closes drinking sessions that have been left `ongoing` past their
        staleness threshold. The backstop behind the Kiroku client's lazy
        close — it catches dormant and multi-device users. Authenticated by the
        shared secret in the `Authorization` header (constant-time compare —
        **not** a Firebase token), so Cloud Scheduler (hourly) and the
        kiroku-cli backfill one-shot can call it.

        Per-user threshold (hours) = the `auto_close_sessions_after_hours`
        preference if set, else `config/auto_close_default_hours`; a `"never"`
        preference opts the user out. When the global default is unset the sweep
        is a no-op for users without an explicit preference, so an environment
        stays disabled until it is deliberately enabled. Staleness is measured
        from the last activity (`max(last drink ts, start_time)`) against the
        server clock. A close sets `ongoing: false`, `end_time` to the last-drink
        timestamp (fallback `start_time`), and an `auto_closed: true` marker. The
        close is guarded by a transaction (idempotent + race-safe).
      operationId: closeStaleSessions
      security:
        - jobsSharedSecret: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum: [sweep, backfill]
                  default: sweep
                  description: |
                    `sweep` (default) scans the `user_status` live-session index;
                    `backfill` does a full `user_drinking_sessions` scan to also
                    catch `ongoing`-without-`user_status` drift.
      responses:
        "200":
          description: Sweep summary.
          content:
            application/json:
              schema:
                type: object
                required: [mode, scanned, closed, skipped]
                properties:
                  mode:
                    type: string
                    enum: [sweep, backfill]
                  scanned:
                    type: integer
                    description: Candidate sessions examined (`scanned === closed + skipped`).
                  closed:
                    type: integer
                  skipped:
                    type: integer
        "401":
          description: Missing or wrong shared secret (or the secret is not configured server-side).
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string
        "500":
          description: Internal error during the sweep.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error:
                    type: string

components:
  securitySchemes:
    firebaseIdToken:
      type: http
      scheme: bearer
      bearerFormat: Firebase ID token (JWT)
      description: |
        `Authorization: Bearer <Firebase ID token>` — the ID token the Kiroku
        client obtains from Firebase Auth (the API never mints credentials).
        Missing/malformed/revoked tokens → HTTP `401`. An **expired** token →
        HTTP `200` with `{"jsonCode": 407, "onyxData": []}` so the client
        refreshes the token and replays (see the API description; there is no
        actual HTTP 407). Admin endpoints additionally require the
        `admin: true` custom claim on the token (otherwise `403`) — noted in
        each admin operation's description.
    revenuecatSharedSecret:
      type: apiKey
      in: header
      name: Authorization
      description: >
        The RevenueCat webhook shared secret, sent verbatim as the
        `Authorization` header value (exactly as configured in the RevenueCat
        dashboard — no `Bearer` prefix is added or required by the API; the
        header is compared in constant time against the configured secret).
    jobsSharedSecret:
      type: apiKey
      in: header
      name: Authorization
      description: >
        The `JOBS_SHARED_SECRET` value, sent verbatim as the `Authorization`
        header value (no `Bearer` prefix; compared in constant time against the
        configured secret). Presented by Cloud Scheduler and the kiroku-cli
        backfill — not a Firebase token.
    unsubscribeToken:
      type: apiKey
      in: query
      name: t
      description: >
        HMAC unsubscribe token: `base64url(HMAC_SHA256(secret, uid))`, signed
        by the email sender with the shared unsubscribe secret and verified in
        constant time. Always paired with the `uid` query parameter.

  parameters:
    TargetUid:
      name: uid
      in: path
      required: true
      description: The target user's uid.
      schema:
        type: string
    AdminUserIdentifier:
      name: uid
      in: path
      required: true
      description: The target user — a Firebase UID **or** an email address.
      schema:
        type: string
    UnsubscribeUid:
      name: uid
      in: query
      required: true
      description: The recipient's uid (as embedded in the signed link).
      schema:
        type: string
    UnsubscribeToken:
      name: t
      in: query
      required: true
      description: The HMAC token signed over `uid` (see the `unsubscribeToken` security scheme).
      schema:
        type: string

  responses:
    Unauthorized:
      description: >
        No, malformed, or revoked Firebase ID token (plain-text body). NOTE:
        an *expired* token is not a 401 — it is HTTP 200 with `jsonCode: 407`
        (see the API description).
      content:
        text/html:
          schema:
            type: string
            examples: ["Unauthorized: No token provided"]
    EnvelopeBadRequest:
      description: Failed input validation.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OnyxFailure"
          example:
            jsonCode: 400
            onyxData: []
            message: Invalid payload
    InternalError:
      description: Unexpected server error (internals never leak).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OnyxFailure"
          example:
            jsonCode: 500
            onyxData: []
            message: Internal error
    RateLimited:
      description: >
        Per-user rate limit exceeded on the `/v1/users/*` read surface
        (100 requests / 60 s / uid, approximate per instance). Carries a
        `Retry-After` header (seconds).
      headers:
        Retry-After:
          description: Seconds until the caller's window resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OnyxFailure"
          example:
            jsonCode: 429
            onyxData: []
            message: Too many requests. Please slow down and try again.
    AdminForbidden:
      description: "The token is valid but does not carry the `admin: true` custom claim."
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error:
                type: string
          example:
            error: "Forbidden: Admin privileges required"
    AdminBadRequest:
      description: Failed input validation (admin error body).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AdminError"
    AdminInternalError:
      description: Unexpected server error (admin error body, generic message).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AdminError"
          example:
            error: Internal error
    UnsubscribeRedirect:
      description: >
        Redirect to the kiroku.cz unsubscribe result page —
        `/<locale>/unsubscribed/` on success, `/<locale>/unsubscribed/?error=invalid`
        on a missing/forged token or a failed write.
      headers:
        Location:
          description: The locale-appropriate result page on the Kiroku marketing site.
          schema:
            type: string
            examples:
              - https://kiroku.cz/en/unsubscribed/
              - https://kiroku.cz/cs/unsubscribed/?error=invalid

  schemas:
    OnyxUpdate:
      type: object
      description: >
        One update the client applies to its local Onyx store via
        `Onyx.update`. In a `merge`, a nested `null` value **deletes** that
        child (Onyx semantics) — the API uses this for un-friending, evictions,
        and clearing requests rather than replacing whole subtrees.
      required: [onyxMethod, key]
      properties:
        onyxMethod:
          type: string
          enum: [merge, set, mergecollection, clear]
        key:
          type: string
          description: >
            The Onyx key (e.g. `userDataList`, `preferences`,
            `cachedDrinkingSessions`, or a collection member key like
            `sessionLocations_<sessionId>`).
        value:
          description: The value to apply — any JSON value, including `null` (delete on merge).

    OnyxResponse:
      type: object
      description: |
        The unified response envelope every business endpoint returns.

        - `jsonCode: 200` — success; `onyxData` carries the updates (possibly
          empty for a no-op success).
        - **`jsonCode: 407` — the Firebase ID token is expired.** This arrives
          with HTTP status **200** (a non-2xx would be treated as a network
          error before the body is inspected). The client must refresh its ID
          token and replay the request. `onyxData` is empty.
        - Other non-200 `jsonCode`s mirror the HTTP status of a failure
          response (see `OnyxFailure`).

        `lastUpdateID`/`previousUpdateID` stamp realtime-sequenced responses
        for gap detection; a backfill response that *omits* `lastUpdateID`
        despite an empty `onyxData` is the re-baseline signal (see
        `GET /v1/updates`).
      required: [jsonCode, onyxData]
      properties:
        jsonCode:
          type: integer
          description: "`200` success · `407` expired token (inside HTTP 200) · otherwise mirrors the failure HTTP status."
        onyxData:
          type: array
          items:
            $ref: "#/components/schemas/OnyxUpdate"
        lastUpdateID:
          type: integer
          description: The caller's update-sequence position after this response (when sequenced).
        previousUpdateID:
          type: integer
          description: The sequence position this response builds on (gap detection).
        requestID:
          type: string
      examples:
        - jsonCode: 200
          onyxData:
            - onyxMethod: merge
              key: userDataList
              value: { uid-abc: { friend_requests: { uid-xyz: sent } } }
          lastUpdateID: 42
          previousUpdateID: 41
        - jsonCode: 407
          onyxData: []

    OnyxFailure:
      type: object
      description: >
        Failure envelope for business endpoints: `jsonCode` mirrors the HTTP
        status, `onyxData` is empty, `message` is human-readable.
      required: [jsonCode, onyxData]
      properties:
        jsonCode:
          type: integer
        onyxData:
          type: array
          items:
            $ref: "#/components/schemas/OnyxUpdate"
          description: Always empty on failure.
        message:
          type: string

    AdminError:
      type: object
      description: >
        Error body for `/v1/admin/*` routes. `code` is the stable identifier
        client code branches on (`USER_NOT_FOUND`, `INVALID_EMAIL`,
        `INVALID_MAINTENANCE_WINDOW`, `MAINTENANCE_CONFIG_NOT_FOUND`); it is
        present for typed admin-core errors and absent for generic ones.
      required: [error]
      properties:
        error:
          type: string
        code:
          type: string

    PusherSocketID:
      type: string
      description: >
        Optional — the caller's Pusher socket id. When supplied on a write, the
        realtime broadcast to the caller's own channel excludes this socket so
        the sender never receives an echo of its own optimistic update (it gets
        the change inline in this response instead).

    OtherUserBody:
      type: object
      required: [otherUserId]
      properties:
        otherUserId:
          type: string
          description: The counterpart user's uid. Must not be the caller.
        pusherSocketID:
          $ref: "#/components/schemas/PusherSocketID"

    DrinkingSession:
      type: object
      description: |
        A drinking session. The server treats it as **opaque single-user
        data** — the only field it reads is `start_time` (to maintain the
        earliest-session floor); everything else (drinks, note, blackout,
        timezone, type, ongoing, …) is written through verbatim, matching the
        app's `DrinkingSession` type. Bounded at the edge: at most 50 top-level
        fields, 64 KiB serialized, and 8 levels of nesting (the whole request
        body is capped at 128 KiB).
      required: [start_time]
      properties:
        start_time:
          type: number
          description: Session start, epoch ms. Must be a finite number.
        ongoing:
          type: boolean
          description: True while the session is still being recorded live.
      additionalProperties: true
      maxProperties: 50

    DrinkLocation:
      type: object
      description: One GPS fix (the app's `DrinkLocation` type). All numbers must be finite.
      required: [latitude, longitude, capturedAt]
      properties:
        latitude:
          type: number
        longitude:
          type: number
        capturedAt:
          type: number
          description: Capture time, epoch ms.
        accuracy:
          type: number
          description: Optional reported accuracy in meters.
      additionalProperties: false

    ImageKind:
      type: string
      enum: [avatar, session]
      description: >
        `avatar` — public-read profile photo. `session` — private per-session
        image (reserved scaffolding; the consumption side has not shipped).

    HexColor:
      type: string
      description: A `#RGB` or `#RRGGBB` hex color string (case-insensitive).
      pattern: "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
      example: "#1A2B3C"

    SessionColorPalette:
      type: object
      description: >
        A session color palette. All five band keys (`green`, `yellow`,
        `orange`, `red`, `black`) are required and each value must be a valid
        `HexColor`. A palette that is missing a band or carries a non-hex
        value is skipped — never persisted.
      required: [green, yellow, orange, red, black]
      properties:
        green: { $ref: "#/components/schemas/HexColor" }
        yellow: { $ref: "#/components/schemas/HexColor" }
        orange: { $ref: "#/components/schemas/HexColor" }
        red: { $ref: "#/components/schemas/HexColor" }
        black: { $ref: "#/components/schemas/HexColor" }

    PreferencesPatch:
      type: object
      description: >
        Partial preferences update. Every field is optional, but at least one
        recognized, correctly-typed field must be present (`400` otherwise);
        unrecognized or mistyped fields are silently ignored. Object-valued
        fields replace the stored object for that key. The two
        `session_color_palette` / `custom_session_color_palette` fields are
        additionally shape-validated (see `SessionColorPalette`) and skipped
        when malformed.
      minProperties: 1
      properties:
        theme:
          type: string
          description: "`light` | `dark` | `system` — also mirrored to the dedicated `preferredTheme` Onyx key."
        locale:
          type: string
          description: Preferred locale — also mirrored to `nvp_preferredLocale`.
        first_day_of_week:
          type: string
        units_to_colors:
          type: object
          additionalProperties: true
        drinks_to_units:
          type: object
          additionalProperties: true
        session_color_palette:
          $ref: "#/components/schemas/SessionColorPalette"
          description: The user's active session color palette.
        custom_session_color_palette:
          $ref: "#/components/schemas/SessionColorPalette"
          description: The user's saved custom palette slot, sent alongside the active one.
        use_own_palette_for_others:
          type: boolean
        track_location_during_sessions:
          type: boolean
        crash_reporting_enabled:
          type: boolean
        location_prompt_seen:
          type: boolean
        bac_display_unit:
          type: string
        bac_intro_seen:
          type: boolean
        bac_time_format:
          type: string
        timezone:
          type: object
          description: Stored under the user node (not `user_preferences`) and mirrored to `userDataList[uid].timezone`.
          additionalProperties: true
      additionalProperties: true

    FeedbackBody:
      type: object
      required: [text]
      properties:
        text:
          type: string
          minLength: 1

    FeedbackItem:
      type: object
      required: [submit_time, text, user_id]
      properties:
        submit_time:
          type: number
          description: Server-stamped submit time, epoch ms.
        text:
          type: string
        user_id:
          type: string
          description: Uid of the submitting user.

    FeedbackList:
      type: object
      description: The whole collection keyed by chronological push id; `{}` when empty.
      additionalProperties:
        $ref: "#/components/schemas/FeedbackItem"

    AdminUserResult:
      type: object
      description: Result of a user-targeted admin operation.
      required: [uid, applied]
      properties:
        uid:
          type: string
          description: UID of the resolved user.
        email:
          type: string
          description: Email of the resolved user, if Firebase has one on file.
        applied:
          type: boolean
          description: Whether the change was applied (false for dry runs).
