openapi: 3.0.3

info:
  title: Lantern Platform API
  version: "1.0.0"
  contact:
    name: Lantern Platform Team
    email: team@lantern-us.com
    url: https://lantern-us.com/docs
  description: |
    Partner-facing API for integrating with Lantern, a decentralized
    provenance ledger for digital images on Base (Ethereum L2).

    This spec covers the OAuth-driven write surface (register on a user's
    behalf, transfer commissioned-work ownership, subscribe to webhook
    events) plus the public read surface (verify a Lantern ID, fetch
    provenance history). Internal endpoints (admin, OAuth-server
    internals, consent-session schemas) are deliberately not in this
    spec — they're enforced at the backend but not part of the partner
    contract.

    ## Authentication

    Two flavours, both `Authorization: Bearer <token>`:

    - **Public read endpoints** (`/api/v1/verify/*`) require no auth.
    - **Partner write endpoints** (`/api/v1/register/delegated`,
      `/api/v1/content/{id}/transfer`, all `/api/v1/webhooks*`) require
      an OAuth 2.0 access token obtained via the full authorization-code
      + PKCE flow (RFC 6749 + RFC 7636 + RFC 9126):

      1. `POST /oauth/par` with client credentials + PKCE challenge →
         opaque `request_uri`.
      2. Redirect the user's browser to
         `GET /oauth/authorize?request_uri=<uri>`.
      3. User clicks Approve in the Lantern consent UI; partner receives
         `code` + `state` at their `redirect_uri`.
      4. `POST /oauth/token` with `grant_type=authorization_code` to
         exchange the code for an `access_token` + `refresh_token`.

      Token TTL: access 3600s, refresh 90d (rotates on each use).

    ## Rate limits

    | Surface | Cap |
    |---|---|
    | `/oauth/par` | 30 / min per (client_id, IP) |
    | `/oauth/token` | 30 / min per (client_id, IP) |
    | `/oauth/authorize` | 60 / min per IP |
    | `/oauth/clients/me/rotate-secret` | 10 / min per client_id |
    | `/api/v1/verify/*` | 60 / min per IP, 5-min cache headers |
    | `/api/v1/register/delegated` | 30 / min per (client_id, IP) |
    | `/api/v1/content/{id}/transfer` | 30 / min per (client_id, IP) |
    | `/api/v1/webhooks*` | 100 / min per (client_id, IP) |

    Exceeded → `429 Too Many Requests`.

    ## Error envelope

    All `/oauth/*` and `/api/v1/*` 4xx + 5xx responses use the
    RFC 6749 § 5.2 OAuth-style shape:

    ```json
    { "error": "invalid_request", "error_description": "Human-readable." }
    ```

    Status code carries the broad class; `error` carries the machine-
    readable code; `error_description` is safe to surface to users
    (never leaks secrets, never includes sensitive PII).

    ## Webhooks

    Subscribing partners receive signed deliveries at their `target_url`.
    OpenAPI 3.0 doesn't natively model webhooks (3.1 does, but the
    code-gen tooling lags); the event catalogue is documented here.

    **Supported event types** (`event_types` array on
    `WebhookSubscriptionCreateRequest`):

    | Event | Fires on |
    |---|---|
    | `content_registered` | A new work was registered by your client. |
    | `content_revoked` | A work registered by your client was revoked. |
    | `content_disputed` | A dispute was filed or resolved on a work registered by your client. |
    | `content_transferred` | Ownership of a work registered by your client transferred to a new owner. |
    | `content_embedded` | Lantern's neural matcher fingerprinted the work; it's now searchable for unauthorized copies. |
    | `content_source_confirmed` | The background source-URL worker verified the work is publicly hosted at the `source_url` the user claimed. |

    **Delivery shape**: POST to your `target_url` with body:

    ```json
    {
      "event_id": "<uuid>",
      "event_type": "<string>",
      "created_at": "<iso8601>",
      "attempt": 1,
      "data": { ... }
    }
    ```

    **Headers**:

    - `X-Lantern-Event-Id` — UUID, use for idempotency dedup
    - `X-Lantern-Event-Type` — same as `event_type` in body
    - `X-Lantern-Timestamp` — Unix seconds; reject if more than 5 min old
    - `X-Lantern-Attempt` — 1-indexed attempt number
    - `X-Lantern-Signature` — `sha256=<hex>`, HMAC-SHA256 over the raw
      request body using your subscription's `secret`

    **Verification** (Node example):

    ```ts
    import { createHmac, timingSafeEqual } from "crypto";
    const expected = "sha256=" + createHmac("sha256", secret)
      .update(rawBody).digest("hex");
    const ok = timingSafeEqual(Buffer.from(expected), Buffer.from(header));
    ```

    **Retry policy**: non-2xx (or 10s timeout) retries with exponential
    backoff at 1m, 2m, 4m, 8m, 16m, 32m, 64m, 128m (max 8 attempts,
    ~4h15m total). After that the delivery is marked `dead_letter` and
    won't retry. Dedup on `X-Lantern-Event-Id`.

servers:
  - url: https://api.lantern-us.com
    description: Production

tags:
  - name: Authentication
    description: OAuth 2.0 + PKCE + PAR partner authentication.
  - name: Registration
    description: Register new works on a user's behalf.
  - name: Transfer
    description: Transfer ownership of a registered work to a new creator.
  - name: Verify
    description: Public lookups for Lantern IDs, content hashes, and history.
  - name: Webhooks
    description: Subscribe to partner-scoped lifecycle events.

paths:
  # ─── Authentication ────────────────────────────────────────────────
  /oauth/par:
    post:
      tags: [Authentication]
      summary: Push authorization request (RFC 9126)
      description: |
        Stores partner-supplied OAuth parameters server-side and returns
        an opaque `request_uri` to redirect the user's browser to
        `/oauth/authorize`. Partner credentials supplied via HTTP Basic
        OR form-body `client_id` + `client_secret`.
      operationId: oauthPar
      security:
        - basicAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/PARRequestBody"
      responses:
        "201":
          description: PAR row stored.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PARResponse"
        "400":
          $ref: "#/components/responses/OAuthError"
        "401":
          $ref: "#/components/responses/OAuthError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /oauth/authorize:
    get:
      tags: [Authentication]
      summary: Consent page state (JSON-shaped)
      description: |
        Returns the consent-page state for a previously-pushed PAR row.
        The Lantern web app renders this as the user-facing consent UI;
        partners do not call it directly — they redirect the user's
        browser to this URL with the `request_uri` query param.
      operationId: oauthAuthorize
      security: []
      parameters:
        - in: query
          name: request_uri
          required: true
          schema:
            type: string
            example: "urn:ietf:params:oauth:request_uri:abc123…"
      responses:
        "200":
          description: Consent state. Renderable by the Lantern web UI.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/OAuthAuthorizeResponse"
                  - $ref: "#/components/schemas/OAuthAuthorizeAutoCreateResponse"
        "401":
          description: No session cookie; user needs to sign in first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthAuthorizeLoginRequiredResponse"
        "400":
          $ref: "#/components/responses/OAuthError"

  /oauth/authorize/approve:
    post:
      tags: [Authentication]
      summary: User-approves consent; mints auth code
      description: |
        Called by the Lantern consent UI after the user clicks Approve.
        Partners do not call this directly. Returns a `redirect_url`
        with `?code=<plaintext>&state=<state>` appended; the UI 302s
        there.
      operationId: oauthAuthorizeApprove
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthApproveRequest"
      responses:
        "200":
          description: Auth code minted; redirect URL returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthApproveResponse"
        "400":
          $ref: "#/components/responses/OAuthError"
        "401":
          $ref: "#/components/responses/OAuthError"

  /oauth/authorize/deny:
    post:
      tags: [Authentication]
      summary: User denies consent
      description: |
        Symmetric to /approve; returns the partner's redirect_uri with
        `?error=access_denied&state=<state>`.
      operationId: oauthAuthorizeDeny
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OAuthApproveRequest"
      responses:
        "200":
          description: Denial recorded; redirect URL returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthApproveResponse"

  /oauth/token:
    post:
      tags: [Authentication]
      summary: Exchange auth code or refresh token for access token
      description: |
        Two grant types: `authorization_code` (initial exchange after
        consent) and `refresh_token` (rotation). Client credentials
        accepted via HTTP Basic OR form-body.

        Successful response includes both `access_token` (3600s TTL)
        and `refresh_token` (90d TTL, rotates on each use).
      operationId: oauthToken
      security:
        - basicAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OAuthTokenRequest"
      responses:
        "200":
          description: Token pair minted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthTokenResponse"
        "400":
          $ref: "#/components/responses/OAuthError"
        "401":
          $ref: "#/components/responses/OAuthError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /oauth/revoke:
    post:
      tags: [Authentication]
      summary: Revoke an access or refresh token (RFC 7009)
      operationId: oauthRevoke
      security:
        - basicAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: "#/components/schemas/OAuthRevokeRequest"
      responses:
        "200":
          description: Token revoked (or already revoked, or never existed — always 200 per RFC).
        "401":
          $ref: "#/components/responses/OAuthError"

  /oauth/userinfo:
    get:
      tags: [Authentication]
      summary: Minimal user info for the access token's bearer
      description: |
        Returns the four fields a partner needs to identify the user:
        opaque UUID, email, display_name, account creation timestamp.
        No wallet address, no email_verified_at, no admin flag, no
        session state. Partners that need more call the relevant
        `/api/v1/*` endpoint with the same token.
      operationId: oauthUserInfo
      responses:
        "200":
          description: User info.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthUserInfoResponse"
        "401":
          $ref: "#/components/responses/OAuthError"

  /oauth/clients/me/rotate-secret:
    post:
      tags: [Authentication]
      summary: Rotate the client_secret of the authenticated client
      description: |
        Partner-self-service rotation. Authenticate with the CURRENT
        secret via HTTP Basic or form-body, receive the NEW secret in
        the response (returned ONCE — store it immediately).

        Existing access + refresh tokens minted under the OLD secret
        are NOT revoked. To invalidate those too, call /oauth/revoke
        per-token before or after rotation.
      operationId: oauthClientSecretRotate
      security:
        - basicAuth: []
        - {}
      responses:
        "200":
          description: New secret minted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthClientSecretRotateResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ─── Registration ─────────────────────────────────────────────────
  /api/v1/register/delegated:
    post:
      tags: [Registration]
      summary: Register a new work on behalf of the authenticated user
      description: |
        Partner-facing register endpoint. Provide EXACTLY ONE of:
        `image` (multipart file upload, max 50 MB) OR `image_url`
        (HTTPS URL that Lantern fetches server-side with 3-layer SSRF
        defense).

        On success, Lantern mints a Lantern ID, hashes the image,
        computes neural embeddings (SSCD + CLIP + DINOv3), submits
        the registration to the Base blockchain, and stores all
        metadata. The response includes the Lantern ID, chain tx,
        and a verify URL.

        Idempotency: pass `partner_idempotency_key` in the form body
        (1-64 chars, unique per partner). Re-running with the same
        key returns the original record instead of double-registering.

        Required scope: `register:write`.
      operationId: registerDelegated
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: "#/components/schemas/DelegatedRegisterRequest"
      responses:
        "201":
          description: Work registered on chain and in the registry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DelegatedRegisterResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "403":
          description: Token lacks `register:write` scope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "409":
          description: Image already registered (content_hash collision).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "413":
          description: Image exceeds 50 MB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "422":
          description: Validation failed (bad MIME, image_url SSRF reject, etc.).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ─── Transfer ─────────────────────────────────────────────────────
  /api/v1/content/{lantern_id}/transfer:
    post:
      tags: [Transfer]
      summary: Transfer ownership of a registered work
      description: |
        Used when a partner brokers a commission sale: the seller's
        record gets superseded by a new record under the buyer's
        wallet, on-chain. If the buyer doesn't have a Lantern account,
        Lantern auto-creates one and emails a sign-in link.

        The on-chain `transferContentOwnership` event fires
        synchronously; the response returns once the transaction is
        submitted (not necessarily confirmed). The `chain_tx` in the
        response can be used to track confirmation.

        Required scope: `transfer:write`. The authenticated user must
        own the work being transferred.
      operationId: transferContentDelegated
      parameters:
        - in: path
          name: lantern_id
          required: true
          schema:
            type: string
            pattern: "^LNTN-[a-zA-Z0-9]{8}$"
            example: "LNTN-AbCd1234"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/DelegatedTransferRequest"
      responses:
        "200":
          description: Transfer submitted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DelegatedTransferResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "403":
          description: Token lacks `transfer:write` scope OR caller is not the work's owner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "404":
          description: No record matches this lantern_id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "409":
          description: Record inactive (already revoked) or disputed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "422":
          description: Validation failed (e.g., transferring to self, stale ToS version).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"

  # ─── Verify ───────────────────────────────────────────────────────
  /api/v1/verify/{lantern_id}:
    get:
      tags: [Verify]
      summary: Full provenance for a Lantern ID
      description: |
        Public read endpoint. Returns the display block (badge content),
        creator info, on-chain state, source-URL verification status,
        and a canonical verify_url. Suitable for partner badge rendering.

        ETag + 300s cache headers; mirror them in your CDN.
      operationId: verifyByLanternId
      security: []
      parameters:
        - in: path
          name: lantern_id
          required: true
          schema:
            type: string
            pattern: "^LNTN-[a-zA-Z0-9]{8}$"
      responses:
        "200":
          description: Provenance record found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformVerifyResponse"
        "404":
          description: Unknown lantern_id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

  /api/v1/verify/hash/{content_hash}:
    get:
      tags: [Verify]
      summary: Lookup by SHA-256 content hash
      description: |
        Same response shape as /api/v1/verify/{lantern_id}, but the
        lookup key is the SHA-256 of the image bytes. Useful before
        upload — partner has a local file, wants to know if it's
        already registered without sending the bytes.
      operationId: verifyByContentHash
      security: []
      parameters:
        - in: path
          name: content_hash
          required: true
          schema:
            type: string
            pattern: "^sha256:[a-f0-9]{64}$"
            example: "sha256:abc123…"
      responses:
        "200":
          description: Match found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformVerifyResponse"
        "404":
          description: No match.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

  /api/v1/verify/{lantern_id}/history:
    get:
      tags: [Verify]
      summary: Provenance timeline for a Lantern ID
      description: |
        Returns the chronological history of register, revoke, dispute,
        and ownership-transfer events for the work. Ordered oldest-first.
      operationId: verifyHistory
      security: []
      parameters:
        - in: path
          name: lantern_id
          required: true
          schema:
            type: string
            pattern: "^LNTN-[a-zA-Z0-9]{8}$"
      responses:
        "200":
          description: Timeline returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlatformVerifyHistoryResponse"
        "404":
          description: Unknown lantern_id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

  # ─── Webhooks ─────────────────────────────────────────────────────
  /api/v1/webhooks:
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: |
        Subscribe to one or more event types for works your client
        originally registered or brokered. Returns the subscription
        record PLUS the signing `secret` — store the secret
        immediately, it is never returned again. To rotate, delete
        the subscription and create a new one.

        Required scope: `webhook:manage`.
      operationId: webhookCreate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookSubscriptionCreateRequest"
      responses:
        "201":
          description: Subscription created. Includes the plaintext secret.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionCreateResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "403":
          description: Token lacks `webhook:manage` scope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "422":
          description: Validation failed (HTTPS-only target_url, unknown event_type, etc.).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"
        "429":
          $ref: "#/components/responses/RateLimited"
    get:
      tags: [Webhooks]
      summary: List your active subscriptions
      description: |
        Returns all subscriptions scoped to the authenticated client.
        Secret is never returned by this endpoint — only by the
        original POST.
      operationId: webhookList
      responses:
        "200":
          description: Subscription list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionListResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "403":
          description: Token lacks `webhook:manage` scope.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

  /api/v1/webhooks/{subscription_id}:
    delete:
      tags: [Webhooks]
      summary: Revoke a subscription
      description: |
        Soft-revokes the subscription (status flips to `revoked`); no
        further deliveries fire. Idempotent — re-calling on an already-
        revoked subscription returns 200 with the same payload.
      operationId: webhookDelete
      parameters:
        - in: path
          name: subscription_id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookSubscriptionResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "404":
          description: Subscription doesn't exist or belongs to another partner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

  /api/v1/webhooks/{subscription_id}/test:
    post:
      tags: [Webhooks]
      summary: Fire a synthetic test delivery
      description: |
        Sends a `test` event to the subscription's `target_url` so the
        partner can confirm their receiver wiring (HMAC verification,
        endpoint reachability). The test event is NOT one of the
        production event types; receivers should accept it explicitly.
      operationId: webhookTest
      parameters:
        - in: path
          name: subscription_id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Test delivery queued.
        "401":
          $ref: "#/components/responses/OAuthError"
        "404":
          description: Subscription doesn't exist or belongs to another partner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

  /api/v1/webhooks/{subscription_id}/deliveries:
    get:
      tags: [Webhooks]
      summary: Inspect recent delivery attempts
      description: |
        Returns up to 100 delivery records per call with status,
        attempts, last_status, last_attempt_at, and a 500-char snippet
        of the response body. Newest-first. Use `next_offset` from the
        response for pagination.

        Cross-partner filtered — partner B never sees partner A's
        deliveries even if they guess the subscription_id.

        Required scope: `webhook:manage`.
      operationId: webhookDeliveriesList
      parameters:
        - in: path
          name: subscription_id
          required: true
          schema:
            type: string
            format: uuid
        - in: query
          name: status
          required: false
          schema:
            type: string
            enum: [pending, in_progress, success, failed, dead_letter]
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - in: query
          name: offset
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Delivery list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryListResponse"
        "401":
          $ref: "#/components/responses/OAuthError"
        "404":
          description: Subscription doesn't exist or belongs to another partner.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OAuthErrorResponse"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        OAuth 2.0 access token from POST /oauth/token. Token TTL is
        3600s; refresh via the same endpoint with grant_type=refresh_token.
    basicAuth:
      type: http
      scheme: basic
      description: |
        Confidential-client credentials (`client_id` + `client_secret`).
        Used on OAuth-server endpoints; partner write endpoints
        (`/api/v1/*`) use bearer tokens instead.

  responses:
    OAuthError:
      description: OAuth-style error response.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OAuthErrorResponse"
    RateLimited:
      description: Rate limit exceeded.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OAuthErrorResponse"
      headers:
        Retry-After:
          description: Seconds until the next request is allowed.
          schema:
            type: integer

  schemas:
    # ─── Error envelope ─────────────────────────────────────────────
    OAuthErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Machine-readable error code (RFC 6749 § 5.2 vocabulary).
          example: "invalid_request"
        error_description:
          type: string
          nullable: true
          description: Human-readable detail. Never leaks secrets or PII.

    # ─── OAuth schemas ──────────────────────────────────────────────
    PARRequestBody:
      type: object
      required: [response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method]
      properties:
        response_type:
          type: string
          enum: [code]
        client_id:
          type: string
          minLength: 1
          maxLength: 64
        redirect_uri:
          type: string
          minLength: 1
          maxLength: 500
        scope:
          type: string
          minLength: 1
          maxLength: 255
          description: Space-separated scopes. Allowed values are `register:write`, `transfer:write`, `webhook:manage`.
          example: "register:write webhook:manage"
        state:
          type: string
          minLength: 1
          maxLength: 500
        code_challenge:
          type: string
          minLength: 43
          maxLength: 128
          description: PKCE challenge (RFC 7636 § 4.2).
        code_challenge_method:
          type: string
          enum: [S256]
        prefill_email:
          type: string
          format: email
          nullable: true
          description: If the partner already knows the user's email, pass it to skip the email-entry step in the consent UI.

    PARResponse:
      type: object
      required: [request_uri, expires_in]
      properties:
        request_uri:
          type: string
          description: |
            Opaque, single-use, expires in 90 seconds. Format:
            `urn:ietf:params:oauth:request_uri:<opaque>`.
          example: "urn:ietf:params:oauth:request_uri:abc123…"
        expires_in:
          type: integer
          description: Seconds until the PAR row expires.
          example: 90

    OAuthAuthorizeResponse:
      type: object
      required: [consent_state, client_name, scopes_requested, redirect_uri, state, consent_token]
      properties:
        consent_state:
          type: string
          example: "consent_required"
        client_name:
          type: string
        client_icon_url:
          type: string
          nullable: true
        scopes_requested:
          type: array
          items:
            type: string
        redirect_uri:
          type: string
        state:
          type: string
        consent_token:
          type: string
          description: Single-use, 90s TTL. Input to POST /oauth/authorize/approve.
        prefill_email_match:
          type: string
          nullable: true
          enum: [lantern_user, match_existing_user, no_match]

    OAuthAuthorizeAutoCreateResponse:
      type: object
      required: [consent_state, client_name, scopes_requested, redirect_uri, state, consent_token, prefill_email]
      properties:
        consent_state:
          type: string
          enum: [auto_create_needed]
        client_name:
          type: string
        client_icon_url:
          type: string
          nullable: true
        scopes_requested:
          type: array
          items:
            type: string
        redirect_uri:
          type: string
        state:
          type: string
        consent_token:
          type: string
        prefill_email:
          type: string
          format: email

    OAuthAuthorizeLoginRequiredResponse:
      type: object
      required: [error, auth_url]
      properties:
        error:
          type: string
          enum: [login_required]
        auth_url:
          type: string

    OAuthApproveRequest:
      type: object
      required: [consent_token]
      properties:
        consent_token:
          type: string
          minLength: 1
          maxLength: 255

    OAuthApproveResponse:
      type: object
      required: [redirect_url]
      properties:
        redirect_url:
          type: string
          description: Includes `?code=<plaintext>&state=<state>` for the partner's redirect_uri.

    OAuthTokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type:
          type: string
          enum: [authorization_code, refresh_token]
        code:
          type: string
          nullable: true
          description: Required for `authorization_code` grant.
        redirect_uri:
          type: string
          nullable: true
          description: Required for `authorization_code` grant; must match the PAR's redirect_uri exactly.
        code_verifier:
          type: string
          nullable: true
          description: PKCE verifier (RFC 7636 § 4.1). Required for `authorization_code` grant.
        refresh_token:
          type: string
          nullable: true
          description: Required for `refresh_token` grant.

    OAuthTokenResponse:
      type: object
      required: [access_token, token_type, expires_in, refresh_token, scope]
      properties:
        access_token:
          type: string
        token_type:
          type: string
          enum: [Bearer]
        expires_in:
          type: integer
          description: Access token TTL in seconds (3600).
        refresh_token:
          type: string
          description: New refresh token (the old one is revoked).
        scope:
          type: string
          description: Granted scopes, space-separated.

    OAuthRevokeRequest:
      type: object
      required: [token]
      properties:
        token:
          type: string
          minLength: 1
          maxLength: 500
        token_type_hint:
          type: string
          nullable: true
          enum: [access_token, refresh_token]

    OAuthUserInfoResponse:
      type: object
      required: [id, email, display_name, created_at]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        display_name:
          type: string
        created_at:
          type: string
          format: date-time

    OAuthClientSecretRotateResponse:
      type: object
      required: [client_id, client_secret, rotated_at]
      properties:
        client_id:
          type: string
        client_secret:
          type: string
          description: Returned ONCE. Store it now.
        rotated_at:
          type: string
          format: date-time

    # ─── Registration schemas ───────────────────────────────────────
    DelegatedRegisterRequest:
      type: object
      description: |
        Multipart form body. Provide EXACTLY ONE of `image` (file
        upload) or `image_url` (HTTPS URL).
      properties:
        image:
          type: string
          format: binary
          description: File upload (PNG, JPEG, WebP, GIF, TIFF; max 50 MB).
        image_url:
          type: string
          format: uri
          description: HTTPS URL of the image. Server fetches with SSRF defense.
        title:
          type: string
          minLength: 2
          maxLength: 100
        description:
          type: string
          minLength: 10
          maxLength: 1000
        creation_tool:
          type: string
          minLength: 2
          maxLength: 80
        source_url:
          type: string
          format: uri
          description: Public URL where the work is posted on your platform.
        terms_agreed_version:
          type: string
          description: Fetched from GET /auth/terms-version at startup.
        authorship_affirmed:
          type: boolean
          description: Must be `true`.
        age_confirmed:
          type: boolean
          description: Must be `true`. The user is 13+ (or local equivalent).
        opt_out_training:
          type: boolean
          nullable: true
          description: Per-work AI-training opt-out. Inherits user default if omitted.
        partner_idempotency_key:
          type: string
          minLength: 1
          maxLength: 64
          description: Idempotency key. Re-using returns the original record.
      required: [title, description, creation_tool, source_url, terms_agreed_version, authorship_affirmed, age_confirmed]

    DelegatedRegisterResponse:
      type: object
      required: [lantern_id, content_hash, verify_url, registered_at, source_platform]
      properties:
        lantern_id:
          type: string
          example: "LNTN-AbCd1234"
        content_hash:
          type: string
          pattern: "^sha256:[a-f0-9]{64}$"
        verify_url:
          type: string
          format: uri
          description: Canonical public verify URL.
        chain_tx:
          type: string
          nullable: true
          description: Base blockchain tx hash. Null if registration was queued for gas (rare).
        registered_at:
          type: string
          format: date-time
        source_platform:
          type: string
          description: Encoded as `partner:<client_id>` for partner-originated registrations.

    # ─── Transfer schemas ───────────────────────────────────────────
    DelegatedTransferRequest:
      type: object
      required: [new_creator_email, terms_agreed_version]
      properties:
        new_creator_email:
          type: string
          format: email
          description: The buyer. If they don't have a Lantern account, one is auto-created.
        reason_category:
          type: string
          minLength: 1
          maxLength: 40
          enum: [commission_sale, sold_offline, gift, other]
          default: commission_sale
        terms_agreed_version:
          type: string
          minLength: 1
          maxLength: 20

    DelegatedTransferResponse:
      type: object
      required: [new_lantern_id, superseded_lantern_id, chain_tx, buyer_was_auto_created, transferred_at]
      properties:
        new_lantern_id:
          type: string
          example: "LNTN-XyZw5678"
        superseded_lantern_id:
          type: string
          example: "LNTN-AbCd1234"
        chain_tx:
          type: string
        buyer_was_auto_created:
          type: boolean
          description: True if Lantern created an account for the buyer + emailed them a sign-in link.
        transferred_at:
          type: string
          format: date-time

    # ─── Verify schemas ─────────────────────────────────────────────
    PlatformVerifyCreator:
      type: object
      required: [verified_platforms]
      properties:
        display_name:
          type: string
          nullable: true
        verified_platforms:
          type: array
          items:
            $ref: "#/components/schemas/PlatformVerifyLinkedPlatform"

    PlatformVerifyLinkedPlatform:
      type: object
      required: [platform]
      properties:
        platform:
          type: string
          description: Platform name (e.g., "twitter", "pixiv"). No username; no profile URL.

    PlatformVerifyChain:
      type: object
      required: [verified, code]
      properties:
        verified:
          type: boolean
        code:
          type: string
          example: "confirmed"
        tx:
          type: string
          nullable: true
        explorer_url:
          type: string
          format: uri
          nullable: true

    PlatformVerifyDisplay:
      type: object
      required: [severity, label]
      properties:
        severity:
          type: string
          enum: [verified, unverified, warning, error, chain_issue, not-found]
        label:
          type: string
        helper_text:
          type: string
          nullable: true

    PlatformVerifyResponse:
      type: object
      required: [lantern_id, status, dispute_status, creator, registered_at, chain, display, verify_url, source_status]
      properties:
        lantern_id:
          type: string
        status:
          type: string
          enum: [active, revoked]
        dispute_status:
          type: string
          enum: [none, open, upheld, rejected]
        creator:
          $ref: "#/components/schemas/PlatformVerifyCreator"
        registered_at:
          type: string
          format: date-time
        chain:
          $ref: "#/components/schemas/PlatformVerifyChain"
        display:
          $ref: "#/components/schemas/PlatformVerifyDisplay"
        verify_url:
          type: string
          format: uri
        source_status:
          type: string
          enum: [pending, confirmed, not_found, error]
          default: pending
        source_url:
          type: string
          nullable: true

    PlatformVerifyHistoryEvent:
      type: object
      required: [event_type, occurred_at]
      properties:
        event_type:
          type: string
          enum: [registered, revoked, admin_revoked, dispute_filed, dispute_resolved, reactivated, ownership_transferred, embedded, source_confirmed]
        occurred_at:
          type: string
          format: date-time
        actor_display_name:
          type: string
          nullable: true
        actor_wallet:
          type: string
          nullable: true
        chain_tx:
          type: string
          nullable: true
        explorer_url:
          type: string
          format: uri
          nullable: true
        reason_category:
          type: string
          nullable: true

    PlatformVerifyHistoryResponse:
      type: object
      required: [lantern_id, events]
      properties:
        lantern_id:
          type: string
        events:
          type: array
          items:
            $ref: "#/components/schemas/PlatformVerifyHistoryEvent"

    # ─── Webhook schemas ────────────────────────────────────────────
    WebhookEventType:
      type: string
      enum:
        - content_registered
        - content_revoked
        - content_disputed
        - content_transferred
        - content_embedded
        - content_source_confirmed

    WebhookSubscriptionCreateRequest:
      type: object
      required: [name, target_url, event_types]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: Display name shown in the subscription list (yours; not surfaced to end users).
        target_url:
          type: string
          format: uri
          description: HTTPS URL. HTTP rejected. Private/loopback ranges blocked at delivery time.
        event_types:
          type: array
          minItems: 1
          maxItems: 10
          items:
            $ref: "#/components/schemas/WebhookEventType"

    WebhookSubscriptionResponse:
      type: object
      required: [id, name, target_url, event_types, status, created_at]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        target_url:
          type: string
        event_types:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEventType"
        status:
          type: string
          enum: [active, paused, revoked]
        created_at:
          type: string
          format: date-time
        revoked_at:
          type: string
          format: date-time
          nullable: true
        last_success_at:
          type: string
          format: date-time
          nullable: true
        last_failure_at:
          type: string
          format: date-time
          nullable: true

    WebhookSubscriptionCreateResponse:
      allOf:
        - $ref: "#/components/schemas/WebhookSubscriptionResponse"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: HMAC-SHA256 signing key. Returned ONCE — store it.

    WebhookSubscriptionListResponse:
      type: object
      required: [subscriptions]
      properties:
        subscriptions:
          type: array
          items:
            $ref: "#/components/schemas/WebhookSubscriptionResponse"

    WebhookDeliveryResponse:
      type: object
      required: [id, event_type, status, attempts, created_at]
      properties:
        id:
          type: string
          format: uuid
        event_type:
          type: string
        status:
          type: string
          enum: [pending, in_progress, success, failed, dead_letter]
        attempts:
          type: integer
        last_status:
          type: integer
          nullable: true
          description: HTTP status of the last attempt's response.
        last_response_snippet:
          type: string
          nullable: true
          description: First 500 chars of the response body.
        last_attempt_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true

    WebhookDeliveryListResponse:
      type: object
      required: [deliveries]
      properties:
        deliveries:
          type: array
          items:
            $ref: "#/components/schemas/WebhookDeliveryResponse"
        next_offset:
          type: integer
          nullable: true
          description: Offset to pass on the next call, or null if this was the last page.

# Default security: every operation requires a bearer token UNLESS
# overridden by operation-level `security: []` (public verify endpoints)
# or `security: [{basicAuth: []}, {}]` (OAuth server endpoints, which
# accept basic-auth credentials OR form-body credentials with no
# header).
security:
  - bearerAuth: []
