# People — sign-in, who may enter, who runs the room A room is open to anyone with its link, or **gated**: only signed-in people on its list may read or write it. This page is signing people in, gating a room, its admins and owners, direct chats, and changing who is in it. Part of [the SDK](./sdk.md) · every call: [the reference](./sdk-reference.md#the-surface). --- ## Sign in {#sign-in} Your app needs no accounts of its own. The SDK signs people in the way the Spaces app's *Email me a code* does: a six-digit code to any address, from any origin. ```js const wb = new Witbitz() const { ticket } = await wb.signIn({ email }) // a code is emailed const session = await wb.signIn({ email, code }) // → { email, idToken, exp, sign } localStorage.setItem('wb:session', JSON.stringify(session)) const renewed = await wb.refreshSession() // before session.exp, with no new code ``` - **Keep the session.** Pass it back as `new Witbitz({ session })`, or `wb.useSession(session)` on a running client (`null` drops it). `wb.signOut()` forgets it. - **Renew it before it expires** (`session.exp`) with `refreshSession()`, the renewal the Spaces app runs. It throws `refresh_failed` when the person has to sign in again, and `no_session` when there is nothing to renew. - **A sign-in on your site is your app's, not the person's whole Witbitz account.** The token names the site that asked for it. Connections, the Siri inbox, outside agents and speech keys made on your page live under an account of your app's own; the person's own Witbitz account — their connections elsewhere, their @username, the account itself — cannot be changed from your page. It still proves the email, so room gates work as for any sign-in. This is the builder's half of the [double blind](./trust-model.md#the-double-blind): your page cannot spend or erase what the person did in other apps. - **Your server can check a sign-in** on its own: [knowing who is signed in](./sdk-builder.md#verify-sign-in). ## Gated rooms {#secure-rooms} A room's gate is chosen when it is made, with `wb.createSpace` or `space.privateLane`; a room with none is open to anyone holding its link. ```js const space = await wb.createSpace({ agent: { name: 'Warden' }, admission: { emails: ['dana@example.com'], domains: ['keepwarden.com'] }, }) ``` - **Every operation is gated,** reads included. The SDK sends the signed-in identity with each request, from the session `wb.signIn()` returned — so sign in before opening a gated room, or its reads come back refused. - **The list is sealed** with the rest of the room's settings: the platform can tell *that* a room is gated, never *who* is on its list. - **A list takes emails and domains** (`acme.com`). The Spaces app's own form also admits by @username — see [usernames, discovery, and who is in the room](./identity-and-admission.md#usernames). - A list that admits nobody is refused at the call: it would lock you out of your own room. ### Reading the gate {#reading-the-gate} A member holds the room key, so your app can read the gate the server enforces instead of keeping a list of its own: ```js await space.admission() // → { gated: true, emails: ['lead@keepwarden.com'], domains: ['keepwarden.com'], hidden: 3, // admins: ['lead@keepwarden.com'], owners: [] } await space.admits('dana@example.com') // → true / false, against the sealed list ``` - **Addresses are hidden by default.** Each is stored as a hash salted with the room id, so a member can *check* an address but not *read* the list; `hidden` counts them. To show some to members, list them in `advertise` when you make the room (`admission: { emails, advertise: ['lead@keepwarden.com'] }`), or pass `true` for all. - **"Hidden" hides the list, not the people.** A member cannot enumerate who was invited, but a signed-in member's messages carry their @username (unless the room set `usernames: false`), their rows carry their email, and the room's member list shows who has signed in. - **The link alone reads nothing.** A gated room's sealed settings — the list, the admins, the connection grants — are served only to a signed-in member on the list; anyone else learns only that the room is gated. - A member admitted only by @username cannot be checked on the device: that match needs the server's key. - **Errors:** `config_unavailable` when the settings cannot be read (the SDK refuses to guess), and `sign_in_required` before the member signs in. ### Who did what {#who-did-what} In a gated room every write needs an allowed sign-in, and the server marks what a signed-in member wrote: | On | The server sets | |---|---| | a message | `verified`; `user`, their @username (absent when the room set `usernames: false`); `spk`, the key of the device that sent it | | a row | `_verified`; `_email`, their verified email; `_user`, their @username (absent when the room set `usernames: false`) | A message does not carry the email, so its author's address is not readable by every member. `from` and a row's `_by` stay the display name the writer chose; `lane.approve(id, { from })` sets the one a lane's message shows. - **A lane's message into the room is verified** only when the member who approved it signed in — to the room it lands in or, when that room is open, to the gated lane it came from. A message into an open room in another region carries no mark: that region never sees the lane's sign-in. - **A row's own fields cannot pose as these marks.** `id` and every name starting with `_` are the room's: `data.put` refuses them (`reserved_field`), and a row is always read without them. - **Copied history keeps its old marks apart.** Rows and messages an [older client's move](#fork) carried keep the marks they had as `carried` (`{ from, verified, user? }`), never as this room's own `verified`, which was not earned here, and name who carried them (`carriedBy` on a message, `_carriedBy` on a row). A lane's mirror copies messages only (rows as lines, with `mirror: { data: true }`), with no marks. ## Admins {#admins} A gated room can name **admins**, and the room itself — not your app's interface — enforces what only they may do: ```js const team = await wb.createSpace({ admission: { emails: ['lead@keepwarden.com', 'dana@example.com'], admins: ['lead@keepwarden.com'] }, data: { tasks: { fields: { title: 'text', done: 'boolean' } }, // every member budget: { fields: { item: 'text', amount: 'number' }, write: 'admins' }, // admins only }, }) await team.isAdmin('dana@example.com') // → false ``` In a room with admins, the room refuses a member who is not one (`not_admin`) when they: - change the assistant ([`configure`](./sdk-assistant.md#changing-the-assistant)); - write a collection declared `write: 'admins'`; - approve or deny a card that would do something they may not do themselves — an admin-only row, or a connection call, which spends the room's connected account. The card stays pending for an admin. Posting, reading, the other collections and a member's own lane stay open to every member. - Admins must be admitted by the gate: an address in `emails`, or one in a listed domain. - They are sealed in the room's settings, so members can see who they are (`isAdmin`, `admission().admins`) and the platform cannot. - They are set when the room is made and changed in place with [`changeMembers()`](#members). - A collection declared `write: 'admins'` in a room with no admins or owners is refused when declared (`admins_required`): nobody could be held to it. ### Owners {#owners} Admins are equal, and any admin can change the room's members — so on its own, any admin could change the list to one without the other admins, or without the person who made the room. **Owners** are a level above, enforced by the room too: ```js const team = await wb.createSpace({ admission: { emails: ['founder@keepwarden.com', 'lead@keepwarden.com', 'dana@example.com'], owners: ['founder@keepwarden.com'], admins: ['lead@keepwarden.com'] }, }) ``` - **An owner is an admin:** whatever an admin may do, an owner may. - **Only an owner** can change the owners, or lower or remove an admin. Admins still add and remove members and raise members to admin. - The server refuses a member change by someone who is not an owner that breaks this rule (`not_owner`); the SDK checks before it changes anything. - `space.isOwner(email)`, `admission().owners`. Owners must be admitted by the gate, like admins. ## Direct chats {#direct-chats} A **direct chat** is the Spaces app's one-to-one chat: a gated room for exactly you and one other person. Each of you sees it under the other's name, nobody renames it, and the assistant is in it but answers only when @-mentioned. ```js const chat = await wb.createSpace({ agent: { name: 'Helper' }, direct: 'sam@example.com' }) // needs wb.signIn // give Sam chat.shareLink; Sam opens it signed in as sam@example.com await chat.directPeer() // → { email: 'sam@example.com' } (on Sam's side: you) ``` - `direct` takes the other person's email in place of `admission`: the room admits exactly the two of you (both addresses visible to members), you are its owner, and `direct: true` is sealed in its settings. - `directPeer()` reads the other person from the room's sealed list — never from an invite or a link — so it is the name to show. It is `null` for any other room. - You can make several direct chats with the same person. A room for two that you give a name is an ordinary gated room, made with `admission`. - **Refusals:** no sign-in (`sign_in_required`); `direct` together with `admission`, or with your own address (`invalid_argument`); `rename()` (`direct_chat`). ## Changing members in place {#members} A gated room changes who is in it **without becoming a new room**: the same id, link, history, store and lanes, and, from that moment, a **new key** that only the people still on the list receive. ```js const r = await team.changeMembers({ emails: ['lead@keepwarden.com', 'dana@example.com'], admins: ['lead@keepwarden.com'] }) // r → { inPlace: true, epoch, link, removed: ['eve@example.com'], added: [], unreachable: [] } await team.rotateKey() // the same members, a new key — for a lost phone, or a link sent to the wrong place team.on('rekeyed', ({ epoch, link }) => saveLink(link)) team.on('removed', () => showGone()) team.on('pending', ({ reason }) => askForTheLink()) ``` `changeMembers` takes the whole new list. Every member's open copy of the room takes the new key by itself, on its next request: `'rekeyed'` gives the new `link` (`null` in a vault), `'removed'` means this account is no longer on the list, and `'pending'` means it is on the list but has no key this device can open yet. - **How the key reaches each member.** When a person signs in, their account publishes an **invite key for your app** (`wb.invite`) in Witbitz's directory, under your app's own scope — the origin your page signs in from; no other app can write there. A change seals the room's new key to each remaining member's invite key. Their devices fetch it when the old key is refused (`stale_key`), check it against the room's commitment, and carry on. - **Keep `wb.invite.state` like the session.** It holds a private key: persist it and pass it back as `new Witbitz({ invite })`. With the [account backup](./sdk-accounts.md#backup) on, it is kept there too, so a restored device follows. Signing in publishes the key by itself; `await wb.invite.enroll()` publishes it now and answers the status. - **A second device of the same account** that has neither the state nor the backup never takes the published key over — that would cut the first device off. `wb.invite.status` reads `held_elsewhere`, and its rooms say `pending` with `reason: 'no_invite_key'`, until you `wb.invite.restore(state)`. In [vault mode](./sdk-keys.md#vault-mode) the vault keeps and publishes the key: `status` reads `vault`, and `restore` is refused (`vault_unsupported`). - **`unreachable`** lists members with no invite key for your app — they never signed in to it, or only to another app, which has its own scope. Their devices are told `pending`; send them `link`. - **Who may change it, or rotate its key:** a signed-in owner or admin (`not_admin` otherwise), within [the owners' rules](#owners). A rotation goes to everyone still on the list. A room you make names you its owner when you are on its list. A room with no owners or admins is changed by its creator, in the Spaces app. - **A removed member** keeps what they had read, and reads nothing written after: the room refuses their old key. - **Only the roles changed** (the same people, new admins or owners): the room keeps its key and nobody's key changes; the answer says `roles: true`. - **Members added by @username** in the Spaces app are on the list as a tag that no email names. A change keeps them; removing a person drops their @username entry too, once they have signed in to the room. - **A room without a gate** whose members the Spaces app changed seals its new key to the app's own member keys, which the SDK does not hold: its rooms say `pending` with `reason: 'link_changed'`. Ask for the link again. - [`plan()`](./sdk-reference.md#the-surface) answers `in_place` for a new list, with `call: 'space.changeMembers(admission)'`. A lane is one member's own room: `changeMembers` on a lane throws `lane`. - **In a vault,** `changeMembers` and `rotateKey` go through the vault, which asks the person in its own screen and makes and keeps the new key; the page sees neither key and gets no `link`. ## @usernames {#usernames} ```js await wb.usernames.claim('dana', { publish: true, name: 'Dana' }) // → { username, published } await wb.usernames.mine() // → { username, published }; throws when signed out await wb.usernames.resolve('dana') // → { username, exists }; no sign-in needed await wb.usernames.release() ``` One @username means one person across all of Witbitz, not per app. Verified messages and rows carry it (`user`, `_user`), and the Spaces app can admit a person to a room by it; the SDK's `admission` takes emails and domains. - `claim` refuses a name that is taken (`taken`) or was released in the last 30 days (`held`). - `publish: true` also lists the person in the public directory, under the display `name` when you pass one. It is off by default. - Releasing a name does not change what the person already wrote. ## Rooms an older client moved {#fork} A room changes its members only in place ([above](#members)). `space.fork()` makes nothing and throws `use_change_members`, and `plan()` refuses a change that names a `history` with the same code. For a fresh start, make a new room with `wb.createSpace({ admission })`; nothing is carried into it. An older client (a 2.2.x page) could still **move** a room to a new one, and the SDK follows such a move: ```js team.on('moved', ({ room, link }) => switchTo(wb.openSpace(link))) team.on('moveRefused', ({ reason }) => showWhy(reason)) ``` - `'moved'` fires only for a notice the server stamped and sealed to this device's key: in a room with admins, an admin's; in a room with owners, one whose new room keeps exactly the owners and admins it declares. Anything else is `'moveRefused'`, with the reason. - A confidential move is taken into the enclave before its settings are read. - History such a move carried keeps its marks as `carried` on a message and `_carried` on a row: `{ from, verified, user? / email? }`, the server's marks from the room it was first written in, plus `carriedBy`. - **In a vault,** the vault reads the notice from the room itself and follows it by the same rules; the page gets `'moved'` with `{ room, link: null }` — open it with `wb.vault.openRoom(room)`. Following a confidential room's move is refused there (`vault_unsupported`).