openapi: 3.0.3
info:
  title: GPU-Agent Customer API
  version: v1-alpha
  description: |
    Customer-facing API for the GPU-Agent platform. The product is
    **customer custom training**: customers upload their own dataset +
    training package, the planner cuts the job into a DAG of CPU + GPU
    compute units, and the controlled-tier home compute fleet executes
    them. Use the `Datasets / Training packages / Training jobs / Compute
    units` flow.

    **Stage 17.1 Final Purge** — the legacy preset-model image-generation
    surface has been REMOVED. Only two minimal-guard stubs remain
    (`POST /jobs/image` and `POST /jobs/image-api`) so historical clients
    fail fast with `error.code = LEGACY_DEPRECATED` and a migration hint.
    `GET /jobs`, `GET /jobs/{id}`, `GET /jobs/{id}/artifacts`, the
    `signed-url` artifact fetcher and `GET /customer/models` are gone.
    The `image_generation:write` and `image_generation:read` scopes are
    no longer issued or honoured.

    **Status:** Alpha. Surface is non-breaking within v1-alpha unless a
    change is documented in `docs/customer-api-reference.md`.

    **Auth tiers:**
      - Browser portal — opaque session token (cookie + Bearer).
      - Programmatic — `X-API-Key: sk_alpha_<24 hex>` with optional scopes.

    **Boundaries (security model):**
      - A customer never sees another customer's data. Cross-tenant probes
        return 404 (not 403) to avoid existence leaks.
      - The Customer API is mounted on `customer-staging.<domain>`; Admin
        and Node APIs are on separate vhosts and require different auth.
      - All examples use placeholder secrets — never real keys.

    See `docs/customer-portal-mvp.md` for the architectural overview and
    `docs/customer-security-model.md` for the threat model.
  contact:
    name: GPU-Agent Alpha
  license:
    name: Internal alpha — not for redistribution
servers:
  - url: https://api-staging.c3pool.cn
    description: Alpha staging
  - url: https://api.example.com
    description: Operator-replaced placeholder

tags:
  - name: meta
    description: Public configuration / metadata
  - name: auth
    description: Session-based authentication
  - name: api-keys
    description: Programmatic API key management
  - name: training
    description: Datasets, training packages, training jobs, compute units, training artifacts
  - name: usage
    description: Credits, usage rollups, exports
  - name: webhooks
    description: Outgoing event delivery (Stage 15)

paths:
  /api/v1/customer/config:
    get:
      tags: [meta]
      summary: Public portal config
      operationId: getPublicConfig
      security: []
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicConfig" }
              example:
                env: staging
                portal_domain: customer-staging.c3pool.cn
                api_base_url: https://api-staging.c3pool.cn
                limits:
                  max_jobs_per_minute: 10
                  max_concurrent_jobs: 3
                  max_tasks_per_day: 200
                signup_enabled: false

  /api/v1/customer/meta:
    get:
      tags: [meta]
      summary: API metadata for SDKs and clients
      operationId: getMeta
      security: []
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Meta" }

  # /api/v1/customer/models — REMOVED in Stage 17.1 Final Purge.

  # ---- auth ----------------------------------------------------------
  /api/v1/customer/auth/register:
    post:
      tags: [auth]
      summary: Register a customer (gated by ENABLE_CUSTOMER_SIGNUP)
      operationId: register
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 8 }
                display_name: { type: string }
      responses:
        "201":
          description: created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicUser" }
        "403":
          $ref: "#/components/responses/CustomerForbidden"
        "400":
          $ref: "#/components/responses/ValidationError"

  /api/v1/customer/auth/login:
    post:
      tags: [auth]
      summary: Issue a session token
      operationId: login
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string }
                password: { type: string }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_token: { type: string }
                  expires_at: { type: string, format: date-time }
                  user: { $ref: "#/components/schemas/PublicUser" }
        "401":
          $ref: "#/components/responses/InvalidCredentials"

  /api/v1/customer/auth/logout:
    post:
      tags: [auth]
      summary: Revoke the current session
      operationId: logout
      security: [{ sessionAuth: [] }]
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /api/v1/customer/auth/me:
    get:
      tags: [auth]
      summary: Current user
      operationId: me
      security: [{ sessionAuth: [] }]
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicUser" }

  /api/v1/customer/auth/change-password:
    post:
      tags: [auth]
      summary: Change password (revokes other sessions)
      operationId: changePassword
      security: [{ sessionAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [old_password, new_password]
              properties:
                old_password: { type: string }
                new_password: { type: string, minLength: 8 }
      responses:
        "200": { description: ok }

  # ---- api keys ------------------------------------------------------
  /api/v1/customer/api-keys:
    get:
      tags: [api-keys]
      summary: List API keys (paginated)
      operationId: listApiKeys
      security: [{ sessionAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageOfApiKey" }
    post:
      tags: [api-keys]
      summary: Create an API key (plaintext returned ONCE)
      operationId: createApiKey
      security: [{ sessionAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 64 }
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/Scope" }
      responses:
        "201":
          description: created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }
              example:
                id: "ck_b1f2..."
                prefix: "sk_alpha_xxxxxxxx"
                name: "ci-bot"
                scopes: ["dataset:write","training:write","artifact:read","usage:read"]
                key: "sk_alpha_REPLACE_ME_NEVER_LOG_THIS"
                created_at: "2026-05-01T10:00:00Z"

  /api/v1/customer/api-keys/{id}:
    delete:
      tags: [api-keys]
      summary: Revoke an API key (soft delete)
      operationId: revokeApiKey
      security: [{ sessionAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200": { description: ok }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/v1/customer/api-keys/{id}/rotate:
    post:
      tags: [api-keys]
      summary: Rotate (revoke old + create new with same scopes)
      operationId: rotateApiKey
      security: [{ sessionAuth: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "201":
          description: created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiKeyCreated" }

  # ---- legacy image-generation jobs / artifacts: REMOVED (Stage 17.1 Final Purge)
  # The two POST stubs (jobs/image and jobs/image-api) only return HTTP 410.
  # All read endpoints (GET /jobs, GET /jobs/{id}, /jobs/{id}/artifacts,
  # /artifacts/{id}/signed-url) and the model listing have been deleted.
  # Customer training-jobs / compute-units / training-artifacts surfaces
  # are documented in docs/customer-custom-training-guide.md.
  /api/v1/customer/jobs/image:
    post:
      tags: [training]
      summary: "[REMOVED — 410 Gone] Legacy image_generation submit (portal)"
      description: |
        Stage 17.1 Final Purge — the legacy preset-model image_generation
        pipeline was removed. This stub returns HTTP 410 with
        `error.code = LEGACY_DEPRECATED`. Submit a training job via
        POST /api/v1/customer/training-jobs instead.
      deprecated: true
      operationId: createImageJob_PURGED_PORTAL
      responses:
        "410":
          description: "Gone — endpoint removed"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/customer/jobs/image-api:
    post:
      tags: [training]
      summary: "[REMOVED — 410 Gone] Legacy image_generation submit (API key)"
      description: |
        Stage 17.1 Final Purge — see /api/v1/customer/jobs/image.
      deprecated: true
      operationId: createImageJob_PURGED_API
      responses:
        "410":
          description: "Gone — endpoint removed"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ---- usage ---------------------------------------------------------
  /api/v1/customer/usage/summary:
    get:
      tags: [usage]
      summary: Account summary (balance, today's jobs, limits)
      operationId: usageSummary
      security:
        - sessionAuth: []
        - apiKeyAuth: [usage:read]
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageSummary" }

  /api/v1/customer/usage/daily:
    get:
      tags: [usage]
      summary: Per-day usage rollup
      operationId: usageDaily
      security:
        - sessionAuth: []
        - apiKeyAuth: [usage:read]
      parameters:
        - { in: query, name: days,      schema: { type: integer, default: 30, maximum: 90 } }
        - { in: query, name: date_from, schema: { type: string, format: date-time } }
        - { in: query, name: date_to,   schema: { type: string, format: date-time } }
        - { in: query, name: model,     schema: { type: string } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/UsageDailyRow" }

  /api/v1/customer/usage/export.csv:
    get:
      tags: [usage]
      summary: CSV export of usage rollup (max 90 days)
      operationId: usageExportCsv
      security:
        - sessionAuth: []
        - apiKeyAuth: [usage:read]
      parameters:
        - { in: query, name: date_from, schema: { type: string, format: date-time } }
        - { in: query, name: date_to,   schema: { type: string, format: date-time } }
        - { in: query, name: model,     schema: { type: string } }
      responses:
        "200":
          description: csv
          content:
            text/csv:
              schema: { type: string }
              example: |
                date,workload,units,credits
                2026-05-01,gpu_training,4,12
                2026-05-01,cpu_processing,3,3

  /api/v1/customer/credits/statement.csv:
    get:
      tags: [usage]
      summary: CSV credit statement (Stage 16)
      operationId: creditStatementCsv
      security:
        - sessionAuth: []
        - apiKeyAuth: [usage:read]
      parameters:
        - { in: query, name: date_from, schema: { type: string, format: date-time } }
        - { in: query, name: date_to,   schema: { type: string, format: date-time } }
        - { in: query, name: entry_type, schema: { type: string, enum: [grant, debit, refund, adjustment, correction, expire] } }
      responses:
        "200":
          description: csv
          content:
            text/csv:
              schema: { type: string }
              example: |
                created_at,entry_type,amount,balance_after,training_job_id,compute_unit_id,refund_of_id,source,reason
                2026-05-01T12:30:00Z,grant,100,100,,,,,Stage 17.1 alpha grant
                2026-05-01T12:31:14Z,training_debit,-3,97,tj_..,cu_..,,system,Compute unit completed (cpu_processing)

  /api/v1/customer/credits/ledger:
    get:
      tags: [usage]
      summary: Credit ledger entries (paginated)
      operationId: creditLedger
      security:
        - sessionAuth: []
        - apiKeyAuth: [usage:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - { in: query, name: entry_type, schema: { type: string, enum: [grant, debit, adjustment] } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageOfLedgerEntry" }

  # ---- webhooks (Stage 15) ------------------------------------------
  /api/v1/customer/webhooks:
    get:
      tags: [webhooks]
      summary: List webhooks
      operationId: listWebhooks
      security:
        - sessionAuth: []
        - apiKeyAuth: [webhooks:write]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PageOfWebhook" }
    post:
      tags: [webhooks]
      summary: Create a webhook (secret returned ONCE)
      operationId: createWebhook
      security:
        - sessionAuth: []
        - apiKeyAuth: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhook" }
      responses:
        "201":
          description: created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
        "400":
          $ref: "#/components/responses/WebhookInvalidUrl"

  /api/v1/customer/webhooks/{id}:
    delete:
      tags: [webhooks]
      summary: Delete a webhook
      operationId: deleteWebhook
      security:
        - sessionAuth: []
        - apiKeyAuth: [webhooks:write]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200": { description: ok }

  /api/v1/customer/webhooks/{id}/rotate-secret:
    post:
      tags: [webhooks]
      summary: Rotate the signing secret (Stage 16). Returns the new secret ONCE.
      operationId: rotateWebhookSecret
      security:
        - sessionAuth: []
        - apiKeyAuth: [webhooks:write]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookSummary"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: |
                          New plaintext signing secret. Old secret stays valid
                          until previous_secret_expires_at; after that, only
                          this secret verifies.
                      previous_secret_expires_at: { type: string, format: date-time, nullable: true }
                      grace_hours: { type: integer }

  /api/v1/customer/webhooks/{id}/test:
    post:
      tags: [webhooks]
      summary: Send a synthetic test event to a webhook
      operationId: testWebhook
      security:
        - sessionAuth: []
        - apiKeyAuth: [webhooks:write]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        "200":
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivered: { type: boolean }
                  response_status: { type: integer }
                  error_code: { type: string, nullable: true }

components:
  securitySchemes:
    sessionAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque-session-token
      description: |
        32-byte random base64url token issued by `POST /auth/login`. Send as
        `Authorization: Bearer <token>` or via the `gpu_agent_customer_session`
        cookie. The DB stores `sha256(token)`.
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        `sk_alpha_<24 hex>`. Hashed with `sha256(pepper + plaintext)`.
        Returned to the customer ONCE at create-time; lost = rotate.
        Optional scopes narrow the key's permissions.

  parameters:
    Limit:
      in: query
      name: limit
      schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
    Cursor:
      in: query
      name: cursor
      schema: { type: string }
      description: opaque base64url cursor returned in the previous page response
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      schema: { type: string, pattern: "^[A-Za-z0-9._:-]{8,128}$" }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { type: string }
            message: { type: string }
            request_id: { type: string }
            details: { type: object, additionalProperties: true }

    Scope:
      type: string
      # Stage 17.1 Final Purge — image_generation:write / image_generation:read removed.
      enum:
        - dataset:read
        - dataset:write
        - training:read
        - training:write
        - artifact:read
        - usage:read
        - api_keys:write
        - webhooks:write

    Page:
      type: object
      properties:
        limit: { type: integer }
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }

    PublicConfig:
      type: object
      properties:
        env: { type: string }
        portal_domain: { type: string, nullable: true }
        api_base_url: { type: string, nullable: true }
        limits: { $ref: "#/components/schemas/Limits" }
        signup_enabled: { type: boolean }

    Limits:
      type: object
      properties:
        max_jobs_per_minute: { type: integer }
        max_concurrent_jobs: { type: integer }
        max_tasks_per_day: { type: integer }

    Meta:
      type: object
      properties:
        api_version: { type: string, example: v1 }
        status: { type: string, example: alpha }
        compatibility: { type: string }
        environment: { type: string }
        api_base_url: { type: string, nullable: true }
        portal_domain: { type: string, nullable: true }
        docs_url: { type: string, nullable: true }
        supported_models: { type: array, items: { type: string } }
        rate_limit_policy: { type: object, additionalProperties: true }
        credit_policy: { type: object, additionalProperties: true }
        idempotency: { type: object, additionalProperties: true }
        pagination: { type: object, additionalProperties: true }
        signup_enabled: { type: boolean }

    PublicUser:
      type: object
      properties:
        id: { type: string }
        email: { type: string, format: email }
        role: { type: string }
        display_name: { type: string, nullable: true }
        status: { type: string }
        created_at: { type: string, format: date-time }
        last_login_at: { type: string, format: date-time, nullable: true }

    ApiKeySummary:
      type: object
      properties:
        id: { type: string }
        prefix: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, revoked] }
        scopes:
          type: array
          items: { $ref: "#/components/schemas/Scope" }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }

    ApiKeyCreated:
      allOf:
        - $ref: "#/components/schemas/ApiKeySummary"
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: |
                Plaintext API key — returned ONCE at creation. Format
                `sk_alpha_<24 hex>`. Save it now.

    PageOfApiKey:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/ApiKeySummary" }
        page: { $ref: "#/components/schemas/Page" }

    # Stage 17.1 Final Purge — CreateImageJob / JobCreated / JobSummary /
    # JobDetail / TaskWithArtifacts / Artifact / PageOfJob /
    # PageOfArtifact schemas REMOVED. Training-side schemas live in
    # docs/customer-custom-training-guide.md.

    UsageSummary:
      type: object
      properties:
        balance: { type: integer }
        total_granted: { type: integer }
        total_used: { type: integer }
        today_jobs: { type: integer }
        today_completed_jobs: { type: integer }
        limits: { $ref: "#/components/schemas/Limits" }

    UsageDailyRow:
      type: object
      properties:
        date: { type: string, format: date }
        modelId: { type: string }
        jobsCount: { type: integer }
        tasksCount: { type: integer }
        creditsUsed: { type: integer }
        artifactsCount: { type: integer }

    LedgerEntry:
      type: object
      properties:
        id: { type: string }
        entryType: { type: string, enum: [grant, debit, adjustment] }
        amount: { type: integer }
        reason: { type: string }
        taskId: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }

    PageOfLedgerEntry:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/LedgerEntry" }
        page: { $ref: "#/components/schemas/Page" }

    CreateWebhook:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: must be HTTPS unless WEBHOOK_DEVELOPER_MODE is on
        events:
          type: array
          items: { type: string, enum: [job.completed, job.failed, artifact.ready] }
          default: [job.completed]
        description: { type: string }

    WebhookSummary:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        events: { type: array, items: { type: string } }
        status: { type: string, enum: [active, paused, disabled] }
        description: { type: string, nullable: true }
        last_success_at: { type: string, format: date-time, nullable: true }
        last_failure_at: { type: string, format: date-time, nullable: true }
        consecutive_failures: { type: integer }
        created_at: { type: string, format: date-time }

    WebhookCreated:
      allOf:
        - $ref: "#/components/schemas/WebhookSummary"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: |
                HMAC-SHA256 signing secret — returned ONCE at creation. Use
                with X-GPU-Agent-Signature header verification.

    PageOfWebhook:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookSummary" }
        page: { $ref: "#/components/schemas/Page" }

  responses:
    ValidationError:
      description: 400 validation error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: VALIDATION_ERROR
              message: dataset_id required
              request_id: req_a1b2c3d4
    InvalidCredentials:
      description: 401 invalid credentials
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: UNAUTHORIZED
              message: Invalid credentials
              request_id: req_a1b2c3d4
    CustomerForbidden:
      description: 403 customer disabled / signup disabled / wrong role
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: CUSTOMER_FORBIDDEN
              message: Public signup is disabled.
              request_id: req_a1b2c3d4
    ScopeDenied:
      description: 403 API key missing required scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: API_KEY_SCOPE_DENIED
              message: API key missing required scope
              request_id: req_a1b2c3d4
              details: { required_scope: training:write }
    NotFound:
      description: 404 not found
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    # JobNotFound — REMOVED (Stage 17.1 Final Purge). Use NotFound for 404.
    CreditInsufficient:
      description: 402 insufficient Alpha credits
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: CREDIT_INSUFFICIENT
              message: "Insufficient credits: balance=0, projected_cost=4"
              request_id: req_a1b2c3d4
              details: { balance: 0, projected_cost: 4 }
    IdempotencyConflict:
      description: 409 idempotency key reused with different body
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: IDEMPOTENCY_CONFLICT
              message: Idempotency-Key reused with a different request body
              request_id: req_a1b2c3d4
              details: { idempotency_key: client-uuid-1 }
    RateLimited:
      description: 429 rate or quota limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit:     { schema: { type: integer } }
        X-RateLimit-Remaining: { schema: { type: integer } }
        X-RateLimit-Reset:     { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: RATE_LIMITED
              message: Exceeded max_jobs_per_minute=10
              request_id: req_a1b2c3d4
              details: { retry_after_seconds: 12, limit_kind: jobs_per_minute }
    WebhookInvalidUrl:
      description: 400 invalid webhook url (e.g. plaintext / private IP)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: WEBHOOK_INVALID_URL
              message: webhook URL must be HTTPS and resolve to a public host
              request_id: req_a1b2c3d4
