Ease docs/Concepts/Groups
Groups
A group is an encrypted, multi-member room with owner / admin / member roles. Open one through a GroupRoom handle and the same send and receive you use for a DM works unchanged; the role verbs, the approval gate, invite tokens, and disband layer on top. Message content is end-to-end encrypted; group metadata is plaintext on the wire.
The model
- A group has at least three members (the creator plus two others) at create time.
- Every member has a role:
owner(exactly one),admin, ormember. Themember.roleon each roster entry is one of'owner'|'admin'|'member'. - Members must be contacts of the caller at create and add time. The invite token bypasses this gate.
- Group messages are end-to-end encrypted over libsignal Sender Keys. The server stores ciphertext, never plaintext.
- Group metadata (the name, the roster, the roles, the avatar) is plaintext on the wire. Only message content is encrypted end to end.
What plaintext metadata means in practice: the server sees the group name, the roster (each member's user ID and display name), the roles, and the avatar bytes. It does not see message content. That stays libsignal-encrypted end to end.
Create a group, open the room
ease.chat.createGroup(name, memberUserIds) mints the group and returns a snapshot with the server-issued created.groupId. The caller becomes the owner; the listed members become member. You don't list yourself. Every subsequent operation goes through a GroupRoom handle, which you open by ID with ease.chat.group(groupId). The handle is a typed wrapper that hits the server as needed.
// createGroup mints the group and returns its id; the caller
// becomes owner. Each member id must be a contact of the caller,
// and a group needs at least three members (creator + two).
const created = await ease.chat.createGroup(
'Friday lunch',
[bobUserId, carolUserId],
);
const room = ease.chat.group(created.groupId);
room.on('message', (event) => {
if (event.kind !== 'message') return;
const { envelope } = event.event;
if (envelope.type === 'ease.text') render(envelope.text);
});
await room.send({ type: 'ease.text', text: 'pizza?' });The receive shape is the same typed envelope a DM uses: filter on event.kind, read event.event.envelope, and branch on envelope.type. The SDK decrypts before dispatch, so the envelope you handle already carries plaintext.
The duplicate-roster guard
If the caller already belongs to a non-archived group with the exact same roster, createGroup throws an EaseError with code === 'duplicate_roster'. Catch it and let the user open the existing group or mint a fresh one by passing { force: true }.
try {
const created = await ease.chat.createGroup('Crew', [bobId, carolId]);
} catch (err) {
if (err instanceof EaseError && err.code === 'duplicate_roster') {
// The caller already belongs to a group with this exact roster.
// Either open the existing group, or mint a fresh one with force.
const fresh = await ease.chat.createGroup(
'Crew',
[bobId, carolId],
{ force: true },
);
}
}The roster
room.members() returns the current roster. Each entry carries an identity and a role: read member.identity.userId for who they are and member.role for what they can do. The handle caches nothing, so the roster is the server's current view each time you read it.
const room = ease.chat.group(created.groupId);
const members = await room.members();
for (const member of members) {
// member.identity.userId — who they are
// member.role — 'owner' | 'admin' | 'member'
console.log(member.identity.userId, member.role);
}Who can do what
Every verb checks the caller's role on the server. The SDK doesn't pre-flight; it makes the call and surfaces a typed error if the role is insufficient. Two structural rules sit behind the surface:
- Admins manage the group; owners manage admins. Adding and removing admins (
promote/demote), transferring ownership, toggling the approval gate, and disbanding are owner-only. Admins can do everything else that affects content and membership. - Anyone in the group can nominate a member. When the approval gate is on, a regular member's nomination enters a pending state instead of adding straight away; an owner or admin add always goes through immediately.
Membership: add, remove, leave
Membership verbs live on the GroupRoom handle. room.addMember(userId) nominates a contact of the caller; a non-contact is rejected. room.removeMember(userId) is owner / admin only. room.leave() lets any non-owner leave freely.
const room = ease.chat.group(created.groupId);
// addMember nominates a contact of the caller. Under the approval
// gate, a regular member's nomination throws pending_approval with
// the request id instead of adding immediately.
try {
await room.addMember(daveUserId);
} catch (err) {
if (err instanceof EaseError && err.code === 'pending_approval') {
// The nomination is queued for an owner or admin to resolve.
}
}
await room.removeMember(daveUserId); // owner / admin only
// A non-owner can leave freely. An owner with members remaining
// must transferOwnership first, or this throws.
await room.leave(); // creator_must_transfer_first for an ownerWhen the group has the approval gate on and the caller is a regular member, addMember throws an EaseError with code === 'pending_approval' carrying the request id, rather than adding immediately. Surface it as a queued nomination in the UI.
An owner with members still in the group cannot leave: leave() throws creator_must_transfer_first. The two legal next moves are transferOwnership(...) or disband(). Surface the error as a modal offering a transfer dropdown or a disband button.
Roles: promote, demote, transfer ownership
Role verbs are owner-only and live on the handle. promote raises a member to admin and demote drops an admin back to member. A fourth admin throws admin_cap_reached. Demote an existing admin first.
const room = ease.chat.group(created.groupId);
await room.promote(carolUserId); // member → admin; owner-only
await room.demote(carolUserId); // admin → member; owner-only
// Hand the group over. The caller drops to member.
await room.transferOwnership(carolUserId);transferOwnership(newOwnerUserId) hands the group to a current member. After the call, the target is the new owner and the caller drops to member. The new owner can promote the previous owner back to admin if they want.
Metadata: rename and avatar
Two kinds of metadata change, and they live in two places. The rename verb is per-room, so it sits on the GroupRoom handle. The avatar and the approval-gate toggles are administrative group metadata rather than per-room messaging, so they sit on ease.chat and take the groupId directly.
Rename
room.rename(name) is owner / admin only and returns the updated group so you can reconcile a local cache without a second fetch.
const room = ease.chat.group(created.groupId);
// Owner / admin only.
await room.rename('Friday lunch crew');Avatar
Group avatars are JPEG bytes, the same shape as profile avatars. All three verbs (setGroupAvatar, getGroupAvatar, clearGroupAvatar) live on ease.chat. Setting and clearing are owner / admin only; getGroupAvatar returns the bytes or null when none is set.
// Owner / admin only. Bytes are JPEG, within the tenant cap.
const jpegBytes = new Uint8Array(/* JPEG bytes */);
await ease.chat.setGroupAvatar(created.groupId, jpegBytes);
const bytes = await ease.chat.getGroupAvatar(created.groupId);
// → Uint8Array, or null when no avatar is set
await ease.chat.clearGroupAvatar(created.groupId);The approval gate
The approval gate is a per-group toggle. When it is on, a nomination from a regular member enters a pending state instead of adding straight away; when off, every add goes through. The toggle is owner-only and lives on ease.chat, not on the handle, because it is administrative metadata rather than per-room messaging.
// Owner-only. Bind this to the "joining requires approval"
// switch on a manage-group screen. Toggling to the current
// value is a no-op, so it is safe to call on every switch
// interaction without first reading the current state.
await ease.chat.setGroupRequiresApproval(created.groupId, true);
await ease.chat.setGroupRequiresApproval(created.groupId, false);setGroupRequiresApproval returns the updated group. Toggling to the value the group already has is a no-op, not an error, so it is safe to call from a UI handler that fires on every switch interaction without first reading the current state.
The approval queue
When the gate is on, nominations queue. joinRequests() lists the open nominations and pendingJoinRequestCount() returns the count for a chat-info badge; both are owner / admin reads. approveJoinRequest(requestId) promotes a pending nomination to a real member, and rejectJoinRequest(requestId) discards it; both are owner / admin writes.
const room = ease.chat.group(created.groupId);
// Owner / admin reads.
const requests = await room.joinRequests();
const count = await room.pendingJoinRequestCount(); // for a badge
// Owner / admin writes.
await room.approveJoinRequest(requests[0].requestId);
await room.rejectJoinRequest(requests[0].requestId);Invite token
The invite token is a string that bypasses both the contact-of-caller gate and the approval gate. Anyone with a valid token can join. That's the point. Use it for QR posters, shareable invite links, and discovery surfaces. regenerateInviteToken() mints or rotates the token (owner-only; any prior token stops being accepted), and getInviteToken() reads the current one without rotating (owner / admin). A joiner redeems it through ease.chat.joinGroupByToken(token).
const room = ease.chat.group(created.groupId);
// Owner-only. Rotating invalidates any prior token.
const token = await room.regenerateInviteToken();
// Owner / admin. Read the current token without rotating.
const current = await room.getInviteToken();
// Anyone with a valid token can join, bypassing the contact
// gate and the approval gate. token.token is null only when a
// token has never been generated, so narrow before passing it on.
if (token.token) {
const joined = await ease.chat.joinGroupByToken(token.token);
}The token is plaintext on the wire. Treat it like a shareable secret, not like a password: anyone who sees it can join until the owner rotates. Rotate it whenever the audience changes.
Disband
room.disband() is owner-only. It tears the group down server-side and fans group_removed to every current member. There is no undo; the legal alternative to leaving as an owner is to transfer ownership instead.
const room = ease.chat.group(created.groupId);
// Owner-only. Disbands the group and fans group_removed to
// every current member.
await room.disband();Encryption, in one line
Group messages ride libsignal Sender Keys. When you call room.send(...), the SDK mints the distribution, fans it to the roster, and sends the group ciphertext. None of that is on your call path. Group metadata (the name, the members, the roles) is plaintext on the wire; the message content is the part that stays end-to-end encrypted.