openapi: 3.0.3
info:
  title: Backlit Glow API
  version: "1.0.0"
  description: |
    Manage, deploy, and roll back a Backlit glow via per-glow API key.

    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.

    Every error response is the `{error:{code,message}}` envelope (the
    `Error` schema below). Any authenticated operation can additionally
    return `404 glow_not_found` (the glow was deleted) and `500 internal`
    (unexpected failure), even where not repeated per operation.

    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.

    Every deploy bundle must satisfy the bundle contract:

      1. Three required root files — `index.html`, `APP.md`, and
         `DATA.md` must exist at the archive root (exact name, exact
         case, not in a subdirectory). Existence-only: a zero-byte file
         passes. `APP.md` describes what the app does; `DATA.md`
         describes how it uses the backlit data namespaces. Both are
         served like any other asset — they are public documentation,
         so never put secrets, credentials, or PII in them. Missing
         file(s) reject the deploy with 400 `missing_required_files`,
         naming every missing file.
      2. No browser storage — the content of every `.html`, `.htm`,
         `.js`, and `.mjs` entry is scanned for usage-shaped
         `localStorage` / `indexedDB` substrings (the shapes `G.`,
         `G[`, `.G`, `=G`, and `= G` for each global G; case-sensitive;
         `sessionStorage` is not banned). A hit rejects the deploy with
         400 `browser_storage_forbidden`, naming the offending files
         and the first matched token in each (capped at 20 files).
         Comments and string literals count — this is a best-effort
         heuristic steering apps toward the `window.backlit` data APIs,
         not a security boundary. Glow apps must persist data via
         `window.backlit.data/userdata/records/capture`, never browser
         storage.
      3. Optional `notify.json` — a bundle MAY carry a root
         `notify.json` (the presence-triggered 4th file; never required)
         declaring server-evaluated Backlit Push trigger rules. When
         present it is validated at deploy time: over `NOTIFY_JSON_MAX_BYTES`
         (default 64 KiB) rejects with 400 `notify_too_large`; a
         schema/security-invalid document (or a message URL outside the
         manifest scope) rejects with 400 `invalid_notify`. A valid
         document stamps the compiled rules on the immutable version and
         marks it push-enabled; absent, the version has push off.

    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.

    A sibling download surface streams a single data-plane object (one
    key's stored value rather than a version's assets): the unauthenticated
    stream (GET `/v1/data-downloads/{token}`) emits the raw object with its
    stored `Content-Type`/`Content-Encoding` passed through. Its token is
    minted out of band (by console-api) against an owned glow + object path;
    shared/records/userdata tokens are reusable within their TTL while capture
    tokens are single-use. The version-bundle stream (`/v1/downloads/{token}`) is
    free and unmetered; the data-value stream (`/v1/data-downloads/{token}`)
    meters its egress + one request against the account-wide monthly bandwidth
    pool and returns `429 quota_exceeded` when the account cap is exhausted.

    index.html is treated as the default document — one reason it is a
    required root file in every bundle.

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

security:
  - bearerAuth: []

paths:
  /healthz:
    get:
      operationId: healthCheck
      summary: Liveness probe
      description: Also served at the `/health` alias.
      security: []
      responses:
        "200":
          description: Service is healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

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

  /v1/glows/{id}/deploy:
    post:
      operationId: deployGlow
      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`.

        The bundle contract is enforced after the archive streams
        cleanly: the bundle must carry `index.html`, `APP.md`, and
        `DATA.md` at the archive root (existence-only, exact case;
        missing files → 400 `missing_required_files` naming every
        missing file), and no `.html`/`.htm`/`.js`/`.mjs` entry may
        contain a usage-shaped `localStorage`/`indexedDB` substring
        (`G.`, `G[`, `.G`, `=G`, `= G`; comments and string literals
        count — a best-effort heuristic, not a security boundary) —
        a hit → 400 `browser_storage_forbidden` naming the offending
        files and first matched token each, capped at 20. Use the
        `window.backlit` data APIs instead of browser storage. APP.md
        and DATA.md are served like any other asset — public
        documentation, no secrets or PII. An optional root `notify.json`
        (Backlit Push rules) is validated when present — over
        `NOTIFY_JSON_MAX_BYTES` → 400 `notify_too_large`; schema/security
        or scope-invalid → 400 `invalid_notify`. A rejected deploy leaves
        no version behind and charges nothing.

        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
            (`invalid_request` / `invalid_archive`), or a bundle-contract
            violation: `missing_required_files` (index.html, APP.md, or
            DATA.md absent from the archive root),
            `browser_storage_forbidden` (a scanned `.html`/`.htm`/`.js`/`.mjs`
            entry contains a usage-shaped localStorage/indexedDB token),
            or an invalid optional `notify.json`: `notify_too_large`
            (over `NOTIFY_JSON_MAX_BYTES`, default 64 KiB) or
            `invalid_notify` (schema/security-invalid, or a message URL
            outside the manifest scope).
          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: >-
            Two distinct over-size cases share this status:
            `payload_too_large` (the compressed upload body exceeds the
            configured MAX_DEPLOY_SIZE, default 100 MiB) and
            `quota_exceeded` (unpacking the archive would push the owner's
            account-wide storage allowance — shared between runtime data
            and deploy versions — over its cap, or exceed the per-deploy
            decompression hard-stop). A rejected deploy leaves no version
            behind and charges nothing.
          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:
      operationId: mintDeployTicket
      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 by the
        first upload ATTEMPT — even one whose bundle is rejected — and
        every subsequent request to it gets a generic 401; after any
        failed upload, mint a fresh ticket before retrying. The uploaded
        bundle must satisfy the bundle contract (required root files
        index.html/APP.md/DATA.md and no browser-storage usage in
        `.html`/`.htm`/`.js`/`.mjs` entries — see the API description),
        or the upload is rejected with 400.

        This surface mints LIVE tickets only — a `?draft=true` query
        param is ignored here. Draft tickets (which stage `draft_version`
        instead of flipping `live_version`) are minted via console-api's
        `POST /v1/glows/{id}/deploy-ticket?draft=true`.

        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.

        The optional `X-Glow-Public-URL-Override` header rewrites the host
        portion of the returned `upload_url` for split-host tunnel
        topologies. Its host must match the server's configured
        `GLOW_OVERRIDE_ALLOWED_SUFFIXES` allowlist; an override outside the
        allowlist (or an empty allowlist) is rejected with `400
        invalid_override` and no ticket is minted.
      parameters:
        - $ref: "#/components/parameters/GlowID"
        - $ref: "#/components/parameters/GlowPublicURLOverride"
      responses:
        "201":
          description: Ticket minted.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeployTicketResult"
        "400":
          description: invalid_override — the X-Glow-Public-URL-Override host is not permitted.
          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"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/versions/{ver}/bundle-url:
    post:
      operationId: mintVersionBundleURL
      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 5 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.

        The optional `X-Glow-Public-URL-Override` header rewrites the host
        portion of the returned `download_url` for split-host tunnel
        topologies. Its host must match the server's configured
        `GLOW_OVERRIDE_ALLOWED_SUFFIXES` allowlist; an override outside the
        allowlist (or an empty allowlist) is rejected with `400
        invalid_override` and no token is minted.
      parameters:
        - $ref: "#/components/parameters/GlowID"
        - $ref: "#/components/parameters/GlowPublicURLOverride"
        - 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"
        "400":
          description: invalid_override — the X-Glow-Public-URL-Override host is not permitted.
          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: 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:
      operationId: downloadVersionBundle
      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/data-downloads/{token}:
    get:
      operationId: downloadDataValue
      summary: Download a single data-plane object by capability token
      description: |
        Unauthenticated by API key — the raw download token in the URL
        path is the only authn token. Resolves the token to exactly one
        stored data object (one key's value) of one glow in one region,
        then streams that object's bytes verbatim from the region-pinned
        data bucket. Unlike `/v1/downloads/{token}` (which streams a
        version's assets as a `tar.gz`), this serves a single object as-is.

        The token is minted out of band (by console-api) against an owned
        glow + object path. Its lifecycle depends on the silo it was minted
        for: tokens for shared, records, and userdata values are **reusable**
        until they expire; tokens for capture (private) values are
        **single-use** and the second redemption is rejected. The endpoint
        does not branch on the silo itself — the token row carries the
        lifecycle.

        The `200` response echoes the object's stored `Content-Type`, sets
        `X-Content-Type-Options: nosniff`, and `Cache-Control: private,
        no-store`. When the object was stored gzipped the response carries
        `Content-Encoding: gzip` (so an HTTP client auto-decodes), and
        `X-Backlit-CRC32C` carries the uncompressed-body CRC32C for
        integrity.

        Unlike the version-bundle stream (`/v1/downloads/{token}`, which is
        free), this data-value stream is real runtime egress: each served
        download meters `len(body)` egress + one request against the
        account-wide monthly bandwidth pool (best-effort, synchronous). When
        the owner's account is already over its bandwidth cap the request is
        rejected with `429 quota_exceeded` before any bytes are read — the
        check sits after the token + glow resolve, so a bad/expired token still
        returns the generic 401 (a 429 never confirms a token is valid).

        Tokens that are missing, unknown, expired, or already-consumed —
        and a token whose object has since been deleted — are all rejected
        with a generic 401 `unauthenticated`, the same anti-enumeration
        posture as the API-key middleware (the endpoint never confirms a
        path's existence or a token's exact failure mode).
      security: []
      parameters:
        - name: token
          in: path
          required: true
          description: The raw data-download token minted by console-api.
          schema:
            type: string
      responses:
        "200":
          description: |
            The stored object's bytes. See the operation description for
            the `Content-Type` / `Content-Encoding` / `X-Backlit-CRC32C` /
            `Cache-Control` headers.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "401":
          description: Missing, unknown, expired, consumed, or vanished-object token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Glow deleted between mint and download.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: |
            The owner's account is over its monthly bandwidth cap; the data
            value is not served. Reuses the `quota_exceeded` code (shared with
            the deploy `413` storage overflow, differing only by status). The
            cap resets monthly — do not retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/uploads/{ticket}:
    post:
      operationId: redeemUploadTicket
      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` — including the bundle contract: the
        bundle must carry `index.html`, `APP.md`, and `DATA.md` at the
        archive root (existence-only, exact case; missing → 400
        `missing_required_files`), and no `.html`/`.htm`/`.js`/`.mjs`
        entry may contain a usage-shaped `localStorage`/`indexedDB`
        substring (`G.`, `G[`, `.G`, `=G`, `= G`; comments and string
        literals count — a best-effort heuristic, not a security
        boundary) — a hit → 400 `browser_storage_forbidden`. APP.md and
        DATA.md are served like any other asset — public documentation,
        no secrets or PII. The committed version records the uploader for
        audit: the sha256 of the API key that minted the ticket
        (`uploader_key_hash`), or — for a ticket minted via console-api on
        a user's behalf — the acting user's UID (`uploader_user_uid`).

        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}/`.

        Draft deploys are append-only: each one lands a new version row
        and moves `draft_version`, so Drafts accumulate. The bytes a Draft
        consumes are reclaimed when it is promoted to Live (promote sweeps
        the abandoned intermediate Drafts) or via a manual version delete.

        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
            (`invalid_request` / `invalid_archive`), or a bundle-contract
            violation: `missing_required_files` (index.html, APP.md, or
            DATA.md absent from the archive root),
            `browser_storage_forbidden` (a scanned `.html`/`.htm`/`.js`/`.mjs`
            entry contains a usage-shaped localStorage/indexedDB token),
            or an invalid optional `notify.json` (`notify_too_large` /
            `invalid_notify`).
          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: >-
            Two distinct over-size cases share this status:
            `payload_too_large` (the compressed upload body exceeds the
            configured MAX_DEPLOY_SIZE, default 100 MiB) and
            `quota_exceeded` (unpacking the archive would push the owner's
            account-wide storage allowance — shared between runtime data
            and deploy versions — over its cap, or exceed the per-deploy
            decompression hard-stop). A rejected deploy leaves no version
            behind and charges nothing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/uploads/{ticket}/from-github:
    post:
      operationId: redeemUploadTicketFromGitHub
      summary: Redeem an upload ticket by pulling source from GitHub
      description: |
        The GitHub-relay counterpart to POST `/v1/uploads/{ticket}`.
        Unauthenticated by API key — the raw ticket value in the URL path
        is the only authn token. Atomically consumes the ticket (single
        use), then — instead of accepting an uploaded archive — fetches the
        repository's source tarball directly from GitHub, strips GitHub's
        single top-level directory, and runs the **same** archive
        validation, gzip-bomb budget, deploy-bundle contract, storage
        charge, and atomic version commit as POST `/v1/glows/{id}/deploy`.

        The repository coordinates (`owner/repo` and the branch ref) come
        **only from the consumed ticket** — never from the request body —
        so a relay deploy is SSRF-pinned to the repository the ticket was
        minted for. The request body carries only the short-lived,
        repo-scoped GitHub **read token** (kept off the ticket so a
        `deploy_tickets` store leak yields no repo-read credential). A
        ticket minted as a classic upload ticket (no GitHub coordinates)
        is rejected `400 invalid_request`.

        The bundle contract is identical to the archive-upload path: the
        stripped tree must carry `index.html`, `APP.md`, and `DATA.md` at
        the root (missing → `400 missing_required_files`) and no
        `.html`/`.htm`/`.js`/`.mjs` entry may contain a usage-shaped
        `localStorage`/`indexedDB` substring (hit → `400
        browser_storage_forbidden`). When the ticket was minted as a Draft,
        the commit flips `draft_version` and the response carries
        `draft_version`; otherwise it flips `live_version`. The committed
        version records the acting user's UID (`uploader_user_uid`).

        Ticket rejection (unknown, already used, expired, or a soft-deleted
        glow) returns a generic `401 unauthenticated`, the same
        anti-enumeration posture as `/v1/uploads/{ticket}`.

        GitHub fetch errors are mapped so the caller (the MCP layer) can
        react: a missing branch (codeload 404) → `404 push_not_found` (the
        agent re-pushes and retries); any other GitHub HTTP error → `502
        github_error`; an unreachable GitHub (network/timeout) → `503
        github_unavailable`. The 502/503 codes signal that a URL-upload
        fallback may be used.
      security: []
      parameters:
        - name: ticket
          in: path
          required: true
          description: The raw ticket value returned by `/deploy-ticket`.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [read_token]
              properties:
                read_token:
                  type: string
                  description: >-
                    A short-lived, repo-scoped GitHub installation token with
                    read access to the ticket's repository. Used once to fetch
                    the source tarball; never stored.
      responses:
        "201":
          description: Deploy committed (live or draft per the ticket).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeployResult"
        "400":
          description: >-
            Malformed request (`invalid_request` — missing `read_token`, a
            non-JSON body, or a ticket carrying no GitHub coordinates),
            invalid archive contents (`invalid_archive`), or a
            bundle-contract violation: `missing_required_files`
            (index.html, APP.md, or DATA.md absent from the stripped root),
            `browser_storage_forbidden` (a scanned entry contains a
            usage-shaped localStorage/indexedDB token), or an invalid
            optional `notify.json` (`notify_too_large` / `invalid_notify`).
          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 (`glow_not_found`), or the pushed branch
            was not found on GitHub (`push_not_found` — re-push and retry).
          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: >-
            Over-size: `payload_too_large` (the fetched archive exceeds
            MAX_DEPLOY_SIZE) or `quota_exceeded` (unpacking would push the
            owner's account-wide storage allowance over its cap, or exceed
            the per-deploy decompression hard-stop). A rejected deploy
            leaves no version behind and charges nothing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: >-
            GitHub returned an error fetching the source archive
            (`github_error`). The push exists but GitHub failed; a
            URL-upload fallback may be used.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: >-
            Could not reach GitHub (`github_unavailable`) or the relay
            fetcher is not configured. A URL-upload fallback may be used.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/exports/{ticket}/to-github:
    post:
      operationId: redeemExportTicketToGitHub
      summary: Redeem an export ticket by pushing a version's tree to GitHub
      description: |
        The inverse of POST `/v1/uploads/{ticket}/from-github` — the
        byte-mover for the GitHub **export/pull** transport (CLAUDE.md §7).
        Unauthenticated by API key: the raw export ticket in the URL path is
        the only authn token. Atomically consumes the ticket (single use),
        reads the committed version's assets from GCS, and **pushes** them as
        a working tree onto the lease branch via the GitHub Git Data API
        (create blobs → tree → commit → ref). The agent then `git clone`s the
        lease branch out of band.

        The repository coordinates (`owner/repo`, the lease branch) **and the
        version id** come **only from the consumed ticket** — never from the
        request body — so the export is SSRF-pinned to the repository + version
        the ticket was minted for. The request body carries only the
        short-lived, repo-scoped GitHub **write token** (kept off the ticket so
        a `deploy_tickets` store leak yields no repo-write credential). A
        ticket minted without export coordinates (a classic upload / from-github
        deploy ticket) is rejected `400 invalid_request`.

        **Free & unmetered** — like the version-bundle download
        (`/v1/downloads/{token}`), and unlike the metered data-value download
        (`/v1/data-downloads/{token}`): the bytes egress to GitHub, not to a
        client.

        Ticket rejection (unknown, already used, expired, or a soft-deleted
        glow) returns a generic `401 unauthenticated`, the same
        anti-enumeration posture as `/v1/uploads/{ticket}`.
      security: []
      parameters:
        - name: ticket
          in: path
          required: true
          description: The raw export ticket value minted by console-api's relay-export.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [write_token]
              properties:
                write_token:
                  type: string
                  description: >-
                    A short-lived, repo-scoped GitHub installation token with
                    write access to the ticket's repository. Used once to push
                    the version's working tree; never stored.
      responses:
        "200":
          description: The version's working tree was pushed to the lease branch.
          content:
            application/json:
              schema:
                type: object
                properties:
                  version_id:
                    type: string
                    description: The exported version id (from the ticket).
                  commit_sha:
                    type: string
                    description: The commit sha created on the lease branch.
                  files:
                    type: integer
                    description: Number of files pushed.
                  bytes:
                    type: integer
                    format: int64
                    description: Total uncompressed bytes pushed.
        "400":
          description: >-
            Malformed request (`invalid_request` — missing `write_token`, a
            non-JSON body, or a ticket carrying no export coordinates).
          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 (`glow_not_found`), or the version has no
            assets (`version_not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: >-
            The version exceeds the per-export file/byte budget
            (`export_too_large`). Nothing is pushed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "502":
          description: >-
            GitHub returned an error pushing the export (`github_error`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: >-
            Could not reach GitHub (`github_unavailable`) or the relay
            exporter is not configured.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: Internal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/glows/{id}/copy-from/{ticket}:
    post:
      operationId: redeemCopyTicket
      summary: Redeem a copy ticket by copying a starter version into this glow
      description: |
        The internal byte-mover for the **create-from-starter** flow
        (CLAUDE.md §7) — **not** an agent-facing deploy transport. console-api
        creates an empty destination glow, mints this single-use copy ticket
        carrying the SOURCE (starter) version's coordinates, and drives this
        endpoint; here glow-api reads the source version's committed GCS asset
        tree and commits it **Live** into the destination glow, reusing the
        **same** archive validation, gzip-bomb budget, storage charge, and
        atomic version commit as POST `/v1/glows/{id}/deploy`.

        Unauthenticated by API key — the raw ticket value in the URL path is
        the only authn token (the same posture as POST `/v1/uploads/{ticket}`).
        The source coordinates (glow id, version id, region) come **only from
        the consumed ticket** — never from the request body or the URL — so the
        copy is SSRF-pinned to the version the ticket authorised. There is **no
        request body**. The URL `{id}` must match the ticket's destination glow
        (a mismatch is masked as `404 not_found`). A ticket that was not minted
        for a starter copy (no source coordinates) is rejected `400
        invalid_request`.

        The deploy-bundle **contract scan is skipped**: the source is an
        already-committed, currently-serving version, so it is treated like
        rollback/promote (no re-validation) — but the account-wide storage
        budget, the gzip-bomb hard-stop, the dual byte charge, and the atomic
        `live_version` flip all still run. The copy is charged against the **new
        owner's** account-wide storage; over-cap returns `413 quota_exceeded`
        and nothing is committed (console-api then soft-deletes the half-made
        glow). The source region and the destination glow's region may differ
        (cross-region copy). On success the response carries `live_version`.

        Ticket rejection (unknown, already used, expired, or a soft-deleted
        glow) returns a generic `401 unauthenticated`, the same
        anti-enumeration posture as `/v1/uploads/{ticket}`.
      security: []
      parameters:
        - name: id
          in: path
          required: true
          description: The destination glow id (must match the ticket's glow).
          schema:
            type: string
        - name: ticket
          in: path
          required: true
          description: The raw copy-ticket value minted by console-api.
          schema:
            type: string
      responses:
        "201":
          description: Copy committed Live into the destination glow.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeployResult"
        "400":
          description: >-
            The ticket was not minted for a starter copy
            (`invalid_request`), or the source stream was malformed
            (`invalid_archive`).
          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 (`glow_not_found`), or the URL `{id}` did
            not match the ticket's destination glow (`not_found`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Transaction aborted; retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "413":
          description: >-
            Unpacking the source version would push the destination owner's
            account-wide storage allowance over its cap (`quota_exceeded`),
            or exceed the per-deploy decompression hard-stop. A rejected copy
            leaves no version behind and charges nothing.
          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:
      operationId: rollbackGlow
      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:
      operationId: getGlow
      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"
        "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"
    patch:
      operationId: updateGlow
      summary: Update mutable glow fields
      description: |
        Updates the glow's name, title, category, auth_mode,
        hide_branding, or status. Region and server-owned fields
        (owner_uid, live_version, *_at, hmac_salt*) are rejected. Setting
        auth_mode to Public or Accounts, choosing a custom name, and hiding
        the Backlit branding each require a subscription; title and category
        (free-text display/grouping labels) do 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, or re-lighting a glow (status:"Lit") over the
            account's lit-glow limit (lit_limit_reached).
          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:
      operationId: listVersions
      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"
        "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}:
    delete:
      operationId: deleteVersion
      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 account-wide
        storage allowance the quota actually enforces (and to the glow's
        per-glow `data_bytes_used` display counter).

        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 manual way to
        reclaim bytes consumed by old versions (promote also sweeps the
        intermediate drafts it supersedes); 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:
      operationId: getAllowlist
      summary: Read the domain patterns and owner email
      description: |
        Returns the glow's domain-pattern allowlist plus the owner's
        signin email. Individual users live in the glow's users
        subcollection and are managed via console-api's
        `/v1/glows/{id}/users` endpoints (or the MCP
        `backlit_glow_user_*` tools) — this API-key surface exposes only
        the patterns and the owner.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      responses:
        "200":
          description: Domain patterns plus the owner's email.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AllowlistView"
        "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"
    put:
      operationId: replacePatterns
      summary: Replace the domain patterns
      description: |
        Full-replace of the `allowed_patterns` list — and ONLY the
        patterns. The request body accepts exactly one field,
        `allowed_patterns`; any other field (including the retired
        `allowed_users` array) is rejected with `400 invalid_body`.
        Individual users are managed via console-api's
        `/v1/glows/{id}/users` endpoints (or the MCP
        `backlit_glow_user_*` tools), not here.

        Each pattern is `{pattern, permission}` (permission defaults to
        `user`). Adding any pattern not already present requires a
        subscription; editing a pattern's permission or removing one is
        free.
      parameters:
        - $ref: "#/components/parameters/GlowID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PatternsRequest"
      responses:
        "200":
          description: The patterns after the update.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Patterns"
        "400":
          description: >-
            invalid_allowed_pattern (a malformed pattern entry) or
            invalid_body (unparseable JSON, or an unknown field such as
            the retired allowed_users array).
          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 patterns requires a subscription.
          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}/api-keys:
    get:
      operationId: listAPIKeys
      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"
        "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}/api-keys/{key_hash}:
    delete:
      operationId: revokeAPIKey
      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:
      operationId: rotateGlowKey
      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"
        "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"

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
    GlowPublicURLOverride:
      name: X-Glow-Public-URL-Override
      in: header
      required: false
      description: |
        Optional. Rewrites the host portion of the minted capability URL
        (upload_url / download_url) for split-host tunnel topologies. The
        host must match the server's configured
        `GLOW_OVERRIDE_ALLOWED_SUFFIXES` allowlist; an override outside it
        (or when the allowlist is empty) is rejected with `400
        invalid_override` and nothing is minted.
      schema:
        type: string

  schemas:
    DeployResult:
      type: object
      required: [version_id, preview_url, 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.backlit.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.backlit.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
        - region
        - auth_mode
        - 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.
        category:
          type: string
          description: |
            Optional free-text category for grouping glows in the console
            (max 50 chars). Cosmetic and not subscription-gated. Empty when
            no category is set.
        region:
          type: string
          enum: [us-central1, australia-southeast1, europe-west1]
        auth_mode:
          type: string
          enum: [Public, Private, Accounts]
        hide_branding:
          type: boolean
          description: |
            When true (and the owner is on a paid plan), suppresses the
            SDK-injected Backlit floating action button on this glow.
        allowed_patterns:
          type: array
          items:
            $ref: "#/components/schemas/AllowedPattern"
        status:
          type: string
          enum: [Lit, Dark]
        dark_reason:
          type: string
          description: |
            Why the glow is Dark. Empty for an owner-set Dark or a glow that
            was never darkened; "downgrade" when auto-darkened because the
            account exceeded its plan's glow limit.
        owner_uid:
          type: string
        live_version:
          type: string
        draft_version:
          type: string
          description: |
            The currently staged Draft version id; empty when no draft is
            staged. The draft is reachable at
            `https://{glow}.backlit.run/_v/{draft_version}/` until it is
            promoted to Live or deleted.
        data_bytes_used:
          type: integer
          format: int64
          description: |
            Per-glow storage attribution counter (display only). The
            enforced storage cap is account-wide across all the owner's
            glows.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    PatchRequest:
      type: object
      description: |
        Only the mutable fields — name, title, category, auth_mode,
        status, and hide_branding — are accepted. region trips a 400
        region_immutable,
        the server-owned fields trip a 400 field_immutable, and any other
        field — including the retired default-file setting, which is no
        longer settable (/ always serves index.html) — is an unknown
        field rejected with 400 invalid_body.
      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.
        category:
          type: string
          description: |
            Free-text category for grouping glows in the console (max 50
            chars, no control characters). Cosmetic, distinct from name.
            Pass an empty string to clear it. Not subscription-gated.
        auth_mode:
          type: string
          enum: [Public, Private, Accounts]
          description: |
            Setting to Public or Accounts is subscription-gated (Accounts is
            open self-serve signin). Downgrades to Private are free.
        hide_branding:
          type: boolean
          description: |
            Suppress the SDK-injected Backlit floating action button.
            Enabling it is subscription-gated (any paid plan); clearing it is
            free.
        status:
          type: string
          enum: [Lit, Dark]

    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. Viewer users cannot write shared data.

    PatternsRequest:
      type: object
      required: [allowed_patterns]
      description: |
        The PUT /allowlist body. Exactly this one field — unknown fields
        (including the retired allowed_users array) are rejected with 400
        invalid_body. Users are managed via console-api's
        /v1/glows/{id}/users endpoints.
      properties:
        allowed_patterns:
          type: array
          items:
            $ref: "#/components/schemas/AllowedPattern"

    Patterns:
      type: object
      required: [allowed_patterns]
      properties:
        allowed_patterns:
          type: array
          items:
            $ref: "#/components/schemas/AllowedPattern"

    AllowlistView:
      type: object
      required: [allowed_patterns, owner_email]
      properties:
        allowed_patterns:
          type: array
          items:
            $ref: "#/components/schemas/AllowedPattern"
        owner_email:
          type: string
          description: |
            The owner's signin email (empty if the owner user doc cannot
            be resolved). The owner is always an admin and is not part of
            allowed_patterns.

    Version:
      type: object
      required: [id, created_at, size, is_live, is_draft]
      properties:
        id:
          type: string
        created_at:
          type: string
          format: date-time
        size:
          type: integer
          format: int64
        is_live:
          type: boolean
        is_draft:
          type: boolean
          description: True when this version is the currently staged Draft.
        uploader_key_hash:
          type: string
          description: |
            SHA-256 of the API key that deployed this version. Mutually
            exclusive with uploader_user_uid — exactly one of the two is
            set per version.
        uploader_user_uid:
          type: string
          description: |
            UID of the acting user when the version was deployed via a
            console-api-minted (user-acting / MCP) ticket.

    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
                - quota_exceeded
                - invalid_archive
                - missing_required_files
                - browser_storage_forbidden
                - invalid_notify
                - notify_too_large
                - invalid_request
                - invalid_body
                - invalid_title
                - invalid_category
                - invalid_auth_mode
                - invalid_status
                - invalid_override
                - lit_limit_reached
                - version_in_use
                - 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
