@kubun/plugin-p2p 0.10.0 → 0.11.0

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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,164 @@
1
+ import { HLC } from '@kubun/hlc';
2
+ import { getKubunLogger } from '@kubun/logger';
3
+ import { getP2PStore } from '@kubun/store-p2p';
4
+ import { exportGroupInfo as exportEnkakuGroupInfo, readMessageEpoch } from '@kumiai/mls';
5
+ import { fromB64 } from '@sozai/codec';
6
+ import { anchorImmutabilityPolicy, readGroupAnchor } from './group-anchor.js';
7
+ import { CommitOutOfOrderError, DecryptError } from './mls-receive-errors.js';
8
+ /**
9
+ * Adapts a `GroupHandleRegistry` group into the `@kumiai/rpc`
10
+ * {@link GroupMLS} lifecycle port: apply received Commits to advance the epoch,
11
+ * export GroupInfo for a recovery responder, and expose the epoch-independent
12
+ * recovery seed. group-rpc owns the transport + orchestration (subscribe the
13
+ * non-rotating handshake topic, run these methods, resync the app lane when the
14
+ * epoch advances); this adapter owns the MLS state, storage, and atomicity.
15
+ */ export function createGroupMLS(params) {
16
+ const { registry, stores, hlc, groupID } = params;
17
+ const logger = params.logger ?? getKubunLogger('plugin-p2p:group-mls');
18
+ return {
19
+ /**
20
+ * Apply a received MLS handshake Commit: advance the local ratchet and write
21
+ * the membership delta derived by diffing the roster before and after.
22
+ *
23
+ * Runs INSIDE a single store transaction so the ratchet advance (persisted
24
+ * by the registry) and the resulting roster delta writes commit or roll back
25
+ * together. A roster-write failure after the ratchet persisted would
26
+ * otherwise leave a removed member's writes still applying. The registry
27
+ * holds the per-group lock across the whole transaction, so no concurrent
28
+ * same-group op can interleave.
29
+ *
30
+ * Epoch handling (advisory pre-check on the cleartext header):
31
+ * - `messageEpoch < handle.epoch` → already applied; no-op (`advanced:false`).
32
+ * - `messageEpoch > handle.epoch` → gap in the stream; throw
33
+ * {@link CommitOutOfOrderError} so the hub redelivers after the gap fills.
34
+ * - otherwise process; `processMessage` is the authoritative crypto check.
35
+ */ async processCommit (commit, context) {
36
+ // The transport sender is auxiliary (logging), NOT the MLS committer and
37
+ // never an authorization boundary — the Commit authenticates its committer
38
+ // internally.
39
+ logger.debug('processCommit', {
40
+ groupID,
41
+ senderDID: context.senderDID
42
+ });
43
+ return await registry.withHandleReplacingInTransaction(groupID, stores, async (handle, tx)=>{
44
+ // The Commit's cleartext wire-header epoch is the sender's pre-commit
45
+ // epoch — the epoch a receiver must be at to process it.
46
+ const messageEpoch = readMessageEpoch(commit);
47
+ const handleEpoch = handle.epoch;
48
+ if (messageEpoch != null && messageEpoch < handleEpoch) {
49
+ return {
50
+ result: {
51
+ advanced: false
52
+ },
53
+ updated: handle
54
+ };
55
+ }
56
+ if (messageEpoch != null && messageEpoch > handleEpoch) {
57
+ throw new CommitOutOfOrderError(messageEpoch, handleEpoch);
58
+ }
59
+ const before = handle.listMembers();
60
+ try {
61
+ await handle.processMessage(commit, {
62
+ commitPolicy: anchorImmutabilityPolicy
63
+ });
64
+ } catch (error) {
65
+ // A Commit that fails the authenticated MLS check at the matching
66
+ // epoch is corrupt/forged — unrecoverable, so ack-and-skip rather
67
+ // than redeliver. (Future-epoch ordering is handled above via
68
+ // CommitOutOfOrderError, which DOES redeliver.)
69
+ throw new DecryptError('Failed to process MLS commit', error);
70
+ }
71
+ const after = handle.listMembers();
72
+ const beforeIDs = new Set(before.map((member)=>member.id));
73
+ const afterIDs = new Set(after.map((member)=>member.id));
74
+ const removed = before.filter((member)=>!afterIDs.has(member.id)).map((member)=>member.id);
75
+ const added = after.filter((member)=>!beforeIDs.has(member.id)).map((member)=>member.id);
76
+ // All reads + writes use the transactional p2p store so they share the
77
+ // tx's single connection (a bare store read inside the tx would
78
+ // deadlock single-connection SQLite against the tx's write lock).
79
+ const localHLC = HLC.serialize(hlc.now());
80
+ const p2pStore = await getP2PStore(tx);
81
+ for (const memberDID of removed){
82
+ await p2pStore.markMemberRemoved(groupID, memberDID, localHLC);
83
+ }
84
+ for (const memberDID of added){
85
+ // Role defaults to 'member': the MLS ratchet (listMembers) carries no
86
+ // kubun role, so a receiver can't recover the committer's
87
+ // admin/member distinction from the Commit. The write-membership gate
88
+ // keys only on `removed_at_hlc`, never role, so this divergence is not
89
+ // a security issue; a role-bearing path corrects it later.
90
+ await p2pStore.addGroupMember({
91
+ group_id: groupID,
92
+ member_did: memberDID,
93
+ role: 'member',
94
+ hlc: localHLC
95
+ });
96
+ }
97
+ return {
98
+ result: {
99
+ advanced: handle.epoch > handleEpoch
100
+ },
101
+ updated: handle
102
+ };
103
+ }).catch((error)=>{
104
+ // A corrupt/forged Commit threw inside the transaction, so it rolled back
105
+ // (DB untouched). Ack-and-skip it: report no epoch advance so group-rpc
106
+ // acks and the hub stops redelivering — group-rpc re-fetches any Commit
107
+ // whose `processCommit` throws, so letting a corrupt frame escape here
108
+ // would poison-redeliver forever. CommitOutOfOrderError (future-epoch gap)
109
+ // and transient store faults are re-thrown so they DO redeliver and retry.
110
+ if (error instanceof DecryptError) {
111
+ logger.warn('dropping corrupt MLS commit (ack-and-skip)', {
112
+ groupID,
113
+ error
114
+ });
115
+ return {
116
+ advanced: false
117
+ };
118
+ }
119
+ throw error;
120
+ });
121
+ },
122
+ /**
123
+ * Export a fresh `MLSMessage(GroupInfo)` from the current handle for a
124
+ * recovery responder to send a stranded peer. Read-only: takes the registry
125
+ * lock but does not advance or persist state.
126
+ */ async exportGroupInfo () {
127
+ return await registry.readHandle(groupID, async (handle)=>{
128
+ const { groupInfo } = await exportEnkakuGroupInfo({
129
+ group: handle
130
+ });
131
+ return groupInfo;
132
+ });
133
+ },
134
+ /**
135
+ * Re-sync from a recovery reply (a peer stranded past the handshake
136
+ * backlog).
137
+ *
138
+ * DESIGN GAP — intentionally unimplemented. The only MLS path that catches a
139
+ * stranded member up from GroupInfo is `joinGroupExternal({ resync: true })`,
140
+ * which produces an external-commit `commitMessage` that EVERY other member
141
+ * MUST process to admit the rejoiner's replaced leaf and advance to the new
142
+ * epoch. The {@link GroupMLS.applyRecovery} contract returns only
143
+ * `{ advanced }` and group-rpc's `recover()` never fans out anything on the
144
+ * handshake topic — so this adapter has no channel to deliver that Commit.
145
+ * Applying it locally and dropping the Commit would fork the group (this peer
146
+ * ahead on an epoch nobody else has). Failing loudly here is strictly safer
147
+ * than a silent fork until the transport/orchestration question is resolved.
148
+ */ async applyRecovery (_groupInfo) {
149
+ throw new Error('GroupMLS.applyRecovery is not wired: an MLS resync produces an external Commit that ' + 'other members must process, but the recovery port has no channel to fan it out. ' + 'Resolve the recovery transport design before enabling deep recovery.');
150
+ },
151
+ /**
152
+ * The epoch-independent secret for the non-rotating handshake/recovery topic,
153
+ * sourced from the genesis anchor (immutable, authenticated by the GroupInfo
154
+ * signature → every member on every epoch reads the same value). Throws when
155
+ * the anchor is absent: a group with no anchor cannot participate in recovery.
156
+ */ async exportRecoverySecret () {
157
+ const anchor = await registry.readHandle(groupID, (handle)=>readGroupAnchor(handle));
158
+ if (anchor == null) {
159
+ throw new Error(`group ${groupID} has no genesis anchor; cannot derive recovery secret`);
160
+ }
161
+ return fromB64(anchor.recoverySecret);
162
+ }
163
+ };
164
+ }
@@ -0,0 +1,108 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
+ import type { HLC } from '@kubun/hlc';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { GraphStoreAPI } from '@kubun/store-graph';
6
+ import type { HubLike } from '@kumiai/hub-tunnel';
7
+ import { type GroupPeer } from '@kumiai/rpc';
8
+ import type { OnServerDIDObserved } from '../hub/did-observing-transport.js';
9
+ import type { CreateHubClient } from '../hub/http-client.js';
10
+ import type { RejoinResult, StoreUnreadableMode } from '../types.js';
11
+ import type { GroupBroadcastMessage } from './broadcast.js';
12
+ import type { P2PEventEmitter } from './events.js';
13
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
14
+ import { type GroupProtocols } from './group-protocols.js';
15
+ export type GroupPeerManagerParams = {
16
+ /**
17
+ * Spawns a fresh `Client<HubProtocol>` for a hub URL. The manager wraps each
18
+ * URL's client in one reconnecting {@link createHubLike} (a single device
19
+ * receive drain), lazily on first peer creation for that hub.
20
+ */
21
+ createHubClient: CreateHubClient;
22
+ /** Single canonical access point for the device's MLS `GroupHandle` instances. */
23
+ registry: GroupHandleRegistry;
24
+ /**
25
+ * Store coordinator. Resolves the device's p2p store for the apply path,
26
+ * group↔hub binding reads, and server-DID pinning; also passed to
27
+ * `createGroupMLS` for the receive-side commit transaction.
28
+ */
29
+ stores: StoreProvider;
30
+ graphStore: GraphStoreAPI;
31
+ /**
32
+ * Engine graph internals — threaded into the apply path so a `sync/mutationApply`
33
+ * frame routes peer-authored mutations through `applyVerifiedMutation` with
34
+ * `origin: 'peer'`.
35
+ */
36
+ graph: GraphInternals;
37
+ /** Device-wide monotonic clock shared with the engine and MLS receive path. */
38
+ hlc: HLC;
39
+ /** Authenticated DID of this device (normalized). */
40
+ localDID: string;
41
+ emitter: P2PEventEmitter;
42
+ /** Fresh correlation ids for group-rpc; forwarded to each peer when set. */
43
+ getRandomID?: () => string;
44
+ logger?: Logger;
45
+ /** Receive-side storage mode. Forwarded to the apply path. Defaults to `'persist'`. */
46
+ storeUnreadable?: StoreUnreadableMode;
47
+ /** Server default access level — required when `storeUnreadable === 'drop'`. */
48
+ defaultAccessLevel?: DefaultAccessLevel;
49
+ /**
50
+ * Fired once per freshly-spawned hub client when its first signed response
51
+ * arrives, with the hub URL bound. Lets callers route capture into
52
+ * `captureServerDID` for TOFU pinning; omitting it skips capture.
53
+ */
54
+ onServerDIDObserved?: OnServerDIDObserved;
55
+ };
56
+ /**
57
+ * Coordinates the per-(group, hub) `GroupPeer`s for one device — full multi-hub.
58
+ *
59
+ * The manager owns one reconnecting `HubLike` per hub URL (created lazily on
60
+ * first use, shared across every group bound to that hub via the multi-subscriber
61
+ * adapter) and one `GroupPeer` per (group, hub) pair. A peer exists iff the group
62
+ * is joined AND bound to that hub. Lifecycle mirrors the old `HubRelayManager`,
63
+ * but per (group, hub) instead of single-hub: the wiring phase drives `addGroup`
64
+ * / `removeGroup` / `addBinding` / `removeBinding` from the emitter and holds the
65
+ * unsubscribes. The manager does not subscribe to the emitter itself.
66
+ */
67
+ export type GroupPeerManager = {
68
+ /** Bring up peers for each already-bound hub of each joined group. */
69
+ start: (groupIDs: Array<string>) => Promise<void>;
70
+ /** Mark a group joined and create a peer for each of its hub bindings. */
71
+ addGroup: (groupID: string) => Promise<void>;
72
+ /** Unmark a group and dispose all its peers (GC-ing now-idle hubs). */
73
+ removeGroup: (groupID: string) => Promise<void>;
74
+ /** Create the (group, hub) peer if the group is joined and none exists yet. */
75
+ addBinding: (groupID: string, hubURL: string) => Promise<void>;
76
+ /** Dispose the (group, hub) peer if present, GC-ing the hub if now idle. */
77
+ removeBinding: (groupID: string, hubURL: string) => Promise<void>;
78
+ /** Fan a broadcast message out across every hub-peer of a group. */
79
+ broadcast: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
80
+ /** Fan a locally-produced MLS Commit out across every hub-peer of a group. */
81
+ sendCommit: (groupID: string, commit: Uint8Array) => Promise<void>;
82
+ /** Deep recovery is deferred — always reports `recovery-failed` without throwing. */
83
+ rejoinGroup: (groupID: string) => Promise<RejoinResult>;
84
+ /** The peer for one (group, hub), or `undefined` (used by tests). */
85
+ peer: (groupID: string, hubURL: string) => GroupPeer<GroupProtocols> | undefined;
86
+ /**
87
+ * The shared reconnecting `HubLike` for a hub URL, creating it on first use.
88
+ * Lets a caller obtain the device-wide drain to pass into a directed sync
89
+ * transport (provider/listener) sharing the same hub connection.
90
+ */
91
+ getHubLike: (hubURL: string) => HubLike & {
92
+ dispose: () => Promise<void>;
93
+ };
94
+ /** Dispose every peer and hub, then clear all maps. */
95
+ stop: () => Promise<void>;
96
+ /** Alias for {@link stop}. */
97
+ dispose: () => Promise<void>;
98
+ };
99
+ /**
100
+ * Send-side union → procedure mapping — the inverse of `buildGroupHandlers`.
101
+ *
102
+ * Translates a {@link GroupBroadcastMessage} into the matching group-rpc
103
+ * `dispatch` on a peer's control/sync protocol surface. Variants with no carried
104
+ * procedure (catalog control) and the legacy catch-up variants (now a `gather`,
105
+ * not a dispatch) are logged and no-op'd, matching the receive side.
106
+ */
107
+ export declare function dispatchMessage(peer: GroupPeer<GroupProtocols>, message: GroupBroadcastMessage, logger?: Logger): Promise<void>;
108
+ export declare function createGroupPeerManager(params: GroupPeerManagerParams): GroupPeerManager;
@@ -0,0 +1,344 @@
1
+ import { getDelegationStore } from '@kubun/store-delegation';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { createGroupPeer } from '@kumiai/rpc';
4
+ import { createHubLike } from '../hub/hub-like.js';
5
+ import { readGroupAnchor } from './group-anchor.js';
6
+ import { createGroupCrypto } from './group-crypto.js';
7
+ import { buildGroupHandlers } from './group-handlers.js';
8
+ import { createGroupMLS } from './group-mls.js';
9
+ import { groupProtocols } from './group-protocols.js';
10
+ const peerKey = (groupID, hubURL)=>`${groupID}|${hubURL}`;
11
+ /**
12
+ * Send-side union → procedure mapping — the inverse of `buildGroupHandlers`.
13
+ *
14
+ * Translates a {@link GroupBroadcastMessage} into the matching group-rpc
15
+ * `dispatch` on a peer's control/sync protocol surface. Variants with no carried
16
+ * procedure (catalog control) and the legacy catch-up variants (now a `gather`,
17
+ * not a dispatch) are logged and no-op'd, matching the receive side.
18
+ */ export async function dispatchMessage(peer, message, logger) {
19
+ switch(message.type){
20
+ case 'ledger:entry':
21
+ await peer.protocol('control').dispatch('control/ledgerEntry', {
22
+ token: message.token
23
+ });
24
+ return;
25
+ case 'delegation:share':
26
+ await peer.protocol('control').dispatch('control/delegationShare', {
27
+ token: message.token,
28
+ hlc: message.hlc
29
+ });
30
+ return;
31
+ case 'delegation:revoke':
32
+ await peer.protocol('control').dispatch('control/delegationRevoke', {
33
+ token: message.token,
34
+ hlc: message.hlc
35
+ });
36
+ return;
37
+ case 'group:leaveRequest':
38
+ await peer.protocol('control').dispatch('control/groupLeaveRequest', {
39
+ memberDID: message.memberDID,
40
+ hlc: message.hlc
41
+ });
42
+ return;
43
+ case 'mutation:apply':
44
+ await peer.protocol('sync').dispatch('sync/mutationApply', {
45
+ entries: message.entries
46
+ });
47
+ return;
48
+ case 'catalog:create':
49
+ case 'catalog:update':
50
+ case 'catalog:delete':
51
+ logger?.warn('dispatchMessage: no broadcast path for catalog control', {
52
+ type: message.type
53
+ });
54
+ return;
55
+ case 'ledger-catchup:request':
56
+ case 'ledger-catchup:reply':
57
+ logger?.warn('dispatchMessage: unsupported via broadcast (catch-up uses gather)', {
58
+ type: message.type
59
+ });
60
+ return;
61
+ }
62
+ }
63
+ export function createGroupPeerManager(params) {
64
+ // One reconnecting HubLike per hub URL (shared across that hub's groups).
65
+ const hubLikes = new Map();
66
+ // One peer per (group, hub), keyed `${groupID}|${hubURL}`.
67
+ const peers = new Map();
68
+ // Groups the device currently belongs to.
69
+ const joined = new Set();
70
+ // Active bindings: groupID → set of hub URLs that have a live peer.
71
+ const bindings = new Map();
72
+ // Reverse index for GC: hub URL → set of groupIDs with a peer on it.
73
+ const hubGroups = new Map();
74
+ const { logger } = params;
75
+ const getHubLike = (hubURL)=>{
76
+ const existing = hubLikes.get(hubURL);
77
+ if (existing != null) {
78
+ return existing;
79
+ }
80
+ const hub = createHubLike({
81
+ // Resolved per (re)connect: the pinned server DID is read fresh from the
82
+ // local hub row so a row update re-arms TOFU on the next reconnect.
83
+ createClient: async ()=>{
84
+ const store = await getP2PStore(params.stores);
85
+ const row = await store.getHubByURL(hubURL);
86
+ return params.createHubClient(hubURL, {
87
+ ...params.onServerDIDObserved != null ? {
88
+ onServerDID: (serverDID)=>params.onServerDIDObserved?.({
89
+ hubURL,
90
+ serverDID
91
+ })
92
+ } : {},
93
+ ...row?.server_did != null ? {
94
+ expectedServerDID: row.server_did
95
+ } : {}
96
+ });
97
+ },
98
+ localDID: params.localDID,
99
+ ...logger != null ? {
100
+ logger
101
+ } : {}
102
+ });
103
+ hubLikes.set(hubURL, hub);
104
+ return hub;
105
+ };
106
+ const buildProcessParams = async ()=>{
107
+ const [p2pStore, delegationStore] = await Promise.all([
108
+ getP2PStore(params.stores),
109
+ getDelegationStore(params.stores)
110
+ ]);
111
+ return {
112
+ p2pStore,
113
+ delegationStore,
114
+ graphStore: params.graphStore,
115
+ graph: params.graph,
116
+ selfDID: params.localDID,
117
+ storeUnreadable: params.storeUnreadable,
118
+ defaultAccessLevel: params.defaultAccessLevel,
119
+ // Forwarding is dropped in this cutover — the receive path applies locally
120
+ // only and never re-broadcasts to other groups.
121
+ forwarding: undefined,
122
+ emitter: params.emitter,
123
+ hlc: params.hlc,
124
+ // Reads the genesis anchor baked into the MLS GroupContext so role
125
+ // projection on a received ledger entry runs against the authenticated
126
+ // epoch-0 creator. Null for a group with no anchor (e.g. external).
127
+ getGroupAnchor: (groupID)=>params.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle)),
128
+ logger
129
+ };
130
+ };
131
+ // Create a peer for one (group, hub) pair, idempotent on the key. The peer is
132
+ // eager (createGroupPeer restores the anchored MLS handle + opens the receive
133
+ // drain in its constructor), so it must only be called once the group's MLS
134
+ // state is seeded (i.e. after `groupJoined`).
135
+ const createPeer = async (groupID, hubURL)=>{
136
+ const key = peerKey(groupID, hubURL);
137
+ if (peers.has(key)) {
138
+ return;
139
+ }
140
+ const processParams = await buildProcessParams();
141
+ // Re-check after the await: a concurrent call may have installed this peer,
142
+ // or `removeGroup` may have left the group, while the store resolved. The
143
+ // emitter handlers (groupJoined/groupLeft/hubBound/hubUnbound) are not
144
+ // serialized, so installing a live peer for a no-longer-joined group here
145
+ // would leak a drain + apply mutations for a left group.
146
+ if (peers.has(key) || !joined.has(groupID)) {
147
+ return;
148
+ }
149
+ const peer = createGroupPeer({
150
+ hub: getHubLike(hubURL),
151
+ crypto: createGroupCrypto(params.registry, groupID),
152
+ mls: createGroupMLS({
153
+ registry: params.registry,
154
+ stores: params.stores,
155
+ hlc: params.hlc,
156
+ groupID,
157
+ ...logger != null ? {
158
+ logger
159
+ } : {}
160
+ }),
161
+ localDID: params.localDID,
162
+ protocols: groupProtocols,
163
+ handlers: buildGroupHandlers(processParams, groupID),
164
+ ...params.getRandomID != null ? {
165
+ getRandomID: params.getRandomID
166
+ } : {}
167
+ });
168
+ peers.set(key, peer);
169
+ let groupSet = bindings.get(groupID);
170
+ if (groupSet == null) {
171
+ groupSet = new Set();
172
+ bindings.set(groupID, groupSet);
173
+ }
174
+ groupSet.add(hubURL);
175
+ let hubSet = hubGroups.get(hubURL);
176
+ if (hubSet == null) {
177
+ hubSet = new Set();
178
+ hubGroups.set(hubURL, hubSet);
179
+ }
180
+ hubSet.add(groupID);
181
+ };
182
+ // Dispose one (group, hub) peer, updating both indexes, and GC the hub's
183
+ // HubLike once its last peer is gone.
184
+ const disposePeer = async (groupID, hubURL)=>{
185
+ const key = peerKey(groupID, hubURL);
186
+ const peer = peers.get(key);
187
+ if (peer == null) {
188
+ return;
189
+ }
190
+ peers.delete(key);
191
+ const groupSet = bindings.get(groupID);
192
+ if (groupSet != null) {
193
+ groupSet.delete(hubURL);
194
+ if (groupSet.size === 0) {
195
+ bindings.delete(groupID);
196
+ }
197
+ }
198
+ const hubSet = hubGroups.get(hubURL);
199
+ if (hubSet != null) {
200
+ hubSet.delete(groupID);
201
+ if (hubSet.size === 0) {
202
+ hubGroups.delete(hubURL);
203
+ }
204
+ }
205
+ await peer.dispose();
206
+ // GC the HubLike when no peer references it anymore.
207
+ if (!hubGroups.has(hubURL)) {
208
+ const hub = hubLikes.get(hubURL);
209
+ if (hub != null) {
210
+ hubLikes.delete(hubURL);
211
+ await hub.dispose();
212
+ }
213
+ }
214
+ };
215
+ // Fan an operation across every live peer of a group. A single hub's failure
216
+ // is logged but does not sink the others; this rejects only when EVERY hub
217
+ // fails (and there was at least one peer), so an ordering-sensitive caller
218
+ // (`broadcastNow` before a local teardown) still observes a total failure
219
+ // while a partial multi-hub failure resolves and the message reaches the
220
+ // reachable hubs.
221
+ const fanOutToPeers = async (groupID, op, label)=>{
222
+ const groupSet = bindings.get(groupID);
223
+ if (groupSet == null || groupSet.size === 0) {
224
+ return;
225
+ }
226
+ const tasks = [];
227
+ for (const hubURL of groupSet){
228
+ const peer = peers.get(peerKey(groupID, hubURL));
229
+ if (peer != null) {
230
+ tasks.push(op(peer));
231
+ }
232
+ }
233
+ if (tasks.length === 0) {
234
+ return;
235
+ }
236
+ const results = await Promise.allSettled(tasks);
237
+ const rejected = results.filter((result)=>result.status === 'rejected');
238
+ for (const result of rejected){
239
+ logger?.warn(`${label} failed on a hub`, {
240
+ groupID,
241
+ error: result.reason
242
+ });
243
+ }
244
+ if (rejected.length === results.length) {
245
+ throw rejected[0]?.reason;
246
+ }
247
+ };
248
+ const addGroup = async (groupID)=>{
249
+ joined.add(groupID);
250
+ const store = await getP2PStore(params.stores);
251
+ const hubs = await store.listHubsByGroupID(groupID);
252
+ if (hubs.length === 0) {
253
+ logger?.warn('group has no hub bindings, no peer created', {
254
+ groupID
255
+ });
256
+ return;
257
+ }
258
+ const results = await Promise.allSettled(hubs.map((hub)=>createPeer(groupID, hub.url)));
259
+ results.forEach((result, index)=>{
260
+ if (result.status === 'rejected') {
261
+ logger?.error('createPeer failed', {
262
+ groupID,
263
+ hubURL: hubs[index]?.url,
264
+ error: result.reason
265
+ });
266
+ }
267
+ });
268
+ };
269
+ return {
270
+ async start (groupIDs) {
271
+ const results = await Promise.allSettled(groupIDs.map((id)=>addGroup(id)));
272
+ results.forEach((result, index)=>{
273
+ if (result.status === 'rejected') {
274
+ logger?.error('addGroup failed during start', {
275
+ groupID: groupIDs[index],
276
+ error: result.reason
277
+ });
278
+ }
279
+ });
280
+ },
281
+ addGroup,
282
+ async removeGroup (groupID) {
283
+ joined.delete(groupID);
284
+ const groupSet = bindings.get(groupID);
285
+ const urls = groupSet != null ? [
286
+ ...groupSet
287
+ ] : [];
288
+ for (const hubURL of urls){
289
+ await disposePeer(groupID, hubURL);
290
+ }
291
+ },
292
+ async addBinding (groupID, hubURL) {
293
+ if (!joined.has(groupID)) {
294
+ logger?.debug('binding ignored: group not joined', {
295
+ groupID,
296
+ hubURL
297
+ });
298
+ return;
299
+ }
300
+ await createPeer(groupID, hubURL);
301
+ },
302
+ async removeBinding (groupID, hubURL) {
303
+ await disposePeer(groupID, hubURL);
304
+ },
305
+ async broadcast (groupID, message) {
306
+ await fanOutToPeers(groupID, (peer)=>dispatchMessage(peer, message, logger), 'broadcast');
307
+ },
308
+ async sendCommit (groupID, commit) {
309
+ await fanOutToPeers(groupID, (peer)=>peer.localCommitted(commit), 'sendCommit');
310
+ },
311
+ async rejoinGroup (_groupID) {
312
+ // INERT stub: deep recovery is deferred. `mls.applyRecovery` deliberately
313
+ // throws (a GroupInfo lacks the missed path secrets → applying it forks the
314
+ // group), and tier-1 replay via `processCommit` is automatic, so there is
315
+ // nothing to drive here. The real path is Welcome re-admission — see
316
+ // docs/agents/plans/backlog/welcome-based-deep-recovery.md.
317
+ return {
318
+ status: 'recovery-failed'
319
+ };
320
+ },
321
+ peer (groupID, hubURL) {
322
+ return peers.get(peerKey(groupID, hubURL));
323
+ },
324
+ getHubLike,
325
+ async stop () {
326
+ const livePeers = [
327
+ ...peers.values()
328
+ ];
329
+ peers.clear();
330
+ bindings.clear();
331
+ hubGroups.clear();
332
+ joined.clear();
333
+ const liveHubs = [
334
+ ...hubLikes.values()
335
+ ];
336
+ hubLikes.clear();
337
+ await Promise.all(livePeers.map((peer)=>peer.dispose()));
338
+ await Promise.all(liveHubs.map((hub)=>hub.dispose()));
339
+ },
340
+ async dispose () {
341
+ await this.stop();
342
+ }
343
+ };
344
+ }