feat(groups): group chats, and a mesh rather than a star; release v6.1.1
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run

A group is an orchestration layer over the pairwise sessions the app already
holds. It owns no transport and no shared key: every frame leaves over a chat
that is already SAS-verified and already ratcheted, so a removed member simply
stops being sent anything. Membership is a roster the admin signs, ordered by
epoch, and the safety code is a commit-then-reveal round over every member's
fingerprint and nonce.

Delivery was the part that did not match its own description. The admin held a
link to everyone and nobody else held a link to anybody, so the relay path — the
documented fallback — was in fact the entire topology, and the admin going away
partitioned the group. Now, once the code is confirmed, each pair without a link
dials one over that relay path. The descriptors are compact enough to ride a
group frame and are signed with the sender's group identity key, so the relaying
member can drop a dial but cannot substitute one. The member with the smaller
fingerprint dials, which is the whole glare protocol.

Mesh links are released without a human comparing digits. Twenty-eight codes for
a group of eight is not a check anyone performs; the guarantee moves rather than
disappears, since the descriptor was signed by a key the signed roster names and
the group code covers. markGroupLinkVerified refuses any session whose in-band
exchange has not completed and whose peer has not proved possession of that key.

An existing 1:1 chat between two members is adopted instead of re-dialled, via a
probe bound to that session's own key fingerprint so it cannot be replayed onto
another chat to impersonate its author.

Security fix: g_hello was accepted on any session from anyone who knew the group
id, so any member could publish an identity the admin never invited and have the
admin sign and broadcast a roster containing it. It is now accepted only on a
session an invitation went out on, which also confines it to a direct link.

Mesh connections are kept out of the chat registry and muted from the document
events the header listens to, so a routing detail cannot tear down the display of
a conversation the user actually opened.
This commit is contained in:
lockbitchat
2026-08-25 16:27:08 -04:00
parent 6a98e2eb00
commit e00c3bd413
30 changed files with 11635 additions and 421 deletions
+707
View File
@@ -0,0 +1,707 @@
// A whole group, formed end to end over a virtual mesh.
//
// The topology is deliberately INCOMPLETE: Alice (the admin) holds a link to
// Bob and a link to Carol, and Bob and Carol have no link to each other. That is
// the real shape of a group at the moment it is created, and it is what forces
// the relay path — so this file covers both the happy case and the case the
// design actually has to survive.
//
// What is asserted, in order of how much it matters:
// 1. every member computes the SAME safety code, and only after a complete
// commit round;
// 2. a member the admin relays for still receives and verifies messages;
// 3. a forged or tampered message does not verify;
// 4. a member who sends two different bodies under one sequence number is
// caught.
import assert from 'node:assert/strict';
const { GroupSession, GROUP_FRAMES, groupFrameType, decodeEnvelope, encodeEnvelope } =
await import('../src/group/GroupSession.js');
const { GROUP_PHASE, MEMBER_STATE } = await import('../src/state/groupsStore.js');
const { toB64, fromB64, hashBody, signGroupMessage, generateGroupIdentity } =
await import('../src/group/groupCrypto.js');
const subtle = crypto.subtle;
// ---------------------------------------------------------------------------
// a virtual mesh
// ---------------------------------------------------------------------------
/**
* Nodes are wired by named links. `send(sessionId, frame)` delivers to whichever
* endpoint of that link is not the sender, awaiting the handler so the whole
* cascade settles before the test continues.
*/
function makeMesh() {
const links = new Map(); // sessionId -> [nodeA, nodeB]
const nodes = new Map(); // name -> node
const events = new Map(); // name -> [{ event, payload }]
function connect(a, b, sessionId) {
links.set(sessionId, [a, b]);
}
function makeNode(name, { isAdmin, groupId, groupName }) {
const log = [];
events.set(name, log);
const node = {
name,
session: null,
dropRelays: false,
events: log,
};
const send = async (sessionId, frame) => {
const pair = links.get(sessionId);
// A link that does not exist rejects, exactly as sendGroupFrame does
// for a session with no manager. Returning quietly would let a test
// pass on a send that never happened.
if (!pair) throw new Error('no such link');
const other = pair[0] === name ? pair[1] : pair[0];
const target = nodes.get(other);
if (!target) return;
if (node.dropRelays && groupFrameType(frame) === GROUP_FRAMES.RELAY) return;
// A frame is JSON on the wire; round-trip it so nothing in the test
// shares an object reference the real transport would have severed.
const wire = JSON.parse(JSON.stringify(frame));
if (groupFrameType(wire) === GROUP_FRAMES.INVITE && !target.session) {
const invite = decodeEnvelope(wire);
target.session = new GroupSession({
groupId: invite.gid, name: invite.name, isAdmin: false,
subtle, send: target.send, emit: target.emit,
});
await target.session.acceptInvite(sessionId, invite);
return;
}
if (!target.session) return;
await target.session.handleFrame(sessionId, wire);
};
const emit = (event, payload) => { log.push({ event, payload }); };
node.send = send;
node.emit = emit;
if (isAdmin) {
node.session = new GroupSession({ groupId, name: groupName, isAdmin: true, subtle, send, emit });
}
nodes.set(name, node);
return node;
}
return { connect, makeNode, nodes };
}
const last = (node, event) => [...node.events].reverse().find((e) => e.event === event)?.payload;
const all = (node, event) => node.events.filter((e) => e.event === event).map((e) => e.payload);
// ---------------------------------------------------------------------------
// form a three-member group over an incomplete mesh
// ---------------------------------------------------------------------------
async function formGroup() {
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
const bob = mesh.makeNode('bob', {});
const carol = mesh.makeNode('carol', {});
// Alice knows both. Bob and Carol do not know each other — the relay case.
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'carol', 'A-C');
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A-B', name: 'Bob' },
{ sessionId: 'A-C', name: 'Carol' },
]);
return { mesh, gid, alice, bob, carol };
}
{
const { alice, bob, carol } = await formGroup();
// Everyone adopted the roster and reached the code.
for (const node of [alice, bob, carol]) {
assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS,
`${node.name} must reach the safety-code step`);
assert.equal(node.session.members.size, 3, `${node.name} must see three members`);
}
// THE assertion: one group, one code.
const codes = [alice, bob, carol].map((n) => n.session.sasCode);
assert.equal(new Set(codes).size, 1, 'every member must read the same digits');
assert.match(codes[0], /^\d{7}$/);
// Bob and Carol learned about each other only through the admin's signed
// roster, and neither has a direct link to the other.
const bobsCarol = [...bob.session.members.values()].find((m) => m.fp === carol.session.selfFp);
assert.ok(bobsCarol, 'Bob must know Carol from the roster');
assert.equal(bobsCarol.state, MEMBER_STATE.PENDING, 'with no direct link, Carol is pending for Bob');
assert.equal(bobsCarol.sessionId, null);
// Nothing may be sent before the humans confirm.
await assert.rejects(() => bob.session.sendText('too early'), /group code has not been confirmed/);
// Confirm everywhere.
for (const node of [alice, bob, carol]) node.session.confirmSas();
for (const node of [alice, bob, carol]) {
assert.equal(node.session.phase, GROUP_PHASE.READY);
assert.equal(node.session.sasConfirmed, true);
assert.ok(last(node, 'confirmed'), 'the app is told the group is ready');
}
// ---- delivery over a direct link ----
await alice.session.sendText('roll call');
assert.equal(last(bob, 'message').body, 'roll call');
assert.equal(last(carol, 'message').body, 'roll call');
assert.equal(last(bob, 'message').fp, alice.session.selfFp, 'attributed to the signer');
// ---- delivery THROUGH the admin, between two members with no link ----
const result = await bob.session.sendText('on my way');
assert.equal(result.delivered, 2, 'Bob reaches both members: Alice directly, Carol relayed');
assert.equal(last(alice, 'message').body, 'on my way');
assert.equal(last(carol, 'message').body, 'on my way',
'a relayed message must arrive and verify');
assert.equal(last(carol, 'message').fp, bob.session.selfFp,
'the relay does not become the author');
// The relay is single-hop: Carol never forwards what she received.
assert.equal(carol.session._pendingCeremony.length, 0);
// ---- a duplicate is absorbed, not shown twice ----
const before = all(carol, 'message').length;
await bob.session._sendTo(carol.session.selfFp, {
type: GROUP_FRAMES.MESSAGE,
gid: bob.session.groupId,
epoch: bob.session.epoch,
seq: result.seq,
fp: bob.session.selfFp,
ts: Date.now(),
body: 'on my way',
sig: toB64(await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: bob.session.groupId, epoch: bob.session.epoch, seq: result.seq,
senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, 'on my way'),
})),
});
assert.equal(all(carol, 'message').length, before, 'a repeated frame is absorbed');
}
// ---------------------------------------------------------------------------
// the reveal gate really is closed until every commitment is in
// ---------------------------------------------------------------------------
{
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Slow' });
const bob = mesh.makeNode('bob', {});
const carol = mesh.makeNode('carol', {});
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'carol', 'A-C');
// Alice refuses to carry anything between Bob and Carol, so Carol's
// commitment never reaches Bob and Bob's never reaches Carol.
alice.dropRelays = true;
await alice.session.init();
await alice.session.invite([
{ sessionId: 'A-B', name: 'Bob' },
{ sessionId: 'A-C', name: 'Carol' },
]);
assert.equal(bob.session.phase, GROUP_PHASE.COMMITTING,
'an incomplete commit round leaves the member waiting, never revealing');
assert.equal(bob.session.ceremony.revealed, false, 'no nonce left the device');
assert.equal(bob.session.sasCode, '', 'and no code was produced');
assert.throws(() => bob.session.confirmSas(), /no group code to confirm/);
}
// ---------------------------------------------------------------------------
// forged and tampered messages
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
// A body swapped after signing does not verify.
const bodyHash = await hashBody(subtle, 'the real text');
const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 40, senderFp: bob.session.selfFp, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 40, fp: bob.session.selfFp,
ts: Date.now(), body: 'the SWAPPED text', sig: toB64(sig),
}),
/signature did not verify/,
);
// Alice cannot sign as Bob, however well placed she is to relay for him.
const aliceSig = await signGroupMessage(subtle, alice.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 41, senderFp: bob.session.selfFp, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 41, fp: bob.session.selfFp,
ts: Date.now(), body: 'the real text', sig: toB64(aliceSig),
}),
/signature did not verify/,
);
// An outsider with a perfectly valid signature is still not a member.
const outsider = await generateGroupIdentity(subtle);
const outsiderSig = await signGroupMessage(subtle, outsider.keyPair.privateKey, {
groupId: gid, epoch, seq: 1, senderFp: outsider.fingerprint, bodyHash,
});
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 1, fp: outsider.fingerprint,
ts: Date.now(), body: 'the real text', sig: toB64(outsiderSig),
}),
/message from a non-member/,
);
// A message from an epoch the group has left is refused even though it verifies.
await assert.rejects(
() => carol.session._onMessage({
type: GROUP_FRAMES.MESSAGE, gid, epoch: epoch + 5, seq: 42, fp: bob.session.selfFp,
ts: Date.now(), body: 'the real text', sig: toB64(sig),
}),
/another epoch/,
);
}
// ---------------------------------------------------------------------------
// a member telling two halves of the group different things is detectable
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
const seq = 7;
const signed = async (body) => toB64(await signGroupMessage(
subtle, bob.session.identity.keyPair.privateKey,
{ groupId: gid, epoch, seq, senderFp: bob.session.selfFp, bodyHash: await hashBody(subtle, body) },
));
const frame = (body, sig) => ({
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq, fp: bob.session.selfFp,
ts: Date.now(), body, sig,
});
const sellFrame = frame('sell', await signed('sell'));
const buyFrame = frame('buy', await signed('buy'));
await carol.session._onMessage(sellFrame);
assert.equal(last(carol, 'message').body, 'sell');
// The second, differently-signed body under the same sequence number is the
// split. Both signatures are valid, which is exactly what makes it provable.
await assert.rejects(
() => carol.session._onMessage(buyFrame),
/conflicting messages under one sequence number/,
);
const flagged = last(carol, 'inconsistency');
assert.ok(flagged, 'the app is told which member did it');
assert.equal(flagged.fp, bob.session.selfFp);
assert.equal(flagged.seq, seq);
}
// ---------------------------------------------------------------------------
// removing a member re-keys the group
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const codeBefore = alice.session.sasCode;
const epochBefore = alice.session.epoch;
await alice.session.removeMember(carol.session.selfFp);
assert.equal(alice.session.epoch, epochBefore + 1, 'removal opens a new epoch');
assert.equal(alice.session.members.size, 2);
assert.equal(bob.session.members.size, 2, 'the remaining member adopted the new roster');
assert.ok(!bob.session.members.has(carol.session.selfFp), 'Carol is gone from Bobs roster');
// A new epoch means a new code, and it must be compared again before
// anything is sent.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.notEqual(alice.session.sasCode, codeBefore, 're-keying must change the digits');
assert.equal(alice.session.sasCode, bob.session.sasCode, 'and both remaining members agree');
assert.equal(alice.session.sasConfirmed, false);
await assert.rejects(() => alice.session.sendText('still here?'), /has not been confirmed/);
}
// ---------------------------------------------------------------------------
// relay hygiene
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A relay envelope already marked as hopped is not forwarded again.
let forwarded = 0;
const realSend = alice.session._send;
alice.session._send = async (sid, frame) => { forwarded++; return realSend(sid, frame); };
await alice.session._onRelay('A-B', {
type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp,
hopped: true,
inner: { type: GROUP_FRAMES.MESSAGE, gid: alice.session.groupId },
});
assert.equal(forwarded, 0, 'a second hop is refused');
// A nested relay is refused outright.
await alice.session._onRelay('A-B', {
type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: carol.session.selfFp,
hopped: false,
inner: { type: GROUP_FRAMES.RELAY, gid: alice.session.groupId, to: bob.session.selfFp },
});
assert.equal(forwarded, 0, 'relays do not nest');
alice.session._send = realSend;
// A frame for another group is ignored entirely.
const otherGid = GroupSession.newId();
await alice.session.handleFrame('A-B', { type: GROUP_FRAMES.MESSAGE, gid: otherGid, epoch: 1, seq: 1 });
// (no throw, no message emitted)
assert.equal(all(alice, 'message').length, 0);
}
// ---------------------------------------------------------------------------
// the envelope: what keeps a signature intact through the chat path
// ---------------------------------------------------------------------------
{
/**
* Stand-in for EnhancedSecureCryptoUtils.sanitizeMessage, which every group
* frame passes through on its way out: DOMPurify escapes the HTML-significant
* characters, control characters go, blank runs collapse, the string is
* trimmed and then cut to 2000 characters.
*/
const sanitizeLikeTheChatPath = (s) => String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
.replace(/\r\n?/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
.substring(0, 2000);
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A body full of exactly the characters that path rewrites.
const hostile = 'if a < b && c > d then "quote" \n\n\n trailing ';
const gid = bob.session.groupId;
const epoch = bob.session.epoch;
const bodyHash = await hashBody(subtle, hostile);
const sig = await signGroupMessage(subtle, bob.session.identity.keyPair.privateKey, {
groupId: gid, epoch, seq: 99, senderFp: bob.session.selfFp, bodyHash,
});
const frame = {
type: GROUP_FRAMES.MESSAGE, gid, epoch, seq: 99, fp: bob.session.selfFp,
ts: Date.now(), body: hostile, sig: toB64(sig),
};
// Without the envelope the chat path would rewrite the body and the
// signature would no longer match — the failure this design exists to avoid.
const bare = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(frame)));
assert.notEqual(bare.body, hostile, 'the chat path really does rewrite a raw body');
// With it, every byte survives.
const wrapped = encodeEnvelope(frame);
const throughTheWire = JSON.parse(sanitizeLikeTheChatPath(JSON.stringify(wrapped)));
assert.deepEqual(throughTheWire, wrapped, 'the envelope passes through untouched');
const recovered = decodeEnvelope(throughTheWire);
assert.equal(recovered.body, hostile, 'the body arrives byte for byte');
// And it verifies on the far side.
await carol.session._onMessage(recovered);
assert.equal(last(carol, 'message').body, hostile);
// The routing hints outside the encoding carry no authority.
assert.throws(() => decodeEnvelope({ ...wrapped, gid: GroupSession.newId() }),
/group id does not match/);
assert.throws(() => decodeEnvelope({ ...wrapped, t: GROUP_FRAMES.ROSTER }),
/type does not match/);
assert.throws(() => decodeEnvelope({ type: 'g_env', gid, t: 'g_msg', d: 'bm90IGpzb24=' }),
/Unexpected token|not valid JSON|no recognisable frame/);
// A frame that would be truncated by the chat path is refused at the source,
// where it is still a clear error rather than a corrupt payload.
assert.throws(
() => encodeEnvelope({ ...frame, body: 'x'.repeat(4000) }),
/exceeds the transport budget/,
);
// Every frame the protocol actually emits fits the budget, including the
// largest one: a roster for a full group.
const bigRoster = {
type: GROUP_FRAMES.ROSTER, gid, epoch: 1, op: 'create', name: 'x'.repeat(64),
adminSpki: toB64(new Uint8Array(120)),
members: Array.from({ length: 8 }, () => 'ab'.repeat(32)),
sig: toB64(new Uint8Array(96)),
};
assert.doesNotThrow(() => encodeEnvelope(bigRoster), 'a full 8-member roster must fit');
}
// ---------------------------------------------------------------------------
// nobody reveals before their own commitment is on the wire
// ---------------------------------------------------------------------------
{
// Ordering regression. Draining held frames before broadcasting our own
// commitment could complete the round on the spot and put a reveal out ahead
// of the commitment it belongs to. The peer then had a reveal it could not
// check and had to hold it, so the ceremony only finished if that one later
// frame arrived — and when it did not, every member sat at "exchanging
// nonces" waiting on a nonce that had already been sent.
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Pair' });
const bob = mesh.makeNode('bob', {});
mesh.connect('alice', 'bob', 'A-B');
const wire = [];
for (const node of [alice, bob]) {
const inner = node.send;
node.send = async (sid, frame) => {
wire.push(`${node.name}:${groupFrameType(frame)}`);
return inner(sid, frame);
};
}
// Bob's session is built later, from `bob.send`, so it picks the wrapper up
// on its own. Alice's already exists and captured the original at
// construction — spy on the session itself.
alice.session._send = alice.send;
await alice.session.init();
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
for (const node of [alice, bob]) {
const commit = wire.indexOf(`${node.name}:${GROUP_FRAMES.COMMIT}`);
const reveal = wire.indexOf(`${node.name}:${GROUP_FRAMES.REVEAL}`);
assert.ok(commit >= 0, `${node.name} must broadcast a commitment`);
assert.ok(reveal >= 0, `${node.name} must broadcast a reveal`);
assert.ok(commit < reveal, `${node.name} must commit before revealing (wire: ${wire.join(' ')})`);
assert.equal(node.session._pendingCeremony.length, 0,
`${node.name} should not need to hold any ceremony frame`);
}
// A two-member group is the smallest one, and it must still converge.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(bob.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(alice.session.sasCode, bob.session.sasCode);
assert.match(alice.session.sasCode, /^\d{7}$/);
}
// ---------------------------------------------------------------------------
// leaving a group frees its members for the next one
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// A group of three losing one is still a group; losing two is not, and the
// admin must be told it ended rather than throwing mid-teardown.
await alice.session.removeMember(carol.session.selfFp);
assert.equal(alice.session.members.size, 2);
await assert.rejects(
() => alice.session.removeMember(bob.session.selfFp),
/cannot drop below two members/,
);
// When the admin leaves, the remaining member's group is over: nobody else
// can sign a roster, so there is no next epoch and no code to compare again.
await alice.session.leave();
assert.ok(last(bob, 'ended'), 'the remaining member is told the group ended');
assert.equal(last(bob, 'ended').reason, 'admin_left');
assert.ok(!bob.session.members.has(alice.session.selfFp));
}
// ---------------------------------------------------------------------------
// a member who leaves disappears from everyone's list
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const carolFp = carol.session.selfFp;
await carol.session.leave();
// The admin's own view is what regressed: it deleted the member internally
// but never told the UI, so a member who had visibly left stayed on screen.
assert.ok(!alice.session.members.has(carolFp), 'the admin drops the departed member');
const adminView = last(alice, 'members');
assert.ok(adminView, 'the admin must emit a member list when someone leaves');
assert.ok(!adminView.members.some((m) => m.fp === carolFp),
'the departed member must be gone from the list the admin renders');
assert.equal(adminView.members.length, 2);
// And the remaining member learns it through the signed roster.
assert.ok(!bob.session.members.has(carolFp), 'the other member drops her too');
assert.ok(!last(bob, 'members').members.some((m) => m.fp === carolFp));
// Membership changed, so the code must be compared again.
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS);
assert.equal(alice.session.sasCode, bob.session.sasCode);
}
// ---------------------------------------------------------------------------
// the admin can invite into a group that is already running
// ---------------------------------------------------------------------------
{
const mesh = makeMesh();
const gid = GroupSession.newId();
const alice = mesh.makeNode('alice', { isAdmin: true, groupId: gid, groupName: 'Field team' });
const bob = mesh.makeNode('bob', {});
const dana = mesh.makeNode('dana', {});
mesh.connect('alice', 'bob', 'A-B');
mesh.connect('alice', 'dana', 'A-D');
await alice.session.init();
await alice.session.invite([{ sessionId: 'A-B', name: 'Bob' }]);
alice.session.confirmSas();
bob.session.confirmSas();
const firstCode = alice.session.sasCode;
const firstEpoch = alice.session.epoch;
assert.equal(alice.session.members.size, 2);
// The group stays usable while the invitation is outstanding — nothing about
// the membership has changed yet.
await alice.session.sendText('before dana');
assert.equal(last(bob, 'message').body, 'before dana');
await alice.session.addMembers([{ sessionId: 'A-D', name: 'Dana' }]);
for (const node of [alice, bob, dana]) {
assert.equal(node.session.members.size, 3, `${node.name} sees three members`);
assert.equal(node.session.epoch, firstEpoch + 1, `${node.name} moved to the next epoch`);
assert.equal(node.session.phase, GROUP_PHASE.AWAITING_SAS, `${node.name} must compare a new code`);
}
// A changed member set means a changed code — the old one no longer says
// anything about who is in the room.
const codes = [alice, bob, dana].map((n) => n.session.sasCode);
assert.equal(new Set(codes).size, 1, 'all three agree on the new code');
assert.notEqual(codes[0], firstCode, 'adding a member must change the code');
// And nothing flows until it is confirmed again, including for the member
// who was already verified a moment ago.
await assert.rejects(() => bob.session.sendText('too early'), /has not been confirmed/);
for (const node of [alice, bob, dana]) node.session.confirmSas();
await alice.session.sendText('welcome dana');
assert.equal(last(dana, 'message').body, 'welcome dana');
assert.equal(last(bob, 'message').body, 'welcome dana');
// Dana can reach Bob even with no direct link, relayed by the admin.
const result = await dana.session.sendText('hello bob');
assert.equal(result.delivered, 2);
assert.equal(last(bob, 'message').body, 'hello bob');
assert.equal(last(bob, 'message').fp, dana.session.selfFp);
}
// ---------------------------------------------------------------------------
// invitation rounds that should not happen
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
// Only the admin invites.
await assert.rejects(
() => bob.session.addMembers([{ sessionId: 'X', name: 'Nope' }]),
/only the admin invites/,
);
// A session already carrying a member cannot be invited again — it would
// answer with a second identity key and take two slots in the safety code.
const bobsSession = [...alice.session.members.values()].find((m) => m.fp === bob.session.selfFp).sessionId;
await assert.rejects(
() => alice.session.addMembers([{ sessionId: bobsSession, name: 'Bob again' }]),
/already a member/,
);
// The ceiling is enforced before anything is sent.
await assert.rejects(
() => alice.session.addMembers(Array.from({ length: 7 }, (_, i) => ({ sessionId: `s${i}`, name: 'x' }))),
/limited to 8 members/,
);
// An add nobody answers leaves the group exactly as it was.
const epochBefore = alice.session.epoch;
const membersBefore = alice.session.members.size;
mesh_unreachable: {
// A link the mesh does not know about: the send fails, so the round is
// abandoned at once rather than half-applied.
await assert.rejects(
() => alice.session.addMembers([{ sessionId: 'nowhere', name: 'Ghost' }]),
/could not be sent/,
);
}
assert.equal(alice.session.epoch, epochBefore, 'a failed invitation does not open an epoch');
assert.equal(alice.session.members.size, membersBefore, 'and does not change the membership');
assert.equal(alice.session._pendingAdd, null, 'the round is cleared');
}
// ---------------------------------------------------------------------------
// a member going offline is not a member leaving
// ---------------------------------------------------------------------------
{
const { alice, bob, carol } = await formGroup();
for (const node of [alice, bob, carol]) node.session.confirmSas();
const carolFp = carol.session.selfFp;
const carolsSession = [...alice.session.members.values()].find((m) => m.fp === carolFp).sessionId;
const epochBefore = alice.session.epoch;
const codeBefore = alice.session.sasCode;
alice.session.setSessionState(carolsSession, false);
// Still a member. Membership is a signed, epoch-ordered fact and a dropped
// connection does not change it — re-keying the group every time someone's
// network hiccups would make everyone re-compare a code for nothing.
assert.ok(alice.session.members.has(carolFp), 'an offline member is still a member');
assert.equal(alice.session.epoch, epochBefore, 'no new epoch for a dropped link');
assert.equal(alice.session.sasCode, codeBefore, 'and no new code to compare');
assert.equal(alice.session.phase, GROUP_PHASE.READY, 'the group stays usable');
// But unmistakably unreachable, and the UI is told.
assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LOST);
const view = last(alice, 'members');
assert.equal(view.members.find((m) => m.fp === carolFp).state, MEMBER_STATE.LOST,
'the rendered list marks them lost rather than dropping or hiding them');
// Sending reports the shortfall instead of pretending it reached everyone,
// and it names WHO was missed. A count alone cannot tell one absent member
// from another, which is what the app needs to know to stop repeating
// itself under every message for as long as somebody stays away.
const result = await alice.session.sendText('anyone there?');
assert.equal(result.total, 2);
assert.ok(result.delivered < result.total, 'delivery to an offline member is reported, not assumed');
assert.deepEqual(result.unreachable.map((m) => m.fp), [carolFp],
'the member who was missed is named, not just counted');
// The same shortfall reported twice is the same shortfall: the app compares
// these sets, so they have to be stable for an unchanged situation.
const again = await alice.session.sendText('still anyone there?');
assert.deepEqual(again.unreachable.map((m) => m.fp), [carolFp]);
// Coming back restores the link with no ceremony at all.
alice.session.setSessionState(carolsSession, true);
assert.equal(alice.session.members.get(carolFp).state, MEMBER_STATE.LINKED);
assert.equal(alice.session.epoch, epochBefore, 'reconnecting does not re-key either');
// Removing them, by contrast, IS a membership change and does re-key.
await alice.session.removeMember(carolFp);
assert.equal(alice.session.epoch, epochBefore + 1);
assert.ok(!alice.session.members.has(carolFp));
assert.equal(alice.session.phase, GROUP_PHASE.AWAITING_SAS, 'a real removal makes everyone compare again');
}
console.log('group-session-e2e.test.mjs: all assertions passed');