openapi: 3.0.3
info:
  title: Backlit Glow API
  version: "1.0.0"
  summary: Manage, deploy, and roll back a Backlit glow via per-glow API key.
  description: |
    The Glow API is the AI-agent-facing REST surface for managing a
    single glow. Authentication is a per-glow API key in the
    `Authorization: Bearer` header. Tokens are minted via the
    console-api endpoint `POST /v1/glows/{id}/api-keys` and never
    recoverable after the initial response.

    Deploys are a `tar.gz` archive of the site's static files, sent
    as a single multipart/form-data part named `archive`. The
    archive's contents are unpacked into the glow's region-pinned
    asset bucket under an immutable version id and the live-version
    pointer is flipped atomically on success.

    There are two upload paths:

      1. Direct (POST `/v1/glows/{id}/deploy`) — bearer the per-glow
         API key on every request. Suitable for scripts and CLIs.
      2. Ticket handoff (POST `/v1/glows/{id}/deploy-ticket` followed by
         POST `/v1/uploads/{ticket}`) — the authenticated mint returns
         a single-use, short-lived URL. The unauthenticated upload
         endpoint accepts the archive at that URL. This is the path
         the MCP server (`mcp.backlit.run`) uses so AI agents can keep
         the archive bytes out of the MCP transport entirely.

    The inverse of deploy is the download/bundle path: the authenticated
    mint (POST `/v1/glows/{id}/versions/{ver}/bundle-url`) returns a
    reusable, short-lived capability URL, and the unauthenticated stream
    (GET `/v1/downloads/{token}`) serves that committed version as a
    `tar.gz` of its static assets.

    index.html is treated as the default document.

servers:
  - url: https://glow.backlitdev.run
    description: Production

security:
  - bearerAuth: []

paths:
  /healthz:
    get:
      summary: Liveness probe
      security: []
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /openapi.yaml:
    get:
      summary: This document
      security: []
      responses:
        "200":
          description: OpenAPI document for the deploy API.
          content:
            application/yaml: {}

  /v1/glows/{id}/deploy:
    post:
      summary: Upload and activate a new version
      description: |
        Streams the multipart `archive` field through a tar.gz reader,
        uploads each regular-file entry under
        `gs://backlit-assets-{region}/{glow_id}/versions/{version_id}/`,
        and atomically inserts the version record while flipping the
        glow's `live_version` pointer.

        Path traversal, symlinks, hardlinks, devices, and other
        non-regular tar entry types are rejected with 400
        `invalid_archive`. Bodies larger than the configured
        `MAX_DEPLOY_SIZE` (default 100 MiB compressed) return 413
        `payload_too_large`.

        AI agents using the MCP server should prefer the ticket-handoff
        flow (POST `/v1/glows/{id}/deploy-ticket` then POST
        `/v1/uploads/{ticket}`) so the archive bytes don't have to
        traverse the MCP transport.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [archive]
              properties:
                archive:
                  type: string
                  format: binary
                  description: gzipped tar of the site assets.
      responses:
        "201":
          description: Deploy committed and now live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeployResult"
        "400":
          description: Malformed request or invalid archive contents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Glow no longer exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Transaction aborted; retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: Archive exceeds the configured upload cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/deploy-ticket:
    post:
      summary: Mint a short-lived upload URL
      description: |
        Authenticated with the per-glow API key. Returns a single-use,
        time-limited URL. POST a `multipart/form-data` body with field
        `archive` (a gzipped tar of the site assets) to that URL — no
        `Authorization` header. The URL is consumed atomically on the
        first request and rejected on every subsequent one.

        Default TTL is 15 minutes (overridable server-side via the
        `DEPLOY_TICKET_TTL` env var). The raw token in the URL is the
        only authn token; only its sha256 hash is persisted server-side
        so a Firestore read leak does not yield usable tickets.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      responses:
        "201":
          description: Ticket minted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeployTicketResult"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Glow no longer exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/versions/{ver}/bundle-url:
    post:
      summary: Mint a reusable download URL for a committed version
      description: |
        Mint a reusable, short-lived URL to download a committed version
        as a tar.gz. Free; not metered. The URL is reusable until it
        expires — unlike the single-use `/v1/uploads/{ticket}` upload URL,
        the same token verifies repeatedly until its expiry.

        Authenticated with the per-glow API key. Returns a URL whose path
        is `/v1/downloads/{token}` rooted at `GLOW_API_PUBLIC_URL`; GET it
        with no `Authorization` header (the raw token is the capability)
        to stream `gzip(tar(...))` of the version's static assets. Any
        committed version qualifies — there is no live/draft restriction
        and the download is not gated on the glow's status (owner export).

        Default TTL is 15 minutes (overridable server-side via the
        `BUNDLE_URL_TTL` env var, independent of `DEPLOY_TICKET_TTL`). The
        raw token in the URL is the only authn token; only its sha256 hash
        is persisted server-side (collection `download_tokens`) so a
        Firestore read leak does not yield usable tokens.
      parameters:
        - $ref: "#/components/parameters/GlowID"
        - name: ver
          in: path
          required: true
          description: Version id to bundle.
          schema:
            type: string
      responses:
        "201":
          description: Download URL minted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BundleURLResult"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: version_not_found — no such version for this glow.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/downloads/{token}:
    get:
      summary: Download a committed version as a tar.gz
      description: |
        Unauthenticated by API key — the raw download token in the URL
        path is the only authn token. Non-destructively verifies the
        token (it is **reusable** until it expires), resolves the glow,
        version, and region, then streams `gzip(tar(...))` of the
        version's static assets built on the fly from the region-pinned
        asset bucket. The archive is re-tarred: file contents match the
        deployed version, but tar framing and mtimes are not
        byte-identical to the original upload.

        The `200` response carries
        `Content-Disposition: attachment; filename="{name}-{ver}.tar.gz"`
        and `Cache-Control: private, no-store`. There is no
        `Content-Length` — the archive size is unknown until the stream
        completes. Download integrity is verified by the gzip/tar trailer,
        not the HTTP status: a mid-stream failure truncates the body
        without changing the already-committed 200 (the gzip footer is
        omitted so truncation is detectable from the trailer).

        Tokens that are missing, unknown, or past their expiry are all
        rejected with a generic 401 `unauthenticated` — the same
        anti-enumeration posture as the API-key middleware. (The token is
        reusable, so the rejection is "unknown or expired", never "already
        used".)
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: The raw download token returned by `/bundle-url`.
          schema:
            type: string
      responses:
        "200":
          description: |
            The version's assets as a gzipped tar stream. See the
            operation description for the `Content-Disposition` /
            `Cache-Control` headers and the trailer-based integrity note.
          content:
            application/gzip:
              schema:
                type: string
                format: binary
        "401":
          description: Missing, unknown, or expired token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Glow or version deleted between mint and download.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/uploads/{ticket}:
    post:
      summary: Redeem an upload ticket and activate a new version
      description: |
        Unauthenticated by API key — the raw ticket value in the URL
        path is the only authn token. Atomically consumes the ticket
        (single use), then runs the same archive validation, GCS
        streaming, and atomic version commit as POST
        `/v1/glows/{id}/deploy`. The committed version records the
        sha256 of the API key that minted the ticket as the uploader
        for audit.

        When the ticket was minted as a Draft (only possible via
        console-api's `POST /v1/glows/{id}/deploy-ticket?draft=true`),
        the commit flips `draft_version` instead of `live_version` and
        the response carries `draft_version`. The version is
        immediately reachable at
        `https://{glow}.backlit.run/_v/{version_id}/`.

        When the consumed ticket is a Draft and the new version lands at
        the head of the version list (newest by `created_at`), it
        **replaces** the prior head-draft: that prior draft's history row
        and GCS assets are removed and its bytes refunded, keeping the
        version list clean during tight deploy/test loops. The live
        version is never replaced.

        Tickets that are unknown, already used, or past their expiry
        are all rejected with a generic 401 `unauthenticated` — the
        same posture as the API-key middleware — so an attacker cannot
        distinguish "I have a bad ticket" from "this ticket was just
        used".

        CORS: this endpoint serves an OPTIONS preflight and echoes
        `Access-Control-Allow-Origin` on the POST response for any
        origin in the `GLOW_API_CORS_ORIGINS` allowlist (production:
        `https://console.backlit.run`). Browser uploads from the
        console SPA's "Upload to Versions" button rely on this.
      security: []
      parameters:
        - name: ticket
          in: path
          required: true
          description: The raw ticket value returned by `/deploy-ticket`.
          schema:
            type: string
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [archive]
              properties:
                archive:
                  type: string
                  format: binary
                  description: gzipped tar of the site assets.
      responses:
        "201":
          description: Deploy committed and now live.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeployResult"
        "400":
          description: Malformed request or invalid archive contents.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, unknown, already-used, or expired ticket.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Glow no longer exists (deleted between mint and upload).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Transaction aborted; mint a fresh ticket and retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: Archive exceeds the configured upload cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/rollback:
    post:
      summary: Roll back to a prior version
      description: |
        Flips the glow's `live_version` pointer to a previously
        deployed version. The target version must exist in the glow's
        versions subcollection; rolling back to the currently-live
        version is a 200 no-op.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RollbackRequest"
      responses:
        "200":
          description: Rollback applied (or no-op when version_id already current).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RollbackResult"
        "400":
          description: Malformed JSON or missing version_id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Target version not found or glow deleted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}:
    get:
      summary: Read glow metadata
      parameters:
        - $ref: "#/components/parameters/GlowID"
      responses:
        "200":
          description: Glow document.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Glow"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      summary: Update mutable glow fields
      description: |
        Updates the glow's name, title, default_file, auth_mode, or
        status. Region and server-owned fields (owner_uid, live_version,
        *_at, hmac_salt*) are rejected. Setting auth_mode to Public and
        choosing a custom name both require a subscription; title (a
        free-text display label) does not.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatchRequest"
      responses:
        "200":
          description: Patched glow.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Glow"
        "400":
          description: Validation failed (invalid_*, region_immutable, field_immutable).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          description: Paid feature; subscription required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Glow no longer exists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Name conflict.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/versions:
    get:
      summary: List deploy history
      parameters:
        - $ref: "#/components/parameters/GlowID"
        - name: limit
          in: query
          required: false
          description: Maximum entries to return (default 50, cap 200).
          schema:
            type: integer
            minimum: 1
            maximum: 200
      responses:
        "200":
          description: Versions newest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Versions"
        "400":
          description: invalid_limit.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/versions/{ver}:
    delete:
      summary: Delete a deploy version and reclaim its storage
      description: |
        Permanently removes a committed version's assets and history row
        and refunds its recorded uncompressed size to the glow's shared
        per-glow data cap (`data_bytes_used`).

        The currently-live version cannot be deleted — roll back or promote
        a different version first, then delete. Returns `409 version_in_use`
        for the live version. The currently-pinned **draft** version CAN be
        deleted: doing so clears the `draft_version` pointer in the same
        transaction and reclaims its bytes. This is the only way to reclaim
        bytes consumed by old versions; there is no automatic version GC.
      parameters:
        - $ref: "#/components/parameters/GlowID"
        - name: ver
          in: path
          required: true
          description: Version id to delete.
          schema:
            type: string
      responses:
        "204":
          description: Version deleted; bytes refunded. No body.
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: version_not_found — no such version for this glow.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: version_in_use — the version is the live version.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/allowlist:
    get:
      summary: Read the allowed users and domain patterns
      parameters:
        - $ref: "#/components/parameters/GlowID"
      responses:
        "200":
          description: Allowlist (the owner is always present as an admin at position 0).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Allowlist"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    put:
      summary: Replace the allowed users and domain patterns
      description: |
        Full-replace semantics. The owner is always merged in as an admin
        at position 0 — supplying them explicitly is fine. Each user needs
        an email and/or mobile plus a permission (defaults to user). Echo
        each existing user's id so their per-user data silo survives an
        email change. Adding any non-owner user or pattern requires a
        subscription; edits and removals are free.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Allowlist"
      responses:
        "200":
          description: Allowlist after the update.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Allowlist"
        "400":
          description: invalid_allowed_user or invalid_allowed_pattern.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "402":
          description: Adding non-owner emails or patterns requires a subscription.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/api-keys:
    get:
      summary: List active API keys for this glow
      description: |
        Plaintext tokens are returned only at mint time (console-api). This
        endpoint surfaces hashes + metadata so an owner / agent can identify
        keys to revoke.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      responses:
        "200":
          description: Active keys (revoked entries are excluded).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/APIKeys"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/api-keys/{key_hash}:
    delete:
      summary: Revoke an API key
      description: |
        Marks the key revoked. Revoking the caller's own key is allowed —
        the in-flight 204 still completes; the next request bearing that
        key 401s. Unknown or foreign hashes both return 404 (envelopes are
        identical so foreign hashes aren't observable).
      parameters:
        - $ref: "#/components/parameters/GlowID"
        - name: key_hash
          in: path
          required: true
          description: SHA-256 hex of the API key to revoke.
          schema:
            type: string
      responses:
        "204":
          description: Key revoked.
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: API key not found (unknown or foreign hash).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/rotate-key:
    post:
      summary: Forensic per-glow HMAC rotation
      description: |
        Advances the per-glow salt slot ("" → s1 → s2 → ...) and writes a
        fresh random salt. Every existing session JWT on this glow becomes
        invalid on the next request. API keys (stored separately in
        /api_keys) are NOT invalidated by this endpoint — revoke them
        explicitly via DELETE /v1/glows/{id}/api-keys/{key_hash}.
        Non-idempotent: each call advances the slot.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      responses:
        "200":
          description: New salt slot kid.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RotateKeyResult"
        "401":
          description: Missing, malformed, or wrong-glow API key.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: blk_<base64>

  parameters:
    GlowID:
      name: id
      in: path
      required: true
      description: Glow document id.
      schema:
        type: string

  schemas:
    DeployResult:
      type: object
      required: [version_id, size, files, created_at]
      properties:
        version_id:
          type: string
        live_version:
          type: string
          description: |
            Present when the deploy flipped `live_version`. Mutually
            exclusive with `draft_version` — exactly one of the two
            pointer fields is set per response.
        draft_version:
          type: string
          description: |
            Present when the deploy was committed as a Draft (only
            possible via a draft ticket minted by console-api). The
            glow's `live_version` is unchanged; the version is
            reachable at `https://{glow}.backlit.run/_v/{version_id}/`
            until the owner promotes it to Live.
        preview_url:
          type: string
          description: |
            Absolute URL for previewing this exact version on the glow's
            subdomain: https://{glow}.{domain}/_v/{version_id}/. For a Draft
            deploy, show this to the user to test before promoting. Private
            glows require an allowlisted signin to load it.
        size:
          type: integer
          format: int64
          description: Total uncompressed bytes uploaded.
        files:
          type: integer
          description: Number of regular files uploaded.
        created_at:
          type: string
          format: date-time

    DeployTicketResult:
      type: object
      required: [upload_url, expires_at]
      properties:
        upload_url:
          type: string
          description: |
            Absolute URL the archive must be POSTed to. The path is
            `/v1/uploads/{ticket}` rooted at `GLOW_API_PUBLIC_URL`
            (default `https://glow.backlitdev.run`).
        expires_at:
          type: string
          format: date-time
          description: RFC3339 timestamp after which the ticket is rejected.

    BundleURLResult:
      type: object
      required: [download_url, expires_at]
      properties:
        download_url:
          type: string
          format: uri
          description: |
            Absolute URL to download the version as a tar.gz. The path is
            `/v1/downloads/{token}` rooted at `GLOW_API_PUBLIC_URL`
            (default `https://glow.backlitdev.run`). GET it with no
            `Authorization` header — the raw token is the capability.
        expires_at:
          type: string
          format: date-time
          description: |
            RFC3339 timestamp after which the token is rejected. The token
            stays valid — and reusable — until this time.

    RollbackRequest:
      type: object
      required: [version_id]
      properties:
        version_id:
          type: string

    RollbackResult:
      type: object
      required: [live_version]
      properties:
        live_version:
          type: string

    Glow:
      type: object
      required:
        - id
        - name
        - default_file
        - region
        - auth_mode
        - allowed_users
        - allowed_patterns
        - status
        - owner_uid
        - live_version
        - created_at
        - updated_at
      properties:
        id:
          type: string
        name:
          type: string
        title:
          type: string
          description: |
            Optional free-text display label (max 100 chars). Cosmetic and
            distinct from name, which is the DNS subdomain label. Empty when
            no title is set.
        default_file:
          type: string
        region:
          type: string
          enum: [us-central1, australia-southeast1]
        auth_mode:
          type: string
          enum: [Public, Private]
        allowed_users:
          type: array
          items:
            $ref: "#/components/schemas/AllowedUser"
        allowed_patterns:
          type: array
          items:
            $ref: "#/components/schemas/AllowedPattern"
        status:
          type: string
          enum: [Lit, Dark]
        owner_uid:
          type: string
        live_version:
          type: string
        data_bytes_used:
          type: integer
          format: int64
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PatchRequest:
      type: object
      description: |
        Only the four mutable fields are accepted. region and the
        server-owned fields trip a 400 (region_immutable / field_immutable).
      properties:
        name:
          type: string
          description: New subdomain label. Subscription-gated.
        title:
          type: string
          description: |
            Free-text display label (max 100 chars, no control characters).
            Cosmetic, distinct from name. Pass an empty string to clear it.
            Not subscription-gated.
        default_file:
          type: string
        auth_mode:
          type: string
          enum: [Public, Private]
          description: Setting to Public is subscription-gated.
        status:
          type: string
          enum: [Lit, Dark]

    AllowedUser:
      type: object
      required: [id, permission]
      properties:
        id:
          type: string
          description: |
            Stable, server-allocated id. Omit when adding a new user (the
            server allocates one); echo the existing id when editing so the
            user's per-user data silo survives an email change.
        name:
          type: string
        email:
          type: string
          description: Required for signin; at least one of email/mobile must be set.
        mobile:
          type: string
          description: Contact metadata only in v1 (no SMS signin yet).
        permission:
          type: string
          enum: [admin, user, viewer]
          description: Defaults to user. Viewer users cannot write shared data.

    AllowedPattern:
      type: object
      required: [pattern, permission]
      properties:
        pattern:
          type: string
          description: Domain pattern, e.g. "@company.com".
        permission:
          type: string
          enum: [admin, user, viewer]
          description: Defaults to user.

    Allowlist:
      type: object
      required: [allowed_users, allowed_patterns]
      properties:
        allowed_users:
          type: array
          items:
            $ref: "#/components/schemas/AllowedUser"
        allowed_patterns:
          type: array
          items:
            $ref: "#/components/schemas/AllowedPattern"

    Version:
      type: object
      required: [id, created_at, size, is_live]
      properties:
        id:
          type: string
        created_at:
          type: string
          format: date-time
        size:
          type: integer
          format: int64
        is_live:
          type: boolean
        uploader_key_hash:
          type: string

    Versions:
      type: object
      required: [versions]
      properties:
        versions:
          type: array
          items:
            $ref: "#/components/schemas/Version"

    APIKey:
      type: object
      required: [id, glow_id, created_at]
      properties:
        id:
          type: string
          description: SHA-256 hex of the bearer token (the storage key).
        glow_id:
          type: string
        created_at:
          type: string
          format: date-time

    APIKeys:
      type: object
      required: [api_keys]
      properties:
        api_keys:
          type: array
          items:
            $ref: "#/components/schemas/APIKey"

    RotateKeyResult:
      type: object
      required: [hmac_salt_kid]
      properties:
        hmac_salt_kid:
          type: string
          description: New per-glow salt slot ("s1", "s2", ...).

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - unauthenticated
                - glow_not_found
                - not_found
                - payload_too_large
                - invalid_archive
                - invalid_request
                - invalid_body
                - invalid_default_file
                - invalid_title
                - invalid_auth_mode
                - invalid_status
                - invalid_allowed_user
                - invalid_allowed_pattern
                - invalid_name
                - name_reserved
                - name_taken
                - region_immutable
                - field_immutable
                - subscription_required
                - invalid_limit
                - version_not_found
                - aborted
                - method_not_allowed
                - internal
            message:
              type: string
