openapi: "3.1.0"
info:
  title: plurnk
  version: "0.1.0"
  summary: Custodial-Monero control plane for plurnk's optional hosted LLM inference.
  description: |
    The REST/JSON contract for plurnk.web — a Tor-first, no-JS-first commerce
    and account control plane for Plurnk's hosted inference service. HTML forms,
    this OpenAPI surface, and the MCP 2026-07-28 server at `/mcp` are peer
    adapters over one shared capability layer; none proxies through another.
    `/v1` remains the separately purchased, OpenAI-compatible inference usage
    API, and metered search remains under `/api/v1/search/*`.

    ## Getting started
    A REST client, end to end:

    - For an agentic purchase, create or sign into an account in the browser.
      Password, TOTP, recovery, and authorization consent never enter MCP
      elicitation or tool arguments. The existing JSON auth endpoints remain
      available to deliberate REST consumers.
    - Discover OAuth metadata, register a public client, and complete an
      authorization-code flow with S256 PKCE for resource
      `https://plurnk.ai/api/v1` and scope `plurnk:control`.
    - Read `GET /api/v1/account`, create a deposit intent, and manage usage keys
      with the returned `pct_` control token.
    - A newly created plaintext `pk_<64-hex>` usage key is returned exactly
      once. Store it on the host that will call `/v1` or metered `/search`.
    - Fund: see "The money flow". Deposits are prepaid, spend-only credits.
    - Inference: the hosted service meters `/v1` usage against that `pk_` key and
      debits the account balance.

    ## Auth
    Three credentials have deliberately disjoint authority:

    - `Cookie: pk.sid` — same-origin browser session. It drives forms and is the
      only credential accepted by password, TOTP, recovery-code, session,
      security-activity, account-deletion, and operator surfaces.
    - `Authorization: Bearer pct_<opaque>` — one-hour OAuth 2.1 control token,
      bound to either the `/api/v1` or `/mcp` resource and scope
      `plurnk:control`. It can read account commerce state and manage `pk_`
      usage keys. A token for one resource is rejected by the other.
    - `Authorization: Bearer pk_<64-hex>` — usage key accepted only by `/v1`
      inference and metered `/api/v1/search/*`. It cannot read or mutate the
      account, mint/revoke keys, authorize MCP, or subscribe to account events.

    ## Key lifecycle
    A `pk_` key has no account-control scopes. It carries usage authority only:
    inference plus the independently toggleable Search capability. There is no
    way to promote it into a session or OAuth principal.

    The controls are lifecycle, not privilege:

    - **Expiry** — optional `expires_in_days` makes the key stop authenticating
      after that window.
    - **Disable** — a key can be disabled (a kill-switch short of deletion) and
      re-enabled; a disabled key doesn't authenticate at all.
    - **Revoke** — `DELETE /api/v1/auth/keys/{id}` retires it for good.

    Sensitive account mutations require a browser session and their documented
    step-up proof. OAuth control tokens and usage keys are categorically refused.

    ## The money flow
    Custodial piconero, double-entry ledger. Amounts are strings of piconero
    (1 XMR = 1e12 piconero) to stay exact past 2^53.

    - Deposit: `POST /api/v1/account/deposit-intent` leases a Monero subaddress
      and can include an exact optional `amount_xmr`; its response carries the
      canonical `monero:` payment URI. The service cannot spend the caller's
      external wallet. The scanner credits an observed payment after confirmations
      (pending → credited → settled).
    - Spend: the hosted service meters inference per key and debits your
      balance (priced in piconero) — there is no public spend call; usage is
      reported by the plurnk endpoint and lands in the ledger.
    - No withdrawals: deposits are final, prepaid, spend-only credits (Terms
      §6). The hot wallet is watch-only — no spend key exists on this host, so
      no call can move funds out.

    ## Event stream
    Connect a WebSocket to `/ws` with either a same-origin browser session or an
    `/api/v1`-bound `pct_` control token in the `Authorization` header. Query
    credentials and `pk_` usage keys never authorize private events. Every
    connection gets a `{ "type": "hello" }` greeting; an authenticated one
    then receives its OWN account's events as
    `{ type, at, data }` JSON messages (amounts are decimal piconero strings):

    - `deposit_credited` — a chain deposit landed (data: amount_piconero,
      target_kind/target_id, source_ref, block_height).
    - `deposit_tier` — a deposit crossed a confirmation tier (data adds
      `tier`: `credited` = spendable, `settled` = fully confirmed).
    - `inference_debit` — metered inference spend debited the balance (data:
      amount_piconero, source_ref — the usage report's id).

    The `type` vocabulary is append-only, like `error_key` — tolerate unknown
    types. Delivery is pull-only and best-effort notification (a missed event
    changes nothing in the ledger; reconcile with the REST reads). There are
    no outbound webhooks — the platform makes no outbound product requests.

    ## CORS
    `/api/*`, OAuth discovery/token/registration, protected-resource metadata,
    `/mcp`, and this spec's own URLs answer any origin where the protocol needs
    it. Never credentials mode: session cookies stay same-origin; cross-origin
    control uses OAuth bearer tokens. HTML pages remain outside the embedding
    surface.

    ## Versioning
    Within `/api/v1`, changes are additive only — new endpoints, new optional
    fields, new values on the append-only vocabularies (`error_key`, the event
    `type`s). Build clients that tolerate unknown fields and unknown enum
    values. Anything removed or reshaped ships as `/api/v2` served alongside
    `/v1` for an announced overlap window; retired identifiers are never
    reused.

    ## Moderation
    Operator bans come in tiers (`ban_type`): `timed` (locked out until expiry,
    content stays), `permanent` (locked out + content hidden from public reads),
    `erasure` (irreversible removal, == self-deletion). A ban revokes the user's
    sessions + keys immediately.

    ## Content negotiation
    `/signup`, `/signin`, `/signin/recover`, `/signout` are content-negotiated:

    - `Content-Type: application/json` → JSON response
    - urlencoded form post → 303 redirect (success → destination; failure → back
      to the form with `?e=ERROR_KEY`)

    ## Error shape
    Every JSON error is `{ error_key: string, fields?: string[] }`. The
    `error_key` is a stable machine-readable identifier (the full vocabulary is
    enumerated on the ErrorResponse schema below); the locale files provide human
    text. `fields` names the inputs at fault on validation errors.

    ## Rate limiting
    Report filing is per-IP-bucket rate-limited; when tripped, `429` with
    `{ error_key: "error_rate_limited" }` and a `Retry-After` header. IPs are
    hashed with a daily-rotating salt (SPEC §2) — no raw IP is persisted.
  contact:
    name: plurnk
    url: https://plurnk.ai
  license:
    name: AGPL-3.0-or-later

servers:
  - url: https://plurnk.ai
    description: Production
  - url: https://dev.plurnk.ai
    description: Development
  - url: https://test.plurnk.ai
    description: Test
  - url: http://plurnksnr2kihuukt6v22ko72r34dxeatbsfhgow3hvnlw6btanxphad.onion
    description: Production onion
  - url: http://127.0.0.1:13081
    description: Local prod build
  - url: http://127.0.0.1:13082
    description: Local dev (Vite middleware)

tags:
  - name: auth
    description: Account creation, signin, signout, recovery codes
  - name: account
    description: The signed-in user's own surfaces — balance, keys, ledger
  - name: operator
    description: Operator-only surfaces (single-operator deployment)
  - name: health
    description: Liveness probe

paths:

  # ───────────── Auth ─────────────

  /api/v1/auth/signup:
    post:
      tags: [ auth ]
      summary: Create a new account
      description: |
        Creates the user, issues 8 single-use recovery codes (returned in
        the JSON path; flashed to session and retrieved separately in the
        form path), signs the new user in immediately.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignupRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/SignupRequest" }
        required: true
      responses:
        "200":
          description: |
            Account created. JSON path returns the 8 single-use recovery
            codes inline so a scripted signup is a single round-trip;
            the same flash is consumed by the form path via the
            redirect → /signup/codes → /api/v1/auth/codes chain.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignupSuccessResponse" }
        "303":
          description: Account created (form path) → redirect to /signup/codes
        "400":
          description: Missing fields, mismatched passwords, or invalid username
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Username taken (or reserved)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/signin:
    post:
      tags: [ auth ]
      summary: Sign in to an existing account
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SigninRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/SigninRequest" }
        required: true
      responses:
        "200":
          description: Signed in (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Signed in (form path) → redirect to /account
        "400":
          description: Missing fields
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Wrong username or password
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403":
          description: Account banned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/recover:
    post:
      tags: [ auth ]
      summary: Recover account using a single-use recovery code
      description: |
        Consumes one of the 8 recovery codes issued at signup; sets a new
        password and signs the user in. Code is single-use; remaining codes
        stay valid.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RecoverRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/RecoverRequest" }
        required: true
      responses:
        "200":
          description: Recovered + signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Form-path success → redirect to /account
        "400":
          description: Missing fields or mismatched passwords
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Wrong username or code
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403":
          description: Account banned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/signin/totp:
    post:
      tags: [ auth ]
      summary: Complete signin with a TOTP code
      description: |
        Second step of a two-factor signin. POST /signin sets a
        partial session (`pendingTotpUserId`) when the user has TOTP
        enrolled; this endpoint takes the 6-digit code from the
        authenticator and promotes the partial session to a fully
        signed-in one. Recovery codes bypass this — `/signin/recover`
        clears `totp_secret` on success, so a user who lost their
        authenticator escapes via the recovery flow rather than this
        one.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SigninTotpRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/SigninTotpRequest" }
        required: true
      responses:
        "200":
          description: Signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Signed in (form path) → 303 to /account
        "400":
          description: Code missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: No pending TOTP signin, or code invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403":
          description: Account banned
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/signout:
    post:
      tags: [ auth ]
      summary: Destroy the current session
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Signed out (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Signed out (form path) → redirect to /

  /api/v1/auth/codes:
    get:
      tags: [ auth ]
      summary: Retrieve the post-signup recovery codes (one-shot flash)
      description: |
        Returns the 8 recovery codes generated at signup, then clears them
        from the session. Subsequent calls return 404. Only useful in the
        narrow window between a successful `/signup` and the user closing
        the tab.
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Codes available
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CodesResponse" }
        "404":
          description: No codes in the session flash (already retrieved, or never signed
            up in this session)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys:
    post:
      tags: [ auth ]
      summary: Issue an inference/search usage key
      description: |
        Generates a `pk_<64-hex>` usage key bound to the authenticated user.
        The plaintext key is returned exactly once — in this response
        (JSON path), or in the one-shot flash on the keys page (form
        path). Only its sha256 hash is persisted. Pass the key only to
        `/v1` inference or metered `/api/v1/search/*`; it is categorically
        rejected as account-control or MCP authentication.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KeyCreateRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/KeyCreateFormRequest" }
      responses:
        "201":
          description: Token issued (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/KeyCreateResponse" }
        "303":
          description: >
            Form path → "/account/keys" — success renders the
            plaintext exactly once via a session flash; failure carries
            `?e=error_key_label_required` / `?e=error_key_expiry_invalid`.
        "400":
          description: Missing label or invalid expiry (error_key_label_required /
            error_key_expiry_invalid)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
    get:
      tags: [ auth ]
      summary: List the authenticated user's active keys
      description: |
        Lists active (un-revoked) keys with metadata only — the
        plaintext key is never re-derivable. Use `DELETE` to revoke.
        Paginated, newest first, 25 per page.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - {
            name: page,
            in: query,
            required: false,
            schema: { type: string, pattern: "^[1-9][0-9]*$" }
          }
      responses:
        "200":
          description: A page of keys
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenListResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys/{id}:
    delete:
      tags: [ auth ]
      summary: Revoke a usage key
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      responses:
        "200":
          description: Token revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active key with that id belongs to the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys/{id}/delete:
    post:
      tags: [ auth ]
      summary: Revoke a usage key (no-JS form alias of DELETE)
      description: |
        Same effect as `DELETE /api/v1/auth/keys/{id}` — exposed as a
        POST so the no-JS keys page can revoke from a native form.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Token revoked (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Form path → "/account/keys" (failure carries
            ?e=error_not_found)
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active key with that id belongs to the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys/{id}/disable:
    post:
      tags: [ auth ]
      summary: Disable your own usage key (user axis)
      description: |
        Sets the USER disable axis on the caller's own key. A key is live only
        when all three axes (user/system/operator) are enabled, so this disables
        but cannot override a system/operator disable. Reversible via /enable.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Token disabled (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Form path → "/account/keys"
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active key with that id belongs to the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys/{id}/enable:
    post:
      tags: [ auth ]
      summary: Re-enable your own usage key (user axis)
      description: |
        Clears the USER disable axis on the caller's own key. The key becomes
        live only if the system and operator axes are also enabled.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Token enabled (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Form path → "/account/keys"
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active key with that id belongs to the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys/{id}/search/enable:
    post:
      tags: [ auth ]
      summary: Allow web search for your own key (Search capability)
      description: |
        Sets the Search capability flag on the caller's own key, permitting it
        to spend on the metered web-search path. Orthogonal to the disable axes
        and the money path - it never affects authentication or inference spend.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Search allowed (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Form path -> "/account/keys"
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active key with that id belongs to the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/auth/keys/{id}/search/disable:
    post:
      tags: [ auth ]
      summary: Disallow web search for your own key (Search capability)
      description: |
        Clears the Search capability flag on the caller's own key. The key keeps
        inference usage authority; it just can't reach the metered search path.
        Reversible via /search/enable.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Search disallowed (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Form path -> "/account/keys"
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active key with that id belongs to the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/search/authorize:
    post:
      tags: [ auth ]
      summary: Authorize-and-spend for one web search (metered)
      description: |
        A search-side filter pings this once per search with the user's usage key.
        Verifies the key is live and Search-enabled, then debits the
        endpoint-published search price from spendable balance (atomic, idempotent
        on request_id). Off (503) when the search rate lane is offline.
      security:
        - usageKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ request_id ]
              properties:
                request_id: { type: string }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [ request_id ]
              properties:
                request_id: { type: string }
      responses:
        "200":
          description: Authorized and charged (or an idempotent replay)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  charge_id: { type: integer }
                  amount_piconero: { type: string }
                  replayed: { type: boolean }
        "400":
          description: Missing request_id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Invalid or inactive key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "402":
          description: Insufficient spendable balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403":
          description: Key lacks the Search capability
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: Search rate lane offline
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/search/void:
    post:
      tags: [ auth ]
      summary: Reverse a search charge the endpoint couldn't fulfil
      description: |
        Called when a charged search fails downstream (results undeliverable).
        Idempotently reverses the charge — credits the customer, debits the
        operator — scoped to the charge's owner. A re-void is a no-op (replayed).
      security:
        - usageKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ request_id ]
              properties:
                request_id: { type: string }
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [ request_id ]
              properties:
                request_id: { type: string }
      responses:
        "200":
          description: Reversed (or an idempotent replay)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  voided_piconero: { type: string }
                  replayed: { type: boolean }
        "400":
          description: Missing request_id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Invalid or inactive key
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No matching charge for this account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/operator/keys/{id}/disable:
    post:
      tags: [ operator ]
      summary: Operator-disable any user's usage key (operator axis)
      description: |
        Sets the OPERATOR disable axis on any user's key (abuse intervention).
        Gated by operator session; non-operators get 404.
      security:
        - cookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Token operator-disabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "404":
          description: Not an operator, or no active key with that id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/operator/keys/{id}/enable:
    post:
      tags: [ operator ]
      summary: Operator-enable any user's usage key (operator axis)
      description: |
        Clears the OPERATOR disable axis on any user's key. Gated by operator
        session; non-operators get 404.
      security:
        - cookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, minimum: 1 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Token operator-enabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "404":
          description: Not an operator, or no active key with that id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ───────────── Account ─────────────

  /api/v1/account:
    get:
      tags: [ account ]
      summary: The signed-in user's identity
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      responses:
        "200":
          description: The user's public handle
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/deposit:
    get:
      tags: [ account ]
      summary: The current deposit surface — the ACTIVE lease (a pure read)
      description: |
        Reads the active deposit lease (or a legacy permanent address on
        pre-lease accounts). Never allocates — POST /api/v1/account/deposit-intent
        to lease an address. 404 when nothing is live.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - in: query
          name: amount_xmr
          required: false
          schema: { $ref: "#/components/schemas/XmrAmount" }
          description: Optional exact amount to encode in the returned Monero payment URI.
      responses:
        "200":
          description: The active lease (expires_at/reserved_until null on a legacy address)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositAddressResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No active lease — POST /api/v1/account/deposit-intent first
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/ledger:
    get:
      tags: [ account ]
      summary: The signed-in user's ledger entries (account statement)
      description: |
        Paginated, newest first. Each entry's `amount_piconero` is signed —
        debits (inference spend) negative, credits (deposits)
        positive. `tier` is the confirmation status computed on
        read against the chain height (CRYPTO §5): chain deposits climb
        pending → credited → settled; internal moves are `final`; null when the
        daemon is unreachable. Operator fee rows are not the user's and never
        appear.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - {
            in: query,
            name: page,
            required: false,
            schema: { type: string, pattern: "^[1-9][0-9]*$" }
          }
        # Filter/sort — all optional, lenient strings: the server whitelists them
        # (Account #ledgerFilters), so an unknown value falls back to a safe
        # default rather than erroring. type = one entry_type or absent (All);
        # key = a key id (ownership enforced by the query — a foreign id yields
        # empty, never a leak); from/to = YYYY-MM-DD; sort = created|amount;
        # dir = desc|asc.
        - { in: query, name: type, required: false, schema: { type: string } }
        - { in: query, name: key,  required: false, schema: { type: string } }
        - { in: query, name: from, required: false, schema: { type: string } }
        - { in: query, name: to,   required: false, schema: { type: string } }
        - { in: query, name: sort, required: false, schema: { type: string } }
        - { in: query, name: dir,  required: false, schema: { type: string } }
      responses:
        "200":
          description: A page of ledger entries
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LedgerResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/deposit-intent:
    post:
      tags: [ account ]
      summary: Lease a deposit address (active ~1 hour, then retired)
      description: |
        Deposit addresses are LEASES, not permanent identities. The response's
        address is yours until `expires_at` (~1 hour) — pay within the window.
        A deposit broadcast inside the window is credited to you even if it
        mines later (a quiet reserve outlasts the mempool's lifetime). Do NOT
        save or reuse the address: after the reserve it recycles, and a payment
        to a stale address lands in an operator queue instead of your balance.
        Calling again inside the active window returns the SAME lease. An
        optional exact XMR amount changes only the returned payment URI; this
        call never spends an external wallet.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DepositIntentRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/DepositIntentFormRequest" }
      responses:
        "200":
          description: The active lease
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DepositAddressResponse" }
        "303":
          description: Form path redirects to `/account`, preserving a normalized non-secret amount in the query when present.
        "400":
          description: amount_xmr is not positive exact XMR with at most 12 decimal places
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: No address can be issued right now (pool empty + wallet unreachable) — try again shortly
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/ledger/summary:
    get:
      tags: [ account ]
      summary: Exact signed pɱ nets grouped by key, day, or entry type
      description: |
        The reporting engine's summary: signed piconero nets (credits positive,
        debits negative — an inference settle refund nets against its reserve)
        grouped by `group` = `key` | `day` | `type` (default `type`), over the
        same filter set as the statement (`type`, `key`, `from`, `to`). `entries`
        counts collapsed statement lines — turns, not raw double-entry rows.
        "How much did Key X spend last week?" is
        `?group=key&key=X&from=...&to=...` — one call, exact to the piconero.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - { in: query, name: group, required: false, schema: { type: string } }
        - { in: query, name: type,  required: false, schema: { type: string } }
        - { in: query, name: key,   required: false, schema: { type: string } }
        - { in: query, name: from,  required: false, schema: { type: string } }
        - { in: query, name: to,    required: false, schema: { type: string } }
      responses:
        "200":
          description: The grouped summary
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LedgerSummaryResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/turns/{holdId}:
    get:
      tags: [ account ]
      summary: Analyze one metered turn — the charge record + the turn content (§spec/inference-link)
      description: >
        The Analyze view behind every inference line on the statement (its
        `hold_id`). `hold` is web's OWN ledger record for the turn — the
        authoritative money facts; the endpoint's copy of the charge is never
        served. `turn` is the content (messages in, emission out, usage),
        fetched on view from the endpoint over the private bank link; it is
        null when that link is down or the turn has aged out of the endpoint's
        ~30-day hot content window — the charge facts above it are permanent.
        Ownership is an existence-leak 404: another user's hold id reads as
        not-found.
      security:
        - cookieAuth: []
        - controlTokenAuth: [ plurnk:control ]
      parameters:
        - { in: path, name: holdId, required: true, schema: { type: string, pattern: "^[1-9][0-9]*$" } }
      responses:
        "200":
          description: The turn view
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TurnViewResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Unknown hold / not yours
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/password:
    post:
      tags: [ account ]
      summary: Change the signed-in user's password
      description: |
        Requires current-password reauth + new password + confirmation.
        On success, every other session for this user is terminated;
        the caller's own session is preserved so they aren't signed
        out from the device they're rotating from. Usage keys are
        NOT automatically revoked — they're managed independently via
        `/api/v1/auth/keys`.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChangePasswordRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/ChangePasswordRequest" }
      responses:
        "200":
          description: Password changed (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Password changed (form path) → redirect to /account/security
        "400":
          description: Missing or mismatched fields
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in, or current-password reauth failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          {
            description: "Reauth throttled — too many password attempts for this account
              (Retry-After set; SECURITY §6)",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/sessions:
    get:
      tags: [ account ]
      summary: List active sessions for the signed-in user
      description: |
        Returns one entry per non-expired session bound to this user.
        Each carries a short `fingerprint` (sha256 of the sid, truncated)
        for display and as the revoke target. The full sid is never
        exposed to the client. `current: true` marks the session this
        request rode in on.
      security:
        - cookieAuth: []
      responses:
        "200":
          description: Session list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionListResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/sessions/revoke-others:
    post:
      tags: [ account ]
      summary: Sign out everywhere else (revoke all sessions except the caller's)
      description: |
        Terminates every session for this user EXCEPT the caller's
        own — they aren't signed out from the device they're managing
        their security on.
      security:
        - cookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Others revoked (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Others revoked (form path) → 303 to "/account/security/sessions"
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/sessions/{fingerprint}/delete:
    post:
      tags: [ account ]
      summary: Revoke a session by fingerprint (no-JS form alias of DELETE)
      description: |
        Same effect as `DELETE /api/v1/account/sessions/{fingerprint}` —
        exposed as a POST so the no-JS sessions page can revoke from a
        native form. Revoking the caller's own current session destroys
        the cookie and (form path) redirects to "/".
      security:
        - cookieAuth: []
      parameters:
        - in: path
          name: fingerprint
          required: true
          schema: { type: string, minLength: 1, maxLength: 32 }
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object }
          application/x-www-form-urlencoded:
            schema: { type: object }
      responses:
        "200":
          description: Revoked (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionRevokeResponse" }
        "303":
          description: Revoked (form path) → 303 to "/account/security/sessions" (or "/"
            on self-revoke)
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No matching session for the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/sessions/{fingerprint}:
    delete:
      tags: [ account ]
      summary: Revoke a specific session by fingerprint
      description: |
        Deletes the session identified by `fingerprint`. The user_id
        guard on the server-side query prevents cross-user revocation.
        If the user revokes their own current session, the response
        carries `self: true` and the session cookie is destroyed in
        the same response — the page redirects to `/signin`.
      security:
        - cookieAuth: []
      parameters:
        - in: path
          name: fingerprint
          required: true
          schema: { type: string, minLength: 1, maxLength: 32 }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SessionRevokeResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: No matching session for the caller
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/activity:
    get:
      tags: [ account ]
      summary: User-facing security activity log (paginated, newest first)
      description: |
        Lists auth-lifecycle events on this account so the user can
        notice unauthorized changes. Each event has a stable
        `event_type` enum, a UTC timestamp, a transport
        ("clearnet" | "onion"), and an optional `detail` blob with
        event-specific JSON (e.g. key name on `key_issued`).
        Per SPEC §2 no raw IP or User-Agent is ever logged.
        Paginated, newest first, 25 per page.
      security:
        - cookieAuth: []
      parameters:
        - {
            name: page,
            in: query,
            required: false,
            schema: { type: string, pattern: "^[1-9][0-9]*$" }
          }
      responses:
        "200":
          description: A page of the activity log
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthActivityResponse" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/delete:
    post:
      tags: [ account ]
      summary: Delete the signed-in account
      description: |
        Deletes the signed-in account after password reauth. Any remaining
        balance is FORFEITED to the house per the Terms (an adjustment pair —
        the response reports the exact forfeited_piconero; CRYPTO §9.5).
        Deletion is a tombstone (SPEC §3): the public identity is severed and
        the user's content is removed from the live site, while the rows stay
        intact internally. Sessions, OAuth control credentials, usage keys,
        and recovery codes are dropped and the live session is destroyed in
        the same response.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeleteAccountRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/DeleteAccountRequest" }
      responses:
        "200":
          description: Account deleted (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Account deleted (form path) → 303 to "/"
        "400":
          description: Password missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in, or password reauth failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          {
            description: "Reauth throttled — too many password attempts for this account
              (Retry-After set; SECURITY §6)",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/totp/enroll:
    post:
      tags: [ account ]
      summary: Begin TOTP enrollment (step 1 of 2)
      description: |
        Generates a fresh base32 secret and returns it along with the
        `otpauth://` URI an authenticator app reads. The secret is
        stashed in the session as `pendingTotpSecret` — it is NOT
        written to the user row until the follow-up confirm call
        succeeds, so an abandoned enrollment never strands the user.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TotpEnrollRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/TotpEnrollRequest" }
      responses:
        "200":
          description: Enrollment started (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TotpEnrollResponse" }
        "303":
          description: Enrollment started (form path) → 303 to "/account/security"
            (pending state renders the secret)
        "400":
          description: Password missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in, or password reauth failed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          {
            description: "Reauth throttled — too many password attempts for this account
              (Retry-After set; SECURITY §6)",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          description: Already enrolled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/totp/confirm:
    post:
      tags: [ account ]
      summary: Confirm TOTP enrollment (step 2 of 2)
      description: |
        Verifies the code generated by the user's authenticator against
        the pending session secret. On success, promotes the pending
        secret to `users.totp_secret` and clears the pending slot.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TotpCodeRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/TotpCodeRequest" }
      responses:
        "200":
          description: Enrollment confirmed (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Enrollment confirmed (form path) → 303 to "/account/security"
            (active state)
        "400":
          description: Code missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in, or code invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: No pending enrollment (run /enroll first)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/totp:
    delete:
      tags: [ account ]
      summary: Disable TOTP for the signed-in account
      description: |
        Requires BOTH password AND a current TOTP code. Disabling 2FA
        from an active session is the attacker's preferred next step;
        gating on the live authenticator ensures the attacker would
        need both the password and the device.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TotpDisableRequest" }
      responses:
        "200":
          description: Disabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "400":
          description: Password or code missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in, password reauth failed, or code invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          {
            description: "Reauth throttled — too many password attempts for this account
              (Retry-After set; SECURITY §6)",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          description: Not currently enrolled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/totp/disable:
    post:
      tags: [ account ]
      summary: Disable TOTP (no-JS form alias of DELETE /api/v1/account/totp)
      description: |
        Same effect and same dual-credential gate (password AND a current
        TOTP code) as `DELETE /api/v1/account/totp` — exposed as a POST so
        the no-JS security page can reach it from a native form. JSON
        clients should prefer the DELETE verb.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TotpDisableRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/TotpDisableRequest" }
      responses:
        "200":
          description: Disabled (JSON path)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "303":
          description: Disabled (form path) → 303 to "/account/security" (initial state)
        "400":
          description: Password or code missing
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Not signed in, password reauth failed, or code invalid
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          {
            description: "Reauth throttled — too many password attempts for this account
              (Retry-After set; SECURITY §6)",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          description: Not currently enrolled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/rates:
    get:
      tags: [ health ]
      summary: The live pricing table as a plain GET (§spec/event-stream)
      description: >
        The same snapshot the /ws `rates` frame pushes — one entry per SERVICE
        (endpoint configuration) with the four token-category prices in exact
        piconero, plus marketplace metrics (context_tokens, tq, conformance_pct, latency_p50/p95_ms)
        when the endpoint has measured them. Agents poll this; browsers get the
        push twin. Fail-closed: 503 when no live snapshot exists — never a
        stale or fabricated price.
      responses:
        "200":
          description: The live snapshot
          content:
            application/json:
              schema:
                type: object
                required: [ xmr_usd, services, fetched_at ]
                properties:
                  xmr_usd: { type: string }
                  fetched_at: { type: string }
                  services:
                    type: array
                    items:
                      type: object
                      required: [ name, input_piconero, cached_piconero, reasoning_piconero, output_piconero ]
                      properties:
                        name: { type: string }
                        context_tokens: { type: integer }
                        tq: { type: number }
                        conformance_pct: { type: number }
                        latency_p50_ms: { type: number }
                        latency_p95_ms: { type: number }
                        input_piconero: { type: string, pattern: "^[0-9]+$" }
                        cached_piconero: { type: string, pattern: "^[0-9]+$" }
                        reasoning_piconero: { type: string, pattern: "^[0-9]+$" }
                        output_piconero: { type: string, pattern: "^[0-9]+$" }
        "503":
          description: No live snapshot (endpoint link down or no valid rates yet)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/health:
    get:
      tags: [ health ]
      summary: Public liveness probe — server + db only
      responses:
        "200":
          description: Liveness. Operational detail (wallet/daemon/pool/read-only state)
            is operator-only — see /api/v1/account/operator/health.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthResponse" }

  /api/v1/account/operator/health:
    get:
      tags: [ health ]
      summary: Operator-only operational detail (wallet, daemon, pool depth, read-only
        state)
      security:
        - cookieAuth: []
      description: Behind operator auth. Carries internal state deliberately kept off
        the public probe (e.g. pool depth an attacker might try to drain).
        Non-operators get 404 (existence-leak posture).
      responses:
        "200":
          description: Full subsystem detail.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OperatorHealthResponse" }
        "404":
          description: Not an operator (existence-leak posture).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  # ───────────── Support ─────────────
  /api/v1/reports:
    post:
      tags: [ reports ]
      summary: Report a user account (anonymous-capable; rate-limited)
      description: |
        Files a conduct report against a user account (SPEC §7). The server
        resolves the subject, captures an immutable evidence snapshot, and records
        the reporter context (salted-IP bucket — never the raw IP — transport, and
        the account id when signed in). Per-bucket rate-limited. Reporter
        identity is never exposed to the reported party.
      security:
        - cookieAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReportRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/ReportRequest" }
      responses:
        "201":
          description: Report filed (JSON path) — returns the report id
          content:
            application/json:
              schema:
                type: object
                required: [ ok, report_id ]
                properties:
                  ok: { type: boolean }
                  report_id: { type: integer }
        "303": { description: "Form path: 303 back to the subject with ?reported=1" }
        "400":
          description: Invalid subject or category, or notes too long
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: The reported subject does not exist
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/v1/account/operator/reports:
    get:
      tags: [ operator ]
      summary: The content-report triage queue (operator)
      description: |
        Open reports (SPEC §7), priority-ordered — subjects with more open
        reports float to the top, ties break newest-first. Each row carries the
        repeat-count signal. Paginated.
      security:
        - cookieAuth: []
      parameters:
        - { in: query, name: page, required: false, schema: { type: string } }
      responses:
        "200":
          description: Open report queue
          content:
            application/json:
              schema: { type: object }
        "404":
          {
            description: Not an operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/reports/{id}:
    get:
      tags: [ operator ]
      summary: A single report with evidence + pattern stats (operator)
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: The report, its evidence snapshot, and subject-pattern stats
          content:
            application/json:
              schema: { type: object }
        "404":
          {
            description: Not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/reports/{id}/resolve:
    post:
      tags: [ operator ]
      summary: Resolve a report — record the decision, enforce the side-effect
        (operator)
      description: |
        Records the operator's triage decision on the report and applies its
        side-effect: `dismiss`/`warn` record only; `ban` bans the reported user.
        Enforcement runs before the close, so a self-ban or a decision
        that can't apply to the subject aborts without resolving. Idempotent —
        a second resolve 409s.
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResolveReportRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/ResolveReportRequest" }
      responses:
        "200":
          {
            description: Resolved,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /account/operator/reports" }
        "400":
          {
            description: Invalid decision / reason missing or too long,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "404":
          {
            description: Not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          {
            description: Already resolved / cannot ban self,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/support/tickets:
    post:
      tags: [ support ]
      summary: Submit a support ticket (anonymous-capable; rate-limited)
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SupportTicketRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/SupportTicketRequest" }
      responses:
        "200":
          {
            description: Submitted,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /contact?submitted=1" }
        "400":
          {
            description: Validation failed,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/support/tickets:
    get:
      tags: [ operator ]
      summary: List support tickets (operator)
      security:
        - cookieAuth: []
      parameters:
        - { in: query, name: status, required: false, schema: { type: string } }
        - { in: query, name: category, required: false, schema: { type: string } }
        - { in: query, name: page, required: false, schema: { type: string } }
      responses:
        "200":
          description: Ticket list
          content:
            application/json:
              schema: { type: object }
        "404":
          {
            description: Not an operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/support/tickets/{id}:
    get:
      tags: [ operator ]
      summary: A single support ticket (operator)
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: The ticket
          content:
            application/json:
              schema: { type: object }
        "404":
          {
            description: Not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/support/tickets/{id}/resolve:
    post:
      tags: [ operator ]
      summary: Resolve a support ticket (operator)
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResolveTicketRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/ResolveTicketRequest" }
      responses:
        "200":
          {
            description: Resolved,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /account/operator/tickets" }
        "400":
          {
            description: Notes missing/too long,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "404":
          {
            description: Not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          {
            description: Already resolved,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  # ───────────── Operator: moderation ─────────────
  /api/v1/account/operator/moderation/actions:
    get:
      tags: [ operator ]
      summary: The moderation audit log (operator)
      security:
        - cookieAuth: []
      parameters:
        - { in: query, name: target_kind, required: false, schema: { type: string } }
        - { in: query, name: target_id, required: false, schema: { type: string } }
        - { in: query, name: page, required: false, schema: { type: string } }
      responses:
        "200":
          description: Recent moderation actions
          content:
            application/json:
              schema: { type: object }
        "404":
          {
            description: Not an operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/moderate:
    post:
      tags: [ operator ]
      summary: No-JS moderation console dispatch (ban/unban by body)
      description: |
        One body-keyed endpoint behind the operator moderation console's no-JS
        forms. The REST routes put the target user in the path, which a native
        form can't fill from a typed input; this dispatches on `action`
        (ban/unban by `target_username`) to the same lookup, mutation, and audit
        the REST routes use. JSON clients should prefer the REST routes; this
        exists for the no-JS console.
      security:
        - cookieAuth: []
      requestBody:
        required: true
        content:
          application/json: { schema: { $ref: "#/components/schemas/ModerateRequest" } }
          application/x-www-form-urlencoded: { schema: { $ref: "#/components/schemas/ModerateRequest" } }
      responses:
        "200":
          {
            description: Action applied (JSON path),
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /account/operator/moderation" }
        "400":
          {
            description: Invalid action/kind or reason missing,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "404":
          {
            description: Target not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          {
            description: "Already in the requested state, or self-ban",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/users/{user}/ban:
    post:
      tags: [ operator ]
      summary: Ban a user account (moderation)
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: user, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json: { schema: { $ref: "#/components/schemas/ModerationActionRequest" } }
          application/x-www-form-urlencoded: { schema: { $ref: "#/components/schemas/ModerationActionRequest" } }
      responses:
        "200":
          {
            description: Banned,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 redirect" }
        "400":
          {
            description: Reason required,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "404":
          {
            description: Not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          {
            description: "Already banned, or banning yourself",
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/users/{user}/unban:
    post:
      tags: [ operator ]
      summary: Unban a user account (moderation)
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: user, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json: { schema: { $ref: "#/components/schemas/ModerationActionRequest" } }
          application/x-www-form-urlencoded: { schema: { $ref: "#/components/schemas/ModerationActionRequest" } }
      responses:
        "200":
          {
            description: Unbanned,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 redirect" }
        "400":
          {
            description: Reason required,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "404":
          {
            description: Not found / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "409":
          {
            description: Not banned,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/janitor/sweep:
    post:
      tags: [ operator ]
      summary: Run the 30-day account janitor sweep (§spec/account-lifecycle)
      description: >
        Sweeps accounts older than the age window whose lifetime balance
        high-water never reached the reserve minimum (and that aren't the
        operator): each account's dust is forfeited to the house as an
        adjustment (conservation preserved) and its username is freed. This is
        the invocable surface — plurnk-build owns the schedule and calls this on
        its own timer. Idempotent in effect (swept accounts are tombstoned and
        drop out of the candidate set). No body.
      security:
        - cookieAuth: []
      responses:
        "200":
          {
            description: "Sweep complete — returns the swept accounts and forfeited amounts",
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /account/operator/janitor" }
        "404":
          {
            description: Not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/deposits/{id}/credit:
    post:
      tags: [ operator ]
      summary: Credit an unattributed deposit to a user (§spec/account-lifecycle)
      description: >
        Attributes a deposit that arrived on our address outside any lease window
        to a username. Uses the scanner's own `txid:addressIndex` source_ref, so
        the chain-idempotency index makes a double-credit impossible even against
        a racing scan. Single-shot: a replay (or a row already resolved) yields
        404. `username` must resolve to an existing account.
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json: { schema: { $ref: "#/components/schemas/DepositCreditRequest" } }
          application/x-www-form-urlencoded: { schema: { $ref: "#/components/schemas/DepositCreditRequest" } }
      responses:
        "200":
          {
            description: Credited,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /account/operator/deposits" }
        "404":
          {
            description: Not found / already resolved / unknown user / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

  /api/v1/account/operator/deposits/{id}/dismiss:
    post:
      tags: [ operator ]
      summary: Dismiss an unattributed deposit with a mandatory note (§spec/account-lifecycle)
      description: >
        Resolves an unattributed deposit without crediting anyone (e.g. returned
        out-of-band). The note is recorded on the resolution. Single-shot: a
        replay (or a row already resolved) yields 404.
      security:
        - cookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json: { schema: { $ref: "#/components/schemas/ModerationActionRequest" } }
          application/x-www-form-urlencoded: { schema: { $ref: "#/components/schemas/ModerationActionRequest" } }
      responses:
        "200":
          {
            description: Dismissed,
            content: { application/json: { schema: { $ref: "#/components/schemas/OkResponse" } } }
          }
        "303": { description: "Form path: 303 to /account/operator/deposits" }
        "400":
          {
            description: Note required,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }
        "404":
          {
            description: Not found / already resolved / not operator,
            content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
          }

components:
  schemas:

    DepositCreditRequest:
      type: object
      description: >
        Credit an unattributed deposit to a user. `username` is NOT marked
        required here — the handler owns the check so an unknown/blank user
        returns the existence-leak 404, not a generic validation error.
      properties:
        username: { type: string, maxLength: 32 }

    ModerationActionRequest:
      type: object
      description: >
        Operator moderation action. `reason` is mandatory for the audit log but
        is NOT marked required here — the handler owns that check so it returns
        the specific `error_moderation_reason_required`, not a generic
        validation error. `related_ticket_id` optionally links the action to a
        support ticket.
      properties:
        reason: { type: string, maxLength: 4000 }
        related_ticket_id: { type: string, pattern: "^[1-9][0-9]*$" }
        ban_type:
          {
            type: string,
            description: "On the ban route: timed | permanent | erasure (SECURITY §6.5). The
              handler validates → error_ban_type_invalid."
          }
        ban_duration:
          {
            type: string,
            description: "On a timed ban: a duration modifier from PLURNK_WEB_BAN_DURATIONS."
          }

    ModerateRequest:
      type: object
      description: >
        No-JS console dispatch body. `action` is ban | unban (with
        `target_username`). The business-required fields are NOT marked required
        — the handler owns validation so it returns specific error_keys. Numeric
        patterns tolerate an empty value so a blank optional form field doesn't
        trip the validator.
      properties:
        action: { type: string, enum: [ ban, unban ] }
        target_username: { type: string }
        ban_type:
          {
            type: string,
            description: "On action=ban: timed (lockout, auto-expires) | permanent
              (lockout) | erasure (tombstone, irreversible). SECURITY §6.5. The
              handler validates → error_ban_type_invalid."
          }
        ban_duration:
          {
            type: string,
            description: "On a timed ban: a SQLite duration modifier from
              PLURNK_WEB_BAN_DURATIONS (e.g. '7 days'). Ignored for
              permanent/erasure."
          }
        reason: { type: string, maxLength: 4000 }
        related_ticket_id: { type: string, pattern: "^([1-9][0-9]*)?$" }

    SupportTicketRequest:
      type: object
      description: A support ticket submission. The handler validates category + body.
      properties:
        category: { type: string }
        body: { type: string }
        reply_token: { type: string }

    ReportRequest:
      type: object
      description: >
        A content report (SPEC §7). The handler owns validation so it returns
        the specific error_keys (invalid subject/category, notes too long). The
        evidence snapshot, content hash, URL, and reporter context are captured
        server-side — never supplied by the client.
      properties:
        subject_kind:
          {
            type: string,
            description: "user (the only supported subject; handler-validated)."
          }
        subject_id:
          {
            type: string,
            description: The subject's id (form values are strings; handler-validated).
          }
        category:
          {
            type: string,
            description: "Enum: out_of_scope | spam | harassment | impersonation |
              copyright | payment_fraud | other (handler-validated)."
          }
        notes:
          {
            type: string,
            description: Free-form reporter note (handler caps at 4000 chars).
          }

    ResolveTicketRequest:
      type: object
      description: Operator ticket resolution. The handler owns the resolution-notes checks.
      properties:
        resolution_notes: { type: string, maxLength: 4000 }

    ResolveReportRequest:
      type: object
      description: >
        Operator report resolution (SPEC §7). The handler owns validation so it
        returns the specific error_keys; `resolution` is left loose here (not an
        enum) so an unsupported value yields error_report_resolution_invalid
        rather than the generic validator error.
      properties:
        resolution:
          {
            type: string,
            description: "One of: dismiss | warn | ban (handler-validated)."
          }
        reason:
          {
            type: string,
            description: Free-form reason,
            recorded on the report + audit log (handler caps at 4000 chars,
            required).
          }

    OkResponse:
      type: object
      required: [ ok ]
      properties:
        ok: { type: boolean, const: true }

    SignupSuccessResponse:
      description: |
        JSON-path signup response with the 8 single-use recovery codes
        inline. The codes flash is consumed in this response; subsequent
        calls to /api/v1/auth/codes return 404.
      type: object
      required: [ ok, codes ]
      properties:
        ok: { type: boolean, const: true }
        codes:
          type: array
          description: Eight 16-character hex single-use recovery codes.
          items: { type: string, pattern: "^[0-9a-f]{16}$" }
        key:
          type: object
          description: Present only when the request carried issue_key true —
            the first usage key, shown exactly once.
          required: [ id, key, name ]
          properties:
            id: { type: integer }
            key: { type: string, description: The usage secret (pk_...), shown once. }
            name: { type: string }
          minItems: 8
          maxItems: 8

    ErrorResponse:
      description: |
        The shape of every JSON error: `{ error_key, fields? }`. `error_key` is
        a stable identifier you can branch on; `fields` (validation errors) names
        the offending inputs. The `enum` below is the exhaustive list; the
        families a consumer hits most:

        ## Auth & session
        - `error_signin_required` — not authenticated (401).
        - `error_signin_bad_creds` / `error_password_incorrect` — wrong credentials.
        - `error_totp_required` / `error_totp_code_required` / `error_totp_invalid` — 2FA step.
        - `error_account_banned` — the account is banned (permanent, or timed not yet expired).
        - `error_rate_limited` — per-IP bucket limit tripped (429; `Retry-After`).

        ## Keys
        - `error_key_label_required` / `error_key_expiry_invalid` — bad key-creation input.

        ## Validation
        - `error_validation_failed` — schema validation failed (`fields` names them).
        - `error_not_found` — the target doesn't exist (or you can't see it — no leak).

        ## Operator
        - `error_already_banned` / `error_not_banned` / `error_cannot_ban_self`,
          `error_ban_type_invalid` / `error_ban_duration_invalid` — ban actions.
      type: object
      required: [ error_key ]
      properties:
        error_key:
          type: string
          description: |
            Stable machine-readable identifier. The complete vocabulary
            (and English text) lives in `src/locales/en.json`.
          enum:
            - error_unknown
            - error_not_found
            - error_validation_failed
            - error_signin_required
            - error_key_label_required
            - error_key_expiry_invalid
            - error_signin_missing_fields
            - error_signin_bad_creds
            - error_signup_missing_fields
            - error_signup_username_invalid
            - error_signup_username_taken
            - error_terms_required
            - error_american_required
            - error_password_mismatch
            - error_recover_missing_fields
            - error_recover_bad_code
            - error_account_banned
            - error_codes_unavailable
            - error_password_required
            - error_password_incorrect
            - error_deposit_address_unavailable
            - error_deposit_amount_invalid
            - error_rates_unavailable
            - error_totp_required
            - error_totp_code_required
            - error_totp_invalid
            - error_totp_already_enrolled
            - error_totp_not_enrolled
            - error_totp_no_pending
            - error_rate_limited
            - error_csrf
            - error_support_category_invalid
            - error_support_body_required
            - error_support_body_too_long
            - error_support_reply_token_too_long
            - error_support_resolution_notes_required
            - error_support_resolution_notes_too_long
            - error_report_subject_invalid
            - error_report_category_invalid
            - error_report_notes_too_long
            - error_report_resolution_invalid
            - error_report_resolution_reason_required
            - error_report_resolution_reason_too_long
            - error_report_already_resolved
            - error_support_already_resolved
            - error_moderation_reason_required
            - error_moderation_reason_too_long
            - error_already_banned
            - error_not_banned
            - error_cannot_ban_self
            - error_ban_type_invalid
            - error_ban_duration_invalid
            - error_payment_required
            - error_search_unavailable
            - error_search_not_enabled
            - error_search_charge_not_found
            - error_inference_cap_exceeded
            - error_invalid_key
            - error_amount_required
        fields:
          type: array
          description: For validation errors, the input names at fault.
          items: { type: string }

    SlugResponse:
      type: object
      required: [ slug ]
      properties:
        slug: { type: string }

    SignupRequest:
      type: object
      required: [ username, password, password_confirm ]
      properties:
        username:
          type: string
          pattern: "^[A-Za-z0-9_-]{1,32}$"
        issue_key:
          type: boolean
          description: JSON path only — also mint a first usage key inline
            (returned once as `key`), so a deliberate REST client can go
            zero→usage in one call. The HTML form path never sends this.
        accept_terms:
          description: Affirmative Terms-of-Service + Privacy-Policy acceptance —
            REQUIRED to create an account (enforced by the handler with
            error_terms_required so it owns the error_key; not schema-required).
            JSON clients send true; the HTML form sends "on".
          oneOf:
            - type: boolean
            - type: string
        american:
          description: Affirmative jurisdiction-eligibility representation (the
            service is only available to adult American citizens subject to
            American jurisdiction) — REQUIRED to create an account (handler-
            enforced with error_american_required). JSON clients send true;
            the HTML form sends "on".
          oneOf:
            - type: boolean
            - type: string
        password:
          type: string
          format: password
        password_confirm:
          type: string
          format: password

    SigninRequest:
      type: object
      required: [ username, password ]
      properties:
        username: { type: string }
        password: { type: string, format: password }

    RecoverRequest:
      type: object
      required: [ username, code, password, password_confirm ]
      properties:
        username: { type: string }
        code:
          type: string
          description: One of the 8 single-use recovery codes issued at signup.
        password: { type: string, format: password }
        password_confirm: { type: string, format: password }

    CodesResponse:
      type: object
      required: [ codes ]
      properties:
        codes:
          type: array
          description: Eight 16-character hex single-use recovery codes.
          items: { type: string, pattern: "^[0-9a-f]{16}$" }
          minItems: 8
          maxItems: 8

    AccountResponse:
      type: object
      required: [ username, has_totp ]
      properties:
        username: { type: string }
        has_totp:
          type: boolean
          description: True if the user has an active TOTP enrollment.
        balance:
          nullable: true
          description: >
            Balance in piconero (exact decimal strings, §13). null when the
            daemon is unreachable. spendable = internal funds + chain deposits
            at ≥ credited confs.
          type: object
          required: [ spendable_piconero ]
          properties:
            spendable_piconero: { type: string, pattern: "^[0-9]+$" }

    XmrAmount:
      type: string
      description: |
        Positive exact XMR amount with no more than 12 decimal places. The
        shared capability layer owns this semantic validation so every adapter
        returns `error_deposit_amount_invalid` rather than an adapter-specific
        schema error.

    DepositIntentRequest:
      type: object
      additionalProperties: false
      properties:
        amount_xmr: { $ref: "#/components/schemas/XmrAmount" }

    DepositIntentFormRequest:
      type: object
      additionalProperties: false
      required: [ _csrf ]
      properties:
        amount_xmr: { $ref: "#/components/schemas/XmrAmount" }
        _csrf:
          type: string
          description: Same-origin double-submit token rendered into the native form.

    DepositAddressResponse:
      type: object
      required: [ address, expires_at, reserved_until, amount_xmr, payment_uri ]
      properties:
        address:
          type: string
          description: The leased Monero subaddress for self-deposit.
        expires_at:
          type: string
          nullable: true
          description: Lease expiry (ISO-8601); null on a legacy permanent address.
        reserved_until:
          type: string
          nullable: true
          description: Until when a slow-to-mine payment still credits you; null on legacy.
        amount_xmr:
          oneOf:
            - { $ref: "#/components/schemas/XmrAmount" }
            - { type: "null" }
          description: Normalized requested amount, or null when the intent is amount-free.
        payment_uri:
          type: string
          pattern: "^monero:"
          description: Canonical wallet handoff URI; includes tx_amount exactly when amount_xmr is present.

    TurnViewResponse:
      type: object
      required: [ hold, turn ]
      properties:
        hold:
          type: object
          description: Web's authoritative charge record for the turn (the ledger's hold).
          required: [ id, request_id, status, ceiling_piconero, created_at ]
          properties:
            id: { type: integer }
            request_id: { type: string }
            status: { type: string, enum: [ held, settled ] }
            ceiling_piconero: { type: string, pattern: "^[0-9]+$" }
            actual_piconero: { type: string, pattern: "^[0-9]+$", nullable: true }
            key_name: { type: string, nullable: true }
            created_at: { type: string }
            settled_at: { type: string, nullable: true }
        # turn is DELIBERATELY untyped: the packet's shape is the endpoint's
        # contract (endpoint#13 — messages/emission/usage/system_hash), evolves
        # on their side, and is null past the ~30-day hot window.

    LedgerResponse:
      type: object
      required: [ entries, page, page_size, total, page_count ]
      properties:
        entries:
          type: array
          items:
            type: object
            required: [ id, entry_type, amount_piconero, source_kind, created_at ]
            properties:
              id: { type: integer }
              entry_type:
                type: string
                enum: [ inference, search, transfer, fee, deposit, adjustment ]
              amount_piconero:
                type: string
                description: Signed decimal string (BigInt-safe). Debits negative, credits
                  positive.
                pattern: "^-?[0-9]+$"
              source_kind: { type: string, enum: [ chain, internal, operator ] }
              target_kind:
                type: string
                nullable: true
                enum: [ user, null ]
              target_id: { type: integer, nullable: true }
              tier:
                type: string
                nullable: true
                enum: [ pending, credited, settled, final, null ]
                description: Confirmation status (CRYPTO §5), computed on read; null when the
                  daemon is unreachable.
              confs:
                type: integer
                nullable: true
                description: Confirmations for a chain deposit; null for internal entries or
                  when the daemon is unreachable. Drives the "x/y Conf" progress.
              hold_id:
                type: integer
                nullable: true
                description: For a collapsed inference line, the turn's hold id — the key
                  into GET /api/v1/account/turns/{holdId} (the Analyze view). Null
                  for every other entry.
              created_at: { type: string }
        page: { type: integer }
        page_size: { type: integer }
        total: { type: integer }
        page_count: { type: integer }
        filter:
          type: object
          description: The normalized filter/sort echoed back (server-whitelisted).
          properties:
            type: { type: string, nullable: true }
            key: { type: integer, nullable: true }
            from: { type: string, nullable: true }
            to: { type: string, nullable: true }
            sort: { type: string, enum: [ created, amount ] }
            dir: { type: string, enum: [ desc, asc ] }

    LedgerSummaryResponse:
      type: object
      required: [ group, rows, filter ]
      properties:
        group: { type: string, enum: [ key, day, type ] }
        rows:
          type: array
          items:
            type: object
            required: [ group_value, total_piconero, entries ]
            properties:
              group_value:
                # Deliberately untyped (AJV/3.0 can't express nullable oneOf):
                # a key id (integer; null = entries with no key), a YYYY-MM-DD
                # day, or an entry_type string — polymorphic by design.
                description: The group's identity — a key id (integer; null =
                  entries with no key), a YYYY-MM-DD day, or an entry_type.
              key_name: { type: string, nullable: true }
              key_prefix: { type: string, nullable: true }
              total_piconero:
                type: string
                description: Signed exact net (BigInt-safe decimal string).
                pattern: "^-?[0-9]+$"
              entries: { type: integer }
        filter:
          type: object
          properties:
            type: { type: string, nullable: true }
            key: { type: integer, nullable: true }
            from: { type: string, nullable: true }
            to: { type: string, nullable: true }
            sort: { type: string, enum: [ created, amount ] }
            dir: { type: string, enum: [ desc, asc ] }

    ChangePasswordRequest:
      type: object
      required: [ current_password, password, password_confirm ]
      properties:
        current_password: { type: string, format: password }
        password: { type: string, format: password }
        password_confirm: { type: string, format: password }

    SessionSummary:
      type: object
      required: [ fingerprint, created_at, last_seen, current ]
      properties:
        fingerprint:
          type: string
          description: Short base64url-encoded sha256 of the sid (12 chars). Stable per
            session, non-reversible.
        created_at: { type: string }
        last_seen: { type: string }
        current:
          type: boolean
          description: True if this is the session the request rode in on.

    SessionListResponse:
      type: object
      required: [ sessions ]
      properties:
        sessions:
          type: array
          items: { $ref: "#/components/schemas/SessionSummary" }

    AuthEvent:
      type: object
      required: [ id, event_type, occurred_at, transport ]
      properties:
        id: { type: integer, minimum: 1 }
        event_type:
          type: string
          enum:
            - signup
            - signin_password
            - signin_totp
            - signout
            - recover_used
            - password_changed
            - totp_enrolled
            - totp_disabled
            - key_issued
            - key_revoked
            - session_revoked
            - sessions_revoked_others
            - control_authorized
        occurred_at: { type: string }
        transport:
          type: string
          enum: [ clearnet, onion, "" ]
        detail:
          type: object
          additionalProperties: true
          description: Event-specific JSON; shape varies per event_type.
          nullable: true

    AuthActivityResponse:
      type: object
      required: [ events, page, page_size, total, page_count ]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/AuthEvent" }
        page: { type: integer }
        page_size: { type: integer }
        total: { type: integer }
        page_count: { type: integer }

    SessionRevokeResponse:
      type: object
      required: [ ok, self ]
      properties:
        ok: { type: boolean, const: true }
        self:
          type: boolean
          description: True if the caller revoked their own current session. Page should
            redirect to /signin.

    DeleteAccountRequest:
      type: object
      required: [ password ]
      properties:
        password:
          type: string
          format: password
          description: |
            The current account password. Required as a reauth even
            on an active session — destructive actions don't ride a
            stolen cookie.

    SigninTotpRequest:
      type: object
      required: [ code ]
      properties:
        code:
          type: string
          pattern: "^[0-9]{6}$"

    TotpEnrollRequest:
      type: object
      required: [ password ]
      properties:
        password: { type: string, format: password }

    TotpEnrollResponse:
      type: object
      required: [ secret, otpauth_url ]
      properties:
        secret:
          type: string
          pattern: "^[A-Z2-7]+$"
          description: |
            Base32-encoded TOTP shared secret. Shown to the user once,
            here, for entry into an authenticator app. The server has
            already stashed this in the session as `pendingTotpSecret`
            — it is NOT written to the user row until /confirm
            succeeds.
        otpauth_url:
          type: string
          description: |
            `otpauth://` URI containing the secret + issuer + algorithm,
            for the authenticator app to enroll directly.

    TotpCodeRequest:
      type: object
      required: [ code ]
      properties:
        code:
          type: string
          pattern: "^[0-9]{6}$"

    TotpDisableRequest:
      type: object
      required: [ password, code ]
      properties:
        password: { type: string, format: password }
        code:
          type: string
          pattern: "^[0-9]{6}$"

    # — Usage keys —

    KeyCreateRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 80
          description: Required human-readable label for the key.
        expires_in_days:
          type: integer
          description: >
            Optional hard expiry in days from now (positive integer). Omit for a
            key that never expires. The handler validates →
            error_key_expiry_invalid.
        search:
          type: boolean
          default: true
          description: Whether this usage key may also authorize metered web search.

    KeyCreateFormRequest:
      type: object
      properties:
        name:
          type: string
          maxLength: 80
          description: Required human-readable label for the key.
        expires_in_days:
          type: string
          description: >
            Optional expiry in days (form values are strings). The handler
            validates → error_key_expiry_invalid.
        search:
          type: string
          enum: [ on, off ]
          description: Optional no-JS form representation of the Search capability.

    KeyCreateResponse:
      type: object
      required: [ id, key, name, created_at ]
      properties:
        id: { type: integer, minimum: 1 }
        key:
          type: string
          pattern: "^pk_[0-9a-f]{64}$"
          description: |
            The plaintext key, returned exactly once. Pass it only to
            `/v1` inference or metered `/api/v1/search/*`.
        name: { type: string }
        created_at: { type: string }

    TokenSummary:
      type: object
      required: [ id, prefix, name, expires_at, created_at, last_used_at, user_disabled_at, system_disabled_at, operator_disabled_at, search_enabled ]
      properties:
        id: { type: integer, minimum: 1 }
        prefix: { type: string }
        name: { type: string }
        expires_at: { type: string, nullable: true }
        created_at: { type: string }
        last_used_at:
          type: string
          nullable: true
        user_disabled_at: { type: string, nullable: true }
        system_disabled_at: { type: string, nullable: true }
        operator_disabled_at: { type: string, nullable: true }
        search_enabled: { type: integer, enum: [ 0, 1 ] }

    TokenListResponse:
      type: object
      required: [ keys, page, page_size, total, page_count ]
      properties:
        keys:
          type: array
          items: { $ref: "#/components/schemas/TokenSummary" }
        page: { type: integer }
        page_size: { type: integer }
        total: { type: integer }
        page_count: { type: integer }

    HealthResponse:
      type: object
      required: [ server, db ]
      properties:
        server: { type: string, description: "Status string for the express process" }
        db: { type: string, description: "Status string for sqlite" }
        timestamp: { type: string, description: "ISO-8601 probe time" }

    OperatorHealthResponse:
      type: object
      required: [ server, db, daemon, wallet, writable, read_only_reason, pool_depth ]
      properties:
        server: { type: string, description: "Status string for the express process" }
        db: { type: string, description: "Status string for sqlite" }
        daemon: { type: string, description: "Status string for monerod" }
        wallet: { type: string, description: "Status string for monero-wallet-rpc" }
        writable:
          type: boolean
          description: "False = the platform is in maintenance read-only (PLURNK_WEB_FREEZE);
            every authenticated mutation returns 503."
        read_only_reason:
          type: [ string, "null" ]
          description: "null when writable; otherwise frozen | wallet_unreachable |
            daemon_unreachable | wallet_sync_behind (the latter three are
            reported for visibility but no longer gate globally)."
        pool_depth:
          type: integer
          description: "Unassigned subaddresses remaining in the pool (operator runway
            gauge)."

  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: pk.sid
      description: |
        HttpOnly session cookie issued by `/signup` / `/signin` /
        `/signin/recover`. Browser path.
    controlTokenAuth:
      type: oauth2
      description: |
        OAuth 2.1 authorization-code token with S256 PKCE, scope
        `plurnk:control`, and exact resource audience `/api/v1`. Control tokens
        begin `pct_`, expire after one hour, and are not usage credentials.
      flows:
        authorizationCode:
          authorizationUrl: https://plurnk.ai/oauth/authorize
          tokenUrl: https://plurnk.ai/oauth/token
          scopes:
            plurnk:control: Read and mutate the customer's commerce state and usage keys.
    usageKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: pk_<hex>
      description: |
        Usage key issued by `POST /api/v1/auth/keys`. Pass as
        `Authorization: Bearer pk_<64-hex>` only to `/v1` inference or
        `/api/v1/search/*`. It has no account-control, MCP, session, or event
        authority. It may have an optional expiry and can be disabled or revoked.
