@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
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.d.ts
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
|
-
import { type VerifyTokenHook } from '@enkaku/capability';
|
|
2
1
|
import type { KubunDB } from '@kubun/db';
|
|
3
2
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
4
3
|
import type { Logger } from '@kubun/logger';
|
|
5
|
-
import type { SyncDirection } from '../protocol.js';
|
|
6
4
|
import type { StoreUnreadableMode } from '../types.js';
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
* @param viewerDID - The DID of the requesting viewer
|
|
12
|
-
* @param ownerDID - The DID of the document owner
|
|
13
|
-
* @param delegationTokens - Array of delegation JWT tokens
|
|
14
|
-
* @param revocationChecker - Optional per-leaf-capability verification hook.
|
|
15
|
-
* When set, capabilities whose `jti` has been revoked cause the check to
|
|
16
|
-
* fail closed.
|
|
17
|
-
* @returns true if any token grants read access
|
|
18
|
-
*/
|
|
19
|
-
export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>, revocationChecker?: VerifyTokenHook): Promise<boolean>;
|
|
5
|
+
import { checkSyncDelegation } from './authorize.js';
|
|
6
|
+
import { type PeerRegistry } from './peer-registry.js';
|
|
7
|
+
export { checkSyncDelegation };
|
|
20
8
|
export type CreateSyncHandlersParams = {
|
|
21
9
|
db: KubunDB;
|
|
22
10
|
graph: GraphInternals;
|
|
@@ -35,13 +23,6 @@ export type CreateSyncHandlersParams = {
|
|
|
35
23
|
/** Server default access level — required when `storeUnreadable === 'drop'`. */
|
|
36
24
|
defaultAccessLevel?: DefaultAccessLevel;
|
|
37
25
|
};
|
|
38
|
-
/**
|
|
39
|
-
* Resolve the effective sync direction given what the initiator requested
|
|
40
|
-
* and what the responder's per-peer policy allows.
|
|
41
|
-
* If allowed is 'both' (or unset), the request passes through.
|
|
42
|
-
* Otherwise the responder's policy wins.
|
|
43
|
-
*/
|
|
44
|
-
export declare function negotiateDirection(requested: SyncDirection, allowed: SyncDirection): SyncDirection;
|
|
45
26
|
/**
|
|
46
27
|
* Create sync protocol handlers served by plugin-p2p's own Enkaku server.
|
|
47
28
|
*/
|
package/lib/sync/handlers.js
CHANGED
|
@@ -1 +1,160 @@
|
|
|
1
|
-
import{
|
|
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,14 +1,20 @@
|
|
|
1
|
-
import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
|
|
2
1
|
import { type ProcedureHandlers } from '@enkaku/server';
|
|
2
|
+
import type { OwnIdentity } from '@kokuin/token';
|
|
3
|
+
import { type HubLike, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
|
|
3
4
|
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
4
|
-
import type { HubConnection } from '../hub/hub-connection.js';
|
|
5
5
|
import type { SyncProtocol } from '../protocol.js';
|
|
6
6
|
export type HubTunnelSyncListenerParams = {
|
|
7
|
-
|
|
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;
|
|
8
13
|
registry: GroupHandleRegistry;
|
|
9
14
|
groupID: string;
|
|
10
15
|
localDID: string;
|
|
11
16
|
peerDID: string;
|
|
17
|
+
identity: OwnIdentity;
|
|
12
18
|
syncHandlers: ProcedureHandlers<SyncProtocol>;
|
|
13
19
|
getRandomID?: () => string;
|
|
14
20
|
idleTimeoutMs?: number;
|