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
and as plain Markdown at /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 (hosted alongside the bundle at
sdk.backlit.run/sdk/v1/sdk.d.ts), and a routing index for agents at
/llms.txt.
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.
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
Machine-readable types: /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
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.
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.
Loading
The SDK is a single UMD bundle hosted on the Backlit CDN. Drop one
<script> tag in your HTML; no build step required.
<!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:
// 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:
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:
// `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 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.
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:
- Everything is async.
localStorage.getItemis synchronous;backlit.data.getreturns aPromise. Wrap reads inawaitand move any work that depended on synchronous storage into anasyncinitialiser. - Objects use the
*JSONhelpers.localStoragestores strings; for object state useputJSON/getJSON, which serialize and parse for you. (The underlyingput/gettake rawstring | Blob | ArrayBuffer | ArrayBufferViewwith an explicitcontentType— that's the path for binary like images.) - Keys are restricted.
[A-Za-z0-9._~-]only, no slashes. If you usedlocalStorage.setItem("user/42/profile", …)translate touser.42.profile(or any other slash-free scheme).
A typical port — the localStorage version on the left, the Backlit
version on the right:
// 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
userdatahas a single writer — you. The identical shape on sharedbacklit.data.*is a lost-update bug. For any shared key more than one session can write, useupdateJSONinstead ofputJSON.
When you need a key gone, call backlit.data.delete(key) or
backlit.userdata.delete(key) — see the delete sections
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 fromhttps://{name}.backlit.run/, and/always returnsindex.html. A request path without a file extension serves the matching.htmlfile when it exists — e.g./aboutservesabout.html./aboutis 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 thewindow.backlitdata 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:
<!-- 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.
<!-- 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 for the glow — its
rules declare which data writes send a notification to whom (see the
notify.json schema
for every field, and the
push guide 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
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.jsonis 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:
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
- ChatGPT — set up an MCP connector
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.combut notglow.backlit.run): the agent leases a repo,git pushes your files to it, and signals the push — Backlit pulls and deploys the draft. The push goes directly togithub.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.
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'snextto fetch the following page; omit (or"") for the first page. A malformed cursor is rejected withBacklitError(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.
// 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.)
// 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.
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 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
compare-and-swap primitive below — reach for that directly only for
create-only locks or a custom merge.) Plain data.put
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
(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
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 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, so open
peer sessions don't hear it and no
notification rule 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).
- Parameters
key— string, 1–256 bytes, alphanumerics +-._~only. See Validation.
- Returns — a
Promisefor{ value, contentType, crc32c }. - Throws —
BacklitErrorwithcode === "not_found"if the key has never been written. See Errors for the full list.
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.
⚠️
putis 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 bareputonly when last-writer-wins is genuinely fine — a single writer, or idempotent content. For any shared mutable state,updateJSONis the default (or theputIfMatchprimitive for non-JSON bodies).
- Parameters
key— same rules asdata.get.body— one ofstring,Blob,ArrayBuffer, or anyArrayBufferView(e.g.Uint8Array). Plain objects are rejected — for JSON, callJSON.stringify(obj)yourself and pass"application/json".contentType— required, non-empty string. Forwarded verbatim as the requestContent-Typeheader. 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 }>.crc32cis the Castagnoli checksum of the body, matching the value the server recorded and any subsequentdata.get/data.listexposes 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(avieweris blocked from shared-data writes),unsupported_content_type,payload_too_large,quota_exceeded. See Errors.
// 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
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).
The result is a discriminated union:
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 asdata.get.body/contentType— same rules asdata.put.expectedCrc32c— thecrc32cyou expect the key to currently hold (the value a priorget/put/listreturned). The empty string""means create-only: write only if the key is currently absent.
- Returns — a
Promise<PutIfMatchResult>.{ matched: true, crc32c }— the write landed;crc32cis the checksum of the body you just stored.{ matched: false, witness }— yourexpectedCrc32cdid not match the current value (you lost the race).witnessis the current{ value, contentType, crc32c }, exactly asdata.getwould 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-emptyexpectedCrc32cagainst a key that does not exist — there is nothing to compare against), plus the same write-path codes asdata.put(invalid_key,invalid_argument,unauthenticatedfor an anonymous session,forbiddenfor aviewer,unsupported_content_type,payload_too_large,quota_exceeded). See Errors.
// 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, which
wraps this method with serialize/parse and an already-parsed witness, or
data.updateJSON, 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
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:
getJSONresolves{ value, crc32c }—valueis the parsed object andcrc32cis its hash, ready to pass straight intoputJSONIfMatch. Throwsnot_foundon a missing key (likeget), andinvalid_bodyif the stored bytes are not JSON (e.g. the key was written via rawput).putJSONstoresJSON.stringify(value). Throwsinvalid_argumentifvaluehas no JSON form (a bareundefined, a function, a circular reference, or aBigInt).putJSONIfMatchis the safe shared-write primitive. On a mismatch it resolves{ matched: false, witness }wherewitnessis{ value, crc32c }withvaluealready parsed — the retry loop readswitness.valuedirectly, never a Blob.expectedCrc32c === ""means create-only. A mismatch never throws.updateJSONis the safe shared-write you actually reach for: it runs thegetJSON→mutate→putJSONIfMatch→ rebase-and-retry loop for you and resolves the committed{ value, crc32c }.mutate(current)gets the current value (undefinedon first write) and returns the new value; it must be replayable (it reruns against a fresher value on conflict). ThrowsBacklitError(code: "conflict")if it can't win withinmaxAttempts(default 6). See Saving shared state safely for the worked example.
type PutJSONIfMatchResult<T> =
| { matched: true; crc32c: string }
| { matched: false; witness: { value: T; crc32c: string } };
// 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.
data.delete(key: string): Promise<void>
Remove the value stored at key. The deletion is observable via
backlit.on("storage.change")
under {store:"data", operation:"delete"}.
- Parameters
key— same rules asdata.get.
- Returns — a
Promise<void>. - Throws —
BacklitError. Common codes:invalid_key,unauthenticated(no signed-in user — shared-data writes require one),forbidden(avieweris blocked from shared-data writes),not_found(no value at this key — deletion is not idempotent; callgetfirst or catch the code if you want it to be).
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'snextto fetch the following page; omit (or"") for the first page. A malformed cursor is rejected withBacklitError(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 }>:itemsis the array of matching entries for this page (each{ key, crc32c, contentType, size });nextis the cursor for the following page, or""when there are no more entries. - Throws —
BacklitErrorwithcode === "invalid_prefix"if the prefix violates the charset or length rule, orcode === "invalid_cursor"ifopts.cursoris not a valid pagination token.
// 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,
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 (
omittedabsent orfalse) — carriesvalue, aBlobof the stored bytes. A zero-byteBlobis a real empty value, never an omission. - metadata-only (
omitted: true) — novalue, plus an optionalreason. A single value larger than the whole per-response budget arrives this way withreason: "too_large": a normal result to plan for, not an error — read that one key with a plainget.reasonis 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 asdata.list.opts.cursor— optional opaque string; pass a prior page'snext. Omit (or"") for the first page. A malformed cursor throwsBacklitError(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 }>:itemsis this page's entries (each aBatchEntry— an included or metadata-only arm);nextis the cursor for the following page, or""when the listing is exhausted. - Throws —
BacklitErrorwithcode === "invalid_prefix"orcode === "invalid_cursor"(same aslist), orquota_exceededif 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.
// 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 sibling
that JSON-parses each included value for you — the bulk companion to
getJSON, 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.parsed, 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
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 — the
bulk way to seed an updateJSON retry loop with values you already have.
- Parameters — identical to
data.getBatch. OneTapplies to the whole page. - Returns — a
Promise<{ items, next }>where each entry's includedvalueis the parsedT. - Throws — same as
data.getBatch.
// 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 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 (store: "userdata") that only the writing user's own sessions receive.
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 match the
backlit.data.* methods of the same name, with these differences:
owner—getand everylistentry carry anownerstring: the user id of the record's creator (an opaque per-user id, not the email). EverygetBatch/getBatchJSONentry carries it too.putIfMatchreports it — on a successful write and on the mismatch witness — and so do the JSON wrappers (getJSON,putJSONIfMatch, andupdateJSONall resolve{ value, crc32c, owner }), so anupdateJSONloop can tell who owns a key.- Anyone signed in may create. Writing a key that does not exist yet claims
it for you. A
viewercan create records — the shared-data viewer write block does not apply here. Overwriting or deleting a record you do not own throwsBacklitError(code: "forbidden"), unless you are anadmin. - 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.deleteorrecords.reassignthat races a concurrent overwrite or reassign of the same key throwsBacklitError(code: "conflict")(HTTP 412) — the record changed between the ownership check and the action. Re-read (get) and retry.
// 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.
// 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 asdata.*keys — see Validation).body,contentType— same rules asdata.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.
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 bycreate. A bare GUID is not accepted. See Validation.body,contentType— same rules ascreate/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 fromcreate.
// 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 fromcreate.
- Throws —
BacklitError.forbiddenwhen the signed-in user is not an admin;unauthenticatedfor an anonymous or signed-out visitor (no user session);unknown_handleif nothing is stored at that handle;invalid_handlefor a malformed handle.
// 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 adata.*prefix. It matches against the handle, so passing acreatekey lists that group —list("contact")returns everycontact-*handle. Omit (or pass"") to list every handle.opts.cursor— optional opaque continuation token (a prior page'snext); omit for the first page. A malformed cursor throwsBacklitError(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 }>:itemsis this page's handles (each{ handle, crc32c, contentType, size });nextis the cursor for the following page, or""when exhausted. - Throws —
BacklitError.forbiddenwhen the signed-in user is not an admin;unauthenticatedfor an anonymous or signed-out visitor (no user session);invalid_prefixfor a bad prefix;invalid_cursorfor a badopts.cursor.
// 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 fromcreate.
- Throws —
BacklitError.forbidden/unauthenticated(same gating asget);unknown_handleif the handle was never created;invalid_handlefor a malformed handle.
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).
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
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, likedata.*— every connected session receives them — and additionally carryowner(the record's owner uid). Areassignarrives as asetevent 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'suserdatakeys viaon(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 treatonas 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(or theputIfMatchprimitive directly), not bareput: it rebases onto the newer value and retries instead of silently overwriting it. Deferral plus a compare-and-swap is safe; deferral plus bareputis a guaranteed lost update. - At-least-once is possible. Duplicate deliveries can occur under
retries.
crc32cis the easiest dedup key. - Same-tab writes fire too. A
data.putfrom this tab triggersstorage.changefor 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()
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 for its
fields and the Push notifications end-to-end guide
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/indexedDBis rejected for the same reason — the platform owns that layer; usewindow.backlit.*.)
// 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—truewhen this browser can subscribe to push right now.reason— whensupportedisfalse, 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 whensupportedistrue.requiresInstall—trueon 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_installwhensupported()isfalse;unsupported_hostoff 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.
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_foundwhen the user has no active subscription (callsubscribefirst);unauthenticatedwith no signed-in user;unsupported_hostoff 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;urlis its tap target (navigate there, or refresh a view).{ type: "subscription-changed" }— the browser rotated the subscription and Backlit re-registered it; re-readstate()if you display subscription status.{ type: "permission-changed", permission }— the OS notification permission changed topermission.
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).
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.
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.
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_foundvs.glow_not_found—not_foundis "no value at this key under a live glow";glow_not_foundis "the glow itself is gone". Handle them differently.payload_too_largevs.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).unauthenticatedfromuserdata.*— 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
putIfMatchmismatch is not an error — it resolves{ matched: false, witness }rather than rejecting, so it carries nocode. The only thrown case unique toputIfMatchisnot_found: a non-emptyexpectedCrc32cagainst a key that does not exist.updateJSON(which loops onputIfMatchfor you) instead absorbs mismatches as retries and throwsconflictonly 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 lists 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 tocreate(the key charset above).{guid}is a server-generated 36-char lower-hex 8-4-4-4-12 GUID (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, eachxis[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/htmlapplication/xhtml+xmlimage/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 rebindswindow.backlitwith 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 flipswindow.backlit._versionStale = trueon 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/recordsgainedgetBatchandgetBatchJSON. Bulk reads that return the values alongside the same list-shaped pagination — one round trip for a whole page of keys instead of alistplus agetper key. An entry too large for a page arrives metadata-only withomitted: true(fetch it with a plainget); a page can end early at its per-response value budget, sonextis the sole completion signal.getBatchJSON<T>parses each value likegetJSONand degrades a non-JSON entry to the omitted arm.capturehas no batch read.backlit.pushwas added — notifications + universal install. A new namespace for Web Push:supported()(a synchronous, never-throwing capability probe),state(),subscribe(),unsubscribe(),test(), andwatch(). 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 thedata/userdata/records/capturelist()surfaces. Previously it returned a flatBacklitUser[]capped at the first 1000 users. Walk the pages with{ cursor: next }untilnextis"".- Shared-data writes now require a signed-in user. Anonymous
sessions (a signed-out Public or Accounts visitor) can still read
backlit.data.*, butput/delete/putIfMatchand the JSON write wrappers now throwBacklitError(code: "unauthenticated"). Previously anonymous sessions could write shared data. Apps that collect input from anonymous visitors should usebacklit.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 aBacklitUser({ uid, name, email, mobile, permission }). Unlikeauth.me()it throwsBacklitError(forbiddenfor a non-admin,unauthenticatedwith no signed-in user,unsupported_hostoff a glow host,network).auth.user()andauth.permission()were replaced byauth.me(). A single call now returns the whole signed-in user —{ uid, name, email, mobile, permission }— ornullwhen there is no user. Reademail/permissionoff the result instead of the two old methods.window.backlit.modeand the__BACKLIT_LOCALoverride 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 everydata.*/userdata.*/records.*/capture.*call throwsBacklitError(code: "unsupported_host"), whileauth.me()resolvesnull.list()entries now carrycontentTypeandsize. Everydata.list/userdata.list/records.list/capture.listentry includes the stored content-type and the body's byte size alongsidecrc32c(and, for records,owner).