openapi: 3.1.0
info:
  title: Tracium Cabinet API
  version: 1.0.0
  summary: Внутренний API кабинета пользователя
  description: |
    Cabinet API под `/api/cabinet/*` за nginx auth_request → trusted JWT
    forwarded headers. Все routes требуют активного customer'а; archived
    customer возвращает 410 на cabinet-уровне (account_archived).

    Этот файл описывает только `/api/cabinet/*`. Customer data API лежит
    в `public-api.openapi.yaml` под префиксом `/v1/*`. Authentication через
    API-token + scopes управляется ADR-0051.

servers:
  - url: https://tracium.dev/api/cabinet
    description: Production

tags:
  - name: keys
    description: API-ключи для публичного surface'а /v1/*
  - name: stats
    description: Per-customer usage statistics

paths:
  /keys:
    get:
      tags: [keys]
      operationId: listAPIKeys
      summary: Список ключей текущего customer'а
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [active, revoked, expired] }
        - in: query
          name: sort
          schema: { type: string, enum: [created_at, name, last_used_at], default: created_at }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: offset
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [items, total]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/APIKey' }
                  total: { type: integer }
        '410': { $ref: '#/components/responses/AccountArchived' }
    post:
      tags: [keys]
      operationId: createAPIKey
      summary: Создать ключ; возвращает полный секрет один раз
      description: |
        Phase 2 (ADR-0051): request body принимает опциональный role ИЛИ scopes
        (mutual exclusion). Оба nil → default RoleViewer. created_by_admin=false
        (cabinet self-service; admin-flow через отдельный endpoint).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAPIKeyRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [key, secret]
                properties:
                  key: { $ref: '#/components/schemas/APIKey' }
                  secret: { type: string, description: "Полный токен trk_<env>_<base62>; reveal once" }
        '400': { $ref: '#/components/responses/ValidationFailed' }
        '410': { $ref: '#/components/responses/AccountArchived' }
  /keys/{id}:
    get:
      tags: [keys]
      operationId: getAPIKey
      parameters: [{ $ref: '#/components/parameters/KeyID' }]
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/APIKey' } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/AccountArchived' }
    patch:
      tags: [keys]
      operationId: renameAPIKey
      parameters: [{ $ref: '#/components/parameters/KeyID' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, version]
              properties:
                name: { type: string, maxLength: 100 }
                version: { type: integer, format: int64 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [key]
                properties: { key: { $ref: '#/components/schemas/APIKey' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/VersionConflict' }
        '410': { $ref: '#/components/responses/AccountArchived' }
    delete:
      tags: [keys]
      operationId: deleteAPIKey
      parameters: [{ $ref: '#/components/parameters/KeyID' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version, last4_confirm]
              properties:
                version: { type: integer, format: int64 }
                last4_confirm: { type: string, minLength: 4, maxLength: 4 }
      responses:
        '204': { description: Deleted }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/VersionConflict' }
        '410': { $ref: '#/components/responses/AccountArchived' }
        '422': { $ref: '#/components/responses/Last4Mismatch' }
  /keys/{id}/rotate:
    post:
      tags: [keys]
      operationId: rotateAPIKey
      parameters: [{ $ref: '#/components/parameters/KeyID' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version]
              properties: { version: { type: integer, format: int64 } }
      responses:
        '200':
          description: New key created, old revoked atomically
          content:
            application/json:
              schema:
                type: object
                required: [key, secret]
                properties:
                  key: { $ref: '#/components/schemas/APIKey' }
                  secret: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/VersionConflict' }
        '410': { $ref: '#/components/responses/AccountArchived' }
  /keys/{id}/revoke:
    post:
      tags: [keys]
      operationId: revokeAPIKey
      parameters: [{ $ref: '#/components/parameters/KeyID' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [version]
              properties:
                version: { type: integer, format: int64 }
                reason: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                required: [key]
                properties: { key: { $ref: '#/components/schemas/APIKey' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Version conflict OR already revoked
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410': { $ref: '#/components/responses/AccountArchived' }

  /keys/{id}/security:
    patch:
      tags: [keys]
      operationId: patchCabinetKeySecurity
      summary: Update API-key security settings (monthly_limit + ip_allowlist)
      parameters: [{ $ref: '#/components/parameters/KeyID' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CabinetKeySecurityPatch' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/APIKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/VersionConflict' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }

  /scopes/catalog:
    get:
      tags: [keys]
      operationId: getScopesCatalog
      summary: Каталог активных scopes и roles
      description: |
        UI читает этот endpoint при открытии CreateKeyModal —
        динамически рендерит chip-список scopes и role radio-group.
        Возвращает только активные (grantable) элементы (spec D9/D10).
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScopeCatalog'
        '410': { $ref: '#/components/responses/AccountArchived' }

  /overview:
    get:
      operationId: getCabinetOverview
      summary: Cabinet "Главная" KPI snapshot
      tags: [overview]
      security: [{cookieSession: []}]
      description: |
        Read-only aggregate consumed by the cabinet "Главная" screen.
        Returns four numeric counters in a single round-trip:

        - `active_keys` — count of API keys with status='active' for the
          current customer.
        - `connected_suppliers` — DISTINCT supplier_ref of customer-scope
          credentials with status='active'.
        - `total_suppliers` — number of suppliers in the catalog (denominator
          for "X из Y" rendering).
        - `requests_24h` — total /v1/* request count over the last 24h.

        Implementation: one server-side aggregation across cabinet/apikeys,
        core/credentials, cabinet/stats and cabinet/credentials/supplier_catalog
        BCs. No params; period is fixed at 24h (Stats screen owns variable
        period UX).
      responses:
        '200':
          description: Overview snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CabinetOverview'
        '401': { description: Unauthorized }
        '410': { $ref: '#/components/responses/AccountArchived' }

  /credentials/suppliers/catalog:
    get:
      summary: Catalog of supported suppliers (auth schemes + defaults)
      operationId: getCredentialsSupplierCatalog
      tags: [Credentials]
      security: [{cookieSession: []}]
      responses:
        '200':
          description: Catalog
          content:
            application/json:
              schema:
                type: object
                required: [suppliers]
                properties:
                  suppliers:
                    type: array
                    items: { $ref: '#/components/schemas/SupplierMeta' }
        '401': { description: Unauthorized }
        '410': { $ref: '#/components/responses/AccountArchived' }

  /stats:
    get:
      operationId: getCabinetStats
      summary: Per-customer usage statistics overview
      tags: [stats]
      parameters:
        - name: period
          in: query
          schema: { type: string, enum: [24h, 7d, 30d, 90d], default: 30d }
        - name: scope
          in: query
          schema: { type: string, enum: [account, key], default: account }
        - name: key_id
          in: query
          schema: { type: string, format: uuid }
        - name: endpoint
          in: query
          schema: { type: string }
        - name: status_class
          in: query
          schema: { type: string, enum: [2xx, 4xx, 5xx] }
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CabinetStatsOverview' }
        '401': { description: Unauthorized }
        '410': { $ref: '#/components/responses/AccountArchived' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '404': { $ref: '#/components/responses/NotFound' }

  /stats.csv:
    get:
      operationId: getCabinetStatsCSV
      summary: CSV export of usage time-series
      tags: [stats]
      parameters:
        - name: period
          in: query
          schema: { type: string, enum: [24h, 7d, 30d, 90d], default: 30d }
        - name: scope
          in: query
          schema: { type: string, enum: [account, key], default: account }
        - name: key_id
          in: query
          schema: { type: string, format: uuid }
        - name: endpoint
          in: query
          schema: { type: string }
        - name: status_class
          in: query
          schema: { type: string, enum: [2xx, 4xx, 5xx] }
      responses:
        '200':
          description: CSV file
          content:
            text/csv:
              schema: { type: string }
        '401': { description: Unauthorized }
        '410': { $ref: '#/components/responses/AccountArchived' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  parameters:
    KeyID:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
  schemas:
    APIKey:
      type: object
      required: [id, name, env, prefix, last4, status, effective_status, created_at, updated_at, version, scopes, created_by_admin]
      description: |
        Phase 2 (ADR-0051): добавлены scopes, role, created_by_admin.
        scopes — non-empty после migration 0072 backfill (минимум whoami).
        role — nullable; nil если customer указал custom scope-set.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        env: { type: string, enum: [production, test] }
        prefix: { type: string, description: "первые 15 символов токена для UI mask" }
        last4: { type: string, minLength: 4, maxLength: 4 }
        status: { type: string, enum: [active, revoked, expired] }
        effective_status:
          type: string
          enum: [active, revoked, expired]
          description: |
            Серверное вычисление с учётом expires_at vs now.
            UI и фильтры ориентируются на это поле.
        expires_at: { type: string, format: date-time, nullable: true }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }
        revoked_reason: { type: string, nullable: true }
        rotated_from: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        version: { type: integer, format: int64 }
        scopes:
          type: array
          items:
            type: string
            enum: [whoami, products:read, search:read, credentials:read, credentials:write, orders:write]
          description: |
            Granted scopes. После migration 0072 — non-empty (минимум whoami).
            Whoami sentinel always included.
        role:
          type: string
          nullable: true
          enum: [viewer, editor, admin]
          description: |
            Display preset для UI. null если customer указал custom scope-set
            (без role-resolution). Изменение role-template НЕ ретроактивно
            меняет права existing keys (spec D2).
        created_by_admin:
          type: boolean
          description: True если ключ выпущен через admin-flow (D14).
        monthly_limit:
          type: integer
          format: int64
          nullable: true
          description: |
            Monthly request limit. null = unlimited. When set, counter increments
            each request and verify-handler returns 429 when exceeded.
        ip_allowlist:
          type: array
          items: { type: string }
          description: |
            CIDR or bare IP entries (IPv4+IPv6). Empty array means open
            (no IP restriction). Non-empty means only requests from these IPs
            are allowed; others rejected with specific outbox event.
        usage_this_period:
          type: integer
          format: int64
          nullable: true
          description: |
            Counter for current calendar month (UTC, YYYY-MM).
            null if monthly_limit is nil (unlimited). Incremented by verify-handler.

    CreateAPIKeyRequest:
      type: object
      required: [name, env]
      properties:
        name: { type: string, maxLength: 100 }
        env: { type: string, enum: [production, test] }
        expires_at: { type: string, format: date-time, nullable: true }
        role:
          type: string
          enum: [viewer]
          nullable: true
          description: |
            UI preset; mutual exclusion с scopes. Default — viewer.
            На launch только viewer grantable (D10); editor/admin не активны.
        scopes:
          type: array
          items:
            type: string
            enum: [whoami, products:read, search:read]
          description: |
            Custom scope-set; mutual exclusion с role.
            whoami auto-prepended если не указан явно.
        monthly_limit:
          type: integer
          format: int64
          nullable: true
          minimum: 1
          description: |
            Optional monthly request limit. null = unlimited.
        ip_allowlist:
          type: array
          items: { type: string }
          description: |
            Optional IP allowlist (CIDR or bare IPs). Empty array or absent = open.

    CabinetKeySecurityPatch:
      type: object
      required: [version]
      description: |
        Partial update schema for PATCH /keys/{id}/security.
        Fields not present in JSON are left unchanged (absent != null).
      properties:
        monthly_limit:
          type: integer
          format: int64
          nullable: true
          minimum: 1
          description: |
            Monthly request limit. null clears (unlimited); ≥1 sets;
            field absent (key not present in JSON) leaves unchanged.
        ip_allowlist:
          type: array
          items: { type: string }
          description: |
            CIDR or bare IP entries (IPv4+IPv6). Empty array clears (open);
            field absent leaves unchanged.
        version:
          type: integer
          format: int64
          description: Optimistic locking — current row version.

    ScopeCatalog:
      type: object
      required: [active_scopes, active_roles]
      description: |
        Каталог активных (grantable) scopes и roles для UI.
        UI читает при открытии CreateKeyModal для динамического рендеринга
        chip-списка и role radio-group (no hard-coded enum во frontend).
      properties:
        active_scopes:
          type: array
          items: { type: string }
          description: Scope identifiers currently grantable (active, spec D9).
        active_roles:
          type: array
          items: { type: string }
          description: Role identifiers currently grantable (active, spec D10).

    SupplierMeta:
      type: object
      required: [supplier_ref, display_name, auth_scheme, secret_fields, default_allowed_usages, default_rate_limit]
      properties:
        supplier_ref: { type: string, example: etm }
        display_name: { type: string, example: ЭТМ }
        auth_scheme: { type: string, enum: [basic, api_token, master_key] }
        secret_fields:
          type: array
          items: { $ref: '#/components/schemas/SecretField' }
        default_allowed_usages:
          type: array
          items:
            type: string
            enum: [customer_proposal, price_refresh, stock_refresh, diagnostic_probe]
        default_rate_limit: { $ref: '#/components/schemas/SupplierRateLimit' }
        notes: { type: string }

    SecretField:
      type: object
      required: [key, label, is_secret, is_mono]
      properties:
        key: { type: string }
        label: { type: string }
        placeholder: { type: string }
        is_secret: { type: boolean }
        is_mono: { type: boolean }
        help: { type: string }

    SupplierRateLimit:
      type: object
      required: [requests_per_minute, requests_per_day, burst_allowance]
      properties:
        requests_per_minute: { type: integer }
        requests_per_day: { type: integer }
        burst_allowance: { type: integer }

    CabinetOverview:
      type: object
      required: [active_keys, connected_suppliers, total_suppliers, requests_24h]
      properties:
        active_keys:
          type: integer
          format: int64
          minimum: 0
          description: Count of api_keys with status='active' for the customer.
        connected_suppliers:
          type: integer
          format: int64
          minimum: 0
          description: DISTINCT supplier_ref of customer-scope credentials with status='active'.
        total_suppliers:
          type: integer
          format: int64
          minimum: 0
          description: Total suppliers in the catalog (denominator of "X из Y").
        requests_24h:
          type: integer
          format: int64
          minimum: 0
          description: Total /v1/* requests in the last 24 hours for this customer.

    CabinetStatsOverview:
      type: object
      required: [period, scope, kpis, timeseries, top_keys, top_endpoints, top_errors]
      properties:
        period: { type: string, enum: [24h, 7d, 30d, 90d] }
        scope: { type: string, enum: [account, key] }
        kpis: { $ref: '#/components/schemas/CabinetStatsKPIs' }
        timeseries:
          type: array
          items: { $ref: '#/components/schemas/CabinetStatsTimeseriesPoint' }
        top_keys:
          type: array
          items: { $ref: '#/components/schemas/CabinetStatsTopKey' }
        top_endpoints:
          type: array
          items: { $ref: '#/components/schemas/CabinetStatsTopEndpoint' }
        top_errors:
          type: array
          items: { $ref: '#/components/schemas/CabinetStatsTopError' }

    CabinetStatsKPIs:
      type: object
      required: [total_requests, success_count, error_count, success_rate, error_rate]
      properties:
        total_requests: { type: integer, format: int64 }
        success_count: { type: integer, format: int64 }
        error_count: { type: integer, format: int64 }
        success_rate: { type: number, format: double }
        error_rate: { type: number, format: double }
        delta_pct_vs_previous_period: { type: number, format: double, nullable: true }

    CabinetStatsTimeseriesPoint:
      type: object
      required: [bucket, total, errors]
      properties:
        bucket: { type: string, format: date-time }
        total: { type: integer, format: int64 }
        errors: { type: integer, format: int64 }

    CabinetStatsTopKey:
      type: object
      required: [key_id, key_name, key_masked, status, requests]
      properties:
        key_id: { type: string, format: uuid }
        key_name: { type: string }
        key_masked: { type: string }
        status: { type: string, enum: [active, revoked, expired, deleted] }
        requests: { type: integer, format: int64 }

    CabinetStatsTopEndpoint:
      type: object
      required: [endpoint, requests]
      properties:
        endpoint: { type: string }
        requests: { type: integer, format: int64 }

    CabinetStatsTopError:
      type: object
      required: [status_code, name, count]
      properties:
        status_code: { type: integer }
        name: { type: string }
        count: { type: integer, format: int64 }

    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        field_errors:
          type: array
          items:
            type: object
            required: [field, code, message]
            properties:
              field: { type: string }
              code: { type: string }
              message: { type: string }
  responses:
    Unauthorized:
      description: Unauthorized
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: Not found
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    VersionConflict:
      description: Version conflict
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    AccountArchived:
      description: Account archived (410 Gone)
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    ValidationFailed:
      description: Validation failed
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    UnprocessableEntity:
      description: Unprocessable entity
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Last4Mismatch:
      description: Last4 confirmation mismatch
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
