@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.
Files changed (145) 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 +3 -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 +105 -91
  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 +5 -1
  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 +61 -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 +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  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 +20 -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 +0 -6
  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.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. 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 +1,160 @@
1
- import{createRevocationChecker as e}from"@enkaku/capability";import{getGraphStore as t}from"@kubun/store-graph";import{createP2PRevocationBackend as r,getP2PStore as o}from"@kubun/store-p2p";import{authorizeScope as s,checkSyncDelegation as n}from"./authorize.js";import{resolveCatalogSyncScopes as l}from"./catalog-scope.js";import{SyncAccessDeniedError as a}from"./errors.js";import{createMerkleSyncChannelHandler as i}from"./merkle-channel.js";import{negotiateDirection as c}from"./peer-registry.js";export function createSyncHandlers(n){let{db:u,graph:d,logger:f,peerRegistry:m}=n,p=n.storeUnreadable??"persist",g=null;async function w(){return null==g&&(g=e(r(await o(u)))),g}return{"sync/negotiate":async e=>{let r,{scopes:o,delegationTokens:n,catalogIDs:i,knownModelIDs:d,direction:p}=e.param,g=e.message.payload;if(null!=g.sub&&g.sub!==g.iss)throw new a("forged-subject","Sync access denied: signed payload subject does not match issuer");let D=g.iss;f.debug("sync/negotiate requested",{scopes:o,catalogIDs:i,viewerDID:D});let h=[],y=await t(u);if(null!=i&&i.length>0){let e=await l(u,i,d);if(e.modelIDs.length>0)if(null!=e.owners)for(let t of e.modelIDs)for(let r of e.owners)await s({viewerDID:D,ownerDID:r,delegationTokens:n,revocationChecker:await w()})&&h.push({modelID:t,ownerDID:r});else for(let t of e.modelIDs)for(let e of(await y.getDistinctOwnersForModel(t)))await s({viewerDID:D,ownerDID:e,delegationTokens:n,revocationChecker:await w()})&&h.push({modelID:t,ownerDID:e});e.missingClusterIDs.length>0&&(r=await y.getClusters(e.missingClusterIDs))}if(null!=o)for(let e of o)await s({viewerDID:D,ownerDID:e.ownerDID,delegationTokens:n,revocationChecker:await w()})?h.push(e):f.debug("sync/negotiate: scope rejected",{scope:e,viewerDID:D});let I=new Set,b=h.filter(e=>{let t=`${e.modelID}:${e.ownerDID}`;return!I.has(t)&&(I.add(t),!0)});f.info("sync/negotiate completed",{requested:(o?.length??0)+(i?.length??0),accepted:b.length,missingClusters:null!=r?Object.keys(r).length:0});let k=p??"pull",j=await m.getPeer({peerDID:D,stores:u}),C=j?.allowedDirection??"both",v=c(k,C);return v!==k&&f.info("sync/negotiate: direction downgraded",{requested:k,allowed:C,agreed:v}),{direction:v,acceptedScopes:b,excludedDocumentIDs:[],...null!=r&&{missingClusters:r}}},"sync/merkle-sync":i({db:u,graph:d,logger:f,peerRegistry:m,selfDID:n.selfDID,storeUnreadable:p,defaultAccessLevel:n.defaultAccessLevel,getRevocationChecker:w})}}export{n as checkSyncDelegation};
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 '@enkaku/token';
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
- 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;
9
13
  registry: GroupHandleRegistry;
10
14
  groupID: string;
11
15
  localDID: string;
@@ -1 +1,172 @@
1
- import{createEncryptedHubTunnelTransport as t}from"@enkaku/hub-tunnel";import{Server as e}from"@enkaku/server";import{MLSEncryptor as s}from"../groups/mls-encryptor.js";export class HubTunnelSyncListener{#t;#e;#s;#n;#i;#r;#o;#c;#a;#h;#u;#p;#l=!1;#d=!1;#y;#D;#m;constructor(t){this.#t=t.hubConnection,this.#e=t.registry,this.#s=t.groupID,this.#n=t.localDID,this.#i=t.peerDID,this.#r=t.identity,this.#o=t.syncHandlers,this.#c=t.getRandomID,this.#a=t.idleTimeoutMs,this.#h=t.reconnectTimeoutMs,this.#u=t.inboxCapacity,this.#p=t.onEvent}start(){this.#l||this.#d||(this.#l=!0,this.#m=new s({registry:this.#e,groupID:this.#s}),this.#D=this.#t.tunnelMessageStream({peerDID:this.#i}),this.#I())}async stop(){if(this.#d)return;this.#d=!0;let t=this.#y;if(this.#y=void 0,null!=t){try{await t.transport.dispose()}catch{}try{await t.server.dispose()}catch{}}let e=this.#D;if(this.#D=void 0,null!=e)try{e.return()}catch{}this.#m=void 0}#I(){let s;if(this.#d)return;let n=this.#m;if(null==n)throw Error("HubTunnelSyncListener: encryptor not initialized; call start() first");let i=t({hub:this.#b(),encryptor:n,groupID:this.#s,sessionID:{auto:!0},localDID:this.#n,peerDID:this.#i,idleTimeoutMs:this.#a,reconnectTimeoutMs:this.#h,inboxCapacity:this.#u,onEvent:this.#p,onSessionEnd:()=>{s?.dispose().catch(()=>{})}});s=i;let r=new e({getRandomID:this.#c,handlers:this.#o,identity:this.#r,accessRules:{"sync/*":{allow:!0}},transports:[i]}),o={transport:i,server:r};this.#y=o,i.events.on("disposed",()=>{this.#y===o&&(this.#y=void 0),r.dispose().catch(()=>{}),this.#I()})}#b(){let t=this.#t,e=this.#D;if(null==e)throw Error("HubTunnelSyncListener: stream not initialized; call start() first");return{send:async e=>t.send({recipients:e.recipients,payload:e.payload}),receive:t=>e,events:{subscribe:e=>t.events.on("lifecycle",t=>{let s=function(t){switch(t.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(t);void 0!==s&&e(s)})}}}}
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
+ }