# Backlit JavaScript SDK — `window.backlit`

The Backlit SDK is a small browser library that AI-generated static apps
("glows") use at runtime: read the signed-in user's identity, read and
write glow-shared data, per-user data, and owner-scoped records,
subscribe to realtime change events, and post write-only telemetry. This
page is the public contract for everything `window.backlit` exposes.

This document is published as HTML at [/reference](https://sdk.backlit.run/reference)
and as plain Markdown at [/sdk.md](https://sdk.backlit.run/sdk.md) so AI agents can
fetch and parse the surface without HTML scraping. A machine-readable
TypeScript declaration of the whole surface is published at
[/sdk.d.ts](https://sdk.backlit.run/sdk.d.ts) (hosted alongside the bundle at
`sdk.backlit.run/sdk/v1/sdk.d.ts`), and a routing index for agents at
[/llms.txt](https://sdk.backlit.run/llms.txt).

<!-- QUICKREF:START -->
## Quickref

Drop in the SDK, then call `window.backlit.*`. Every method is async and
throws `BacklitError` on failure — except `auth.me()`, which resolves `null`
instead. The full prose, semantics, and examples are below; this section is
the ~80% an agent needs to write the calls.

```html
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
```

Machine-readable types: [`/sdk.d.ts`](https://sdk.backlit.run/sdk.d.ts).

### Pick a store

| Store | Who reads / writes | Use for |
| --- | --- | --- |
| `backlit.data` | every visitor reads one shared set; signed-in non-`viewer` users write (anonymous sessions are read-only) | leaderboards, shared docs, published content |
| `backlit.userdata` | each signed-in user, private isolated silo (requires sign-in) | per-user settings, drafts, history |
| `backlit.records` | any signed-in user creates + reads all; only the creator or an admin edits (requires sign-in) | comments, forum posts, submissions, galleries |
| `backlit.capture` | anyone writes; only an admin reads / lists / deletes | form submissions, telemetry the app must not read back |

### Methods

```ts
backlit.version: string

backlit.auth.me(): Promise<BacklitUser | null>             // never throws; null when signed out
backlit.auth.users({ cursor?, limit? }?): Promise<{ items: BacklitUser[], next: string }>  // admin only; THROWS
backlit.auth.login(returnPath?): void                      // send the visitor to sign in (no-op off-host)
backlit.auth.logout(returnPath?): void                     // sign the visitor out (no-op off-host)

// data, userdata, and records share this shape. records.* additionally carries
// `owner` on get / list / getBatch / getBatchJSON / putIfMatch / updateJSON, and adds reassign().
backlit.data.get(key): Promise<{ value: Blob, contentType: string, crc32c: string }>
backlit.data.put(key, body, contentType): Promise<{ crc32c: string }>
backlit.data.putIfMatch(key, body, contentType, expectedCrc32c): Promise<PutIfMatchResult>
backlit.data.delete(key): Promise<void>
backlit.data.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ key, crc32c, contentType, size }>, next: string }>
backlit.data.getBatch(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchEntry>, next: string }>       // list + each value; oversized entries arrive metadata-only (omitted:true)
backlit.data.getBatchJSON<T>(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>  // getBatch, each value JSON.parsed
backlit.data.getJSON<T>(key): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key, value): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T>(key, value, expectedCrc32c): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T>(key, mutate, options?): Promise<{ value: T, crc32c: string }>

backlit.records.reassign(key, newOwnerUid): Promise<void>  // admin only

backlit.capture.create(prefix, body, contentType): Promise<{ handle: string, crc32c: string }>
backlit.capture.update(handle, body, contentType): Promise<{ crc32c: string }>
backlit.capture.get(handle): Promise<{ value: Blob, contentType: string, crc32c: string }>  // admin only
backlit.capture.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ handle, crc32c, contentType, size }>, next: string }>  // admin only
backlit.capture.delete(handle): Promise<void>                                                 // admin only

backlit.on("storage.change", handler): () => void  // handler({ store, operation, key, crc32c, owner? })

backlit.push.supported(): { supported, reason?, requiresInstall, installed }   // SYNC; never throws
backlit.push.state(): Promise<{ permission, subscribed, endpointId }>          // requires sign-in
backlit.push.subscribe(): Promise<{ endpointId, permission: "granted" }>       // requires sign-in; prompts for permission
backlit.push.unsubscribe(): Promise<void>
backlit.push.test(): Promise<void>                                             // send yourself a test push
backlit.push.watch(cb): () => void   // cb({ type: "notification-click", url } | { type: "subscription-changed" } | { type: "permission-changed", permission })

backlit.hideUI(): void  // remove the Backlit button this SDK injects (paid plans only; no-op otherwise; if hidden, offer your own sign-out via auth.logout)
```

`body` is `string | Blob | ArrayBuffer | ArrayBufferView` (plain objects are
rejected — use the `*JSON` helpers, or `JSON.stringify` + `"application/json"`).
`contentType` is required. Keys/prefixes are `[A-Za-z0-9._~-]`, 1–256 bytes
(prefix may be empty); capture handles are the `"{prefix}-{guid}"` value
`create` returned.

### Shared writes — the one rule

For any `data` / `records` key more than one user can write, use
`updateJSON` (load → mutate → compare-and-swap → rebase-and-retry), never a
bare `putJSON` (an unconditional last-writer-wins overwrite that silently
drops a concurrent edit). `userdata` has a single writer, so `putJSON` is fine
there. Shared `data` writes need a signed-in non-`viewer` user — anonymous
sessions can only read.

```js
await backlit.data.updateJSON("todos", (todos) => {
  todos ??= { items: [] };
  todos.items.push({ text, done: false });
  return todos; // mutate MUST be replayable — it reruns against a fresher value on conflict
});
```

### Error codes

Discriminate on `err.code` (`err instanceof backlit.BacklitError`). `err.status`
is the HTTP status, or `0` for client-side/network failures.

| `code` | Meaning |
| --- | --- |
| `not_found` | `get` / `delete` on a key that was never written |
| `unauthenticated` | a shared `data` write, `userdata.*` / `records.*`, an admin-only `capture` op, or `auth.users()` with no user session |
| `forbidden` | a `viewer` wrote shared `data`; a non-creator/non-admin wrote a `records` key; a non-admin called an admin-only op |
| `invalid_key` / `invalid_prefix` / `invalid_handle` | argument failed the charset / length / handle-form rule |
| `invalid_cursor` | a `list` (or `auth.users()`) `cursor` was not a valid pagination token |
| `unknown_handle` | well-formed `capture` handle with no stored record |
| `invalid_body` | `getJSON` read non-JSON bytes, or the server could not decode the body |
| `invalid_argument` | bad `body` type, missing `contentType`, a non-serializable `putJSON` value, or a malformed `reassign` owner id |
| `unsupported_content_type` | content-type off the allowlist (or an always-denied executable type) |
| `payload_too_large` | body exceeds the per-object cap (10 MiB) |
| `quota_exceeded` | a write exceeded the account's storage allowance (413), or any call landed after the monthly bandwidth allowance ran out (429) |
| `conflict` | `updateJSON` exhausted its retries (`maxAttempts`, default 6) under contention; or a `records` delete/reassign lost a concurrent-write race (412) — retry |
| `unsupported_host` | the SDK was loaded off a deployed glow host |
| `unsupported` / `requires_install` / `permission_denied` | `push.subscribe()` — the browser can't do push, needs the app installed first (iOS), or the user declined the permission prompt |
| `cross_glow_token` · `glow_not_found` · `glow_dark` · `region_mismatch` · `invalid_host` · `method_not_allowed` · `network` · `internal` | transport / glow-state failures |

A `putIfMatch` mismatch is **not** an error — it resolves
`{ matched: false, witness }` carrying the current value.

### Deploy bundle contract

Every deploy bundle must contain `index.html`, `APP.md` (what the app
is, who it's for), and `DATA.md` (how it uses the data namespaces) at
the archive root, or the upload is rejected with
`missing_required_files`; `.html`/`.htm`/`.js`/`.mjs` files that
reference `localStorage` or `indexedDB` are rejected with
`browser_storage_forbidden` — use `window.backlit.*` instead. APP.md
and DATA.md are served publicly: no secrets, credentials, or PII.
An optional root `notify.json` turns on push notifications for the
glow (see `backlit.push`); without one, push is simply off.
<!-- QUICKREF:END -->

## Loading

The SDK is a single UMD bundle hosted on the Backlit CDN. Drop one
`<script>` tag in your HTML; no build step required.

```html
<!doctype html>
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
<script>
  (async () => {
    const user = await window.backlit.auth.me();
    // ...
  })();
</script>
```

(The async IIFE matters: classic `<script>` blocks can't use top-level
`await` — that's a SyntaxError outside module scripts.)

Two URL channels are supported:

| URL                                          | Stability  | Use for                                       |
| -------------------------------------------- | ---------- | --------------------------------------------- |
| `https://sdk.backlit.run/sdk/v1/sdk.js`      | Stable     | Production glows. Pinned to the v1 surface.   |
| `https://sdk.backlit.run/sdk/latest/sdk.js`  | Rolling    | Quick experiments. Aliases the newest v1.     |

The bundle is an IIFE that attaches a single global, `window.backlit`. It
takes no constructor — the namespaces below are ready to use the moment
the script tag finishes parsing.

`window.backlit.version` is a string identifying the bundled SDK version
(e.g. `"1.0.0"`).

The SDK runs only on a deployed glow host (a `*.backlit.run` subdomain,
or a recognised dev host — `*.backlitdev.run`, `*.localtest.me`,
`localhost`, `*.localhost`).
Loaded anywhere else — straight from `file://`, an AI design tool's
preview iframe, or any other origin — every `data.*` / `userdata.*` /
`records.*` / `capture.*` call (and `auth.users()`) rejects with
`BacklitError(code: "unsupported_host")`; only `auth.me()` resolves
`null` rather than throws. There is no
local/offline fallback — deploy the glow to exercise the data surface.

## Storing app state

Most glows keep some state — a list, a document, user preferences. Backlit
gives you three key/value stores; pick by **who owns the data**:

| Store              | Who can read & write                                          | Use for                                          |
| ------------------ | ------------------------------------------------------------ | ------------------------------------------------ |
| `backlit.data`     | every visitor reads (one shared set); signed-in non-`viewer` users write | leaderboards, shared docs, published content     |
| `backlit.userdata` | each signed-in user, isolated (requires sign-in)             | per-user settings, drafts, history               |
| `backlit.records`  | any signed-in user creates + reads all; only the creator/admin edits (requires sign-in) | forum posts, comments, user submissions, galleries |

All three stores hold **bytes**, but the `*JSON` helpers serialize and parse
objects for you — so most apps never touch the raw byte API:

```js
// Save an object. Returns its { crc32c } — an integrity/version hash.
await backlit.data.putJSON("todos", { items: [], updatedAt: 0 });

// Load it back. `value` is the parsed object; `crc32c` is its current hash.
const { value: todos, crc32c } = await backlit.data.getJSON("todos");
```

The first read of a key — before anything is written — throws `not_found`.
Catch it and start from a default:

```js
let todos;
try {
  todos = (await backlit.userdata.getJSON("todos")).value;
} catch (e) {
  if (e.code !== "not_found") throw e;
  todos = { items: [] }; // first run
}
```

### Saving shared state safely

`putJSON` is an **unconditional overwrite — last writer wins.** That is fine
for `userdata` (you are the only writer) and for any `data` key only one
user ever writes. But when two users can edit the *same* `data` key, a
plain `putJSON` silently drops one of their changes.

For shared, multi-writer state use **`updateJSON`** instead. It loads the
current value, runs your merge function, and commits with a compare-and-swap;
if another session wrote in between, it rebases onto the latest value and
retries — so no edit is ever lost, and you never write a loop yourself:

```js
// `mutate(current)` receives the current value (undefined on the first write)
// and returns the new value. It may run more than once — it replays against a
// fresher value on conflict — so keep it a pure function of its input (no side
// effects).
await backlit.data.updateJSON("todos", (todos) => {
  todos ??= { items: [] };
  todos.items.push({ text, done: false });
  return todos;
});
```

`updateJSON` resolves the committed `{ value, crc32c }`. It retries up to
`maxAttempts` (default 6) and throws `BacklitError(code: "conflict")` only if
it can't win the race in that many attempts — a sign of extreme contention.
It's built on `putJSONIfMatch`; reach for
[that](#datagetjson--dataputjson--dataputjsonifmatch) directly only when you
want create-only semantics or to inspect the conflicting value yourself.

Rule of thumb: **`putJSON`** when last-writer-wins is genuinely fine (single
writer, or idempotent content); **`updateJSON`** for anything several people
edit at once. Full method docs are under
[`backlit.data`](#backlitdata--glow-shared-keyvalue).

## Migrations

When porting an existing standalone HTML app to a glow, replace browser-
local persistence (`localStorage`, `sessionStorage`, raw `indexedDB`)
with the equivalent Backlit surface. The mapping is straightforward
once you decide who the data belongs to:

| Browser API                                  | Backlit equivalent              | When to pick it                                                                                  |
| -------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
| `localStorage` / `sessionStorage` (per-user) | `backlit.userdata.*`            | Settings, drafts, history — anything scoped to *this signed-in user*. Requires sign-in (a Private or Accounts glow). |
| `localStorage` (app-global state)            | `backlit.data.*`                | Content that every visitor should see: a wiki page, a leaderboard, a published gallery. Writes need a signed-in non-`viewer` user. |
| `IndexedDB` keyed records                    | `backlit.data.*` / `userdata.*` / `records.*` | Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string; one entry per author that others can read but not edit → `records.*`.  |
| Hidden form posts / `fetch("/submit")`       | `backlit.capture.create`    | Contact forms, telemetry events, support requests — write-only captures the page can't read back.|
| File uploads to a separate server            | `backlit.data.put` (binary)     | Accepts `Blob` / `ArrayBuffer` / typed arrays with a matching raster content type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`). |

Three differences will trip you up if you don't plan for them:

1. **Everything is async.** `localStorage.getItem` is synchronous;
   `backlit.data.get` returns a `Promise`. Wrap reads in `await` and
   move any work that depended on synchronous storage into an `async`
   initialiser.
2. **Objects use the `*JSON` helpers.** `localStorage` stores strings;
   for object state use [`putJSON` / `getJSON`](#storing-app-state),
   which serialize and parse for you. (The underlying `put` / `get` take
   raw `string | Blob | ArrayBuffer | ArrayBufferView` with an explicit
   `contentType` — that's the path for binary like images.)
3. **Keys are restricted.** `[A-Za-z0-9._~-]` only, no slashes. If you
   used `localStorage.setItem("user/42/profile", …)` translate to
   `user.42.profile` (or any other slash-free scheme).

A typical port — the `localStorage` version on the left, the Backlit
version on the right:

```js
// before
const prefs = JSON.parse(localStorage.getItem("prefs") ?? "{}");
prefs.theme = "dark";
localStorage.setItem("prefs", JSON.stringify(prefs));

// after
let prefs = { theme: "light" };
try {
  prefs = (await backlit.userdata.getJSON("prefs")).value;
} catch (e) {
  if (e.code !== "not_found") throw e;
}
prefs.theme = "dark";
await backlit.userdata.putJSON("prefs", prefs);
```

> ⚠️ This load → mutate → save is safe here **only because `userdata`
> has a single writer — you.** The identical shape on shared
> `backlit.data.*` is a lost-update bug. For any shared key more than one
> session can write, use [`updateJSON`](#saving-shared-state-safely)
> instead of `putJSON`.

When you need a key gone, call `backlit.data.delete(key)` or
`backlit.userdata.delete(key)` — see the [delete sections](#datadeletekey-string-promisevoid)
below. Reach for `backlit.capture.create` whenever the page must
*capture* something — a contact form, an event — but ordinary app code
should never be able to *read* it back even if the page's JavaScript is
compromised. (An **admin** user can later read, enumerate, and delete
those captures with `backlit.capture.{get,list,delete}`; non-admin
sessions cannot.)

### Offer advanced features that fit the app

A faithful port reproduces what the original app already did — but
moving onto Backlit unlocks capabilities a plain static page never
had, and the person you're building for usually doesn't know to ask
for them. **If you're an AI agent migrating an app, read what it does
and proactively offer the matching upgrades below before you deploy.**
Describe the benefit in plain terms, suggest only what genuinely fits
the app, and wire one in only if the user says yes.

| If the app…                                                                            | Offer                                                                                                    | Backlit surface                                                                                |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| shows shared state several people view at once — leaderboard, kanban board, presence, a collaborative doc | **Realtime sync + safe writes** — live updates across tabs and users (no polling), and `updateJSON` so concurrent edits merge instead of clobbering. Realtime makes a conflict *visible*; `updateJSON` makes it *impossible to lose*. | `backlit.on("storage.change", …)` + `data.updateJSON`                                          |
| lets people post content others see but shouldn't edit on each other's behalf — comments, chat, forum threads, reviews, profiles, a shared gallery, contest entries | **Owner-scoped records** — anyone signed in can post and read everyone's, but only each item's author (or an admin) can edit or delete it, so users can't tamper with one another's content. Emits realtime events and has `updateJSON`, just like `data`. | `backlit.records.*` (requires sign-in)                                                        |
| keeps per-user settings, drafts, or history in one global blob                         | **Per-user data** — each signed-in user gets an isolated silo                                             | `backlit.userdata.*` (requires sign-in)                                                        |
| collects form submissions, contact requests, or telemetry                              | **Private capture + an admin review page** — visitors write submissions the page can't read back; an admin page lists and reads them | `backlit.capture.create` to capture; `capture.list`/`get`/`delete` behind an `admin`   |
| shows or hides controls by role — an editor vs. a read-only viewer                     | **Permission-aware UI** — branch on the signed-in user's role                                             | `backlit.auth.me()` (the `permission` field)                                                   |
| needs sign-in, or should be limited to specific people or domains                      | **Private or Accounts auth mode** — Private restricts sign-in to an allowlist; Accounts lets any visitor create an account themselves; leave it Public for an open app | the glow's `auth_mode` setting                                                                  |
| lets users upload or attach images                                                     | **Binary storage** — store images directly, no separate file server                                      | `backlit.data.put(key, blob, "image/png")`                                                     |

Don't bolt realtime onto a single-user notepad or force sign-in on a
public toy — suggest only what the app's purpose calls for. When the
user opts in, the relevant section of this reference has the full
signatures.

### Packaging the deploy bundle

A glow is deployed as a single **bundle** — a gzipped tar
(`.tar.gz`) of its built static assets, posted as the `archive`
field of a `multipart/form-data` request (see the glow deploy API's
OpenAPI at `glow.backlit.run/openapi.yaml` for the direct and
ticket-based upload endpoints). A few rules for what to put inside.

**Required files at the bundle root.** Every deploy bundle must
contain three files at the top level of the archive (exact names,
exact case):

- **`index.html`** — the serving root. Glows are served from
  `https://{name}.backlit.run/`, and `/` always returns `index.html`.
  A request path **without a file extension** serves the matching
  `.html` file when it exists — e.g. `/about` serves `about.html`.
  `/about` is served as-is (it is not redirected to `/about/`), so
  relative links on the page resolve against the site root; reference
  assets by root-relative or absolute paths. A path that already has an
  extension is served unchanged, and there is no directory-index or
  trailing-slash behavior.
- **`APP.md`** — 1–2 paragraphs of documentation: what the app is
  and who it's for.
- **`DATA.md`** — how the app uses the `window.backlit` data
  namespaces: what goes where (each namespace's keys/prefixes and
  what they mean).

A bundle missing any of them is rejected with
`missing_required_files` (HTTP 400); the message lists every missing
name — add the file(s) and re-upload. A short skeleton is enough:

```md
<!-- APP.md -->
# Trail Crew Scheduler

A weekend roster for the volunteer trail-maintenance crew: members
claim shifts, see who else is coming, and leave notes for the lead.

Built for the crew's ~20 members; the crew lead administers it.
```

```md
<!-- DATA.md -->
# Data layout

| Namespace | Keys / prefixes | Meaning |
| --- | --- | --- |
| `data` | `roster` | the shared shift roster (JSON, written via `updateJSON`) |
| `userdata` | `prefs` | each member's notification preferences |
| `capture` | `signup-…` | new-member signup submissions (admin reads them back) |
```

**APP.md and DATA.md are public.** They are served like any other
asset (e.g. `https://{name}.backlit.run/APP.md`) — they are public
documentation files; never put secrets, credentials, or PII in them.

**Optional `notify.json`.** A bundle may additionally carry a
`notify.json` at the archive root to turn on
[push notifications](#backlitpush--notifications) for the glow — its
rules declare which data writes send a notification to whom (see the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json)
for every field, and the
[push guide](https://sdk.backlit.run/guides/push-end-to-end.md) for the
end-to-end story). A bundle without one deploys with push off; an
invalid one is rejected at upload.

**No browser storage.** At upload, the content of every `.html`,
`.htm`, `.js`, and `.mjs` file in the bundle is scanned for
browser-storage usage; any hit rejects the deploy with
`browser_storage_forbidden` (HTTP 400), and the message lists the
offending files and the first matched token in each. The platform's
published scan list is these case-sensitive substrings, for each of
`localStorage` and `indexedDB`:

`localStorage.` · `localStorage[` · `.localStorage` ·
`=localStorage` · `= localStorage` · `indexedDB.` · `indexedDB[` ·
`.indexedDB` · `=indexedDB` · `= indexedDB`

Comments and string literals count — the scan is a best-effort
heuristic that steers apps toward the `window.backlit` data APIs,
not a security boundary. On a reject, replace the storage calls with
`backlit.data.*` / `backlit.userdata.*` (see [Migrations](#migrations)
above) and delete the replaced code — don't comment it out.

**Path rules.** Bundle paths must be forward-slash-relative —
absolute paths, parent traversal (`..`), NUL bytes, and backslashes
are rejected by the unpacker.

**Split inline data out of the HTML.** AI generators often emit a
single self-contained HTML file with JSON state and base64 images
embedded. Once that page runs on a real glow, the habit costs you
cache hits: the HTML is fetched on every navigation, while sibling
files at `/_v/{ver}/...` carry `Cache-Control: public, max-age=31536000,
immutable` (`private` on Private glows) and become free on repeat
visits. Two refactors are worth doing before packing:

- **JSON blocks over ~1 KiB.** Move them to a sibling file
  (`data.json` is conventional) and fetch at runtime:
  `const data = await fetch("./data.json").then(r => r.json());`.
  The HTML stays small; the JSON is cached independently.
- **`data:` URIs over ~1 KiB.** Decode them into real files
  (`images/hero.png`, `fonts/inter.woff2`, …) and reference by
  relative URL. Same caching win, plus `<img loading="lazy">` and
  font subsetting become available.

Anything under ~1 KiB is fine inline — the round-trip to fetch a
separate file dwarfs the parse cost.

**Building the bundle.** From the directory containing your built
site:

```sh
tar -czf site.tar.gz -C ./dist .
```

The trailing `.` matters: it produces entries like `index.html`
rather than `dist/index.html`, which is what the server expects at
the bundle root.

### Deploy with an agent

You don't have to build and upload the bundle by hand. Backlit runs a
hosted **MCP server**, so an AI assistant — Claude, ChatGPT, or any
client that speaks the Model Context Protocol — can create the glow,
deploy your files as a draft, hand you a preview URL, and promote it
live, all from a chat.

Connect it once, then just ask ("deploy this to Backlit and give my team
access"):

- **MCP server URL** — `https://mcp.backlit.run/mcp`
- **Claude** — [set up a custom connector](https://support.anthropic.com/en/articles/11175166-about-custom-connectors-remote-mcp)
- **ChatGPT** — [set up an MCP connector](https://platform.openai.com/docs/mcp)

The deploy tools move the bundle **out of band** rather than over the MCP
connection, and there are **two transports** — pick by what your agent's
sandbox can reach:

- **GitHub push** (for sandboxed agents that can reach `github.com` but
  not `glow.backlit.run`): the agent leases a repo, `git push`es your
  files to it, and signals the push — Backlit pulls and deploys the
  draft. The push goes directly to **`github.com`**.
- **URL upload** (otherwise): the agent uploads the bundle directly to
  **`https://glow.backlit.run`**.

So if your agent runs in a sandbox with a domain allow-list, **add the
host for whichever transport it uses** (`github.com` or
`glow.backlit.run`) to its allowed/permitted domains, or the deploy is
blocked.

The agent acts as you (no API key to copy around), and every deploy lands
as a **draft** you preview before promoting — so nothing goes live
without your go-ahead.

## Public, Private, and Accounts glows

What works for a given visitor depends on the glow's `auth_mode` —
**Public** (serves anonymously; sign-in is optional, and when the app
offers it only allowlisted users — e.g. an admin — are let in),
**Private** (only allowlisted users may sign in, and they must), or
**Accounts** (serves anonymously like Public, but any visitor can create
an account and sign in themselves) — and whether the visitor has signed
in. A visitor with no signed-in user — a signed-out Public or Accounts
visitor — is an **anonymous session**.

| Surface                       | Public glow / Accounts glow, signed out (anonymous) | Private or Accounts glow, signed in | Private glow, signed out         |
| ----------------------------- | ---------------------------- | --------------------------- | -------------------------------- |
| `backlit.auth.me()`           | returns `null`               | returns `{ uid, name, email, mobile, permission }` (`permission` ∈ `admin`/`user`/`viewer`) | n/a — the visitor never reaches the glow; Backlit redirects them to sign in |
| `backlit.auth.users()`        | throws `unauthenticated`     | works only for `admin` (else `forbidden`) | n/a                              |
| `backlit.auth.login()`        | navigates to sign-in — Accounts admits any visitor (self-serve); a Public glow admits only allowlisted users | n/a — already signed in | n/a               |
| `backlit.data.* (reads)`      | works                        | works                       | n/a                              |
| `backlit.data.put/delete/putIfMatch` | throw `unauthenticated` — anonymous sessions cannot write shared data | work (blocked with `forbidden` for `viewer`) | n/a |
| `backlit.userdata.*`          | throws `unauthenticated`     | works (per-user silo)       | n/a                              |
| `backlit.records.* (reads)`   | throws `unauthenticated`     | works (read every record)   | n/a                              |
| `backlit.records.put/delete`  | throws `unauthenticated`     | works on your own records (others' → `forbidden`) | n/a            |
| `backlit.records.reassign`    | throws `unauthenticated`     | works only for `admin` (else `forbidden`) | n/a                |
| `backlit.capture.create/update` | works                  | works                       | n/a                              |
| `backlit.capture.get/list/delete` | throw `unauthenticated` (no admin) | work only for `admin` (else `forbidden`) | n/a               |

Signed-out Private visitors never execute JavaScript on the glow —
Backlit intercepts the request and redirects them to sign in. So inside
a glow page, "signed out on a **Private** glow" is not a state your code
has to handle. An **Accounts** glow is different: it serves anonymously,
so your code DOES run for signed-out visitors there — branch on
`auth.me()` returning `null` and offer `auth.login()`.

One Accounts-mode trap to design around: a self-serve account is created
with permission **`viewer`**, and viewers cannot write shared `data` —
so signing in does not, by itself, grant shared-data write access. The
glow's owner promotes accounts to `user` / `admin` in the Backlit
console. Apps on Accounts glows should treat shared-data writes as a
privileged action (hide or disable the control unless
`me.permission === "user" || me.permission === "admin"`), or use
`records.*` — where any signed-in user, including a `viewer`, can create
their own entries.

---

## `backlit.auth`

### `auth.me(): Promise<BacklitUser | null>`

Returns the signed-in user, or `null` when there is no user session —
an anonymous or signed-out visitor and the SDK loaded off a deployed glow
host both resolve to `null`. **Never throws**:
network failures and missing sessions both resolve to `null` (off-host it
resolves to `null` too, rather than throwing `unsupported_host`), since
they all render the same anonymous fallback UI in practice.

`BacklitUser` is a plain object with five string fields:

| Field        | Notes                                                                          |
| ------------ | ------------------------------------------------------------------------------ |
| `uid`        | The user's stable id — survives an email change; keys their `userdata` silo.   |
| `name`       | Display name, or `""` if unset.                                                |
| `email`      | The user's verified email, or `""` if they signed in without one.              |
| `mobile`     | Mobile number, or `""` if unset.                                               |
| `permission` | Permission level — `"admin"`, `"user"`, `"viewer"`, or `""` (legacy/unknown).  |

Every field is a string; absent values are `""` (never `null` or
`undefined`). The whole result is `null` only when there is no signed-in
user. `name`, `email`, and `mobile` are user-controlled display strings —
**escape them before inserting into HTML.**

`"viewer"` users are blocked from writing **shared** data: `data.put`
and `data.delete` reject with `BacklitError(code: "forbidden")` (HTTP 403).
Their `userdata.*` and `capture.*` writes are unaffected. The server is
the source of truth — `permission` exists so apps can branch ahead of a
write, e.g. hiding a Save button.

You stay signed in across visits, and while a page is open it is kept
signed in and your access level stays up to date automatically — no app
code required.

```js
const user = await backlit.auth.me();
if (user === null) {
  // Show the anonymous experience.
} else {
  greeting.textContent = `Hello, ${user.name || user.email}`;
  saveButton.disabled = user.permission === "viewer"; // the server would 403 a data.put anyway
}
```

### `auth.users(opts?: { cursor?: string, limit?: number }): Promise<{ items: BacklitUser[], next: string }>`

Lists this glow's users. Each entry is the same `BacklitUser` object
`auth.me()` returns (`{ uid, name, email, mobile, permission }`).
**Admin only** and **read-only** — it's an operator view, not an editing
surface.

The result is **paginated**: it returns one bounded page of
`BacklitUser` entries plus an opaque `next` cursor. To walk the whole
roster, re-call `users` with `{ cursor: next }` until `next` is `""`
(the empty string means the roster is exhausted).

- **Parameters**
  - `opts.cursor` — optional opaque string. Pass a prior page's `next` to
    fetch the following page; omit (or `""`) for the first page. A
    malformed cursor is rejected with `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size. Clamped to `[1, 1000]`; omitting it
    uses the default of 500. An over-range value is clamped, never
    rejected.

Unlike `auth.me()`, this method **throws** `BacklitError` rather than
resolving to a fallback:

| `code`             | When                                                            |
| ------------------ | -------------------------------------------------------------- |
| `forbidden`        | The signed-in user's `permission` is not `"admin"`.           |
| `unauthenticated`  | No signed-in user (an anonymous or signed-out visitor).       |
| `invalid_cursor`   | `opts.cursor` is not a valid pagination token.                 |
| `unsupported_host` | The SDK was loaded outside a deployed glow host.               |
| `network`          | The request could not be completed.                            |

Each `name`, `email`, and `mobile` is a user-controlled display string —
**escape it before inserting into HTML.**

```js
// Render an admin-only roster of the glow's users, one page at a time.
try {
  let cursor = "";
  do {
    const page = await backlit.auth.users({ cursor });
    for (const u of page.items) {
      const row = document.createElement("li");
      row.textContent = `${u.name || u.email} — ${u.permission}`;
      roster.appendChild(row);
    }
    cursor = page.next;
  } while (cursor);
} catch (err) {
  if (err.code === "forbidden") {
    // Not an admin — hide the roster.
  } else {
    throw err;
  }
}
```

### `auth.login(returnPath?: string): void` / `auth.logout(returnPath?: string): void`

Send the visitor to sign in, or sign them out. Both are **top-level
navigations** — the page unloads — so they return `void` (not a Promise) and
nothing runs after them. `returnPath` is where the visitor lands afterwards: an
**absolute same-origin path** (e.g. `/dashboard`); it defaults to the current
page, and anything that isn't a plain same-origin path is replaced with `/`.
Off a deployed glow host both are a **silent no-op**.

Use `login()` to add a "Sign in" button on a glow whose auth mode is
**Accounts** (open self-serve sign-in — signed-out visitors can create an
account) — or on a **Public** glow to sign in an allowlisted user for an
admin-only view (only allowlisted users are let in; the general public is
not, and the built-in Backlit button never shows a Login option on a Public
glow, so this is app-driven). Use `logout()` for a "Sign out" button. After
`login()` completes,
`auth.me()` returns the signed-in user and `userdata.*` works. (A fresh
self-serve account starts as a `viewer`, so shared `data` writes stay blocked
until the owner promotes it — see the auth-mode matrix above.)

```js
// A sign-in / sign-out button that reflects the current session.
const user = await backlit.auth.me();
if (user) {
  signOutBtn.onclick = () => backlit.auth.logout();      // returns to this page
} else {
  signInBtn.onclick = () => backlit.auth.login("/welcome");
}
```

---

## The Backlit button (and `backlit.hideUI()`)

The SDK adds a small **Backlit button** to the bottom-right corner of your glow.
A visitor can open it to sign in or out (when the glow offers accounts) and to
discover Backlit. You don't have to add or configure anything — it appears
automatically.

`backlit.hideUI()` removes it. Removing the button is a **paid feature**: on a
free glow the call is a silent no-op (the button stays); on a paid glow it is
removed. It never throws and is safe to call as early as you like.

```js
backlit.hideUI(); // paid glows: removes the button. free glows: no-op.
```

There is also a per-glow setting in the Backlit console that removes the button
without any code (also paid). Either one is enough.

The button is also where a signed-in visitor signs out. If you remove it — by
either route — you're encouraged to give visitors your own "Sign out" control
that calls `backlit.auth.logout()`.

---

## `backlit.data` — glow-shared key/value

Readable by any visitor with a valid glow session — every visitor of a
Public or Accounts glow, and every signed-in visitor of a Private glow.
**Writes require a signed-in user**: `put` / `delete` / `putIfMatch` (and
the `*JSON` write wrappers) throw `unauthenticated` for an anonymous
session (a signed-out Public or Accounts visitor) and `forbidden` for a
signed-in `viewer`. There is one shared keyspace
per glow, and more than one user can write the same key at the same
time.

**For any shared key more than one session can write,
[`updateJSON`](#saving-shared-state-safely) is the default** — it runs a
load-modify-commit loop that rebases and retries on a concurrent write, so
nothing is ever silently lost. (It's built on the
[`putIfMatch`](#dataputifmatchkey-string-body-bodyinit-contenttype-string-expectedcrc32c-string-promiseputifmatchresult)
compare-and-swap *primitive* below — reach for that directly only for
create-only locks or a custom merge.) Plain [`data.put`](#dataputkey-string-body-bodyinit-contenttype-string-promise-crc32c-string)
is an unconditional, last-writer-wins overwrite; reserve it for a single
writer or genuinely idempotent content. This is the one habit that does
**not** survive the jump from `userdata` (one writer per silo) to shared
`data`.

**Working with objects?** The
[`*JSON` helpers](#datagetjson--dataputjson--dataputjsonifmatch)
(`getJSON` / `putJSON` / `putJSONIfMatch` / `updateJSON`) wrap the byte
methods below with serialize/parse — most apps use those. The raw `get` /
`put` below are for strings and binary (e.g. images).

**Sizes and integrity.** Your storage allowance and the 10 MiB
per-object cap both measure the data you pass, so the limit you hit
is the size of your own bytes. Every `put`/`create`/`update` response
carries `{ crc32c }` and every `get` / `list` entry carries the body's
CRC32C — the same value the [storage.change event](#backliton--realtime-subscriptions)
broadcasts on writes, so app code can dedup local state against
server state without an extra fetch. Each `list` entry also carries the
stored `contentType` and the body's `size` in bytes, so a listing alone
tells you each value's type and size without a per-key `get`. A `get` or `list` against a
server that omits the CRC (a legacy object, or a proxy that stripped
the non-standard `X-Backlit-CRC32C` header) tolerates the gap and
returns `crc32c: ""` rather than failing the read; only `put` /
`create` / `update`, where the CRC is the whole success payload, treat
a missing value as a protocol error.

**Debounce frequent writes.** Each `put` / `putIfMatch` / `updateJSON`
is a network round-trip that counts toward your glow's monthly request
and bandwidth allowance, and every successful write broadcasts a
[`storage.change` event](#backliton--realtime-subscriptions) to every
connected session. Don't write on every keystroke, drag, or animation
frame — coalesce rapid edits (debounce ~250–500 ms, or save on blur /
pause) and send one write when the value settles. This keeps you well
under your allowance and stops connected peers from being flooded with
intermediate updates. The same applies to `userdata.*` and `records.*`.

**Writing identical content is a no-op.** A write that stores exactly
what the key already holds — the same bytes with the same `contentType`
— succeeds normally and returns the same `{ crc32c }`, but nothing is
observed to change: it emits **no
[`storage.change` event](#backliton--realtime-subscriptions)**, so open
peer sessions don't hear it and no
[notification rule](#backlitpush--notifications) fires. This applies to
overwrites in every store (`userdata`, `records`, and `capture.update`
included, and the `*JSON` wrappers); a first write to a key is never a
no-op, and changing only the `contentType` is a real write. If a repeat
of the same value must still be observed — for example a key written
purely to trigger a notification — include something that varies in the
payload, like a timestamp or counter.

### `data.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>`

Read the value stored at `key`. Returns the raw bytes as a `Blob`, the
`Content-Type` the value was written with, and the Castagnoli CRC32C
of the body (base64-encoded big-endian uint32 — same format as the
[storage.change event](#backliton--realtime-subscriptions)).

- **Parameters**
  - `key` — string, 1–256 bytes, alphanumerics + `-` `.` `_` `~` only. See [Validation](#validation).
- **Returns** — a `Promise` for `{ value, contentType, crc32c }`.
- **Throws** — `BacklitError` with `code === "not_found"` if the key has
  never been written. See [Errors](#errors) for the full list.

```js
try {
  const { value, contentType, crc32c } = await backlit.data.get("greeting");
  console.log(await value.text(), contentType, crc32c);
  // → "hello world" "text/plain" "+IxOLg=="
} catch (e) {
  if (e.code === "not_found") {
    // First-run path.
  } else {
    throw e;
  }
}
```

### `data.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`

Write `body` to `key` under the glow-shared keyspace.

> ⚠️ **`put` is an unconditional overwrite — last writer wins.**
> Read-modify-write on a shared key loses any write that landed in
> between, worst with the one-big-JSON-blob state AI generators emit,
> where every edit rewrites the whole blob so even edits to unrelated
> records clobber each other. Reach for bare `put` only when
> last-writer-wins is genuinely fine — a single writer, or idempotent
> content. For any shared mutable state, [`updateJSON`](#saving-shared-state-safely)
> is the default (or the [`putIfMatch`](#dataputifmatchkey-string-body-bodyinit-contenttype-string-expectedcrc32c-string-promiseputifmatchresult)
> primitive for non-JSON bodies).

- **Parameters**
  - `key` — same rules as `data.get`.
  - `body` — one of `string`, `Blob`, `ArrayBuffer`, or any `ArrayBufferView`
    (e.g. `Uint8Array`). Plain objects are rejected — for JSON, call
    `JSON.stringify(obj)` yourself and pass `"application/json"`.
  - `contentType` — required, non-empty string. Forwarded verbatim as
    the request `Content-Type` header. The server enforces a strict
    allowlist; the SDK pre-flights the always-denied set
    (`text/html`, `application/xhtml+xml`, `image/svg+xml`, JavaScript
    MIME variants) so you get a clearer error than a 415.
- **Returns** — a `Promise<{ crc32c }>`. `crc32c` is the Castagnoli
  checksum of the body, matching the value the server recorded and any
  subsequent `data.get` / `data.list` exposes for the same key.
- **Throws** — `BacklitError`. Common codes: `invalid_key`,
  `invalid_argument` (bad body type), `unauthenticated` (no signed-in
  user — shared-data writes require one), `forbidden` (a `viewer` is
  blocked from shared-data writes), `unsupported_content_type`,
  `payload_too_large`, `quota_exceeded`. See [Errors](#errors).

```js
// String.
const { crc32c } = await backlit.data.put("greeting", "hello world", "text/plain");

// JSON — the byte API takes a serialized string; prefer putJSON for objects.
await backlit.data.put(
  "config",
  JSON.stringify({ theme: "dark" }),
  "application/json",
);

// Binary — pass the bytes with the matching image content-type.
const png = await fetch("/icon.png").then(r => r.arrayBuffer());
await backlit.data.put("icon", png, "image/png");
```

If you pass a `Blob` whose `.type` disagrees with the explicit
`contentType` argument, the explicit argument wins and the SDK emits one
`console.warn` per page.

### `data.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>`

Atomic compare-and-swap, and **the default way to write any shared key
more than one session can touch** — bare [`data.put`](#dataputkey-string-body-bodyinit-contenttype-string-promise-crc32c-string)
is the exception, for a single writer or idempotent content. Writes
`body` to `key` **only if** the current stored value's `crc32c` equals
`expectedCrc32c`, otherwise resolves `{ matched: false, witness }`
carrying the current value — so two callers racing on the same key can
detect the conflict and merge instead of silently clobbering each other
(see [`data.put`'s last-writer-wins note](#backlitdata--glow-shared-keyvalue)).

The result is a discriminated union:

```ts
type StoredValue = { value: Blob; contentType: string; crc32c: string };
type PutIfMatchResult =
  | { matched: true;  crc32c: string }        // your write landed; crc32c of the new body
  | { matched: false; witness: StoredValue }; // someone else's value is there now
```

- **Parameters**
  - `key` — same rules as `data.get`.
  - `body` / `contentType` — same rules as `data.put`.
  - `expectedCrc32c` — the `crc32c` you expect the key to currently hold
    (the value a prior `get` / `put` / `list` returned). The **empty
    string `""` means create-only**: write only if the key is currently
    absent.
- **Returns** — a `Promise<PutIfMatchResult>`.
  - `{ matched: true, crc32c }` — the write landed; `crc32c` is the
    checksum of the body you just stored.
  - `{ matched: false, witness }` — your `expectedCrc32c` did **not**
    match the current value (you lost the race). `witness` is the current
    `{ value, contentType, crc32c }`, exactly as `data.get` would return
    it — use it to re-apply your change and retry.
- **Throws** — `BacklitError`. A **mismatch never throws** — it resolves
  `{ matched: false }`. The thrown cases are real failures: `not_found`
  (a **non-empty** `expectedCrc32c` against a key that does not exist —
  there is nothing to compare against), plus the same write-path codes as
  `data.put` (`invalid_key`, `invalid_argument`, `unauthenticated` for an
  anonymous session, `forbidden` for a `viewer`,
  `unsupported_content_type`, `payload_too_large`, `quota_exceeded`).
  See [Errors](#errors).

```js
// Create-only: claim a key iff nobody else has it yet.
const r = await backlit.data.putIfMatch("lock", "held", "text/plain", "");
if (!r.matched) {
  // Already taken — r.witness.value is the current holder's bytes.
}
```

For JSON state — the common case — use
[`data.putJSONIfMatch`](#datagetjson--dataputjson--dataputjsonifmatch), which
wraps this method with serialize/parse and an already-parsed witness, or
[`data.updateJSON`](#saving-shared-state-safely), which runs the whole
load-mutate-commit-retry loop for you.

The check-and-set is one atomic operation, so there is no read-then-write
window to lose data in — the only failure mode left is *contention* (too
many conflicting writers), which the retry cap surfaces honestly rather
than hides. The one constraint `putIfMatch` imposes is that **your merge
must be replayable**: on a conflict it runs again against the witness's
fresher state, so write it as a pure rebase ("apply my change to *this*
base"), never baking in "apply my one edit" assumptions that break on the
second pass.

Storing a whole dataset under one key is **correct** under `putIfMatch`,
just higher-contention — every edit competes for that one key's `crc32c`.
If independent records start colliding, split them into per-record keys
(`students.{id}`) so unrelated edits never touch the same key and retries
approach zero. A small team can keep one blob; a busy multi-writer board
should split. It's a contention optimization, not a correctness
requirement.

### `data.getJSON` / `data.putJSON` / `data.putJSONIfMatch`

```ts
backlit.data.getJSON<T = unknown>(key: string): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: { maxAttempts?: number }): Promise<{ value: T, crc32c: string }>
```

Convenience wrappers over `get` / `put` / `putIfMatch` that JSON-serialize
on write and parse on read, always using `application/json`. Keys, caps,
errors, and compare-and-swap semantics are identical to the byte methods —
only the body shape changes. The differences worth knowing:

- **`getJSON`** resolves `{ value, crc32c }` — `value` is the parsed object
  and `crc32c` is its hash, ready to pass straight into `putJSONIfMatch`.
  Throws `not_found` on a missing key (like `get`), and `invalid_body` if
  the stored bytes are not JSON (e.g. the key was written via raw `put`).
- **`putJSON`** stores `JSON.stringify(value)`. Throws `invalid_argument`
  if `value` has no JSON form (a bare `undefined`, a function, a circular
  reference, or a `BigInt`).
- **`putJSONIfMatch`** is the safe shared-write *primitive*. On a mismatch it
  resolves `{ matched: false, witness }` where `witness` is
  `{ value, crc32c }` with `value` **already parsed** — the retry loop
  reads `witness.value` directly, never a Blob. `expectedCrc32c === ""`
  means create-only. A mismatch never throws.
- **`updateJSON`** is the safe shared-write you actually reach for: it runs
  the `getJSON` → `mutate` → `putJSONIfMatch` → rebase-and-retry loop for you
  and resolves the committed `{ value, crc32c }`. `mutate(current)` gets the
  current value (`undefined` on first write) and returns the new value; it
  **must be replayable** (it reruns against a fresher value on conflict).
  Throws `BacklitError(code: "conflict")` if it can't win within `maxAttempts`
  (default 6). See [Saving shared state safely](#saving-shared-state-safely)
  for the worked example.

```ts
type PutJSONIfMatchResult<T> =
  | { matched: true;  crc32c: string }
  | { matched: false; witness: { value: T; crc32c: string } };
```

```js
// Single-writer save/load (userdata, or a data key only you write).
await backlit.data.putJSON("config", { theme: "dark" });
const { value: config } = await backlit.data.getJSON("config");
```

The canonical multi-writer retry loop is under
[Saving shared state safely](#saving-shared-state-safely).

### `data.delete(key: string): Promise<void>`

Remove the value stored at `key`. The deletion is observable via
[`backlit.on("storage.change")`](#backliton--realtime-subscriptions)
under `{store:"data", operation:"delete"}`.

- **Parameters**
  - `key` — same rules as `data.get`.
- **Returns** — a `Promise<void>`.
- **Throws** — `BacklitError`. Common codes: `invalid_key`,
  `unauthenticated` (no signed-in user — shared-data writes require
  one), `forbidden` (a `viewer` is blocked from shared-data writes),
  `not_found` (no value at this key — deletion is **not** idempotent;
  call `get` first or catch the code if you want it to be).

```js
try {
  await backlit.data.delete("greeting");
} catch (e) {
  if (e.code !== "not_found") throw e;
}
```

### `data.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>`

List the keys in the glow-shared keyspace whose names start with
`prefix`. The empty prefix (or omitting the argument) lists everything.

Each entry carries the body's CRC32C, the stored `contentType`, and the
body's `size` in bytes, so apps can dedup against locally-cached state
and learn each value's type and size in one round trip — no per-key
`get` needed just to discover what changed.

The result is **paginated**: it returns one bounded page of entries plus
an opaque `next` cursor. To walk the whole listing, re-call `list` with
`{ cursor: next }` until `next` is `""` (the empty string means the
listing is exhausted). Entries come back in ascending key order, and that
same order is stable across pages.

- **Parameters**
  - `prefix` — optional string, 0–256 bytes, same charset as a key. May
    be empty.
  - `opts.cursor` — optional opaque string. Pass a prior page's `next` to
    fetch the following page; omit (or `""`) for the first page. A
    malformed cursor is rejected with `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size. Clamped to `[1, 1000]`; omitting it
    uses the default of 500. An over-range value is clamped, never
    rejected.
- **Returns** — a `Promise<{ items, next }>`: `items` is the array of
  matching entries for this page (each `{ key, crc32c, contentType, size }`);
  `next` is the cursor for the following page, or `""` when there are no
  more entries.
- **Throws** — `BacklitError` with `code === "invalid_prefix"` if the
  prefix violates the charset or length rule, or `code === "invalid_cursor"`
  if `opts.cursor` is not a valid pagination token.

```js
// Walk every "posts." key, one page at a time.
const stale = [];
let cursor = "";
do {
  const page = await backlit.data.list("posts.", { cursor, limit: 100 });
  for (const e of page.items) {
    if (e.crc32c !== cache.get(e.key)) stale.push(e);
  }
  cursor = page.next;
} while (cursor);
// page.items entries look like:
// { key: "posts.0001", crc32c: "+IxOLg==", contentType: "application/json", size: 128 }
```

### `data.getBatch(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchEntry>, next: string }>`

Like [`list`](#datalistprefix-string-opts--cursor-string-limit-number--promise-items-array-key-string-crc32c-string-contenttype-string-size-number--next-string),
but each page entry **carries the value** — so a whole page of small keys
comes back in one round trip instead of a `list` followed by a `get`
per key. Same inputs, same ascending-key order, same
`{ items, next }` pagination.

Each entry is the `list` metadata (`key`, `crc32c`, `contentType`,
`size`) plus one of two arms — discriminate on `omitted`, exactly as you
narrow a `putIfMatch` result on `matched` (both arms are **normal**
outcomes, not errors):

- **included** (`omitted` absent or `false`) — carries `value`, a `Blob`
  of the stored bytes. A zero-byte `Blob` is a real empty value, never an
  omission.
- **metadata-only** (`omitted: true`) — no `value`, plus an optional
  `reason`. A single value **larger than the whole per-response budget**
  arrives this way with `reason: "too_large"`: a normal result to plan
  for, not an error — read that one key with a plain
  [`get`](#datagetkey-string-promise-value-blob-contenttype-string-crc32c-string).
  `reason` is an open vocabulary; tolerate values you don't recognize.

A page's included entries stop when their combined stored-data size
reaches the per-response budget — **default 10 MiB, the same measure as
the per-object cap** (the size of the data you stored, not of the
response payload). So a page can hold **fewer** entries than `limit`
asked for, and end before the listing is exhausted. **`next` is the sole
completion signal:** walk until `next` is `""`, never stop because a page
came back short. (Every page makes progress — it always includes or omits
at least one entry, so the walk can't stall.)

- **Parameters**
  - `prefix` — optional string, same rules as
    [`data.list`](#datalistprefix-string-opts--cursor-string-limit-number--promise-items-array-key-string-crc32c-string-contenttype-string-size-number--next-string).
  - `opts.cursor` — optional opaque string; pass a prior page's `next`.
    Omit (or `""`) for the first page. A malformed cursor throws
    `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size, **clamped to `[1, 500]`; omitting
    it uses the default of 100** (an over-range value is clamped, never
    rejected). The per-response value budget may end a page below this
    count regardless.
- **Returns** — a `Promise<{ items, next }>`: `items` is this page's
  entries (each a `BatchEntry` — an included or metadata-only arm);
  `next` is the cursor for the following page, or `""` when the listing is
  exhausted.
- **Throws** — `BacklitError` with `code === "invalid_prefix"` or
  `code === "invalid_cursor"` (same as `list`), or `quota_exceeded` if the
  call lands after the monthly bandwidth allowance ran out. A response
  that fails to parse is a **transient** failure — retry the same cursor;
  a page that parses as complete JSON is a complete page.

```js
// Load every "posts." value in bulk, page by page.
const posts = new Map();
let cursor = "";
do {
  const page = await backlit.data.getBatch("posts.", { cursor, limit: 200 });
  for (const e of page.items) {
    if (e.omitted) {
      // Too big for a batch page — fetch it on its own.
      if (e.reason === "too_large") posts.set(e.key, await backlit.data.get(e.key));
      continue;
    }
    posts.set(e.key, e.value); // e.value is a Blob
  }
  cursor = page.next;
} while (cursor); // next === "" means done
```

### `data.getBatchJSON<T>(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>`

The [`getBatch`](#datagetbatchprefix-string-opts--cursor-string-limit-number--promise-items-arraybatchentry-next-string) sibling
that JSON-parses each included value for you — the bulk companion to
[`getJSON`](#datagetjson--dataputjson--dataputjsonifmatch), the way
`getBatch` is the bulk companion to `get`. Same inputs, same pagination,
same budget and `omitted` discrimination; only the value shape differs.

Per included entry, the value is the parsed `T` (parsing is
content-type-agnostic — every included value is `JSON.parse`d, matching
`getJSON`). A JSON `null` (or `0` / `""` / `false`) is a **legal parsed
value** — the discriminant is `omitted`, never value-nullness. A value
that is **not valid JSON** degrades that one entry to the metadata-only
arm with `reason: "not_json"` (the raw bytes are not carried; the rest of
the page is unaffected), so you can spot a key written via raw
[`put`](#dataputkey-string-body-bodyinit-contenttype-string-promise-crc32c-string)
without a failed `getJSON`. A server-omitted entry passes through with its
own `reason` (e.g. `"too_large"`). Each entry still carries `crc32c`, so a
value you read here feeds straight into
[`putJSONIfMatch`](#datagetjson--dataputjson--dataputjsonifmatch) — the
bulk way to seed an `updateJSON` retry loop with values you already have.

- **Parameters** — identical to `data.getBatch`. One `T` applies to the
  whole page.
- **Returns** — a `Promise<{ items, next }>` where each entry's included
  `value` is the parsed `T`.
- **Throws** — same as `data.getBatch`.

```js
// Read a page of JSON posts; a non-JSON key degrades to omitted, not a throw.
const page = await backlit.data.getBatchJSON("posts.");
for (const e of page.items) {
  if (e.omitted) continue;      // too_large, or not_json
  render(e.key, e.value);       // e.value is the parsed object
}
```

---

## `backlit.userdata` — per-user data silo

Same shape as `backlit.data`, but each signed-in user has their own
isolated silo. Two users on the same glow cannot see each other's
`userdata`.

**Available to signed-in users only** — a Private glow, an Accounts glow
after the visitor signs in, or a Public glow's allowlisted user after they
sign in. With no user session (an anonymous or signed-out visitor), every
method throws `BacklitError` with `code === "unauthenticated"` and an
actionable message. Call `backlit.auth.me()` first if you need to branch.

### `userdata.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>`
### `userdata.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`
### `userdata.delete(key: string): Promise<void>`
### `userdata.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>`
### `userdata.getBatch(prefix?, opts?)` / `userdata.getBatchJSON<T>(prefix?, opts?)`
### `userdata.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>`
### `userdata.getJSON<T>(key: string): Promise<{ value: T, crc32c: string }>`
### `userdata.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>`
### `userdata.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>`
### `userdata.updateJSON<T>(key: string, mutate: (current: T | undefined) => T, options?): Promise<{ value: T, crc32c: string }>`

Signatures, parameters, return types, validation rules, and the `*JSON`
[convenience wrappers](#datagetjson--dataputjson--dataputjsonifmatch) are
identical to the `backlit.data.*` methods of the same name. The only
behavioural difference is the `unauthenticated` rewrite above.

`userdata.delete` removes a key from the calling user's silo — never
from another user's. Successful deletes emit a privacy-scoped
[storage.change event](#backliton--realtime-subscriptions) (`store:
"userdata"`) that only the writing user's own sessions receive.

```js
const user = await backlit.auth.me();
if (user === null) {
  // userdata not available — fall back to glow-shared.
  return;
}

await backlit.userdata.putJSON("preferences", { theme: "dark" });

const { value: prefs } = await backlit.userdata.getJSON("preferences");
```

---

## `backlit.records` — public-read, owner-write store

A third key/value store, between `backlit.data` (everyone reads and writes one
shared set) and `backlit.userdata` (each user's private silo). With
`backlit.records`, **any signed-in user creates records and reads everyone's,
but only the record's creator — or an `admin` — may overwrite or delete it.**
It's the store for user-generated content many people see but shouldn't be able
to edit on each other's behalf: forum posts, comments, profiles, submitted
entries, a shared gallery where each image belongs to whoever uploaded it.

**Available to signed-in users only** — a Private glow, an Accounts glow
after the visitor signs in, or a Public glow's signed-in user. With no user
session every method throws `BacklitError` with `code === "unauthenticated"`,
exactly like `backlit.userdata`. The store is **free** — no subscription required. (Unlike
shared `data`, a `viewer` CAN create records — so on an Accounts glow, records
is the store self-serve users can write to out of the box.)

### `records.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string, owner: string }>`
### `records.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`
### `records.delete(key: string): Promise<void>`
### `records.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number, owner: string }>, next: string }>`
### `records.getBatch(prefix?, opts?)` / `records.getBatchJSON<T>(prefix?, opts?)`
### `records.putIfMatch(key, body, contentType, expectedCrc32c): Promise<{ matched: true, crc32c, owner } | { matched: false, witness: { value, contentType, crc32c, owner } }>`
### `records.getJSON<T>(key)` / `records.putJSON(key, value)` / `records.putJSONIfMatch<T>(key, value, expectedCrc32c)` / `records.updateJSON<T>(key, mutate, options?)`

Signatures, parameters, validation rules, and the `*JSON`
[convenience wrappers](#datagetjson--dataputjson--dataputjsonifmatch) match the
`backlit.data.*` methods of the same name, with these differences:

- **`owner`** — `get` and every `list` entry carry an `owner` string: the user
  id of the record's creator (an opaque per-user id, not the email). Every
  `getBatch` / `getBatchJSON` entry carries it too. `putIfMatch` reports it —
  on a successful write and on the mismatch witness — and so do the JSON
  wrappers (`getJSON`, `putJSONIfMatch`, and `updateJSON` all resolve
  `{ value, crc32c, owner }`), so an `updateJSON` loop can tell who owns a key.
- **Anyone signed in may create.** Writing a key that does not exist yet claims
  it for you. A `viewer` *can* create records — the shared-data viewer write
  block does not apply here. Overwriting or deleting a record you do not own
  throws `BacklitError(code: "forbidden")`, unless you are an `admin`.
- **First-come claim.** `records.putIfMatch(key, body, ct, "")` (create-only)
  writes only while the key is still unclaimed, so two users racing for the same
  key resolve deterministically — the loser gets `{ matched: false }`.
- **Concurrent-edit safety.** A `records.delete` or `records.reassign` that
  races a concurrent overwrite or reassign of the same key throws
  `BacklitError(code: "conflict")` (HTTP 412) — the record changed between the
  ownership check and the action. Re-read (`get`) and retry.

```js
// Anyone signed in posts a comment under their own key.
await backlit.records.putJSON(`comment.${Date.now()}`, { text: "nice!" });

// Everyone reads every comment, and sees who wrote each one.
// list() is paginated — `items` is this page, `next` continues it.
const { items } = await backlit.records.list("comment.");
for (const { key, owner } of items) {
  const { value } = await backlit.records.getJSON(key);
  render(value, owner);
}
```

### `records.reassign(key: string, newOwnerUid: string): Promise<void>`

**Admin only.** Transfers a record's ownership to `newOwnerUid` (a user id — for
example an `owner` value read from `get` / `list`). The body is untouched; only
the owner moves. A non-`admin` caller throws `BacklitError(code: "forbidden")`,
an unknown key throws `not_found`, a malformed `newOwnerUid` throws
`invalid_argument` (HTTP 400), and a call that races a concurrent
write/reassign of the same key throws `conflict` (HTTP 412) — retry. The SDK
does not pre-check admin — the server is the boundary.

```js
// An admin hands a record over to another user.
await backlit.records.reassign("entry.42", someUserId);
```

---

## `backlit.capture` — write-only capture store

For data the glow's app code needs to capture but never read back from
the browser: form submissions, telemetry events, contact requests,
support tickets. The server returns an opaque `{prefix}-{guid}` **handle**
on create.

Writes (`create` / `update`) are available on any glow — no user session
required. Reads, enumeration, and deletion (`get` / `list` / `delete`) are
**admin only** — they need a signed-in user whose `auth.me()` `permission`
is `admin` (a Public glow's app can sign an allowlisted admin in for exactly
this — see `auth.login()`), so ordinary app code can capture data but can
never read it back even if the page is compromised.

### `capture.create(prefix: string, body: BodyInit, contentType: string): Promise<{ handle: string, crc32c: string }>`

Store a new value and get back its handle plus the body's CRC32C. The
handle is `"{prefix}-{guid}"` — the `prefix` you supply, prepended
to a server-generated 36-character lower-hex GUID in 8-4-4-4-12 format
(e.g. `create("contact", ...)` → `"contact-3b1f4a2c-1d2e-4f5a-8b9c-0d1e2f3a4b5c"`).
The prefix is a human-meaningful label that lets whoever reads these
objects out of band group and recognise them; the GUID keeps each name
unguessable and unique.

- **Parameters**
  - `prefix` — required string, 1-256 bytes, alphanumerics plus `-` `.`
    `_` `~` (same charset as `data.*` keys — see [Validation](#validation)).
  - `body`, `contentType` — same rules as `data.put`.
- **Returns** — a `Promise<{ handle, crc32c }>` for the new `{prefix}-{guid}`
  handle and its integrity hash.
- **Throws** — `BacklitError`. Common codes: `invalid_key`,
  `invalid_argument`, `unsupported_content_type`, `payload_too_large`,
  `quota_exceeded`.

```js
const { handle } = await backlit.capture.create(
  "contact",
  JSON.stringify({ email: form.email.value, message: form.body.value }),
  "application/json",
);
// handle === "contact-3b1f4a2c-..." — show the user the handle if you
// want them to be able to reference their submission later, or just
// store it for your own records.
form.querySelector(".receipt").textContent = `Submitted (ref ${handle})`;
```

### `capture.update(handle: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`

Replace the value stored at an existing handle. Open to any session —
the handle is the capability.

- **Parameters**
  - `handle` — string, the full `"{prefix}-{guid}"` handle returned by
    `create`. A bare GUID is **not** accepted. See
    [Validation](#validation).
  - `body`, `contentType` — same rules as `create` / `data.put`.
- **Returns** — a `Promise<{ crc32c }>` for the integrity hash of the
  new value.
- **Throws** — `BacklitError`. Common codes: `invalid_handle`,
  `unknown_handle` (no record at that handle), plus the body / size
  codes from `create`.

```js
// handle is the value create returned, e.g. "contact-3b1f4a2c-..."
await backlit.capture.update(
  handle,
  JSON.stringify({ ...previous, resolved: true }),
  "application/json",
);
```

### `capture.get(handle: string): Promise<{ value: Blob, contentType: string, crc32c: string }>`

**Admin only.** Read the value stored at a handle. Resolves to the same
shape as `data.get` (the `value` Blob, `contentType` as stored,
`crc32c` of the body).

- **Parameters**
  - `handle` — the `"{prefix}-{guid}"` handle from `create`.
- **Throws** — `BacklitError`. `forbidden` when the signed-in user is
  not an admin; `unauthenticated` for an anonymous or signed-out visitor
  (no user session); `unknown_handle` if nothing is stored at
  that handle; `invalid_handle` for a malformed handle.

```js
// Only an admin can read captured submissions back.
const { value } = await backlit.capture.get(handle);
const submission = JSON.parse(await value.text());
```

### `capture.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ handle: string, crc32c: string, contentType: string, size: number }>, next: string }>`

**Admin only.** Enumerate the handles stored in this glow's private
store, each with its body's CRC32C, stored `contentType`, and `size` in
bytes. Returns handles and metadata only, never values.

The result is **paginated**, exactly like `data.list`: one bounded page
of handles plus an opaque `next` cursor — re-call with `{ cursor: next }`
until `next` is `""`.

- **Parameters**
  - `prefix` — optional string, same charset as a `data.*` prefix. It
    matches against the handle, so passing a `create` key lists that
    group — `list("contact")` returns every `contact-*` handle. Omit (or
    pass `""`) to list every handle.
  - `opts.cursor` — optional opaque continuation token (a prior page's
    `next`); omit for the first page. A malformed cursor throws
    `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size, clamped to `[1, 1000]` (default 500);
    an over-range value is clamped, never rejected.
- **Returns** — a `Promise<{ items, next }>`: `items` is this page's
  handles (each `{ handle, crc32c, contentType, size }`); `next` is the
  cursor for the following page, or `""` when exhausted.
- **Throws** — `BacklitError`. `forbidden` when the signed-in user is
  not an admin; `unauthenticated` for an anonymous or signed-out visitor
  (no user session); `invalid_prefix` for a bad prefix;
  `invalid_cursor` for a bad `opts.cursor`.

```js
// Only meaningful for an admin user; branch on auth.me() first.
if ((await backlit.auth.me())?.permission === "admin") {
  let cursor = "";
  do {
    const page = await backlit.capture.list("contact", { cursor });
    for (const { handle } of page.items) {
      const { value } = await backlit.capture.get(handle);
      console.log(handle, await value.text());
    }
    cursor = page.next;
  } while (cursor);
}
```

### `capture.delete(handle: string): Promise<void>`

**Admin only.** Remove the object stored at a handle.

- **Parameters**
  - `handle` — the `"{prefix}-{guid}"` handle from `create`.
- **Throws** — `BacklitError`. `forbidden` / `unauthenticated` (same
  gating as `get`); `unknown_handle` if the handle was never created;
  `invalid_handle` for a malformed handle.

```js
await backlit.capture.delete(handle);
```

Writes (`create` / `update`) are open to any session; reads, enumeration,
and deletion (`get` / `list` / `delete`) are admin-only. That asymmetry
is intentional — ordinary app code can *capture* data here but can never
*read it back*, even if the page's JavaScript is compromised, while an
admin operator retains a way to inspect and clean up what was captured.

---

## `backlit.on` — realtime subscriptions

The SDK delivers realtime notifications whenever a `data.*`,
`userdata.*`, or `records.*` key is set or deleted, so apps can keep two tabs in
sync, react to a peer's write, or invalidate a cached read without polling.
Subscribe with `backlit.on("storage.change", handler)`.

```js
const off = backlit.on("storage.change", (event) => {
  // event = { store, operation, key, crc32c, owner? }
  if (event.store === "data" && event.key === "leaderboard") {
    refreshLeaderboard();
  }
});

// Later — stop receiving events.
off();
```

### Event shape

```ts
interface StorageChangeEvent {
  readonly store:     "data" | "userdata" | "records";
  readonly operation: "set"  | "delete";
  readonly key:       string;
  readonly crc32c:    string; // base64 big-endian uint32; empty on delete
  readonly owner?:    string; // records events only — the record's owner uid
}
```

`crc32c` is the base64-encoded Castagnoli CRC32C of the body — the
same value `get` / `list` entries and the `data.put` response carry. On a `"delete"` event it is the empty string. Apps that maintain a local
cache can compare a freshly-fetched body's CRC32C against the event's
to detect duplicate deliveries.

### Scoping rules

- **`data.*` events** reach every connected session of the glow.
- **`records.*` events** are unscoped, like `data.*` — every connected
  session receives them — and additionally carry `owner` (the record's
  owner uid). A `reassign` arrives as a `set` event with the new owner.
- **`userdata.*` events** reach only the writing user's own sessions.
  Two users on the same Private glow never see each other's `userdata`
  keys via `on` (matching the read-side privacy guarantee).
- **`capture.*` is silent.** Handle creation and updates do not
  emit events — the store is opaque write-only telemetry and
  broadcasting handle minting would defeat its purpose.

### Delivery semantics

- **Best-effort.** Events that arrive during a reconnect window are
  dropped. If your app's correctness depends on never missing a
  change, call `data.list()` / `userdata.list()` once on the first
  event after registration and treat `on` as a hint to re-check rather
  than as the source of truth.
- **Deferring a remote change is safe with a compare-and-swap.** If you
  postpone applying an event — say, to avoid disrupting a form the user
  has open — your in-memory copy is now known-stale. Write it back with
  [`updateJSON`](#saving-shared-state-safely) (or the
  [`putIfMatch`](#dataputifmatchkey-string-body-bodyinit-contenttype-string-expectedcrc32c-string-promiseputifmatchresult)
  primitive directly), not bare `put`: it rebases onto the newer value and
  retries instead of silently overwriting it. Deferral plus a compare-and-swap
  is safe; deferral plus bare `put` is a guaranteed lost update.
- **At-least-once is possible.** Duplicate deliveries can occur under
  retries. `crc32c` is the easiest dedup key.
- **Same-tab writes fire too.** A `data.put` from this tab triggers
  `storage.change` for handlers registered on this same tab.
- **Handler order matches registration.** Handlers are called in the
  order they were registered. A thrown handler does not prevent the
  others from running; the exception is reported via `console.error`.

### Reconnect

The SDK opens a single WebSocket on the first
`on("storage.change", …)` call and reuses it for every subsequent
subscription. If the socket drops the SDK reconnects with exponential
backoff (1s → 2s → 4s → … capped at 30s, with ±25% jitter). The socket
closes 5 seconds after the last `unsubscribe()` so a tab that briefly
drops to zero handlers (a route change in an SPA) doesn't pay the
reconnect cost.

### Future events

The first argument is currently restricted to the literal string
`"storage.change"`. Other event names will be added in future v1 minor
releases without breaking existing callers.

---

## `backlit.push` — notifications

`backlit.push` lets your app enroll the signed-in user for **push
notifications** — messages that reach the user even when the glow's tab is
closed, delivered by the browser and the operating system. Backlit installs
and runs the notification machinery for you; your app decides *when* to offer
to enable notifications and reacts to taps.

Every glow is also **installable** — a visitor can add it to their home screen
or app launcher as a standalone app. That happens automatically; there is no
API to call, and it works whether or not you use push.

**Requirements.** `subscribe` / `state` / `unsubscribe` / `test` need a
**signed-in user** (a Private glow, an Accounts glow after the visitor signs
in, or a Public glow's signed-in user) — an anonymous or signed-out visitor
gets `BacklitError(code: "unauthenticated")`. Push must be **enabled for the
glow** (turned on at deploy time); when it isn't, `subscribe` throws
`unsupported`. Browser support varies — always gate your UI on
[`supported()`](#pushsupported--supported-reason-requiresinstall-installed)
first.

**Enabling push.** To turn push on for a glow, ship a root `notify.json` in
the deploy bundle declaring when a notification fires — see the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json) for its
fields and the [Push notifications end-to-end guide](https://sdk.backlit.run/guides/push-end-to-end.md)
for the full author story.

> **Don't register your own service worker.** Backlit owns the glow's service
> worker and registers it for you on page load. Registering your own would
> replace Backlit's and break push + install. (A deploy bundle that reaches for
> `localStorage` / `indexedDB` is rejected for the same reason — the platform
> owns that layer; use `window.backlit.*`.)

```js
// Offer a notification opt-in only where it can actually work.
const cap = backlit.push.supported();
if (cap.supported) {
  enableBtn.onclick = async () => {
    try {
      await backlit.push.subscribe();          // prompts the user for permission
      await backlit.push.test();               // send yourself a test push
    } catch (e) {
      if (e.code === "permission_denied") showTip("You blocked notifications.");
      else throw e;
    }
  };
} else if (cap.requiresInstall) {
  showTip("Install this app to your home screen to turn on notifications.");
} else {
  enableBtn.hidden = true;                      // this browser can't do push
}

// React to a notification tap (or a permission change) while a tab is open.
backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});
```

### `push.supported(): { supported, reason?, requiresInstall, installed }`

A **synchronous** capability probe — the only SDK method that is not `async`,
and the only one that never throws. Call it to decide whether to show an
"Enable notifications" control at all.

- **Returns** (synchronously):
  - `supported` — `true` when this browser can subscribe to push right now.
  - `reason` — when `supported` is `false`, a short tag for *why*:
    `"off_host"` (the SDK isn't running on a deployed glow), `"insecure_context"`,
    `"no_service_worker"`, `"no_push_manager"`, or `"requires_install"`. Omitted
    when `supported` is `true`.
  - `requiresInstall` — `true` on a platform where push works **only** after the
    app is installed to the home screen, and it isn't installed yet (this is
    iOS/iPadOS Safari). Prompt the user to install, then re-probe.
  - `installed` — whether the page is currently running as an installed
    (standalone) app.
- **Never throws**, even off a glow host or in an unusual embedding (it returns
  `{ supported: false, requiresInstall: false, installed: false }`).

### `push.state(): Promise<{ permission, subscribed, endpointId }>`

Report the current push state for this browser and signed-in user.

- **Returns** — a `Promise<{ permission, subscribed, endpointId }>`:
  - `permission` — the OS notification permission: `"granted"`, `"denied"`, or
    `"default"` (not yet asked).
  - `subscribed` — whether this browser currently holds a push subscription.
  - `endpointId` — the subscription's id when subscribed, else `""`.
- **Throws** — `BacklitError`. `unsupported` / `requires_install` when
  `supported()` is `false`; `unsupported_host` off a glow host.

### `push.subscribe(): Promise<{ endpointId, permission: "granted" }>`

Enroll the signed-in user's browser for push. This prompts the user for
notification permission (if not already granted), sets up the subscription, and
registers it with Backlit — so a later `push.test()` (or a notification your
glow's rules send) reaches this browser.

- **Returns** — a `Promise<{ endpointId, permission: "granted" }>` for the new
  subscription's id. (It only resolves when permission was granted.)
- **Throws** — `BacklitError`:
  - `permission_denied` — the user declined (or had already blocked) the
    notification prompt.
  - `unsupported` — the browser can't do push, or push isn't enabled for this
    glow.
  - `requires_install` — the platform needs the app installed first (iOS).
  - `unsupported_host` — the SDK isn't running on a deployed glow.
  - `unauthenticated` — no signed-in user.

```js
try {
  const { endpointId } = await backlit.push.subscribe();
  console.log("subscribed", endpointId);
} catch (e) {
  if (e.code === "permission_denied") {
    // The user said no — don't nag; offer it again later from a clear action.
  } else {
    throw e;
  }
}
```

### `push.unsubscribe(): Promise<void>`

Remove this browser's push subscription (from the browser and from Backlit). A
no-op when there is no active subscription. Requires a signed-in user; throws
`unsupported` / `requires_install` / `unsupported_host` on the same conditions
as `subscribe`.

### `push.test(): Promise<void>`

Send a test push to the signed-in user's own subscriptions — a quick way for a
just-enrolled user to confirm notifications work.

- **Throws** — `BacklitError`. `not_found` when the user has no active
  subscription (call `subscribe` first); `unauthenticated` with no signed-in
  user; `unsupported_host` off a glow host.

### `push.watch(callback): () => void`

Observe push-related events **while a tab is open**, and get back an
unsubscribe function. The callback receives one of:

- `{ type: "notification-click", url }` — the user tapped a delivered
  notification; `url` is its tap target (navigate there, or refresh a view).
- `{ type: "subscription-changed" }` — the browser rotated the subscription and
  Backlit re-registered it; re-read `state()` if you display subscription
  status.
- `{ type: "permission-changed", permission }` — the OS notification permission
  changed to `permission`.

`watch` never throws and is a no-op off a glow host. It observes only; it does
not deliver the notification payloads themselves (the browser shows those).

```js
const off = backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});
// Later: off();
```

---

## Errors

Every method (except `auth.me()`, which never throws) rejects its
returned `Promise` with a single error class,
`BacklitError`. Discriminate on `.code`.

```ts
class BacklitError extends Error {
  readonly code: string;     // one of the codes in the table below
  readonly status: number;   // HTTP status, or 0 for network / client-side
  readonly backlitCause: unknown; // underlying Error or fetch failure, when there is one
}
```

`instanceof BacklitError` works as expected. `.code` matches the
server's `{error:{code,message}}` envelope verbatim so the same switch
statement handles both server-emitted and client-side errors.

```js
try {
  await backlit.data.put("key", new Date(), "application/json");
} catch (e) {
  if (e instanceof backlit.BacklitError) {
    switch (e.code) {
      case "invalid_argument": /* the Date isn't a BodyInit */ break;
      case "invalid_key":      /* charset / length problem */ break;
      case "quota_exceeded":   /* account storage/bandwidth allowance hit */ break;
      default: throw e;
    }
  } else {
    throw e;
  }
}
```

### Error codes

| `code`                       | Source       | Meaning                                                                                          |
| ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
| `not_found`                  | server       | `get` / `getJSON` / `delete` on a key that has never been written (any store), a `putIfMatch` with a non-empty `expectedCrc32c` against an absent key, or `records.reassign` on an unknown key. |
| `unauthenticated`            | server / SDK | A shared-data write (`data.put`/`delete`/`putIfMatch` and the JSON write wrappers), `userdata.*`, or `records.*` called without a user session, or `capture.get`/`list`/`delete` or `auth.users()` with no signed-in user (an anonymous or signed-out visitor, an expired session). |
| `cross_glow_token`           | server       | Your session cookie is bound to a different glow. Rare; indicates cookie reuse.                  |
| `forbidden`                  | server       | A `viewer` attempted `data.put` / `data.delete`; a non-creator/non-`admin` attempted to overwrite, delete, or `reassign` a `records` key; or a non-`admin` attempted `capture.get`/`list`/`delete` or `auth.users()`. (`capture.create`/`update` and `records` *create* are unaffected.) |
| `invalid_key`                | both         | Key violates the charset (`[A-Za-z0-9._~-]`) or length (1–256 bytes) rule.                       |
| `invalid_prefix`             | both         | Prefix violates the charset or length rule (length 0 is allowed).                                |
| `invalid_cursor`             | server       | A `list` `opts.cursor` is not a valid pagination token. Pass a prior page's `next` verbatim, or omit it for the first page. |
| `invalid_handle`             | both         | `capture.update`/`get`/`delete` handle is not the `{prefix}-{guid}` form (a bare GUID is rejected). |
| `unknown_handle`             | server       | `capture.update`/`get`/`delete` against a well-formed handle that has no record.             |
| `invalid_body`               | both         | SDK-side when `getJSON` reads a key whose stored bytes are not valid JSON; server-side when the request body could not be decoded. |
| `invalid_argument`           | both         | Client-side rejection (status 0): wrong `body` type, missing `contentType`, or a `putJSON` value with no JSON form (`undefined` / function / circular / `BigInt`). Also server-side (HTTP 400) for a malformed `records.reassign` owner id. |
| `unsupported_content_type`   | both         | `Content-Type` isn't on the allowlist (or is one of the always-denied executable types).         |
| `payload_too_large`          | server       | Body exceeds the server's per-object cap (default 10 MiB).                                       |
| `quota_exceeded`             | server       | Two cases, split by `status`: **413** — the write would push you over your account's storage allowance (the just-written object has already been rolled back); **429** — the account's monthly bandwidth/request allowance is exhausted, which rejects **every** call, reads and lists included, until the monthly reset. |
| `method_not_allowed`         | server       | A non-SDK caller used an unsupported HTTP method. Should never fire from the SDK.                |
| `invalid_host`               | server       | The request reached Backlit with no recognisable glow Host. Should never fire from the SDK.      |
| `glow_not_found`             | server       | The glow has been deleted (or never existed) since the page loaded.                              |
| `glow_dark`                  | server       | The glow has been switched to `Dark` since the page loaded.                                      |
| `region_mismatch`            | server       | The request reached a Backlit region different from the glow's region.                           |
| `network`                    | SDK          | `fetch` itself rejected: offline, CORS, aborted, etc. `.backlitCause` holds the underlying error. |
| `conflict`                   | both         | SDK-side (status 0): `updateJSON` exhausted its compare-and-swap retries (`maxAttempts`, default 6) under write contention. Server-side (HTTP 412): a `records.delete` / `records.reassign` lost a concurrent-write race — re-read and retry. |
| `unsupported_host`           | SDK          | A `data.*` / `userdata.*` / `records.*` / `capture.*` call — or `auth.users()` / `push.*` — ran while the SDK was loaded outside a deployed glow host. The SDK has no data surface off-glow; deploy the glow to use it. (`auth.me()` and `push.supported()` do not throw it — they resolve `null` / `{ supported:false }` instead.) |
| `unsupported`                | SDK          | `push.subscribe`/`state`/`unsubscribe` — this browser can't do push (no service worker / Push API / secure context), or push isn't enabled for this glow. Gate on `push.supported()` first. |
| `requires_install`           | SDK          | `push.subscribe`/`state`/`unsubscribe` on a platform where push works only from an installed home-screen app (iOS Safari) and it isn't installed yet. `push.supported()` reports this as `requiresInstall: true`. |
| `permission_denied`          | SDK          | `push.subscribe` — the user declined (or had previously blocked) the notification-permission prompt.                                                    |
| `internal`                   | both         | Server returned 5xx (or a non-JSON 4xx), or the response body wasn't parseable.                  |

A few patterns worth pulling out:

- **`not_found` vs. `glow_not_found`** — `not_found` is "no value at
  this key under a live glow"; `glow_not_found` is "the glow itself is
  gone". Handle them differently.
- **`payload_too_large` vs. `quota_exceeded`** — the first is a per-
  object limit (don't upload a 100 MB file); the second is an account
  allowance — storage (a 413 on writes) or monthly bandwidth (a 429 on
  any call, reads included).
- **`unauthenticated` from `userdata.*`** — the SDK rewrites the
  server's terse message to one that names the actual constraint
  ("requires a signed-in user; an anonymous or signed-out visitor cannot
  use this surface").
- **A `putIfMatch` mismatch is not an error** — it **resolves**
  `{ matched: false, witness }` rather than rejecting, so it carries no
  `code`. The only thrown case unique to `putIfMatch` is `not_found`: a
  **non-empty** `expectedCrc32c` against a key that does not exist.
  `updateJSON` (which loops on `putIfMatch` for you) instead absorbs
  mismatches as retries and throws `conflict` only when it gives up.

### `BacklitError.status`

The HTTP status code that produced the error, when one exists. `0` for
client-side validation failures (`invalid_argument`, `invalid_key`,
`invalid_prefix`, `invalid_handle`, the always-denied content-type
pre-flight), for `conflict` raised by `updateJSON` (gave up after its
retries — a server-raised `records` race carries HTTP 412 instead), for
`unsupported_host` (the SDK was loaded off a deployed glow host), and
for transport failures (`network`).

---

## Validation

The SDK pre-flights all inputs so you see a `BacklitError` from the
caller's `await` rather than from a wasted round-trip. The rules below
mirror the server exactly.

### Keys (every `data.*` / `userdata.*` / `records.*` method that takes a key — `get`, `put`, `putIfMatch`, `delete`, `getJSON`, `putJSON`, `putJSONIfMatch`, `updateJSON`)

- 1–256 bytes long.
- Characters: the RFC 3986 unreserved set — ASCII letters (`A–Z`, `a–z`),
  digits (`0–9`), hyphen (`-`), period (`.`), underscore (`_`), tilde (`~`).
- **No slashes**, no whitespace, no other punctuation.

Failures throw `BacklitError` with `code === "invalid_key"`.

### Prefixes (`data.list`, `userdata.list`, `records.list`)

Applies to every method that takes a `prefix` — the three `list`s above
and the matching `getBatch` / `getBatchJSON` on `data` / `userdata` /
`records`:

- 0–256 bytes long. The empty prefix (or omitting the argument) lists
  the whole keyspace.
- Same charset as a key.

Failures throw `BacklitError` with `code === "invalid_prefix"`.

### Handles (`capture.update` / `get` / `delete`)

A handle is the keyed string `"{prefix}-{guid}"` returned by `create`:

- `{prefix}` is the prefix you passed to `create` (the key charset above).
- `{guid}` is a server-generated 36-char lower-hex 8-4-4-4-12 GUID
  (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, each `x` is `[0-9a-f]`).

A bare GUID with no prefix is **not** a valid handle. The GUID is
always server-generated; never construct a handle yourself — pass back
exactly what `create` returned.

Failures throw `BacklitError` with `code === "invalid_handle"`.

### Content-Type

`contentType` is required on every `put` and `create`/`update`. It must
be a non-empty string. The server enforces a strict allowlist (by
default `text/plain`, `text/css`, `text/csv`, `text/markdown`, the
raster image families `image/{png,jpeg,gif,webp}`, `application/json`,
and `application/octet-stream` — note these are the exact `text/`
subtypes, **not** a `text/*` wildcard); the exact allowlist may be
tuned per deployment.

A small set is always denied, regardless of the allowlist, because
serving them back on a GET could execute as same-origin script:

- `text/html`
- `application/xhtml+xml`
- `image/svg+xml` (an SVG can embed `<script>` that runs on navigation
  to the object URL)
- `application/javascript`, `text/javascript`,
  `application/ecmascript`, `text/ecmascript`

The SDK pre-flights this always-denied set client-side
(`unsupported_content_type`). The full allowlist is enforced server-
side. On reads the server also sends `X-Content-Type-Options: nosniff`
so stored bytes can't be MIME-sniffed into executable markup.

---

## Notes

- **No SDK guard against double-load.** If a page accidentally
  `<script src>`s the bundle twice, the second copy rebinds
  `window.backlit` with an equivalent surface. There is no internal
  state to corrupt; a guard would break legitimate hot-reload flows.
- **`window.backlit._versionStale`.** After a deploy, sessions issued
  against an older live version of the glow stay valid (you don't get
  signed out), but the server signals that the page is now serving
  stale code. The SDK flips `window.backlit._versionStale = true` on
  first sighting so apps that care can prompt the user to reload.
  **The leading underscore means this is not part of the v1 contract**
  and may evolve.
- **Cookies and credentials.** Requests carry the glow's session
  cookies automatically — the SDK never reads or writes cookies itself.

## Changelog

- **`data` / `userdata` / `records` gained `getBatch` and `getBatchJSON`.**
  Bulk reads that return the values alongside the same list-shaped
  pagination — one round trip for a whole page of keys instead of a `list`
  plus a `get` per key. An entry too large for a page arrives metadata-only
  with `omitted: true` (fetch it with a plain `get`); a page can end early
  at its per-response value budget, so `next` is the sole completion signal.
  `getBatchJSON<T>` parses each value like `getJSON` and degrades a non-JSON
  entry to the omitted arm. `capture` has no batch read.
- **`backlit.push` was added — notifications + universal install.** A new
  namespace for Web Push: `supported()` (a synchronous, never-throwing
  capability probe), `state()`, `subscribe()`, `unsubscribe()`, `test()`, and
  `watch()`. Push needs a signed-in user and must be enabled for the glow;
  three new client-side error codes surface the browser-capability cases
  (`unsupported`, `requires_install`, `permission_denied`). Separately, **every
  glow is now installable** — the SDK sets up the platform service worker on
  load automatically. Don't register your own service worker on a Backlit glow.
- **`auth.users()` is now paginated.** It takes `{ cursor?, limit? }` and
  returns `{ items: BacklitUser[], next: string }` — the same cursor shape
  as the `data` / `userdata` / `records` / `capture` `list()` surfaces.
  Previously it returned a flat `BacklitUser[]` capped at the first 1000
  users. Walk the pages with `{ cursor: next }` until `next` is `""`.
- **Shared-data writes now require a signed-in user.** Anonymous
  sessions (a signed-out Public or Accounts visitor) can still *read*
  `backlit.data.*`, but `put` / `delete` / `putIfMatch`
  and the JSON write wrappers now throw
  `BacklitError(code: "unauthenticated")`. Previously anonymous sessions
  could write shared data. Apps that collect input from anonymous
  visitors should use `backlit.capture.create` (open to any session) or
  require sign-in.
- **`auth.users()` was added.** An admin-only, read-only roster of the
  Private glow's users — each a `BacklitUser`
  (`{ uid, name, email, mobile, permission }`). Unlike `auth.me()` it
  **throws** `BacklitError` (`forbidden` for a non-admin, `unauthenticated`
  with no signed-in user, `unsupported_host` off a glow host, `network`).
- **`auth.user()` and `auth.permission()` were replaced by `auth.me()`.**
  A single call now returns the whole signed-in user —
  `{ uid, name, email, mobile, permission }` — or `null` when there is no
  user. Read `email`/`permission` off the result instead of the two old
  methods.
- **`window.backlit.mode` and the `__BACKLIT_LOCAL` override were removed.**
  The SDK is always remote and runs only on a deployed glow host; there
  is no local/offline (browser-storage) fallback. Off a glow host every
  `data.*` / `userdata.*` / `records.*` / `capture.*` call throws
  `BacklitError(code: "unsupported_host")`, while `auth.me()` resolves
  `null`.
- **`list()` entries now carry `contentType` and `size`.** Every
  `data.list` / `userdata.list` / `records.list` / `capture.list` entry
  includes the stored content-type and the body's byte size alongside
  `crc32c` (and, for records, `owner`).
