client.space
See the Messaging guide for usage. Channels are group spaces addressed by a human name through your app’s registry; each space seals messages once with a shared group key so the server can persist + replay them as history. Requires a configured app key.
Channels
Section titled “Channels”createChannel(name, opts?)
Section titled “createChannel(name, opts?)”createChannel(name: string, opts?: { historyPolicy?: "static" | "rotate"; private?: boolean }): Promise<Space>Mint a new channel: create a fan-out space, register name → id in the app
directory, and admit the app keeper so it’s joinable later. You become the first
key-holder. Throws ChannelExistsError if the name is
taken.
With private: true the channel is membership-gated: it’s hidden from
listChannels, and only members the creator (or an existing member) has
invited are admitted by the keeper. Public channels (the default) are joinable
by any authenticated app user.
joinChannel(name, opts?)
Section titled “joinChannel(name, opts?)”joinChannel(name: string, opts?: { timeoutMs?: number }): Promise<Space>Resolve an existing channel name (or a raw space id) and join it — the keeper
admits you and hands over the group key. Throws
ChannelNotFoundError if no such channel is registered.
listChannels() / resolveChannel(name)
Section titled “listChannels() / resolveChannel(name)”listChannels(): Promise<Array<{ name: string; spaceId: string }>>resolveChannel(name: string): Promise<string | null> // space id, or nullThe channel directory is app-public (readable with the app key); channel contents stay end-to-end encrypted.
Spaces (no registry name)
Section titled “Spaces (no registry name)”createSpace(opts?) / joinSpace(id, opts?) / get(id)
Section titled “createSpace(opts?) / joinSpace(id, opts?) / get(id)”createSpace(opts?: { historyPolicy?: "static" | "rotate" }): Promise<Space>joinSpace(spaceId: string, opts?: { timeoutMs?: number }): Promise<Space>get(spaceId: string, historyPolicy?: "static" | "rotate"): Promise<Space>Lower-level: a space’s id is its encoded public key. createChannel /
joinChannel are these plus the name registry. get returns a handle without
connecting (handy for shard-only file reads).
historyPolicy is static (one key, full history — the default) or rotate
(per-epoch keys; members read only the epochs they belonged to).
Invite links & roles
Section titled “Invite links & roles”A private space (createChannel(name, { private: true })) admits only
allowlisted members. Rather than inviting one username at a time, mint a
shareable capability link anyone can redeem — the keeper admits them on
redemption. Members carry a role (viewer / editor) the owner controls;
apps use roles to gate delegated management.
// Owner — mint / list / revoke invite links (optionally bounded + with a role).createInviteLink(spaceId, opts?: { expiresInSec?; maxUses?; role?: "viewer" | "editor" }): Promise<InviteLink>listInviteLinks(spaceId): Promise<InviteLink[]>revokeInviteLink(spaceId, token): Promise<void>
// Recipient — redeem a link: admitted by the keeper, group key wrapped to them.joinByInvite(spaceId, token, opts?: { timeoutMs? }): Promise<Space>
// Owner — members + roles.members(spaceId): Promise<Array<{ memberId: string; role: string }>>setMemberRole(spaceId, username, role): Promise<void>roster(spaceId): Promise<RosterMember[]>const { token } = await client.space.createInviteLink(space.id, { role: "viewer" });const url = `https://yourapp.com/join?space=${space.id}&token=${token}`;// …recipient opens the link…await client.space.joinByInvite(space.id, token);// …owner promotes them…await client.space.setMemberRole(space.id, "alice", "editor");Mint/list/revoke and role changes are owner-only (the space creator);
joinByInvite only needs a signed-in session and a valid token.
| Member | Signature | Description |
|---|---|---|
id | string | The space’s encoded public key. |
keyring | SpaceKeyring | undefined | The group-key ring backing this space (present once joined/created). |
sendMessage(payload, opts?) | (unknown, { channel?, contentType? }) => Promise<void> | Seal once with the group key + fan out. Loops back to the sender. |
onMessage(handler) | (e: SpaceMessageEvent) => () => void | Decrypted messages. Returns an unsubscribe fn. |
editMessage(handle, payload, opts?) | (number, unknown, { channel?, contentType? }) => Promise<void> | Replace a persisted message in place, addressed by its server handle. Re-seals the payload; the server authorizes against the original author. |
deleteMessage(handle) | (number) => Promise<void> | Hard-delete a persisted message by its handle — the ciphertext is removed from storage. |
onMessageEdited(handler) | (e: SpaceMessageEvent) => () => void | Authoritative in-place edits: new content at the same handle. |
onMessageDeleted(handler) | (e: MessageDeletedEvent) => () => void | Authoritative deletions ({ handle }). |
sendEphemeral(subject, data) | (string, unknown) => void | Broadcast a never-persisted signal (no history). The generic primitive for typing indicators, presence, cursors — see below. No-op if disconnected. |
onEphemeral(handler) | (e: EphemeralEvent) => () => void | Inbound ephemeral signals; e.from is the server-authenticated sender. |
history(opts?) | ({ before?, limit? }) => Promise<{ messages, nextCursor }> | Fetch + decrypt persisted history. |
putFile(file, metadata) | => Promise<{ manifest, stat }> | Encrypt + erasure-code + upload to space shards. |
getFile(manifest) | => Promise<{ data: Uint8Array, stat }> | Fetch + decode a shared file. |
admit(memberId, identityEcdhPub) | => Promise<void> | Wrap the group key for a member (key-holder action). |
invite(username) | (string) => Promise<void> | Add a user to a private channel’s membership allowlist (member-only). |
onJoinRequest(handler) | (req: JoinRequest) => () => void | Inbound join requests — for manual membership gating (autoAdmit: false). |
rotate(roster?) | => Promise<number> | Mint a new epoch + re-wrap to members (rotate spaces). |
connect() / disconnect() | Open / close the websocket. | |
on(event, handler) / off(...) | Raw channel events (channel:raw_frame for roster, etc.). | |
peers() / isConnected() | Roster ids / socket liveness. |
SpaceMessageEvent
Section titled “SpaceMessageEvent”interface SpaceMessageEvent { from: string; // sender member id (server-stamped, signature-verified) channel: string; // subject/topic within the space epoch: number; // group-key epoch contentType?: string; handle: number; // server storage handle — see below message: Message; // message.body is the (app-defined) payload}
interface EphemeralEvent { from: string; subject: string; data: unknown }interface MessageDeletedEvent { handle: number }The handle is the message’s server-assigned storage key (a monotonic
timestamp). It is stable across edits and is what you pass to
editMessage / deleteMessage. Use it — not message.id — as a message’s
identity for dedup and mutation: an edit re-seals a fresh Message (new
message.id) under the same handle.
Editing & deleting messages
Section titled “Editing & deleting messages”editMessage and deleteMessage are generic persisted-item operations —
the server mutates the stored entry (it has no notion of what your payload
means) and authorizes the request against the original author (it can read the
cleartext envelope’s signed source, never the sealed body). An edit replaces
the ciphertext in place at the same handle; a delete removes it entirely.
space.onMessage((e) => render(e.handle, e.message.body));space.onMessageEdited((e) => render(e.handle, e.message.body)); // same handlespace.onMessageDeleted((e) => remove(e.handle));
// Send, then edit/delete by the handle you received:await space.sendMessage({ contents: "hello" });// …later, holding `handle` from the message's SpaceMessageEvent:await space.editMessage(handle, { contents: "hello (fixed)" });await space.deleteMessage(handle);Whether an edited message is marked as edited, or a deleted one leaves a
“message deleted” placeholder, is an application choice expressed in your
message body shape (e.g. { contents, edited }) — the SDK and server stay
domain-agnostic.
Ephemeral signals
Section titled “Ephemeral signals”sendEphemeral(subject, data) broadcasts a signal the server relays to the space
but never persists (it won’t appear in history). It’s the generic building
block for presence-style features — typing indicators, “who’s online” pings,
live cursors — which you layer on top by choosing a subject and payload:
// Typing indicator, built in the app on top of the generic primitive:space.sendEphemeral("typing", { isTyping: true });space.onEphemeral((e) => { if (e.subject === "typing") setTyping(e.from, (e.data as { isTyping: boolean }).isTyping);});Sender authenticity
Section titled “Sender authenticity”Every fan-out message is ECDSA-signed by the sender’s identity key over its
{source, target, subject, epoch, iv, ciphertext}. Receivers verify the
signature against the sender’s published key (from the member directory) and
drop anything unsigned, signed by an unknown member, or mismatched — so a member
can’t forge another member’s from, and a relay can’t relabel a message. The
group key gives confidentiality + membership; the signature gives authorship.
The keeper (server-blind escrow)
Section titled “The keeper (server-blind escrow)”Channels are joinable even when no human member is online because the app runs a
keeper — a trusted, always-available member that holds the group key and
re-issues it to newcomers. The keeper can
read its channels; the relay + storage stay blind (they only ever see opaque
ECIES-wrapped key blobs). createChannel admits the keeper for you.