agent-comms 2.21.18 → 2.21.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/test/room-lifecycle-membership.test.d.ts +2 -0
- package/dist/test/room-lifecycle-membership.test.d.ts.map +1 -0
- package/dist/test/room-lifecycle-membership.test.js +577 -0
- package/dist/test/room-lifecycle-membership.test.js.map +1 -0
- package/dist/test/room-lifecycle-remote.test.d.ts +2 -0
- package/dist/test/room-lifecycle-remote.test.d.ts.map +1 -0
- package/dist/test/room-lifecycle-remote.test.js +391 -0
- package/dist/test/room-lifecycle-remote.test.js.map +1 -0
- package/dist/test/room-lifecycle.test.d.ts +2 -0
- package/dist/test/room-lifecycle.test.d.ts.map +1 -0
- package/dist/test/room-lifecycle.test.js +523 -0
- package/dist/test/room-lifecycle.test.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ExaDev/agent-comms)
|
|
4
4
|
[](https://www.npmjs.com/package/agent-comms)
|
|
5
|
-
[](https://github.com/ExaDev/agent-comms/releases/tag/v2.21.19)
|
|
6
6
|
[](https://github.com/ExaDev/agent-comms/actions)
|
|
7
7
|
|
|
8
8
|
Cross-harness communication mesh for LLM agents: rooms, DMs, presence, and visibility over TCP with zero filesystem dependencies.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"room-lifecycle-membership.test.d.ts","sourceRoot":"","sources":["../../src/test/room-lifecycle-membership.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct, DI-based unit tests for RoomLifecycle's membership-grant half -- leaveRoom/leaveRemoteRoom, inviteToRoom, kickFromRoom, and destroyRoom. Split from room-lifecycle.test.ts (createRoom/listRooms/joinRoom) and room-lifecycle-remote.test.ts (refreshRoomMembers/requestDmAccess/getRoom/revokeMemberGrant/declineInvite) to stay under this repo's max-lines cap. All three files share an identical preamble (helpers, fakes, makeHarness) by necessity of the split -- see room-lifecycle.test.ts's own header for the full rationale (real identities/tokens, not opaque placeholders, since this class genuinely mints and revokes capability tokens).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id";
|
|
9
|
+
import { mintCapabilityToken } from "wire-mesh-core/domain/tokens";
|
|
10
|
+
import { createSystemClock } from "wire-mesh-core/adapters/system-clock";
|
|
11
|
+
import { createRevocationView } from "wire-mesh-core/domain/revocation-view";
|
|
12
|
+
import { generateIdentity } from "../core/identity.js";
|
|
13
|
+
import { loadOrCreateIdentity, loadIssuedRoomGrant, loadRoomTokens, saveIssuedRoomGrant, saveRoomToken, } from "../core/identity-store.js";
|
|
14
|
+
import { toIdentityPort } from "../core/wire-mesh-identity.js";
|
|
15
|
+
import { ownerNamedRoomPath } from "../core/room-path.js";
|
|
16
|
+
import { randomId } from "../core/random-id.js";
|
|
17
|
+
import { RoomLifecycle, } from "../core/room-lifecycle.js";
|
|
18
|
+
const TOKEN_TTL_MS = 60_000;
|
|
19
|
+
function room(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
id: "room-1",
|
|
22
|
+
version: 1,
|
|
23
|
+
name: "room-name",
|
|
24
|
+
type: "public",
|
|
25
|
+
owner: "",
|
|
26
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
27
|
+
description: "a room",
|
|
28
|
+
members: [],
|
|
29
|
+
invited: [],
|
|
30
|
+
memberJoins: {},
|
|
31
|
+
memberLeaves: {},
|
|
32
|
+
invitedJoins: {},
|
|
33
|
+
invitedLeaves: {},
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function agent(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
id: "",
|
|
40
|
+
version: 1,
|
|
41
|
+
name: "agent-name",
|
|
42
|
+
harness: "pi",
|
|
43
|
+
cwd: "/tmp/agent",
|
|
44
|
+
pid: 1,
|
|
45
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
46
|
+
visibility: "visible",
|
|
47
|
+
status: "active",
|
|
48
|
+
tags: [],
|
|
49
|
+
subscribedRooms: [],
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function makeIdentities() {
|
|
54
|
+
const owner = generateIdentity();
|
|
55
|
+
const member = generateIdentity();
|
|
56
|
+
return {
|
|
57
|
+
ownerId: bytesToHex(Uint8Array.from(owner.deviceId)),
|
|
58
|
+
ownerPort: await toIdentityPort(owner),
|
|
59
|
+
memberId: bytesToHex(Uint8Array.from(member.deviceId)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function mintRoomToken(issuerPort, bearerHex, roomPath) {
|
|
63
|
+
const clock = createSystemClock();
|
|
64
|
+
const verdict = await mintCapabilityToken({
|
|
65
|
+
identity: issuerPort,
|
|
66
|
+
clock,
|
|
67
|
+
tokenId: randomId(),
|
|
68
|
+
bearer: deviceIdFromHex(bearerHex),
|
|
69
|
+
capability: "room:member",
|
|
70
|
+
scope: { kind: "room", path: roomPath },
|
|
71
|
+
expires: clock.now() + TOKEN_TTL_MS,
|
|
72
|
+
delegationsRemaining: 0,
|
|
73
|
+
});
|
|
74
|
+
if (!verdict.ok)
|
|
75
|
+
throw new Error("expected the fixture token to mint successfully");
|
|
76
|
+
return verdict.token;
|
|
77
|
+
}
|
|
78
|
+
async function makeHarness() {
|
|
79
|
+
const ids = await makeIdentities();
|
|
80
|
+
const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-lifecycle-membership-test-"));
|
|
81
|
+
const slot = { harness: "test", cwd: "room-lifecycle", dir: slotDir };
|
|
82
|
+
// saveRoomToken/saveIssuedRoomGrant write into this slot's own persisted identity file, which must already exist on disk -- unrelated to which crypto identity requireIdentity() uses for minting/verifying, purely a filesystem bookkeeping precondition.
|
|
83
|
+
loadOrCreateIdentity(slot);
|
|
84
|
+
const bump = vi.fn((readonlyEntity) => {
|
|
85
|
+
const entity = readonlyEntity;
|
|
86
|
+
entity.version += 1;
|
|
87
|
+
return entity;
|
|
88
|
+
});
|
|
89
|
+
const recordMemberOp = vi.fn((r, list, op, agentId) => {
|
|
90
|
+
const joins = list === "member" ? r.memberJoins : r.invitedJoins;
|
|
91
|
+
const leaves = list === "member" ? r.memberLeaves : r.invitedLeaves;
|
|
92
|
+
if (op === "join")
|
|
93
|
+
joins[agentId] = r.version;
|
|
94
|
+
else
|
|
95
|
+
leaves[agentId] = r.version;
|
|
96
|
+
});
|
|
97
|
+
const refreshMembership = vi.fn((r) => {
|
|
98
|
+
r.members = Object.keys(r.memberJoins).filter((id) => (r.memberJoins[id] ?? 0) > (r.memberLeaves[id] ?? 0));
|
|
99
|
+
r.invited = Object.keys(r.invitedJoins).filter((id) => (r.invitedJoins[id] ?? 0) > (r.invitedLeaves[id] ?? 0));
|
|
100
|
+
});
|
|
101
|
+
const broadcastPatch = vi.fn().mockResolvedValue(undefined);
|
|
102
|
+
const deliverToRoom = vi.fn().mockResolvedValue(undefined);
|
|
103
|
+
const deliverLocallyAndBroadcast = vi.fn().mockResolvedValue(undefined);
|
|
104
|
+
const broadcastRoomJoin = vi.fn().mockResolvedValue(undefined);
|
|
105
|
+
const broadcastRoomLeave = vi.fn().mockResolvedValue(undefined);
|
|
106
|
+
const broadcastRevocation = vi.fn().mockResolvedValue(undefined);
|
|
107
|
+
const sendRoomRequest = vi.fn().mockResolvedValue({
|
|
108
|
+
result: "error",
|
|
109
|
+
code: "not_connected",
|
|
110
|
+
});
|
|
111
|
+
const deps = {
|
|
112
|
+
rooms: new Map(),
|
|
113
|
+
messages: new Map(),
|
|
114
|
+
agents: new Map(),
|
|
115
|
+
dmRequestsInitiatedByMe: new Set(),
|
|
116
|
+
getPeerId: () => ids.ownerId,
|
|
117
|
+
requireIdentity: () => ({
|
|
118
|
+
identity: ids.ownerPort,
|
|
119
|
+
clock: createSystemClock(),
|
|
120
|
+
slot,
|
|
121
|
+
revocation: createRevocationView(),
|
|
122
|
+
}),
|
|
123
|
+
requireTransport: () => ({ sendRoomRequest, broadcastRevocation }),
|
|
124
|
+
deliveryEngine: {
|
|
125
|
+
bump,
|
|
126
|
+
recordMemberOp,
|
|
127
|
+
refreshMembership,
|
|
128
|
+
broadcastPatch,
|
|
129
|
+
deliverToRoom,
|
|
130
|
+
deliverLocallyAndBroadcast,
|
|
131
|
+
},
|
|
132
|
+
federation: { broadcastRoomJoin, broadcastRoomLeave },
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
deps,
|
|
136
|
+
lifecycle: new RoomLifecycle(deps),
|
|
137
|
+
ids,
|
|
138
|
+
slotDir,
|
|
139
|
+
bump,
|
|
140
|
+
recordMemberOp,
|
|
141
|
+
refreshMembership,
|
|
142
|
+
broadcastPatch,
|
|
143
|
+
deliverToRoom,
|
|
144
|
+
deliverLocallyAndBroadcast,
|
|
145
|
+
broadcastRoomJoin,
|
|
146
|
+
broadcastRoomLeave,
|
|
147
|
+
sendRoomRequest,
|
|
148
|
+
broadcastRevocation,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Persists an issued-grant record for memberId in roomPath as if this store's own identity had genuinely admitted them (invite or join), via the same real crypto path inviteToRoom itself uses -- revokeMemberGrant/kickFromRoom/destroyRoom all read this record by its own token-id, not anything derivable from the token or the in-memory Room object afterward. */
|
|
152
|
+
async function seedIssuedGrant(h, roomPath, memberId) {
|
|
153
|
+
const { slot } = h.deps.requireIdentity();
|
|
154
|
+
const tokenId = randomId();
|
|
155
|
+
const clock = createSystemClock();
|
|
156
|
+
const verdict = await mintCapabilityToken({
|
|
157
|
+
identity: h.ids.ownerPort,
|
|
158
|
+
clock,
|
|
159
|
+
tokenId,
|
|
160
|
+
bearer: deviceIdFromHex(memberId),
|
|
161
|
+
capability: "room:member",
|
|
162
|
+
scope: { kind: "room", path: roomPath },
|
|
163
|
+
expires: clock.now() + TOKEN_TTL_MS,
|
|
164
|
+
delegationsRemaining: 0,
|
|
165
|
+
});
|
|
166
|
+
if (!verdict.ok)
|
|
167
|
+
throw new Error("expected the fixture token to mint successfully");
|
|
168
|
+
saveIssuedRoomGrant(slot, roomPath, memberId, tokenId);
|
|
169
|
+
}
|
|
170
|
+
// Two mutants Stryker still raises here are left undocumented-but-untested, both genuinely unreachable with real crypto: inviteToRoom's own `this.deps.rooms.set(roomId, room)` call is a same-object-reference no-op (the identical pattern documented at the top of room-lifecycle.test.ts's joinRoom block), and its `if (!verdict.ok)` MINT_FAILED branch cannot fail against a validly-generated identity for the same reason mintOwnerRootGrant's own failure branch can't (documented at the top of room-lifecycle.test.ts's createRoom block).
|
|
171
|
+
describe("RoomLifecycle — inviteToRoom", () => {
|
|
172
|
+
it("throws ROOM_NOT_FOUND for an unknown room", async () => {
|
|
173
|
+
const h = await makeHarness();
|
|
174
|
+
await expect(h.lifecycle.inviteToRoom("no-such-room", h.ids.memberId, h.ids.ownerId)).rejects.toMatchObject({
|
|
175
|
+
message: "Room no-such-room not found",
|
|
176
|
+
code: "ROOM_NOT_FOUND",
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
it("throws NOT_OWNER when the inviter isn't the room's own owner", async () => {
|
|
180
|
+
const h = await makeHarness();
|
|
181
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId }));
|
|
182
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
183
|
+
result: "ok",
|
|
184
|
+
});
|
|
185
|
+
await expect(h.lifecycle.inviteToRoom("room-1", "some-target", h.ids.memberId)).rejects.toMatchObject({
|
|
186
|
+
message: "Only the room owner can invite",
|
|
187
|
+
code: "NOT_OWNER",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
it("records an invited-join only for a target not already invited or a member, and bumps the room's version", async () => {
|
|
191
|
+
const h = await makeHarness();
|
|
192
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId, version: 1 }));
|
|
193
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
194
|
+
result: "ok",
|
|
195
|
+
});
|
|
196
|
+
await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
197
|
+
expect(h.recordMemberOp).toHaveBeenCalledWith(expect.anything(), "invited", "join", h.ids.memberId);
|
|
198
|
+
expect(h.deps.rooms.get("room-1")?.version).toBe(2);
|
|
199
|
+
});
|
|
200
|
+
it("does not re-record an invited-join for a target already invited", async () => {
|
|
201
|
+
const h = await makeHarness();
|
|
202
|
+
h.deps.rooms.set("room-1", room({
|
|
203
|
+
id: "room-1",
|
|
204
|
+
owner: h.ids.ownerId,
|
|
205
|
+
invited: [h.ids.memberId],
|
|
206
|
+
invitedJoins: { [h.ids.memberId]: 1 },
|
|
207
|
+
}));
|
|
208
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
209
|
+
result: "ok",
|
|
210
|
+
});
|
|
211
|
+
await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
212
|
+
expect(h.recordMemberOp).not.toHaveBeenCalledWith(expect.anything(), "invited", "join", h.ids.memberId);
|
|
213
|
+
});
|
|
214
|
+
it("does not record an invited-join for a target who is already a member", async () => {
|
|
215
|
+
const h = await makeHarness();
|
|
216
|
+
h.deps.rooms.set("room-1", room({
|
|
217
|
+
id: "room-1",
|
|
218
|
+
owner: h.ids.ownerId,
|
|
219
|
+
members: [h.ids.memberId],
|
|
220
|
+
memberJoins: { [h.ids.memberId]: 1 },
|
|
221
|
+
}));
|
|
222
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
223
|
+
result: "ok",
|
|
224
|
+
});
|
|
225
|
+
await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
226
|
+
expect(h.recordMemberOp).not.toHaveBeenCalledWith(expect.anything(), "invited", "join", h.ids.memberId);
|
|
227
|
+
});
|
|
228
|
+
it("persists an issued-grant record for the invited target", async () => {
|
|
229
|
+
const h = await makeHarness();
|
|
230
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId }));
|
|
231
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
232
|
+
result: "ok",
|
|
233
|
+
});
|
|
234
|
+
await h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
235
|
+
const { slot } = h.deps.requireIdentity();
|
|
236
|
+
expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeDefined();
|
|
237
|
+
});
|
|
238
|
+
it("includes the inviter-agent extension only when the inviter has a local agent record", async () => {
|
|
239
|
+
const withInviter = await makeHarness();
|
|
240
|
+
withInviter.deps.rooms.set("room-1", room({ id: "room-1", owner: withInviter.ids.ownerId }));
|
|
241
|
+
withInviter.deps.agents.set(withInviter.ids.ownerId, agent({ id: withInviter.ids.ownerId, name: "inviter-name" }));
|
|
242
|
+
withInviter.sendRoomRequest.mockResolvedValue({
|
|
243
|
+
result: "ok",
|
|
244
|
+
});
|
|
245
|
+
await withInviter.lifecycle.inviteToRoom("room-1", withInviter.ids.memberId, withInviter.ids.ownerId);
|
|
246
|
+
const params = withInviter.sendRoomRequest.mock.calls[0]?.[1]
|
|
247
|
+
?.params;
|
|
248
|
+
expect(params).toHaveProperty("inviter-agent");
|
|
249
|
+
const withoutInviter = await makeHarness();
|
|
250
|
+
withoutInviter.deps.rooms.set("room-1", room({ id: "room-1", owner: withoutInviter.ids.ownerId }));
|
|
251
|
+
withoutInviter.sendRoomRequest.mockResolvedValue({
|
|
252
|
+
result: "ok",
|
|
253
|
+
});
|
|
254
|
+
await withoutInviter.lifecycle.inviteToRoom("room-1", withoutInviter.ids.memberId, withoutInviter.ids.ownerId);
|
|
255
|
+
const params2 = withoutInviter.sendRoomRequest.mock.calls[0]?.[1]
|
|
256
|
+
?.params;
|
|
257
|
+
expect(params2).not.toHaveProperty("inviter-agent");
|
|
258
|
+
});
|
|
259
|
+
it("throws INVITE_FAILED naming the outcome code on a non-ok outcome", async () => {
|
|
260
|
+
const h = await makeHarness();
|
|
261
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId }));
|
|
262
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
263
|
+
result: "error",
|
|
264
|
+
code: "not_reachable",
|
|
265
|
+
});
|
|
266
|
+
await expect(h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId)).rejects.toMatchObject({
|
|
267
|
+
message: `Invite to ${h.ids.memberId} for room-1 failed (not_reachable)`,
|
|
268
|
+
code: "INVITE_FAILED",
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
it("resolves without throwing on a genuinely successful invite, sent scoped to the room path", async () => {
|
|
272
|
+
const h = await makeHarness();
|
|
273
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId }));
|
|
274
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
275
|
+
result: "ok",
|
|
276
|
+
});
|
|
277
|
+
await expect(h.lifecycle.inviteToRoom("room-1", h.ids.memberId, h.ids.ownerId)).resolves.toBeUndefined();
|
|
278
|
+
expect(h.sendRoomRequest).toHaveBeenCalledWith(h.ids.memberId, expect.anything(), { kind: "room", path: "room-1" });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
// kickFromRoom's own trailing `this.deps.rooms.set(roomId, room)` call is the same same-object-reference no-op documented at the top of room-lifecycle.test.ts's joinRoom block -- `room` is already the map's own stored reference by the time this runs.
|
|
282
|
+
describe("RoomLifecycle — kickFromRoom", () => {
|
|
283
|
+
it("throws ROOM_NOT_FOUND for an unknown room", async () => {
|
|
284
|
+
const h = await makeHarness();
|
|
285
|
+
await expect(h.lifecycle.kickFromRoom("no-such-room", h.ids.memberId, h.ids.ownerId)).rejects.toMatchObject({
|
|
286
|
+
message: "Room no-such-room not found",
|
|
287
|
+
code: "ROOM_NOT_FOUND",
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
it("throws NOT_OWNER when the kicker isn't the room's own owner", async () => {
|
|
291
|
+
const h = await makeHarness();
|
|
292
|
+
h.deps.rooms.set("room-1", room({
|
|
293
|
+
id: "room-1",
|
|
294
|
+
owner: h.ids.ownerId,
|
|
295
|
+
members: [h.ids.memberId],
|
|
296
|
+
memberJoins: { [h.ids.memberId]: 1 },
|
|
297
|
+
}));
|
|
298
|
+
await expect(h.lifecycle.kickFromRoom("room-1", h.ids.memberId, "not-the-owner")).rejects.toMatchObject({
|
|
299
|
+
message: "Only the room owner can kick",
|
|
300
|
+
code: "NOT_OWNER",
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
it("revokes the target's own issued grant", async () => {
|
|
304
|
+
const h = await makeHarness();
|
|
305
|
+
h.deps.rooms.set("room-1", room({
|
|
306
|
+
id: "room-1",
|
|
307
|
+
owner: h.ids.ownerId,
|
|
308
|
+
members: [h.ids.memberId],
|
|
309
|
+
memberJoins: { [h.ids.memberId]: 1 },
|
|
310
|
+
}));
|
|
311
|
+
await seedIssuedGrant(h, "room-1", h.ids.memberId);
|
|
312
|
+
await h.lifecycle.kickFromRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
313
|
+
const { slot } = h.deps.requireIdentity();
|
|
314
|
+
expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeUndefined();
|
|
315
|
+
expect(h.broadcastRevocation).toHaveBeenCalledTimes(1);
|
|
316
|
+
});
|
|
317
|
+
it("removes the target from both the member and invited lists, and bumps the room's version", async () => {
|
|
318
|
+
const h = await makeHarness();
|
|
319
|
+
h.deps.rooms.set("room-1", room({
|
|
320
|
+
id: "room-1",
|
|
321
|
+
version: 1,
|
|
322
|
+
owner: h.ids.ownerId,
|
|
323
|
+
members: [h.ids.memberId],
|
|
324
|
+
memberJoins: { [h.ids.memberId]: 1 },
|
|
325
|
+
}));
|
|
326
|
+
await h.lifecycle.kickFromRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
327
|
+
expect(h.recordMemberOp).toHaveBeenCalledWith(expect.anything(), "member", "leave", h.ids.memberId);
|
|
328
|
+
expect(h.recordMemberOp).toHaveBeenCalledWith(expect.anything(), "invited", "leave", h.ids.memberId);
|
|
329
|
+
const updated = h.deps.rooms.get("room-1");
|
|
330
|
+
expect(updated?.version).toBe(2);
|
|
331
|
+
expect(updated?.members).not.toContain(h.ids.memberId);
|
|
332
|
+
});
|
|
333
|
+
it("broadcasts the updated room", async () => {
|
|
334
|
+
const h = await makeHarness();
|
|
335
|
+
h.deps.rooms.set("room-1", room({
|
|
336
|
+
id: "room-1",
|
|
337
|
+
owner: h.ids.ownerId,
|
|
338
|
+
members: [h.ids.memberId],
|
|
339
|
+
memberJoins: { [h.ids.memberId]: 1 },
|
|
340
|
+
}));
|
|
341
|
+
await h.lifecycle.kickFromRoom("room-1", h.ids.memberId, h.ids.ownerId);
|
|
342
|
+
expect(h.broadcastPatch).toHaveBeenCalledWith(expect.objectContaining({ type: "room_upsert" }));
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
// destroyRoom's per-member `this.deps.agents.set(memberId, member)` call is the same same-object-reference no-op documented at the top of room-lifecycle.test.ts's joinRoom block -- `member` is fetched via `this.deps.agents.get(memberId)` and mutated (its `subscribedRooms` property reassigned) in place before this call, so re-setting the map entry to the identical reference it already holds changes nothing observable.
|
|
346
|
+
describe("RoomLifecycle — destroyRoom", () => {
|
|
347
|
+
it("throws ROOM_NOT_FOUND for an unknown room", async () => {
|
|
348
|
+
const h = await makeHarness();
|
|
349
|
+
await expect(h.lifecycle.destroyRoom("no-such-room", h.ids.ownerId)).rejects.toMatchObject({
|
|
350
|
+
message: "Room no-such-room not found",
|
|
351
|
+
code: "ROOM_NOT_FOUND",
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
it("throws NOT_OWNER when the requester isn't the room's own owner", async () => {
|
|
355
|
+
const h = await makeHarness();
|
|
356
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId }));
|
|
357
|
+
await expect(h.lifecycle.destroyRoom("room-1", h.ids.memberId)).rejects.toMatchObject({
|
|
358
|
+
message: "Only the room owner can destroy",
|
|
359
|
+
code: "NOT_OWNER",
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
it("revokes every member's own issued grant", async () => {
|
|
363
|
+
const h = await makeHarness();
|
|
364
|
+
h.deps.rooms.set("room-1", room({
|
|
365
|
+
id: "room-1",
|
|
366
|
+
owner: h.ids.ownerId,
|
|
367
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
368
|
+
}));
|
|
369
|
+
await seedIssuedGrant(h, "room-1", h.ids.memberId);
|
|
370
|
+
await h.lifecycle.destroyRoom("room-1", h.ids.ownerId);
|
|
371
|
+
const { slot } = h.deps.requireIdentity();
|
|
372
|
+
expect(loadIssuedRoomGrant(slot, "room-1", h.ids.memberId)).toBeUndefined();
|
|
373
|
+
});
|
|
374
|
+
it("updates subscribedRooms and broadcasts agent_upsert only for members with a local agent record", async () => {
|
|
375
|
+
const h = await makeHarness();
|
|
376
|
+
h.deps.rooms.set("room-1", room({
|
|
377
|
+
id: "room-1",
|
|
378
|
+
owner: h.ids.ownerId,
|
|
379
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
380
|
+
}));
|
|
381
|
+
h.deps.agents.set(h.ids.memberId, agent({ id: h.ids.memberId, subscribedRooms: ["room-1", "other-room"] }));
|
|
382
|
+
await h.lifecycle.destroyRoom("room-1", h.ids.ownerId);
|
|
383
|
+
expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toEqual([
|
|
384
|
+
"other-room",
|
|
385
|
+
]);
|
|
386
|
+
expect(h.broadcastPatch).toHaveBeenCalledWith(expect.objectContaining({ type: "agent_upsert" }));
|
|
387
|
+
});
|
|
388
|
+
it("skips the agent_upsert broadcast for a member with no local agent record", async () => {
|
|
389
|
+
const h = await makeHarness();
|
|
390
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId, members: [h.ids.memberId] }));
|
|
391
|
+
await h.lifecycle.destroyRoom("room-1", h.ids.ownerId);
|
|
392
|
+
expect(h.broadcastPatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: "agent_upsert" }));
|
|
393
|
+
});
|
|
394
|
+
it("deletes the room and its message history, and broadcasts room_delete", async () => {
|
|
395
|
+
const h = await makeHarness();
|
|
396
|
+
h.deps.rooms.set("room-1", room({ id: "room-1", owner: h.ids.ownerId, members: [] }));
|
|
397
|
+
h.deps.messages.set("room-1", []);
|
|
398
|
+
await h.lifecycle.destroyRoom("room-1", h.ids.ownerId);
|
|
399
|
+
expect(h.deps.rooms.has("room-1")).toBe(false);
|
|
400
|
+
expect(h.deps.messages.has("room-1")).toBe(false);
|
|
401
|
+
expect(h.broadcastPatch).toHaveBeenCalledWith({
|
|
402
|
+
type: "room_delete",
|
|
403
|
+
roomId: "room-1",
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
// leaveRoom's own `this.deps.rooms.set(roomId, room)` and `this.deps.agents.set(agentId, agent)` calls are the same same-object-reference no-ops documented at the top of room-lifecycle.test.ts's joinRoom block.
|
|
408
|
+
describe("RoomLifecycle — leaveRoom / leaveRemoteRoom", () => {
|
|
409
|
+
it("throws ROOM_NOT_FOUND for an unknown room", async () => {
|
|
410
|
+
const h = await makeHarness();
|
|
411
|
+
await expect(h.lifecycle.leaveRoom("no-such-room", h.ids.ownerId)).rejects.toMatchObject({
|
|
412
|
+
message: "Room no-such-room not found",
|
|
413
|
+
code: "ROOM_NOT_FOUND",
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
it("goes remote when this store's own agent leaves a room it does not own, omitting reason entirely (not just as undefined)", async () => {
|
|
417
|
+
const h = await makeHarness();
|
|
418
|
+
const roomPath = ownerNamedRoomPath(h.ids.memberId, "r");
|
|
419
|
+
h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId }));
|
|
420
|
+
const { slot } = h.deps.requireIdentity();
|
|
421
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath);
|
|
422
|
+
saveRoomToken(slot, roomPath, token);
|
|
423
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
424
|
+
result: "ok",
|
|
425
|
+
});
|
|
426
|
+
await h.lifecycle.leaveRoom(roomPath, h.ids.ownerId);
|
|
427
|
+
expect(h.sendRoomRequest).toHaveBeenCalled();
|
|
428
|
+
expect(h.deps.rooms.has(roomPath)).toBe(false);
|
|
429
|
+
const params = h.sendRoomRequest.mock.calls[0]?.[1]?.params;
|
|
430
|
+
expect(params).not.toHaveProperty("reason");
|
|
431
|
+
});
|
|
432
|
+
it("takes the local path when this store's own agent leaves a room it owns", async () => {
|
|
433
|
+
const h = await makeHarness();
|
|
434
|
+
h.deps.rooms.set("room-1", room({
|
|
435
|
+
id: "room-1",
|
|
436
|
+
owner: h.ids.ownerId,
|
|
437
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
438
|
+
memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 },
|
|
439
|
+
}));
|
|
440
|
+
await h.lifecycle.leaveRoom("room-1", h.ids.ownerId);
|
|
441
|
+
expect(h.sendRoomRequest).not.toHaveBeenCalled();
|
|
442
|
+
});
|
|
443
|
+
it("always takes the local path for an agent other than this store's own peer", async () => {
|
|
444
|
+
const h = await makeHarness();
|
|
445
|
+
h.deps.rooms.set("room-1", room({
|
|
446
|
+
id: "room-1",
|
|
447
|
+
owner: h.ids.ownerId,
|
|
448
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
449
|
+
memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 },
|
|
450
|
+
}));
|
|
451
|
+
await h.lifecycle.leaveRoom("room-1", h.ids.memberId);
|
|
452
|
+
expect(h.sendRoomRequest).not.toHaveBeenCalled();
|
|
453
|
+
expect(h.deps.rooms.get("room-1")?.members).not.toContain(h.ids.memberId);
|
|
454
|
+
});
|
|
455
|
+
it("updates subscribedRooms and broadcasts agent_upsert only for a known agent", async () => {
|
|
456
|
+
const h = await makeHarness();
|
|
457
|
+
h.deps.rooms.set("room-1", room({
|
|
458
|
+
id: "room-1",
|
|
459
|
+
owner: h.ids.ownerId,
|
|
460
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
461
|
+
memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 },
|
|
462
|
+
}));
|
|
463
|
+
h.deps.agents.set(h.ids.memberId, agent({
|
|
464
|
+
id: h.ids.memberId,
|
|
465
|
+
subscribedRooms: ["room-1", "other-room"],
|
|
466
|
+
}));
|
|
467
|
+
await h.lifecycle.leaveRoom("room-1", h.ids.memberId);
|
|
468
|
+
expect(h.deps.agents.get(h.ids.memberId)?.subscribedRooms).toEqual([
|
|
469
|
+
"other-room",
|
|
470
|
+
]);
|
|
471
|
+
expect(h.broadcastPatch).toHaveBeenCalledWith(expect.objectContaining({ type: "agent_upsert" }));
|
|
472
|
+
});
|
|
473
|
+
it("skips the agent_upsert broadcast when the leaving agent has no local record", async () => {
|
|
474
|
+
const h = await makeHarness();
|
|
475
|
+
h.deps.rooms.set("room-1", room({
|
|
476
|
+
id: "room-1",
|
|
477
|
+
owner: h.ids.ownerId,
|
|
478
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
479
|
+
memberJoins: { [h.ids.ownerId]: 1, [h.ids.memberId]: 1 },
|
|
480
|
+
}));
|
|
481
|
+
await h.lifecycle.leaveRoom("room-1", h.ids.memberId);
|
|
482
|
+
expect(h.broadcastPatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: "agent_upsert" }));
|
|
483
|
+
});
|
|
484
|
+
it("notifies federated links only when the room is federated", async () => {
|
|
485
|
+
const federated = await makeHarness();
|
|
486
|
+
federated.deps.rooms.set("room-1", room({
|
|
487
|
+
id: "room-1",
|
|
488
|
+
owner: federated.ids.ownerId,
|
|
489
|
+
members: [federated.ids.ownerId, federated.ids.memberId],
|
|
490
|
+
memberJoins: {
|
|
491
|
+
[federated.ids.ownerId]: 1,
|
|
492
|
+
[federated.ids.memberId]: 1,
|
|
493
|
+
},
|
|
494
|
+
federated: true,
|
|
495
|
+
}));
|
|
496
|
+
await federated.lifecycle.leaveRoom("room-1", federated.ids.memberId);
|
|
497
|
+
expect(federated.broadcastRoomLeave).toHaveBeenCalledWith("room-1", federated.ids.memberId);
|
|
498
|
+
const plain = await makeHarness();
|
|
499
|
+
plain.deps.rooms.set("room-1", room({
|
|
500
|
+
id: "room-1",
|
|
501
|
+
owner: plain.ids.ownerId,
|
|
502
|
+
members: [plain.ids.ownerId, plain.ids.memberId],
|
|
503
|
+
memberJoins: { [plain.ids.ownerId]: 1, [plain.ids.memberId]: 1 },
|
|
504
|
+
federated: false,
|
|
505
|
+
}));
|
|
506
|
+
await plain.lifecycle.leaveRoom("room-1", plain.ids.memberId);
|
|
507
|
+
expect(plain.broadcastRoomLeave).not.toHaveBeenCalled();
|
|
508
|
+
});
|
|
509
|
+
it("destroys the room when the last remaining member is also its own owner", async () => {
|
|
510
|
+
const h = await makeHarness();
|
|
511
|
+
h.deps.rooms.set("room-1", room({
|
|
512
|
+
id: "room-1",
|
|
513
|
+
owner: h.ids.ownerId,
|
|
514
|
+
members: [h.ids.ownerId],
|
|
515
|
+
memberJoins: { [h.ids.ownerId]: 1 },
|
|
516
|
+
}));
|
|
517
|
+
await h.lifecycle.leaveRoom("room-1", h.ids.ownerId);
|
|
518
|
+
expect(h.deps.rooms.has("room-1")).toBe(false);
|
|
519
|
+
expect(h.broadcastPatch).toHaveBeenCalledWith({
|
|
520
|
+
type: "room_delete",
|
|
521
|
+
roomId: "room-1",
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
it("does not destroy the room when the last remaining member leaving is not its owner", async () => {
|
|
525
|
+
const h = await makeHarness();
|
|
526
|
+
h.deps.rooms.set("room-1", room({
|
|
527
|
+
id: "room-1",
|
|
528
|
+
owner: h.ids.ownerId,
|
|
529
|
+
members: [h.ids.memberId],
|
|
530
|
+
memberJoins: { [h.ids.memberId]: 1 },
|
|
531
|
+
}));
|
|
532
|
+
await h.lifecycle.leaveRoom("room-1", h.ids.memberId);
|
|
533
|
+
expect(h.deps.rooms.has("room-1")).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
it("leaveRemoteRoom throws NOT_MEMBER when no token is persisted for the target room", async () => {
|
|
536
|
+
const h = await makeHarness();
|
|
537
|
+
const roomPath = ownerNamedRoomPath(h.ids.memberId, "r");
|
|
538
|
+
h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId }));
|
|
539
|
+
await expect(h.lifecycle.leaveRoom(roomPath, h.ids.ownerId)).rejects.toMatchObject({
|
|
540
|
+
message: `No room:member token for ${roomPath}`,
|
|
541
|
+
code: "NOT_MEMBER",
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
it("leaveRemoteRoom throws LEAVE_FAILED naming the outcome code on a non-ok outcome", async () => {
|
|
545
|
+
const h = await makeHarness();
|
|
546
|
+
const roomPath = ownerNamedRoomPath(h.ids.memberId, "r");
|
|
547
|
+
h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId }));
|
|
548
|
+
const { slot } = h.deps.requireIdentity();
|
|
549
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath);
|
|
550
|
+
saveRoomToken(slot, roomPath, token);
|
|
551
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
552
|
+
result: "error",
|
|
553
|
+
code: "not_a_member",
|
|
554
|
+
});
|
|
555
|
+
await expect(h.lifecycle.leaveRoom(roomPath, h.ids.ownerId)).rejects.toMatchObject({
|
|
556
|
+
message: `Leaving ${roomPath} failed (not_a_member)`,
|
|
557
|
+
code: "LEAVE_FAILED",
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
it("leaveRemoteRoom deletes the persisted token and every local record on success", async () => {
|
|
561
|
+
const h = await makeHarness();
|
|
562
|
+
const roomPath = ownerNamedRoomPath(h.ids.memberId, "r");
|
|
563
|
+
h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.memberId }));
|
|
564
|
+
h.deps.messages.set(roomPath, []);
|
|
565
|
+
const { slot } = h.deps.requireIdentity();
|
|
566
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath);
|
|
567
|
+
saveRoomToken(slot, roomPath, token);
|
|
568
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
569
|
+
result: "ok",
|
|
570
|
+
});
|
|
571
|
+
await h.lifecycle.leaveRoom(roomPath, h.ids.ownerId);
|
|
572
|
+
expect(loadRoomTokens(slot)[roomPath]).toBeUndefined();
|
|
573
|
+
expect(h.deps.rooms.has(roomPath)).toBe(false);
|
|
574
|
+
expect(h.deps.messages.has(roomPath)).toBe(false);
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
//# sourceMappingURL=room-lifecycle-membership.test.js.map
|