@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- 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
|
+
}
|