@kubun/plugin-p2p 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,277 @@
1
- import{catalogMatchesDoc as r,resolveAccessRule as e}from"@kubun/engine";import{computeBroadcastScopes as o}from"./scope-resolver.js";export function rethrowIfProgrammerError(r){if(r instanceof SyntaxError||r instanceof TypeError||r instanceof ReferenceError||r instanceof RangeError)throw r}export async function evaluateAndForward(t){let a,l;if(!1===t.forwarding)return{forwarded:0,skipped:0};let i=!0===t.forwarding?()=>!0:t.forwarding,n=(await t.p2pStore.getGroupsForMember(t.selfDID)).map(r=>r.id).filter(r=>r!==t.sourceGroupID);if(0===n.length)return{forwarded:0,skipped:0};try{a=await e(t.postState,t.postState.model,t.postState.owner,"read",{getUserModelAccessDefault:(r,e,o)=>t.graphStore.getUserModelAccessDefault(r,e,o),isMemberOfAnyCircle:(r,e)=>t.p2pStore.isMemberOfAnyCircle(r,e),isMemberOfAnyGroup:(r,e)=>t.p2pStore.isMemberOfAnyGroup(r,e),getModelInterfaces:r=>t.graphStore.getModelInterfaces(r)},t.defaultAccessLevel)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: resolveAccessRule failed, skipping forward",{docID:t.entry.docID,version:t.entry.version,sourceGroupID:t.sourceGroupID,error:String(r)}),{forwarded:0,skipped:0}}let s=new Set(a.allowedGroups??[]),f=new Set;if(null!=a.allowedCircles&&a.allowedCircles.length>0)try{for(let r of(await t.p2pStore.getCircles(a.allowedCircles)).values())null!=r.group_id&&f.add(r.group_id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCircles failed, skipping allowedCircles",{count:a.allowedCircles.length,error:String(r)})}let d=new Set;if(null!=a.allowedDIDs&&a.allowedDIDs.length>0)try{for(let r of(await t.p2pStore.getGroupsForMembers(a.allowedDIDs)).values())for(let e of r)d.add(e.id)}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getGroupsForMembers failed, skipping allowedDIDs",{count:a.allowedDIDs.length,error:String(r)})}let p=o({resolvedRule:{level:a.level,allowedGroupIDs:s,allowedCircleParentGroupIDs:f,allowedDIDGroupIDs:d},senderGroupIDs:n});if(0===p.length)return{forwarded:0,skipped:0};try{l=await t.p2pStore.getCirclesForMember(t.selfDID)}catch(r){return rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCirclesForMember failed",{selfDID:t.selfDID,error:String(r)}),{forwarded:0,skipped:0}}let c=new Set(p),g=new Map;for(let r of l){if(!c.has(r.group_id))continue;let e=g.get(r.group_id);null!=e?e.push(r):g.set(r.group_id,[r])}let u=new Set;for(let r of g.values())for(let e of r)for(let r of e.catalog_ids??[])u.add(r);let w=new Map;if(u.size>0)try{w=await t.graphStore.getCatalogs(Array.from(u))}catch(r){rethrowIfProgrammerError(r),t.logger?.warn("forwarder: getCatalogs failed, treating all as missing",{count:u.size,error:String(r)})}let D=0,I=0;for(let e of p)try{let o=g.get(e)??[],a=!1;for(let e of o){for(let o of e.catalog_ids??[]){let e=w.get(o);if(null!=e&&await r(e.filter_criteria,t.postState,t.p2pStore)){a=!0;break}}if(a)break}if(!a){I++;continue}let l={docID:t.entry.docID,modelID:t.entry.modelID,ownerDID:t.postState.owner,sourceGroupID:t.sourceGroupID,candidateGroupID:e,postState:t.postState};if(!await i(l)){I++;continue}if(!await t.p2pStore.recordBroadcast({docID:t.entry.docID,version:t.entry.version,mlsGroupID:e}))continue;let n={type:"mutation:apply",entries:[t.entry],senderPeerDID:t.selfDID};await t.scheduleBroadcast(e,n),D++}catch(r){t.logger?.warn("forwarder: candidate evaluation failed",{docID:t.entry.docID,version:t.entry.version,candidateGroupID:e,sourceGroupID:t.sourceGroupID,error:String(r)})}return{forwarded:D,skipped:I}}
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
+ }
@@ -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 type { PeerRegistry } from './peer-registry.js';
8
- /**
9
- * Check if delegation tokens grant the viewer read access to a user's documents.
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
  */
@@ -1 +1,160 @@
1
- import{checkCapability as e,createRevocationChecker as t}from"@enkaku/capability";import{getGraphStore as n}from"@kubun/store-graph";import{createP2PRevocationBackend as r,getP2PStore as o}from"@kubun/store-p2p";import{resolveCatalogSyncScopes as l}from"./catalog-scope.js";import{createMerkleSyncChannelHandler as a}from"./merkle-channel.js";export async function checkSyncDelegation(t,n,r,o){if(!r||0===r.length)return!1;let l=["*","urn:kubun:user:*",`urn:kubun:user:${n}`],a="document/read";for(let s of l)try{return await e({act:a,res:s},{iss:t,sub:n,cap:r},{verifyToken:o}),!0}catch{}for(let s of r)for(let r of l)try{return await e({act:a,res:r},{iss:t,sub:n,cap:s},{verifyToken:o}),!0}catch{}return!1}export function negotiateDirection(e,t){return"both"===t?e:"both"===e?t:e===t?e:t}export function createSyncHandlers(e){let{db:s,graph:i,logger:c,peerRegistry:u}=e,f=e.storeUnreadable??"persist",d=null;async function g(){return null==d&&(d=t(r(await o(s)))),d}return{"sync/negotiate":async e=>{let t,{scopes:r,delegationTokens:o,catalogIDs:a,knownModelIDs:i,direction:f}=e.param,d=e.message.payload,p=d.sub||d.iss;c.debug("sync/negotiate requested",{scopes:r,catalogIDs:a,viewerDID:p});let D=[],m=await n(s);if(null!=a&&a.length>0){let e=await l(s,a,i);if(e.modelIDs.length>0)if(null!=e.owners)for(let t of e.modelIDs)for(let n of e.owners){let e=p===n,r=!e&&await checkSyncDelegation(p,n,o,await g());(e||r)&&D.push({modelID:t,ownerDID:n})}else for(let t of e.modelIDs)for(let e of(await m.getDistinctOwnersForModel(t))){let n=p===e,r=!n&&await checkSyncDelegation(p,e,o,await g());(n||r)&&D.push({modelID:t,ownerDID:e})}e.missingClusterIDs.length>0&&(t=await m.getClusters(e.missingClusterIDs))}if(null!=r)for(let e of r){let t=p===e.ownerDID,n=!t&&await checkSyncDelegation(p,e.ownerDID,o,await g());t||n?D.push(e):c.debug("sync/negotiate: scope rejected",{scope:e,viewerDID:p})}let w=new Set,h=D.filter(e=>{let t=`${e.modelID}:${e.ownerDID}`;return!w.has(t)&&(w.add(t),!0)});c.info("sync/negotiate completed",{requested:(r?.length??0)+(a?.length??0),accepted:h.length,missingClusters:null!=t?Object.keys(t).length:0});let y=f??"pull",b=await u.getPeer({peerDID:p,stores:s}),k=b?.allowedDirection??"both",I=negotiateDirection(y,k);return I!==y&&c.info("sync/negotiate: direction downgraded",{requested:y,allowed:k,agreed:I}),{direction:I,acceptedScopes:h,excludedDocumentIDs:[],...null!=t&&{missingClusters:t}}},"sync/merkle-sync":a({db:s,graph:i,logger:c,selfDID:e.selfDID,storeUnreadable:f,defaultAccessLevel:e.defaultAccessLevel})}}
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
- hubConnection: HubConnection;
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;