openapi: 3.1.0
info:
  title: YorkHost Client API
  version: "1.0"
  description: |
    API publique des clients YorkHost. Authentification par clé API créée dans
    l'espace client (page « Clés API ») : `Authorization: Bearer yhk_client_v1_…`.

    Chaque route exige un scope, choisi à la création de la clé. Une clé peut
    aussi être restreinte à certains services : les autres répondent 404.
    Le compte doit avoir la double authentification active.

    Le serveur MCP `POST /client/mcp` expose les mêmes opérations comme outils,
    avec la même clé (Claude Code, Cursor, VS Code…).

    Le test `TestOpenAPIMatchesRoutes` (cmd/api) garantit que ce document et
    les routes montées restent identiques.
servers:
  - url: https://api.yorkhost.fr
security:
  - apiKey: []
tags:
  - name: account
  - name: services
  - name: billing
  - name: support
  - name: vps
  - name: game
  - name: antiddos
  - name: orders
  - name: sandboxes

paths:
  /client/v1/account:
    get:
      tags: [account]
      summary: Profil du compte
      description: "Scope : `account:read`."
      responses:
        "200": { description: "Profil (e-mail, nom, solde de crédit)" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /client/v1/services:
    get:
      tags: [services]
      summary: Liste des services
      description: "Scope : `services:read`. Filtrée par la restriction de services de la clé."
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/limit"
      responses:
        "200": { $ref: "#/components/responses/Paginated" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /client/v1/services/{id}:
    get:
      tags: [services]
      summary: Détail d'un service
      description: "Scope : `services:read`."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      responses:
        "200": { description: Service }
        "404": { $ref: "#/components/responses/NotFound" }

  /client/v1/invoices:
    get:
      tags: [billing]
      summary: Liste des factures
      description: "Scope : `billing:read`."
      parameters:
        - $ref: "#/components/parameters/page"
        - $ref: "#/components/parameters/limit"
      responses:
        "200": { $ref: "#/components/responses/Paginated" }
  /client/v1/invoices/{id}:
    get:
      tags: [billing]
      summary: Détail d'une facture
      description: "Scope : `billing:read`."
      parameters: [ { $ref: "#/components/parameters/invoiceId" } ]
      responses:
        "200": { description: Facture et lignes }
        "404": { $ref: "#/components/responses/NotFound" }
  /client/v1/invoices/{id}/pdf:
    get:
      tags: [billing]
      summary: Lien de téléchargement PDF
      description: "Scope : `billing:read`. Renvoie une URL signée, valable quelques minutes."
      parameters: [ { $ref: "#/components/parameters/invoiceId" } ]
      responses:
        "200": { description: "`{ url }`" }
        "404": { $ref: "#/components/responses/NotFound" }

  /client/v1/invoices/{id}/payment-link:
    get:
      tags: [billing]
      summary: Lien de paiement d'une facture
      description: "Scope : `billing:read`. Statut frais de la facture et, si elle est impayée, `payment_url` : page sécurisée de l'espace client (3-D Secure, PayPal, crédit). Aucun paiement ne transite par l'API."
      parameters: [ { $ref: "#/components/parameters/invoiceId" } ]
      responses:
        "200": { description: "Statut, total, échéance, `payment_url` si impayée" }
        "404": { $ref: "#/components/responses/NotFound" }

  /client/v1/catalog:
    get:
      tags: [orders]
      summary: Produits commandables par API
      description: "Scope : `services:read`. Liste blanche des produits commandables (tarifs par cycle `m`, `q`, `s`, `a`), plafond de dépense journalier et offres de sandboxes agents."
      responses:
        "200": { description: "Produits, cycles, plafond, offres sandbox" }

  /client/v1/orders:
    post:
      tags: [orders]
      summary: Commander un produit (préflight puis confirmation)
      description: |
        Scope : `orders:write`. Limite : 5 par minute et par clé.

        1. Sans `confirmation_token` : **rien n'est commandé**. La réponse donne le prix et un `confirmation_token` (10 min) lié à la clé, au produit, au cycle et au prix.
        2. Après accord explicite du client, même appel avec `confirmation_token` : commande + facture HostBill. Réponse `201` avec `invoice_id`, `total` et `payment_url` (ou `invoice_status: paid` si le crédit du compte a été imputé).

        Le jeton ne sert qu'une fois (`409 already_ordered` sinon). Plafond de dépense par compte et par jour (`403 spend_cap`). Sans Redis, les commandes sont refusées (`503 order_unavailable`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id]
              properties:
                product_id: { type: integer }
                cycle: { type: string, enum: [m, q, s, a], default: m }
                confirmation_token: { type: string }
      responses:
        "200": { description: "Préflight : prix + `confirmation_token`" }
        "201": { description: "Commande passée : facture et `payment_url`" }
        "403": { description: "Scope manquant ou plafond journalier atteint (`spend_cap`)" }
        "404": { description: "Produit non commandable par API (`not_orderable`)" }
        "409": { description: "Confirmation expirée ou déjà utilisée (`quote_invalid`, `already_ordered`), cycle non proposé" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /client/v1/tickets:
    get:
      tags: [support]
      summary: Liste des tickets
      description: "Scope : `tickets:read`."
      responses:
        "200": { description: Tickets }
    post:
      tags: [support]
      summary: Ouvrir un ticket
      description: "Scope : `tickets:write`. Limite : 5 par minute et par clé."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body]
              properties:
                subject: { type: string }
                body: { type: string }
                requestType: { type: string, enum: [HELP, BILLING, CANCELLATION, OTHER] }
                serviceId: { type: string, description: ID du service concerné }
      responses:
        "200": { description: "`{ id, number }`" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /client/v1/ticket-departments:
    get:
      tags: [support]
      summary: Types de demande
      description: "Scope : `tickets:read`."
      responses:
        "200": { description: "Liste `{ id, name }`" }
  /client/v1/tickets/{id}:
    get:
      tags: [support]
      summary: Détail d'un ticket
      description: "Scope : `tickets:read`."
      parameters: [ { $ref: "#/components/parameters/ticketId" } ]
      responses:
        "200": { description: Ticket et messages }
        "404": { $ref: "#/components/responses/NotFound" }
  /client/v1/tickets/{id}/reply:
    post:
      tags: [support]
      summary: Répondre à un ticket
      description: "Scope : `tickets:write`. Limite : 10 par minute et par clé."
      parameters: [ { $ref: "#/components/parameters/ticketId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
      responses:
        "200": { description: Réponse enregistrée }
        "429": { $ref: "#/components/responses/RateLimited" }

  /client/v1/vps/{id}:
    get:
      tags: [vps]
      summary: État d'un VPS
      description: "Scope : `vps:read`."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      responses:
        "200": { description: "État, ressources, IPs" }
        "404": { $ref: "#/components/responses/NotFound" }
  /client/v1/vps/{id}/metrics:
    get:
      tags: [vps]
      summary: Métriques d'un VPS (1 h)
      description: "Scope : `vps:read`."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      responses:
        "200": { description: Séries CPU / RAM / réseau }
  /client/v1/vps/{id}/power:
    post:
      tags: [vps]
      summary: Démarrer, arrêter ou redémarrer un VPS
      description: "Scope : `vps:power`. Limite : 10 par minute et par clé."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [start, stop, restart] }
      responses:
        "200": { description: Action lancée }
        "429": { $ref: "#/components/responses/RateLimited" }

  /client/v1/game-servers/{id}:
    get:
      tags: [game]
      summary: État d'un serveur de jeu
      description: "Scope : `game:read`."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      responses:
        "200": { description: "État, adresse, ressources en direct" }
        "404": { $ref: "#/components/responses/NotFound" }
  /client/v1/game-servers/{id}/metrics:
    get:
      tags: [game]
      summary: Métriques d'un serveur de jeu
      description: "Scope : `game:read`."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      responses:
        "200": { description: Séries CPU / RAM }
  /client/v1/game-servers/{id}/power:
    post:
      tags: [game]
      summary: Démarrer, arrêter, redémarrer ou tuer un serveur de jeu
      description: "Scope : `game:power`. Limite : 10 par minute et par clé."
      parameters: [ { $ref: "#/components/parameters/serviceId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [start, stop, restart, kill] }
      responses:
        "200": { description: Action lancée }
        "429": { $ref: "#/components/responses/RateLimited" }

  /client/v1/antiddos:
    get:
      tags: [antiddos]
      summary: Attaques DDoS détectées
      description: "Scope : `antiddos:read`. Cibles et attaques filtrées par la restriction de services de la clé."
      parameters:
        - name: days
          in: query
          schema: { type: integer, minimum: 1, maximum: 365, default: 90 }
      responses:
        "200": { description: Cibles protégées et attaques }

  /client/v1/sandboxes:
    get:
      tags: [sandboxes]
      summary: Essais agents du compte
      description: "Scope : `sandbox:read`. VM d'essai pour agents IA (Linux, bureau Linux, bureau Windows), sans IP publique : commande SSH (via la passerelle yorkhost.sh), URL de preview, échéances, et avancement du passage en VPS."
      responses:
        "200": { description: "Sandboxes vivantes et offres disponibles" }
    post:
      tags: [sandboxes]
      summary: Créer un essai agent
      description: "Scope : `sandbox:write`. Limite : 3 par minute et par clé, 3 essais par compte (réglable). Gratuit pendant sa fenêtre de fonctionnement (60 min par défaut), puis arrêté ; supprimé avec ses fichiers au bout de 24 h sauf passage en VPS (`/keep`). Sans IP publique : `ssh <id>@yorkhost.sh` avec la clé fournie, applications web sur `preview_url`. Disque, réseau et CPU bridés. `desktop_url` n'est montrée qu'à la création (voir `/links`)."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ssh_public_key]
              properties:
                kind: { type: string, enum: [linux, desktop, windows], default: linux }
                ssh_public_key: { type: string, description: "Une ligne OpenSSH (ssh-ed25519, ecdsa, rsa ≥ 2048)" }
      responses:
        "201": { description: "Sandbox créée (SSH, preview, bureau)" }
        "400": { description: "Type indisponible ou clé SSH invalide" }
        "429": { description: "Quota de sandboxes atteint" }
        "503": { description: "Capacité atteinte ou créations suspendues" }
  /client/v1/sandboxes/claim:
    post:
      tags: [sandboxes]
      summary: Réclamer un essai anonyme (ssh yorkhost.sh) et le passer en VPS
      description: "Scope : `orders:write`. Rattache au compte l'essai créé anonymement dont on fournit le jeton `yhsc_…` (ou le lien complet) et commande un VPS classique (VPS-2) : même préflight / confirmation et mêmes garde-fous que `POST /client/v1/orders`. Facture payée : HostBill crée le VPS, le dossier `/home/agent` de l'essai y est copié et vérifié, puis l'essai est supprimé."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [claim_token]
              properties:
                claim_token: { type: string }
                confirmation_token: { type: string }
      responses:
        "200": { description: "Préflight : prix + `confirmation_token`" }
        "201": { description: "Commande passée : facture et `payment_url`" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: "Essai déjà réclamé, expiré, ou confirmation invalide" }
  /client/v1/sandboxes/{id}:
    get:
      tags: [sandboxes]
      summary: Détail d'une sandbox
      description: "Scope : `sandbox:read`."
      parameters: [ { $ref: "#/components/parameters/sandboxId" } ]
      responses:
        "200": { description: Sandbox }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [sandboxes]
      summary: Supprimer un essai
      description: "Scope : `sandbox:write`. Supprime la VM et ses fichiers. Un essai en cours de passage en VPS ne se supprime plus ici (`409 billed_service`)."
      parameters: [ { $ref: "#/components/parameters/sandboxId" } ]
      responses:
        "200": { description: Supprimée }
        "404": { $ref: "#/components/responses/NotFound" }
  /client/v1/sandboxes/{id}/links:
    post:
      tags: [sandboxes]
      summary: Nouveau lien bureau
      description: "Scope : `sandbox:write`. Génère un nouveau `desktop_url` ; l'ancien cesse de fonctionner."
      parameters: [ { $ref: "#/components/parameters/sandboxId" } ]
      responses:
        "200": { description: "Sandbox avec `desktop_url`" }
  /client/v1/sandboxes/{id}/keep:
    post:
      tags: [sandboxes]
      summary: Passer un essai en VPS
      description: "Scope : `orders:write`. Commande un VPS classique (VPS-2, IP publique, géré dans l'espace client) : préflight / confirmation et garde-fous de `POST /client/v1/orders`. Facture payée : le dossier `/home/agent` de l'essai est copié dans le nouveau VPS, puis l'essai est supprimé."
      parameters: [ { $ref: "#/components/parameters/sandboxId" } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                confirmation_token: { type: string }
      responses:
        "200": { description: "Préflight : prix + `confirmation_token`" }
        "201": { description: "Commande passée : facture et `payment_url`" }
        "409": { description: "Essai non convertible ou confirmation invalide" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: Clé `yhk_client_v1_<id>_<secret>` créée dans l'espace client.
  parameters:
    serviceId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: ID du service (voir `GET /client/v1/services`)
    invoiceId:
      name: id
      in: path
      required: true
      schema: { type: integer }
    sandboxId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: "^sbx[a-z2-7]{12}$" }
      description: ID de la sandbox (voir `GET /client/v1/sandboxes`)
    ticketId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Numéro ou identifiant du ticket
    page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
  responses:
    Paginated:
      description: Liste paginée
      content:
        application/json:
          schema:
            type: object
            properties:
              items: { type: array, items: { type: object } }
              page: { type: integer }
              pageSize: { type: integer }
              total: { type: integer }
              hasMore: { type: boolean }
    Unauthorized:
      description: Clé absente, invalide, révoquée, expirée, ou A2F inactive (`code` = `missing_token`, `invalid_token`, `mfa_required`)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Scope manquant (`missing_scope`, avec `required_scope`) ou IP hors allowlist (`ip_not_allowed`)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Ressource inexistante, hors restriction de la clé, ou appartenant à un autre client
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Limite atteinte (`Retry-After` en secondes)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        code: { type: string }
        required_scope: { type: string }
