# Your server — WitbitzAdmin What belongs to your tenant rather than to a room — its keys, the origins your pages run on, your users and what they cost, deleting rooms — is done from your server with `WitbitzAdmin`, never from a page. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-server-sdk-witbitzadmin). --- ## Set it up {#builder-calls} Most of the SDK is a member's work and needs no key: a room proves itself by its key, so the room endpoint asks for nothing else. A few calls are the **builder's** — your OAuth apps, the hosts your users may attach a key for, the origins your pages run on, deleting rooms, billing — and those belong to your *tenant*, proved by your **tenant secret key** (`wsk_…`). They live in their own module, for your server: a page holding the key has given it away, and a page that imports only `witbitz-sdk.js` cannot hold it by accident. Download it once, as one file, beside your server's code (Node cannot import from a URL): ```sh curl -O https://witbitz-spaces.pages.dev/sdk/3.10.0/witbitz-admin.mjs # a released version; Node 18+, no packages ``` ```js import { WitbitzAdmin } from './witbitz-admin.mjs' const admin = new WitbitzAdmin({ tenant: 't_…', tenantKey: process.env.WITBITZ_TENANT_KEY }) const { ok, tenant, reason } = await admin.verifyTenantKey() // is this key good, and whose is it? ``` - **Two endpoints.** `WitbitzAdmin` sends its calls to the authenticated endpoint (`/v1/tenant-space`) and the page SDK sends member calls to the keyless one (`/v1/space`). The first is derived from the second, so a staging or self-hosted deployment needs no configuration; `new WitbitzAdmin({ endpoint, tenantEndpoint })` overrides both. - **`403 tenant_required`? Read the `reason`.** It tells apart two faults that look the same from outside: a request that reached the **keyless** endpoint, where your key was never examined — a routing mistake, not a bad secret — and one that authenticated but carried a credential naming no tenant (a session or a publishable key instead of a `wsk_`). `verifyTenantKey()` settles which you have. - **Older names.** The page client's builder calls (`wb.origins`, `wb.users`, `wb.billing`, …, with `tenantKey` on `new Witbitz`) still reach the same calls; a page that passes `tenantKey` gets a console warning. `admin.customers` is what `wb.users` was: *your app's* billed users, not Witbitz accounts. ## Restricted keys {#restricted-keys} Your secret key can do everything, including deleting every room and the tenant itself. A server that needs only part of that — an account server that creates customers and mints their tokens — should hold a **restricted key** (`wrk_…`) with only the capabilities you choose. Mint one with your secret key: ```js const { key, keyId } = await admin.keys.create({ type: 'restricted', label: 'account server', scopes: ['customers:write', 'customers:tokens'], maxCustomerCapUsd: 50, // optional: the highest monthly cap this key may give a customer expiresAt: '2027-10-01', // optional: refused from then on; at most two years ahead }) const accounts = new WitbitzAdmin({ tenant: 't_…', tenantKey: key }) // the same client, fewer powers ``` `key` is shown once: store it as that server's secret, and keep the `wsk_` key offline. | Capability | What it opens | |---|---| | `customers:read` | `admin.customers.list()` | | `customers:write` | `customers.create`, `customers.update`, `customers.revoke` | | `customers:tokens` | `customers.token(userId)` — a customer's turn token | | `customers:erase` | `customers.erase({ email })` — deletes the rooms that person created | | `rooms:read` · `rooms:write` · `rooms:delete` | your room counts · refreshing and restoring rooms · deleting one room | | `settings:read` · `settings:write` | your tenant record · the webhook URL, `requireUserToken`, `visitorLimit`, your app's name, provider keys | | `origins:write` | `admin.origins.declare` — which pages act as your app | | `integrations:write` | `admin.providers` and `admin.hosts` | | `billing:read` · `wallets:grant` | `admin.billing` · granting credit | | `apps:release` | releasing your app through `/v1/apps` (`witbitz app release`): reserve, submit and read a release — not claiming a new app id | - **Never grantable:** managing keys (minting, listing, revoking), deleting the tenant, deleting every room. - A restricted key cannot make itself or another key more powerful. To change what a key may do, mint another and revoke the old one (`admin.keys.revoke(keyId)`); `admin.keys.list()` lists them. - Any key may call `verifyTenantKey()`. A call the key cannot make answers `403`, naming the capability it lacks. A cap above the key's ceiling answers `403 cap_above_ceiling`, and a customer created with no cap gets the default lowered to the ceiling. ## Signing your tenant's requests {#tenant-signing} Your tenant key proves who you are to Witbitz's gateway, and the Sentinel — the enclave that holds your OAuth apps' secrets, your users' connections and their erase lists — would otherwise take the gateway's word for it. Give your server a **signing key** and the Sentinel checks you itself: every request your server sends it is signed, and once the key is registered, an unsigned or altered request for your tenant is refused, whoever sends it, Witbitz's own servers included. ```js const { privateKey, publicKey } = await WitbitzAdmin.generateSigningKey() // once; keep privateKey with your tenant key const admin = new WitbitzAdmin({ tenant: 't_…', tenantKey: process.env.WITBITZ_TENANT_KEY, signingKey: privateKey }) await admin.signing.register() // from now on, every Sentinel request of this tenant must be signed ``` - **From then on,** pass `signingKey` wherever you make a `WitbitzAdmin`; nothing else changes. - **A new key only with the current one.** The first key is registered on your tenant key alone; a new one only by a request signed with the current key (`new WitbitzAdmin({ …, signingKey: oldKey }).signing.register(newPublicKey)`). So a stolen tenant key alone can neither act in the Sentinel nor swap your signing key. - **Your pages, signed too.** With a signing key, `admin.origins.declare([...])` also files the same list, signed, with the Sentinel (or call `admin.signing.publishOrigins()`). From then on a page that *names your tenant* there — a person connecting through your OAuth apps, a device filing a person's erase list for you — must be on one of those origins. The Sentinel reads the page's origin from the person's own sign-in, so another app's page cannot spend your OAuth apps or file lists for you. If Witbitz's own app should be able to name your tenant, list one of its origins. - **Watch the record.** The Sentinel keeps your registered key in its sealed store on a Witbitz host. No one can forge or change that record, but someone with access to the host could **delete** it, and a tenant with no key registered is again taken on the gateway's word. `await admin.signing.status()` → `{ registered, key, origins }`: alert if it ever reads `registered: false` after you registered. - **Lost your signing key?** Write to us: we remove the record, with your tenant key and proof of one of your origins, and you register a new one. ## Your page origins {#origins} The origins your pages run on act for your tenant: a room created from one of them is yours, and your rooms can be read and embedded from them. A page on an origin you have not declared gets `403 tenant_required` when it creates a room. ```js await admin.origins.declare([ 'https://app.example.com', 'https://test.example.com', 'https://old-box.example.ts.net', // declaring replaces the list: keep every origin you still use ]) // → { origins, unproven } await admin.origins.list() ``` - **Compared by exact string** against the `Origin` header the browser sends, so the server stores that exact form: `https://host` or `https://host:port`. It lower-cases and drops a trailing slash for you, and refuses anything else — `http://`, a path, a wildcard, `localhost`, an IP — naming the entry, rather than store something that would admit nobody. At most 20. A change takes effect within about a minute. - **Over HTTP** it is `PUT /v1/tenant/webhooks` with `{ "origins": [...] }` and your key; `GET /v1/tenant` reads it back. - **Nothing else of ours is tied to your origin:** sign-in codes, connections, `admin.hosts`, the Siri inbox and the API's CORS work from any origin. What moves with you is on your side: your own OAuth app's redirect URI and JavaScript origins (and the `redirectUri` you registered with `admin.providers`). ### Prove the origin is yours {#prove-origin} Declaring an origin is enough for your pages to work. For your app's **name** to appear in sign-in mails sent from it, the origin's host must say it belongs to your tenant, one of two ways: - a file at `https:///.well-known/witbitz-tenant.txt` with a line holding your tenant id (`t_…`), or - a DNS TXT record `_witbitz.` with the value `witbitz-tenant=t_…`. We check when you declare: `declare` answers each origin it could not prove in `unproven`, with the exact file or record to add — add it and declare the same list again. `GET /v1/tenant` lists the proven ones in `origins_proven`. Without this, anyone could declare a site that is not theirs and have Witbitz mail its users a code in that site's name. ## Your app's name {#app-name} A sign-in code mailed from one of your declared and [proven](#prove-origin) origins comes from **" via Witbitz"**, with the subject "Your code: 123456", and names the site that asked — so your users find it when they look for your app. ```js await admin.app.rename('Keepwarden') // → { name: 'Keepwarden' } await admin.app.name() // → 'Keepwarden' ``` - 1–40 characters: letters, digits, spaces and `. & ' + -`. A name containing "Witbitz" is refused. - A rename reaches the mail within about 15 minutes. - A page on an origin you have not declared or proven, or a sign-in that names no tenant, gets the plain Witbitz mail. The page sends your tenant when you make its client with `new Witbitz({ tenant })`. ## Hosts and OAuth apps {#integrations} ```js await admin.hosts.declare(['api.example.com']) // the hosts your users may attach a key for; replaces the list await admin.hosts.list() await admin.providers.attach({ kind: 'google', clientId, clientSecret, redirectUri, scopes, label }) // → { id, kind, tenant } await admin.providers.attachMcp({ issuer, clientId, clientSecret, redirectUris, label }) await admin.providers.list() await admin.providers.revoke(id) // → { revoked, connections } ``` The client secret of an OAuth app you attach is sealed to the Sentinel. Your users then connect through it from the page — [connections](./sdk-accounts.md#account). `attachMcp` files an OAuth app for an MCP server that does not let apps register themselves (GitHub, Asana, Box). ## Billing, per user {#user-billing} Your rooms' model use is billed to **you**, in the provider's own numbers per key — never our token arithmetic. To see, cap and pass on what each of *your* users costs, give each one their own key: their turns then bill to it, and what they cost is what the provider reports for that key. ```js // on your server, once per user const user = await admin.customers.create({ userRef: 'cust-42', capMonthlyUsd: 10 }) // → { userId: 'u_…', … } // on your server, when that user signs in, and again before it expires const { token, expiresAt } = await admin.customers.token(user.userId, { ttlSec: 900 }) ``` ```js // in their page const wb = new Witbitz({ tenant: 't_…', userToken: token }) wb.useUserToken(async () => (await fetch('/api/witbitz-token')).text()) // or a function that fetches a fresh one ``` - **Each user key has a hard monthly cap** (`capMonthlyUsd`, default 25). When it is spent, that user's turns are refused by the provider until the month resets; nobody else is affected. `userRef` is *your* label, shown to you in billing and never sent to the provider, which sees an opaque key name. - **The token** is signed by Witbitz and lasts `ttlSec` (60–3600 s, default 900). It goes only with operations that can spend — a turn, a title, approving an action, an import — so a page left open past its expiry can still read its rooms. - **A token that does not hold is refused, never billed to you:** `401 user_token_invalid` (bad or expired — mint a new one), `402 user_key_unavailable` (revoked). Without a token, a turn bills to your tenant key. - **The person prompting pays, in any room.** A token bills its user's key wherever that user prompts — your rooms, another tenant's, or a room with no tenant. - **Managing them:** `admin.customers.list()` (nothing secret in it); `update(userId, { capMonthlyUsd: 40 })` for a plan change — the provider's limit moves first, and the user, their key and their history stay; `revoke(userId)` deletes the key, and spend up to that moment is still billed. A revoked user cannot be changed. - **Requiring the token.** Without one, a spending operation bills to *your* tenant key — so on a public page anyone could skip your per-user caps by sending none. Turn that off once, from your server: `await admin.billing.requireUserToken(true)`. From then on a turn, title, approval or import in **your rooms** that arrives without a user token is refused (`401 user_token_required`) instead of billed to you; reads never need one. It takes effect within about a minute, and `requireUserToken(false)` undoes it. If Witbitz cannot read this setting at that moment and has no recent answer, a spending operation in your rooms is refused (`503 billing_policy_unavailable`) rather than billed to you on a guess — retry shortly. - **Confidential rooms bill the same way.** The token travels sealed to the attested enclave, which checks it and fetches that user's key, also sealed, so no key is usable outside the enclave. ### People who are not signed in {#visitor-limits} A public page — a help desk, a demo, a support chat — has visitors with no account and so no user token. Their questions bill to your tenant key. To keep that bounded, set how many questions a day they may ask: ```js await admin.billing.visitorLimit({ perNetwork: 20, total: 2000 }) // null turns it off ``` - **What is counted:** a question (a turn) in **your rooms** that carries no user token, when the assistant will answer it. A message the assistant does not answer (an [on-demand](./sdk-assistant.md#reply-mode) room, not addressed to it) is not counted, and a question resent while the room is busy counts once. A question with a user token is that user's, under their own cap. - **Per network, per UTC day:** at most `perNetwork` questions from one network (an IPv4 address, or an IPv6 /64), and at most `total` from all of them. The address itself is never stored or sent: the Space's front door signs a daily hash of it, salted with your tenant, so it cannot be matched across apps. - **Over a limit**, the question is refused before anything runs or is stored: `send()` answers `{ ok: false, status: 429, error: 'visitor_limit', scope: 'network' | 'total', reason }` — `reason` is a sentence to show the visitor. If the count cannot be had, the question is refused (`503 visitor_count_unavailable`), never answered for free; retry shortly. - **Both tiers**, standard and [confidential](./sdk-keys.md): in a confidential room the count is asked for by the attested enclave, at the moment it knows the question bills to you. - Takes effect within about a minute. Over HTTP: `PUT /v1/tenant/webhooks` `{ "visitorLimit": { "perNetwork": 20, "total": 2000 } }` with your secret key (or a restricted key with `settings:write`). What you are billed, from your server — the same numbers as the tenant portal: ```js const b = await admin.billing.get() // { month, days, users, userDays, storage, rate, … }, in microdollars const csv = await admin.billing.csv({ byUser: true }) // one line per user per day; without it, one line per day ``` `month` is your own key, live, plus your users' keys through yesterday; `users` lists each with its cap and this month's spend. Over HTTP: `POST|GET /v1/tenant/users`, `PATCH /v1/tenant/users/{id}` `{ "capMonthlyUsd": … }`, `DELETE /v1/tenant/users/{id}`, `POST /v1/tenant/users/{id}/token` `{ "ttlSec": … }`, `PUT /v1/tenant/webhooks` `{ "requireUserToken": true }`, and `GET /v1/billing` (`?format=csv`, `&by=user`), each with your secret key. ### Knowing who is signed in, on your server {#verify-sign-in} Before your server mints a user token, it should know the caller really is the Witbitz account it claims. A signed-in page holds `wb.session` = `{ email, idToken, exp, sign }`, and `idToken` is a standard JWT your server can verify on its own: - `alg: RS256`. The key is in the issuer's JWKS — for the SDK's default issuer, `iss: "https://witbitz-spaces.pages.dev"`, at `https://witbitz-spaces.pages.dev/api/email/jwks` (select by `kid`; cache it, and fetch again on an unknown `kid`). - Check `aud === "witbitz-email"`, `exp` in the future and `email_verified === true`; the account is `email`. - **The token lasts 30 days, and every Witbitz app shares its audience,** so a token alone proves the account but not that *this* page is presenting it. Make the page prove it holds the session's key: your server sends a fresh random challenge; the page signs it with `wb.session.sign.priv` (ECDSA P-256, SHA-256) and returns `{ idToken, spk: wb.session.sign.pub, sig }`. Your server checks the signature against `spk`, **and** that the token's `nonce` equals standard base64 (with padding) of SHA-256 over the canonical JSON of `spk` exactly as the page sent it — keys sorted at every level, no spaces, such as `{"crv":"P-256","kty":"EC","x":"…","y":"…"}` — so the token is bound to that key. A copied token cannot pass without the key. ## Deleting a room {#delete} When a person deletes their data, delete their rooms from your server. You choose how much to keep, and for how long: ```js await admin.rooms.delete('sp-…') // retain: 'none' — everything, now await admin.rooms.delete('sp-…', { retain: 'backups' }) // now, except old versions for 35 days await admin.rooms.delete('sp-…', { retain: 'grace', graceDays: 30 }) // nothing yet; undo until the window ends await admin.rooms.restore('sp-…') // → { restored: true, room } ``` | `retain` | The room | Its data | Undo | |---|---|---|---| | `'none'` (default) | gone at once | every stored version deleted now: sealed messages, files, the confidential tier's copies | none | | `'backups'` | gone at once | current copies deleted now; old versions expire within 35 days | only by Witbitz operators, by hand, until then | | `'grace'` | gone at once | nothing deleted until the window ends (`graceDays`, 1–90, default 30), then purged as `after` says (`'none'` by default, or `'backups'`) | `admin.rooms.restore(room)` until the window ends | "Gone at once" holds in every mode: from the moment you call, the room answers `410 deleted` to everyone, a member's open device included, and no one can make a room with the same id again. A member's `space.send` returns `{ status: 410, error: 'deleted' }`. - **Only for a room no one else uses.** A shared room belongs to its other members too. Our server cannot tell a room with one person from a shared one, because membership is sealed, so that decision is yours. When a person leaves a shared room, forget it on their side instead. - **What it answers:** `{ deleted: true, room, retain, versions, bytes, turnWasRunning }` (plus `backupsUntil` with `'backups'`), or `{ deleted: false, held: true, room, purgeAt, after }` with `'grace'`. `restore` answers `purged` or `grace_ended` once it is too late. - **`turnWasRunning: true`** means a reply was being written at that moment and may land a few seconds later. Call `delete` again after a few minutes; it is idempotent and sweeps up whatever arrived. - It **throws** if anything could not be removed (`partial`). Call it again; it is never reported done when it is not. - A held room can be purged early: call `delete` again with `'none'` or `'backups'`. - **What no option deletes on request:** our database keeps a 35-day point-in-time recovery of room **records**, which hold no messages, only encrypted settings, and expire by themselves. The model providers that answered a room's turns keep their own logs under their own terms. - **Over HTTP:** `DELETE /v1/tenant/rooms/{room}` with your key and `{ "confirm": "", "retain": …, "graceDays": …, "after": … }`; `POST /v1/tenant/rooms/{room}/restore` to undo. `DELETE /v1/tenant/rooms` (with `{ "confirm": "" }` and the same options) does it for every room you own. ### Your rooms, and the idle rule {#room-list} `admin.rooms.list()` returns your rooms a page at a time — to find a room whose link was lost, or to audit what you hold: ```js let cursor do { const { rooms, next } = await admin.rooms.list({ cursor }) // rooms: [{ room, createdAt, lastUsedAt, kind, split, gated, held }] cursor = next } while (cursor) ``` `createdAt` and `lastUsedAt` are ISO times or `null`. `lastUsedAt` is when a member last opened the room, wrote to it or prompted in it (recorded since 2026-10-07; your own `rooms/touch` does not count). `held` is `{ purgeAt }` for a room waiting to be deleted. Rooms made before creation times were recorded show `createdAt: null`. **The idle rule** deletes rooms nobody uses, on a schedule you set: ```js await admin.rooms.idleDeletion(180) // rooms no member used for 180 days: held, then deleted await admin.rooms.idleDeletion(null) // off ``` Once a day, each room past the limit is put on a **30-day hold** — it answers `410` at once, and `admin.rooms.restore(room)` brings it back until the hold ends — and is then deleted (`retain: 'grace'` above). The limit is 30 to 3,650 days. A room with no recorded use counts from 2026-10-07, so turning the rule on deletes nothing that day. At most 100 of your rooms are held a day. Like `delete`, it cannot know who else uses a room — but a room past the limit is one *nobody* has opened. ## Erasing a user who has left {#user-erasure} When someone deletes their account in your app, you want their rooms gone too. You cannot simply delete "their rooms" from your server: you have no list, membership is sealed, and a room they shared belongs to the others in it. Erasure works from a list the person's own devices filed while they used your app, so neither you nor Witbitz holds it before: ```js // on your server, with your tenant key — after the person asked to be erased const r = await admin.customers.erase({ email: 'dana@example.com' }) // → { filed, rooms, deleted, shared, notTheirs, failed, cleared, incomplete? } if (r.failed > 0 || r.incomplete) { /* run it again later; what was deleted stays deleted */ } await admin.customers.revoke(userId) // their billing key, if they had one ``` - **What is deleted:** the rooms that person created in your app and nobody else used — every message, file and stored version, as [`admin.rooms.delete`](#delete) with `retain: 'none'`. - **What stays:** a room they created that **someone else used** is refused as `shared`, because what they wrote there is part of the others' conversation; a room they only **joined** is counted as `notTheirs`, because its creator decides. **How it holds.** Three things in the page make it safe, and `wb.erasure` does all three for you: 1. **A deletion commitment.** Every room the SDK creates records a hash of a delete secret, derived from the account's [backup](./sdk-accounts.md#backup) master secret and the room id — so only that account can produce it, on any of its devices. A person who joined holds the room key but not the secret, so cannot delete the room. 2. **A membership mark.** Every room the SDK opens is marked with an opaque tag for the account. A room that carries anyone else's mark is refused as `shared`, even when its creator asks. 3. **The filed list.** A few seconds after a room is created or opened, the account's list of rooms and delete secrets is sealed to Witbitz's attested enclave under your tenant and the person's email, each filing replacing the last. It is opened only when your server calls `erase`, and cleared once nothing failed. **What the page needs:** `new Witbitz({ tenant })`, a signed-in person ([`wb.signIn`](./sdk-people.md#sign-in)) and an enabled [`wb.backup`](./sdk-accounts.md#backup) — the backup's master secret is what every device of the account derives the same delete secrets from, and the enclave keeps one list per account. ```js await wb.erasure.file() // file now, e.g. right after a sign-in or a restore (it also runs by itself) await wb.erasure.status() // → { filed, rooms, updated } wb.erasure.available // true when this page can file: a tenant, a sign-in and a backup ``` - `filed: false, rooms: 0` from `erase` means no device of that person ever filed: nothing in the page met the three conditions above. - A room created before October 2026, or while no backup was on, has no commitment. `erase` cannot delete it; [`admin.rooms.delete(room)`](#delete) can, if you know the room is theirs alone. - To clear out rooms nobody opens, rather than one person's, see [the idle rule](#room-list).