openapi: 3.1.0

info:
  title: Tvarka ATK - eID Authentication API
  version: 1.3.0
  summary: LT ATK authentication, with optional Smart-ID and Mobile-ID server-side methods.
  description: |
    Card-native LT eID (ATK) **authentication** (login). A relying party sends its user through a
    challenge-response: the user's ATK card signs a server-issued, audience-bound challenge with its
    **authentication key** (after PIN), and this API returns a verified identity `{authentication
    certificate, raw signature, optional assertion}`. It is a proof of possession + PIN, not a
    qualified signature.

    This is scope v15 of `tvarka_atk_qes_api_spec.md`, the `/v1/auth/*` slice. It is **one API, two
    tiers**:
      * **Free tier (Surface A):** auth only, zero-rated. National services (ePaslaugos, Sodra) are
        registered *providers* on this tier. This document + a reference client is what ePaslaugos
        audits.
      * **Paid tier (Surface B):** the same auth endpoints **plus** `/v1/sign/*` (QES) + metering,
        documented separately.

    Both tiers use the **same provider credential management** below; only capability scope and pricing
    differ per provider.

    ## ATK card flow (two-phase, per request)
      1. `POST /v1/auth/requests` (server-to-server, HTTP Basic) - the provider backend creates a request
         and receives a scoped `clientToken`.
      2. `POST /v1/auth/{requestId}/certificate` (Bearer clientToken) - the client reads the card's
         **authentication** certificate and submits it; the API returns the exact `dtbs` (data-to-be-
         signed) + the challenge.
      3. The client drives the card to sign `dtbs` with the auth key (after PIN).
      4. `POST /v1/auth/{requestId}/complete` (Bearer clientToken) - the client submits the raw signature
         + the single-use `operationToken`; the API verifies it and returns the verified identity.

    ## Smart-ID and Mobile-ID flow (server-side)
      1. `POST /v1/auth/requests` uses `method:"smart_id"` or `method:"mobile_id"`, with
         `personalCode` and, for Mobile-ID, `phoneNumber`.
      2. The response contains the user verification code and no `clientToken`. Display the code and
         poll `GET /v1/auth/{requestId}` with Basic credentials (or consume the terminal webhook).
      3. ATK verifies the returned certificate, Lithuanian identity, audience-bound signature, trust,
         key usage, and revocation before publishing `status:"done"`.

    ## Card facts
      * Access is fixed CAN + PIN over PACE; one user PIN. **CAN and PIN never reach this API** - they are
        consumed by the card stack on the client device.
      * The card signs **ECDSA P-256 or P-384** (LT card is dual-curve). `signatureAlgorithm` is reported
        by the card; the `dtbs` digest matches the key curve (P-256 -> SHA-256, P-384 -> SHA-384).
      * ECDSA signatures are accepted as raw P1363 (r||s) or DER (`signatureEncoding`).

    ## Get access
      Request a zero-rated sandbox key at <https://atk.tvarka.pro/docs/access/> (issued after a short
      review; test material only). Production credentials are set up with you directly. Full docs,
      rendered reference, and Postman collections at <https://atk.tvarka.pro/docs/>.
  termsOfService: https://tvarka.pro
  contact:
    name: Tvarka
    url: https://atk.tvarka.pro/docs/
    email: info@tvarka.pro
  license:
    name: Proprietary
    url: https://tvarka.pro
  x-status: >-
    IMPLEMENTED through 2026-08-02 (tvarka/atk/ Django app): /auth/* physical, NFC, Smart-ID, and Mobile-ID ceremonies + provider
    credentials + tokens + `assertion` JWT + JWKS (/.well-known/atk-jwks.json) + per-provider rate limiting
    + retention/deletion (POST /v1/auth/{id}/delete, POST /v1/erasure). Sandbox IMPLEMENTED: providers with
    environment=sandbox may present test cards chained to ATK_SANDBOX_TRUST_ANCHORS_PEM (production LT chain
    checked first; production providers never trust the sandbox CA); test cards are minted with `manage.py
    atk_issue_test_card` and driven by the reference client's FileCardDriver. Shared one-claim pairing,
    Android/iOS ATK client source and Tvarka's internal NFC login bridge are implemented behind independent
    flags. The published production mobile support boundary is Android; iOS remains pre-release until its
    CoreNFC real-card/session-budget release gate passes.
    Provider (tenant) management is internal (Django admin + the /ops/atk panel) and is not part of
    this public contract; the provider data model lives in spec section 9.

servers:
  - url: https://atk.tvarka.pro/v1
    description: |
      Production. Sandbox runs on the same host - provider credentials issued with
      environment=sandbox get the identical API against test-card material and are never billed.

tags:
  - name: Auth
    description: The eID authentication ceremony (`/v1/auth/*`).
  - name: Pairing
    description: NFC remote pairing - complete a request by tapping a card on a different device.
  - name: Well-known
    description: Keys for verifying the optional `assertion` JWT.

security:
  - basicAuth: []

paths:

  # ---------------------------------------------------------------------------
  # Auth ceremony
  # ---------------------------------------------------------------------------

  /auth/requests:
    post:
      tags: [Auth]
      operationId: createAuthRequest
      summary: Create an authentication request (server-to-server)
      description: |
        Called by the provider's backend with HTTP Basic (`keyId:keySecret`). `audience` must be an
        exact origin on the provider's allow-list. Physical requests return a short-lived `clientToken`;
        NFC requests return a pairing hint; Smart-ID/Mobile-ID requests remain entirely server-side.
      x-codeSamples:
        - lang: curl
          label: Smart-ID
          source: |
            curl -u "$ATK_KEY_ID:$ATK_KEY_SECRET" https://atk.tvarka.pro/v1/auth/requests \
              -H 'Content-Type: application/json' \
              -d '{"audience":"https://app.example","method":"smart_id","personalCode":"39001010000"}'
        - lang: curl
          label: Mobile-ID
          source: |
            curl -u "$ATK_KEY_ID:$ATK_KEY_SECRET" https://atk.tvarka.pro/v1/auth/requests \
              -H 'Content-Type: application/json' \
              -d '{"audience":"https://app.example","method":"mobile_id","personalCode":"39001010000","phoneNumber":"+37061234567"}'
      security:
        - basicAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AuthRequestCreate' }
            examples:
              physicalAtk:
                summary: Primary ATK desktop-reader method
                value: { audience: https://app.example, method: physical }
              smartId:
                summary: Optional Smart-ID method
                value: { audience: https://app.example, method: smart_id, personalCode: '39001010000' }
              mobileId:
                summary: Optional Mobile-ID method
                value: { audience: https://app.example, method: mobile_id, personalCode: '39001010000', phoneNumber: '+37061234567' }
      responses:
        '202':
          description: Request created; awaiting the ATK client or remote user authorization.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthRequestCreated' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /auth/{requestId}/certificate:
    post:
      tags: [Auth]
      operationId: submitAuthCertificate
      summary: Submit the card's authentication certificate; receive the DTBS + challenge
      description: |
        Called by the client with `Authorization: Bearer <clientToken>`. The API validates the
        certificate (chain, revocation, purpose = authentication, key usage), builds the canonical
        challenge, and returns the exact `dtbs` the card must sign plus a single-use `operationToken`.
        Physical/NFC only; server-side methods do not call this endpoint.
      security:
        - clientToken: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CertificateSubmit' }
      responses:
        '200':
          description: DTBS + challenge + operation token.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CertificateChallenge' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /auth/{requestId}/complete:
    post:
      tags: [Auth]
      operationId: completeAuth
      summary: Submit the raw card signature; receive the verified identity
      description: |
        Called by the client with `Authorization: Bearer <clientToken>` and the single-use
        `operationToken` in the body. The API verifies the signature against the stored `dtbs` and the
        certificate's public key, enforces the identity-swap guard, and returns the verified identity
        (and the optional `assertion` JWT). Retry-safe: a repeat with the same tuple returns the same
        result; a different signature/cert after the token is claimed is rejected.
        Physical/NFC only; server-side methods complete asynchronously.
      security:
        - clientToken: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompleteSubmit' }
      responses:
        '200':
          description: Authenticated - verified identity.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /auth/{requestId}:
    get:
      tags: [Auth]
      operationId: getAuthRequest
      summary: Poll request status / fetch the result
      description: |
        Readable with the provider's Basic credentials for every method, or with the request's
        `clientToken` for physical/NFC requests. Smart-ID/Mobile-ID providers poll this endpoint or
        consume the terminal webhook.
      security:
        - basicAuth: []
        - clientToken: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Current state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthRequestState' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /auth/{requestId}/cancel:
    post:
      tags: [Auth]
      operationId: cancelAuthRequest
      summary: Cancel a non-terminal request
      security:
        - basicAuth: []
        - clientToken: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthRequestState' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  # ---------------------------------------------------------------------------
  # Data deletion (GDPR; spec section 9)
  # ---------------------------------------------------------------------------

  /auth/{requestId}/delete:
    post:
      tags: [Erasure]
      operationId: deleteAuthRequest
      summary: Erase one request on demand (GDPR)
      description: |
        Basic only, scoped to the caller's own provider. A non-terminal request is cancelled first,
        then its document/result bytes + derived personal data are purged (exactly what the retention
        job strips); a de-identified audit row remains. Idempotent.
      security:
        - basicAuth: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Erased.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requestId: { type: string, format: uuid }
                  deleted: { type: boolean }
        '404': { $ref: '#/components/responses/NotFound' }

  /erasure:
    post:
      tags: [Erasure]
      operationId: bulkErasure
      summary: Bulk-erase the provider's terminal requests (GDPR)
      description: |
        Basic only. Purges document/result bytes + derived personal data of every TERMINAL,
        not-yet-purged request of the calling provider (auth and sign alike); optional `before`
        (ISO-8601) restricts to requests last updated before that moment. In-flight requests are
        left untouched - erase them individually if intended.
      security:
        - basicAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                before: { type: string, format: date-time }
      responses:
        '200':
          description: Erasure summary.
          content:
            application/json:
              schema:
                type: object
                properties:
                  purged: { type: integer }
                  failed: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  # ---------------------------------------------------------------------------
  # NFC remote pairing
  # ---------------------------------------------------------------------------

  /auth/{requestId}/pairing:
    get:
      tags: [Pairing]
      operationId: getAuthPairing
      summary: Get a pairing token/QR to complete this request on a phone
      description: |
        Only for `method:"nfc"`. Basic only - the integrator's server rotates the one-time token.
        Returns a high-entropy, short-lived, one-claim pairing token and a deep link / QR the phone
        scans. The phone then calls `/pairing/claim`, receives the request's `clientToken`, and
        drives the same `/certificate` + `/complete` endpoints.
      security:
        - basicAuth: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      responses:
        '200':
          description: Pairing payload.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PairingInfo' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /pairing/claim:
    post:
      tags: [Pairing]
      operationId: claimPairing
      summary: Claim a pairing token from the tapping phone
      description: |
        No Basic auth - authorized solely by the high-entropy `pairingToken`. One-claim, rate-limited,
        stored hashed. Returns the request's `clientToken` and a minimal `summary` (purpose + label
        only, never document contents).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PairingClaimRequest' }
      responses:
        '200':
          description: Claimed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PairingClaimResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ---------------------------------------------------------------------------
  # Well-known
  # ---------------------------------------------------------------------------

  /.well-known/atk-jwks.json:
    servers:
      - url: https://atk.tvarka.pro
        description: Host root - the JWKS lives outside the /v1 base path.
    get:
      tags: [Well-known]
      operationId: getJwks
      summary: JWKS for verifying the optional `assertion` JWT
      description: |
        Public keys (by `kid`) for the ES256 signer. Rotation keeps current + previous keys published so
        in-flight assertions verify.
      security: []
      responses:
        '200':
          description: Key set.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Jwks' }
        '404': { $ref: '#/components/responses/NotFound' }

components:

  securitySchemes:
    basicAuth:
      type: http
      scheme: basic
      description: |
        Provider server-to-server credentials `keyId:keySecret`. Server-to-server only, also gated by the
        provider's server IP allow-list. Never placed in a browser/mobile client.
    clientToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Short-lived, per-request JWT (`ES256`) minted at request creation, scoped
        `{tenant, requestId, purpose, method, allowedOrigins}`. Safe to hand to a browser/mobile client:
        it cannot create requests, read tenant data, or touch other requests. For browser clients the API
        enforces `Origin` against `allowedOrigins` (rejects cross-origin replay).

  parameters:
    RequestId:
      name: requestId
      in: path
      required: true
      description: The `requestId` returned by `POST /auth/requests`.
      schema: { type: string, format: uuid }

  schemas:

    Method:
      type: string
      enum: [physical, nfc, smart_id, mobile_id]
      example: physical
      description: physical = desktop reader; nfc = phone tap; smart_id/mobile_id = server-side remote eID.

    Status:
      type: string
      description: |
        Flat request status. Non-terminal - `pending`, `awaitingCard`, `awaitingCredentials`,
        `finalizing`. Terminal success - `done`. Terminal failure -
        `cancelled`/`timeout`/`deviceError`/`pinBlocked`/`cardRemoved`/`certInvalid`.
        `awaitingCredentials` is SDK-reported (CAN/PIN entry is out-of-band).
      enum:
        [pending, awaitingCard, awaitingCredentials, finalizing, done,
         cancelled, timeout, deviceError, pinBlocked, cardRemoved, certInvalid]

    HashAlgOid:
      type: string
      description: Digest algorithm OID, curve-matched. SHA-256 = 2.16.840.1.101.3.4.2.1; SHA-384 = 2.16.840.1.101.3.4.2.2.
      enum: ['2.16.840.1.101.3.4.2.1', '2.16.840.1.101.3.4.2.2']

    SignatureAlgorithm:
      type: string
      description: Card-reported JWS-style algorithm.
      enum: [ES256, ES384]

    SignatureEncoding:
      type: string
      enum: [P1363, DER]
      default: P1363
      description: >-
        Raw ECDSA signature encoding. P1363 = fixed-width r||s; DER = SEQUENCE(r, s). Informational -
        the server auto-detects and accepts either encoding regardless of this field.

    CertSubject:
      type: object
      description: Selected subject fields from the certificate. `serialNumber` holds `PNOLT-<personal code>`.
      properties:
        serialNumber: { type: string, example: 'PNOLT-39001010000' }
        givenName: { type: string }
        sn: { type: string, description: Surname. }
        cn: { type: string }
        c: { type: string, example: LT }

    AuthRequestCreate:
      type: object
      required: [audience, method]
      additionalProperties: false
      oneOf:
        - title: Physical ATK
          properties: { method: { const: physical } }
          not:
            anyOf:
              - required: [personalCode]
                properties: { personalCode: {} }
              - required: [phoneNumber]
                properties: { phoneNumber: {} }
        - title: ATK over NFC
          properties: { method: { const: nfc } }
          not:
            anyOf:
              - required: [personalCode]
                properties: { personalCode: {} }
              - required: [phoneNumber]
                properties: { phoneNumber: {} }
        - title: Smart-ID
          required: [personalCode]
          properties: { method: { const: smart_id } }
          not:
            required: [phoneNumber]
            properties: { phoneNumber: {} }
        - title: Mobile-ID
          required: [personalCode, phoneNumber]
          properties: { method: { const: mobile_id } }
      properties:
        audience:
          type: string
          description: The relying-party origin (must be exactly on the provider's allow-list), e.g. https://www.epaslaugos.lt.
          example: https://www.epaslaugos.lt
        method: { $ref: '#/components/schemas/Method' }
        personalCode:
          type: string
          pattern: '^[0-9]{11}$'
          example: '39001010000'
          description: Required for Smart-ID and Mobile-ID; rejected for physical/NFC.
        phoneNumber:
          type: string
          pattern: '^\+3706[0-9]{7}$'
          example: '+37061234567'
          description: Required for Mobile-ID, rejected for every other method.
        message:
          type: string
          description: Optional short message the client may display to the user.
        externalId:
          type: string
          description: Provider's own correlation id (echoed back, deduped).
        webhookUrl:
          type: string
          format: uri
          description: Optional public HTTPS webhook for the terminal status; private/non-global destinations are rejected.

    AuthRequestCreated:
      type: object
      required: [requestId, method, verificationCode, status, expiresAt]
      description: Physical requests return `clientToken`; NFC requests return `pairing`; remote methods return neither.
      properties:
        requestId: { type: string, format: uuid }
        method: { $ref: '#/components/schemas/Method' }
        verificationCode:
          type: string
          description: NFC anti-relay code, or the user verification code for Smart-ID/Mobile-ID.
        clientToken:
          type: string
          description: Scoped bearer JWT for physical-card `/certificate` + `/complete`; absent for remote methods.
        status: { $ref: '#/components/schemas/Status' }
        expiresAt: { type: string, format: date-time }
        challengeProfile:
          type: string
          const: atk-auth-v1
        nonce:
          type: string
          description: |
            Server-minted challenge nonce (see `Challenge`), so the provider backend can
            re-derive `dtbs` and verify `sig` independently - no device relay needed.
        pairing:
          allOf:
            - $ref: '#/components/schemas/PairingHint'
          description: Present only when method == nfc.

    CertificateSubmit:
      type: object
      required: [certificate]
      properties:
        certificate:
          type: string
          format: byte
          description: Base64 DER of the card's **authentication** certificate.

    CertificateChallenge:
      type: object
      required: [requestId, dtbs, dtbsHashAlgOid, operationToken, challenge, cert]
      properties:
        requestId: { type: string, format: uuid }
        dtbs:
          type: string
          format: byte
          description: |
            Base64 of the exact digest the card must sign (ECDSA prehashed). Built as the `atk-auth-v1`
            construction - see `Challenge`.
        dtbsHashAlgOid: { $ref: '#/components/schemas/HashAlgOid' }
        operationToken:
          type: string
          description: Single-use JWT bound to {tenant, requestId, purpose, certFp, dtbsHash}; the nonce is bound indirectly via dtbs. Returned at /complete.
        verificationCode: { type: string }
        challenge: { $ref: '#/components/schemas/Challenge' }
        cert:
          type: object
          required: [subject]
          description: |
            Echoes only the parsed `subject` (the client itself just submitted the DER).
            The certificate DER is returned at `/complete` and on GET once done.
          properties:
            subject: { $ref: '#/components/schemas/CertSubject' }

    Challenge:
      type: object
      description: |
        Canonical `atk-auth-v1` anti-relay challenge. The server issues `nonce`; `dtbs` returned above =
        `HASH( HASH(utf8(audience_origin)) || HASH(utf8(nonce)) )` with `HASH` matched to the auth-key
        curve (P-256 -> SHA-256, P-384 -> SHA-384). Versioned - future profiles never reinterpret an
        existing `dtbs`. This binding is what a relying party (e.g. ePaslaugos) audits.
      required: [profile, audience, nonce]
      properties:
        profile: { type: string, const: atk-auth-v1 }
        audience: { type: string }
        nonce:
          type: string
          description: Server-issued base64url challenge string (44-128 chars).

    CompleteSubmit:
      type: object
      required: [signature, signatureAlgorithm, operationToken]
      properties:
        signature:
          type: string
          format: byte
          description: Base64 raw card signature over `dtbs` (auth key).
        signatureAlgorithm: { $ref: '#/components/schemas/SignatureAlgorithm' }
        signatureEncoding: { $ref: '#/components/schemas/SignatureEncoding' }
        operationToken: { type: string }

    AuthResult:
      type: object
      required: [status, method, sig, cert]
      properties:
        status: { type: string, const: done }
        method: { $ref: '#/components/schemas/Method' }
        sig:
          type: string
          format: byte
          description: |
            The raw card signature over the audience-bound challenge, returned so the relying party can
            verify identity independently of `assertion`. Audience-binding prevents cross-RP relay.
        cert:
          type: object
          properties:
            subject: { $ref: '#/components/schemas/CertSubject' }
            der: { type: string, format: byte }
        assertion:
          type: string
          description: |
            Optional short-lived Tvarka-signed JWT (ES256), verifiable against the JWKS endpoint. Claims:
            iss=atk.tvarka.pro, sub=<personal code>, aud=<audience>, iat, exp, jti, request_id
            (binds the assertion to this ceremony), given_name, family_name, cert_fp,
            authentication_method, amr. NOT OIDC.
            Optional by contract - integrators may ignore it and verify the raw `sig` themselves.

    AuthRequestState:
      type: object
      required: [requestId, method, status]
      properties:
        requestId: { type: string, format: uuid }
        method: { $ref: '#/components/schemas/Method' }
        status: { $ref: '#/components/schemas/Status' }
        verificationCode:
          type: string
          description: Present while a Smart-ID/Mobile-ID request is non-terminal.
        nonce:
          type: string
          description: Server-minted challenge nonce (see `Challenge`) - enables independent `sig` verification from polling alone.
        cert:
          type: object
          properties:
            subject: { $ref: '#/components/schemas/CertSubject' }
            der:
              type: string
              format: byte
              description: Base64 DER, present once status == done.
        sig: { type: string, format: byte }
        assertion: { type: string }
        error: { $ref: '#/components/schemas/ErrorBody' }

    PairingHint:
      type: object
      properties:
        pairingUrl: { type: string, format: uri, example: 'https://sign.tvarka.pro/atk-login/#PAIRING_TOKEN' }
        deepLink: { type: string, example: 'tvarkasign://atk/pair/PAIRING_TOKEN' }
        qrPayload: { type: string, example: 'https://sign.tvarka.pro/atk-login/#PAIRING_TOKEN' }

    PairingInfo:
      allOf:
        - $ref: '#/components/schemas/PairingHint'
        - type: object
          required: [pairingToken, expiresAt]
          properties:
            pairingToken:
              type: string
              description: High-entropy, one-claim, short-lived.
            expiresAt: { type: string, format: date-time }

    PairingClaimRequest:
      type: object
      required: [pairingToken]
      properties:
        pairingToken: { type: string, minLength: 32, maxLength: 200 }
        claimNonce:
          type: string
          maxLength: 120
          description: Optional; when the originating UI can pre-share it, binds the tap to the intended session.
        device:
          type: object
          additionalProperties: false
          properties:
            platform: { type: string, maxLength: 40 }
            appVersion: { type: string, maxLength: 40 }
            deviceName: { type: string, maxLength: 120 }

    PairingClaimResponse:
      type: object
      required: [requestId, purpose, clientToken, status]
      properties:
        requestId: { type: string, format: uuid }
        purpose: { type: string, enum: [auth, sign] }
        clientToken: { type: string }
        status: { $ref: '#/components/schemas/Status' }
        summary:
          type: object
          additionalProperties: true
          description: Minimal display context {purpose, provider, audience, label, format?} - never document contents.

    ErrorCode:
      type: string
      description: |
        The full stable error-code catalog of the ATK API family (auth + sign + services);
        each endpoint returns the subset that applies to it. By HTTP status:
        400 invalid_json, missing_field, invalid_field, invalid_webhook_url, invalid_document_ref.
        401 invalid_basic_credentials, invalid_client_token, client_token_expired,
        operation_token_expired, invalid_download_token.
        403 origin_not_allowed, ip_not_allowed, format_not_allowed, document_ref_host_not_allowed,
        method_not_allowed, tenant_suspended, tier_forbidden.
        404 request_not_found, pairing_not_found, document_not_ready.
        409 request_terminal, operation_token_spent, identity_swap, idempotency_conflict,
        service_in_progress, pairing_not_available, pairing_already_claimed.
        410 pairing_expired. 413 document_too_large.
        422 cert_invalid, untrusted_chain, cert_revoked, cert_expired, not_qualified,
        cert_purpose_mismatch, signature_invalid, document_hash_mismatch, document_size_mismatch.
        429 rate_limited. 500 assembly_failed. 502 document_ref_fetch_failed.
        503 erasure_failed, pairing_service_unavailable, service_unavailable.
        The catalog can grow in minor versions - fall back to HTTP status semantics
        for codes you do not recognize.
      enum:
        - invalid_json
        - missing_field
        - invalid_field
        - invalid_webhook_url
        - invalid_document_ref
        - invalid_basic_credentials
        - invalid_client_token
        - client_token_expired
        - operation_token_expired
        - invalid_download_token
        - origin_not_allowed
        - ip_not_allowed
        - format_not_allowed
        - document_ref_host_not_allowed
        - method_not_allowed
        - tenant_suspended
        - tier_forbidden
        - request_not_found
        - pairing_not_found
        - document_not_ready
        - request_terminal
        - operation_token_spent
        - identity_swap
        - idempotency_conflict
        - service_in_progress
        - pairing_not_available
        - pairing_already_claimed
        - pairing_expired
        - document_too_large
        - cert_invalid
        - untrusted_chain
        - cert_revoked
        - cert_expired
        - not_qualified
        - cert_purpose_mismatch
        - signature_invalid
        - document_hash_mismatch
        - document_size_mismatch
        - rate_limited
        - assembly_failed
        - document_ref_fetch_failed
        - erasure_failed
        - pairing_service_unavailable
        - service_unavailable

    ErrorBody:
      type: object
      required: [code, message]
      properties:
        code: { $ref: '#/components/schemas/ErrorCode' }
        message: { type: string }
        requestId: { type: string, format: uuid }
        retryable: { type: boolean }

    Error:
      type: object
      required: [error]
      properties:
        error: { $ref: '#/components/schemas/ErrorBody' }

    Jwks:
      type: object
      properties:
        keys:
          type: array
          items:
            type: object
            description: A JWK (EC P-256, `kid`, `use:sig`, `alg:ES256`).

  responses:
    BadRequest:
      description: Malformed input.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unauthorized:
      description: Missing/invalid credentials or client token.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: Authorized but not permitted (origin/format/tenant).
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: Unknown request.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: State/idempotency conflict (spent operation token, identity swap, terminal request).
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Unprocessable:
      description: Semantic reject (cert purpose/chain/revocation, dtbs mismatch, bad signature).
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds. }
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    ServiceUnavailable:
      description: A required remote or erasure service is temporarily unavailable.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
