openapi: 3.1.0
info:
  title: Corevia VPS REST API
  version: 1.0.0
  summary: Manage dedicated Corevia VPS subscriptions (resold from Contabo)
  description: |
    Per-tenant management API for [Corevia VPS](https://sl-claw.tech/box.html).
    8 endpoints covering full lifecycle: list, status, health, logs, restart, upgrade, cancel,
    and API-key management.

    **Auth modes (try in order):**
    1. `Authorization: Bearer cv_xxxxx` — generated at `/portal/api-keys`. For scripts, MCP, CI.
    2. Cabinet session cookie `nd_session` — for UI buttons. Not for external clients.

    **Standard error shape:** `{ ok: false, error: "machine_readable_code", ... }`.

    All endpoints return JSON. All timestamps ISO 8601 UTC. Money in USD as decimal strings or numbers.
  contact:
    name: Corevia Support
    email: support@sl-claw.tech
    url: https://sl-claw.tech/docs/vps-api.html
  license:
    name: Proprietary
servers:
  - url: https://app.sl-claw.tech
    description: Production
security:
  - bearerAuth: []
  - cabinetSession: []

paths:
  /api/box:
    get:
      summary: List boxes
      description: All Corevia VPS subscriptions for the authenticated tenant.
      operationId: listBoxes
      tags: [boxes]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  count: { type: integer, example: 2 }
                  boxes: { type: array, items: { $ref: "#/components/schemas/BoxSummary" } }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/box/{id}/status:
    get:
      summary: Get live status
      description: |
        Detailed state of one box including live Contabo status (ping ~200-500ms).
        `id` accepts either Prisma cuid (`cl_xxx`) OR domain slug (`cafemario`).
      operationId: getBoxStatus
      tags: [boxes]
      parameters: [{ $ref: "#/components/parameters/BoxId" }]
      responses:
        "200": { $ref: "#/components/responses/BoxDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/BoxNotFound" }

  /api/box/{id}/health:
    get:
      summary: Health check
      description: |
        3 parallel probes: HTTPS public domain, direct IP, SSH TCP. Returns verdict
        (`healthy` / `container_up_but_dns_or_ssl_down` / `ssh_up_but_bot_down` / `unreachable`)
        and human-readable hint.
      operationId: checkBoxHealth
      tags: [boxes]
      parameters: [{ $ref: "#/components/parameters/BoxId" }]
      responses:
        "200":
          description: Health report
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  box: { type: object }
                  verdict:
                    type: string
                    enum: [healthy, not_active, container_up_but_dns_or_ssl_down, ssh_up_but_bot_down, unreachable]
                  checks: { type: object }
                  hint: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/BoxNotFound" }

  /api/box/{id}/logs:
    get:
      summary: Tail bot logs
      description: SSH into the VPS and tail `docker logs corevia-bot --tail N`. Cap 2000.
      operationId: getBoxLogs
      tags: [boxes]
      parameters:
        - { $ref: "#/components/parameters/BoxId" }
        - in: query
          name: lines
          schema: { type: integer, default: 200, minimum: 10, maximum: 2000 }
      responses:
        "200":
          description: Logs
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  box: { type: object }
                  lines: { type: integer }
                  logs: { type: string }
        "409":
          description: Box not yet provisioned
        "502":
          description: SSH to VPS failed

  /api/box/{id}/restart:
    post:
      summary: Restart VPS
      description: Soft reboot via Contabo API. 30-60s downtime. Only ACTIVE/GRACE allowed.
      operationId: restartBox
      tags: [boxes]
      parameters: [{ $ref: "#/components/parameters/BoxId" }]
      responses:
        "200": { description: Restart issued }
        "409": { description: Wrong status (must be ACTIVE/GRACE) }
        "502": { description: Contabo API failed }

  /api/box/{id}/cancel:
    post:
      summary: Cancel subscription
      description: |
        Stops Contabo VPS + marks subscription CANCELLED. Within 30 days = eligible for
        refund (manual via support@sl-claw.tech after this call).
      operationId: cancelBox
      tags: [boxes]
      parameters: [{ $ref: "#/components/parameters/BoxId" }]
      responses:
        "200": { description: Cancelled (or already was) }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/box/{id}/upgrade:
    post:
      summary: Change tier
      description: |
        Records upgrade intent. V1: manual migration by ops within 24h (data preserved).
        Allowed tiers: V68 ($19, 4 vCPU/8GB), V69 ($29, 6 vCPU/12GB), V70 ($59, 8 vCPU/24GB).
      operationId: upgradeBox
      tags: [boxes]
      parameters: [{ $ref: "#/components/parameters/BoxId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [catalogProductId]
              properties:
                catalogProductId: { type: string, enum: [V68, V69, V70] }
      responses:
        "200": { description: Upgrade intent saved }

  /api/account/api-key:
    get:
      summary: Get current API key metadata
      description: Returns prefix + creation date (no raw value).
      operationId: getApiKey
      tags: [account]
      security: [{ cabinetSession: [] }]
      responses:
        "200":
          description: Key metadata (or `has_key:false` if none)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  has_key: { type: boolean }
                  prefix: { type: string, nullable: true, example: "cv_a1b2c3d4" }
                  created_at: { type: string, format: date-time, nullable: true }
    post:
      summary: Generate API key
      description: |
        Creates new key, replacing existing (rotation). Returns raw value ONCE — store
        immediately in 1Password / env / GitHub Secrets.
      operationId: createApiKey
      tags: [account]
      security: [{ cabinetSession: [] }]
      responses:
        "200":
          description: New key (raw value visible only here)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  api_key: { type: string, example: "cv_a1b2c3d4e5f6..." }
                  prefix: { type: string }
                  warning: { type: string }
    delete:
      summary: Revoke API key
      operationId: deleteApiKey
      tags: [account]
      security: [{ cabinetSession: [] }]
      responses:
        "200": { description: Revoked }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: cv_*
      description: Per-tenant API key (cv_ prefix + 40 hex chars). Create at /portal/api-keys.
    cabinetSession:
      type: apiKey
      in: cookie
      name: nd_session
      description: HMAC-signed session cookie. Auto-set on cabinet login.

  parameters:
    BoxId:
      in: path
      name: id
      required: true
      schema: { type: string }
      description: Box id (cl_xxx) OR domain slug (cafemario)

  schemas:
    BoxSummary:
      type: object
      properties:
        id: { type: string, example: "cl_abc123" }
        domain_slug: { type: string, example: "cafemario" }
        bot_url: { type: string, format: uri, example: "https://cafemario.bot.sl-claw.tech" }
        box_url: { type: string, format: uri, example: "https://cafemario.box.sl-claw.tech" }
        niche: { type: string, example: "restaurant-delivery" }
        status: { type: string, enum: [PROVISIONING, ACTIVE, GRACE, SUSPENDED, CANCELLED, FAILED] }
        tier: { type: object }
        ipv4: { type: string, nullable: true }
        mrr_usd: { type: number, example: 29 }
        started_at: { type: string, format: date-time }
        next_charge_at: { type: string, format: date-time }
        activated_at: { type: string, format: date-time, nullable: true }
        cancelled_at: { type: string, format: date-time, nullable: true }

  responses:
    Unauthorized:
      description: Missing or invalid Bearer/session
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean, example: false }
              error: { type: string, example: "auth_required" }
              hint: { type: string }
    BoxNotFound:
      description: Box not found OR not owned by tenant
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean, example: false }
              error: { type: string, example: "box_not_found" }
    BoxDetail:
      description: Full box details (with live Contabo state)
      content:
        application/json:
          schema:
            type: object
            properties:
              ok: { type: boolean }
              box: { $ref: "#/components/schemas/BoxSummary" }
              live: { type: object, nullable: true }
              live_error: { type: string, nullable: true }
