Identity and admission
Identity in Witbitz is not "there is an account in our database." A Space is self-authenticated: its link carries the public material needed to verify who belongs, and members hold the corresponding private credentials. The same definition works with no server at all (true peer-to-peer) and with a server that verifies but cannot read. It is inherited from an open, proven real-time engine — where it secures live P2P calls — and carried, mechanism for mechanism, to asynchronous, end-to-end-encrypted Spaces.
This page is the whole subject: the quick reader model, then the definition and its primitive, the credential families, how membership is proved, the owner rule (who decides admission and at what tier), and — the part usually skipped — removal, rotation and recovery, marked precisely shipped vs designed. For a privacy product, overclaiming the lifecycle is worse than admitting a gap.
See also: the double blind (why the platform can't read the room) and Verify it yourself (reproduce the gate in your terminal).
The quick model
Base membership: hold the room key. Every Space has a room key mk. The client mints it and stores only a
commitment on the server — commit(mk) = base64url(sha256(mk)). When a member acts, the render checks the presented key
against the stored commitment: a right key proves the caller holds the link; a wrong key opens nothing. This proves a
member is present — not which member.
Per-member identity: signed entries. To attribute entries to a specific person, each member holds a signing key. The room's public invite key verifies the member grant; the member's public signing key verifies the entry. Because the author is inside the signed core, a rename or forged attribution fails verification.
Admission families. A room composes any of these verifiers (e.g. a human gate and an agent allow-list):
| Family | What it proves |
|---|---|
| Key possession | The caller holds the Space key. |
| Signed invite | A creator or room authority granted this member access. |
| OIDC / Google | The member authenticated as an allow-listed identity. |
| Agent key | This agent key is allowed by the room. |
| Capability grant | This participant may perceive or act in specific ways. |
| Owner policy | The app owner mandated admission tiers for the room (the owner rule). |
Gated Spaces. On an email-gated Space the link alone is not enough: reads and writes both require an allow-listed
sign-in — which is why the verification page starts with token-less poll/turn requests that return
403. The allow-list lives in the sealed config, so the platform never reads the membership list.
Live vs designed, at a glance:
| Piece | Status |
|---|---|
| Key-possession membership | Shipped |
| Solo signed identity | Live and verified |
| Email-gated reads and writes | Live and checkable |
| Agent-key admission · scoped agent reads | Deployed |
| 2-of-2 per-member signed path · distinct invite links | Not yet |
| Crypto-strong single-Space read revocation by epoch re-key | Implemented (fork-based re-key; built, wired, tested) |
The rest of this page is the mechanism behind that summary.
The definition
A verified room has no account server and no stored access-control list. Instead:
The link carries a verifier, never a secret. A member holds a credential. Anyone can check credential-against-verifier using only the link's public material — so trust is rooted in the link, not in any server.
Three consequences fall out, and together they are the whole model:
- The link is a capability. Public verifier material — a room-invite public key, a creator public key, a name list, an OAuth client id — rides the link openly. Secrets — the room key, a member's private signing key — ride the URL fragment, which a browser never transmits. Witbitz adds one thing to the live-call link: the fragment also carries the decryption key for the room's content, so a Witbitz link literally holds everything.
- Verification is cryptographic, not a lookup. "Is this a member?" is answered by verifying a signature against a public key from the link — never by asking a database who someone is.
- The verifier can be anyone. Because the check needs only public material, the party that runs it is a free choice — a peer, a reader, or a certified function — without changing the credential. This is the hinge the rest turns on.
The hard constraint. Nothing sensitive is persisted server-side. After a Space is created and its member links handed out, the link is the entire authority: it reconstructs the key, proves membership, and (optionally) signs entries. The server keeps only a commitment to the key and the sealed ledger — never the key, never plaintext.
Membership = key possession (mkCommit)
The base Space auth is possession of the room key mk, minted client-side and living in the link fragment — only
its commitment is ever stored (commit(mk) = base64url(sha256(mk)), agent/envelope.mjs). On every mutating op the
render checks commit(presented mk) === the Space's stored mkCommit:
- a right key → you hold the link → you are a member → the turn runs;
- a wrong key →
401 unauthorized, sealed ledger untouched (a wrong key also decrypts nothing); - first use (no commitment yet) → the server returns
commitOutso the deployment persists it, pinning the key model on first turn.
The key model (k-of-n) governs presence, not per-member identity: mk can be split so presence is required to
reconstruct it. 1-of-1 (solo) — mk rides one link fragment (#mk=…). 2-of-2 (couple) — a Shamir split where
each partner's link carries one share (#s=…) and mk exists only when both combine; the creator seals shareA under
an out-of-band code and registers it as the room's invite, the partner unseals with the code and combines.
The primitive
Every credential in the model is the same ECDSA P-256 token, and it is tiny (the live-call engine's invite token,
reimplemented byte-for-byte in agent/spaceEntrySig.mjs):
token = base64url(JSON.stringify(payload)) + "." + base64url( ECDSA-P256-SHA256 sig over the first part )- Bearer + unforgeable. Minting needs the private key; verifying needs only the public key. Forging one is forging ECDSA, not guessing a short code.
- Room-bound + expiring. Every payload carries
roomandexp, so a token cannot be replayed into another room or used forever. - The link holds only the public half, so any authority — even one that took over after the creator left — can verify without ever holding a private key.
signPayload / verifyPayload are the shared floor; invite grants, agent assertions and host commands are all this
token with a different payload and a wire tag, so they can never be confused for one another.
The credential families
A room can carry any of these verifiers; it composes them.
- Signed invites (
inviteToken.ts). The creator holds an invite keypair; each guest gets a grant{name, room, exp}; the link carries the invite public key (gk). A signed invite link, cryptographically — no server, no account. - The signed manifest (
roomManifest.ts). The committed roster{room, mode, exp, members?, domains?, agentKeys?}, signed by the creator key and verified against the creator public key (gm) in the link, so every peer checks a joiner against the same committed roster. Can be passphrase-encrypted so a mere link-holder can't read who is allowed. - Join-gate modes (
joinGate.ts).open·names·code(mailed secret, constant-time compared) ·email/google(OIDC) ·invite. All butemailare serverless. - OIDC + cert-binding (
oidcVerify.ts·oidcBinding.ts·identityCert.ts). "Sign in with Google," without a backend and without trusting the transport.oidcVerifyis hand-rolled RS256-only ID-token verification —alg:"none", HS* (algorithm-confusion) and ES* are rejected outright, the algorithm never taken from the token; the key is chosen bykidfrom the provider's JWKS, unknownkidfails closed.oidcBindingis the serverless heart: at sign-in the user sets the token'snonceto a hash of their WebRTC DTLS cert fingerprint, and every peer recomputes it from the cert it actually handshook with — so a token is valid only for the peer holding that cert's private key.identityCertpins one cert, private key never leaving the browser → non-transferable. Cert-binding composes with the emoji safety code (both read the same cert), so "this is really Emma" and "there is no man-in-the-middle" collapse into one guarantee. - Agent keys (
agentKey.ts). An agent's identity is an ECDSA keypair; to enter it signs a cert-bound assertion{k:"kbz-agent-key.v1", room, fp, iat}, admitted iff the key is on the room's committedagentKeysand the assertion is bound to this connection and fresh. The room stores only the public key. - Capabilities (
capabilities.ts). A per-participantGrant= what it may perceive (read-chat,read-roster,read-media,see-screen,hear-audio,receive-directed) and act (send-chat,speak,act).defaultGrant('agent')is least-privilege — an agent perceives the conversation but acts on nothing until granted. - Verified host (
hostKey.ts). Admin is bound to a password, not to who holds the room id: the link commits the host public key (gh) and the private key sealed under a host password (ghk). It survives a coordinator migration — every peer already holds the committed key from its own link.
Per-member identity in an async Space
Status — per-participant verified identity is LIVE (deployed + enforcement-proven in prod). Two admission paths sign every turn and the deployed
/spacerender verifies them, attributing each entry to a verified author (forged / unsigned / non-allowed →400 bad_attribution/403 not_authorized). Reads are gated too: every content op requires an allow-listed sign-in, so the link alone reveals nothing. Remaining: the couple (2-of-2) path is still unsigned, and distinct per-member invite links aren't built.
A live call attributes each message via the DTLS-bound connection; an async Space has no live connection, so each
ledger entry carries its own signature. A member holds an ECDSA signing keypair; their grant is the live-call invite
token — byte-identical to inviteToken.signPayload — with the superset payload { name, room, exp, spk } where spk
is the member's signing public key, signed by the room invite key and verified against the public gk. Each entry
is signed over a canonical core { room, author, ts, kind, id, text } — author is inside the signed core, so a rename
is unforgeable. The email/Google path additionally binds a fresh, externally-issued OIDC identity to the signing key
(nonce = spkNonce(spk)), so an entry proves "signed by the keyholder who authenticated as this account, now" — a
leaked link is then no longer enough to post.
One definition, three verifiers
The verifier can be anyone — which is what lets the same credential model span three runtimes. Only who runs the check and what fresh value the credential is bound to change:
| Live P2P call | Async P2P read | Async server verify | |
|---|---|---|---|
| Who verifies | a peer — no server | any reader, at read time | the certified-ephemeral function |
| Membership | the join gate over presence | hold the key (commit(mk) == mkCommit) |
same |
| Freshness / anti-replay | the live DTLS cert fingerprint | the per-entry signature | a server-issued challenge, folded into the signed core |
| Trust root | the link's public key | the link's public key | the link's public key + a certified deployment |
| Content secrecy | peer-to-peer E2EE | sealed ledger, content-blind platform + owner | same — the double blind |
Async did not need a weaker model. It needed a different anchor for the same model — the member's signing key (identity) plus a server-issued challenge (presence) in place of the DTLS fingerprint.
Where the server sits, precisely. In async there is no peer to run the gate, so the turn function does — but it
is a certified, ephemeral, content-blind verifier, not a trusted ACL store: it checks signatures against the link's
public keys and a public JWKS (rooting no trust of its own); the allow-list it consults is sealed under mk, decrypted
for one turn; an OIDC email it verifies is transient, never persisted; reads it serves an agent are sealed to that
agent's key. The server verifies without being trusted with content.
The owner rule
Status: Implemented — built and tested, behavior-neutral, not yet enabled in production. The full path is implemented and tested end-to-end for both trust-root modes: on-prem (the owner pins one
OWNER_POLICY_KEY) and hosted multi-tenant (a tenant registry,OWNER_REGISTRY=1, resolves the trusted key by the room's app). Also built: the create-time mandate, the render's integrity gate, tier-driven capabilities, client peer verification, tenant self-registration (op:'register-owner'), and key rotation/revoke (op:'rotate-owner'). It stays inert until an owner opts in — keys unset, rooms behave exactly as before.
A Witbitz room is a door whose key is the link — great between equals, a problem the moment there's an owner. If a company runs an app on Witbitz, it has no way to say "every room in my app is only for my people, and gold customers get more than free ones." The owner rule turns "the link is the authority" into "the owner is the authority," opt-in, without giving up content-blindness.
The rule: a signed App Policy. The owner writes one small document and signs it with a key only they hold:
AppPolicy {
app: "acme-portal" // which app this governs
ownerKey: <owner's public key> // the root of authority
tiers: [
{ name:"open", admit: anonymous, caps:{ read }, limits: low }
{ name:"member", admit: { issuer:"acme.okta.com", any:true }, caps:{ read, chat }, limits: mid }
{ name:"gold", admit: { issuer:"acme.okta.com", claim:{group:"gold"} },caps:{ read, chat, act, premium }, limits: high }
]
roomTiers: { default:"member", admits:["open","member","gold"] }
}
→ signed by ownerKeyThe signature is the whole trick: nobody without ownerKey can produce a valid policy, so nobody can forge a weaker
one. Admission stops being binary — it's a ladder from anon-public to premium-verified, and the flat "must be my
user" rule is just its one-tier case.
Bound to a room at birth. When a room is created through the owner's credential (hosted, the owner's tenant key;
on-prem, its own create endpoint), create stamps the signed policy sealed into the room config (the render reads
and enforces it) and a cleartext marker { app, ownerKeyId, policyHash } on the public record (so the content-blind
platform and keyless read paths know "this room is owner-gated" without reading anything). The owner's create path
refuses to mint a room that doesn't carry the policy — the room is born gated.
Only the app can mint its rooms (room-genesis). The signed policy is a reusable public artifact — anyone could copy it and stamp it on a room they create, standing up a look-alike to phish the owner's users. So stamping a policy at creation also requires a room-auth: a signature by the owner key over this specific room (the room id is in the signed core). Only the app owner holds the key, so only the app can mint "a genuine room of X's."
Tiers can't self-promote. A member's tier comes only from a signed source: IdP claims (the owner's directory is the source of truth for who is gold), a signed capability grant (a room admin promoting a member), and the room's own tier (the ceiling). A tampered client can't forge the claim or the grant, and the render — not the client — resolves and enforces the tier.
Enforced in three independent places, no single point to bypass:
| When | Check | Where it runs |
|---|---|---|
| Birth | create refuses any owner-room without the signed policy + marker | the owner's create path (tenant-keyed / on-prem) |
| Writes (every turn) | the render opens the sealed policy, verifies its signature against the pinned ownerKey, resolves the tier, gates each action; policy absent/invalid → refuse |
the certified render |
| Blind reads | the cleartext marker makes keyless reads refuse; keyed reads pass the same identity gate | the content-blind platform |
| Join | the member's app verifies the room policy against its own pinned ownerKey and won't join an unrecognized room |
every member's app (peer check) |
Per-tier limits (rate / token / tool budget) reuse the platform's existing metering — so the ladder doubles as the owner's pricing surface: admission, capability, and monetization collapse into one signed table.
Registration — Witbitz federates; it doesn't run the directory. A "registered user" is a record in the owner's
identity provider, not a Witbitz account. Owner registration happens once (the trust root: ownerKey, the IdP issuer +
JWKS as a SPACE_OIDC_ISSUERS entry, and the claim → tier map); user registration happens in
the owner's own directory (enterprise SSO / SCIM / invite — the tier is a group the owner assigns, so a user can't
self-register as gold); device enrollment mints a signing key per device bound at sign-in. So joining a room is
authentication, not registration, and revocation is symmetric — remove the user from the gold group and their next
sign-in drops a tier, enforced on the very next turn.
Orthogonal to attestation. This is the identity + capability axis; whether the client is trustworthy code is a
separate axis (the verified client). They compose — a tier's admit can additionally
require an attested client — but identity-tiers work on the plain web with no attestation in sight.
The honest trade. Requiring registration deliberately reintroduces a central authority — the owner's directory — into an otherwise account-free, link-based model. That's not a regression; it's exactly the control an owner asks for, and it's opt-in per app/room. The platform stays content-blind throughout — the policy is enforced in the certified render and by peers, never by a platform that reads the room.
Lifecycle — removal, rotation, recovery
The hard questions are never the initial key exchange. A bearer link is easy to copy, screenshot, sync, log, lose or leak — so these are the ones that matter.
After a member is removed
- Writes — cut now (shipped). A revoked member is on the Space's
revokedlist (sealed in the config); the turn op rejects their next turn403even with a valid credential. - Reads — server cut shipped for email-gated Spaces; the crypto cut is designed. Every read op checks the allow-list
revokedbefore the server decrypts, so a removed member's next read is403— immediately, no re-key. Open Spaces have no read gate — the link reads. The crypto-strong version (holding even if the server were bypassed) is an epoch re-key sealing new content to a fresh key for the remaining set — live in the Bridge, not yet ported to the single-Space ledger.
"But the link is the auth — how can you cut anyone?" A link is a bearer credential: the removed member already
extracted mk the first time they opened it, and no edit reaches into their browser to delete it. You cannot retract a
held key — only abandon it. Revocation works because of the thin-client model: the client never decrypts — the
server does. The server is a decryption oracle, and cutting someone means denying the oracle, not un-giving the
key. (There's no "their slice" to strip — everyone holds the same mk; you remove them from the policy, not the
key.) Dropping the member from the allow-list is a complete, shipped revocation for an ordinary private Space; an epoch
re-key buys exactly one more thing — the cut holding without trusting the platform.
History vs future
Revocation cannot make someone un-see content they already read — true for every end-to-end system. A re-key protects the future, not the past. History a member already saw remains known to them; new content can be blocked by read gates now and by epoch re-key in the stronger model. For rooms with hard compartment boundaries, design epochs up front.
Membership change is a fork
Adding or removing a member is one epoch operation, not an edit to a member list. The room forks: a fresh room key
and roster, the history carried across, and a sealed pointer left behind so present members follow automatically. The
removed member keeps whatever they already had (that cannot be undone) but holds no key to anything after the fork, and
no deny-notice has to reach every peer for the guarantee to hold. That delivery problem is what sank the earlier in-place
"config update" and lease designs. Both the Bridge and single Spaces use epoch re-key on every membership change —
single Spaces via spaces/public/spaceFork.js (built, wired to the Members UI, and tested in agent/spaceFork.test.mjs).
Re-key is triggered by a membership change; absent one, a Space keeps a long-lived key (no scheduled rotation).
Rotation and replacing a compromised identity
Rotation = open a new epoch, re-seal forward, hand the remaining members the new key. Per-member signing keys rotate independently: revoke the old grant, admit the new key. Replacing a compromised identity needs no rebuild — a member is a signing key bound by a grant, not the room itself: revoke the old grant (shipped), admit a fresh key, and re-key the epoch where a crypto-strong future read-cut is required (designed). The Space, its history and every other member are untouched. Same for an agent — its identity is its key, so a rotated key is a one-line allow-list change.
Metadata that remains
Honestly: timing, sizes and coarse structure — never content or membership. The platform stores the mk-commitment,
the sealed config and the sealed ledger, and observes when turns and polls happen and how big the ciphertext is. It
does not see the conversation, the members' names (sealed in the config), or — in invite mode — any real-world identity.
In Google-sign-in mode it sees the presenter's email transiently, at verify time, and discards it. The irreducible leak
is timing correlation, which no content encryption removes.
Recovery
Witbitz deliberately has no operator recovery key. If the platform could recover a Space, the platform could read a Space. Recovery must belong to the user or organization, not to Witbitz as operator.
The current rule. If every copy of the room key is lost, the Space is lost. That is not a product flourish; it is the
cost of the privacy claim — the production envelope is sealed to the room key, not to an operator, support, escrow, or
beta recipient. Verify it with the sealed-store check: POST {"op":"sealed","room":"<ROOM>"} → the recipients list
should name the room, not the operator (Verify it yourself).
Recovery must be user-held. Passkey (shipped) · backup code (shipped) · k-of-n social recovery (the same Shamir split the couple key uses — not built) · organization key policy (the customer controls it — on-prem). None require Witbitz to hold a readable recovery recipient.
The shipped vault. One vault per account, and either enrolled method opens it: a random 32-byte master secret identifies and encrypts the vault; each method stores one wrapper (the master secret sealed to that method's own secret); restoring with any enrolled method opens its wrapper, recovers the same master secret, and opens the one backup. So a passkey and a backup code are not two backups to keep in sync — they are two doors into the same room. The server stays content-blind throughout: it stores opaque blobs at derived ids and never receives the code, the passkey secret, the vault key, or any plaintext. Wrappers are written before the data they unlock, so a half-finished enrolment can never leave a vault nobody can open.
The offline export. Recovery that depends on the platform being reachable is not self-sovereign, so a Space can also
be exported to a file you keep, alongside a standalone decryptor page that opens it with no server at all. The export is
opaque by construction: an earlier version wrote each Space's room, title and message count in the clear next
to its sealed ledger — unreadable ciphertext that nonetheless announced every conversation's name and size to anyone who
opened it in a text editor. That metadata is now sealed under the same room key as the ledger it describes; the decryptor
shows "Conversation N" until you load your keys file. Two things remain visible: how many conversations the bundle
holds and when it was exported (hiding the count would mean padding with decoys — more cost than it buys for a
personal backup).
Enterprise / on-prem recovery. In an on-prem deployment the organization controls the runtime boundary — its own backup policy, IdP, secret storage, and recovery procedure. The privacy story changes from "trust Witbitz not to read" to "verify the image you run and control the boundary yourself" (Deployment options).
What not to promise. Witbitz cannot recover a lost Space on hosted production — by design. And don't hide the user burden: user-held recovery is safer than operator-held, but it still requires a recovery artifact, another device, a passkey sync provider, a social-recovery set, or an organizational process.
Short links stay blind (/shorten)
A room whose link holds the key must never have that key stored by the shortener, or the operator would hold it. So
the shortener is blind-safe: POST /shorten refuses any target carrying key material
(k/key/mk/sk/secret/seed in query or fragment); GET /r/{code} re-attaches the short link's own #fragment
client-side. The shortener may store the room's base, never the key.
Security properties
- Unforgeable — every credential is an ECDSA P-256 signature; minting needs a private key.
- No cross-room, no replay —
room+expin every payload, plus a per-connection fingerprint (live) or a single-use, TTL-bounded server challenge (async). - No algorithm confusion — OIDC verification is RS256-only;
alg:"none"/HS*/ES* rejected; the verifier algorithm is never taken from the token. - Fail-closed everywhere — a malformed grant, unknown
kid, missing signature or empty allow-list → no access. - No server trust root for the credential — a compromised or curious platform cannot mint membership, only (in the async-write locus) decline to run.
- Sealed policy — the policy is sealed under
mk; verified identities are transient.
Status
| Piece | Live P2P | Async |
|---|---|---|
Key-possession membership (mkCommit) |
n/a — presence gate | shipped |
| k-of-n key model (solo / 2-of-2 Shamir) | n/a | shipped |
| Signed-invite grants + per-entry signatures | shipped | deployed + live-verified (forged → 400) |
| Capabilities in the grant (perceive/act) | shipped | deployed |
| OIDC / Google admission, allow-listed | shipped (cert-bound) | LIVE — enforcement-proven (unsigned / non-allowed write → 403) |
| Presence proof / anti-replay | shipped (cert-binding) | deployed (server-issued challenge) |
Agent admission by allow-list (AgentEntry) |
shipped | deployed (same shape, live + async) |
| Scoped read (least-privilege perceive) | shipped (mesh) | deployed (view + member-absent fetchview) |
| Identity-gated reads (the link alone reveals nothing) | admission gates presence | LIVE — enforcement-proven (token-less poll → 403) |
| Owner rule (signed App Policy, tiers, room-genesis) | n/a | Implemented — not yet enabled in prod |
Blind-safe /shorten |
n/a | deployed |
| Remove a member — cut writes | shipped | shipped (gated Spaces) |
| Remove a member — cut reads (server read-gate) | n/a | shipped for email-gated Spaces |
| Remove a member — cut reads (epoch re-key, forward secrecy) | n/a | shipped in the Bridge; designed for the single-Space ledger |
| Rotate a compromised member/agent key | shipped | shipped (identity + revoke) + designed (re-key for full read-cut) |
| Device-loss recovery (user-held code / k-of-n social) | n/a | passkey + code shipped; social recovery designed — never operator-held |
| Per-member identity — couple (2-of-2); distinct invite links | n/a | not yet (solo signed path live) |
Code map
| Concern | Live P2P | Async |
|---|---|---|
Signing primitive (payloadB64.sigB64) |
src/core/inviteToken.ts |
agent/spaceEntrySig.mjs (byte-compatible) |
| Signed invites / grants | src/core/inviteToken.ts |
agent/spaceEntrySig.mjs + spaces/public/entrySig.js |
| Signed roster / policy | src/core/roomManifest.ts |
the mk-sealed config (agent/spaceHandler.mjs, spaceService.mjs) |
| Join-gate modes | src/core/joinGate.ts |
admission open/invite/email in agent/spaceService.mjs |
| OIDC verify + binding | oidcVerify.ts · oidcBinding.ts · identityCert.ts |
agent/spaceOidc.mjs (RS256/JWKS + spkNonce) |
| Presence / anti-replay | cert fingerprint | agent/spaceChallenge.mjs |
| Agent admission | src/core/agentKey.ts |
agent/spaceAgentKeys.mjs |
| Capabilities | src/core/capabilities.ts |
agent/spaceCaps.mjs |
| Scoped read | mesh perceive-gating (mesh.ts) |
agent/spaceScopedView.mjs (view/fetchview) |
| Verified host | src/core/hostKey.ts |
agent/delegatedAuthority.mjs |
| Room key mint / commit / at-rest seal | — | agent/envelope.mjs (newRoomKey, commit, seal/open) |
| Recipient sealing (ECDH box) | — | agent/envelope.mjs (sealTo/openBox) |
| Owner policy (sign / verify / register / rotate) | — | agent/spaceHandler.mjs, tools/owner-policy.mjs |
| Link codec + k-of-n | — | spaces/public/spaceConfig.js, shamir.js, invite.js |
| Epoch re-key + forward secrecy | — | agent/bridgeMembership.mjs |
| Blind-safe short links | — | control-plane/src/shortlink.mjs |
The through-line: a room's authority lives in its link, and the check that enforces it can run wherever the room happens to be — on a peer, in a reader, or inside a function that verifies but cannot read.
Don't take our word for it → Verify it yourself. A token-less read/write to an email-gated Space returns
403;curl -Ithe app to read its browser-enforced egress allowlist;POST {"op":"sealed"}to read the exact ciphertext the server stores —recipients:["room"]proves it's sealed to your key, not the operator's.
Machine-readable source: identity-and-admission.md · Generated 2026-08-29T18:46:30Z · build f3ff88cc · every doc in one fetch: llms-full.txt (HTML) · ← spaces-app-defaults · client-architecture →