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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoomProtocol — the receiving/responding half of the room wire protocol: the room-verb handlers a WireMeshTransport dispatches into (room.send, room.read, room.members, room.join, room.invite, room.leave), the directed room-domain request fan-out primitives (sendRoomMessageDirected, sendRoomRequestToMember, its retry queue and flush), and the human accept/reject decision points for a pending room.join. Split out of mesh-store.ts to reduce it under the repo's max-lines cap. Owns pendingRoomRequests and pendingRoomJoins exclusively -- nothing outside this class ever reads or writes them.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { bytesToHex, deviceIdFromHex } from "wire-mesh-core/domain/device-id";
|
|
6
|
+
import { mintCapabilityToken } from "wire-mesh-core/domain/tokens";
|
|
7
|
+
import type {
|
|
8
|
+
IncomingManageRequest,
|
|
9
|
+
ManageOutcome,
|
|
10
|
+
} from "wire-mesh-core/domain/mesh-session";
|
|
11
|
+
import {
|
|
12
|
+
roomInviteSchema,
|
|
13
|
+
roomLeaveSchema,
|
|
14
|
+
roomReadSchema,
|
|
15
|
+
roomSendSchema,
|
|
16
|
+
} from "wire-mesh-core/generated/protocol";
|
|
17
|
+
import type {
|
|
18
|
+
CapabilityToken,
|
|
19
|
+
MessageRef,
|
|
20
|
+
} from "wire-mesh-core/generated/protocol";
|
|
21
|
+
import { parseRoomPath } from "./room-path.js";
|
|
22
|
+
import {
|
|
23
|
+
ROOM_MEMBER_CAPABILITY,
|
|
24
|
+
ROOM_MEMBER_DELEGATION_POLICY,
|
|
25
|
+
verifyRoomToken,
|
|
26
|
+
} from "./room-token-verification.js";
|
|
27
|
+
import { resolveDelegationsRemaining } from "./delegation-policy.js";
|
|
28
|
+
import { verifyDmSendToken } from "./dm-token-verification.js";
|
|
29
|
+
import {
|
|
30
|
+
loadRoomTokens,
|
|
31
|
+
saveIssuedRoomGrant,
|
|
32
|
+
saveRoomToken,
|
|
33
|
+
} from "./identity-store.js";
|
|
34
|
+
import { randomId } from "./random-id.js";
|
|
35
|
+
import { CommsError } from "./store.js";
|
|
36
|
+
import {
|
|
37
|
+
MAX_PENDING_ROOM_REQUESTS_PER_MEMBER,
|
|
38
|
+
PENDING_ROOM_REQUEST_TTL_MS,
|
|
39
|
+
ROOM_TOKEN_LIFETIME_MS,
|
|
40
|
+
} from "./mesh-store-shared.js";
|
|
41
|
+
import {
|
|
42
|
+
classifyManageOutcome,
|
|
43
|
+
type RoomRequestOutcome,
|
|
44
|
+
} from "./send-outcome.js";
|
|
45
|
+
import type {
|
|
46
|
+
MeshStoreIdentity,
|
|
47
|
+
RoomJoinDecision,
|
|
48
|
+
} from "./mesh-store-shared.js";
|
|
49
|
+
import {
|
|
50
|
+
parseInviterAgentExtension,
|
|
51
|
+
parseRoomStateExtension,
|
|
52
|
+
roomStateExtension,
|
|
53
|
+
} from "./room-wire-extensions.js";
|
|
54
|
+
import type { DeliveryEngine } from "./delivery-engine.js";
|
|
55
|
+
import type { RoomVerbHandler } from "./room-router.js";
|
|
56
|
+
import type { ConnectionHandle, MeshTransport } from "./transport.js";
|
|
57
|
+
import { z } from "zod";
|
|
58
|
+
import { DeliveryEventSchema, StreamingBehavior } from "./types.js";
|
|
59
|
+
import type {
|
|
60
|
+
AgentIdentity,
|
|
61
|
+
DeliveryEvent,
|
|
62
|
+
DmMessage,
|
|
63
|
+
MessageDelivery,
|
|
64
|
+
Room,
|
|
65
|
+
RoomMessage,
|
|
66
|
+
TransientSendFailure,
|
|
67
|
+
} from "./types.js";
|
|
68
|
+
|
|
69
|
+
/** One directed room-domain request held for retry. queuedAt fixes the entry's age for the queue's own age bound, and survives a failed retry rather than being refreshed by it, so a member that is briefly reachable over and over cannot keep a message pending forever. messageId is the hex id of the message this request carries, present only for a room.send: a room.read or room.notify has no message of this sender's own to report a status against. */
|
|
70
|
+
interface PendingRoomRequest {
|
|
71
|
+
roomPath: string;
|
|
72
|
+
params: Record<string, unknown>;
|
|
73
|
+
queuedAt: number;
|
|
74
|
+
reason: TransientSendFailure;
|
|
75
|
+
messageId: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The hex message-id a directed room-domain request carries, when it is a room.send carrying one. Read from the already-built params rather than re-derived, so a queued entry can report a delivery status against the very message the sender was told about. */
|
|
79
|
+
function messageIdFromParams(
|
|
80
|
+
params: Readonly<Record<string, unknown>>,
|
|
81
|
+
): string | undefined {
|
|
82
|
+
if (params.verb !== "room.send") return undefined;
|
|
83
|
+
const raw = params["message-id"];
|
|
84
|
+
return raw instanceof Uint8Array ? bytesToHex(raw) : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** room.notify's own params shape: the already-validated DeliveryEvent to deliver, and nothing else -- room.notify carries no content of its own beyond the event, unlike room.send's own message/dm fields. Not a wire-mesh-generated schema (room.notify is agent-comms' own verb, riding manage-command-params' open socket the same way the legacy opaque frame carriage always did, never a real wire-mesh CDDL type other clients need to interoperate with). */
|
|
88
|
+
const RoomNotifyParamsSchema = z.object({ event: DeliveryEventSchema });
|
|
89
|
+
|
|
90
|
+
/** The state and collaborators RoomProtocol needs from MeshStore. rooms/messages/dms/agents/dmRequestsInitiatedByMe are direct references into MeshStore's own fields; deliveryEngine is the already-constructed instance, narrowed to what a room-verb handler ever needs; revokeMemberGrant is deferred (RoomLifecycle, which owns it, doesn't exist yet when RoomProtocol is constructed -- construction order: ... -\> roomProtocol -\> roomMessaging -\> roomLifecycle -\> ...), wired the same lazy-`this`-capture way DeliveryEngine's own sendRoomRequestToMember closure is. */
|
|
91
|
+
export interface RoomProtocolDeps {
|
|
92
|
+
rooms: Map<string, Room>;
|
|
93
|
+
messages: Map<string, RoomMessage[]>;
|
|
94
|
+
dms: Map<string, DmMessage[]>;
|
|
95
|
+
agents: Map<string, AgentIdentity>;
|
|
96
|
+
dmRequestsInitiatedByMe: Set<string>;
|
|
97
|
+
getPeerId: () => string;
|
|
98
|
+
requireIdentity: () => MeshStoreIdentity;
|
|
99
|
+
requireTransport: () => MeshTransport;
|
|
100
|
+
deliveryEngine: Pick<
|
|
101
|
+
DeliveryEngine,
|
|
102
|
+
| "queueDelivery"
|
|
103
|
+
| "fireLocalDelivery"
|
|
104
|
+
| "bump"
|
|
105
|
+
| "recordMemberOp"
|
|
106
|
+
| "refreshMembership"
|
|
107
|
+
| "broadcastPatch"
|
|
108
|
+
| "deliverToRoom"
|
|
109
|
+
>;
|
|
110
|
+
/** How long a room.join may await a human decision before this side expires it and answers the requester with a timeout. */
|
|
111
|
+
joinDecisionTimeoutMs: number;
|
|
112
|
+
/** RoomLifecycle's own method, deferred -- see the interface doc comment above. */
|
|
113
|
+
revokeMemberGrant: (roomId: string, memberId: string) => Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class RoomProtocol {
|
|
117
|
+
/** Directed room-domain requests (room.send, room.read) that failed because their target member wasn't reachable at send time, held for retry when that member becomes reachable by any route -- the wire-authenticated fan-out's substitute for the legacy full-state-sync's own automatic eventual consistency, since a direct request to a disconnected peer fails immediately with no protocol-level retry of its own. Keyed by member device-id hex, bounded oldest-first per member and by each entry's own age; every entry that leaves the queue without being delivered is reported to its sender as a terminal delivery status. */
|
|
118
|
+
private readonly pendingRoomRequests = new Map<
|
|
119
|
+
string,
|
|
120
|
+
PendingRoomRequest[]
|
|
121
|
+
>();
|
|
122
|
+
|
|
123
|
+
/** Pending room.join requests awaiting owner approval, keyed by `${roomPath}::${requesterId}` -- the held-open manage-request's own resolve() function is stored here so acceptRoomJoin/rejectRoomJoin can settle it in place. */
|
|
124
|
+
private readonly pendingRoomJoins = new Map<
|
|
125
|
+
string,
|
|
126
|
+
{
|
|
127
|
+
roomPath: string;
|
|
128
|
+
requesterId: string;
|
|
129
|
+
resolve: (decision: RoomJoinDecision) => void;
|
|
130
|
+
}
|
|
131
|
+
>();
|
|
132
|
+
|
|
133
|
+
constructor(private readonly deps: RoomProtocolDeps) {}
|
|
134
|
+
|
|
135
|
+
/** Room verb handlers this store registers with its own WireMeshTransport, keyed by params.verb per room-router.ts's own dispatch discipline. */
|
|
136
|
+
get roomVerbHandlers(): Partial<Record<string, RoomVerbHandler>> {
|
|
137
|
+
return {
|
|
138
|
+
"room.join": async (request, handle) =>
|
|
139
|
+
this.handleRoomJoin(request, handle),
|
|
140
|
+
"room.send": async (request, handle) =>
|
|
141
|
+
this.handleRoomSend(request, handle),
|
|
142
|
+
"room.read": async (request, handle) =>
|
|
143
|
+
this.handleRoomRead(request, handle),
|
|
144
|
+
"room.members": async (request, handle) =>
|
|
145
|
+
this.handleRoomMembers(request, handle),
|
|
146
|
+
"room.invite": async (request) => this.handleRoomInvite(request),
|
|
147
|
+
"room.leave": async (request, handle) =>
|
|
148
|
+
this.handleRoomLeave(request, handle),
|
|
149
|
+
"room.notify": async (request, handle) =>
|
|
150
|
+
this.handleRoomNotify(request, handle),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reads the "reply" message-ref out of a room.send's own params (if any) and returns the hex id it names -- room.send's replyTo carries a single parent message, so the first reply-relation ref is the one that matters; any further refs are a future relation this handler doesn't yet act on. */
|
|
155
|
+
private static replyToFromRefs(
|
|
156
|
+
refs: readonly MessageRef[] | undefined,
|
|
157
|
+
): string | undefined {
|
|
158
|
+
const reply = refs?.find((ref) => ref.relation === "reply");
|
|
159
|
+
return reply === undefined ? undefined : bytesToHex(reply.id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Reads room.send's own "streaming-behavior" extension field (open params tail, not a named schema field) and validates it against the same StreamingBehavior contract every other delivery path already enforces -- an unrecognised or malformed value is dropped rather than rejecting the whole send, matching core/room's own obligation to ignore what it doesn't understand instead of failing closed on an extension field. */
|
|
163
|
+
private static streamingBehaviorFromParams(
|
|
164
|
+
params: Readonly<Record<string, unknown>>,
|
|
165
|
+
): StreamingBehavior | undefined {
|
|
166
|
+
const raw = params["streaming-behavior"];
|
|
167
|
+
if (raw === undefined) return undefined;
|
|
168
|
+
const result = StreamingBehavior.safeParse(raw);
|
|
169
|
+
return result.success ? result.data : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Receiving side of a directed room.send (P3.5): verifies the presented token against all six of core/room's own obligations, then delivers the message locally exactly once -- the manage-response this returns IS the delivery receipt, so there is no separate "delivered" event to emit the way the legacy broadcastPatch path needed one. Branches on the room-path's own shape: an owner-named path stores a RoomMessage in this room's own history and fires a room_message event; a DM path stores a DmMessage keyed by the same dm-path sendDm already uses and fires a dm event -- both ride the identical room:member-gated verb, since a DM is just a room-path variant, not a separate verb.
|
|
174
|
+
*/
|
|
175
|
+
private async handleRoomSend(
|
|
176
|
+
request: IncomingManageRequest,
|
|
177
|
+
handle: Readonly<ConnectionHandle>,
|
|
178
|
+
): Promise<ManageOutcome> {
|
|
179
|
+
const roomPath = request.scope.path;
|
|
180
|
+
if (roomPath === undefined) {
|
|
181
|
+
return { result: "error", code: "missing_scope_path" };
|
|
182
|
+
}
|
|
183
|
+
if (request.token === undefined) {
|
|
184
|
+
return { result: "error", code: "unauthorized" };
|
|
185
|
+
}
|
|
186
|
+
const { identity, clock, revocation } = this.deps.requireIdentity();
|
|
187
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
188
|
+
identity,
|
|
189
|
+
clock,
|
|
190
|
+
revocation,
|
|
191
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
192
|
+
roomPath,
|
|
193
|
+
});
|
|
194
|
+
if (!verdict.ok) {
|
|
195
|
+
return { result: "error", code: "unauthorized" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const parsedParams = roomSendSchema.safeParse(request.command.params);
|
|
199
|
+
if (!parsedParams.success) {
|
|
200
|
+
return { result: "error", code: "malformed_params" };
|
|
201
|
+
}
|
|
202
|
+
const params = parsedParams.data;
|
|
203
|
+
const replyTo = RoomProtocol.replyToFromRefs(params.refs);
|
|
204
|
+
const streamingBehavior = RoomProtocol.streamingBehaviorFromParams(params);
|
|
205
|
+
const id = bytesToHex(params["message-id"]);
|
|
206
|
+
const timestamp = new Date(params["sent-at"]).toISOString();
|
|
207
|
+
const peerId = this.deps.getPeerId();
|
|
208
|
+
|
|
209
|
+
const parsedPath = parseRoomPath(roomPath);
|
|
210
|
+
let event: DeliveryEvent;
|
|
211
|
+
if (parsedPath.kind === "dm") {
|
|
212
|
+
const message: DmMessage = {
|
|
213
|
+
id,
|
|
214
|
+
from: handle.id,
|
|
215
|
+
to: peerId,
|
|
216
|
+
content: params.text,
|
|
217
|
+
timestamp,
|
|
218
|
+
readBy: [handle.id],
|
|
219
|
+
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
220
|
+
};
|
|
221
|
+
const history = this.deps.dms.get(roomPath) ?? [];
|
|
222
|
+
history.push(message);
|
|
223
|
+
this.deps.dms.set(roomPath, history);
|
|
224
|
+
event = { type: "dm", message };
|
|
225
|
+
} else {
|
|
226
|
+
const message: RoomMessage = {
|
|
227
|
+
id,
|
|
228
|
+
from: handle.id,
|
|
229
|
+
room: roomPath,
|
|
230
|
+
content: params.text,
|
|
231
|
+
timestamp,
|
|
232
|
+
readBy: [handle.id],
|
|
233
|
+
...(replyTo !== undefined && { replyTo }),
|
|
234
|
+
...(streamingBehavior !== undefined && { streamingBehavior }),
|
|
235
|
+
};
|
|
236
|
+
const history = this.deps.messages.get(roomPath) ?? [];
|
|
237
|
+
history.push(message);
|
|
238
|
+
this.deps.messages.set(roomPath, history);
|
|
239
|
+
event = { type: "room_message", message };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
243
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
244
|
+
|
|
245
|
+
return { result: "ok" };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Receiving side of a directed room.notify (P3.8): the same token verification handleRoomSend does, then queues and fires the already-validated DeliveryEvent locally exactly as if it had arrived any other way -- room.notify carries no content of its own beyond the event, so there is nothing to construct or persist here, unlike room.send's own message/dm branches. Replaces the legacy mesh-wide broadcastPatch deliverToRoom used to ride for informational events (member_status, member_joined, name_changed, and the like) with a real directed request to each room member, matching room.send/room.read's own established shape. A malformed event, or one whose own room field doesn't match the token's verified scope, is refused rather than silently accepted -- unlike a gossiped advert's own self-asserted facts, this is an authenticated peer actively claiming something happened, so it gets the same strict validation room.send's params already get.
|
|
250
|
+
*/
|
|
251
|
+
private async handleRoomNotify(
|
|
252
|
+
request: IncomingManageRequest,
|
|
253
|
+
handle: Readonly<ConnectionHandle>,
|
|
254
|
+
): Promise<ManageOutcome> {
|
|
255
|
+
const roomPath = request.scope.path;
|
|
256
|
+
if (roomPath === undefined) {
|
|
257
|
+
return { result: "error", code: "missing_scope_path" };
|
|
258
|
+
}
|
|
259
|
+
if (request.token === undefined) {
|
|
260
|
+
return { result: "error", code: "unauthorized" };
|
|
261
|
+
}
|
|
262
|
+
const { identity, clock, revocation } = this.deps.requireIdentity();
|
|
263
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
264
|
+
identity,
|
|
265
|
+
clock,
|
|
266
|
+
revocation,
|
|
267
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
268
|
+
roomPath,
|
|
269
|
+
});
|
|
270
|
+
if (!verdict.ok) {
|
|
271
|
+
return { result: "error", code: "unauthorized" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const parsedParams = RoomNotifyParamsSchema.safeParse(
|
|
275
|
+
request.command.params,
|
|
276
|
+
);
|
|
277
|
+
if (!parsedParams.success) {
|
|
278
|
+
return { result: "error", code: "malformed_params" };
|
|
279
|
+
}
|
|
280
|
+
const event = parsedParams.data.event;
|
|
281
|
+
if ("room" in event && event.room !== roomPath) {
|
|
282
|
+
return { result: "error", code: "malformed_params" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const peerId = this.deps.getPeerId();
|
|
286
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
287
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
288
|
+
|
|
289
|
+
return { result: "ok" };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Receiving side of a directed room.read (P3.5): verifies the presented token the same way handleRoomSend does, then for each read message-id in the batch, updates this store's own local copy of that message's readBy (this store holds one because it's the message's own author -- the reason it's the one being notified) and fires a delivery_status event locally, replacing what markRead used to broadcast via the legacy message_read patch. Read receipts stay voluntary and best-effort by design: a message-id this store doesn't recognise (already expired from history, or simply never this store's own) is silently skipped rather than treated as an error.
|
|
294
|
+
*/
|
|
295
|
+
private async handleRoomRead(
|
|
296
|
+
request: IncomingManageRequest,
|
|
297
|
+
handle: Readonly<ConnectionHandle>,
|
|
298
|
+
): Promise<ManageOutcome> {
|
|
299
|
+
const roomPath = request.scope.path;
|
|
300
|
+
if (roomPath === undefined) {
|
|
301
|
+
return { result: "error", code: "missing_scope_path" };
|
|
302
|
+
}
|
|
303
|
+
if (request.token === undefined) {
|
|
304
|
+
return { result: "error", code: "unauthorized" };
|
|
305
|
+
}
|
|
306
|
+
const { identity, clock, revocation } = this.deps.requireIdentity();
|
|
307
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
308
|
+
identity,
|
|
309
|
+
clock,
|
|
310
|
+
revocation,
|
|
311
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
312
|
+
roomPath,
|
|
313
|
+
});
|
|
314
|
+
if (!verdict.ok) {
|
|
315
|
+
return { result: "error", code: "unauthorized" };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parsedParams = roomReadSchema.safeParse(request.command.params);
|
|
319
|
+
if (!parsedParams.success) {
|
|
320
|
+
return { result: "error", code: "malformed_params" };
|
|
321
|
+
}
|
|
322
|
+
const params = parsedParams.data;
|
|
323
|
+
const isDm = parseRoomPath(roomPath).kind === "dm";
|
|
324
|
+
const peerId = this.deps.getPeerId();
|
|
325
|
+
|
|
326
|
+
for (const messageIdBytes of params.messages) {
|
|
327
|
+
const messageId = bytesToHex(messageIdBytes);
|
|
328
|
+
const history = isDm
|
|
329
|
+
? this.deps.dms.get(roomPath)
|
|
330
|
+
: this.deps.messages.get(roomPath);
|
|
331
|
+
const message = history?.find((m) => m.id === messageId);
|
|
332
|
+
if (message === undefined) continue;
|
|
333
|
+
if (!message.readBy.includes(handle.id)) {
|
|
334
|
+
message.readBy.push(handle.id);
|
|
335
|
+
}
|
|
336
|
+
const event: DeliveryEvent = {
|
|
337
|
+
type: "delivery_status",
|
|
338
|
+
messageId,
|
|
339
|
+
agent: handle.id,
|
|
340
|
+
delivery: { status: "read" },
|
|
341
|
+
...(isDm ? {} : { room: roomPath }),
|
|
342
|
+
};
|
|
343
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
344
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { result: "ok" };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Receiving side of room.members (P3.6): a plain membership + room-state refresh for an already-admitted member, verified the same way handleRoomSend/handleRoomRead are -- room.members grants nothing new, it just answers "who's here, and what's this room called" on demand, the same information room.join's own response already carries at admission time. A DM path has no Room record (this.rooms never holds one for a dm-shaped path) and no name/description/type to report, so its own members list is derived directly from the path's own two participants instead.
|
|
352
|
+
*/
|
|
353
|
+
private async handleRoomMembers(
|
|
354
|
+
request: IncomingManageRequest,
|
|
355
|
+
handle: Readonly<ConnectionHandle>,
|
|
356
|
+
): Promise<ManageOutcome> {
|
|
357
|
+
const roomPath = request.scope.path;
|
|
358
|
+
if (roomPath === undefined) {
|
|
359
|
+
return { result: "error", code: "missing_scope_path" };
|
|
360
|
+
}
|
|
361
|
+
if (request.token === undefined) {
|
|
362
|
+
return { result: "error", code: "unauthorized" };
|
|
363
|
+
}
|
|
364
|
+
const { identity, clock, revocation } = this.deps.requireIdentity();
|
|
365
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
366
|
+
identity,
|
|
367
|
+
clock,
|
|
368
|
+
revocation,
|
|
369
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
370
|
+
roomPath,
|
|
371
|
+
});
|
|
372
|
+
if (!verdict.ok) {
|
|
373
|
+
return { result: "error", code: "unauthorized" };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const parsedPath = parseRoomPath(roomPath);
|
|
377
|
+
const room = this.deps.rooms.get(roomPath);
|
|
378
|
+
const members =
|
|
379
|
+
parsedPath.kind === "dm"
|
|
380
|
+
? parsedPath.participants.map((device) => ({
|
|
381
|
+
device: deviceIdFromHex(device),
|
|
382
|
+
}))
|
|
383
|
+
: (room?.members ?? []).map((memberId) => ({
|
|
384
|
+
device: deviceIdFromHex(memberId),
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
result: "ok",
|
|
389
|
+
members,
|
|
390
|
+
...roomStateExtension(room),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Sends one directed room.send to a single member's own session, attaching this store's own persisted room:member token for the given room path -- the primitive P3.5's own directed fan-out (deliverToRoom) will loop over per member once it replaces the legacy broadcastPatch path this store still uses for message delivery today. Throws if this store holds no token for the room: never a member, or a token that expired or was revoked with nothing fresh persisted in its place.
|
|
396
|
+
*/
|
|
397
|
+
async sendRoomMessageDirected(
|
|
398
|
+
roomPath: string,
|
|
399
|
+
memberId: string,
|
|
400
|
+
text: string,
|
|
401
|
+
): Promise<void> {
|
|
402
|
+
const { slot, clock } = this.deps.requireIdentity();
|
|
403
|
+
const token = loadRoomTokens(slot)[roomPath];
|
|
404
|
+
if (token === undefined) {
|
|
405
|
+
throw new CommsError(
|
|
406
|
+
`No room:member token for ${roomPath}`,
|
|
407
|
+
"NOT_A_MEMBER",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const outcome = await this.deps.requireTransport().sendRoomRequest(
|
|
411
|
+
memberId,
|
|
412
|
+
{
|
|
413
|
+
verb: ROOM_MEMBER_CAPABILITY,
|
|
414
|
+
params: {
|
|
415
|
+
verb: "room.send",
|
|
416
|
+
"message-id": randomId(),
|
|
417
|
+
"sent-at": clock.now(),
|
|
418
|
+
text,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
{ kind: "room", path: roomPath },
|
|
422
|
+
token,
|
|
423
|
+
);
|
|
424
|
+
if (outcome.result !== "ok") {
|
|
425
|
+
throw new CommsError(
|
|
426
|
+
`room.send to ${memberId} for ${roomPath} failed (${outcome.code})`,
|
|
427
|
+
"SEND_FAILED",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Tells this device's own agent what became of a message it sent, over the same delivery_status event read receipts already ride, so a queued, dropped, expired or refused message reaches the sender through the one path that already reports delivery. Does nothing for a request carrying no message of this sender's own (a room.read receipt, a room.notify), which has no message-id to report against. */
|
|
433
|
+
private reportDelivery(
|
|
434
|
+
memberId: string,
|
|
435
|
+
entry: Readonly<Pick<PendingRoomRequest, "roomPath" | "messageId">>,
|
|
436
|
+
delivery: MessageDelivery,
|
|
437
|
+
): void {
|
|
438
|
+
if (entry.messageId === undefined) return;
|
|
439
|
+
const peerId = this.deps.getPeerId();
|
|
440
|
+
const isDm = parseRoomPath(entry.roomPath).kind === "dm";
|
|
441
|
+
const event: DeliveryEvent = {
|
|
442
|
+
type: "delivery_status",
|
|
443
|
+
messageId: entry.messageId,
|
|
444
|
+
agent: memberId,
|
|
445
|
+
delivery,
|
|
446
|
+
...(isDm ? {} : { room: entry.roomPath }),
|
|
447
|
+
};
|
|
448
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
449
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Holds a request for retry, evicting the oldest entries beyond the per-member bound and telling each evicted entry's sender the message was dropped, so a message given up on is never left looking merely pending. */
|
|
453
|
+
private enqueuePendingRoomRequest(
|
|
454
|
+
memberId: string,
|
|
455
|
+
entry: PendingRoomRequest,
|
|
456
|
+
): void {
|
|
457
|
+
const queue = this.pendingRoomRequests.get(memberId) ?? [];
|
|
458
|
+
queue.push(entry);
|
|
459
|
+
while (queue.length > MAX_PENDING_ROOM_REQUESTS_PER_MEMBER) {
|
|
460
|
+
const evicted = queue.shift();
|
|
461
|
+
if (evicted !== undefined) {
|
|
462
|
+
this.reportDelivery(memberId, evicted, { status: "dropped" });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
this.pendingRoomRequests.set(memberId, queue);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Gives up on every queued request older than the age bound, across every member, telling each one's sender it expired. Deliberately a sweep run from sendRoomRequestToMember and flushPendingRoomRequests rather than a timer: the queue only ever grows on a send and only ever drains on a flush, so those are exactly the moments an expired entry starts to matter, and nothing has to keep a timer alive for a member that may never come back.
|
|
470
|
+
*/
|
|
471
|
+
private expirePendingRoomRequests(): void {
|
|
472
|
+
// Nothing queued means nothing to sweep, and nothing to ask this store's identity for: a bridge with no identity yet still sees peers connect, and a sweep must not turn that into a rejected promise inside a fire-and-forget event handler.
|
|
473
|
+
if (this.pendingRoomRequests.size === 0) return;
|
|
474
|
+
const cutoff =
|
|
475
|
+
this.deps.requireIdentity().clock.now() - PENDING_ROOM_REQUEST_TTL_MS;
|
|
476
|
+
for (const [memberId, queue] of this.pendingRoomRequests) {
|
|
477
|
+
const live = queue.filter((entry) => entry.queuedAt > cutoff);
|
|
478
|
+
if (live.length === queue.length) continue;
|
|
479
|
+
for (const entry of queue) {
|
|
480
|
+
if (entry.queuedAt <= cutoff) {
|
|
481
|
+
this.reportDelivery(memberId, entry, { status: "expired" });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (live.length === 0) this.pendingRoomRequests.delete(memberId);
|
|
485
|
+
else this.pendingRoomRequests.set(memberId, live);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** One attempt at a directed room-domain request, with no queueing of its own: the raw wire outcome classified into delivered, undelivered (nobody answered, so a retry may still work) or refused (the member answered with a failure it will repeat). A rejection from the transport is the connection dropping mid-request, per wire-mesh-core's own rejectPendingManageRequests, which is the same "never answered" fact as a timeout. */
|
|
490
|
+
private async attemptRoomRequest(
|
|
491
|
+
memberId: string,
|
|
492
|
+
roomPath: string,
|
|
493
|
+
token: CapabilityToken,
|
|
494
|
+
params: Record<string, unknown>,
|
|
495
|
+
): Promise<RoomRequestOutcome> {
|
|
496
|
+
let outcome: ManageOutcome;
|
|
497
|
+
try {
|
|
498
|
+
outcome = await this.deps
|
|
499
|
+
.requireTransport()
|
|
500
|
+
.sendRoomRequest(
|
|
501
|
+
memberId,
|
|
502
|
+
{ verb: ROOM_MEMBER_CAPABILITY, params },
|
|
503
|
+
{ kind: "room", path: roomPath },
|
|
504
|
+
token,
|
|
505
|
+
);
|
|
506
|
+
} catch {
|
|
507
|
+
return { kind: "undelivered", reason: "connection_lost" };
|
|
508
|
+
}
|
|
509
|
+
return classifyManageOutcome(outcome);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Sends one directed room-domain request (room.send, room.read, or any future room:member-gated verb) to a single member and reports honestly what became of it -- the fan-out's own per-recipient primitive, distinct from sendRoomMessageDirected's deliberate throw-on-failure contract for a caller sending to one specific, known recipient. An undelivered request is held for retry and says so; a refused one is not queued at all, since the member has already answered with a failure it will give again, and its sender needs to know now rather than waiting on a retry that cannot help.
|
|
514
|
+
*/
|
|
515
|
+
async sendRoomRequestToMember(
|
|
516
|
+
memberId: string,
|
|
517
|
+
roomPath: string,
|
|
518
|
+
token: CapabilityToken,
|
|
519
|
+
params: Record<string, unknown>,
|
|
520
|
+
): Promise<RoomRequestOutcome> {
|
|
521
|
+
this.expirePendingRoomRequests();
|
|
522
|
+
const outcome = await this.attemptRoomRequest(
|
|
523
|
+
memberId,
|
|
524
|
+
roomPath,
|
|
525
|
+
token,
|
|
526
|
+
params,
|
|
527
|
+
);
|
|
528
|
+
if (outcome.kind === "undelivered") {
|
|
529
|
+
this.enqueuePendingRoomRequest(memberId, {
|
|
530
|
+
roomPath,
|
|
531
|
+
params,
|
|
532
|
+
queuedAt: this.deps.requireIdentity().clock.now(),
|
|
533
|
+
reason: outcome.reason,
|
|
534
|
+
messageId: messageIdFromParams(params),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return outcome;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Retries every request queued for memberId since it was last reachable, and tells each one's sender what came of the retry: delivered, refused, or still undelivered and held for another attempt. An entry whose room this store no longer holds a token for can never be sent at all, so it is reported as dropped rather than retried forever. Called whenever memberId becomes reachable by any route -- a direct peer connection, or the hub admitting its device into this side's directory -- since a queued request cares only that some path to the member now exists, not which one.
|
|
542
|
+
*/
|
|
543
|
+
async flushPendingRoomRequests(memberId: string): Promise<void> {
|
|
544
|
+
this.expirePendingRoomRequests();
|
|
545
|
+
const queue = this.pendingRoomRequests.get(memberId);
|
|
546
|
+
if (queue === undefined || queue.length === 0) return;
|
|
547
|
+
this.pendingRoomRequests.delete(memberId);
|
|
548
|
+
const { slot } = this.deps.requireIdentity();
|
|
549
|
+
for (const pending of queue) {
|
|
550
|
+
const token = loadRoomTokens(slot)[pending.roomPath];
|
|
551
|
+
if (token === undefined) {
|
|
552
|
+
this.reportDelivery(memberId, pending, { status: "dropped" });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const outcome = await this.attemptRoomRequest(
|
|
556
|
+
memberId,
|
|
557
|
+
pending.roomPath,
|
|
558
|
+
token,
|
|
559
|
+
pending.params,
|
|
560
|
+
);
|
|
561
|
+
if (outcome.kind === "delivered") {
|
|
562
|
+
this.reportDelivery(memberId, pending, { status: "delivered" });
|
|
563
|
+
} else if (outcome.kind === "refused") {
|
|
564
|
+
this.reportDelivery(memberId, pending, {
|
|
565
|
+
status: "refused",
|
|
566
|
+
code: outcome.code,
|
|
567
|
+
});
|
|
568
|
+
} else {
|
|
569
|
+
// Re-queued with its original queuedAt, never a fresh one: a member that keeps briefly reappearing and failing must not be able to keep a message pending past the age bound.
|
|
570
|
+
this.enqueuePendingRoomRequest(memberId, {
|
|
571
|
+
...pending,
|
|
572
|
+
reason: outcome.reason,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Owner-side admission for an incoming room.join request, against either a named room this store owns or a DM path this store is a participant of. Named-room admission always needs a human decision; DM admission needs one only for the party being contacted first -- the reply half of the two-round consent flow (section 6) auto-approves, since a reply on a path this node itself opened is not unsolicited contact, and so does a request presenting a valid dm:send capability this node's own user principal already issued the requester (agent-comms#162's own durable admission list, checked before ever falling through to a fresh human decision).
|
|
580
|
+
*/
|
|
581
|
+
private async handleRoomJoin(
|
|
582
|
+
request: IncomingManageRequest,
|
|
583
|
+
handle: Readonly<ConnectionHandle>,
|
|
584
|
+
): Promise<ManageOutcome> {
|
|
585
|
+
const roomPath = request.scope.path;
|
|
586
|
+
if (roomPath === undefined) {
|
|
587
|
+
return { result: "error", code: "missing_scope_path" };
|
|
588
|
+
}
|
|
589
|
+
const parsed = parseRoomPath(roomPath);
|
|
590
|
+
const peerId = this.deps.getPeerId();
|
|
591
|
+
|
|
592
|
+
if (parsed.kind === "owner-named") {
|
|
593
|
+
if (parsed.owner !== peerId) {
|
|
594
|
+
return { result: "error", code: "not_owner" };
|
|
595
|
+
}
|
|
596
|
+
return this.admitRoomJoin(roomPath, handle, false);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!parsed.participants.includes(peerId)) {
|
|
600
|
+
return { result: "error", code: "not_participant" };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// A presented dm:send grant is this node's own user principal admitting the requester ahead of time (agent-comms#162) -- verify it against that principal specifically (never this bridge slot's own device identity, the way an ordinary room:member grant is) and auto-admit on success. A token that fails this check is refused outright: it identifies the requester as claiming durable admission it does not actually hold, not as an ordinary unsolicited contact that still deserves a human decision.
|
|
604
|
+
if (request.token !== undefined) {
|
|
605
|
+
const { identity, clock, revocation, userIdentity } =
|
|
606
|
+
this.deps.requireIdentity();
|
|
607
|
+
const verdict = await verifyDmSendToken(request.token, {
|
|
608
|
+
identity,
|
|
609
|
+
clock,
|
|
610
|
+
revocation,
|
|
611
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
612
|
+
userPrincipalDeviceId: userIdentity.deviceId,
|
|
613
|
+
});
|
|
614
|
+
if (!verdict.ok) {
|
|
615
|
+
return { result: "error", code: "unauthorized" };
|
|
616
|
+
}
|
|
617
|
+
return this.admitRoomJoin(roomPath, handle, true);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The reciprocal half of section 6's own two-round DM flow: this node's own outbound room.join to the same path (recorded by joinRemoteRoom before this response was even awaited) is the consent that makes the counterpart's own reply not unsolicited contact.
|
|
621
|
+
const autoApprove = this.deps.dmRequestsInitiatedByMe.has(roomPath);
|
|
622
|
+
return this.admitRoomJoin(roomPath, handle, autoApprove);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Tells this device's own agent a room.join is now waiting on its decision (accept with room_accept, refuse with room_reject), so a request held open for a human decision is never sitting unseen in pendingRoomJoins. */
|
|
626
|
+
private announcePendingRoomJoin(roomPath: string, requesterId: string): void {
|
|
627
|
+
const peerId = this.deps.getPeerId();
|
|
628
|
+
const event: DeliveryEvent = {
|
|
629
|
+
type: "room_join_request",
|
|
630
|
+
room: roomPath,
|
|
631
|
+
requesterId,
|
|
632
|
+
};
|
|
633
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
634
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Shared admission continuation for both room.join branches above: waits for a human decision (unless auto-approved), then mints and returns the requester's own independent, parent-less room:member grant. */
|
|
638
|
+
private async admitRoomJoin(
|
|
639
|
+
roomPath: string,
|
|
640
|
+
handle: Readonly<ConnectionHandle>,
|
|
641
|
+
autoApprove: boolean,
|
|
642
|
+
): Promise<ManageOutcome> {
|
|
643
|
+
const decision: RoomJoinDecision = autoApprove
|
|
644
|
+
? { kind: "accept" }
|
|
645
|
+
: await new Promise<RoomJoinDecision>((resolve) => {
|
|
646
|
+
// Expires the request if no decision arrives in time, so an unanswered one neither leaks in pendingRoomJoins nor leaves the requester waiting. unref: an undecided request must not keep the process alive.
|
|
647
|
+
const expiry = setTimeout(() => {
|
|
648
|
+
resolve({ kind: "expired" });
|
|
649
|
+
}, this.deps.joinDecisionTimeoutMs);
|
|
650
|
+
expiry.unref();
|
|
651
|
+
this.pendingRoomJoins.set(`${roomPath}::${handle.id}`, {
|
|
652
|
+
roomPath,
|
|
653
|
+
requesterId: handle.id,
|
|
654
|
+
resolve: (answer) => {
|
|
655
|
+
clearTimeout(expiry);
|
|
656
|
+
resolve(answer);
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
this.announcePendingRoomJoin(roomPath, handle.id);
|
|
660
|
+
});
|
|
661
|
+
if (!autoApprove) this.pendingRoomJoins.delete(`${roomPath}::${handle.id}`);
|
|
662
|
+
if (decision.kind === "expired") {
|
|
663
|
+
return { result: "error", code: "timeout" };
|
|
664
|
+
}
|
|
665
|
+
if (decision.kind === "reject") {
|
|
666
|
+
return {
|
|
667
|
+
result: "error",
|
|
668
|
+
code: "denied",
|
|
669
|
+
...(decision.reason !== undefined ? { message: decision.reason } : {}),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// The granted token itself belongs to the requester's own node, which persists it itself once it receives this response -- but this identity slot must also remember the token-id it just issued (saveIssuedRoomGrant below), since revoking a specific member's grant later (kickFromRoom) has no other way to name which token-id to revoke: a token-id is never presented back on the wire, so a room owner's own memory of having minted it is the only record.
|
|
674
|
+
const { identity, clock, slot } = this.deps.requireIdentity();
|
|
675
|
+
const tokenId = randomId();
|
|
676
|
+
const verdict = await mintCapabilityToken({
|
|
677
|
+
identity,
|
|
678
|
+
clock,
|
|
679
|
+
tokenId,
|
|
680
|
+
bearer: deviceIdFromHex(handle.id),
|
|
681
|
+
capability: ROOM_MEMBER_CAPABILITY,
|
|
682
|
+
scope: { kind: "room", path: roomPath },
|
|
683
|
+
expires: clock.now() + ROOM_TOKEN_LIFETIME_MS,
|
|
684
|
+
delegationsRemaining: resolveDelegationsRemaining(
|
|
685
|
+
ROOM_MEMBER_DELEGATION_POLICY,
|
|
686
|
+
ROOM_MEMBER_CAPABILITY,
|
|
687
|
+
handle.id,
|
|
688
|
+
0,
|
|
689
|
+
),
|
|
690
|
+
});
|
|
691
|
+
if (!verdict.ok) {
|
|
692
|
+
return { result: "error", code: "mint_failed" };
|
|
693
|
+
}
|
|
694
|
+
saveIssuedRoomGrant(slot, roomPath, handle.id, tokenId);
|
|
695
|
+
|
|
696
|
+
const room = this.deps.rooms.get(roomPath);
|
|
697
|
+
if (room !== undefined) {
|
|
698
|
+
this.deps.deliveryEngine.bump(room);
|
|
699
|
+
this.deps.deliveryEngine.recordMemberOp(
|
|
700
|
+
room,
|
|
701
|
+
"member",
|
|
702
|
+
"join",
|
|
703
|
+
handle.id,
|
|
704
|
+
);
|
|
705
|
+
this.deps.deliveryEngine.refreshMembership(room);
|
|
706
|
+
this.deps.rooms.set(roomPath, room);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const members = (room?.members ?? [this.deps.getPeerId(), handle.id]).map(
|
|
710
|
+
(memberId) => ({ device: deviceIdFromHex(memberId) }),
|
|
711
|
+
);
|
|
712
|
+
return {
|
|
713
|
+
result: "ok",
|
|
714
|
+
"granted-token": verdict.token,
|
|
715
|
+
members,
|
|
716
|
+
...roomStateExtension(room),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Receiving side of a real, wire-level room.invite (P3.8): unlike every other room verb, the request itself is deliberately ungated (the sender already IS the room's own owner, with no need to prove capability to invite) -- the security instead lives entirely in the embedded params.token, which must genuinely name this store's own identity as bearer and root at the room path's own claimed owner. Persists the verified token via saveRoomToken (mirroring joinRemoteRoom's own persistence) and fires a local room_invite delivery event carrying the room's real name/description (room-state) and the inviter's real name/cwd (inviter-agent) when the sender includes them, falling back to the bare device-id and room path otherwise.
|
|
722
|
+
*/
|
|
723
|
+
private async handleRoomInvite(
|
|
724
|
+
request: IncomingManageRequest,
|
|
725
|
+
): Promise<ManageOutcome> {
|
|
726
|
+
const roomPath = request.scope.path;
|
|
727
|
+
if (roomPath === undefined) {
|
|
728
|
+
return { result: "error", code: "missing_scope_path" };
|
|
729
|
+
}
|
|
730
|
+
const parsedParams = roomInviteSchema.safeParse(request.command.params);
|
|
731
|
+
if (!parsedParams.success) {
|
|
732
|
+
return { result: "error", code: "malformed_params" };
|
|
733
|
+
}
|
|
734
|
+
const { token } = parsedParams.data;
|
|
735
|
+
const peerId = this.deps.getPeerId();
|
|
736
|
+
|
|
737
|
+
const { identity, clock, slot, revocation } = this.deps.requireIdentity();
|
|
738
|
+
const verdict = await verifyRoomToken(token, {
|
|
739
|
+
identity,
|
|
740
|
+
clock,
|
|
741
|
+
revocation,
|
|
742
|
+
expectedBearer: deviceIdFromHex(peerId),
|
|
743
|
+
roomPath,
|
|
744
|
+
});
|
|
745
|
+
if (!verdict.ok) {
|
|
746
|
+
return { result: "error", code: "unauthorized" };
|
|
747
|
+
}
|
|
748
|
+
saveRoomToken(slot, roomPath, token);
|
|
749
|
+
|
|
750
|
+
const parsed = parseRoomPath(roomPath);
|
|
751
|
+
const inviterId = parsed.kind === "owner-named" ? parsed.owner : peerId;
|
|
752
|
+
const roomState = parseRoomStateExtension(parsedParams.data["room-state"]);
|
|
753
|
+
const inviterAgent = parseInviterAgentExtension(
|
|
754
|
+
parsedParams.data["inviter-agent"],
|
|
755
|
+
);
|
|
756
|
+
const event: DeliveryEvent = {
|
|
757
|
+
type: "room_invite",
|
|
758
|
+
room: roomPath,
|
|
759
|
+
roomDescription: roomState?.description ?? "",
|
|
760
|
+
from: inviterId,
|
|
761
|
+
fromName: inviterAgent?.name ?? inviterId,
|
|
762
|
+
fromCwd: inviterAgent?.cwd ?? "",
|
|
763
|
+
};
|
|
764
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
765
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
766
|
+
|
|
767
|
+
return { result: "ok" };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Receiving side of a real, wire-level room.leave (P3.8), covering both an actual member leaving and a decline of a never-joined invite -- the same wire request either way, since both are "give up a room:member grant I hold," per leaveRemoteRoom's own reasoning. Distinguishes the two purely from this store's own membership/invited lists (never from anything the sender claims), revokes the sender's grant for real via revokeMemberGrant, and notifies accordingly: member_left broadcast to the room's other members for a real leave, a local invite_declined event (carrying the sender's own optional reason extension) for a decline -- there is no third party to notify for a decline, since nobody else ever knew about an invite that was never accepted.
|
|
772
|
+
*/
|
|
773
|
+
private async handleRoomLeave(
|
|
774
|
+
request: IncomingManageRequest,
|
|
775
|
+
handle: Readonly<ConnectionHandle>,
|
|
776
|
+
): Promise<ManageOutcome> {
|
|
777
|
+
const roomPath = request.scope.path;
|
|
778
|
+
if (roomPath === undefined) {
|
|
779
|
+
return { result: "error", code: "missing_scope_path" };
|
|
780
|
+
}
|
|
781
|
+
if (request.token === undefined) {
|
|
782
|
+
return { result: "error", code: "unauthorized" };
|
|
783
|
+
}
|
|
784
|
+
const { identity, clock, revocation } = this.deps.requireIdentity();
|
|
785
|
+
const verdict = await verifyRoomToken(request.token, {
|
|
786
|
+
identity,
|
|
787
|
+
clock,
|
|
788
|
+
revocation,
|
|
789
|
+
expectedBearer: deviceIdFromHex(handle.id),
|
|
790
|
+
roomPath,
|
|
791
|
+
});
|
|
792
|
+
if (!verdict.ok) {
|
|
793
|
+
return { result: "error", code: "unauthorized" };
|
|
794
|
+
}
|
|
795
|
+
const parsedParams = roomLeaveSchema.safeParse(request.command.params);
|
|
796
|
+
if (!parsedParams.success) {
|
|
797
|
+
return { result: "error", code: "malformed_params" };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const room = this.deps.rooms.get(roomPath);
|
|
801
|
+
if (room === undefined) {
|
|
802
|
+
return { result: "error", code: "room_not_found" };
|
|
803
|
+
}
|
|
804
|
+
const wasMember = room.members.includes(handle.id);
|
|
805
|
+
const wasInvited = room.invited.includes(handle.id);
|
|
806
|
+
if (!wasMember && !wasInvited) {
|
|
807
|
+
return { result: "ok" };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
await this.deps.revokeMemberGrant(roomPath, handle.id);
|
|
811
|
+
|
|
812
|
+
this.deps.deliveryEngine.bump(room);
|
|
813
|
+
if (wasMember)
|
|
814
|
+
this.deps.deliveryEngine.recordMemberOp(
|
|
815
|
+
room,
|
|
816
|
+
"member",
|
|
817
|
+
"leave",
|
|
818
|
+
handle.id,
|
|
819
|
+
);
|
|
820
|
+
if (wasInvited)
|
|
821
|
+
this.deps.deliveryEngine.recordMemberOp(
|
|
822
|
+
room,
|
|
823
|
+
"invited",
|
|
824
|
+
"leave",
|
|
825
|
+
handle.id,
|
|
826
|
+
);
|
|
827
|
+
this.deps.deliveryEngine.refreshMembership(room);
|
|
828
|
+
this.deps.rooms.set(roomPath, room);
|
|
829
|
+
await this.deps.deliveryEngine.broadcastPatch({
|
|
830
|
+
type: "room_upsert",
|
|
831
|
+
room,
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const peerId = this.deps.getPeerId();
|
|
835
|
+
if (wasMember) {
|
|
836
|
+
await this.deps.deliveryEngine.deliverToRoom(
|
|
837
|
+
roomPath,
|
|
838
|
+
{ type: "member_left", room: roomPath, agent: handle.id },
|
|
839
|
+
handle.id,
|
|
840
|
+
);
|
|
841
|
+
} else {
|
|
842
|
+
const reasonValue = parsedParams.data.reason;
|
|
843
|
+
const decliner = this.deps.agents.get(handle.id);
|
|
844
|
+
const event: DeliveryEvent = {
|
|
845
|
+
type: "invite_declined",
|
|
846
|
+
room: roomPath,
|
|
847
|
+
agent: handle.id,
|
|
848
|
+
agentName: decliner?.name ?? handle.id,
|
|
849
|
+
reason: typeof reasonValue === "string" ? reasonValue : "",
|
|
850
|
+
};
|
|
851
|
+
this.deps.deliveryEngine.queueDelivery(peerId, event);
|
|
852
|
+
this.deps.deliveryEngine.fireLocalDelivery(peerId, event);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return { result: "ok" };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** Every room.join request currently held open awaiting this store's own accept/reject decision. */
|
|
859
|
+
listPendingRoomJoins(): { roomPath: string; requesterId: string }[] {
|
|
860
|
+
return [...this.pendingRoomJoins.values()].map(
|
|
861
|
+
({ roomPath, requesterId }) => ({ roomPath, requesterId }),
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** Approves a pending room.join request, resuming handleRoomJoin's own suspended mint-and-respond continuation. */
|
|
866
|
+
acceptRoomJoin(roomPath: string, requesterId: string): void {
|
|
867
|
+
const key = `${roomPath}::${requesterId}`;
|
|
868
|
+
const pending = this.pendingRoomJoins.get(key);
|
|
869
|
+
if (pending === undefined) {
|
|
870
|
+
throw new CommsError(
|
|
871
|
+
`No pending room.join for ${requesterId} on ${roomPath}`,
|
|
872
|
+
"NOT_PENDING",
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
pending.resolve({ kind: "accept" });
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Denies a pending room.join request, optionally with a reason surfaced to the requester in the resulting manage-error's own message field. */
|
|
879
|
+
rejectRoomJoin(roomPath: string, requesterId: string, reason?: string): void {
|
|
880
|
+
const key = `${roomPath}::${requesterId}`;
|
|
881
|
+
const pending = this.pendingRoomJoins.get(key);
|
|
882
|
+
if (pending === undefined) {
|
|
883
|
+
throw new CommsError(
|
|
884
|
+
`No pending room.join for ${requesterId} on ${roomPath}`,
|
|
885
|
+
"NOT_PENDING",
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
pending.resolve({
|
|
889
|
+
kind: "reject",
|
|
890
|
+
...(reason !== undefined ? { reason } : {}),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|