GroupSessionManager in packages/core/src/groups.ts).
Capabilities
Joining alone does not allow messaging: the host must introduce the relevant
participants, and both
send and receive capabilities are checked at
delivery time — introducing two devices does not implicitly grant either of
them permission to exchange messages.
Lifecycle
- Create —
create(ownerDeviceId)requirescrosslink.group.create, checks the host’smaxGroupsceiling, and returns agroupIdplus the owner’s own opaqueparticipantId. The creating device becomes the group’s sole owner. - Invite —
invite(groupId, createdBy, targetDeviceId?)requirescrosslink.group.introduceand can only be called by the group’s owner. It mints a random 24-byte token that expires afterinviteTtlMs, optionally bound to one specifictargetDeviceIdso it can’t be redeemed by any other device. - Join —
join(token, deviceId)requirescrosslink.group.join. The token is deleted from the invite table as soon as it’s looked up — a second redemption attempt with the same token always fails, even before the TTL expires. An already-a-member device redeeming its own invite again gets back its existingparticipantIdrather than erroring. Joining fails withRATE_LIMITEDoncemaxMembersPerGroupis reached. - Introduce —
introduce(groupId, requester, targetParticipantId)requirescrosslink.group.introducefrom the requester andcrosslink.group.receivefrom the target. It resolves the opaquetargetParticipantIdto a real member, delivers an"introduction"event to both sides carrying only the other side’s opaque participant ID, and is idempotent — introducing the same pair twice reuses the existing introduction record instead of creating a duplicate. - Send —
send(groupId, introductionId, fromDeviceId, payload)requirescrosslink.group.sendfrom the sender and re-checkscrosslink.group.receiveon the recipient at delivery time — a capability revoked after the introduction was made still blocks the message. The introduction must already exist and must name the sender as one of its two parties, or the call fails withCAPABILITY_DENIED(“peer has not been introduced”). Payloads are size-checked againstmaxPayloadBytesafter JSON-encoding. - Leave —
leave(groupId, deviceId)removes the device and every introduction it was party to. If the leaving device was the owner, or the group is now empty, the whole group is deleted — there is no owner handoff.leaveAll(deviceId)is called automatically on revocation, so a revoked device is removed from every group it belonged to in one pass.
What the host never learns it needs to hide, and what it does see
Application data remains end-to-end encrypted on each device-to-host session, but
the trusted host necessarily sees group payloads to route them.
payload, re-serializes it, and re-encrypts on the recipient’s leg.
Members never open a direct connection or session with each other. What stays
hidden from every other member is the fixed, structural information: real
device IDs, network addresses/transport details, long-term public keys, and
each other’s capability grants — a member only ever sees the opaque
participantId values the host hands out, which carry no identifying
information and are freshly generated per group membership.
This is the same host-mediated trust boundary described in
Security overview: a group session extends it to
multi-party routing rather than introducing a new one.