agent-comms 8.15.8 → 8.15.10

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.
Files changed (184) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bridges/user/web/server.d.ts.map +1 -1
  4. package/dist/bridges/user/web/server.js +44 -24
  5. package/dist/bridges/user/web/server.js.map +1 -1
  6. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  10. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  14. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  15. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  16. package/dist/bridges/user/web/web-assets.js +67 -0
  17. package/dist/bridges/user/web/web-assets.js.map +1 -0
  18. package/package.json +8 -2
  19. package/src/account-cli.ts +195 -0
  20. package/src/account-join-cli.ts +191 -0
  21. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  22. package/src/bridges/cc-peer/bridge.ts +138 -0
  23. package/src/bridges/cc-peer/default-front.ts +58 -0
  24. package/src/bridges/cc-peer/front-controller.ts +160 -0
  25. package/src/bridges/cc-peer/front-relay.ts +191 -0
  26. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  27. package/src/bridges/cc-peer/front.ts +59 -0
  28. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  29. package/src/bridges/cc-peer/run.ts +100 -0
  30. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  31. package/src/bridges/claude-code/channel.ts +318 -0
  32. package/src/bridges/codex/tool.ts +132 -0
  33. package/src/bridges/mcp/server.ts +137 -0
  34. package/src/bridges/opencode/plugin.ts +130 -0
  35. package/src/bridges/pi/extension.ts +330 -0
  36. package/src/bridges/registry.ts +17 -0
  37. package/src/bridges/user/cli.ts +87 -0
  38. package/src/bridges/user/controller.ts +345 -0
  39. package/src/bridges/user/tui.ts +300 -0
  40. package/src/bridges/user/web/actions.ts +173 -0
  41. package/src/bridges/user/web/contract.ts +222 -0
  42. package/src/bridges/user/web/dist/bundle.js +49 -0
  43. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  44. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  45. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  46. package/src/bridges/user/web/dist/index.html +21 -0
  47. package/src/bridges/user/web/dist/manifest.json +26 -0
  48. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  49. package/src/bridges/user/web/dist/sw.js +1 -0
  50. package/src/bridges/user/web/event-publisher.ts +114 -0
  51. package/src/bridges/user/web/port-discovery.ts +49 -0
  52. package/src/bridges/user/web/reads.ts +35 -0
  53. package/src/bridges/user/web/router.ts +158 -0
  54. package/src/bridges/user/web/server.ts +782 -0
  55. package/src/bridges/user/web/vite.config.ts +71 -0
  56. package/src/bridges/user/web/web-access.ts +404 -0
  57. package/src/bridges/user/web/web-assets.ts +90 -0
  58. package/src/bridges/user/web/web-beacon.ts +69 -0
  59. package/src/bridges/user/web/web-console-static.ts +90 -0
  60. package/src/cli-main.ts +635 -0
  61. package/src/cli.ts +13 -0
  62. package/src/core/account-bundle.ts +264 -0
  63. package/src/core/account-join.ts +289 -0
  64. package/src/core/account-ledger-crypto.ts +154 -0
  65. package/src/core/account-ledger-store.ts +66 -0
  66. package/src/core/account-ledger.ts +385 -0
  67. package/src/core/agent-details.ts +216 -0
  68. package/src/core/agent-list-format.ts +97 -0
  69. package/src/core/agent-registry.ts +282 -0
  70. package/src/core/agent-table.ts +58 -0
  71. package/src/core/atomic-file.ts +46 -0
  72. package/src/core/bind-retry.ts +38 -0
  73. package/src/core/bridge-mesh.ts +176 -0
  74. package/src/core/bridge.ts +718 -0
  75. package/src/core/capability-ask.ts +162 -0
  76. package/src/core/capability-bubble-up.ts +237 -0
  77. package/src/core/comms-store.ts +130 -0
  78. package/src/core/connection-approval.ts +134 -0
  79. package/src/core/connection-code-tool.ts +92 -0
  80. package/src/core/connection-code.ts +296 -0
  81. package/src/core/coordinator-role.ts +224 -0
  82. package/src/core/data-directory.ts +32 -0
  83. package/src/core/data-frame-routing.ts +309 -0
  84. package/src/core/default-command.ts +12 -0
  85. package/src/core/delegation-policy.ts +41 -0
  86. package/src/core/delivery-engine.ts +649 -0
  87. package/src/core/device-membership-verification.ts +125 -0
  88. package/src/core/device-membership.ts +130 -0
  89. package/src/core/directory-admission.ts +155 -0
  90. package/src/core/discovery-mdns.ts +247 -0
  91. package/src/core/discovery-tailscale.ts +224 -0
  92. package/src/core/discovery.ts +303 -0
  93. package/src/core/display-name.ts +73 -0
  94. package/src/core/dm-grant-actions.ts +72 -0
  95. package/src/core/dm-send-delegation.ts +73 -0
  96. package/src/core/dm-token-verification.ts +76 -0
  97. package/src/core/election-sessions.ts +135 -0
  98. package/src/core/error-reporting.ts +85 -0
  99. package/src/core/first-contact.ts +242 -0
  100. package/src/core/gateway-trust-actions.ts +184 -0
  101. package/src/core/gateway-trust.ts +305 -0
  102. package/src/core/gossip-directory.ts +276 -0
  103. package/src/core/gossip-extensions.ts +123 -0
  104. package/src/core/group-proofs.ts +205 -0
  105. package/src/core/handshake.ts +354 -0
  106. package/src/core/hub-forwarding.ts +24 -0
  107. package/src/core/hub-link.ts +176 -0
  108. package/src/core/hub-session.ts +374 -0
  109. package/src/core/identity-store.ts +715 -0
  110. package/src/core/identity.ts +489 -0
  111. package/src/core/index.ts +65 -0
  112. package/src/core/issuer-grouping.ts +165 -0
  113. package/src/core/issuer-identity-file.ts +187 -0
  114. package/src/core/lan-web-actions.ts +34 -0
  115. package/src/core/lan-web-uis.ts +55 -0
  116. package/src/core/ledger-lock.ts +81 -0
  117. package/src/core/listener-registry.ts +133 -0
  118. package/src/core/loopback-address.ts +22 -0
  119. package/src/core/machine-identity.ts +77 -0
  120. package/src/core/machine-list-groups.ts +53 -0
  121. package/src/core/mcp-registry-retry.ts +69 -0
  122. package/src/core/mcp-registry-version.ts +18 -0
  123. package/src/core/membership-proof.ts +139 -0
  124. package/src/core/membership-proofs.ts +149 -0
  125. package/src/core/mesh-graph-trace-tool.ts +81 -0
  126. package/src/core/mesh-graph.ts +89 -0
  127. package/src/core/mesh-network-actions.ts +183 -0
  128. package/src/core/mesh-store-options.ts +23 -0
  129. package/src/core/mesh-store-shared.ts +134 -0
  130. package/src/core/mesh-store.ts +1114 -0
  131. package/src/core/name-claim.ts +145 -0
  132. package/src/core/naming-actions.ts +90 -0
  133. package/src/core/naming.ts +138 -0
  134. package/src/core/nanoid.ts +20 -0
  135. package/src/core/network-interfaces.ts +24 -0
  136. package/src/core/package-version.ts +34 -0
  137. package/src/core/peer-lifecycle.ts +377 -0
  138. package/src/core/peer-link-error.ts +41 -0
  139. package/src/core/peer-versions.ts +98 -0
  140. package/src/core/pending-connection.ts +53 -0
  141. package/src/core/petnames.ts +121 -0
  142. package/src/core/pgp-keyserver.ts +32 -0
  143. package/src/core/pid-lock-file.ts +122 -0
  144. package/src/core/push-manager.ts +76 -0
  145. package/src/core/random-id.ts +9 -0
  146. package/src/core/receiver-set.ts +42 -0
  147. package/src/core/relay-role.ts +182 -0
  148. package/src/core/relay-selection.ts +120 -0
  149. package/src/core/relay-server.ts +198 -0
  150. package/src/core/relay-uplink.ts +78 -0
  151. package/src/core/request-timeouts.ts +37 -0
  152. package/src/core/room-discovery.ts +48 -0
  153. package/src/core/room-lifecycle.ts +838 -0
  154. package/src/core/room-lookup.ts +41 -0
  155. package/src/core/room-messaging.ts +247 -0
  156. package/src/core/room-notice-log.ts +61 -0
  157. package/src/core/room-path.ts +84 -0
  158. package/src/core/room-protocol.ts +893 -0
  159. package/src/core/room-router.ts +180 -0
  160. package/src/core/room-token-verification.ts +85 -0
  161. package/src/core/room-wire-extensions.ts +60 -0
  162. package/src/core/self-name-claim.ts +67 -0
  163. package/src/core/send-outcome.ts +69 -0
  164. package/src/core/shutdown-signals.ts +57 -0
  165. package/src/core/stale-agent-checker.ts +108 -0
  166. package/src/core/store.ts +704 -0
  167. package/src/core/token-text.ts +40 -0
  168. package/src/core/tool.ts +853 -0
  169. package/src/core/transport-events.ts +73 -0
  170. package/src/core/transport-queries.ts +54 -0
  171. package/src/core/transport.ts +367 -0
  172. package/src/core/types.ts +550 -0
  173. package/src/core/user-identity.ts +290 -0
  174. package/src/core/vapid.ts +81 -0
  175. package/src/core/version-check.ts +147 -0
  176. package/src/core/version-report-actions.ts +79 -0
  177. package/src/core/web-beacon.ts +31 -0
  178. package/src/core/web-push.ts +322 -0
  179. package/src/core/wire-mesh-core-version.ts +25 -0
  180. package/src/core/wire-mesh-identity.ts +36 -0
  181. package/src/core/wire-mesh-transport-options.ts +82 -0
  182. package/src/core/wire-mesh-transport.ts +1007 -0
  183. package/src/core/wire-protocol.ts +153 -0
  184. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,838 @@
1
+ /**
2
+ * RoomLifecycle — room CRUD and the requester's own outbound half of the wire protocol: create/get/list, join/leave (local CRDT mutation for an already-known room, or a real wire-level room.join/room.leave round trip via joinRemoteRoom/leaveRemoteRoom when this store's own identity isn't yet admitted), invite/decline, membership-grant revocation, kick, and destroy. Split out of mesh-store.ts to reduce it under the repo's max-lines cap.
3
+ */
4
+
5
+ import {
6
+ deviceIdFromHex,
7
+ deviceIdToHex,
8
+ } from "wire-mesh-core/domain/device-id";
9
+ import {
10
+ mintCapabilityToken,
11
+ mintRevocationEntry,
12
+ verifyCapabilityToken,
13
+ } from "wire-mesh-core/domain/tokens";
14
+ import {
15
+ roomJoinOkSchema,
16
+ roomMembersOkSchema,
17
+ } from "wire-mesh-core/generated/protocol";
18
+ import {
19
+ dmRoomPath,
20
+ ownerNamedRoomPath,
21
+ parseRoomPath,
22
+ slugRoomName,
23
+ } from "./room-path.js";
24
+ import {
25
+ ROOM_MEMBER_CAPABILITY,
26
+ ROOM_MEMBER_DELEGATION_POLICY,
27
+ } from "./room-token-verification.js";
28
+ import { DM_SEND_CAPABILITY } from "./dm-token-verification.js";
29
+ import { delegateDmSendToDevice } from "./dm-send-delegation.js";
30
+ import { resolveDelegationsRemaining } from "./delegation-policy.js";
31
+ import {
32
+ deleteIssuedRoomGrant,
33
+ deleteRoomToken,
34
+ loadIssuedRoomGrant,
35
+ loadRoomTokens,
36
+ saveIssuedRoomGrant,
37
+ saveRoomToken,
38
+ } from "./identity-store.js";
39
+ import { randomId } from "./random-id.js";
40
+ import { listDiscoveredRooms } from "./room-discovery.js";
41
+ import { resolveRoomId } from "./room-lookup.js";
42
+ import { CommsError } from "./store.js";
43
+ import {
44
+ DM_SEND_GRANT_LIFETIME_MS,
45
+ ROOM_TOKEN_LIFETIME_MS,
46
+ } from "./mesh-store-shared.js";
47
+ import type { MeshStoreIdentity } from "./mesh-store-shared.js";
48
+ import {
49
+ inviterAgentExtension,
50
+ parseRoomStateExtension,
51
+ roomStateExtension,
52
+ } from "./room-wire-extensions.js";
53
+ import type { DeliveryEngine } from "./delivery-engine.js";
54
+ import type { MeshTransport } from "./transport.js";
55
+ import type { CapabilityToken } from "wire-mesh-core/generated/protocol";
56
+ import type {
57
+ AgentIdentity,
58
+ AgentStatus,
59
+ Room,
60
+ RoomMessage,
61
+ RoomType,
62
+ } from "./types.js";
63
+
64
+ /** The state and collaborators RoomLifecycle needs from MeshStore. rooms/messages/agents/dmRequestsInitiatedByMe are direct references into MeshStore's own fields (dmRequestsInitiatedByMe shared with RoomProtocol, which reads what requestDmAccess writes here); deliveryEngine is the already-constructed instance, narrowed to what room CRUD ever needs. */
65
+ export interface RoomLifecycleDeps {
66
+ rooms: Map<string, Room>;
67
+ messages: Map<string, RoomMessage[]>;
68
+ agents: Map<string, AgentIdentity>;
69
+ dmRequestsInitiatedByMe: Set<string>;
70
+ getPeerId: () => string;
71
+ requireIdentity: () => MeshStoreIdentity;
72
+ requireTransport: () => MeshTransport;
73
+ deliveryEngine: Pick<
74
+ DeliveryEngine,
75
+ | "bump"
76
+ | "recordMemberOp"
77
+ | "refreshMembership"
78
+ | "broadcastPatch"
79
+ | "deliverToRoom"
80
+ | "deliverToMember"
81
+ >;
82
+ }
83
+
84
+ /** The verdict reason for a token whose bearer is someone other than the party it was checked against. */
85
+ const BEARER_MISMATCH = "bearer_mismatch";
86
+
87
+ export class RoomLifecycle {
88
+ constructor(private readonly deps: RoomLifecycleDeps) {}
89
+
90
+ /**
91
+ * Mints and persists the room owner's own self-signed room:member grant: issuer = bearer = owner, no parent, delegationsRemaining: 0. This is deliberately NOT the parent every later member grant chains through -- a delegations-remaining: 0 parent cannot mint any child at all (mintCapabilityToken refuses a child whose own delegationsRemaining isn't strictly less than its parent's, and there is no value less than 0), so a later join/invite grant is its own independent, parent-less, owner-issued root-level token instead (still satisfying the "chain roots at the path's own owner" obligation, since rootIssuer is just the token's own issuer when it carries no parent). This root grant exists purely so the owner has a token to present for its own room actions, uniformly with every other member, per the design's own "every code path that checks membership does the same thing regardless of who it is checking" reasoning.
92
+ */
93
+ private async mintOwnerRootGrant(
94
+ roomPath: string,
95
+ owner: string,
96
+ ): Promise<void> {
97
+ const { identity, clock, slot } = this.deps.requireIdentity();
98
+ const verdict = await mintCapabilityToken({
99
+ identity,
100
+ clock,
101
+ tokenId: randomId(),
102
+ bearer: deviceIdFromHex(owner),
103
+ capability: ROOM_MEMBER_CAPABILITY,
104
+ scope: { kind: "room", path: roomPath },
105
+ expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
106
+ delegationsRemaining: resolveDelegationsRemaining(
107
+ ROOM_MEMBER_DELEGATION_POLICY,
108
+ ROOM_MEMBER_CAPABILITY,
109
+ owner,
110
+ 0,
111
+ ),
112
+ });
113
+ if (!verdict.ok) {
114
+ throw new Error(
115
+ `MeshStore: failed to mint room owner grant for ${roomPath}: ${verdict.reason}`,
116
+ );
117
+ }
118
+ saveRoomToken(slot, roomPath, verdict.token);
119
+ }
120
+
121
+ async createRoom(
122
+ opts: Readonly<{
123
+ name: string;
124
+ type: RoomType;
125
+ owner: string;
126
+ description: string;
127
+ }>,
128
+ ): Promise<Room> {
129
+ // slugRoomName sanitises an arbitrary caller-supplied name (e.g. from a live create_room tool call, not just an internal cwd basename) into the room-path grammar's [A-Za-z0-9_-]+ charset -- createRoom is the one choke point every room creation goes through, so this is the right place to do it rather than trusting every caller to have pre-slugged, the way the old bare-name id never required at all.
130
+ const slugName = slugRoomName(opts.name);
131
+ const localName = opts.type === "secret" ? `_${slugName}` : slugName;
132
+ const id = ownerNamedRoomPath(opts.owner, localName);
133
+ if (this.deps.rooms.has(id))
134
+ throw new CommsError(`Room ${id} already exists`, "ROOM_EXISTS");
135
+
136
+ // Every room creation this store performs is local: opts.owner is always this bridge's own identity (create_room's caller passes ctx.agentId, and one bridge process is one agent is one device), so minting the owner's own root grant here always signs under the identity this store was wired with, never someone else's.
137
+ await this.mintOwnerRootGrant(id, opts.owner);
138
+
139
+ const room: Room = {
140
+ id,
141
+ version: 1,
142
+ name: slugName,
143
+ type: opts.type,
144
+ owner: opts.owner,
145
+ createdAt: new Date().toISOString(),
146
+ description: opts.description,
147
+ members: [opts.owner],
148
+ invited: [],
149
+ memberJoins: { [opts.owner]: 1 },
150
+ memberLeaves: {},
151
+ invitedJoins: {},
152
+ invitedLeaves: {},
153
+ };
154
+
155
+ this.deps.rooms.set(id, room);
156
+ this.deps.messages.set(id, []);
157
+ await this.deps.deliveryEngine.broadcastPatch({
158
+ type: "room_upsert",
159
+ room,
160
+ });
161
+ return room;
162
+ }
163
+
164
+ /**
165
+ * Resolves a room id or plain room name to a real room id, against the same set of rooms listRooms shows: rooms this store holds plus rooms other devices advertise as hosted. See resolveRoomId in room-lookup.ts for the exact resolution and ambiguity rules.
166
+ */
167
+ resolveRoomId(roomIdOrName: string): string {
168
+ return resolveRoomId(
169
+ [
170
+ ...this.deps.rooms.values(),
171
+ ...listDiscoveredRooms(this.deps.requireTransport()).map(
172
+ ({ path, name }) => ({ id: path, name }),
173
+ ),
174
+ ],
175
+ roomIdOrName,
176
+ );
177
+ }
178
+
179
+ async getRoom(id: string): Promise<Room | undefined> {
180
+ await Promise.resolve();
181
+ return this.deps.rooms.get(id);
182
+ }
183
+
184
+ /**
185
+ * Every room visible to requesterId: rooms this store holds (secret ones only for members), plus every room another device advertises as hosted that this store has not otherwise recorded. A discovered room is a placeholder-shaped hint built from the advert alone and is never merged into this.deps.rooms: a gossip hint is not membership.
186
+ */
187
+ async listRooms(requesterId: string): Promise<Room[]> {
188
+ await Promise.resolve();
189
+ const result: Room[] = [];
190
+ for (const room of this.deps.rooms.values()) {
191
+ if (room.type === "secret" && !room.members.includes(requesterId))
192
+ continue;
193
+ result.push(room);
194
+ }
195
+ for (const discovered of listDiscoveredRooms(
196
+ this.deps.requireTransport(),
197
+ )) {
198
+ if (this.deps.rooms.has(discovered.path)) continue;
199
+ result.push({
200
+ id: discovered.path,
201
+ version: 0,
202
+ name: discovered.name,
203
+ type: discovered.type,
204
+ owner: discovered.ownerDeviceId,
205
+ createdAt: "",
206
+ description: discovered.description ?? "",
207
+ members: [],
208
+ invited: [],
209
+ memberJoins: {},
210
+ memberLeaves: {},
211
+ invitedJoins: {},
212
+ invitedLeaves: {},
213
+ });
214
+ }
215
+ return result;
216
+ }
217
+
218
+ /**
219
+ * The remote-join path: roomPath names an owner-named room this store has never seen replicated, so joining it means sending a real wire-level room.join request to the room's own owner and persisting whatever grant comes back, rather than mutating already-known local state. Only ever called for this store's own local agent (one bridge is one agent is one device, per the design's own organising fact) -- there is no wire mechanism by which this node could join a room on a different local agent's behalf.
220
+ */
221
+ private async joinRemoteRoom(
222
+ roomPath: string,
223
+ agentId: string,
224
+ ): Promise<Room> {
225
+ if (agentId !== this.deps.getPeerId()) {
226
+ throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
227
+ }
228
+ const parsed = parseRoomPath(roomPath);
229
+ if (parsed.kind !== "owner-named") {
230
+ throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
231
+ }
232
+
233
+ const outcome = await this.deps
234
+ .requireTransport()
235
+ .sendRoomRequest(
236
+ parsed.owner,
237
+ { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } },
238
+ { kind: "room", path: roomPath },
239
+ );
240
+ if (outcome.result !== "ok") {
241
+ throw new CommsError(
242
+ `Join request for ${roomPath} was refused (${outcome.code})`,
243
+ "JOIN_REFUSED",
244
+ );
245
+ }
246
+ const parsedOutcome = roomJoinOkSchema.safeParse(outcome);
247
+ if (!parsedOutcome.success) {
248
+ throw new CommsError(
249
+ `Join response for ${roomPath} was malformed`,
250
+ "MALFORMED_RESPONSE",
251
+ );
252
+ }
253
+ const { "granted-token": grantedToken, members: memberList } =
254
+ parsedOutcome.data;
255
+
256
+ const { slot } = this.deps.requireIdentity();
257
+ saveRoomToken(slot, roomPath, grantedToken);
258
+
259
+ const members = memberList.map((member) => deviceIdToHex(member.device));
260
+ const roomState = parseRoomStateExtension(parsedOutcome.data["room-state"]);
261
+ const room: Room = {
262
+ id: roomPath,
263
+ version: 1,
264
+ name: roomState?.name ?? parsed.localName,
265
+ // Falls back to "public" only against a peer running without the room-state extension (an older version); this joiner is always in `members` by construction regardless, so a stale "public" classification here never hides the room from its own member -- the one place type is read (listRooms' secret-room filter).
266
+ type: roomState?.type ?? "public",
267
+ owner: parsed.owner,
268
+ createdAt: new Date().toISOString(),
269
+ description: roomState?.description ?? "",
270
+ members,
271
+ invited: [],
272
+ memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
273
+ memberLeaves: {},
274
+ invitedJoins: {},
275
+ invitedLeaves: {},
276
+ };
277
+ this.deps.rooms.set(roomPath, room);
278
+ this.deps.messages.set(roomPath, []);
279
+ return room;
280
+ }
281
+
282
+ /**
283
+ * Refreshes this store's own local copy of a named room's membership and room-state via a real room.members request (P3.6): the same information room.join's own response carries at admission time, available on demand for a member whose local copy may have drifted (a kick, an invite, a rename since it joined). Sent to the room's own owner, the authoritative source for that room's real state. Named rooms only, matching joinRemoteRoom's own restriction -- a DM's "membership" is already fully known from the path itself (the sorted pair of exactly two participants), and this codebase has no Room-object representation for a DM to refresh into (DM state lives in this.dms, keyed by message history, not this.rooms). Throws if this store holds no room:member token for roomPath -- refreshing membership presupposes already being a member, the same NOT_A_MEMBER contract sendRoomMessageDirected already uses.
284
+ */
285
+ async refreshRoomMembers(roomPath: string): Promise<Room> {
286
+ const parsed = parseRoomPath(roomPath);
287
+ if (parsed.kind !== "owner-named") {
288
+ throw new CommsError(`Room ${roomPath} not found`, "ROOM_NOT_FOUND");
289
+ }
290
+ const { slot } = this.deps.requireIdentity();
291
+ const token = loadRoomTokens(slot)[roomPath];
292
+ if (token === undefined) {
293
+ throw new CommsError(
294
+ `No room:member token for ${roomPath}`,
295
+ "NOT_MEMBER",
296
+ );
297
+ }
298
+
299
+ const outcome = await this.deps
300
+ .requireTransport()
301
+ .sendRoomRequest(
302
+ parsed.owner,
303
+ { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.members" } },
304
+ { kind: "room", path: roomPath },
305
+ token,
306
+ );
307
+ if (outcome.result !== "ok") {
308
+ throw new CommsError(
309
+ `room.members refresh for ${roomPath} failed (${outcome.code})`,
310
+ "REFRESH_FAILED",
311
+ );
312
+ }
313
+ const parsedOutcome = roomMembersOkSchema.safeParse(outcome);
314
+ if (!parsedOutcome.success) {
315
+ throw new CommsError(
316
+ `room.members response for ${roomPath} was malformed`,
317
+ "MALFORMED_RESPONSE",
318
+ );
319
+ }
320
+ const members = parsedOutcome.data.members.map((member) =>
321
+ deviceIdToHex(member.device),
322
+ );
323
+ const roomState = parseRoomStateExtension(parsedOutcome.data["room-state"]);
324
+
325
+ const existing = this.deps.rooms.get(roomPath);
326
+ const room: Room = {
327
+ id: roomPath,
328
+ version: (existing?.version ?? 0) + 1,
329
+ name: roomState?.name ?? existing?.name ?? parsed.localName,
330
+ type: roomState?.type ?? existing?.type ?? "public",
331
+ owner: parsed.owner,
332
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
333
+ description: roomState?.description ?? existing?.description ?? "",
334
+ members,
335
+ invited: existing?.invited ?? [],
336
+ memberJoins: Object.fromEntries(members.map((member) => [member, 1])),
337
+ memberLeaves: {},
338
+ invitedJoins: existing?.invitedJoins ?? {},
339
+ invitedLeaves: existing?.invitedLeaves ?? {},
340
+ };
341
+ this.deps.rooms.set(roomPath, room);
342
+ return room;
343
+ }
344
+
345
+ /**
346
+ * The requester's own half of section 6's two-round DM consent flow: sends an ungated room.join scoped to dmRoomPath(this, counterpart) directly to the counterpart, records having initiated it so the counterpart's own reciprocal room.join back auto-approves rather than surfacing as a fresh, unsolicited request, and persists whatever grant comes back. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Safe to call again for the same counterpart later (e.g. after an earlier request expired or was rejected) -- it always sends a fresh request rather than checking for an existing token first.
347
+ *
348
+ * dmSendGrant, when given, is a dm:send capability the counterpart's own user principal minted for this device (agent-comms#162, admitAgentForDm) -- attaching it here lets the counterpart's own handleRoomJoin verify durable, pre-existing admission and auto-admit immediately, without holding this request open for a fresh human decision the way an ungated request otherwise would.
349
+ */
350
+ async requestDmAccess(
351
+ counterpart: string,
352
+ dmSendGrant?: CapabilityToken,
353
+ ): Promise<void> {
354
+ const dmPath = dmRoomPath(this.deps.getPeerId(), counterpart);
355
+ this.deps.dmRequestsInitiatedByMe.add(dmPath);
356
+ const outcome = await this.deps
357
+ .requireTransport()
358
+ .sendRoomRequest(
359
+ counterpart,
360
+ { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } },
361
+ { kind: "room", path: dmPath },
362
+ dmSendGrant,
363
+ );
364
+ if (outcome.result !== "ok") {
365
+ throw new CommsError(
366
+ `DM access request to ${counterpart} was refused (${outcome.code})`,
367
+ "JOIN_REFUSED",
368
+ );
369
+ }
370
+ const parsedOutcome = roomJoinOkSchema.safeParse(outcome);
371
+ if (!parsedOutcome.success) {
372
+ throw new CommsError(
373
+ `DM access response from ${counterpart} was malformed`,
374
+ "MALFORMED_RESPONSE",
375
+ );
376
+ }
377
+ const { slot } = this.deps.requireIdentity();
378
+ saveRoomToken(slot, dmPath, parsedOutcome.data["granted-token"]);
379
+ }
380
+
381
+ /**
382
+ * Joins a room. For this store's own identity, "already known locally" is not the right gate for skipping real admission: the legacy full-state-sync replicates a room's metadata to every mesh-connected peer the moment it's created, well before that peer has ever been admitted, so a room already present in this.rooms says nothing about whether this store actually holds a valid room:member token for it. The real gate is that token's presence -- absent, this always goes through joinRemoteRoom's real wire-level admission regardless of what this.rooms already knows, so a peer that merely heard about a room never mistakes hearing about it for having joined it. Joining on behalf of a DIFFERENT agentId (this store's own convergence/admin bookkeeping, exercised directly by state-sync-convergence.test.ts) is untouched -- that's a pure local CRDT mutation with no admission concept at all.
383
+ */
384
+ async joinRoom(roomIdOrName: string, agentId: string): Promise<Room> {
385
+ const roomId = this.resolveRoomId(roomIdOrName);
386
+ const peerId = this.deps.getPeerId();
387
+ if (agentId === peerId) {
388
+ const { slot } = this.deps.requireIdentity();
389
+ if (loadRoomTokens(slot)[roomId] === undefined) {
390
+ return this.joinRemoteRoom(roomId, agentId);
391
+ }
392
+ }
393
+ const room = this.deps.rooms.get(roomId);
394
+ if (!room) return this.joinRemoteRoom(roomId, agentId);
395
+
396
+ const alreadyMember = room.members.includes(agentId);
397
+ if (!alreadyMember && room.type !== "public") {
398
+ if (!room.invited.includes(agentId) && room.owner !== agentId) {
399
+ throw new CommsError(`Not invited to room ${roomId}`, "NOT_INVITED");
400
+ }
401
+ }
402
+
403
+ this.deps.deliveryEngine.bump(room);
404
+ this.deps.deliveryEngine.recordMemberOp(room, "member", "join", agentId);
405
+ if (alreadyMember || room.type !== "public") {
406
+ // Consuming an invitation (or re-joining) retires the invited entry.
407
+ this.deps.deliveryEngine.recordMemberOp(
408
+ room,
409
+ "invited",
410
+ "leave",
411
+ agentId,
412
+ );
413
+ }
414
+ this.deps.deliveryEngine.refreshMembership(room);
415
+ this.deps.rooms.set(roomId, room);
416
+
417
+ const agent = this.deps.agents.get(agentId);
418
+ if (agent && !agent.subscribedRooms.includes(roomId)) {
419
+ agent.subscribedRooms.push(roomId);
420
+ this.deps.deliveryEngine.bump(agent);
421
+ this.deps.agents.set(agentId, agent);
422
+ await this.deps.deliveryEngine.broadcastPatch({
423
+ type: "agent_upsert",
424
+ agent,
425
+ });
426
+ }
427
+
428
+ await this.deps.deliveryEngine.broadcastPatch({
429
+ type: "room_upsert",
430
+ room,
431
+ });
432
+
433
+ // Send current member list to the joining agent
434
+ const members: { id: string; name: string; status: AgentStatus }[] = [];
435
+ for (const memberId of room.members) {
436
+ const memberAgent = this.deps.agents.get(memberId);
437
+ if (memberAgent) {
438
+ members.push({
439
+ id: memberAgent.id,
440
+ name: memberAgent.name,
441
+ status: memberAgent.status,
442
+ });
443
+ }
444
+ }
445
+ await this.deps.deliveryEngine.deliverToMember(agentId, roomId, {
446
+ type: "room_members",
447
+ room: roomId,
448
+ members,
449
+ });
450
+
451
+ // Notify existing members of the join
452
+ await this.deps.deliveryEngine.deliverToRoom(
453
+ roomId,
454
+ {
455
+ type: "member_joined",
456
+ room: roomId,
457
+ agent: agentId,
458
+ },
459
+ agentId,
460
+ );
461
+
462
+ return room;
463
+ }
464
+
465
+ /**
466
+ * Leaves a room. For this store's own identity leaving a room it does not itself own, the local Room object is only ever this store's own static snapshot from when it joined or was invited (P3.6/P3.8) -- mutating it directly, as the legacy branch below does, would tell nobody but this store itself. The real effect needs a wire round trip to the room's own owner instead, so this always defers to leaveRemoteRoom in that case. Leaving on behalf of a DIFFERENT agentId, or the owner leaving their own room (this store's own authoritative copy), is untouched -- that is the legacy CRDT mutation state-sync-convergence.test.ts exercises directly.
467
+ */
468
+ async leaveRoom(roomIdOrName: string, agentId: string): Promise<void> {
469
+ const roomId = this.resolveRoomId(roomIdOrName);
470
+ const room = this.deps.rooms.get(roomId);
471
+ if (!room)
472
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
473
+ const peerId = this.deps.getPeerId();
474
+ if (agentId === peerId && room.owner !== peerId) {
475
+ await this.leaveRemoteRoom(roomId, room.owner);
476
+ return;
477
+ }
478
+
479
+ this.deps.deliveryEngine.bump(room);
480
+ this.deps.deliveryEngine.recordMemberOp(room, "member", "leave", agentId);
481
+ this.deps.deliveryEngine.refreshMembership(room);
482
+ this.deps.rooms.set(roomId, room);
483
+
484
+ const agent = this.deps.agents.get(agentId);
485
+ if (agent) {
486
+ agent.subscribedRooms = agent.subscribedRooms.filter(
487
+ (id) => id !== roomId,
488
+ );
489
+ this.deps.agents.set(agentId, agent);
490
+ await this.deps.deliveryEngine.broadcastPatch({
491
+ type: "agent_upsert",
492
+ agent,
493
+ });
494
+ }
495
+
496
+ await this.deps.deliveryEngine.broadcastPatch({
497
+ type: "room_upsert",
498
+ room,
499
+ });
500
+ await this.deps.deliveryEngine.deliverToRoom(roomId, {
501
+ type: "member_left",
502
+ room: roomId,
503
+ agent: agentId,
504
+ });
505
+
506
+ if (room.members.length === 0 && room.owner === agentId) {
507
+ await this.destroyRoom(roomId, agentId);
508
+ }
509
+ }
510
+
511
+ /**
512
+ * The remote-leave path: roomPath names a room this store is a member of but does not own, so leaving for real means telling the owner over a real, wire-authenticated room.leave rather than mutating a local Room record nobody else reads. Also declineInvite's own mechanism (P3.8): the moment a target receives an invite (handleRoomInvite), it already holds a real, persisted room:member token exactly as if it had joined -- declining is simply leaving before ever really participating, and the owner's own receiving side (handleRoomLeave) tells the two cases apart by checking its own membership/invited lists, not by a separate verb. reason rides room.leave's own open extension tail so the owner can still surface a real decline reason without a second wire shape.
513
+ */
514
+ private async leaveRemoteRoom(
515
+ roomPath: string,
516
+ ownerId: string,
517
+ reason?: string,
518
+ ): Promise<void> {
519
+ const { slot } = this.deps.requireIdentity();
520
+ const token = loadRoomTokens(slot)[roomPath];
521
+ if (token === undefined) {
522
+ throw new CommsError(
523
+ `No room:member token for ${roomPath}`,
524
+ "NOT_MEMBER",
525
+ );
526
+ }
527
+ const params: Record<string, unknown> = {
528
+ verb: "room.leave",
529
+ ...(reason !== undefined ? { reason } : {}),
530
+ };
531
+ const outcome = await this.deps
532
+ .requireTransport()
533
+ .sendRoomRequest(
534
+ ownerId,
535
+ { verb: ROOM_MEMBER_CAPABILITY, params },
536
+ { kind: "room", path: roomPath },
537
+ token,
538
+ );
539
+ if (outcome.result !== "ok") {
540
+ throw new CommsError(
541
+ `Leaving ${roomPath} failed (${outcome.code})`,
542
+ "LEAVE_FAILED",
543
+ );
544
+ }
545
+ deleteRoomToken(slot, roomPath);
546
+ this.deps.rooms.delete(roomPath);
547
+ this.deps.messages.delete(roomPath);
548
+ }
549
+
550
+ /**
551
+ * Invites targetId to roomId, over a real, wire-authenticated room.invite (P3.8): mints a fresh room:member grant for the target, records its own token-id the same way admitRoomJoin does (kickFromRoom can revoke an invited member's own grant exactly as it can a joined one), and pushes the grant to the target directly in the invite request itself -- room.invite is deliberately ungated (the room's own owner needs no capability to invite, per core/room's design), so the target's own verification of the embedded token is what proves this invite is genuine, not anything about the connection it arrived on. Retains the local CRDT invited-list bookkeeping (still this store's own record of who it has invited) but no longer broadcasts it: the target learns of the invite from the real request, not a mesh-wide patch.
552
+ */
553
+ async inviteToRoom(
554
+ roomIdOrName: string,
555
+ targetId: string,
556
+ inviterId: string,
557
+ ): Promise<void> {
558
+ const roomId = this.resolveRoomId(roomIdOrName);
559
+ const room = this.deps.rooms.get(roomId);
560
+ if (!room)
561
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
562
+ if (room.owner !== inviterId)
563
+ throw new CommsError("Only the room owner can invite", "NOT_OWNER");
564
+
565
+ this.deps.deliveryEngine.bump(room);
566
+ if (!room.invited.includes(targetId) && !room.members.includes(targetId)) {
567
+ this.deps.deliveryEngine.recordMemberOp(
568
+ room,
569
+ "invited",
570
+ "join",
571
+ targetId,
572
+ );
573
+ }
574
+ this.deps.deliveryEngine.refreshMembership(room);
575
+ this.deps.rooms.set(roomId, room);
576
+
577
+ const { identity, clock, slot } = this.deps.requireIdentity();
578
+ const tokenId = randomId();
579
+ const verdict = await mintCapabilityToken({
580
+ identity,
581
+ clock,
582
+ tokenId,
583
+ bearer: deviceIdFromHex(targetId),
584
+ capability: ROOM_MEMBER_CAPABILITY,
585
+ scope: { kind: "room", path: roomId },
586
+ expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
587
+ delegationsRemaining: resolveDelegationsRemaining(
588
+ ROOM_MEMBER_DELEGATION_POLICY,
589
+ ROOM_MEMBER_CAPABILITY,
590
+ targetId,
591
+ 0,
592
+ ),
593
+ });
594
+ if (!verdict.ok) {
595
+ throw new CommsError(
596
+ `Failed to mint an invite grant for ${targetId}`,
597
+ "MINT_FAILED",
598
+ );
599
+ }
600
+ saveIssuedRoomGrant(slot, roomId, targetId, tokenId);
601
+
602
+ const inviter = this.deps.agents.get(inviterId);
603
+ const params: Record<string, unknown> = {
604
+ verb: "room.invite",
605
+ invitee: deviceIdFromHex(targetId),
606
+ token: verdict.token,
607
+ ...roomStateExtension(room),
608
+ ...(inviter !== undefined ? inviterAgentExtension(inviter) : {}),
609
+ };
610
+ const outcome = await this.deps
611
+ .requireTransport()
612
+ .sendRoomRequest(
613
+ targetId,
614
+ { verb: ROOM_MEMBER_CAPABILITY, params },
615
+ { kind: "room", path: roomId },
616
+ );
617
+ if (outcome.result !== "ok") {
618
+ throw new CommsError(
619
+ `Invite to ${targetId} for ${roomId} failed (${outcome.code})`,
620
+ "INVITE_FAILED",
621
+ );
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Declines a pending invite, over the same real room.leave request leaveRemoteRoom already sends for an actual leave (P3.8): the moment this store received the invite (handleRoomInvite), it already holds a real, persisted room:member token, so declining before ever really participating is simply leaving early -- the owner's own receiving side (handleRoomLeave) tells the two cases apart from its own membership/invited lists, not from a separate verb. Uses parseRoomPath rather than any locally cached Room record to find the owner to leave, since handleRoomInvite never constructs one -- there is nothing here to read a room.owner field off in the first place.
627
+ */
628
+ async declineInvite(
629
+ roomId: string,
630
+ agentId: string,
631
+ reason: string,
632
+ ): Promise<void> {
633
+ // Every real caller declines on its own behalf (tool.ts always passes ctx.agentId, which for a real bridge process is always this.peerId, per the design's own "one bridge is one agent is one device" organising fact) -- there is no remote-decline-on-someone-else's-behalf mechanism, so a mismatch here means the caller itself is confused about whose invite it is declining.
634
+ if (agentId !== this.deps.getPeerId()) {
635
+ throw new CommsError(
636
+ `Cannot decline an invite on behalf of ${agentId}`,
637
+ "NOT_SELF",
638
+ );
639
+ }
640
+ const parsed = parseRoomPath(roomId);
641
+ if (parsed.kind !== "owner-named") {
642
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
643
+ }
644
+ return this.leaveRemoteRoom(roomId, parsed.owner, reason);
645
+ }
646
+
647
+ /**
648
+ * Revokes memberId's own room:member grant for roomId for real, if this identity ever recorded issuing one: mints a revocation-entry for its token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and forgets the issued-grant record (a later re-admission mints and records a genuinely fresh one rather than leaving a stale entry alongside it). Silently does nothing when no issued-grant record exists (a grant predating this bookkeeping, or a member who was never actually admitted a token at all) -- shared by kickFromRoom (owner-initiated) and RoomProtocol's own handleRoomLeave (member-initiated, including a decline), which calls this via a deferred closure since RoomProtocol is constructed before RoomLifecycle exists.
649
+ */
650
+ async revokeMemberGrant(roomId: string, memberId: string): Promise<void> {
651
+ const { identity, clock, slot, revocation } = this.deps.requireIdentity();
652
+ const tokenId = loadIssuedRoomGrant(slot, roomId, memberId);
653
+ if (tokenId === undefined) return;
654
+ const entry = await mintRevocationEntry({
655
+ identity,
656
+ tokenId,
657
+ revokedAt: clock.now(),
658
+ });
659
+ await revocation.record(entry, { identity });
660
+ await this.deps.requireTransport().broadcastRevocation([entry]);
661
+ deleteIssuedRoomGrant(slot, roomId, memberId);
662
+ }
663
+
664
+ /**
665
+ * Kicks targetId from roomId. Beyond the legacy CRDT membership removal (retired in P3.8 along with every other non-message broadcastPatch caller), this revokes the member's own room:member grant for real: mints a revocation-entry for the token-id this identity recorded when it admitted them (admitRoomJoin's own saveIssuedRoomGrant), records it in this store's own RevocationView immediately (so this identity's own future verifications see the kick without waiting on its own gossip), and announces it to every connected peer so each one's independent verification of the target's token -- not just this room's owner -- also starts failing as "revoked" from here on. Silently skips the revocation step (kick still happens; only the token-side enforcement doesn't) when no issued-grant record exists for this member, e.g. a grant predating this bookkeeping.
666
+ */
667
+ async kickFromRoom(
668
+ roomIdOrName: string,
669
+ targetId: string,
670
+ kickerId: string,
671
+ ): Promise<void> {
672
+ const roomId = this.resolveRoomId(roomIdOrName);
673
+ const room = this.deps.rooms.get(roomId);
674
+ if (!room)
675
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
676
+ if (room.owner !== kickerId)
677
+ throw new CommsError("Only the room owner can kick", "NOT_OWNER");
678
+
679
+ await this.revokeMemberGrant(roomId, targetId);
680
+
681
+ this.deps.deliveryEngine.bump(room);
682
+ this.deps.deliveryEngine.recordMemberOp(room, "member", "leave", targetId);
683
+ this.deps.deliveryEngine.recordMemberOp(room, "invited", "leave", targetId);
684
+ this.deps.deliveryEngine.refreshMembership(room);
685
+ this.deps.rooms.set(roomId, room);
686
+ await this.deps.deliveryEngine.broadcastPatch({
687
+ type: "room_upsert",
688
+ room,
689
+ });
690
+ }
691
+
692
+ /**
693
+ * Destroys roomId, revoking every member's own room:member grant for real first (P3.8) -- the same revocation machinery kickFromRoom already uses, since destroying a room out from under its members is exactly as much a membership revocation as kicking them individually would be, just for all of them at once. Room-existence notification (agent_upsert/room_delete) stays on the legacy broadcastPatch for now: replacing it needs the same broader informational-events redesign the rest of P3.8 already tracks as separate, larger work, not something this specific fix should improvise alone.
694
+ */
695
+ async destroyRoom(roomIdOrName: string, agentId: string): Promise<void> {
696
+ const roomId = this.resolveRoomId(roomIdOrName);
697
+ const room = this.deps.rooms.get(roomId);
698
+ if (!room)
699
+ throw new CommsError(`Room ${roomId} not found`, "ROOM_NOT_FOUND");
700
+ if (room.owner !== agentId)
701
+ throw new CommsError("Only the room owner can destroy", "NOT_OWNER");
702
+
703
+ for (const memberId of room.members) {
704
+ await this.revokeMemberGrant(roomId, memberId);
705
+ const member = this.deps.agents.get(memberId);
706
+ if (member) {
707
+ member.subscribedRooms = member.subscribedRooms.filter(
708
+ (id) => id !== roomId,
709
+ );
710
+ this.deps.agents.set(memberId, member);
711
+ await this.deps.deliveryEngine.broadcastPatch({
712
+ type: "agent_upsert",
713
+ agent: member,
714
+ });
715
+ }
716
+ }
717
+
718
+ this.deps.rooms.delete(roomId);
719
+ this.deps.messages.delete(roomId);
720
+ await this.deps.deliveryEngine.broadcastPatch({
721
+ type: "room_delete",
722
+ roomId,
723
+ });
724
+ }
725
+
726
+ /**
727
+ * Presents a dm:send grant another user issued, so this device's first DM to counterpart is admitted without a decision at the other end. A grant issued to this device is presented as it is. A grant issued to this device's own user principal (admitAgentForDm with a delegation depth) is first sub-delegated to this device by that principal, since the receiver verifies the bearer against the device actually connecting; every device of the user can therefore reuse the same grant.
728
+ *
729
+ * Throws GRANT_NOT_FOR_US when the grant names neither this device nor its user, and GRANT_REJECTED when it names one of them but is unusable (expired, revoked, or badly signed).
730
+ */
731
+ async presentDmGrant(
732
+ counterpart: string,
733
+ grant: CapabilityToken,
734
+ ): Promise<void> {
735
+ const { identity, clock, revocation, userIdentity, accountLedger } =
736
+ this.deps.requireIdentity();
737
+ const forThisDevice = await verifyCapabilityToken(grant, {
738
+ identity,
739
+ clock,
740
+ revocation,
741
+ expectedBearer: identity.deviceId,
742
+ });
743
+ if (forThisDevice.ok) {
744
+ await this.requestDmAccess(counterpart, grant);
745
+ return;
746
+ }
747
+ if (forThisDevice.reason !== BEARER_MISMATCH) {
748
+ throw new CommsError(
749
+ `The DM grant cannot be used (${forThisDevice.reason})`,
750
+ "GRANT_REJECTED",
751
+ );
752
+ }
753
+
754
+ const forThisUser = await verifyCapabilityToken(grant, {
755
+ identity,
756
+ clock,
757
+ revocation,
758
+ expectedBearer: userIdentity.deviceId,
759
+ });
760
+ if (!forThisUser.ok) {
761
+ throw new CommsError(
762
+ forThisUser.reason === BEARER_MISMATCH
763
+ ? "The DM grant was not issued to this device or its user"
764
+ : `The DM grant cannot be used (${forThisUser.reason})`,
765
+ forThisUser.reason === BEARER_MISMATCH
766
+ ? "GRANT_NOT_FOR_US"
767
+ : "GRANT_REJECTED",
768
+ );
769
+ }
770
+ const delegated = await delegateDmSendToDevice({
771
+ userIdentity,
772
+ accountLedger,
773
+ clock,
774
+ tokenId: randomId(),
775
+ parent: grant,
776
+ deviceId: identity.deviceId,
777
+ remoteUserPrincipalDeviceId: forThisUser.rootIssuer,
778
+ expires: forThisUser.claims.expires,
779
+ });
780
+ if (!delegated.ok) {
781
+ throw new CommsError(
782
+ `Could not delegate the DM grant to this device: ${delegated.reason}`,
783
+ "DELEGATION_FAILED",
784
+ );
785
+ }
786
+ await this.requestDmAccess(counterpart, delegated.token);
787
+ }
788
+
789
+ /**
790
+ * Admits bearerId into this user's own DM-communication scope (agent-comms#162): mints a fresh dm:send grant, self-signed by this store's own user principal (userIdentity, distinct from the per-bridge-slot device identity every other room:member grant above is minted against), with no parent: a root-level admission, exactly like mintOwnerRootGrant's own room-owner self-grant. Records the token-id in the account's replicated grant ledger (account-ledger.ts), so revokeAgentDmAccess on this or any other machine holding the account key can later name which one to revoke. Returns the minted token for the caller to get to bearerId out of band (there is no wire-level push here, deliberately: this issue adds the receiver-side check and the admission primitive it checks against, not a new delivery mechanism for the grant itself).
791
+ *
792
+ * delegationsRemaining defaults to 0 -- the original, non-delegable behaviour, unchanged for a bearer that is just a bare device with no principal of its own. Passing a positive value admits bearerId as a user PRINCIPAL rather than a single device (agent-comms#187): the principal itself then holds enough delegation depth to mint further dm:send tokens (bearer = one of its own devices, parent = this grant) via dm-send-delegation.ts's delegateDmSendToDevice, the dm:send counterpart to how #161's device-membership tokens already let a principal admit its own devices. verifyDmSendToken needs no change to accept the result: its chain-walk already resolves rootIssuer through arbitrarily many hops back to this call's own userIdentity, regardless of how many of those hops this grant itself permits.
793
+ */
794
+ async admitAgentForDm(
795
+ bearerId: string,
796
+ delegationsRemaining = 0,
797
+ ): Promise<CapabilityToken> {
798
+ const { userIdentity, accountLedger, clock } = this.deps.requireIdentity();
799
+ const tokenId = randomId();
800
+ const verdict = await mintCapabilityToken({
801
+ identity: userIdentity,
802
+ clock,
803
+ tokenId,
804
+ bearer: deviceIdFromHex(bearerId),
805
+ capability: DM_SEND_CAPABILITY,
806
+ scope: { kind: "user", path: deviceIdToHex(userIdentity.deviceId) },
807
+ expires: clock.now() + DM_SEND_GRANT_LIFETIME_MS,
808
+ delegationsRemaining,
809
+ });
810
+ if (!verdict.ok) {
811
+ throw new CommsError(
812
+ `Failed to mint a dm:send grant for ${bearerId}: ${verdict.reason}`,
813
+ "MINT_FAILED",
814
+ );
815
+ }
816
+ await accountLedger.recordGrant("dm", bearerId, tokenId);
817
+ return verdict.token;
818
+ }
819
+
820
+ /**
821
+ * Revokes every dm:send grant for bearerId that the account's replicated ledger holds as outstanding, whichever machine of the account minted it: mints a revocation-entry for each token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and appends it to the ledger so every machine of the account sees it revoked and a later re-admission starts from nothing outstanding. The same revocation shape revokeMemberGrant gives room:member grants, applied to the user principal's own dm:send grants. Does nothing when the ledger holds no outstanding grant for bearerId.
822
+ */
823
+ async revokeAgentDmAccess(bearerId: string): Promise<void> {
824
+ const { userIdentity, accountLedger, clock, revocation } =
825
+ this.deps.requireIdentity();
826
+ const outstanding = await accountLedger.outstandingGrants("dm", bearerId);
827
+ for (const { tokenId } of outstanding) {
828
+ const entry = await mintRevocationEntry({
829
+ identity: userIdentity,
830
+ tokenId,
831
+ revokedAt: clock.now(),
832
+ });
833
+ await revocation.record(entry, { identity: userIdentity });
834
+ await this.deps.requireTransport().broadcastRevocation([entry]);
835
+ await accountLedger.recordRevocation("dm", bearerId, tokenId, entry);
836
+ }
837
+ }
838
+ }