# 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 email, read and
write glow-shared data, read and write per-user data, and post
write-only telemetry. This page is the public contract for everything
`window.backlit` exposes.

The same bundle also works when no Backlit server is in reach — opened
straight from `file://`, embedded in an AI design tool's preview iframe,
or served from any non-`backlit.run` host. In that situation the SDK
transparently mirrors every operation against browser-local IndexedDB
under a fixed `me@localhost.local` identity, so an app can be prototyped
end-to-end before it ships to a glow. See [Local development and testing](#local-development-and-testing).

This document is published as HTML at <https://backlit.run/sdk> and as
plain Markdown at <https://backlit.run/sdk.md> so AI agents can fetch
and parse the surface without HTML scraping.

## 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://cdn.backlitdev.run/sdk/v1/sdk.js"></script>
<script>
  const email = await window.backlit.auth.user();
  // ...
</script>
```

Two URL channels are supported:

| URL                                          | Stability  | Use for                                       |
| -------------------------------------------- | ---------- | --------------------------------------------- |
| `https://cdn.backlitdev.run/sdk/v1/sdk.js`      | Stable     | Production glows. Pinned to the v1 surface.   |
| `https://cdn.backlitdev.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"`).

`window.backlit.mode` is `"remote"` when the SDK is talking to the live
Backlit server and `"local"` when it's persisting to browser-local
IndexedDB. The value is fixed for the lifetime of the page; branch on
it to render a "running locally" banner or to disable features that
only make sense once deployed. See
[Local development and testing](#local-development-and-testing) for the
detection rule and override hook.

## 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 of the glow (one shared set)                   | leaderboards, shared docs, published content     |
| `backlit.userdata` | each signed-in user, isolated (Private glow)                 | per-user settings, drafts, history               |
| `backlit.records`  | any signed-in user creates + reads all; only the creator/admin edits (Private glow) | forum posts, comments, user submissions, galleries |

Both 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
session ever writes. But when two visitors 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 a Private glow.   |
| `localStorage` (app-global state)            | `backlit.data.*`                | Content that every visitor should see: a wiki page, a leaderboard, a published gallery.          |
| `IndexedDB` keyed records                    | `backlit.data.*` / `userdata.*` | Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string.  |
| 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 the appropriate `image/*` content type.       |

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, chat, 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`                                          |
| keeps per-user settings, drafts, or history in one global blob                         | **Per-user data** — each signed-in user gets an isolated silo                                             | `backlit.userdata.*` (Private glow)                                                            |
| 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.permission()`                                                                    |
| needs sign-in, or should be limited to specific people or domains                      | **Private auth mode** — restrict to an allowlist; 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-api OpenAPI
for the direct and ticket-based upload endpoints). A few rules for
what to put inside.

**`index.html` at the bundle root.** Glows are served from
`https://{name}.backlit.run/`; the file returned for `/` is the
glow's `default_file`, which defaults to `index.html`. Either ship
an `index.html` at the top level of the bundle or set `default_file`
explicitly when you create the glow. 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.backlitdev.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 hand the bundle off **out of band, directly to
`https://glow.backlitdev.run`** rather than over the MCP connection — so if
your agent runs in a sandbox with a domain allow-list, **add that host to
its allowed/permitted domains** or the upload 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.

## Local development and testing

The SDK auto-detects whether it's running on a real Backlit host. When
the page's hostname doesn't end in `.backlit.run` (or a recognised dev
suffix — `.localtest.me`, `localhost`, `*.localhost`), the SDK enters
**local mode**: every `data.*` / `userdata.*` / `capture.*` call
persists to a browser-local IndexedDB database named `backlit_local` in
the page's origin, and `auth.user()` returns the fixed string
`"me@localhost.local"`. No Backlit services need to be reachable.

This is what makes the SDK usable inside AI design tools (v0, bolt,
lovable) and from raw `file://` pages: drop the same `<script src>` tag,
write the same code against `window.backlit`, and deploy unchanged once
you're ready. Local mode is a faithful mirror of the production
surface, not a stub:

- The **content-type allowlist** matches the server default exactly
  (`text/plain`, `text/css`, `text/csv`, `text/markdown`,
  `application/json`, `application/octet-stream`,
  `image/{png,jpeg,gif,webp}`). The always-denied executable
  types — including `image/svg+xml` — are rejected client-side the same way.
- The **per-object cap** is 10 MiB; oversized writes throw
  `payload_too_large`.
- Writes that would push you over **your storage allowance**
  (shared, userdata, and private stores combined) throw
  `quota_exceeded`.
- `userdata.*` always works under the single `me@localhost.local`
  identity — there's no Public-vs-Private distinction locally.
- `putIfMatch` is mirrored too: the compare-and-swap runs inside a single
  IndexedDB transaction, so it is atomic against a same-tab racing write
  and resolves `{ matched: false, witness }` on a CRC mismatch exactly
  like the live surface.

Use `window.backlit.mode` (`"remote"` or `"local"`) to branch UI:

```js
if (backlit.mode === "local") {
  document.getElementById("env-banner").textContent =
    "Running locally — data is stored only in this browser.";
}
```

### Forcing the mode

Set `window.__BACKLIT_LOCAL` to `true` or `false` **before** the SDK
script loads to override the auto-detect. Useful when serving from a
plain static server on `localhost` (which auto-detects as `"remote"`
since that's also the convention for live Backlit dev setups), or when
you explicitly want to test the live Backlit surface from an unusual
hostname.

```html
<script>window.__BACKLIT_LOCAL = true;</script>
<script src="https://cdn.backlitdev.run/sdk/v1/sdk.js"></script>
```

### Inspecting and clearing local data

The data lives in your browser's DevTools under **Application →
IndexedDB → backlit_local**, with one object store per surface
(`shared`, `userdata`, `private`) plus a `meta` store holding the
running byte counter. To wipe state and start clean:

```js
indexedDB.deleteDatabase("backlit_local");
```

Wire that one-liner into a debug button on your own dev page if you
find yourself resetting state often.

### When IndexedDB is unavailable

In some browsing modes (Safari Private mode in older versions, some
in-app webviews, locked-down enterprise browsers) IndexedDB throws or
returns `undefined`. Local mode detects this at first use and every
data operation rejects with `BacklitError(code: "internal")` whose
message names the constraint. `auth.user()` and `window.backlit.mode`
keep working — they don't touch IndexedDB.

## Public vs. Private glows

What works for a given visitor depends on the glow's `auth_mode` and
whether the visitor has signed in.

| Surface                       | Public glow (anyone)         | Private glow, signed in     | Private glow, signed out         | Local mode (any host off `*.backlit.run`)              |
| ----------------------------- | ---------------------------- | --------------------------- | -------------------------------- | ------------------------------------------------------ |
| `backlit.auth.user()`         | returns `null`               | returns the verified email  | n/a — the visitor never reaches the glow; Backlit redirects them to `auth.backlit.run` | returns `"me@localhost.local"`                         |
| `backlit.auth.permission()`   | returns `null`               | returns `admin`/`user`/`viewer` | n/a                          | returns `"admin"`                                      |
| `backlit.data.* (reads)`      | works                        | works                       | n/a                              | works (IndexedDB)                                      |
| `backlit.data.put/delete`     | works                        | works (blocked for `viewer`) | n/a                             | works (IndexedDB)                                      |
| `backlit.userdata.*`          | throws `unauthenticated`     | works (per-user silo)       | n/a                              | works under the fixed local user                       |
| `backlit.records.* (reads)`   | throws `unauthenticated`     | works (read every record)   | n/a                              | works (IndexedDB)                                      |
| `backlit.records.put/delete`  | throws `unauthenticated`     | works on your own records (others' → `forbidden`) | n/a            | works (single local user owns all)                     |
| `backlit.records.reassign`    | throws `unauthenticated`     | works only for `admin` (else `forbidden`) | n/a                | works (single local user is admin)                     |
| `backlit.capture.create/update` | works                  | works                       | n/a                              | works (IndexedDB)                                      |
| `backlit.capture.get/list/delete` | throw `unauthenticated` (no admin) | work only for `admin` (else `forbidden`) | n/a               | work (single local user is admin)                      |

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.

---

## `backlit.auth`

### `auth.user(): Promise<string | null>`

Returns the verified email of the signed-in user, or `null` for Public
glows (which never have a user session). **Never throws** — network
failures and missing sessions both resolve to `null`, since both render
the same fallback UI in practice.

In [local mode](#local-development-and-testing) this method always
resolves to `"me@localhost.local"`, so apps written against a non-null
return value can be developed and tested without a real Backlit signin
flow.

```js
const email = await backlit.auth.user();
if (email === null) {
  // Show the anonymous experience.
} else {
  document.getElementById("greeting").textContent = `Hello, ${email}`;
}
```

### `auth.permission(): Promise<"admin" | "user" | "viewer" | null>`

Returns the signed-in user's permission level (a lowercase enum), or `null`
for Public glows, signed-out visitors, and sessions minted before permissions
shipped. **Never throws** — like `auth.user()`, failures resolve to `null`.

`"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 — this method exists so apps can branch ahead of a
write, e.g. hiding a Save button. In [local mode](#local-development-and-testing)
it always resolves to `"admin"`.

```js
const perm = await backlit.auth.permission();
if (perm === "viewer") {
  saveButton.disabled = true; // the server would 403 a data.put anyway
}
```

---

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

Readable and writable by any visitor with a valid glow session — i.e.
every visitor of a Public glow, and every signed-in visitor of a Private
glow. There is one shared keyspace per glow, and more than one session
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. 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.

### `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), `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`, `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`,
  `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): Promise<Array<{ key: string, crc32c: 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, so apps can dedup against
locally-cached state in one round trip — no per-key `get` needed just
to discover what changed.

- **Parameters**
  - `prefix` — optional string, 0–256 bytes, same charset as a key. May
    be empty.
- **Returns** — a `Promise<Array<{ key, crc32c }>>` of matching entries,
  in unspecified order. The current server returns the entire result
  set in one response; pagination is a possible v2 evolution.
- **Throws** — `BacklitError` with `code === "invalid_prefix"` if the
  prefix violates the charset or length rule.

```js
const entries = await backlit.data.list("posts.");
// → [{ key: "posts.0001", crc32c: "+IxOLg==" }, ...]
const stale = entries.filter((e) => e.crc32c !== cache.get(e.key));
```

---

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

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

**Available on Private glows with a signed-in user only.** On Public
glows (and on any future signed-out Private state), every method throws
`BacklitError` with `code === "unauthenticated"` and an actionable
message. Call `backlit.auth.user()` first if you need to branch.

In [local mode](#local-development-and-testing) the methods always
succeed — the fixed `me@localhost.local` user is implicitly signed in,
so `unauthenticated` is never thrown.

### `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): Promise<Array<{ key: string, crc32c: string }>>`
### `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 email = await backlit.auth.user();
if (email === 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 on Private glows with a signed-in user only.** On Public glows (and
any signed-out Private state) every method throws `BacklitError` with
`code === "unauthenticated"`, exactly like `backlit.userdata`. The store is
**free** — no subscription required.

In [local mode](#local-development-and-testing) the single fixed user owns every
record, so writes always succeed and `reassign` works too.

### `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): Promise<Array<{ key: string, crc32c: string, owner: string }>>`
### `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).
  `putIfMatch` reports it too — on a successful write and on the mismatch
  witness — 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 }`.

```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.
for (const { key, owner } of await backlit.records.list("comment.")) {
  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")`,
and an unknown key throws `not_found`. 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 Public and Private glows
alike — no user session required. Reads, enumeration, and deletion
(`get` / `list` / `delete`) are **admin only** — they need a signed-in
Private user whose `auth.permission()` is `admin`, 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 `-` and
    `.` (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` on Public glows or signed-out Private
  visitors (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): Promise<Array<{ handle: string, crc32c: string }>>`

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

- **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.
- **Returns** — a `Promise<Array<{ handle, crc32c }>>`.
- **Throws** — `BacklitError`. `forbidden` when the signed-in user is
  not an admin; `unauthenticated` on Public glows or signed-out Private
  visitors (no user session); `invalid_prefix` for a bad prefix.

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

### `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);
```

> In local mode the single user is always an admin, so `get` / `list` /
> `delete` work unconditionally — an app built against them behaves the
> same once deployed and signed into as an admin.

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 sessions whose verified email
  matches the writing user. 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

In remote mode 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.

In local mode events flow via `BroadcastChannel` between tabs of the
same origin and synchronously (on the microtask queue) within a single
tab. There is no socket to reconnect.

### 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.

---

## Errors

Every method (except `auth.user()`) 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":   /* glow data cap hit */ break;
      default: throw e;
    }
  } else {
    throw e;
  }
}
```

### Error codes

| `code`                       | Source       | Meaning                                                                                          |
| ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
| `not_found`                  | server       | `data.get` / `userdata.get` on a key that has never been written.                                |
| `unauthenticated`            | server / SDK | `userdata.*` called without a user session, or `capture.get`/`list`/`delete` with no signed-in user (Public glow, 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`. (`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_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         | The body failed a content check: server-side for a declared `Content-Type` mismatch, or SDK-side when `getJSON` reads a key whose stored bytes are not valid JSON. |
| `invalid_argument`           | SDK          | Client-side rejection: wrong `body` type, missing `contentType`, or a `putJSON` value with no JSON form (`undefined` / function / circular / `BigInt`). |
| `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       | The write would push you over your account's storage allowance. The just-written object has already been rolled back. |
| `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`                   | SDK          | `updateJSON` exhausted its compare-and-swap retries (`maxAttempts`, default 6) under write contention. |
| `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 means you've
  used up your account's storage allowance.
- **`unauthenticated` from `userdata.*`** — the SDK rewrites the
  server's terse message to one that names the actual constraint
  ("requires a signed-in user; Public glows 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` (`updateJSON` gave up after its retries), 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: ASCII letters (`A–Z`, `a–z`), digits (`0–9`), hyphen (`-`),
  period (`.`).
- **No slashes**, no underscores, no whitespace, no other punctuation.

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

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

- 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.
  In local mode there are no cookies; nothing leaves the browser.
