openapi: 3.1.0
info:
  title: "elvix REST API"
  version: 1.0.0
  description: "Public surface for customer backends. Call with `Authorization: Bearer eak_...` (or no auth for public sign-in endpoints)."
servers:
  - url: https://elvix.is
tags:
  - name: "API keys"
  - name: "Application activity"
  - name: "Application lifecycle"
  - name: "Applications"
  - name: "Authentication"
  - name: "Beta access"
  - name: "Contact"
  - name: "Memberships"
  - name: "Other"
  - name: "Public state"
  - name: "Roles"
  - name: "SDK bootstrap"
  - name: "Scopes"
  - name: "Token verification"
  - name: "User activity"
  - name: "User sessions"
  - name: "Users"
  - name: "Webhooks"
paths:
  "/api/applications":
    get:
      tags: ["Applications"]
      summary: "List your applications"
      operationId: "get-applications"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    post:
      tags: ["Applications"]
      summary: "Create an application"
      operationId: "post-applications"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: ["Applications"]
      summary: "Update an application"
      operationId: "patch-applications-by-id"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/activity/list":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Application activity"]
      summary: "List recent application activity"
      operationId: "get-applications-by-id-activity-list"
      description: |
        Paginated activity feed for one Application. Cursor-keyset on
        (createdAt, id) so identical-ms rows page through cleanly. Filter
        params:

          ?status=ok | error            — restrict by AuthEvent.ok
          ?family=signin | role | …     — restrict by `kind` prefix
          ?country=DE                   — ipCountry literal
          ?q=foo@bar.com                — user email substring (insensitive)
          ?cursor=<base64>              — keyset pointer from a prior page
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/api-keys":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["API keys"]
      summary: "List API keys"
      operationId: "get-applications-by-id-api-keys"
      description: |
        API-keys collection for one application. Lists keys (paginated) and
        creates new ones.

        Create flow:
          1. Validate name + envelope (roles/scopes/memberships referenced
             must exist in this app) + rate limits.
          2. Mint a User row with kind="api_key" — the synthetic principal.
             Email is a deterministic placeholder so the User table's
             unique-on-email constraint stays content; UI never shows it.
          3. Create an ApplicationUser row so the standard roles/scopes/
             memberships UX, the user-detail page, and the live SSE
             propagation all work as-is.
          4. Attach the chosen roles/scopes/memberships in a single
             transaction, bumping the rolesUpdatedAt / *UpdatedAt fields.
          5. Generate a token, store its sha256 + prefix, and return the
             plaintext in the response — this is the ONLY moment it exists.
          6. Write an `apikey.created` AuthEvent with actorUserId = the
             console session that minted it.
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    post:
      tags: ["API keys"]
      summary: "Create an API key"
      operationId: "post-applications-by-id-api-keys"
      description: |
        API-keys collection for one application. Lists keys (paginated) and
        creates new ones.

        Create flow:
          1. Validate name + envelope (roles/scopes/memberships referenced
             must exist in this app) + rate limits.
          2. Mint a User row with kind="api_key" — the synthetic principal.
             Email is a deterministic placeholder so the User table's
             unique-on-email constraint stays content; UI never shows it.
          3. Create an ApplicationUser row so the standard roles/scopes/
             memberships UX, the user-detail page, and the live SSE
             propagation all work as-is.
          4. Attach the chosen roles/scopes/memberships in a single
             transaction, bumping the rolesUpdatedAt / *UpdatedAt fields.
          5. Generate a token, store its sha256 + prefix, and return the
             plaintext in the response — this is the ONLY moment it exists.
          6. Write an `apikey.created` AuthEvent with actorUserId = the
             console session that minted it.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/api-keys/{keyId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: keyId
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: ["API keys"]
      summary: "Update an API key"
      operationId: "patch-applications-by-id-api-keys-by-keyid"
      description: |
        Per-key edit + revoke. PATCH adjusts name + expiresAt + rate limits.
        DELETE soft-revokes (sets revokedAt + bans the underlying
        ApplicationUser membership so future /verify calls fail at the
        membership gate even if the key row somehow survives).
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: ["API keys"]
      summary: "Delete an API key"
      operationId: "delete-applications-by-id-api-keys-by-keyid"
      description: |
        Per-key edit + revoke. PATCH adjusts name + expiresAt + rate limits.
        DELETE soft-revokes (sets revokedAt + bans the underlying
        ApplicationUser membership so future /verify calls fail at the
        membership gate even if the key row somehow survives).
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/api-keys/{keyId}/rotate":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: keyId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["API keys"]
      summary: "Rotate an API key"
      operationId: "post-applications-by-id-api-keys-by-keyid-rotate"
      description: |
        Rotate the secret on an existing API key. The principal (synthetic
        User), envelope (roles/scopes/memberships), name, expiry and rate
        limits all stay exactly the same — only `tokenHash` + `prefix` are
        replaced with a freshly-generated `eak_...` token.

        Behaviour:
          - Refuses if the key is revoked (rotate a dead key is nonsense —
            mint a new one or restore via the danger zone instead).
          - Refuses if the underlying membership is locked (deleted users,
            same predicate as the attach/detach gate).
          - The OLD plaintext stops authenticating the instant the row is
            updated (its sha256 no longer matches anything in the DB).
          - Writes an `apikey.rotated` AuthEvent with actorUserId so the
            Activity tab + audit trail show who rotated and when.
          - The plaintext NEW token is returned in this single response and
            never stored in plaintext.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/api-keys/{keyId}/rotate-with-grace":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: keyId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["API keys"]
      summary: "Rotate an API key with a 24h grace window"
      operationId: "post-applications-by-id-api-keys-by-keyid-rotate-with-grace"
      description: |
        Rotate an API key with a 24-hour overlap window. Unlike the instant
        `/rotate` action (which swaps `tokenHash` in place), this flow:

          1. Creates a NEW `ApiKeyCredential` row as the SUCCESSOR — same
             principal user, same envelope (roles + scopes + memberships),
             same rate limits, fresh `eak_...` token.
          2. Stamps the PREDECESSOR with `rotatedToId = <successor.id>` and
             `gracePeriodEndsAt = now + 24h`. The predecessor stays valid
             until the deadline; verify() short-circuits to "expired" after.

        Customer workflow: provision the new key, ship deploys that pick it
        up, traffic migrates, predecessor expires automatically. No outage.

        Refuses if:
          - The key is revoked (rotate-with-grace is for live keys only).
          - The key is already inside an active grace window (nested
            rotations would chain forever; finish the current grace first
            or force-revoke).
          - The principal membership is locked (deleted user, etc).

        Writes an `apikey.rotated_with_grace` AuthEvent on success.

        Returns the plaintext successor token in this single response. The
        old plaintext keeps working for 24h.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/beta-interests":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Beta access"]
      summary: "List beta access requests"
      operationId: "get-applications-by-id-beta-interests"
      description: |
        Admin-side Beta interest endpoints.

          GET   — paginated read of every row (admin only). Used by the
                  Limited access page to populate pending + allowlist +
                  notify-list tables in one round trip via groupBy on
                  status (server page does the heavy lift; this endpoint
                  is for future client-side refresh after manual mutations).

          POST  — admin manually adds an email straight to the allowlist
                  (status="approved", source="admin_add"). Idempotent: an
                  existing row for the same email is upserted to approved
                  status with the new admin as decidedById.
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/beta-interests/{interestId}/approve":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: interestId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Beta access"]
      summary: "Approve a beta access request"
      operationId: "post-applications-by-id-beta-interests-by-interestid-approve"
      description: |
        Admin approves a pending BetaInterest. Status flips to "approved",
        stamping decidedById + decidedAt. Idempotent on already-approved
        rows (no-op, returns 200).
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/beta-interests/{interestId}/decline":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: interestId
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Beta access"]
      summary: "Decline a beta access request"
      operationId: "post-applications-by-id-beta-interests-by-interestid-decline"
      description: |
        Admin declines a pending BetaInterest. Status moves to
        "notify_when_public" — the person joins the "tell me when public"
        list and receives a soft "we'll be in touch" email. Word "decline"
        lives only on the admin button; the user-facing email is warm.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/beta-policy":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Beta access"]
      summary: "Set the beta access policy"
      operationId: "post-applications-by-id-beta-policy"
      description: |
        Switch Application.betaRequestPolicy
        ("manual" | "auto_accept" | "auto_decline").

        Flipping to auto_accept bulk-approves every currently-pending
        BetaInterest and (if notify=true) emails each approval. Flipping
        to auto_decline does the symmetric thing — pending rows move to
        notify_when_public and a soft "we'll be in touch" email fires.
        Flipping to manual never touches existing rows; pending stays
        pending for the admin to resolve.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/go-live":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Take an application live (test to live)"
      operationId: "post-applications-by-id-go-live"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/lifecycle/confirm":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Application lifecycle"]
      summary: "Confirm a lifecycle action"
      operationId: "post-applications-by-id-lifecycle-confirm"
      description: |
        Step 3 of the lifecycle wizard — verify the OTP code and execute
        the lifecycle transition atomically in the same DB call.

        Coupling verify + execute in one endpoint matters: it prevents
        "I have a valid token, what could I do with it?" replays. The
        challenge row is consumed (consumedAt set) BEFORE the state
        transition runs, inside the same transaction, so any retry sees
        a consumed row and the action cannot fire twice.

        What "execute" means per kind:
          • archive   — set archivedAt = NOW. Cuts new sign-ins, revokes
                        open sessions, drops pending OTP challenges,
                        writes `application.archived` AuthEvent.
          • delete    — set deletedAt = NOW + purgeAfter = NOW + 90d.
                        Same auth revoke. App stays visible in the list
                        with a red badge + countdown so the owner can
                        cancel any time. App is NEVER physically deleted
                        — purgeAfter is just when the row stops appearing
                        in the default list view.
          • hide_now  — set deletedAt = NOW (if not already), purgeAfter
                        = NOW. Removes the app from the default list
                        immediately. Data lives on in the DB forever.
                        Reachable from (a) delete-wizard "skip grace"
                        toggle, or (b) lifecycle tab while in grace.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/lifecycle/send":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Application lifecycle"]
      summary: "Send a lifecycle action code"
      operationId: "post-applications-by-id-lifecycle-send"
      description: |
        Step 2 of the lifecycle wizard — mint a confirmation challenge,
        send the OTP email, return the challenge id so the client can
        pass it to /confirm in step 3.

        Per kind:
          • archive    — only valid when the app is active (not already
                         archived, not deleting).
          • delete     — only valid when the app is not already deleting.
                         Sets the 90-day grace clock on confirm.
          • hide_now   — valid in two flavours: (a) "delete + skip grace"
                         from the wizard's toggle, or (b) "I'm already in
                         grace and want to short-circuit the rest of the
                         90 days". Both lead to the same final state
                         (purgeAfter = NOW).

        The actual state mutation lives in /confirm — this route only
        issues credentials. Keeping the two endpoints separate means a
        dropped network on step 3 doesn't leave the owner staring at a
        half-applied transition.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/memberships":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Memberships"]
      summary: "List your app's memberships"
      operationId: "get-applications-by-id-memberships"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    post:
      tags: ["Memberships"]
      summary: "Create a membership group"
      operationId: "post-applications-by-id-memberships"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/memberships/{membershipId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: membershipId
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: ["Memberships"]
      summary: "Update a membership group"
      operationId: "patch-applications-by-id-memberships-by-membershipid"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: ["Memberships"]
      summary: "Delete a membership group"
      operationId: "delete-applications-by-id-memberships-by-membershipid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/public/access-interest":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Public state"]
      summary: "Register interest in beta access"
      operationId: "post-applications-by-id-public-access-interest"
      description: |
        Public endpoint hit by the hosted /sign-in/[clientId] card when an
        end-user submits either:

          kind="request" — "Request access to private beta" form. Resolves
                           against Application.betaRequestPolicy:
                             manual       → status="pending"
                             auto_accept  → status="approved"
                             auto_decline → status="notify_when_public"
                           (emails are wired in step 6.)

          kind="notify"  — "Inform me when it goes public" form. Always
                           lands as status="notify_when_public".

        No session required — this surface is intentionally public so
        strangers can show interest. Rate-limited per IP + per email to
        keep abuse small.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/public/state":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Public state"]
      summary: "Read public app state"
      operationId: "get-applications-by-id-public-state"
      description: |
        Public lifecycle/gate snapshot. Polled by the hosted sign-in
        surface so open tabs reflect gate flips + archive/delete events
        in near real-time (default cadence: every 6s, see
        `<HostedSignInLiveRefresh>`). Pure read, no side effects, no
        session — anything we return here is already publicly visible
        by trying to sign in.

        Returns 404 if the app id doesn't resolve so the client can stop
        polling.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/publish":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Publish an application (draft to test)"
      operationId: "post-applications-by-id-publish"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/recheck-urls":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Re-probe application legal URLs"
      operationId: "post-applications-by-id-recheck-urls"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/restore":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Restore a deleted application"
      operationId: "post-applications-by-id-restore"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/roles":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Roles"]
      summary: "List your app's roles"
      operationId: "get-applications-by-id-roles"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    post:
      tags: ["Roles"]
      summary: "Create a role"
      operationId: "post-applications-by-id-roles"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/roles/{roleId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: roleId
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: ["Roles"]
      summary: "Update a role"
      operationId: "patch-applications-by-id-roles-by-roleid"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: ["Roles"]
      summary: "Delete a role"
      operationId: "delete-applications-by-id-roles-by-roleid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/rotate-secret":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Rotate the application client secret"
      operationId: "post-applications-by-id-rotate-secret"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/scopes":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Scopes"]
      summary: "List your app's scopes"
      operationId: "get-applications-by-id-scopes"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    post:
      tags: ["Scopes"]
      summary: "Create a scope"
      operationId: "post-applications-by-id-scopes"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/scopes/{scopeId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: scopeId
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: ["Scopes"]
      summary: "Update a scope"
      operationId: "patch-applications-by-id-scopes-by-scopeid"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: ["Scopes"]
      summary: "Delete a scope"
      operationId: "delete-applications-by-id-scopes-by-scopeid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/signin-gate":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Set sign-in availability"
      operationId: "post-applications-by-id-signin-gate"
      description: |
        Toggle the Application.signinGate enum
        ("public" | "private_beta" | "closed").

        When the gate transitions TO "public" from any gated state, every
        pending BetaInterest is auto-resolved into the notify-when-public
        list (those people will be emailed on the next renderAccessEmail
        step — wired in a follow-up). All notify_when_public rows fire one
        email each, then get stamped with notifiedAt so re-flipping the
        gate doesn't double-send.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/unarchive":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Applications"]
      summary: "Unarchive an application"
      operationId: "post-applications-by-id-unarchive"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/url-check":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Applications"]
      summary: "Check application legal URLs"
      operationId: "get-applications-by-id-url-check"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Users"]
      summary: "List the users in your app"
      operationId: "get-applications-by-id-users"
      description: |
        Cursored users list for a single application. Returns a page of
        memberships ordered by `lastSeenAt` desc with `id` as the tiebreak.
        The opaque cursor packs (lastSeenAt, id) so two rows at the same ms
        still page through correctly.

        Filters out soft-deleted rows past the 90-day grace, same as the
        console's server-rendered initial fetch.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: ["Users"]
      summary: "Delete a user"
      operationId: "delete-applications-by-id-users-by-uid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/activity":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["User activity"]
      summary: "Read a user's activity timeline"
      operationId: "get-applications-by-id-users-by-uid-activity"
      description: |
        Cursored auth-events stream for a single (app, user). Ordered
        newest-first by createdAt with id as tiebreak.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/inactive":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Users"]
      summary: "Deactivate a user"
      operationId: "post-applications-by-id-users-by-uid-inactive"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/logs/{sessionId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
      - name: sessionId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["User activity"]
      summary: "Read one sign-in event"
      operationId: "get-applications-by-id-users-by-uid-logs-by-sessionid"
      description: |
        Single-session drilldown: the session row, every AuthEvent that
        happened during its window, and the active / idle / offline timeline
        computed from the PresenceEvent ledger.

        Window: createdAt <= ts < coalesce(endedAt, now())
        Scope:  same (userId, applicationId | console) as the session.

        Timeline computation: PresenceEvent rows are append-only state-flips.
        To paint the timeline we take the "last known state" at session
        start, then walk forward emitting segments at each transition. The
        tail segment closes at session end (or now() if live).
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/logs/list":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["User activity"]
      summary: "List a user's sign-in events"
      operationId: "get-applications-by-id-users-by-uid-logs-list"
      description: |
        Cursored login-history list for a single (app, user). Every Session
        row (live + ended) becomes a row in the Logs UI with login time,
        logout time, duration, end reason. Ordered newest-first by
        `createdAt`, `id` for tiebreak.

        Scoped the same as the Sessions list — rows that belong to this
        user AND either this app OR the console surface.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/memberships/{membershipId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
      - name: membershipId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: ["Memberships"]
      summary: "Remove a user from a membership group"
      operationId: "delete-applications-by-id-users-by-uid-memberships-by-membershipid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/restore":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Users"]
      summary: "Restore a deactivated user"
      operationId: "post-applications-by-id-users-by-uid-restore"
      description: |
        Cancel a soft-delete within the 90-day grace window.

        Only valid when the deletion was owner-initiated. If the end-user
        triggered their own deletion (future flow, deletedBy === "user"),
        the deletion is sticky for compliance reasons and the owner cannot
        reverse it — the user has to re-sign-in instead, which creates a
        new membership row.
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/roles/{roleId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
      - name: roleId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: ["Roles"]
      summary: "Remove a role from a user"
      operationId: "delete-applications-by-id-users-by-uid-roles-by-roleid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/scopes/{scopeId}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
      - name: scopeId
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: ["Scopes"]
      summary: "Revoke a scope from a user"
      operationId: "delete-applications-by-id-users-by-uid-scopes-by-scopeid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/sessions/{sid}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
      - name: sid
        in: path
        required: true
        schema: { type: string }
    delete:
      tags: ["User sessions"]
      summary: "Sign a user out of one device"
      operationId: "delete-applications-by-id-users-by-uid-sessions-by-sid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/sessions/list":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["User sessions"]
      summary: "List a user's sessions"
      operationId: "get-applications-by-id-users-by-uid-sessions-list"
      description: |
        Cursored sessions list for a single (app, user). Returns sessions
        ordered newest-first (`createdAt desc`, `id desc` for tiebreak).

        Listing endpoint is at `.../sessions/list` because the bare
        `.../sessions` route already handles bulk revoke. Keeping verbs on
        separate URLs makes the contract loud.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/snapshots":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["User activity"]
      summary: "Read a user's identity snapshots"
      operationId: "get-applications-by-id-users-by-uid-snapshots"
      description: |
        Cursored OAuth snapshot history for a single (app, user). Newest
        first by capturedAt, id as tiebreak.

        Snapshots grow ~1 row per sign-in (we never overwrite — they're the
        audit trail for OAuth payloads), so this page is the most likely to
        become long. Pagination matters most here.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/users/{uid}/status":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: uid
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Users"]
      summary: "Block a user"
      operationId: "post-applications-by-id-users-by-uid-status"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/webhooks":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Webhooks"]
      summary: "List webhooks"
      operationId: "get-applications-by-id-webhooks"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    post:
      tags: ["Webhooks"]
      summary: "Add a webhook"
      operationId: "post-applications-by-id-webhooks"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/webhooks/{whid}":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: whid
        in: path
        required: true
        schema: { type: string }
    patch:
      tags: ["Webhooks"]
      summary: "Update a webhook"
      operationId: "patch-applications-by-id-webhooks-by-whid"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: ["Webhooks"]
      summary: "Delete a webhook"
      operationId: "delete-applications-by-id-webhooks-by-whid"
      x-elvix-admin: true
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/webhooks/{whid}/deliveries":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: whid
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Webhooks"]
      summary: "List webhook deliveries"
      operationId: "get-applications-by-id-webhooks-by-whid-deliveries"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/applications/{id}/webhooks/{whid}/test":
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: whid
        in: path
        required: true
        schema: { type: string }
    post:
      tags: ["Webhooks"]
      summary: "Send a test webhook"
      operationId: "post-applications-by-id-webhooks-by-whid-test"
      x-elvix-admin: true
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "403":
          description: Admin scope required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/github/callback":
    get:
      tags: ["Authentication"]
      summary: "Complete GitHub sign-in"
      operationId: "get-auth-github-callback"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/github/start":
    get:
      tags: ["Authentication"]
      summary: "Start GitHub sign-in"
      operationId: "get-auth-github-start"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/google/callback":
    get:
      tags: ["Authentication"]
      summary: "Complete Google sign-in"
      operationId: "get-auth-google-callback"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/google/credential":
    post:
      tags: ["Authentication"]
      summary: "Sign in with a Google credential"
      operationId: "post-auth-google-credential"
      description: |
        Verifies a Google Identity Services credential (ID token) and mints
        a session. Companion to the redirect-OAuth `/api/auth/google/callback`
        route; this one accepts the ID token that GIS hands back to the
        client when the user picks an account via One Tap / Sign-in-with-
        Google button / FedCM / popup ux_mode.

        Flow:
          1. POST { credential, intent, clientId? } from the GIS callback
          2. Verify JWT against Google's JWKS (jose) with aud = our GOOGLE_CLIENT_ID
          3. Find or create the User + OAuthAccount link
          4. Ensure ApplicationUser membership + default roles
          5. Mint a Session and return { redirect: <next-step> }

        No refresh-token here — GIS issues an ID token only. If an app
        needs offline access, it has to use the redirect-OAuth flow.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/google/start":
    get:
      tags: ["Authentication"]
      summary: "Start Google sign-in"
      operationId: "get-auth-google-start"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/identifier/resolve":
    post:
      tags: ["Authentication"]
      summary: "Resolve a sign-in identifier"
      operationId: "post-auth-identifier-resolve"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/otp/start":
    post:
      tags: ["Authentication"]
      summary: "Send a sign-in code"
      operationId: "post-auth-otp-start"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/otp/verify":
    post:
      tags: ["Authentication"]
      summary: "Verify a sign-in code"
      operationId: "post-auth-otp-verify"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/passkey/sign-in/finish":
    post:
      tags: ["Authentication"]
      summary: "Finish passkey sign-in"
      operationId: "post-auth-passkey-sign-in-finish"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/auth/passkey/sign-in/start":
    post:
      tags: ["Authentication"]
      summary: "Start passkey sign-in"
      operationId: "post-auth-passkey-sign-in-start"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/contact":
    post:
      tags: ["Contact"]
      summary: "Send a contact message"
      operationId: "post-contact"
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/v1/bootstrap/{clientId}":
    parameters:
      - name: clientId
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["SDK bootstrap"]
      summary: "Read the public SDK render envelope"
      operationId: "get-v1-bootstrap-by-clientid"
      description: |
        Public bootstrap for `@elvix.is/sdk`. The customer mounts
        `<ElvixProvider clientId="...">` once at the app root and every
        `<Elvix*>` component reads its render envelope from here.

        Resolves an Application by its public `clientId` (NOT the internal
        cuid — customers never see that) and returns the full chrome
        envelope every SDK surface needs: name, logo pair, brand chord,
        enabled methods, legal URLs, layout knobs, gate state.

        Pure read. No session. No auth. Anything in the payload is already
        publicly visible by trying to sign in.

        404 when the clientId doesn't resolve OR the app is in a state where
        the customer should stop showing the sign-in surface (deleted past
        grace, or never published past draft). Archived apps still bootstrap
        so the customer can render the "we're paused" gate state instead of
        a hard 404.
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/v1/device/code":
    post:
      tags: ["Other"]
      summary: "Start a device authorization (CLI login)"
      operationId: "post-v1-device-code"
      description: |
        OAuth 2.0 Device Authorization Grant — step 1 (RFC 8628 §3.1/§3.2).

        A headless device / CLI (`plm login`) posts its `client_id` and gets back a
        `device_code` (secret, polled at the token endpoint) plus a short `user_code`
        the human approves in a browser. Flat OAuth-shaped JSON (no Spine envelope),
        matching the sibling `/api/v1/*` machine contracts.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/v1/device/token":
    post:
      tags: ["Other"]
      summary: "Poll for the device token (CLI login)"
      operationId: "post-v1-device-token"
      description: |
        OAuth 2.0 Device Authorization Grant — step 3 (RFC 8628 §3.4/§3.5).

        The device polls with its `device_code`. While the human hasn't approved we
        answer `{ error: "authorization_pending" }` (HTTP 400, per the RFC); once
        approved we mint a Personal Access Token (`eak_`) bound to the approving
        user, mark the request claimed (single use), and return the token.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/v1/i18n/{product}/{locale}":
    parameters:
      - name: product
        in: path
        required: true
        schema: { type: string }
      - name: locale
        in: path
        required: true
        schema: { type: string }
    get:
      tags: ["Other"]
      summary: "Fetch a translation catalog"
      operationId: "get-v1-i18n-by-product-by-locale"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/v1/session":
    post:
      tags: ["Other"]
      summary: "Verify a user session token"
      operationId: "post-v1-session"
      description: |
        Host-backend verification of a user session token.

        The cross-origin SDK's `verifyElvixToken()` POSTs here with
        `Authorization: Bearer <session-token>` — the token handed to the SDK at
        sign-in (a customer app on its own origin can't receive elvix's cookie).
        Returns the LIVE identity + roles/scopes/memberships for the token's
        application, or `ok:false` when the session is dead or the user/membership
        is banned/paused/deleted. That makes the host enforce bans/pauses
        server-side on its own requests — not just client-side — closing the
        window where a banned user keeps acting on a stale token.

        Flat `{ ok, ... }` response shape (no Spine envelope), matching the sibling
        `/api/v1/verify` M2M contract that customer backends already consume.
        Bearer-only; wildcard CORS is set in middleware.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
  "/api/v1/verify":
    post:
      tags: ["Token verification"]
      summary: "Check if a token is valid"
      operationId: "post-v1-verify"
      description: |
        M2M verification endpoint. Customer backends POST { token } here on
        behalf of an API key, receive the resolved envelope, and cache the
        answer locally for ~60s before re-asking.

          POST /api/v1/verify
          Body: { token: "eak_..." }

        Responses:
          200 { ok: true, applicationId, userId, roles[], scopes[],
                memberships[], expiresAt }
          401 { ok: false, error: "invalid_token" | "revoked" | "expired" }
          403 { ok: false, error: "membership_blocked", membershipStatus }
          429 { ok: false, error: "rate_limited", retryAfterSec }

        Token may also be passed via `Authorization: Bearer eak_...` — most
        SDKs default to that. Body wins if both are sent.
      requestBody:
        required: false
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiOk'
        "400":
          description: Invalid input
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          description: Unauthenticated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "429":
          description: Rate limited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
components:
  schemas:
    ApiOk:
      type: object
      description: 'Spine ResponseDto<T> — success envelope. `data` carries the route payload; the rest are envelope metadata.'
      properties:
        success: { type: boolean, enum: [true] }
        data: { description: 'Route payload — see the individual endpoint docs for its shape.' }
        code: { type: integer, example: 200 }
        successMessage: { type: string, nullable: true }
        helperMessage: { type: string, nullable: true }
        timestamp: { type: integer, example: 1779735988369 }
        requestId: { type: string, nullable: true }
      required: [success, data, code, timestamp]
    ApiError:
      type: object
      description: 'Spine ResponseDto<null> — error envelope. `errorMessage` is the short snake_case key (`unauthenticated`, `not_found`, etc.) stable across versions.'
      properties:
        success: { type: boolean, enum: [false] }
        data: { type: 'null' }
        code: { type: integer, example: 401 }
        errorMessage: { type: string, example: 'unauthenticated' }
        errorKey: { type: string, nullable: true }
        errorParams: { type: object, additionalProperties: { type: string }, nullable: true }
        helperMessage: { type: string, nullable: true }
        timestamp: { type: integer }
        requestId: { type: string, nullable: true }
      required: [success, data, code, errorMessage, timestamp]
