openapi: 3.0.3
info:
  title: Subter Identity Service API
  version: 0.9.0
  description: >
    Paid, self-sovereign identity directory. **Publish paid, resolve free.**


    This is the **definitive integration contract** for clients (notably the
    Flutter app). It is the machine-readable form of §10 of
    `docs/plans/identity-service.md`; where the two disagree, the plan's frozen
    invariants win and this file is the bug. Kept in sync with the code as part
    of Definition of Done (see `CLAUDE.md` → "Documentation maintenance").


    Load-bearing invariants for anyone integrating:

    - **Two keys per identity.** X25519 encryption key (the AID is
      `sha256(encPubKey)[0..16]`) and an Ed25519 signing key that is derived but
      **not** recoverable from it. Enrollment proves control of **both**.
    - **Never send a private key.** Every endpoint takes public keys +
      signatures only.
    - **Resolve is free & unauthenticated** (`/card`, `/doc`, `/kel`,
      `/resolve`, `/.well-known`), `CORS: *`, edge-cached. **Writes**
      (`/api/v1/*`) require a Clerk session, and publishing also requires an
      active entitlement.
    - **Verification is offline.** `/doc` and `/kel` are self-contained and
      self-signed; verify them client-side, do not treat the server as a
      resolve-time oracle.
    - A directory claim is **not** out-of-band verification — never let it set
      the app's `Contact.verified`.


    Internal endpoints (`/api/webhooks/clerk`, `/api/cron/cleanup`) are
    deliberately omitted — they are not part of the client contract.


    This spec covers the machine (JSON/text) surface only. There is also a
    human-facing HTML **profile page** at `/@<handle>` (and canonically
    `/p/<fingerprint>`) — a convenience view of the same public data, not part of
    the integration contract; clients verify against `/doc` + `/kel`, never by
    scraping it.
  contact:
    name: Subter Identity Service
  license:
    name: Proprietary

servers:
  - url: https://identity.subter.app
    description: Production
  - url: https://identity.dev.subter.app
    description: Preview (develop branch)

tags:
  - name: Enrollment
    description: Bind a Clerk account to a key pair (proves control of both keys).
  - name: Publish
    description: Publish the subject-signed identity document. Auth + entitlement.
  - name: Claims
    description: >
      Mint Subter-signed claim VCs. Auth + entitlement. The client embeds them
      in the document it self-signs and publishes; there is no server claim store.
  - name: Account
    description: Authenticated account/session endpoints.
  - name: Resolve (public)
    description: Free, unauthenticated, cacheable reads. No per-user logging.
  - name: Service metadata (public)
    description: The service's own DID + verify key, for offline claim checks.
  - name: Ops
    description: Health/readiness.

security:
  - ClerkSession: []

paths:
  /api/v1/enroll/challenge:
    post:
      tags: [Enrollment]
      summary: Request an enrollment challenge (nonce sealed to the enc key)
      description: >
        Step 1 of enrollment. The server validates that
        `fingerprint == sha256(encPubKey)[0..16]`, then generates a single-use
        nonce and **seals it to `encPubKey`** with X25519 `crypto_box`
        (byte-compatible with the app's pinenacl `CryptoBox`). The client must
        open `sealedNonce` with its X25519 **private** key — proving enc-key
        control — before it can sign it. Rate-limited per user.
      operationId: createEnrollChallenge
      security:
        - ClerkSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrollChallengeRequest"
      responses:
        "200":
          description: Challenge issued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollChallengeResult"
        "400":
          description: >
            Malformed body (`invalidBody`) or a domain error: `invalidInput`,
            `fingerprintMismatch`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          description: Too many challenges for this user in the window (`rateLimited`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/v1/enroll/verify:
    post:
      tags: [Enrollment]
      summary: Complete enrollment (two signatures) and receive the keyBinding claim
      description: >
        Step 2. The client sends two signatures over the **opened** nonce and
        the canonical inception body: `challengeSignature` (Ed25519 over
        `"subter-enroll" ‖ challengeId ‖ nonce`) proves the challenge was
        opened (enc-key control) and signed (sig-key control); `inceptionSignature`
        self-certifies the `icp` key event. On success the server binds the key,
        writes the `icp` event, and returns the service-signed **keyBinding
        claim** to embed in the document you later publish.


        Idempotent: re-verifying the **same** signing key returns `200` with
        `status: "idempotent"` (the `.subterkey` restore path). A **different**
        signing key for the same AID is a hard `409 keyConflict` — never a
        silent rebind.
      operationId: verifyEnroll
      security:
        - ClerkSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrollVerifyRequest"
      responses:
        "201":
          description: Enrolled (new binding created).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollVerifyResult"
        "200":
          description: Idempotent success (same key re-enrolled).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollVerifyResult"
        "400":
          description: >
            `invalidBody`, or a domain error: `challengeInvalid`,
            `badChallengeSignature`, `badInceptionSignature`, `invalidInception`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: >
            `keyConflict` (different signing key for this AID),
            `aidOwnedByOtherUser`, or `userHasOtherIdentity`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/v1/identity:
    put:
      tags: [Publish]
      summary: Publish the subject-signed identity document
      description: >
        Publish-paid (invariant #11). Requires a Clerk session **and** an active
        entitlement. The server verifies the document offline — subject
        self-signature, AID/key match against the replayed KEL — before
        persisting it and exposing it at `GET /doc/{fingerprint}`. The service
        never signs this document; the subject does.
      operationId: publishIdentity
      security:
        - ClerkSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [document]
              properties:
                document:
                  $ref: "#/components/schemas/IdentityDocument"
      responses:
        "200":
          description: Published.
          content:
            application/json:
              schema:
                type: object
                required: [aid, did, published]
                properties:
                  aid:
                    $ref: "#/components/schemas/Fingerprint"
                  did:
                    $ref: "#/components/schemas/Did"
                  published:
                    type: boolean
                    example: true
        "400":
          description: "`invalidBody`, `invalidDocument`, `aidMismatch`, `keyMismatch`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: No active entitlement — publishing is paid (`notEntitled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: This user has not enrolled a key yet (`notEnrolled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/v1/claims/sync:
    post:
      tags: [Claims]
      summary: Mint Subter-signed Family-A claims from Clerk verifications
      description: >
        Reads the caller's Clerk verifications — connected social accounts
        (`externalAccounts`) and verified email addresses — and mints a
        Subter-signed claim VC for each. Clerk already performed the OAuth /
        email verification; the service reads the result and co-signs, it does
        **not** re-run OAuth. `method` records provenance honestly
        (`clerk:oauth:github`, `clerk:email`) so a resolver can weigh Family-A
        (Clerk-attested) apart from Family-B (Subter-verified) — never flattened
        (invariant #6).


        Requires a Clerk session **and** an active entitlement (invariant #11).
        A verified social account with no usable handle is skipped (routed to a
        Family-B verifier), never asserted with an empty handle.


        **Gated per type.** Each claim is kept only if the plan grants its
        `verify_<platform>` Clerk feature (e.g. `verify_github`, `verify_email`);
        a connected account whose feature the plan omits is silently dropped from
        the result — no error. See `docs/verification/attestation-types.md`.


        **The response carries the VCs; it does not publish them.** The service
        never re-signs the subject's document (invariant #10/#14), so there is no
        server-side claim store. The client embeds the returned claims (alongside
        its `keyBinding` claim and any Family-B claims) into the document it
        self-signs and publishes via `PUT /api/v1/identity`. **Removing a claim
        is the same operation in reverse:** re-publish a document that omits it —
        there is no `DELETE` endpoint, because deletion also requires the
        subject's signature over the new document.


        **The response is a set of *candidates*, not an instruction to publish
        all of them.** It returns a claim for *every* verified account and email,
        so the client MUST let the user choose which to include before publishing
        — especially emails, which are otherwise made public. Claims expire
        (~1 year); re-sync and re-publish before expiry or the claim silently
        renders `verified: false` in `/doc`.
      operationId: syncClaims
      security:
        - ClerkSession: []
      responses:
        "200":
          description: Minted (possibly zero) claims for the caller's identity.
          content:
            application/json:
              schema:
                type: object
                required: [aid, did, claims]
                properties:
                  aid:
                    $ref: "#/components/schemas/Fingerprint"
                  did:
                    $ref: "#/components/schemas/Did"
                  claims:
                    type: array
                    items:
                      $ref: "#/components/schemas/Claim"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: No active entitlement — claims are paid (`notEntitled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: This user has not enrolled a key yet (`notEnrolled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many syncs for this user in the window (`rateLimited`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/v1/claims/start:
    post:
      tags: [Claims]
      summary: Begin a Family-B verification (domain | bluesky)
      description: >
        Start a bespoke verification the service must perform itself (Clerk has
        no equivalent). Supported `platform`: **`domain`** and **`bluesky`**. The
        response always carries a one-time `token` and platform-specific
        `instructions`; then poll `GET /api/v1/claims/status/{id}`.


        **`bluesky`** — send `{ platform: "bluesky", handle }` (a handle like
        `ada.bsky.social`, or a DID). Publish `subter-verify=<aid>:<token>` in
        your Bluesky profile bio (description); the service reads it back through
        the public AppView (`public.api.bsky.app`). The minted claim carries the
        human `handle` plus the rename-proof DID in `canonicalId`.


        **`domain`** — the response carries instructions to publish the token in
        **either** place:


        - **DNS TXT** at `_subter.<domain>` with value
          `subter-verify=<aid>:<token>`, or

        - **`.well-known`** — that same string as the body of
          `https://<domain>/.well-known/subter-challenge`.


        Then poll `GET /api/v1/claims/status/{id}`. The `.well-known` fetch is
        **SSRF-hardened** (invariant #17): private/loopback/link-local/CGNAT and
        alternate IP encodings are blocked, the resolved IP is pinned against
        DNS-rebinding, and body/redirect/timeout are capped. Authenticated,
        enrolled, and entitlement-gated; rate-limited per user.
      operationId: startClaimVerification
      security:
        - ClerkSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform:
                  type: string
                  enum: [domain, bluesky]
                domain:
                  type: string
                  description: "Required when platform is domain."
                  example: example.com
                handle:
                  type: string
                  description: >
                    Required when platform is bluesky — a Bluesky handle
                    (ada.bsky.social) or a DID.
                  example: ada.bsky.social
      responses:
        "200":
          description: Verification started.
          content:
            application/json:
              schema:
                type: object
                description: >
                  Common fields plus a platform-specific block: `domain` returns
                  `{ domain, instructions: { dnsRecord, wellKnown } }` (below);
                  `bluesky` returns `{ handle, instructions: { record, where } }`.
                required:
                  [verificationId, platform, token, expiresIn, instructions]
                properties:
                  verificationId: { type: string }
                  platform: { type: string, enum: [domain, bluesky] }
                  domain:
                    type: string
                    description: "Present when platform is domain."
                    example: example.com
                  handle:
                    type: string
                    description: "Present when platform is bluesky."
                    example: ada.bsky.social
                  token: { type: string }
                  expiresIn: { type: integer, example: 3600 }
                  instructions:
                    type: object
                    properties:
                      dnsRecord:
                        type: object
                        properties:
                          name: { type: string, example: _subter.example.com }
                          type: { type: string, example: TXT }
                          value:
                            type: string
                            example: subter-verify=<aid>:<token>
                      wellKnown:
                        type: object
                        properties:
                          url:
                            type: string
                            example: https://example.com/.well-known/subter-challenge
                          body:
                            type: string
                            example: subter-verify=<aid>:<token>
        "400":
          description: "`invalidInput` (bad domain) or `unsupportedPlatform`."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: No active entitlement (`notEntitled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: >
            Subscribed, but the plan doesn't include this verification type
            (`featureNotInPlan`) — gated on the `verify_<platform>` Clerk feature.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Not enrolled (`notEnrolled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many attempts (`rateLimited`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/v1/claims/status/{id}:
    get:
      tags: [Claims]
      summary: Poll a domain verification; mint the claim on success
      description: >
        Check whether the token is now published. While it isn't, returns
        `{status: "pending"}`. Once it is, the service verifies control, mints a
        Subter-signed VC (`method: "subter:dns-txt"` or `"subter:well-known"`),
        and returns it in `claims` — **exactly once** (the token is single-use).
        Embed it in the document you publish via `PUT /api/v1/identity`, exactly
        like a Family-A claim. Authenticated + entitlement-gated.
      operationId: getClaimVerificationStatus
      security:
        - ClerkSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Current status (with the minted claim on the success transition).
          content:
            application/json:
              schema:
                type: object
                required: [status, claims]
                properties:
                  status:
                    type: string
                    enum: [pending, verified, expired]
                  method:
                    type: string
                    enum: [dns-txt, well-known]
                  claims:
                    type: array
                    items:
                      $ref: "#/components/schemas/Claim"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: No active entitlement (`notEntitled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Plan doesn't include this verification type (`featureNotInPlan`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No such verification for this user (`verificationNotFound`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/v1/me:
    get:
      tags: [Account]
      summary: Echo the authenticated session's user id
      description: The user's own id, echoed back to them. Never exposed on a public path.
      operationId: getMe
      security:
        - ClerkSession: []
      responses:
        "200":
          description: Authenticated.
          content:
            application/json:
              schema:
                type: object
                required: [authenticated, userId]
                properties:
                  authenticated:
                    type: boolean
                    example: true
                  userId:
                    type: string
                    example: user_2abcDEF
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/rotate:
    post:
      tags: [Identity]
      summary: Rotate the signing/messaging keys (M10)
      description: >
        Append a subject-signed `rot` event that reveals the pre-committed next
        key. The service verifies (via a full KEL replay) the pre-rotation
        commitment `nextKeyCommitment(newVerifyKey) == current n` and **both**
        signatures (old key authorizes, new key proves), then advances the current
        keys. The AID never changes. **Re-publish** a document with the new keys
        afterwards (`PUT /v1/identity`). Re-submitting the current tip is
        idempotent. See [rotation.md](../rotation.md). Authenticated +
        entitlement-gated.
      operationId: rotateIdentity
      security:
        - ClerkSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event]
              properties:
                event:
                  $ref: "#/components/schemas/SignedKelEvent"
      responses:
        "200":
          description: Rotated (or idempotent no-op).
          content:
            application/json:
              schema:
                type: object
                required: [status, aid, did, sequence]
                properties:
                  status: { type: string, enum: [rotated, idempotent] }
                  aid: { $ref: "#/components/schemas/Fingerprint" }
                  did: { $ref: "#/components/schemas/Did" }
                  sequence: { type: integer, example: 1 }
        "400":
          description: "`invalidBody`, or `invalidRotation` (commitment/signature/chain)."
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: No active entitlement (`notEntitled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Not enrolled (`notEnrolled`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /card/{fingerprint}:
    get:
      tags: [Resolve (public)]
      summary: Legacy text/plain contact card
      description: >
        Returns `text/plain` `subter:contact?pk=<base64url>&name=<url-encoded>`,
        parseable by the app's `ContactCard.parse` (invariant #8). `pk` is the
        X25519 messaging key. Public, `CORS: *`, cached, no auth.
      operationId: getCard
      security: []
      parameters:
        - $ref: "#/components/parameters/FingerprintPath"
      responses:
        "200":
          description: The contact card URI.
          content:
            text/plain:
              schema:
                type: string
                example: subter:contact?pk=Ab3...&name=alice
        "400":
          $ref: "#/components/responses/InvalidFingerprint"
        "404":
          $ref: "#/components/responses/NotFound"

  /doc/{fingerprint}:
    get:
      tags: [Resolve (public)]
      summary: The rich, subject-signed identity document
      description: >
        The self-contained DID/VC-shaped document. Verify it fully offline
        (subject signature + each claim's issuer signature). `404 notPublished`
        until the subject publishes via `PUT /api/v1/identity`.
      operationId: getDoc
      security: []
      parameters:
        - $ref: "#/components/parameters/FingerprintPath"
      responses:
        "200":
          description: The identity document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IdentityDocument"
        "400":
          $ref: "#/components/responses/InvalidFingerprint"
        "404":
          description: Not published yet (`notPublished`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /kel/{fingerprint}:
    get:
      tags: [Resolve (public)]
      summary: The key event log (replay to current key state offline)
      description: >
        The hash-chained `icp`/`rot` event log. Replay it client-side to derive
        the current signing/enc keys — the AID never changes but the current
        keys do after a rotation. Public, cached, no auth.
      operationId: getKel
      security: []
      parameters:
        - $ref: "#/components/parameters/FingerprintPath"
      responses:
        "200":
          description: The event log.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KelResponse"
        "400":
          $ref: "#/components/responses/InvalidFingerprint"
        "404":
          $ref: "#/components/responses/NotFound"

  /resolve/subter/{username}:
    get:
      tags: [Resolve (public)]
      summary: Resolve a handle (Clerk username) to a fingerprint
      description: >
        Handle → AID. Answers a question you already know the answer to; it is
        not an enumeration endpoint. Public, cached, no auth. This is a
        **discovery** surface, so it resolves only while the subject account is
        **currently entitled**; a lapsed (or nonexistent) handle returns 404,
        indistinguishably. Reads keyed by a fingerprint you already hold
        (`/card` `/doc` `/kel`) are not gated this way.
      operationId: resolveUsername
      security: []
      parameters:
        - name: username
          in: path
          required: true
          description: The Subter handle (Clerk username).
          schema:
            type: string
            example: alice
      responses:
        "200":
          description: Resolved.
          content:
            application/json:
              schema:
                type: object
                required: [fingerprint, did]
                properties:
                  fingerprint:
                    $ref: "#/components/schemas/Fingerprint"
                  did:
                    $ref: "#/components/schemas/Did"
        "400":
          description: Empty/invalid username (`invalidUsername`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"

  /resolve/{platform}/{identifier}:
    get:
      tags: [Resolve (public)]
      summary: Reverse lookup — identifier → fingerprint
      description: >
        Find the fingerprint that published a given identifier. `platform:
        "subter"` takes the plaintext username (same as `/resolve/subter/...`).
        Every other platform (`email`, `domain`, `github`, `x`, `google`,
        `bluesky`) takes the **client-computed HASH**
        `sha256(platform ‖ ":" ‖ normalize(identifier))` — the plaintext (email
        especially) never travels the URL. Normalization is a frozen contract
        (trim → strip one leading `@` → lowercase); see
        [reverse-lookup.md](../verification/reverse-lookup.md). Backed by an index
        of **verified** claims rebuilt at publish time. Discovery surface, so
        gated on the subject's current entitlement (a lapsed or unknown identifier
        both return 404). Public, unauthenticated, **per-IP rate-limited**.


        **An identifier resolves to at most one fingerprint, but is not
        exclusive.** Handles are renameable and a domain can be co-controlled, so
        several identities may hold a live claim to one identifier. The service
        picks deterministically — claims expired at read time are ignored, and
        among the rest the **most recently verified** wins — and does not signal
        that an identifier was contested. Treat the result as a discovery hint,
        never as proof of who an identifier belongs to: verification stays
        out-of-band. See
        [reverse-lookup.md](../verification/reverse-lookup.md#contention--an-identifier-is-not-exclusive).
      operationId: resolveIdentifier
      security: []
      parameters:
        - name: platform
          in: path
          required: true
          schema:
            type: string
            enum: [subter, email, domain, github, x, google, bluesky]
        - name: identifier
          in: path
          required: true
          description: >
            A plaintext username when platform is `subter`; otherwise the 64-char
            lowercase-hex identifier hash.
          schema:
            type: string
      responses:
        "200":
          description: Resolved.
          content:
            application/json:
              schema:
                type: object
                required: [fingerprint, did]
                properties:
                  fingerprint:
                    $ref: "#/components/schemas/Fingerprint"
                  did:
                    $ref: "#/components/schemas/Did"
        "400":
          description: Unindexed platform or a non-hash identifier (`invalidInput`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          description: Per-IP rate limit exceeded (`rateLimited`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /.well-known/subter-service-key:
    get:
      tags: [Service metadata (public)]
      summary: The service's Ed25519 verify key
      description: >
        The verify key clients pin to check Subter-signed claims (e.g. the
        keyBinding claim) offline. Only the verify key is ever exposed.
      operationId: getServiceKey
      security: []
      responses:
        "200":
          description: The service verify key.
          content:
            application/json:
              schema:
                type: object
                required: [alg, verificationMethod, publicKeyBase64Url]
                properties:
                  alg:
                    type: string
                    example: Ed25519
                  verificationMethod:
                    type: string
                    example: did:subter:service#sig
                  publicKeyBase64Url:
                    $ref: "#/components/schemas/Base64Url"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /.well-known/did.json:
    get:
      tags: [Service metadata (public)]
      summary: The service's own DID document
      operationId: getServiceDid
      security: []
      responses:
        "200":
          description: The service DID document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DidDocument"
        "503":
          $ref: "#/components/responses/ServiceKeyUnavailable"

  /api/health:
    get:
      tags: [Ops]
      summary: Health/readiness probe
      operationId: getHealth
      security: []
      responses:
        "200":
          description: Healthy.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"
        "503":
          description: Database unreachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Health"

components:
  securitySchemes:
    ClerkSession:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Clerk session token. Native clients send it as
        `Authorization: Bearer <session-jwt>`; the browser SDK may instead use
        the Clerk session cookie. Obtain it from the Clerk SDK for your platform.

  parameters:
    FingerprintPath:
      name: fingerprint
      in: path
      required: true
      description: >
        Subter fingerprint / AID. Accepts the stripped canonical form
        (`^[0-9a-f]{32}$`) or the app's space-grouped display form (URL-encoded);
        the server normalizes by stripping whitespace and lowercasing.
      schema:
        type: string
        example: a1b2c3d4e5f60718293a4b5c6d7e8f90

  responses:
    Unauthorized:
      description: No valid Clerk session (`unauthorized`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: No such identity (`notFound`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    InvalidFingerprint:
      description: Fingerprint failed normalization (`invalidFingerprint`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ServiceKeyUnavailable:
      description: The service signing key is not configured (`serviceKeyUnavailable`).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Base64Url:
      type: string
      description: Unpadded base64url.
      pattern: "^[A-Za-z0-9_-]+$"
      example: Ab3dEf...

    Fingerprint:
      type: string
      description: Stripped, lowercase Subter fingerprint (AID).
      pattern: "^[0-9a-f]{32}$"
      example: a1b2c3d4e5f60718293a4b5c6d7e8f90

    Did:
      type: string
      description: "`did:subter:<32-hex>`."
      pattern: "^did:subter:[0-9a-f]{32}$"
      example: did:subter:a1b2c3d4e5f60718293a4b5c6d7e8f90

    Sha256Hex:
      type: string
      pattern: "^[0-9a-f]{64}$"
      description: Lowercase hex SHA-256 digest.

    Error:
      type: object
      required: [error]
      description: >
        Uniform error envelope. `error` is a stable machine code (documented per
        endpoint). Some errors add `message`; a 400 `invalidBody` lists the
        `required` fields.
      properties:
        error:
          type: string
          example: keyConflict
        message:
          type: string
        required:
          type: array
          items:
            type: string
      example:
        error: notEntitled

    EnrollChallengeRequest:
      type: object
      required: [encPubKey, sigPubKey, fingerprint, nextKeyDigest]
      properties:
        encPubKey:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: X25519 messaging public key (32 bytes, base64url).
        sigPubKey:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: Ed25519 verify key (32 bytes, base64url).
        fingerprint:
          $ref: "#/components/schemas/Fingerprint"
        nextKeyDigest:
          allOf: [{ $ref: "#/components/schemas/Sha256Hex" }]
          description: >
            Pre-rotation commitment — SHA-256 of the NEXT Ed25519 verify key.
            Required at inception even before the rotation flow exists
            (invariant #4).

    EnrollChallengeResult:
      type: object
      required: [challengeId, sealedNonce, ephemeralPublicKey, expiresIn]
      properties:
        challengeId:
          type: string
          format: uuid
        sealedNonce:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: >
            `boxNonce(24) ‖ ciphertext`. Open with
            `CryptoBox.open(sealedNonce, ephemeralPublicKey, encPriv)` to recover
            the raw nonce to sign.
        ephemeralPublicKey:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: The server's ephemeral X25519 public key; open against this.
        expiresIn:
          type: integer
          description: Seconds until the challenge expires.
          example: 300

    EnrollVerifyRequest:
      type: object
      required: [challengeId, challengeSignature, inceptionSignature]
      properties:
        challengeId:
          type: string
          format: uuid
        challengeSignature:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: >
            Ed25519 signature over `"subter-enroll" ‖ challengeId ‖ nonce`,
            where `nonce` is the opened `sealedNonce`.
        inceptionSignature:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: Ed25519 signature over the canonical (JCS) inception event body.

    EnrollVerifyResult:
      type: object
      required: [status, aid, did, keyBindingClaim]
      properties:
        status:
          type: string
          enum: [created, idempotent]
        aid:
          $ref: "#/components/schemas/Fingerprint"
        did:
          $ref: "#/components/schemas/Did"
        keyBindingClaim:
          $ref: "#/components/schemas/Claim"

    Proof:
      type: object
      required: [type, verificationMethod, proofValue]
      description: >
        Ed25519 signature. Signed bytes = JCS (RFC 8785) of the enclosing object
        with its `proof` key removed entirely (frozen signing rule).
      properties:
        type:
          type: string
          example: Ed25519Signature2020
        verificationMethod:
          type: string
          description: DID URL of the signing key (e.g. the subject `#sig` or `did:subter:service#sig`).
          example: did:subter:service#sig
        proofValue:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: The base64url Ed25519 signature.

    VerificationMethod:
      type: object
      required: [id, type, publicKeyBase64Url]
      properties:
        id:
          type: string
          description: "Fragment id, e.g. `#enc` or `#sig`."
          example: "#sig"
        type:
          type: string
          example: Ed25519VerificationKey2020
        publicKeyBase64Url:
          $ref: "#/components/schemas/Base64Url"

    ClaimSubject:
      type: object
      required: [id, platform, handle, verifiedAt, method]
      properties:
        id:
          type: string
          description: Subject DID.
          example: did:subter:a1b2c3d4e5f60718293a4b5c6d7e8f90
        platform:
          type: string
          example: keyBinding
        handle:
          type: string
        profileUrl:
          type: string
          format: uri
        canonicalId:
          type: string
          description: >
            Rename-proof canonical id when the handle can change (e.g. a Bluesky
            DID). Optional + additive; absent on claim types that don't need it.
          example: did:plc:abcdefghijklmnopqrstuvwx
        verifiedAt:
          type: string
          format: date-time
        method:
          type: string
          description: >
            Provenance, kept honest and never flattened (invariant #6): e.g.
            `keyBinding`, `clerk:oauth:github`, `subter:domain`.
          example: keyBinding

    Claim:
      type: object
      required:
        ["@context", type, issuer, credentialSubject, expirationDate, proof]
      description: A signed W3C-VC-shaped claim. A claim is NOT out-of-band verification.
      properties:
        "@context":
          type: array
          items:
            type: string
          example:
            - https://www.w3.org/2018/credentials/v1
            - https://subter.app/ns/v1
        type:
          type: array
          items:
            type: string
        issuer:
          type: string
          description: The claim's issuer DID (service DID for Subter-signed claims).
          example: did:subter:service
        credentialSubject:
          $ref: "#/components/schemas/ClaimSubject"
        expirationDate:
          type: string
          format: date-time
        proof:
          $ref: "#/components/schemas/Proof"

    IdentityDocument:
      type: object
      required:
        [
          "@context",
          id,
          subterVersion,
          fingerprint,
          verificationMethod,
          claims,
          updated,
          proof,
        ]
      description: >
        The subject-signed identity document served at `/doc/{fingerprint}` and
        submitted to `PUT /api/v1/identity`. Self-contained; verify offline.
      properties:
        "@context":
          type: array
          items:
            type: string
        id:
          $ref: "#/components/schemas/Did"
        subterVersion:
          type: integer
          example: 1
        fingerprint:
          $ref: "#/components/schemas/Fingerprint"
        name:
          type: string
        verificationMethod:
          type: array
          items:
            $ref: "#/components/schemas/VerificationMethod"
        claims:
          type: array
          items:
            $ref: "#/components/schemas/Claim"
        updated:
          type: string
          format: date-time
        proof:
          $ref: "#/components/schemas/Proof"

    KelEventBody:
      type: object
      required: [v, t, i, s, enc, sig, n, kt]
      properties:
        v:
          type: string
          example: SBTR1
        t:
          type: string
          enum: [icp, rot]
        i:
          allOf: [{ $ref: "#/components/schemas/Fingerprint" }]
          description: AID.
        s:
          type: integer
          description: Sequence, 0-based.
        p:
          allOf: [{ $ref: "#/components/schemas/Sha256Hex" }]
          description: SHA-256 of the prior event body (rot only).
        enc:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: Current X25519 messaging key.
        sig:
          allOf: [{ $ref: "#/components/schemas/Base64Url" }]
          description: Current Ed25519 verify key.
        n:
          type: array
          items:
            $ref: "#/components/schemas/Sha256Hex"
          description: Pre-rotation commitments (SHA-256 of the next verify key).
        kt:
          type: integer
          description: Signing threshold (1 for now).
          example: 1

    SignedKelEvent:
      type: object
      required: [body, signatures]
      properties:
        body:
          $ref: "#/components/schemas/KelEventBody"
        signatures:
          type: array
          description: "icp: [sig by inception key]. rot: [sig by OLD key, sig by NEW key]."
          items:
            $ref: "#/components/schemas/Base64Url"

    KelResponse:
      type: object
      required: [aid, events]
      properties:
        aid:
          $ref: "#/components/schemas/Fingerprint"
        events:
          type: array
          items:
            $ref: "#/components/schemas/SignedKelEvent"

    DidDocument:
      type: object
      required: ["@context", id, verificationMethod, assertionMethod]
      properties:
        "@context":
          type: array
          items:
            type: string
        id:
          type: string
          example: did:subter:service
        verificationMethod:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                example: did:subter:service#sig
              type:
                type: string
                example: Ed25519VerificationKey2020
              controller:
                type: string
                example: did:subter:service
              publicKeyBase64Url:
                $ref: "#/components/schemas/Base64Url"
        assertionMethod:
          type: array
          items:
            type: string
            example: did:subter:service#sig

    Health:
      type: object
      required: [ok, db, ts]
      properties:
        ok:
          type: boolean
        db:
          type: string
          enum: [up, down]
        ts:
          type: string
          format: date-time
