openapi: 3.1.0
info:
  title: NextPKI Public API
  version: "1.0.0"
  description: |
    Public, token-authenticated REST/JSON API for the NextPKI control plane.
    Clients and agents authenticate with `Authorization: Bearer
    npapi.<tenant>.<random>`. Every response is scoped to the token's tenant and
    that isolation is enforced in the database, not in application code.

    v1 is deliberately narrow: read your certificate inventory and request
    renewals of certificates that already exist. Issuing new certificates,
    managing domains and driving the private CA are not exposed here.

    Documentation: https://docs.nextpki.com
servers:
  - url: https://api.nextpki.com
  - url: https://api.nextpki.de
security:
  - bearerAuth: []
paths:
  /v1/auth/validate:
    get:
      summary: Validate a token
      description: |
        Confirms the token and reports the tenant and scopes it carries. Requires
        no particular scope - use it as a health check for credentials.
      operationId: authValidate
      responses:
        "200":
          description: Token is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  tenant_id: { type: string, format: uuid }
                  name: { type: string }
                  scopes:
                    type: array
                    items: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/certificates:
    get:
      summary: List certificates
      description: |
        Returns the certificate inventory for the token's tenant, newest
        observation first. There is no server-side expiry filter in v1 - page
        through and sort on `not_after` client-side.
      operationId: listCertificates
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
        - { name: cert_type, in: query, schema: { type: string } }
        - { name: org_id, in: query, schema: { type: string, format: uuid }, description: "Restrict to certificates assigned to this organisation." }
      responses:
        "200":
          description: A page of results
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/CertificateSummary" }
                  limit: { type: integer }
                  offset: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/certificates/{id}:
    get:
      summary: Get a certificate
      description: |
        Full metadata for one certificate, including subject DN, SANs and when it
        was first seen. Returns 404 for an identifier that belongs to another
        tenant - an existence check would itself leak information.
      operationId: getCertificate
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CertificateDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/certificates/{id}/renew:
    post:
      summary: Request a renewal
      operationId: renewCertificate
      description: |
        Opens a renewal request in state `pending_approval`. This does **not**
        return a certificate: an approver has to approve the request in the
        Console before any certificate authority is contacted, because issuance
        costs money. Poll `GET /v1/requests/{id}` to follow progress.

        Requires a PKCS#10 CSR and the connector to order through. Send the CSR
        only - NextPKI never accepts or wants a private key. Generate the key on
        the host that will serve the certificate.

        Not idempotent in v1: there is no idempotency key, so after an ambiguous
        timeout check `GET /v1/requests` for this certificate before retrying, or
        you may open a second renewal.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [connector_id, csr]
              properties:
                connector_id: { type: string, format: uuid }
                csr: { type: string, description: "PEM-encoded PKCS#10 certificate signing request. Public data only - never send a private key." }
                org_id: { type: string, format: uuid, description: "Optional organisation assignment. Inherited from the certificate when omitted." }
      responses:
        "201":
          description: Renewal request created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  state: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/requests:
    get:
      summary: List renewal requests
      description: |
        Lifecycle requests for the token's tenant. Filter by `state` to find work
        waiting on approval (`pending_approval`) or renewals that failed.
      operationId: listRequests
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0, minimum: 0 } }
        - { name: state, in: query, schema: { type: string } }
        - { name: org_id, in: query, schema: { type: string, format: uuid }, description: "Restrict to requests assigned to this organisation." }
      responses:
        "200":
          description: Liste
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Request" }
                  limit: { type: integer }
                  offset: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/requests/{id}:
    get:
      summary: Get a renewal request
      description: |
        Current state of one renewal. `state_reason` carries the failure detail
        when `state` is `failed`.
      operationId: getRequest
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Detail
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Request" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Scoped NextPKI API token, of the form npapi.<tenant>.<random>. Stored as a SHA-256 hash; shown once at creation."
  responses:
    BadRequest:
      description: Malformed request - fix it rather than retrying
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, malformed, unknown or revoked token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The token's scopes do not cover this endpoint
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Not found, or not visible to this tenant
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    CertificateSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        fingerprint_sha256: { type: string }
        common_name: { type: string, nullable: true }
        issuer_dn: { type: string }
        serial_number: { type: string }
        not_before: { type: string, format: date-time }
        not_after: { type: string, format: date-time }
        cert_type: { type: string }
        key_algorithm: { type: string }
        is_revoked: { type: boolean }
        trust_status: { type: string }
        last_seen_at: { type: string, format: date-time }
        org_id: { type: string, format: uuid, nullable: true, description: "Organisation this certificate is assigned to, if any." }
    CertificateDetail:
      allOf:
        - $ref: "#/components/schemas/CertificateSummary"
        - type: object
          properties:
            subject_dn: { type: string }
            signature_algorithm: { type: string }
            san_dns: { type: array, items: { type: string } }
            san_email: { type: array, items: { type: string } }
            first_seen_at: { type: string, format: date-time }
    Request:
      type: object
      properties:
        id: { type: string, format: uuid }
        previous_cert_id: { type: string, format: uuid, nullable: true }
        new_cert_id: { type: string, format: uuid, nullable: true }
        via_connector_id: { type: string, format: uuid, nullable: true }
        state:
          type: string
          enum: [pending_approval, approved, csr_generated, submitted, issued, deployed, failed, cancelled]
        state_reason: { type: string, nullable: true }
        attempts: { type: integer }
        org_id: { type: string, format: uuid, nullable: true }
        scheduled_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
