@kubun/plugin-p2p 0.10.1 → 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 +3 -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 +105 -91
- 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 +5 -1
- 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 +61 -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 +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- 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 +20 -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 +0 -6
- 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.js +198 -1
- package/lib/hub/wiring.d.ts +17 -48
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +439 -2
- package/lib/sync/authorize.d.ts +1 -1
- package/lib/sync/authorize.js +73 -1
- 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 +3 -2
- package/lib/sync/errors.js +19 -1
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
- 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 +2 -2
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +1 -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 +3 -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 +11 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.js +21 -1
- package/package.json +47 -40
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.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 -89
- 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 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- 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
package/lib/sync/forwarder.js
CHANGED
|
@@ -1 +1,277 @@
|
|
|
1
|
-
import{catalogMatchesDoc
|
|
1
|
+
import { catalogMatchesDoc, resolveAccessRule } from '@kubun/engine';
|
|
2
|
+
import { computeBroadcastScopes } from './scope-resolver.js';
|
|
3
|
+
// The store-read catch blocks below degrade gracefully on a transient failure
|
|
4
|
+
// (treat the result as missing and skip the forward). A SyntaxError / TypeError
|
|
5
|
+
// / ReferenceError / RangeError is a bug or corrupt data, not a transient
|
|
6
|
+
// condition — rethrow it so it surfaces instead of being silently swallowed.
|
|
7
|
+
export function rethrowIfProgrammerError(error) {
|
|
8
|
+
if (error instanceof SyntaxError || error instanceof TypeError || error instanceof ReferenceError || error instanceof RangeError) {
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Evaluate forwarding candidates for a single mutation:apply entry the local
|
|
14
|
+
* peer has just applied, and forward to each surviving candidate group via
|
|
15
|
+
* `scheduleBroadcast`.
|
|
16
|
+
*
|
|
17
|
+
* Pipeline (per candidate group):
|
|
18
|
+
* 1. Source group exclusion — `sourceGroupID` is filtered out before any check.
|
|
19
|
+
* 2. Access gate — `computeBroadcastScopes` against the post-apply rule.
|
|
20
|
+
* 3. Catalog gate — at least one circle the local peer belongs to in the
|
|
21
|
+
* candidate group must have a catalog whose `filter_criteria` matches the
|
|
22
|
+
* document.
|
|
23
|
+
* 4. Filter (when `forwarding` is a function) — narrows further.
|
|
24
|
+
* 5. Dedup — `recordBroadcast` BEFORE schedule. False return → skip silently.
|
|
25
|
+
* 6. Schedule — single-entry `mutation:apply` broadcast with
|
|
26
|
+
* `senderPeerDID = selfDID`.
|
|
27
|
+
*
|
|
28
|
+
* Per-candidate errors are isolated: one bad candidate logs + continues; the
|
|
29
|
+
* rest still get evaluated. The function never throws.
|
|
30
|
+
*
|
|
31
|
+
* Routing decision: each candidate gets its OWN single-entry broadcast (no
|
|
32
|
+
* batch queue). The queue's `senderPeerDID`-mismatch flush conflicts
|
|
33
|
+
* with mixing locally-authored and forwarded entries in one batch — deferring
|
|
34
|
+
* batched-forwarding keeps the receive path simple.
|
|
35
|
+
*/ export async function evaluateAndForward(params) {
|
|
36
|
+
// 1. Zero-overhead short-circuit when forwarding is disabled.
|
|
37
|
+
if (params.forwarding === false) {
|
|
38
|
+
return {
|
|
39
|
+
forwarded: 0,
|
|
40
|
+
skipped: 0
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const filter = params.forwarding === true ? ()=>true : params.forwarding;
|
|
44
|
+
// 2. Enumerate self group memberships and exclude the source group.
|
|
45
|
+
const myGroups = await params.p2pStore.getGroupsForMember(params.selfDID);
|
|
46
|
+
const candidateGroupIDs = myGroups.map((g)=>g.id).filter((id)=>id !== params.sourceGroupID);
|
|
47
|
+
if (candidateGroupIDs.length === 0) {
|
|
48
|
+
return {
|
|
49
|
+
forwarded: 0,
|
|
50
|
+
skipped: 0
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// 3. Resolve the effective read rule against the post-apply state. Same
|
|
54
|
+
// db-shape wrappers as `processBroadcast` uses for `createReceiveAccessGate`.
|
|
55
|
+
let rule;
|
|
56
|
+
try {
|
|
57
|
+
rule = await resolveAccessRule(params.postState, params.postState.model, params.postState.owner, 'read', {
|
|
58
|
+
getUserModelAccessDefault: (ownerDID, modelID, permissionType)=>params.graphStore.getUserModelAccessDefault(ownerDID, modelID, permissionType),
|
|
59
|
+
isMemberOfAnyCircle: (viewerDID, circleIDs)=>params.p2pStore.isMemberOfAnyCircle(viewerDID, circleIDs),
|
|
60
|
+
isMemberOfAnyGroup: (viewerDID, groupIDs)=>params.p2pStore.isMemberOfAnyGroup(viewerDID, groupIDs),
|
|
61
|
+
getModelInterfaces: (modelID)=>params.graphStore.getModelInterfaces(modelID)
|
|
62
|
+
}, params.defaultAccessLevel);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
rethrowIfProgrammerError(error);
|
|
65
|
+
params.logger?.warn('forwarder: resolveAccessRule failed, skipping forward', {
|
|
66
|
+
docID: params.entry.docID,
|
|
67
|
+
version: params.entry.version,
|
|
68
|
+
sourceGroupID: params.sourceGroupID,
|
|
69
|
+
error: String(error)
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
forwarded: 0,
|
|
73
|
+
skipped: 0
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// 4. Build the resolved-rule shape consumed by computeBroadcastScopes. This
|
|
77
|
+
// mirrors the sender's pre-resolution in broadcast-sender.ts.
|
|
78
|
+
const allowedGroupIDs = new Set(rule.allowedGroups ?? []);
|
|
79
|
+
// Single batched query for allowedCircles → parent group IDs. Replaces a
|
|
80
|
+
// per-circle `getCircle` loop. A query-level failure aborts only this
|
|
81
|
+
// sub-step (with a log) and yields an empty set, mirroring the prior
|
|
82
|
+
// per-circle skip-on-error behavior in aggregate.
|
|
83
|
+
const allowedCircleParentGroupIDs = new Set();
|
|
84
|
+
if (rule.allowedCircles != null && rule.allowedCircles.length > 0) {
|
|
85
|
+
try {
|
|
86
|
+
const circlesByID = await params.p2pStore.getCircles(rule.allowedCircles);
|
|
87
|
+
for (const circle of circlesByID.values()){
|
|
88
|
+
if (circle.group_id != null) {
|
|
89
|
+
allowedCircleParentGroupIDs.add(circle.group_id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
rethrowIfProgrammerError(error);
|
|
94
|
+
params.logger?.warn('forwarder: getCircles failed, skipping allowedCircles', {
|
|
95
|
+
count: rule.allowedCircles.length,
|
|
96
|
+
error: String(error)
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Single batched query for allowedDIDs → groups. Replaces a per-DID
|
|
101
|
+
// `getGroupsForMember` loop.
|
|
102
|
+
const allowedDIDGroupIDs = new Set();
|
|
103
|
+
if (rule.allowedDIDs != null && rule.allowedDIDs.length > 0) {
|
|
104
|
+
try {
|
|
105
|
+
const groupsByDID = await params.p2pStore.getGroupsForMembers(rule.allowedDIDs);
|
|
106
|
+
for (const groups of groupsByDID.values()){
|
|
107
|
+
for (const g of groups){
|
|
108
|
+
allowedDIDGroupIDs.add(g.id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
rethrowIfProgrammerError(error);
|
|
113
|
+
params.logger?.warn('forwarder: getGroupsForMembers failed, skipping allowedDIDs', {
|
|
114
|
+
count: rule.allowedDIDs.length,
|
|
115
|
+
error: String(error)
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const resolvedRule = {
|
|
120
|
+
level: rule.level,
|
|
121
|
+
allowedGroupIDs,
|
|
122
|
+
allowedCircleParentGroupIDs,
|
|
123
|
+
allowedDIDGroupIDs
|
|
124
|
+
};
|
|
125
|
+
// 5. Apply access gate via computeBroadcastScopes.
|
|
126
|
+
const accessPermitted = computeBroadcastScopes({
|
|
127
|
+
resolvedRule,
|
|
128
|
+
senderGroupIDs: candidateGroupIDs
|
|
129
|
+
});
|
|
130
|
+
if (accessPermitted.length === 0) {
|
|
131
|
+
return {
|
|
132
|
+
forwarded: 0,
|
|
133
|
+
skipped: 0
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// 6. Resolve self circles once and bucket by group_id for catalog-match
|
|
137
|
+
// lookups. A candidate group is catalog-eligible iff at least one of the
|
|
138
|
+
// local peer's circles WITHIN that group has a catalog matching the doc.
|
|
139
|
+
let myCircles;
|
|
140
|
+
try {
|
|
141
|
+
myCircles = await params.p2pStore.getCirclesForMember(params.selfDID);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
rethrowIfProgrammerError(error);
|
|
144
|
+
params.logger?.warn('forwarder: getCirclesForMember failed', {
|
|
145
|
+
selfDID: params.selfDID,
|
|
146
|
+
error: String(error)
|
|
147
|
+
});
|
|
148
|
+
return {
|
|
149
|
+
forwarded: 0,
|
|
150
|
+
skipped: 0
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const accessSet = new Set(accessPermitted);
|
|
154
|
+
const circlesByGroup = new Map();
|
|
155
|
+
for (const circle of myCircles){
|
|
156
|
+
if (!accessSet.has(circle.group_id)) continue;
|
|
157
|
+
const list = circlesByGroup.get(circle.group_id);
|
|
158
|
+
if (list != null) {
|
|
159
|
+
list.push(circle);
|
|
160
|
+
} else {
|
|
161
|
+
circlesByGroup.set(circle.group_id, [
|
|
162
|
+
circle
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 6b. Pre-fetch every catalog referenced by the relevant circles in a
|
|
167
|
+
// single batched DB query. This collapses O(candidates × circles ×
|
|
168
|
+
// catalogs) sequential awaits in the candidate loop down to ONE DB call
|
|
169
|
+
// per evaluation. Catalogs can be shared across circles, so the Set
|
|
170
|
+
// already dedups IDs before the call.
|
|
171
|
+
const catalogIDSet = new Set();
|
|
172
|
+
for (const circles of circlesByGroup.values()){
|
|
173
|
+
for (const circle of circles){
|
|
174
|
+
for (const catalogID of circle.catalog_ids ?? []){
|
|
175
|
+
catalogIDSet.add(catalogID);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let catalogByID = new Map();
|
|
180
|
+
if (catalogIDSet.size > 0) {
|
|
181
|
+
try {
|
|
182
|
+
catalogByID = await params.graphStore.getCatalogs(Array.from(catalogIDSet));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
rethrowIfProgrammerError(error);
|
|
185
|
+
// Single-query failure: treat all catalogs as missing (preserves the
|
|
186
|
+
// prior per-getCatalog-failure semantics in aggregate). Candidate
|
|
187
|
+
// groups whose catalogs are now unresolved will fall through the
|
|
188
|
+
// catalog gate as no-match.
|
|
189
|
+
params.logger?.warn('forwarder: getCatalogs failed, treating all as missing', {
|
|
190
|
+
count: catalogIDSet.size,
|
|
191
|
+
error: String(error)
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let forwarded = 0;
|
|
196
|
+
let skipped = 0;
|
|
197
|
+
// 7. Evaluate each candidate group SEQUENTIALLY. This loop is deliberately
|
|
198
|
+
// not parallelized:
|
|
199
|
+
// - `filter(ctx)` is a user-supplied callback whose side effects (e.g.
|
|
200
|
+
// logging, metrics) may rely on candidate order.
|
|
201
|
+
// - `recordBroadcast` ordering matters for the at-most-once invariant —
|
|
202
|
+
// a failure mid-loop must not leave broadcasts recorded for groups
|
|
203
|
+
// that were never scheduled.
|
|
204
|
+
// - `scheduleBroadcast` ordering keeps test assertions stable and
|
|
205
|
+
// surfaces failures deterministically.
|
|
206
|
+
// Pure DB reads upstream (catalog batch fetch, getCircles,
|
|
207
|
+
// getGroupsForMembers) are batched/parallel — only this side-effecting
|
|
208
|
+
// dispatch stays sequential.
|
|
209
|
+
for (const candidateGroupID of accessPermitted){
|
|
210
|
+
try {
|
|
211
|
+
// Catalog gate.
|
|
212
|
+
const circlesInGroup = circlesByGroup.get(candidateGroupID) ?? [];
|
|
213
|
+
let catalogEligible = false;
|
|
214
|
+
for (const circle of circlesInGroup){
|
|
215
|
+
const catalogIDs = circle.catalog_ids ?? [];
|
|
216
|
+
for (const catalogID of catalogIDs){
|
|
217
|
+
const catalog = catalogByID.get(catalogID);
|
|
218
|
+
if (catalog == null) continue;
|
|
219
|
+
if (await catalogMatchesDoc(catalog.filter_criteria, params.postState, params.p2pStore)) {
|
|
220
|
+
catalogEligible = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (catalogEligible) break;
|
|
225
|
+
}
|
|
226
|
+
if (!catalogEligible) {
|
|
227
|
+
skipped++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// Filter gate.
|
|
231
|
+
const ctx = {
|
|
232
|
+
docID: params.entry.docID,
|
|
233
|
+
modelID: params.entry.modelID,
|
|
234
|
+
ownerDID: params.postState.owner,
|
|
235
|
+
sourceGroupID: params.sourceGroupID,
|
|
236
|
+
candidateGroupID,
|
|
237
|
+
postState: params.postState
|
|
238
|
+
};
|
|
239
|
+
const allow = await filter(ctx);
|
|
240
|
+
if (!allow) {
|
|
241
|
+
skipped++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Dedup BEFORE schedule — record-before-send pattern (sender mirror).
|
|
245
|
+
const recorded = await params.p2pStore.recordBroadcast({
|
|
246
|
+
docID: params.entry.docID,
|
|
247
|
+
version: params.entry.version,
|
|
248
|
+
mlsGroupID: candidateGroupID
|
|
249
|
+
});
|
|
250
|
+
if (!recorded) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const message = {
|
|
254
|
+
type: 'mutation:apply',
|
|
255
|
+
entries: [
|
|
256
|
+
params.entry
|
|
257
|
+
],
|
|
258
|
+
senderPeerDID: params.selfDID
|
|
259
|
+
};
|
|
260
|
+
await params.scheduleBroadcast(candidateGroupID, message);
|
|
261
|
+
forwarded++;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// Per-candidate isolation: log and continue with rest.
|
|
264
|
+
params.logger?.warn('forwarder: candidate evaluation failed', {
|
|
265
|
+
docID: params.entry.docID,
|
|
266
|
+
version: params.entry.version,
|
|
267
|
+
candidateGroupID,
|
|
268
|
+
sourceGroupID: params.sourceGroupID,
|
|
269
|
+
error: String(error)
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
forwarded,
|
|
275
|
+
skipped
|
|
276
|
+
};
|
|
277
|
+
}
|
package/lib/sync/handlers.js
CHANGED
|
@@ -1 +1,160 @@
|
|
|
1
|
-
import{createRevocationChecker
|
|
1
|
+
import { createRevocationChecker } from '@kokuin/capability';
|
|
2
|
+
import { createRevocationBackend, getDelegationStore } from '@kubun/store-delegation';
|
|
3
|
+
import { getGraphStore } from '@kubun/store-graph';
|
|
4
|
+
import { authorizeScope, checkSyncDelegation } from './authorize.js';
|
|
5
|
+
import { resolveCatalogSyncScopes } from './catalog-scope.js';
|
|
6
|
+
import { SyncAccessDeniedError } from './errors.js';
|
|
7
|
+
import { createMerkleSyncChannelHandler } from './merkle-channel.js';
|
|
8
|
+
import { negotiateDirection } from './peer-registry.js';
|
|
9
|
+
export { checkSyncDelegation };
|
|
10
|
+
// ---- Handler factory ----
|
|
11
|
+
/**
|
|
12
|
+
* Create sync protocol handlers served by plugin-p2p's own Enkaku server.
|
|
13
|
+
*/ export function createSyncHandlers(params) {
|
|
14
|
+
const { db, graph, logger, peerRegistry } = params;
|
|
15
|
+
const storeUnreadable = params.storeUnreadable ?? 'persist';
|
|
16
|
+
let cachedRevocationChecker = null;
|
|
17
|
+
async function getRevocationChecker() {
|
|
18
|
+
if (cachedRevocationChecker == null) {
|
|
19
|
+
const delegationStore = await getDelegationStore(db);
|
|
20
|
+
cachedRevocationChecker = createRevocationChecker(createRevocationBackend(delegationStore));
|
|
21
|
+
}
|
|
22
|
+
return cachedRevocationChecker;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
'sync/negotiate': async (ctx)=>{
|
|
26
|
+
const { scopes, delegationTokens, catalogIDs, knownModelIDs, direction } = ctx.param;
|
|
27
|
+
const payload = ctx.message.payload;
|
|
28
|
+
// The viewer is the cryptographically-verified signer (`iss`). A signed
|
|
29
|
+
// payload always carries `iss`; no legitimate kubun client sets `sub`.
|
|
30
|
+
// Reject any request whose `sub` is present and differs from `iss` to
|
|
31
|
+
// close the forged-subject path before any store read.
|
|
32
|
+
if (payload.sub != null && payload.sub !== payload.iss) {
|
|
33
|
+
throw new SyncAccessDeniedError('forged-subject', 'Sync access denied: signed payload subject does not match issuer');
|
|
34
|
+
}
|
|
35
|
+
const viewerDID = payload.iss;
|
|
36
|
+
logger.debug('sync/negotiate requested', {
|
|
37
|
+
scopes,
|
|
38
|
+
catalogIDs,
|
|
39
|
+
viewerDID
|
|
40
|
+
});
|
|
41
|
+
const acceptedScopes = [];
|
|
42
|
+
let missingClusters;
|
|
43
|
+
const graphStore = await getGraphStore(db);
|
|
44
|
+
// Catalog-based scope resolution
|
|
45
|
+
if (catalogIDs != null && catalogIDs.length > 0) {
|
|
46
|
+
const catalogScope = await resolveCatalogSyncScopes(db, catalogIDs, knownModelIDs);
|
|
47
|
+
// Generate scopes from resolved catalog criteria
|
|
48
|
+
if (catalogScope.modelIDs.length > 0) {
|
|
49
|
+
if (catalogScope.owners != null) {
|
|
50
|
+
// Explicit owners from catalog criteria
|
|
51
|
+
for (const modelID of catalogScope.modelIDs){
|
|
52
|
+
for (const ownerDID of catalogScope.owners){
|
|
53
|
+
const accepted = await authorizeScope({
|
|
54
|
+
viewerDID,
|
|
55
|
+
ownerDID,
|
|
56
|
+
delegationTokens,
|
|
57
|
+
revocationChecker: await getRevocationChecker()
|
|
58
|
+
});
|
|
59
|
+
if (accepted) {
|
|
60
|
+
acceptedScopes.push({
|
|
61
|
+
modelID,
|
|
62
|
+
ownerDID
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
// No owner filter — get all distinct owners for each model
|
|
69
|
+
for (const modelID of catalogScope.modelIDs){
|
|
70
|
+
const owners = await graphStore.getDistinctOwnersForModel(modelID);
|
|
71
|
+
for (const ownerDID of owners){
|
|
72
|
+
const accepted = await authorizeScope({
|
|
73
|
+
viewerDID,
|
|
74
|
+
ownerDID,
|
|
75
|
+
delegationTokens,
|
|
76
|
+
revocationChecker: await getRevocationChecker()
|
|
77
|
+
});
|
|
78
|
+
if (accepted) {
|
|
79
|
+
acceptedScopes.push({
|
|
80
|
+
modelID,
|
|
81
|
+
ownerDID
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Fetch missing cluster definitions
|
|
89
|
+
if (catalogScope.missingClusterIDs.length > 0) {
|
|
90
|
+
missingClusters = await graphStore.getClusters(catalogScope.missingClusterIDs);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Explicit scopes (existing behavior)
|
|
94
|
+
if (scopes != null) {
|
|
95
|
+
for (const scope of scopes){
|
|
96
|
+
const accepted = await authorizeScope({
|
|
97
|
+
viewerDID,
|
|
98
|
+
ownerDID: scope.ownerDID,
|
|
99
|
+
delegationTokens,
|
|
100
|
+
revocationChecker: await getRevocationChecker()
|
|
101
|
+
});
|
|
102
|
+
if (accepted) {
|
|
103
|
+
acceptedScopes.push(scope);
|
|
104
|
+
} else {
|
|
105
|
+
logger.debug('sync/negotiate: scope rejected', {
|
|
106
|
+
scope,
|
|
107
|
+
viewerDID
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Deduplicate scopes
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const uniqueScopes = acceptedScopes.filter((s)=>{
|
|
115
|
+
const key = `${s.modelID}:${s.ownerDID}`;
|
|
116
|
+
if (seen.has(key)) return false;
|
|
117
|
+
seen.add(key);
|
|
118
|
+
return true;
|
|
119
|
+
});
|
|
120
|
+
logger.info('sync/negotiate completed', {
|
|
121
|
+
requested: (scopes?.length ?? 0) + (catalogIDs?.length ?? 0),
|
|
122
|
+
accepted: uniqueScopes.length,
|
|
123
|
+
missingClusters: missingClusters != null ? Object.keys(missingClusters).length : 0
|
|
124
|
+
});
|
|
125
|
+
// Resolve direction against per-peer policy
|
|
126
|
+
const requestedDirection = direction ?? 'pull';
|
|
127
|
+
const peer = await peerRegistry.getPeer({
|
|
128
|
+
peerDID: viewerDID,
|
|
129
|
+
stores: db
|
|
130
|
+
});
|
|
131
|
+
const peerAllowed = peer?.allowedDirection ?? 'both';
|
|
132
|
+
const agreedDirection = negotiateDirection(requestedDirection, peerAllowed);
|
|
133
|
+
if (agreedDirection !== requestedDirection) {
|
|
134
|
+
logger.info('sync/negotiate: direction downgraded', {
|
|
135
|
+
requested: requestedDirection,
|
|
136
|
+
allowed: peerAllowed,
|
|
137
|
+
agreed: agreedDirection
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
direction: agreedDirection,
|
|
142
|
+
acceptedScopes: uniqueScopes,
|
|
143
|
+
excludedDocumentIDs: [],
|
|
144
|
+
...missingClusters != null && {
|
|
145
|
+
missingClusters
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
'sync/merkle-sync': createMerkleSyncChannelHandler({
|
|
150
|
+
db,
|
|
151
|
+
graph,
|
|
152
|
+
logger,
|
|
153
|
+
peerRegistry,
|
|
154
|
+
selfDID: params.selfDID,
|
|
155
|
+
storeUnreadable,
|
|
156
|
+
defaultAccessLevel: params.defaultAccessLevel,
|
|
157
|
+
getRevocationChecker
|
|
158
|
+
})
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
|
|
2
1
|
import { type ProcedureHandlers } from '@enkaku/server';
|
|
3
|
-
import type { OwnIdentity } from '@
|
|
2
|
+
import type { OwnIdentity } from '@kokuin/token';
|
|
3
|
+
import { type HubLike, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
|
|
4
4
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
5
|
-
import type { HubConnection } from '../hub/hub-connection.js';
|
|
6
5
|
import type { SyncProtocol } from '../protocol.js';
|
|
7
6
|
export type HubTunnelSyncListenerParams = {
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Shared multi-subscriber device hub (from `createHubLike`). Each spawned
|
|
9
|
+
* server transport attaches its own sink to this device-wide drain and filters
|
|
10
|
+
* to its `receiveTopicID`; lifecycle events ride `hub.events`.
|
|
11
|
+
*/
|
|
12
|
+
hub: HubLike;
|
|
9
13
|
registry: GroupHandleRegistry;
|
|
10
14
|
groupID: string;
|
|
11
15
|
localDID: string;
|
|
@@ -1 +1,172 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { Server } from '@enkaku/server';
|
|
2
|
+
import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
|
|
3
|
+
import { inboxTopic } from '@kumiai/rpc';
|
|
4
|
+
import { createGroupCrypto } from '../groups/group-crypto.js';
|
|
5
|
+
import { MLSEncryptor } from '../groups/mls-encryptor.js';
|
|
6
|
+
export class HubTunnelSyncListener {
|
|
7
|
+
#hub;
|
|
8
|
+
#registry;
|
|
9
|
+
#groupID;
|
|
10
|
+
#localDID;
|
|
11
|
+
#peerDID;
|
|
12
|
+
#identity;
|
|
13
|
+
#syncHandlers;
|
|
14
|
+
#getRandomID;
|
|
15
|
+
#idleTimeoutMs;
|
|
16
|
+
#reconnectTimeoutMs;
|
|
17
|
+
#inboxCapacity;
|
|
18
|
+
#onEvent;
|
|
19
|
+
#started = false;
|
|
20
|
+
#stopped = false;
|
|
21
|
+
#current;
|
|
22
|
+
// Single MLSEncryptor reused across spawns. Ordering across overlapping
|
|
23
|
+
// spawns (old transport's last decrypt vs. new spawn's first encrypt) is
|
|
24
|
+
// covered by the GroupHandleRegistry's per-group mutex.
|
|
25
|
+
#encryptor;
|
|
26
|
+
// This device's inbox topic for the live epoch, cached from the first spawn.
|
|
27
|
+
// Within an epoch it is stable (same secret + localDID), so it lets the
|
|
28
|
+
// disposed handler re-subscribe synchronously and close the gap left by the
|
|
29
|
+
// departing transport's teardown-unsubscribe before the async respawn lands.
|
|
30
|
+
#receiveTopicID;
|
|
31
|
+
constructor(params){
|
|
32
|
+
this.#hub = params.hub;
|
|
33
|
+
this.#registry = params.registry;
|
|
34
|
+
this.#groupID = params.groupID;
|
|
35
|
+
this.#localDID = params.localDID;
|
|
36
|
+
this.#peerDID = params.peerDID;
|
|
37
|
+
this.#identity = params.identity;
|
|
38
|
+
this.#syncHandlers = params.syncHandlers;
|
|
39
|
+
this.#getRandomID = params.getRandomID;
|
|
40
|
+
this.#idleTimeoutMs = params.idleTimeoutMs;
|
|
41
|
+
this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
|
|
42
|
+
this.#inboxCapacity = params.inboxCapacity;
|
|
43
|
+
this.#onEvent = params.onEvent;
|
|
44
|
+
}
|
|
45
|
+
start() {
|
|
46
|
+
if (this.#started || this.#stopped) return;
|
|
47
|
+
this.#started = true;
|
|
48
|
+
this.#encryptor = new MLSEncryptor({
|
|
49
|
+
registry: this.#registry,
|
|
50
|
+
groupID: this.#groupID
|
|
51
|
+
});
|
|
52
|
+
void this.#spawn();
|
|
53
|
+
}
|
|
54
|
+
async stop() {
|
|
55
|
+
if (this.#stopped) return;
|
|
56
|
+
this.#stopped = true;
|
|
57
|
+
const current = this.#current;
|
|
58
|
+
this.#current = undefined;
|
|
59
|
+
if (current != null) {
|
|
60
|
+
try {
|
|
61
|
+
await current.transport.dispose();
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore — best-effort teardown
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await current.server.dispose();
|
|
67
|
+
} catch {
|
|
68
|
+
// ignore — best-effort teardown
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.#encryptor = undefined;
|
|
72
|
+
}
|
|
73
|
+
async #spawn() {
|
|
74
|
+
if (this.#stopped) return;
|
|
75
|
+
const encryptor = this.#encryptor;
|
|
76
|
+
if (encryptor == null) {
|
|
77
|
+
throw new Error('HubTunnelSyncListener: encryptor not initialized; call start() first');
|
|
78
|
+
}
|
|
79
|
+
// Derive the epoch-bound inbox topics for this spawn. The server accepts
|
|
80
|
+
// inbound frames on its own inbox (`receiveTopicID`) and replies to the
|
|
81
|
+
// peer's inbox (`sendTopicID`). Topics rotate with the MLS epoch, so each
|
|
82
|
+
// spawn re-derives them against the live handle. MLS decrypt inside the
|
|
83
|
+
// registry lock remains the authoritative membership/forward-secrecy gate;
|
|
84
|
+
// stale-after-remove ciphertext surfaces as DecryptError.
|
|
85
|
+
const crypto = createGroupCrypto(this.#registry, this.#groupID);
|
|
86
|
+
const secret = await crypto.exportSecret();
|
|
87
|
+
const epoch = crypto.epoch();
|
|
88
|
+
const sendTopicID = inboxTopic(secret, epoch, this.#peerDID);
|
|
89
|
+
const receiveTopicID = inboxTopic(secret, epoch, this.#localDID);
|
|
90
|
+
this.#receiveTopicID = receiveTopicID;
|
|
91
|
+
// A stop() that landed while we awaited the handle must abort the spawn.
|
|
92
|
+
if (this.#stopped) return;
|
|
93
|
+
let transportRef;
|
|
94
|
+
const transport = createEncryptedHubTunnelTransport({
|
|
95
|
+
hub: this.#hub,
|
|
96
|
+
encryptor,
|
|
97
|
+
groupID: this.#groupID,
|
|
98
|
+
sessionID: {
|
|
99
|
+
auto: true
|
|
100
|
+
},
|
|
101
|
+
localDID: this.#localDID,
|
|
102
|
+
sendTopicID,
|
|
103
|
+
receiveTopicID,
|
|
104
|
+
idleTimeoutMs: this.#idleTimeoutMs,
|
|
105
|
+
reconnectTimeoutMs: this.#reconnectTimeoutMs,
|
|
106
|
+
inboxCapacity: this.#inboxCapacity,
|
|
107
|
+
onEvent: this.#onEvent,
|
|
108
|
+
onSessionEnd: ()=>{
|
|
109
|
+
// Peer signaled end-of-session. Dispose the transport deterministically
|
|
110
|
+
// so the `disposed` event fires and the spawn loop re-arms for the
|
|
111
|
+
// next session arriving on the shared device drain.
|
|
112
|
+
void transportRef?.dispose().catch(()=>{
|
|
113
|
+
// ignore — best-effort
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
transportRef = transport;
|
|
118
|
+
// Setting identity forces requireAuth: the tunnel server verifies token
|
|
119
|
+
// signatures and that aud equals its own DID, matching the direct/HTTP sync
|
|
120
|
+
// servers. Per-scope authorization is handled uniformly by the merkle-sync
|
|
121
|
+
// scope-gate, not here.
|
|
122
|
+
const server = new Server({
|
|
123
|
+
getRandomID: this.#getRandomID,
|
|
124
|
+
handlers: this.#syncHandlers,
|
|
125
|
+
identity: this.#identity,
|
|
126
|
+
accessRules: {
|
|
127
|
+
'sync/*': {
|
|
128
|
+
allow: true
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
transports: [
|
|
132
|
+
transport
|
|
133
|
+
]
|
|
134
|
+
});
|
|
135
|
+
const session = {
|
|
136
|
+
transport,
|
|
137
|
+
server
|
|
138
|
+
};
|
|
139
|
+
this.#current = session;
|
|
140
|
+
// `disposed` may fire more than once for a single session (disposing the
|
|
141
|
+
// server can re-enter the transport's dispose). Collapse to exactly one
|
|
142
|
+
// teardown + respawn so the inbox is re-armed once, not N times.
|
|
143
|
+
let handled = false;
|
|
144
|
+
transport.events.on('disposed', ()=>{
|
|
145
|
+
if (handled) return;
|
|
146
|
+
handled = true;
|
|
147
|
+
if (this.#current === session) {
|
|
148
|
+
this.#current = undefined;
|
|
149
|
+
}
|
|
150
|
+
// Server stays around until its handlers drain; explicit cleanup here so
|
|
151
|
+
// long-running sessions don't leak server instances.
|
|
152
|
+
void server.dispose().catch(()=>{
|
|
153
|
+
// ignore
|
|
154
|
+
});
|
|
155
|
+
// The departing transport's teardown unsubscribes this device's inbox
|
|
156
|
+
// topic. Re-subscribe it eagerly (idempotent server-side) so a peer that
|
|
157
|
+
// reconnects immediately — before the async respawn re-derives and
|
|
158
|
+
// re-subscribes — does not lose its first frame to a momentarily empty
|
|
159
|
+
// topic. Within an epoch the topic is stable, so this targets the same
|
|
160
|
+
// inbox the next spawn will use.
|
|
161
|
+
const receiveTopicID = this.#receiveTopicID;
|
|
162
|
+
if (receiveTopicID != null) {
|
|
163
|
+
void Promise.resolve(this.#hub.subscribe(this.#localDID, receiveTopicID)).catch(()=>{
|
|
164
|
+
// best-effort; the respawn re-subscribes authoritatively
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Respawn for the next session. The next spawn re-derives the topics
|
|
168
|
+
// against the (possibly advanced) epoch and re-subscribes.
|
|
169
|
+
void this.#spawn();
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|