agent-comms 2.21.16 → 2.21.17
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-protocol-admission.test.d.ts +2 -0
- package/dist/test/room-protocol-admission.test.d.ts.map +1 -0
- package/dist/test/room-protocol-admission.test.js +578 -0
- package/dist/test/room-protocol-admission.test.js.map +1 -0
- package/dist/test/room-protocol.test.d.ts +2 -0
- package/dist/test/room-protocol.test.d.ts.map +1 -0
- package/dist/test/room-protocol.test.js +580 -0
- package/dist/test/room-protocol.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.17)
|
|
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-protocol-admission.test.d.ts","sourceRoot":"","sources":["../../src/test/room-protocol-admission.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct, DI-based unit tests for RoomProtocol -- the receiving/responding half of the room wire protocol, previously exercised only through real two-peer TCP/TLS integration tests (room-send-directed, room-read-directed, dm-admission, room-join-admission, etc.), which cover the happy path end-to-end but not most individual error codes, optional-field spreads, and edge branches. RoomProtocolDeps is a narrow, injectable surface built exactly for direct testing, but token verification here is genuinely cryptographic (verifyRoomToken), so tests use real identities (generateIdentity/toIdentityPort) and real minted tokens (mintCapabilityToken) rather than opaque placeholders -- unlike room-messaging.ts's sending side, which never inspects a token's own structure.
|
|
3
|
+
*
|
|
4
|
+
* Split across two files to stay under this repo's max-lines cap: this file covers sendRoomRequestToMember/flushPendingRoomRequests, handleRoomJoin/admitRoomJoin, acceptRoomJoin/rejectRoomJoin, handleRoomInvite, and handleRoomLeave. See room-protocol.test.ts for handleRoomSend, handleRoomRead, handleRoomMembers, and sendRoomMessageDirected.
|
|
5
|
+
*/
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id";
|
|
11
|
+
import { mintCapabilityToken } from "wire-mesh-core/domain/tokens";
|
|
12
|
+
import { createSystemClock } from "wire-mesh-core/adapters/system-clock";
|
|
13
|
+
import { createRevocationView } from "wire-mesh-core/domain/revocation-view";
|
|
14
|
+
import { generateIdentity } from "../core/identity.js";
|
|
15
|
+
import { loadOrCreateIdentity, loadRoomTokens, saveRoomToken, } from "../core/identity-store.js";
|
|
16
|
+
import { toIdentityPort } from "../core/wire-mesh-identity.js";
|
|
17
|
+
import { dmRoomPath, ownerNamedRoomPath } from "../core/room-path.js";
|
|
18
|
+
import { RoomProtocol } from "../core/room-protocol.js";
|
|
19
|
+
import { randomId } from "../core/random-id.js";
|
|
20
|
+
const TOKEN_TTL_MS = 60_000;
|
|
21
|
+
const MAX_QUEUED_DELIVERIES_PER_AGENT = 100;
|
|
22
|
+
/** A device-id is a 64-character lowercase hex SHA-256 digest; room-path.ts's assertDeviceIdHex rejects anything shorter. */
|
|
23
|
+
const DEVICE_ID_HEX_LENGTH = 64;
|
|
24
|
+
/** Distinct, real random ids for fixture message/ref/token ids -- randomId() is the exact utility production code (admitRoomJoin, sendRoomMessageDirected) uses for the identical purpose, so these fixtures need no arbitrary literal bytes of their own. */
|
|
25
|
+
const SAMPLE_MESSAGE_ID = randomId();
|
|
26
|
+
/** A message-id this store will never recognise, standing in for "already expired from history". */
|
|
27
|
+
const UNRECOGNISED_MESSAGE_ID = randomId();
|
|
28
|
+
/** Arbitrary, distinctive placeholder bytes for a message-ref's own id. */
|
|
29
|
+
const SAMPLE_REF_ID = randomId();
|
|
30
|
+
const SAMPLE_TOKEN_ID = randomId();
|
|
31
|
+
function room(overrides = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id: "room-1",
|
|
34
|
+
version: 1,
|
|
35
|
+
name: "room-name",
|
|
36
|
+
type: "public",
|
|
37
|
+
owner: "",
|
|
38
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
39
|
+
description: "a room",
|
|
40
|
+
members: [],
|
|
41
|
+
invited: [],
|
|
42
|
+
memberJoins: {},
|
|
43
|
+
memberLeaves: {},
|
|
44
|
+
invitedJoins: {},
|
|
45
|
+
invitedLeaves: {},
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function agent(overrides = {}) {
|
|
50
|
+
return {
|
|
51
|
+
id: "",
|
|
52
|
+
version: 1,
|
|
53
|
+
name: "agent-name",
|
|
54
|
+
harness: "pi",
|
|
55
|
+
cwd: "/tmp/agent",
|
|
56
|
+
pid: 1,
|
|
57
|
+
startedAt: "2026-01-01T00:00:00.000Z",
|
|
58
|
+
visibility: "visible",
|
|
59
|
+
status: "active",
|
|
60
|
+
tags: [],
|
|
61
|
+
subscribedRooms: [],
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function makeIdentities() {
|
|
66
|
+
const owner = generateIdentity();
|
|
67
|
+
const member = generateIdentity();
|
|
68
|
+
return {
|
|
69
|
+
ownerId: bytesToHex(Uint8Array.from(owner.deviceId)),
|
|
70
|
+
ownerPort: await toIdentityPort(owner),
|
|
71
|
+
memberId: bytesToHex(Uint8Array.from(member.deviceId)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function mintRoomToken(issuerPort, bearerHex, roomPath) {
|
|
75
|
+
const clock = createSystemClock();
|
|
76
|
+
const verdict = await mintCapabilityToken({
|
|
77
|
+
identity: issuerPort,
|
|
78
|
+
clock,
|
|
79
|
+
tokenId: SAMPLE_TOKEN_ID,
|
|
80
|
+
bearer: deviceIdFromHex(bearerHex),
|
|
81
|
+
capability: "room:member",
|
|
82
|
+
scope: { kind: "room", path: roomPath },
|
|
83
|
+
expires: clock.now() + TOKEN_TTL_MS,
|
|
84
|
+
delegationsRemaining: 0,
|
|
85
|
+
});
|
|
86
|
+
if (!verdict.ok)
|
|
87
|
+
throw new Error("expected the fixture token to mint successfully");
|
|
88
|
+
return verdict.token;
|
|
89
|
+
}
|
|
90
|
+
async function makeHarness() {
|
|
91
|
+
const ids = await makeIdentities();
|
|
92
|
+
const slotDir = fs.mkdtempSync(path.join(tmpdir(), "room-protocol-test-"));
|
|
93
|
+
const slot = { harness: "test", cwd: "room-protocol", dir: slotDir };
|
|
94
|
+
// 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.
|
|
95
|
+
loadOrCreateIdentity(slot);
|
|
96
|
+
const queueDelivery = vi.fn();
|
|
97
|
+
const fireLocalDelivery = vi.fn();
|
|
98
|
+
const bump = vi.fn();
|
|
99
|
+
const recordMemberOp = vi.fn();
|
|
100
|
+
const refreshMembership = vi.fn();
|
|
101
|
+
const broadcastPatch = vi.fn().mockResolvedValue(undefined);
|
|
102
|
+
const deliverToRoom = vi.fn().mockResolvedValue(undefined);
|
|
103
|
+
const revokeMemberGrant = vi.fn().mockResolvedValue(undefined);
|
|
104
|
+
const sendRoomRequest = vi
|
|
105
|
+
.fn()
|
|
106
|
+
.mockResolvedValue({ result: "ok" });
|
|
107
|
+
const deps = {
|
|
108
|
+
rooms: new Map(),
|
|
109
|
+
messages: new Map(),
|
|
110
|
+
dms: new Map(),
|
|
111
|
+
agents: new Map(),
|
|
112
|
+
dmRequestsInitiatedByMe: new Set(),
|
|
113
|
+
getPeerId: () => ids.ownerId,
|
|
114
|
+
requireIdentity: () => ({
|
|
115
|
+
identity: ids.ownerPort,
|
|
116
|
+
clock: createSystemClock(),
|
|
117
|
+
slot,
|
|
118
|
+
revocation: createRevocationView(),
|
|
119
|
+
}),
|
|
120
|
+
requireTransport: () => ({ sendRoomRequest }),
|
|
121
|
+
deliveryEngine: {
|
|
122
|
+
queueDelivery,
|
|
123
|
+
fireLocalDelivery,
|
|
124
|
+
bump,
|
|
125
|
+
recordMemberOp,
|
|
126
|
+
refreshMembership,
|
|
127
|
+
broadcastPatch,
|
|
128
|
+
deliverToRoom,
|
|
129
|
+
},
|
|
130
|
+
revokeMemberGrant,
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
deps,
|
|
134
|
+
protocol: new RoomProtocol(deps),
|
|
135
|
+
ids,
|
|
136
|
+
slotDir,
|
|
137
|
+
queueDelivery,
|
|
138
|
+
fireLocalDelivery,
|
|
139
|
+
bump,
|
|
140
|
+
recordMemberOp,
|
|
141
|
+
refreshMembership,
|
|
142
|
+
broadcastPatch,
|
|
143
|
+
deliverToRoom,
|
|
144
|
+
revokeMemberGrant,
|
|
145
|
+
sendRoomRequest,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function handle(deviceHex) {
|
|
149
|
+
return { id: deviceHex };
|
|
150
|
+
}
|
|
151
|
+
function manageRequest(overrides = {}) {
|
|
152
|
+
return {
|
|
153
|
+
requestId: 1,
|
|
154
|
+
command: { verb: "room:member", params: overrides.params ?? {} },
|
|
155
|
+
scope: overrides.scope ?? { kind: "room", path: "room-1" },
|
|
156
|
+
token: overrides.token,
|
|
157
|
+
respond: vi.fn().mockResolvedValue(undefined),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
describe("RoomProtocol — sendRoomRequestToMember / flushPendingRoomRequests", () => {
|
|
161
|
+
/** flushPendingRoomRequests drops (rather than retries) any queued entry whose room this store holds no persisted token for -- so every test that expects a real retry needs one. The token's own cryptographic validity for the specific path doesn't matter here: flushPendingRoomRequests only checks presence via loadRoomTokens, never re-verifies it (that happens receiver-side). */
|
|
162
|
+
async function persistToken(h, roomPath) {
|
|
163
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath);
|
|
164
|
+
saveRoomToken({ harness: "test", cwd: "room-protocol", dir: h.slotDir }, roomPath, token);
|
|
165
|
+
}
|
|
166
|
+
it("queues for retry when the transport throws outright", async () => {
|
|
167
|
+
const h = await makeHarness();
|
|
168
|
+
await persistToken(h, "room-1");
|
|
169
|
+
h.sendRoomRequest.mockRejectedValue(new Error("connection dropped"));
|
|
170
|
+
await h.protocol.sendRoomRequestToMember(h.ids.memberId, "room-1", "fake-token", { verb: "room.send" });
|
|
171
|
+
// Observable only via flushPendingRoomRequests actually retrying it once reachable.
|
|
172
|
+
h.sendRoomRequest.mockResolvedValue({ result: "ok" });
|
|
173
|
+
await h.protocol.flushPendingRoomRequests(h.ids.memberId);
|
|
174
|
+
expect(h.sendRoomRequest).toHaveBeenCalledTimes(2);
|
|
175
|
+
});
|
|
176
|
+
it("queues for retry when the transport resolves with a non-ok outcome", async () => {
|
|
177
|
+
const h = await makeHarness();
|
|
178
|
+
await persistToken(h, "room-1");
|
|
179
|
+
h.sendRoomRequest.mockResolvedValueOnce({
|
|
180
|
+
result: "error",
|
|
181
|
+
code: "not_connected",
|
|
182
|
+
});
|
|
183
|
+
await h.protocol.sendRoomRequestToMember(h.ids.memberId, "room-1", "fake-token", { verb: "room.send" });
|
|
184
|
+
h.sendRoomRequest.mockResolvedValue({ result: "ok" });
|
|
185
|
+
await h.protocol.flushPendingRoomRequests(h.ids.memberId);
|
|
186
|
+
expect(h.sendRoomRequest).toHaveBeenCalledTimes(2);
|
|
187
|
+
});
|
|
188
|
+
it("does not queue anything when the transport succeeds", async () => {
|
|
189
|
+
const h = await makeHarness();
|
|
190
|
+
await persistToken(h, "room-1");
|
|
191
|
+
h.sendRoomRequest.mockResolvedValue({ result: "ok" });
|
|
192
|
+
await h.protocol.sendRoomRequestToMember(h.ids.memberId, "room-1", "fake-token", { verb: "room.send" });
|
|
193
|
+
await h.protocol.flushPendingRoomRequests(h.ids.memberId);
|
|
194
|
+
expect(h.sendRoomRequest).toHaveBeenCalledTimes(1);
|
|
195
|
+
});
|
|
196
|
+
it("bounds the retry queue oldest-first at exactly MAX_QUEUED_DELIVERIES_PER_AGENT", async () => {
|
|
197
|
+
const h = await makeHarness();
|
|
198
|
+
for (let i = 0; i < MAX_QUEUED_DELIVERIES_PER_AGENT + 1; i++) {
|
|
199
|
+
await persistToken(h, `room-${String(i)}`);
|
|
200
|
+
}
|
|
201
|
+
h.sendRoomRequest.mockResolvedValue({
|
|
202
|
+
result: "error",
|
|
203
|
+
code: "not_connected",
|
|
204
|
+
});
|
|
205
|
+
for (let i = 0; i < MAX_QUEUED_DELIVERIES_PER_AGENT + 1; i++) {
|
|
206
|
+
await h.protocol.sendRoomRequestToMember(h.ids.memberId, `room-${String(i)}`, "fake-token", { verb: "room.send" });
|
|
207
|
+
}
|
|
208
|
+
h.sendRoomRequest.mockClear();
|
|
209
|
+
h.sendRoomRequest.mockResolvedValue({ result: "ok" });
|
|
210
|
+
await h.protocol.flushPendingRoomRequests(h.ids.memberId);
|
|
211
|
+
// The oldest entry (room-0) must have been dropped; exactly the cap's worth of retries fire.
|
|
212
|
+
expect(h.sendRoomRequest).toHaveBeenCalledTimes(MAX_QUEUED_DELIVERIES_PER_AGENT);
|
|
213
|
+
const paths = h.sendRoomRequest.mock.calls.map((c) => c[2].path);
|
|
214
|
+
expect(paths).not.toContain("room-0");
|
|
215
|
+
expect(paths).toContain("room-1");
|
|
216
|
+
});
|
|
217
|
+
it("flushPendingRoomRequests is a no-op for a member with nothing queued", async () => {
|
|
218
|
+
const h = await makeHarness();
|
|
219
|
+
await expect(h.protocol.flushPendingRoomRequests(h.ids.memberId)).resolves.toBeUndefined();
|
|
220
|
+
expect(h.sendRoomRequest).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
it("drops (not re-queues) a flush entry whose room this store no longer holds a token for", async () => {
|
|
223
|
+
const h = await makeHarness();
|
|
224
|
+
h.sendRoomRequest.mockResolvedValueOnce({
|
|
225
|
+
result: "error",
|
|
226
|
+
code: "not_connected",
|
|
227
|
+
});
|
|
228
|
+
await h.protocol.sendRoomRequestToMember(h.ids.memberId, "no-token-room", "fake-token", { verb: "room.send" });
|
|
229
|
+
h.sendRoomRequest.mockClear();
|
|
230
|
+
await h.protocol.flushPendingRoomRequests(h.ids.memberId);
|
|
231
|
+
expect(h.sendRoomRequest).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
describe("RoomProtocol — handleRoomJoin / admitRoomJoin", () => {
|
|
235
|
+
it("returns not_owner when an owner-named room's path names a different owner", async () => {
|
|
236
|
+
const h = await makeHarness();
|
|
237
|
+
const otherOwnerRoom = ownerNamedRoomPath(h.ids.memberId, "general");
|
|
238
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.join"];
|
|
239
|
+
if (handlerFn === undefined)
|
|
240
|
+
throw new Error("expected room.join handler");
|
|
241
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room", path: otherOwnerRoom } }), handle(h.ids.memberId));
|
|
242
|
+
expect(outcome).toEqual({ result: "error", code: "not_owner" });
|
|
243
|
+
});
|
|
244
|
+
it("returns not_participant when a dm path doesn't include this store's own peer id", async () => {
|
|
245
|
+
const h = await makeHarness();
|
|
246
|
+
const strangerA = "a".repeat(DEVICE_ID_HEX_LENGTH);
|
|
247
|
+
const strangerB = "b".repeat(DEVICE_ID_HEX_LENGTH);
|
|
248
|
+
const foreignDm = dmRoomPath(strangerA, strangerB);
|
|
249
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.join"];
|
|
250
|
+
if (handlerFn === undefined)
|
|
251
|
+
throw new Error("expected room.join handler");
|
|
252
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room", path: foreignDm } }), handle(strangerA));
|
|
253
|
+
expect(outcome).toEqual({ result: "error", code: "not_participant" });
|
|
254
|
+
});
|
|
255
|
+
it("auto-approves a dm join reply when this store already initiated the same dm path itself", async () => {
|
|
256
|
+
const h = await makeHarness();
|
|
257
|
+
const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId);
|
|
258
|
+
h.deps.dmRequestsInitiatedByMe.add(dmPath);
|
|
259
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.join"];
|
|
260
|
+
if (handlerFn === undefined)
|
|
261
|
+
throw new Error("expected room.join handler");
|
|
262
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room", path: dmPath } }), handle(h.ids.memberId));
|
|
263
|
+
expect(outcome).toMatchObject({ result: "ok" });
|
|
264
|
+
});
|
|
265
|
+
it("holds an owner-named join open until acceptRoomJoin resolves it, then mints and returns a granted token", async () => {
|
|
266
|
+
const h = await makeHarness();
|
|
267
|
+
const roomPath = ownerNamedRoomPath(h.ids.ownerId, "general");
|
|
268
|
+
h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.ownerId, members: [h.ids.ownerId] }));
|
|
269
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.join"];
|
|
270
|
+
if (handlerFn === undefined)
|
|
271
|
+
throw new Error("expected room.join handler");
|
|
272
|
+
const pending = handlerFn(manageRequest({ scope: { kind: "room", path: roomPath } }), handle(h.ids.memberId));
|
|
273
|
+
await Promise.resolve();
|
|
274
|
+
expect(h.protocol.listPendingRoomJoins()).toEqual([
|
|
275
|
+
{ roomPath, requesterId: h.ids.memberId },
|
|
276
|
+
]);
|
|
277
|
+
h.protocol.acceptRoomJoin(roomPath, h.ids.memberId);
|
|
278
|
+
const outcome = await pending;
|
|
279
|
+
expect(outcome).toMatchObject({ result: "ok" });
|
|
280
|
+
if (outcome.result !== "ok")
|
|
281
|
+
return;
|
|
282
|
+
expect(outcome).toHaveProperty("granted-token");
|
|
283
|
+
expect(h.protocol.listPendingRoomJoins()).toEqual([]);
|
|
284
|
+
expect(h.bump).toHaveBeenCalledWith(expect.objectContaining({ id: roomPath }));
|
|
285
|
+
expect(h.recordMemberOp).toHaveBeenCalledWith(expect.anything(), "member", "join", h.ids.memberId);
|
|
286
|
+
});
|
|
287
|
+
it("returns a denied error, with the reason when given, once rejectRoomJoin resolves it", async () => {
|
|
288
|
+
const h = await makeHarness();
|
|
289
|
+
const roomPath = ownerNamedRoomPath(h.ids.ownerId, "general");
|
|
290
|
+
h.deps.rooms.set(roomPath, room({ id: roomPath, owner: h.ids.ownerId }));
|
|
291
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.join"];
|
|
292
|
+
if (handlerFn === undefined)
|
|
293
|
+
throw new Error("expected room.join handler");
|
|
294
|
+
const pending = handlerFn(manageRequest({ scope: { kind: "room", path: roomPath } }), handle(h.ids.memberId));
|
|
295
|
+
await Promise.resolve();
|
|
296
|
+
h.protocol.rejectRoomJoin(roomPath, h.ids.memberId, "not right now");
|
|
297
|
+
const outcome = await pending;
|
|
298
|
+
expect(outcome).toEqual({
|
|
299
|
+
result: "error",
|
|
300
|
+
code: "denied",
|
|
301
|
+
message: "not right now",
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
it("mints a fresh, independent root grant when no local Room record exists (a dm join)", async () => {
|
|
305
|
+
const h = await makeHarness();
|
|
306
|
+
const dmPath = dmRoomPath(h.ids.ownerId, h.ids.memberId);
|
|
307
|
+
h.deps.dmRequestsInitiatedByMe.add(dmPath);
|
|
308
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.join"];
|
|
309
|
+
if (handlerFn === undefined)
|
|
310
|
+
throw new Error("expected room.join handler");
|
|
311
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room", path: dmPath } }), handle(h.ids.memberId));
|
|
312
|
+
expect(outcome).toMatchObject({
|
|
313
|
+
result: "ok",
|
|
314
|
+
members: [
|
|
315
|
+
{ device: deviceIdFromHex(h.ids.ownerId) },
|
|
316
|
+
{ device: deviceIdFromHex(h.ids.memberId) },
|
|
317
|
+
],
|
|
318
|
+
});
|
|
319
|
+
expect(h.bump).not.toHaveBeenCalled();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
describe("RoomProtocol — acceptRoomJoin / rejectRoomJoin without a pending request", () => {
|
|
323
|
+
it("acceptRoomJoin throws NOT_PENDING naming the requester and room", async () => {
|
|
324
|
+
const h = await makeHarness();
|
|
325
|
+
expect(() => h.protocol.acceptRoomJoin("room-1", "nobody")).toThrow(expect.objectContaining({
|
|
326
|
+
message: "No pending room.join for nobody on room-1",
|
|
327
|
+
code: "NOT_PENDING",
|
|
328
|
+
}));
|
|
329
|
+
});
|
|
330
|
+
it("rejectRoomJoin throws NOT_PENDING naming the requester and room", async () => {
|
|
331
|
+
const h = await makeHarness();
|
|
332
|
+
expect(() => h.protocol.rejectRoomJoin("room-1", "nobody")).toThrow(expect.objectContaining({
|
|
333
|
+
message: "No pending room.join for nobody on room-1",
|
|
334
|
+
code: "NOT_PENDING",
|
|
335
|
+
}));
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
describe("RoomProtocol — handleRoomInvite", () => {
|
|
339
|
+
it("returns missing_scope_path when the request carries no room path", async () => {
|
|
340
|
+
const h = await makeHarness();
|
|
341
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.invite"];
|
|
342
|
+
if (handlerFn === undefined)
|
|
343
|
+
throw new Error("expected room.invite handler");
|
|
344
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room" } }), handle(h.ids.memberId));
|
|
345
|
+
expect(outcome).toEqual({ result: "error", code: "missing_scope_path" });
|
|
346
|
+
});
|
|
347
|
+
it("returns malformed_params for params that don't satisfy the room.invite schema", async () => {
|
|
348
|
+
const h = await makeHarness();
|
|
349
|
+
const roomPath = ownerNamedRoomPath(h.ids.memberId, "general");
|
|
350
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.invite"];
|
|
351
|
+
if (handlerFn === undefined)
|
|
352
|
+
throw new Error("expected room.invite handler");
|
|
353
|
+
const outcome = await handlerFn(manageRequest({
|
|
354
|
+
scope: { kind: "room", path: roomPath },
|
|
355
|
+
params: { verb: "room.invite" },
|
|
356
|
+
}), handle(h.ids.memberId));
|
|
357
|
+
expect(outcome).toEqual({ result: "error", code: "malformed_params" });
|
|
358
|
+
});
|
|
359
|
+
it("returns unauthorized for an embedded token that fails verification", async () => {
|
|
360
|
+
const h = await makeHarness();
|
|
361
|
+
const roomPath = ownerNamedRoomPath(h.ids.memberId, "general");
|
|
362
|
+
const wrongBearerToken = await mintRoomToken(h.ids.ownerPort, h.ids.ownerId, roomPath);
|
|
363
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.invite"];
|
|
364
|
+
if (handlerFn === undefined)
|
|
365
|
+
throw new Error("expected room.invite handler");
|
|
366
|
+
const outcome = await handlerFn(manageRequest({
|
|
367
|
+
scope: { kind: "room", path: roomPath },
|
|
368
|
+
params: {
|
|
369
|
+
verb: "room.invite",
|
|
370
|
+
invitee: deviceIdFromHex(h.ids.ownerId),
|
|
371
|
+
token: wrongBearerToken,
|
|
372
|
+
},
|
|
373
|
+
}), handle(h.ids.memberId));
|
|
374
|
+
expect(outcome).toEqual({ result: "error", code: "unauthorized" });
|
|
375
|
+
});
|
|
376
|
+
it("persists a verified invite token and fires a room_invite event, with real inviter/room-state when given, falling back to bare device-id/room-path otherwise", async () => {
|
|
377
|
+
const h = await makeHarness();
|
|
378
|
+
// The invite's own token must be bearer=this store's own peer id, root=the room's real owner.
|
|
379
|
+
const owningMemberIdentity = generateIdentity();
|
|
380
|
+
const owningMemberHex = bytesToHex(Uint8Array.from(owningMemberIdentity.deviceId));
|
|
381
|
+
const owningMemberPort = await toIdentityPort(owningMemberIdentity);
|
|
382
|
+
const ownedRoomPath = ownerNamedRoomPath(owningMemberHex, "general");
|
|
383
|
+
const inviteToken = await mintRoomToken(owningMemberPort, h.ids.ownerId, ownedRoomPath);
|
|
384
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.invite"];
|
|
385
|
+
if (handlerFn === undefined)
|
|
386
|
+
throw new Error("expected room.invite handler");
|
|
387
|
+
const outcome = await handlerFn(manageRequest({
|
|
388
|
+
scope: { kind: "room", path: ownedRoomPath },
|
|
389
|
+
params: {
|
|
390
|
+
verb: "room.invite",
|
|
391
|
+
invitee: deviceIdFromHex(h.ids.ownerId),
|
|
392
|
+
token: inviteToken,
|
|
393
|
+
"room-state": {
|
|
394
|
+
name: "General",
|
|
395
|
+
description: "a real room",
|
|
396
|
+
type: "public",
|
|
397
|
+
},
|
|
398
|
+
"inviter-agent": { name: "Owning Member", cwd: "/tmp/owner" },
|
|
399
|
+
},
|
|
400
|
+
}), handle(h.ids.memberId));
|
|
401
|
+
expect(outcome).toEqual({ result: "ok" });
|
|
402
|
+
expect(loadRoomTokens({ harness: "test", cwd: "room-protocol", dir: h.slotDir })[ownedRoomPath]).toBeDefined();
|
|
403
|
+
expect(h.queueDelivery).toHaveBeenCalledWith(h.ids.ownerId, expect.objectContaining({
|
|
404
|
+
type: "room_invite",
|
|
405
|
+
room: ownedRoomPath,
|
|
406
|
+
roomDescription: "a real room",
|
|
407
|
+
from: owningMemberHex,
|
|
408
|
+
fromName: "Owning Member",
|
|
409
|
+
fromCwd: "/tmp/owner",
|
|
410
|
+
}));
|
|
411
|
+
});
|
|
412
|
+
it("falls back to bare device-id and empty description/cwd when room-state/inviter-agent are absent", async () => {
|
|
413
|
+
const h = await makeHarness();
|
|
414
|
+
const owningMemberIdentity = generateIdentity();
|
|
415
|
+
const owningMemberHex = bytesToHex(Uint8Array.from(owningMemberIdentity.deviceId));
|
|
416
|
+
const owningMemberPort = await toIdentityPort(owningMemberIdentity);
|
|
417
|
+
const ownedRoomPath = ownerNamedRoomPath(owningMemberHex, "general");
|
|
418
|
+
const inviteToken = await mintRoomToken(owningMemberPort, h.ids.ownerId, ownedRoomPath);
|
|
419
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.invite"];
|
|
420
|
+
if (handlerFn === undefined)
|
|
421
|
+
throw new Error("expected room.invite handler");
|
|
422
|
+
await handlerFn(manageRequest({
|
|
423
|
+
scope: { kind: "room", path: ownedRoomPath },
|
|
424
|
+
params: {
|
|
425
|
+
verb: "room.invite",
|
|
426
|
+
invitee: deviceIdFromHex(h.ids.ownerId),
|
|
427
|
+
token: inviteToken,
|
|
428
|
+
},
|
|
429
|
+
}), handle(h.ids.memberId));
|
|
430
|
+
expect(h.queueDelivery).toHaveBeenCalledWith(h.ids.ownerId, expect.objectContaining({
|
|
431
|
+
roomDescription: "",
|
|
432
|
+
from: owningMemberHex,
|
|
433
|
+
fromName: owningMemberHex,
|
|
434
|
+
fromCwd: "",
|
|
435
|
+
}));
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
describe("RoomProtocol — handleRoomLeave", () => {
|
|
439
|
+
let h;
|
|
440
|
+
let ownerNamedRoom;
|
|
441
|
+
beforeEach(async () => {
|
|
442
|
+
h = await makeHarness();
|
|
443
|
+
ownerNamedRoom = ownerNamedRoomPath(h.ids.ownerId, "general");
|
|
444
|
+
});
|
|
445
|
+
it("returns missing_scope_path when the request carries no room path", async () => {
|
|
446
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
447
|
+
if (handlerFn === undefined)
|
|
448
|
+
throw new Error("expected room.leave handler");
|
|
449
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room" } }), handle(h.ids.memberId));
|
|
450
|
+
expect(outcome).toEqual({ result: "error", code: "missing_scope_path" });
|
|
451
|
+
});
|
|
452
|
+
it("returns unauthorized when no token is presented", async () => {
|
|
453
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
454
|
+
if (handlerFn === undefined)
|
|
455
|
+
throw new Error("expected room.leave handler");
|
|
456
|
+
const outcome = await handlerFn(manageRequest({ scope: { kind: "room", path: ownerNamedRoom } }), handle(h.ids.memberId));
|
|
457
|
+
expect(outcome).toEqual({ result: "error", code: "unauthorized" });
|
|
458
|
+
});
|
|
459
|
+
it("returns malformed_params for params that don't satisfy the room.leave schema", async () => {
|
|
460
|
+
h.deps.rooms.set(ownerNamedRoom, room({
|
|
461
|
+
id: ownerNamedRoom,
|
|
462
|
+
owner: h.ids.ownerId,
|
|
463
|
+
members: [h.ids.memberId],
|
|
464
|
+
}));
|
|
465
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.memberId, ownerNamedRoom);
|
|
466
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
467
|
+
if (handlerFn === undefined)
|
|
468
|
+
throw new Error("expected room.leave handler");
|
|
469
|
+
const outcome = await handlerFn(manageRequest({
|
|
470
|
+
scope: { kind: "room", path: ownerNamedRoom },
|
|
471
|
+
token,
|
|
472
|
+
params: { verb: "wrong" },
|
|
473
|
+
}), handle(h.ids.memberId));
|
|
474
|
+
expect(outcome).toEqual({ result: "error", code: "malformed_params" });
|
|
475
|
+
});
|
|
476
|
+
it("returns room_not_found when this store holds no Room record at all", async () => {
|
|
477
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.memberId, ownerNamedRoom);
|
|
478
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
479
|
+
if (handlerFn === undefined)
|
|
480
|
+
throw new Error("expected room.leave handler");
|
|
481
|
+
const outcome = await handlerFn(manageRequest({
|
|
482
|
+
scope: { kind: "room", path: ownerNamedRoom },
|
|
483
|
+
token,
|
|
484
|
+
params: { verb: "room.leave" },
|
|
485
|
+
}), handle(h.ids.memberId));
|
|
486
|
+
expect(outcome).toEqual({ result: "error", code: "room_not_found" });
|
|
487
|
+
});
|
|
488
|
+
it("is a no-op ok when the sender was neither a member nor invited", async () => {
|
|
489
|
+
h.deps.rooms.set(ownerNamedRoom, room({
|
|
490
|
+
id: ownerNamedRoom,
|
|
491
|
+
owner: h.ids.ownerId,
|
|
492
|
+
members: [h.ids.ownerId],
|
|
493
|
+
}));
|
|
494
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.memberId, ownerNamedRoom);
|
|
495
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
496
|
+
if (handlerFn === undefined)
|
|
497
|
+
throw new Error("expected room.leave handler");
|
|
498
|
+
const outcome = await handlerFn(manageRequest({
|
|
499
|
+
scope: { kind: "room", path: ownerNamedRoom },
|
|
500
|
+
token,
|
|
501
|
+
params: { verb: "room.leave" },
|
|
502
|
+
}), handle(h.ids.memberId));
|
|
503
|
+
expect(outcome).toEqual({ result: "ok" });
|
|
504
|
+
expect(h.revokeMemberGrant).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
506
|
+
it("a real member leaving revokes their grant, records a member leave, and broadcasts member_left to the room", async () => {
|
|
507
|
+
h.deps.rooms.set(ownerNamedRoom, room({
|
|
508
|
+
id: ownerNamedRoom,
|
|
509
|
+
owner: h.ids.ownerId,
|
|
510
|
+
members: [h.ids.ownerId, h.ids.memberId],
|
|
511
|
+
}));
|
|
512
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.memberId, ownerNamedRoom);
|
|
513
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
514
|
+
if (handlerFn === undefined)
|
|
515
|
+
throw new Error("expected room.leave handler");
|
|
516
|
+
const outcome = await handlerFn(manageRequest({
|
|
517
|
+
scope: { kind: "room", path: ownerNamedRoom },
|
|
518
|
+
token,
|
|
519
|
+
params: { verb: "room.leave" },
|
|
520
|
+
}), handle(h.ids.memberId));
|
|
521
|
+
expect(outcome).toEqual({ result: "ok" });
|
|
522
|
+
expect(h.revokeMemberGrant).toHaveBeenCalledWith(ownerNamedRoom, h.ids.memberId);
|
|
523
|
+
expect(h.recordMemberOp).toHaveBeenCalledWith(expect.anything(), "member", "leave", h.ids.memberId);
|
|
524
|
+
expect(h.broadcastPatch).toHaveBeenCalledWith({
|
|
525
|
+
type: "room_upsert",
|
|
526
|
+
room: expect.anything(),
|
|
527
|
+
});
|
|
528
|
+
expect(h.deliverToRoom).toHaveBeenCalledWith(ownerNamedRoom, { type: "member_left", room: ownerNamedRoom, agent: h.ids.memberId }, h.ids.memberId);
|
|
529
|
+
});
|
|
530
|
+
it("declining a never-accepted invite records an invited leave and fires a local invite_declined event instead of broadcasting", async () => {
|
|
531
|
+
h.deps.rooms.set(ownerNamedRoom, room({
|
|
532
|
+
id: ownerNamedRoom,
|
|
533
|
+
owner: h.ids.ownerId,
|
|
534
|
+
members: [h.ids.ownerId],
|
|
535
|
+
invited: [h.ids.memberId],
|
|
536
|
+
}));
|
|
537
|
+
h.deps.agents.set(h.ids.memberId, agent({ id: h.ids.memberId, name: "Declining Member" }));
|
|
538
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.memberId, ownerNamedRoom);
|
|
539
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
540
|
+
if (handlerFn === undefined)
|
|
541
|
+
throw new Error("expected room.leave handler");
|
|
542
|
+
const outcome = await handlerFn(manageRequest({
|
|
543
|
+
scope: { kind: "room", path: ownerNamedRoom },
|
|
544
|
+
token,
|
|
545
|
+
params: { verb: "room.leave", reason: "changed my mind" },
|
|
546
|
+
}), handle(h.ids.memberId));
|
|
547
|
+
expect(outcome).toEqual({ result: "ok" });
|
|
548
|
+
expect(h.recordMemberOp).toHaveBeenCalledWith(expect.anything(), "invited", "leave", h.ids.memberId);
|
|
549
|
+
expect(h.deliverToRoom).not.toHaveBeenCalled();
|
|
550
|
+
expect(h.queueDelivery).toHaveBeenCalledWith(h.ids.ownerId, expect.objectContaining({
|
|
551
|
+
type: "invite_declined",
|
|
552
|
+
agentName: "Declining Member",
|
|
553
|
+
reason: "changed my mind",
|
|
554
|
+
}));
|
|
555
|
+
});
|
|
556
|
+
it("falls back to the bare device-id and empty reason when the decliner isn't a known agent or gives no reason", async () => {
|
|
557
|
+
h.deps.rooms.set(ownerNamedRoom, room({
|
|
558
|
+
id: ownerNamedRoom,
|
|
559
|
+
owner: h.ids.ownerId,
|
|
560
|
+
members: [h.ids.ownerId],
|
|
561
|
+
invited: [h.ids.memberId],
|
|
562
|
+
}));
|
|
563
|
+
const token = await mintRoomToken(h.ids.ownerPort, h.ids.memberId, ownerNamedRoom);
|
|
564
|
+
const handlerFn = h.protocol.roomVerbHandlers["room.leave"];
|
|
565
|
+
if (handlerFn === undefined)
|
|
566
|
+
throw new Error("expected room.leave handler");
|
|
567
|
+
await handlerFn(manageRequest({
|
|
568
|
+
scope: { kind: "room", path: ownerNamedRoom },
|
|
569
|
+
token,
|
|
570
|
+
params: { verb: "room.leave" },
|
|
571
|
+
}), handle(h.ids.memberId));
|
|
572
|
+
expect(h.queueDelivery).toHaveBeenCalledWith(h.ids.ownerId, expect.objectContaining({
|
|
573
|
+
agentName: h.ids.memberId,
|
|
574
|
+
reason: "",
|
|
575
|
+
}));
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
//# sourceMappingURL=room-protocol-admission.test.js.map
|