@kubun/plugin-p2p 0.8.3 → 0.9.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 (78) hide show
  1. package/lib/context/group.js +1 -1
  2. package/lib/context/hub.d.ts +4 -0
  3. package/lib/context/hub.js +1 -0
  4. package/lib/context/join.js +1 -1
  5. package/lib/context/types.d.ts +12 -0
  6. package/lib/groups/broadcast-service.d.ts +71 -17
  7. package/lib/groups/broadcast-service.js +1 -1
  8. package/lib/groups/broadcast.d.ts +112 -5
  9. package/lib/groups/broadcast.js +1 -1
  10. package/lib/groups/events.d.ts +11 -0
  11. package/lib/groups/group-handle-registry.d.ts +90 -0
  12. package/lib/groups/group-handle-registry.js +1 -0
  13. package/lib/groups/invite-payload.d.ts +5 -1
  14. package/lib/groups/manager.d.ts +20 -14
  15. package/lib/groups/manager.js +1 -1
  16. package/lib/groups/mls-codec.d.ts +7 -0
  17. package/lib/groups/mls-codec.js +1 -0
  18. package/lib/groups/mls-encryptor.d.ts +25 -0
  19. package/lib/groups/mls-encryptor.js +1 -0
  20. package/lib/hub/connection-pool.d.ts +24 -0
  21. package/lib/hub/connection-pool.js +1 -1
  22. package/lib/hub/did-observing-transport.d.ts +64 -0
  23. package/lib/hub/did-observing-transport.js +1 -0
  24. package/lib/hub/errors.d.ts +28 -0
  25. package/lib/hub/errors.js +1 -0
  26. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  27. package/lib/hub/forward-remote-broadcast.js +1 -0
  28. package/lib/hub/group-channel.d.ts +29 -21
  29. package/lib/hub/group-channel.js +1 -1
  30. package/lib/hub/http-client.d.ts +17 -0
  31. package/lib/hub/http-client.js +1 -0
  32. package/lib/hub/hub-connection.d.ts +96 -0
  33. package/lib/hub/hub-connection.js +1 -0
  34. package/lib/hub/manager.d.ts +117 -0
  35. package/lib/hub/manager.js +1 -0
  36. package/lib/hub/receive-handler.d.ts +3 -6
  37. package/lib/hub/receive-handler.js +1 -1
  38. package/lib/hub/relay-manager.d.ts +85 -2
  39. package/lib/hub/relay-manager.js +1 -1
  40. package/lib/hub/send-handler.d.ts +12 -16
  41. package/lib/hub/send-handler.js +1 -1
  42. package/lib/hub/tunnel-inbox.d.ts +20 -0
  43. package/lib/hub/tunnel-inbox.js +1 -0
  44. package/lib/hub/wait-for-gate.d.ts +14 -0
  45. package/lib/hub/wait-for-gate.js +1 -0
  46. package/lib/hub/wiring.d.ts +73 -0
  47. package/lib/hub/wiring.js +1 -0
  48. package/lib/index.d.ts +102 -6
  49. package/lib/index.js +1 -1
  50. package/lib/schema.js +67 -19
  51. package/lib/sync/broadcast-queue.d.ts +59 -0
  52. package/lib/sync/broadcast-queue.js +1 -0
  53. package/lib/sync/broadcast-sender.d.ts +52 -0
  54. package/lib/sync/broadcast-sender.js +1 -0
  55. package/lib/sync/catalog-match.d.ts +13 -0
  56. package/lib/sync/catalog-match.js +1 -0
  57. package/lib/sync/forwarder.d.ts +96 -0
  58. package/lib/sync/forwarder.js +1 -0
  59. package/lib/sync/handlers.d.ts +15 -0
  60. package/lib/sync/handlers.js +1 -1
  61. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  62. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  63. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  64. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  65. package/lib/sync/merkle-apply.d.ts +41 -0
  66. package/lib/sync/merkle-apply.js +1 -1
  67. package/lib/sync/merkle-channel.d.ts +15 -0
  68. package/lib/sync/merkle-channel.js +1 -1
  69. package/lib/sync/receive-access-gate.d.ts +36 -0
  70. package/lib/sync/receive-access-gate.js +1 -0
  71. package/lib/sync/scope-resolver.d.ts +32 -0
  72. package/lib/sync/scope-resolver.js +1 -0
  73. package/lib/sync/sync-manager.d.ts +10 -0
  74. package/lib/sync/sync-manager.js +1 -1
  75. package/lib/types.d.ts +59 -0
  76. package/lib/util/mutex.d.ts +4 -0
  77. package/lib/util/mutex.js +1 -0
  78. package/package.json +40 -37
package/lib/index.js CHANGED
@@ -1 +1 @@
1
- import{ServerTransport as e}from"@enkaku/http-server-transport";import{Server as r}from"@enkaku/server";import{DirectTransports as t}from"@enkaku/transport";import{HLC as o}from"@kubun/hlc";import{p2pStoreDefinition as s}from"@kubun/store-p2p";import{createGroupContext as n}from"./context/group.js";import{createJoinContext as a}from"./context/join.js";import{createSyncContext as i}from"./context/sync.js";import{createGroupEventEmitter as p}from"./groups/events.js";import{GroupManager as c}from"./groups/manager.js";import{createP2PSchemaExtension as m}from"./schema.js";import{createSyncHandlers as g}from"./sync/handlers.js";import{SyncManager as d}from"./sync/sync-manager.js";export{processBroadcast}from"./groups/broadcast.js";export{deserializeBroadcast,serializeBroadcast}from"./groups/broadcast-codec.js";export{ApplyError,BroadcastService,DecryptError}from"./groups/broadcast-service.js";export{createFilteredGenerator,createGroupEventEmitter}from"./groups/events.js";export{decodeFullJoinRequest,decodeInvitePayload,decodeJoinRequest,encodeFullJoinRequest,encodeInvitePayload,encodeJoinRequest}from"./groups/invite-payload.js";export{GroupManager}from"./groups/manager.js";export{restoreMLSGroupHandle}from"./groups/mls-group-handle.js";export{replacer as mlsJSONReplacer,reviver as mlsJSONReviver}from"./groups/mls-json.js";export{deserializeMLSGroupState,serializeMLSGroupState}from"./groups/mls-state.js";export{createP2PSchemaExtension}from"./schema.js";export{resolveCatalogSyncScopes}from"./sync/catalog-scope.js";export{checkSyncDelegation,negotiateDirection}from"./sync/handlers.js";export{PeerRegistry}from"./sync/peer-registry.js";export{SyncClient}from"./sync/sync-client.js";export{SyncManager}from"./sync/sync-manager.js";export{HubRelayManager}from"./hub/relay-manager.js";export function createP2PPlugin(l){return u=>{let y,f;u.db.register(s);let j=u.db.adapter,h=u.identity,v=new o({nodeID:h.id}),x=new c({identity:h,getRandomID:u.runtime.getRandomID}),P=p(),S=new d({deployClusters:async e=>{let r=Object.values(e);await u.engine.deployGraph({clusters:r})},runtime:u.runtime,identity:u.identity,logger:u.getLogger("sync")}),b=g({db:u.db,logger:u.getLogger("sync-handlers"),peerRegistry:S.peerRegistry}),R=new Map,w=[],k={addPeer:e=>S.addPeer({config:e,stores:u.db}),removePeer:e=>S.removePeer({peerDID:e,stores:u.db}),listPeers:()=>S.listPeers({stores:u.db}),syncWithPeer:e=>S.merkleSyncWithPeer({...e,stores:u.db}),getStatus:e=>S.getStatus({peerDID:e,stores:u.db}),onSyncEvent:e=>S.onSyncEvent(e),createSyncTransport:function(e){let o=new t({signal:e}),s=new r({accessControl:!1,getRandomID:u.runtime.getRandomID,handlers:b,logger:u.getLogger("sync-server"),transports:[o.server],signal:e});return w.push(s),o.client}};if(l?.http){let t="string"==typeof l.http?l.http.replace(/^\//,""):"sync";k.syncReady=(async()=>{let o=await u.engine.getAPI("http");await o.listening,y=new e({allowedOrigin:["*"]}),f=new r({accessControl:!1,getRandomID:u.runtime.getRandomID,handlers:b,logger:u.getLogger("sync-http-server"),transports:[y]}),w.push(f),o.registerProtocol(t,y.fetch.bind(y))})()}return{name:"p2p",schemaExtension:e=>m(P),api:k,createContextFactory:()=>(e,r)=>{let t={identity:h,groupManager:x,syncManager:S,stores:r,adapter:j,hlc:v,emitter:P,pendingJoinRequests:R,runtime:u.runtime,autoAcceptPeers:l?.autoAcceptPeers};return{group:n(e,t),sync:i(e,t),join:a(e,t)}},dispose:async()=>{for(let e of w)await e.dispose();await y?.dispose(),await S.dispose()}}}}
1
+ import{ServerTransport as e}from"@enkaku/http-server-transport";import{Server as r}from"@enkaku/server";import{isFullIdentity as t}from"@enkaku/token";import{DirectTransports as o}from"@enkaku/transport";import{HLC as s}from"@kubun/hlc";import{p2pStoreDefinition as n}from"@kubun/store-p2p";import{createGroupContext as a}from"./context/group.js";import{createHubContext as i}from"./context/hub.js";import{createJoinContext as c}from"./context/join.js";import{createSyncContext as p}from"./context/sync.js";import{createGroupEventEmitter as d}from"./groups/events.js";import{GroupHandleRegistry as u}from"./groups/group-handle-registry.js";import{GroupManager as l}from"./groups/manager.js";import{forwardRemoteBroadcast as g}from"./hub/forward-remote-broadcast.js";import{createHTTPHubClient as m}from"./hub/http-client.js";import{captureServerDID as y}from"./hub/manager.js";import{DISABLED_HUB as b,setupHubRelay as f}from"./hub/wiring.js";import{createP2PSchemaExtension as h}from"./schema.js";import{createBroadcastQueue as j,DEFAULT_BROADCAST_BATCH_CONFIG as v}from"./sync/broadcast-queue.js";import{DEFAULT_PUSH_SYNC_CONFIG as x,wireBroadcastSender as S}from"./sync/broadcast-sender.js";import{createSyncHandlers as w}from"./sync/handlers.js";import{SyncManager as D}from"./sync/sync-manager.js";export{processBroadcast}from"./groups/broadcast.js";export{deserializeBroadcast,serializeBroadcast}from"./groups/broadcast-codec.js";export{ApplyError,BroadcastService,DecryptError}from"./groups/broadcast-service.js";export{createFilteredGenerator,createGroupEventEmitter}from"./groups/events.js";export{decodeFullJoinRequest,decodeInvitePayload,decodeJoinRequest,encodeFullJoinRequest,encodeInvitePayload,encodeJoinRequest}from"./groups/invite-payload.js";export{GroupManager}from"./groups/manager.js";export{MLSEncryptor}from"./groups/mls-encryptor.js";export{restoreMLSGroupHandle}from"./groups/mls-group-handle.js";export{replacer as mlsJSONReplacer,reviver as mlsJSONReviver}from"./groups/mls-json.js";export{deserializeMLSGroupState,serializeMLSGroupState}from"./groups/mls-state.js";export{createP2PSchemaExtension}from"./schema.js";export{createBroadcastQueue,DEFAULT_BROADCAST_BATCH_CONFIG}from"./sync/broadcast-queue.js";export{DEFAULT_PUSH_SYNC_CONFIG,wireBroadcastSender}from"./sync/broadcast-sender.js";export{catalogMatchesDoc}from"./sync/catalog-match.js";export{resolveCatalogSyncScopes}from"./sync/catalog-scope.js";export{evaluateAndForward}from"./sync/forwarder.js";export{checkSyncDelegation,negotiateDirection}from"./sync/handlers.js";export{HubTunnelSyncListener}from"./sync/hub-tunnel-sync-listener.js";export{HubTunnelSyncProvider}from"./sync/hub-tunnel-sync-provider.js";export{PeerRegistry}from"./sync/peer-registry.js";export{SyncClient}from"./sync/sync-client.js";export{SyncManager}from"./sync/sync-manager.js";export{DIDObservingTransport}from"./hub/did-observing-transport.js";export{ReconnectingError}from"./hub/errors.js";export{createHTTPHubClient}from"./hub/http-client.js";export{bindHubToGroup,captureServerDID,createHub,deleteHub,getHub,HubServerDIDConflictError,listGroupsByHub,listHubs,listHubsByGroup,unbindHubFromGroup,updateHub,upsertHub}from"./hub/manager.js";export{HubRelayManager}from"./hub/relay-manager.js";export{DISABLED_HUB,setupHubRelay}from"./hub/wiring.js";export const DEFAULT_RECEIVE_CONFIG={storeUnreadable:"persist"};export function createP2PPlugin(P){return I=>{var R;let E,H,L,B;I.db.register(n);let C=I.db.adapter;if(!(t(R=I.identity)&&"privateKey"in R))throw Error("createP2PPlugin requires an OwnIdentity (full identity with privateKey) — received a narrower Identity");let G=I.identity,A=new s({nodeID:G.id}),F={...DEFAULT_RECEIVE_CONFIG,...P?.receive},k=P?.defaultAccessLevel??{read:"only_owner",write:"only_owner"},T=new u({stores:I.db,deviceID:G.id,logger:I.getLogger("group-handle-registry")}),U=new l({identity:G,getRandomID:I.runtime.getRandomID,registry:T}),_=d(),O=new D({deployClusters:async e=>{let r=Object.values(e);await I.engine.deployGraph({clusters:r})},graph:I.graph,runtime:I.runtime,identity:I.identity,logger:I.getLogger("sync"),storeUnreadable:F.storeUnreadable,defaultAccessLevel:k}),q=w({db:I.db,graph:I.graph,logger:I.getLogger("sync-handlers"),peerRegistry:O.peerRegistry,selfDID:G.id,storeUnreadable:F.storeUnreadable,defaultAccessLevel:k}),M=new Map,N=[],J=I.getLogger("hub-relay"),z=b,K={...x,...P?.pushSync};if(P?.hub){let e="object"==typeof P.hub&&null!=P.hub.createHubClient?P.hub.createHubClient:m({identity:G,fetch:I.runtime.fetch});z=f({identity:G,db:I.db,graph:I.graph,emitter:_,createHubClient:e,registry:T,onRemoteBroadcast:e=>{g(_,e,J).catch(e=>{J.error("forwardRemoteBroadcast failed",{error:e})})},logger:J,storeUnreadable:F.storeUnreadable,defaultAccessLevel:k,forwarding:P.forwarding,onServerDIDObserved:({hubURL:e,serverDID:r})=>y({stores:I.db,hubURL:e,serverDID:r}).then(()=>void 0)}),K.enabled&&(H=j({config:{...v,...P.batch},scheduleBroadcast:z.scheduleBroadcast,logger:I.getLogger("broadcast-queue")}),E=S({db:I.db,eventBus:I.eventBus,selfDID:G.id,queue:H,logger:I.getLogger("broadcast-sender")}))}let V={hubReady:z.ready,addPeer:e=>O.addPeer({config:e,stores:I.db}),removePeer:e=>O.removePeer({peerDID:e,stores:I.db}),listPeers:()=>O.listPeers({stores:I.db}),syncWithPeer:e=>O.merkleSyncWithPeer({...e,stores:I.db}),getStatus:e=>O.getStatus({peerDID:e,stores:I.db}),onSyncEvent:e=>O.onSyncEvent(e),createSyncTransport:function(e){let t=new o({signal:e}),s=new r({getRandomID:I.runtime.getRandomID,handlers:q,logger:I.getLogger("sync-server"),transports:[t.server],signal:e});return N.push(s),t.client}};if(P?.http){let t="string"==typeof P.http?P.http.replace(/^\//,""):"sync";V.syncReady=(async()=>{let o=await I.engine.getAPI("http");await o.listening,L=new e({allowedOrigin:["*"]}),B=new r({getRandomID:I.runtime.getRandomID,handlers:q,logger:I.getLogger("sync-http-server"),transports:[L]}),N.push(B),o.registerProtocol(t,L.fetch.bind(L))})()}return{name:"p2p",schemaExtension:e=>h(_),api:V,createContextFactory:()=>(e,r)=>{let t={identity:G,groupManager:U,registry:T,syncManager:O,stores:r,adapter:C,hlc:A,emitter:_,pendingJoinRequests:M,runtime:I.runtime,autoAcceptPeers:P?.autoAcceptPeers,scheduleBroadcast:z.scheduleBroadcast,logger:I.getLogger("context")};return{group:a(e,t),sync:p(e,t),join:c(e,t),hub:i(e,t)}},dispose:async()=>{if(null!=E)try{E()}catch{}if(null!=H)try{H.dispose()}catch{}let e=(async()=>{for(let e of N)await e.dispose();null!=L&&await L.dispose()})(),r=I.getLogger("dispose"),t=await Promise.allSettled([e,O.dispose(),z.dispose()]),o=["sync-http","sync-manager","hub"];t.forEach((e,t)=>{"rejected"===e.status&&r.error("dispose branch failed",{branch:o[t],error:e.reason})})}}}}
package/lib/schema.js CHANGED
@@ -3,15 +3,15 @@ type PeerGroup {
3
3
  id: ID!
4
4
  name: String!
5
5
  description: String
6
- createdBy: String!
7
- createdAt: String!
6
+ createdBy: DID!
7
+ createdAt: DateTimeISO!
8
8
  }
9
9
 
10
10
  type PeerGroupMember {
11
11
  groupID: ID!
12
- memberDID: String!
12
+ memberDID: DID!
13
13
  role: String!
14
- createdAt: String!
14
+ createdAt: DateTimeISO!
15
15
  }
16
16
 
17
17
  type PeerCircle {
@@ -19,28 +19,43 @@ type PeerCircle {
19
19
  groupID: ID!
20
20
  name: String!
21
21
  description: String
22
- createdAt: String!
22
+ createdAt: DateTimeISO!
23
23
  }
24
24
 
25
25
  type PeerCircleMember {
26
26
  circleID: ID!
27
- memberDID: String!
27
+ memberDID: DID!
28
28
  role: String!
29
- createdAt: String!
29
+ createdAt: DateTimeISO!
30
30
  }
31
31
 
32
32
  type PeerCatalog {
33
33
  id: ID!
34
- ownerDID: String!
34
+ ownerDID: DID!
35
35
  name: String!
36
36
  description: String
37
37
  filterCriteria: String!
38
- createdAt: String!
38
+ createdAt: DateTimeISO!
39
+ }
40
+
41
+ type PeerHub {
42
+ id: ID!
43
+ label: String
44
+ url: URL!
45
+ serverDID: DID
46
+ createdAt: DateTimeISO!
47
+ updatedAt: DateTimeISO
48
+ }
49
+
50
+ input SuggestedHubInput {
51
+ url: URL!
52
+ serverDID: DID
39
53
  }
40
54
 
41
55
  input CreateGroupInput {
42
56
  name: String!
43
57
  description: String
58
+ hubs: [SuggestedHubInput!]
44
59
  }
45
60
 
46
61
  type CreateGroupPayload {
@@ -110,13 +125,13 @@ type DeleteCirclePayload {
110
125
 
111
126
  type RemoveCircleMemberPayload {
112
127
  circleID: ID!
113
- memberDID: String!
128
+ memberDID: DID!
114
129
  }
115
130
 
116
131
  type SyncPeer {
117
132
  id: String!
118
- peerDID: String!
119
- endpoint: String!
133
+ peerDID: DID!
134
+ endpoint: URL!
120
135
  mode: String!
121
136
  priority: String!
122
137
  trustLevel: String!
@@ -140,35 +155,68 @@ type RemoveSyncPeerPayload {
140
155
  success: Boolean!
141
156
  }
142
157
 
158
+ input CreateHubInput {
159
+ url: URL!
160
+ label: String
161
+ serverDID: DID
162
+ }
163
+
164
+ input UpdateHubInput {
165
+ id: ID!
166
+ url: URL
167
+ label: String
168
+ serverDID: DID
169
+ }
170
+
171
+ type DeleteHubPayload {
172
+ success: Boolean!
173
+ boundGroupIDs: [ID!]!
174
+ }
175
+
143
176
  extend type Query {
144
177
  groups: [PeerGroup!]!
145
178
  group(id: ID!): PeerGroup
146
179
  groupMembers(groupID: ID!): [PeerGroupMember!]!
147
180
  circles(groupID: ID!): [PeerCircle!]!
181
+ hubs: [PeerHub!]!
182
+ hub(id: ID!): PeerHub
148
183
  syncPeers: [SyncPeer!]!
149
184
  syncStatus: SyncStatus!
150
185
  }
151
186
 
152
187
  extend type Mutation {
153
188
  createGroup(input: CreateGroupInput!): CreateGroupPayload!
154
- addGroupMember(groupID: ID!, memberDID: String!, role: String!): PeerGroupMember!
189
+ addGroupMember(groupID: ID!, memberDID: DID!, role: String!): PeerGroupMember!
155
190
  createCircle(groupID: ID!, name: String!, description: String): PeerCircle!
156
- addCircleMember(circleID: ID!, memberDID: String!, role: String!): PeerCircleMember!
191
+ addCircleMember(circleID: ID!, memberDID: DID!, role: String!): PeerCircleMember!
157
192
  createCatalog(name: String!, description: String, filterCriteria: String!): PeerCatalog!
158
193
  inviteToGroup(groupID: ID!, joinRequest: String!): InviteToGroupPayload!
159
194
  joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
160
195
  prepareJoinRequest: PrepareJoinRequestPayload!
161
196
  completeJoin(invitePayload: String!): CompleteJoinPayload!
162
197
  leaveGroup(groupID: ID!): LeaveGroupPayload!
163
- removeGroupMember(groupID: ID!, memberDID: String!): RemoveGroupMemberPayload!
198
+ removeGroupMember(groupID: ID!, memberDID: DID!): RemoveGroupMemberPayload!
164
199
  updateCatalog(input: UpdateCatalogInput!): PeerCatalog!
165
200
  updateGroup(input: UpdateGroupInput!): UpdateGroupPayload!
166
201
  updateCircle(input: UpdateCircleInput!): UpdateCirclePayload!
167
202
  deleteCatalog(catalogID: ID!): DeleteCatalogPayload!
168
203
  deleteCircle(circleID: ID!): DeleteCirclePayload!
169
- removeCircleMember(circleID: ID!, memberDID: String!): RemoveCircleMemberPayload!
170
- addSyncPeer(peerDID: String!, endpoint: String!, mode: String, priority: String, trustLevel: String): Boolean!
171
- removeSyncPeer(peerDID: String!): RemoveSyncPeerPayload!
204
+ removeCircleMember(circleID: ID!, memberDID: DID!): RemoveCircleMemberPayload!
205
+ addSyncPeer(peerDID: DID!, endpoint: URL!, mode: String, priority: String, trustLevel: String): Boolean!
206
+ removeSyncPeer(peerDID: DID!): RemoveSyncPeerPayload!
207
+ createHub(input: CreateHubInput!): PeerHub!
208
+ updateHub(input: UpdateHubInput!): PeerHub!
209
+ deleteHub(id: ID!, force: Boolean): DeleteHubPayload!
210
+ bindHubToGroup(hubID: ID!, groupID: ID!): Boolean!
211
+ unbindHubFromGroup(hubID: ID!, groupID: ID!): Boolean!
212
+ }
213
+
214
+ extend type PeerGroup {
215
+ hubs: [PeerHub!]!
216
+ }
217
+
218
+ extend type PeerHub {
219
+ groups: [PeerGroup!]!
172
220
  }
173
221
 
174
222
  extend type Subscription {
@@ -184,4 +232,4 @@ extend type Subscription {
184
232
  circleMemberRemoved(circleID: ID!): PeerCircleMember!
185
233
  circleCatalogsChanged(circleID: ID!): PeerCircle!
186
234
  }
187
- `;export function createP2PSchemaExtension(i){return{sdl:t,resolvers:{queryFields:{groups:async(e,t,i)=>await r(i).group.list(),group:async(e,t,i)=>await r(i).group.get(t.id),groupMembers:async(e,t,i)=>await r(i).group.listMembers(t.groupID),circles:async(e,t,i)=>await r(i).group.listCircles(t.groupID),syncPeers:async(e,t,i)=>await r(i).sync.listPeers(),syncStatus:(e,t,i)=>{let o=r(i).sync.getStatus();return{...o,lastSyncByPeer:JSON.stringify(o.lastSyncByPeer)}}},mutationFields:{createGroup:async(e,t,i)=>{let o=t.input;return{group:await r(i).group.create(o)}},addGroupMember:async(e,t,i)=>await r(i).group.addMember(t.groupID,t.memberDID,t.role),createCircle:async(e,t,i)=>await r(i).group.createCircle({groupID:t.groupID,name:t.name,description:t.description}),addCircleMember:async(e,t,i)=>await r(i).group.addCircleMember(t.circleID,t.memberDID,t.role),createCatalog:async(e,t,i)=>await r(i).group.createCatalog({name:t.name,description:t.description,filterCriteria:t.filterCriteria}),inviteToGroup:async(e,t,i)=>await r(i).group.invite(t.groupID,t.joinRequest),joinGroup:async(e,t,i)=>await r(i).group.join(t.invitePayload,t.joinRequestPayload),prepareJoinRequest:async(e,t,i)=>await r(i).join.prepareRequest(),completeJoin:async(e,t,i)=>await r(i).join.complete(t.invitePayload),leaveGroup:async(e,t,i)=>await r(i).group.leave(t.groupID),removeGroupMember:async(e,t,i)=>await r(i).group.removeMember(t.groupID,t.memberDID),updateCatalog:async(e,t,i)=>{let o=t.input;return await r(i).group.updateCatalog(o)},updateGroup:async(e,t,i)=>{let o=t.input;return{group:await r(i).group.update(o)}},updateCircle:async(e,t,i)=>{let o=t.input;return{circle:await r(i).group.updateCircle(o)}},deleteCatalog:async(e,t,i)=>await r(i).group.deleteCatalog(t.catalogID),deleteCircle:async(e,t,i)=>await r(i).group.deleteCircle(t.circleID),removeCircleMember:async(e,t,i)=>await r(i).group.removeCircleMember(t.circleID,t.memberDID),addSyncPeer:async(e,t,i)=>(await r(i).sync.addPeer({peerDID:t.peerDID,endpoint:t.endpoint,mode:t.mode??"on-demand",allowedUsers:{all:!0},priority:t.priority??"0",trustLevel:t.trustLevel??"trusted"}),!0),removeSyncPeer:async(e,t,i)=>(await r(i).sync.removePeer(t.peerDID),{success:!0})},subscriptionFields:{groupJoined:{resolve:e=>e,subscribe:()=>e(i,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(i,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(i,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(i,"circleCatalogsChanged",e=>e.circleID===t.circleID)}}}}}
235
+ `;export function createP2PSchemaExtension(a){return{sdl:t,resolvers:{queryFields:{groups:async(e,t,a)=>await r(a).group.list(),group:async(e,t,a)=>await r(a).group.get(t.id),groupMembers:async(e,t,a)=>await r(a).group.listMembers(t.groupID),circles:async(e,t,a)=>await r(a).group.listCircles(t.groupID),hubs:async(e,t,a)=>await r(a).hub.list(),hub:async(e,t,a)=>await r(a).hub.get(t.id),syncPeers:async(e,t,a)=>await r(a).sync.listPeers(),syncStatus:(e,t,a)=>{let o=r(a).sync.getStatus();return{...o,lastSyncByPeer:JSON.stringify(o.lastSyncByPeer)}}},mutationFields:{createGroup:async(e,t,a)=>{let o=t.input;return{group:await r(a).group.create(o)}},addGroupMember:async(e,t,a)=>await r(a).group.addMember(t.groupID,t.memberDID,t.role),createCircle:async(e,t,a)=>await r(a).group.createCircle({groupID:t.groupID,name:t.name,description:t.description}),addCircleMember:async(e,t,a)=>await r(a).group.addCircleMember(t.circleID,t.memberDID,t.role),createCatalog:async(e,t,a)=>await r(a).group.createCatalog({name:t.name,description:t.description,filterCriteria:t.filterCriteria}),inviteToGroup:async(e,t,a)=>await r(a).group.invite(t.groupID,t.joinRequest),joinGroup:async(e,t,a)=>await r(a).group.join(t.invitePayload,t.joinRequestPayload),prepareJoinRequest:async(e,t,a)=>await r(a).join.prepareRequest(),completeJoin:async(e,t,a)=>await r(a).join.complete(t.invitePayload),leaveGroup:async(e,t,a)=>await r(a).group.leave(t.groupID),removeGroupMember:async(e,t,a)=>await r(a).group.removeMember(t.groupID,t.memberDID),updateCatalog:async(e,t,a)=>{let o=t.input;return await r(a).group.updateCatalog(o)},updateGroup:async(e,t,a)=>{let o=t.input;return{group:await r(a).group.update(o)}},updateCircle:async(e,t,a)=>{let o=t.input;return{circle:await r(a).group.updateCircle(o)}},deleteCatalog:async(e,t,a)=>await r(a).group.deleteCatalog(t.catalogID),deleteCircle:async(e,t,a)=>await r(a).group.deleteCircle(t.circleID),removeCircleMember:async(e,t,a)=>await r(a).group.removeCircleMember(t.circleID,t.memberDID),addSyncPeer:async(e,t,a)=>(await r(a).sync.addPeer({peerDID:t.peerDID,endpoint:t.endpoint,mode:t.mode??"on-demand",allowedUsers:{all:!0},priority:t.priority??"0",trustLevel:t.trustLevel??"trusted"}),!0),removeSyncPeer:async(e,t,a)=>(await r(a).sync.removePeer(t.peerDID),{success:!0}),createHub:async(e,t,a)=>{let o=t.input;return await r(a).hub.create(o)},updateHub:async(e,t,a)=>{let o=t.input;return await r(a).hub.update(o)},deleteHub:async(e,t,a)=>await r(a).hub.delete(t.id,t.force??!1),bindHubToGroup:async(e,t,a)=>await r(a).hub.bind({hubID:t.hubID,groupID:t.groupID}),unbindHubFromGroup:async(e,t,a)=>await r(a).hub.unbind({hubID:t.hubID,groupID:t.groupID})},typeFields:{PeerGroup:{hubs:async(e,t,a)=>await r(a).hub.listByGroup(e.id)},PeerHub:{groups:async(e,t,a)=>await r(a).hub.listGroups(e.id)}},subscriptionFields:{groupJoined:{resolve:e=>e,subscribe:()=>e(a,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(a,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(a,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(a,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(a,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(a,"circleCatalogsChanged",e=>e.circleID===t.circleID)}}}}}
@@ -0,0 +1,59 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
3
+ /**
4
+ * Configuration for the per-target-group batch broadcast queue.
5
+ *
6
+ * Each queue accumulates {@link MutationApplyEntry} items destined for the
7
+ * same MLS group, then flushes them as a single `mutation:apply` broadcast
8
+ * when ANY threshold trips:
9
+ * - `windowMs` elapses since the first entry of the current batch
10
+ * - the queue accumulates `maxCount` entries
11
+ * - the accumulated byte size hits `maxBytes`
12
+ *
13
+ * When `enabled` is false, the queue degrades to pass-through: each enqueue
14
+ * fires `scheduleBroadcast` immediately as a single-entry batch.
15
+ */
16
+ export type BroadcastBatchConfig = {
17
+ enabled: boolean;
18
+ windowMs: number;
19
+ maxCount: number;
20
+ maxBytes: number;
21
+ };
22
+ export declare const DEFAULT_BROADCAST_BATCH_CONFIG: BroadcastBatchConfig;
23
+ export type BroadcastQueueParams = {
24
+ config: BroadcastBatchConfig;
25
+ scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
26
+ logger?: Logger;
27
+ };
28
+ export type BroadcastQueue = {
29
+ /**
30
+ * Enqueue a single mutation entry for the given target group, attributed to
31
+ * `senderPeerDID`. May trigger an immediate flush if a threshold is reached.
32
+ */
33
+ enqueue(targetGroupID: string, entry: MutationApplyEntry, senderPeerDID: string): void;
34
+ /**
35
+ * Cancel all pending timers and synchronously flush remaining entries
36
+ * via `scheduleBroadcast`. Safe to call multiple times.
37
+ */
38
+ dispose(): void;
39
+ };
40
+ /**
41
+ * Build a per-target-group batch queue around a `scheduleBroadcast` hook.
42
+ *
43
+ * The `mutation:apply` broadcast variant carries `entries: Array<...>` —
44
+ * a queue flush emits ONE broadcast with the accumulated entries, not one
45
+ * broadcast per entry.
46
+ *
47
+ * Each enqueued entry's wire size is approximated via `JSON.stringify(entry).length`.
48
+ * The accumulated byte budget tracks per-batch payload size only — message
49
+ * envelope (`type`, `senderPeerDID`) overhead is intentionally ignored to keep
50
+ * accounting cheap and predictable.
51
+ *
52
+ * Sender-DID consistency: the queue tracks the `senderPeerDID` of the first
53
+ * entry in each batch. If a later enqueue arrives with a different sender
54
+ * (defensive — in practice senderPeerDID is constant per peer), the prior
55
+ * batch is flushed and a fresh batch starts. This preserves the invariant
56
+ * that every flushed `mutation:apply` message carries a single coherent
57
+ * `senderPeerDID`.
58
+ */
59
+ export declare function createBroadcastQueue(params: BroadcastQueueParams): BroadcastQueue;
@@ -0,0 +1 @@
1
+ export const DEFAULT_BROADCAST_BATCH_CONFIG={enabled:!0,windowMs:500,maxCount:20,maxBytes:65536};export function createBroadcastQueue(e){let{config:t,scheduleBroadcast:r,logger:n}=e,l=new Map,o=!1;function u(e){let t=l.get(e);if(null==t||0===t.entries.length)return;null!=t.timer&&(clearTimeout(t.timer),t.timer=null);let o=t.entries,u=t.senderPeerDID;l.delete(e);try{r(e,{type:"mutation:apply",entries:o,senderPeerDID:u})}catch(t){n?.warn("broadcast-queue flush failed",{targetGroupID:e,entryCount:o.length,error:String(t)})}}return{enqueue:function(e,i,a){if(o)return void n?.warn("broadcast-queue enqueue after dispose, dropping entry",{targetGroupID:e,docID:i.docID});if(!t.enabled){try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue pass-through scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let s=JSON.stringify(i).length;if(s>=t.maxBytes){let t=l.get(e);null!=t&&t.entries.length>0&&u(e);try{r(e,{type:"mutation:apply",entries:[i],senderPeerDID:a})}catch(t){n?.warn("broadcast-queue oversize-entry scheduleBroadcast failed",{targetGroupID:e,docID:i.docID,error:String(t)})}return}let d=l.get(e);if(null!=d&&d.senderPeerDID!==a&&(u(e),d=void 0),null!=d&&d.bytes+s>t.maxBytes&&(u(e),d=void 0),null==d){l.set(e,{entries:[i],bytes:s,senderPeerDID:a,timer:null});let r=l.get(e);return null!=r&&r.entries.length>=t.maxCount?void u(e):void!function(e){let r=l.get(e);if(null==r||null!=r.timer)return;let n=setTimeout(()=>{u(e)},t.windowMs),o=n.unref;"function"==typeof o&&o.call(n),r.timer=n}(e)}d.entries.push(i),d.bytes+=s,d.entries.length>=t.maxCount&&u(e)},dispose:function(){if(!o)for(let e of(o=!0,Array.from(l.keys())))u(e)}}}
@@ -0,0 +1,52 @@
1
+ import type { KubunDB } from '@kubun/db';
2
+ import { type DefaultAccessLevel, type EngineEventBus, type EngineEvents } from '@kubun/engine';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { BroadcastQueue } from './broadcast-queue.js';
5
+ /**
6
+ * Push-sync runtime config. Gates the local `engine:mutation:authored`
7
+ * subscriber that drives `mutation:apply` broadcasts. When disabled, no
8
+ * `mutation:apply` broadcasts leave this peer; merkle-pull continues to
9
+ * operate; the receive path is unaffected.
10
+ */
11
+ export type PushSyncConfig = {
12
+ enabled: boolean;
13
+ };
14
+ /**
15
+ * Default push-sync config. Disabled by default — push-sync is opt-in even
16
+ * when a hub is configured. Apps that want immediate broadcast on local
17
+ * authoring must explicitly set `pushSync: { enabled: true }`.
18
+ */
19
+ export declare const DEFAULT_PUSH_SYNC_CONFIG: PushSyncConfig;
20
+ export type BroadcastSenderParams = {
21
+ db: KubunDB;
22
+ eventBus: EngineEventBus<EngineEvents>;
23
+ selfDID: string;
24
+ /**
25
+ * Per-target-group batch queue. The sender enqueues per-scope entries here
26
+ * rather than calling `scheduleBroadcast` directly — the queue accumulates
27
+ * entries and flushes them as a single `mutation:apply` broadcast per
28
+ * target group when window/count/byte thresholds trip.
29
+ */
30
+ queue: BroadcastQueue;
31
+ logger?: Logger;
32
+ /**
33
+ * Server default access level. Used when neither the document nor any user
34
+ * model default resolves a rule. Defaults to `only_owner` for both
35
+ * permissions (no fan-out for undeclared models).
36
+ */
37
+ defaultAccessLevel?: DefaultAccessLevel;
38
+ };
39
+ /**
40
+ * Subscribe to `engine:mutation:authored` and, for each locally-authored
41
+ * mutation, compute the set of MLS group scopes to broadcast to (via the
42
+ * pure `computeBroadcastScopes`) and fire per-scope
43
+ * `mutation:apply` broadcasts through the provided `scheduleBroadcast` hook.
44
+ *
45
+ * The subscriber swallows errors — a failure here must not propagate into the
46
+ * engine's event loop and stall other listeners. Errors are logged via the
47
+ * optional logger.
48
+ *
49
+ * Returns an unsubscribe function — the caller is responsible for invoking it
50
+ * on plugin dispose.
51
+ */
52
+ export declare function wireBroadcastSender(params: BroadcastSenderParams): () => void;
@@ -0,0 +1 @@
1
+ import{resolveAccessRule as e}from"@kubun/engine";import{DocumentID as r}from"@kubun/id";import{getGraphStore as o}from"@kubun/store-graph";import{getP2PStore as t}from"@kubun/store-p2p";import{catalogMatchesDoc as l}from"./catalog-match.js";import{computeBroadcastScopes as a}from"./scope-resolver.js";export const DEFAULT_PUSH_SYNC_CONFIG={enabled:!1};let n={read:"only_owner",write:"only_owner"};export function wireBroadcastSender(e){let r=e.defaultAccessLevel??n;return e.eventBus.on("engine:mutation:authored",async o=>{try{await i(o,{...e,defaultAccessLevel:r})}catch(r){e.logger?.warn("broadcast-sender handler failed",{documentID:o.documentID,error:String(r)})}})}async function i(n,i){let s=await o(i.db),u=await t(i.db),d=await s.getDocument(r.fromString(n.documentID));if(null==d)return;let f=await e(d,d.model,d.owner,"read",{getUserModelAccessDefault:(e,r,o)=>s.getUserModelAccessDefault(e,r,o),isMemberOfAnyCircle:(e,r)=>u.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>u.isMemberOfAnyGroup(e,r)},i.defaultAccessLevel);if("only_owner"===f.level)return;let c=await u.getCirclesForMember(i.selfDID),m=new Set;for(let e of c)for(let r of e.catalog_ids??[])m.add(r);let w=m.size>0?await s.getCatalogs(Array.from(m)):new Map,g=[];for(let e of c)for(let r of e.catalog_ids??[]){let o=w.get(r);null!=o&&g.push({groupID:e.group_id,criteria:o.filter_criteria})}let D=await Promise.all(g.map(async e=>({groupID:e.groupID,matched:await l(e.criteria,d,u)}))),p=new Set;for(let e of D)e.matched&&p.add(e.groupID);let I=Array.from(p);if(0===I.length)return;let b=new Set(f.allowedGroups??[]),v=new Set;if(null!=f.allowedCircles&&f.allowedCircles.length>0)for(let e of(await u.getCircles(f.allowedCircles)).values())null!=e.group_id&&v.add(e.group_id);let y=new Set;if(null!=f.allowedDIDs&&f.allowedDIDs.length>0)for(let e of(await u.getGroupsForMembers(f.allowedDIDs)).values())for(let r of e)y.add(r.id);let h=a({resolvedRule:{level:f.level,allowedGroupIDs:b,allowedCircleParentGroupIDs:v,allowedDIDGroupIDs:y},senderGroupIDs:I});if(0!==h.length)for(let e of h){if(!await u.recordBroadcast({docID:n.documentID,version:n.version,mlsGroupID:e}))continue;let r={mutationJWT:n.mutationJWT,docID:n.documentID,version:n.version,modelID:n.modelID};i.queue.enqueue(e,r,i.selfDID)}}
@@ -0,0 +1,13 @@
1
+ import type { CatalogFilterCriteria, DocumentNode } from '@kubun/protocol';
2
+ import type { P2PStoreAPI } from '@kubun/store-p2p';
3
+ /**
4
+ * Determine whether a document matches a catalog's filter criteria.
5
+ *
6
+ * Mirrors the semantics of {@link resolveCatalogSyncScopes} (which collects
7
+ * broadly-applicable models + owner filters for sync): each catalog field is
8
+ * AND'd, values within a field are OR'd, and the `circles` field expands to a
9
+ * union of circle members and merges with the explicit `owners` filter.
10
+ *
11
+ * Returns `true` iff the document would be included by this catalog.
12
+ */
13
+ export declare function catalogMatchesDoc(criteria: CatalogFilterCriteria, doc: Pick<DocumentNode, 'model' | 'owner'>, p2pStore: P2PStoreAPI): Promise<boolean>;
@@ -0,0 +1 @@
1
+ export async function catalogMatchesDoc(e,l,n){if(null!=e.models&&e.models.length>0&&!e.models.includes(l.model))return!1;let c=null!=e.owners&&e.owners.length>0,r=null!=e.circles&&e.circles.length>0;return!!(!c&&!r||c&&e.owners?.includes(l.owner)||r&&null!=e.circles&&await n.isMemberOfAnyCircle(l.owner,e.circles))}
@@ -0,0 +1,96 @@
1
+ import { type DefaultAccessLevel } from '@kubun/engine';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { DocumentNode } from '@kubun/protocol';
4
+ import type { GraphStoreAPI } from '@kubun/store-graph';
5
+ import type { P2PStoreAPI } from '@kubun/store-p2p';
6
+ import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
7
+ /**
8
+ * Context passed to a {@link ForwardFilter} for each candidate forwarding scope.
9
+ *
10
+ * The filter is invoked once per surviving (catalog ∧ access ∧ not-loopback)
11
+ * candidate group. The filter's role is to NARROW — gates are applied first,
12
+ * the filter only ever further reduces the forwarded set. Returning `false`
13
+ * skips the candidate; returning `true` (or no filter at all) forwards it.
14
+ */
15
+ export type ForwardContext = {
16
+ docID: string;
17
+ modelID: string;
18
+ ownerDID: string;
19
+ /** MLS group the broadcast was received from. Excluded from forwarding. */
20
+ sourceGroupID: string;
21
+ /** Candidate MLS group ID to forward to. */
22
+ candidateGroupID: string;
23
+ /** Post-apply document state. */
24
+ postState: DocumentNode;
25
+ };
26
+ /**
27
+ * Per-candidate filter callback. May be sync or async.
28
+ *
29
+ * Awaited per candidate — slow filters serialize forwarding within a single
30
+ * broadcast batch. Filters MUST NOT throw; throwing is treated as `false`
31
+ * (skip) and logged.
32
+ */
33
+ export type ForwardFilter = (ctx: ForwardContext) => boolean | Promise<boolean>;
34
+ /**
35
+ * Forwarding configuration.
36
+ * - `false` (default) — forwarding disabled. Hot-path has no extra DB
37
+ * lookups and no extra awaits in the receive path.
38
+ * - `true` — forward to every candidate group that passes catalog + access
39
+ * gates and is not the source group.
40
+ * - {@link ForwardFilter} — same as `true`, plus per-candidate narrowing.
41
+ */
42
+ export type ForwardingConfig = boolean | ForwardFilter;
43
+ export type EvaluateAndForwardParams = {
44
+ /** Original broadcast entry being considered for forwarding. */
45
+ entry: MutationApplyEntry;
46
+ /** Post-apply document state — required for catalog match + filter context. */
47
+ postState: DocumentNode;
48
+ /** MLS group the broadcast was received from — never a forwarding target. */
49
+ sourceGroupID: string;
50
+ /** Bridging peer's own DID — used as `senderPeerDID` on forwarded broadcasts. */
51
+ selfDID: string;
52
+ p2pStore: P2PStoreAPI;
53
+ graphStore: GraphStoreAPI;
54
+ defaultAccessLevel: DefaultAccessLevel;
55
+ /** See {@link ForwardingConfig}. `false` short-circuits before any work. */
56
+ forwarding: ForwardingConfig;
57
+ /**
58
+ * Hub forwarding hook. Same shape as `HubWiring.scheduleBroadcast`. Called
59
+ * once per surviving candidate with a single-entry `mutation:apply`
60
+ * broadcast. Bypasses the batch queue intentionally — see
61
+ * {@link evaluateAndForward} for rationale.
62
+ */
63
+ scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
64
+ logger?: Logger;
65
+ };
66
+ export type EvaluateAndForwardResult = {
67
+ /** Number of candidate groups the entry was forwarded to. */
68
+ forwarded: number;
69
+ /** Number of candidate groups skipped (filter returned false, catalog miss, dedup). */
70
+ skipped: number;
71
+ };
72
+ /**
73
+ * Evaluate forwarding candidates for a single mutation:apply entry the local
74
+ * peer has just applied, and forward to each surviving candidate group via
75
+ * `scheduleBroadcast`.
76
+ *
77
+ * Pipeline (per candidate group):
78
+ * 1. Source group exclusion — `sourceGroupID` is filtered out before any check.
79
+ * 2. Access gate — `computeBroadcastScopes` against the post-apply rule.
80
+ * 3. Catalog gate — at least one circle the local peer belongs to in the
81
+ * candidate group must have a catalog whose `filter_criteria` matches the
82
+ * document.
83
+ * 4. Filter (when `forwarding` is a function) — narrows further.
84
+ * 5. Dedup — `recordBroadcast` BEFORE schedule. False return → skip silently.
85
+ * 6. Schedule — single-entry `mutation:apply` broadcast with
86
+ * `senderPeerDID = selfDID`.
87
+ *
88
+ * Per-candidate errors are isolated: one bad candidate logs + continues; the
89
+ * rest still get evaluated. The function never throws.
90
+ *
91
+ * Routing decision: each candidate gets its OWN single-entry broadcast (no
92
+ * batch queue). The queue's `senderPeerDID`-mismatch flush conflicts
93
+ * with mixing locally-authored and forwarded entries in one batch — deferring
94
+ * batched-forwarding keeps the receive path simple.
95
+ */
96
+ export declare function evaluateAndForward(params: EvaluateAndForwardParams): Promise<EvaluateAndForwardResult>;
@@ -0,0 +1 @@
1
+ import{resolveAccessRule as e}from"@kubun/engine";import{catalogMatchesDoc as r}from"./catalog-match.js";import{computeBroadcastScopes as o}from"./scope-resolver.js";export async function evaluateAndForward(t){let l,a;if(!1===t.forwarding)return{forwarded:0,skipped:0};let i=!0===t.forwarding?()=>!0:t.forwarding,s=(await t.p2pStore.getGroupsForMember(t.selfDID)).map(e=>e.id).filter(e=>e!==t.sourceGroupID);if(0===s.length)return{forwarded:0,skipped:0};try{l=await e(t.postState,t.postState.model,t.postState.owner,"read",{getUserModelAccessDefault:(e,r,o)=>t.graphStore.getUserModelAccessDefault(e,r,o),isMemberOfAnyCircle:(e,r)=>t.p2pStore.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>t.p2pStore.isMemberOfAnyGroup(e,r)},t.defaultAccessLevel)}catch(e){return t.logger?.warn("forwarder: resolveAccessRule failed, skipping forward",{docID:t.entry.docID,version:t.entry.version,sourceGroupID:t.sourceGroupID,error:String(e)}),{forwarded:0,skipped:0}}let n=new Set(l.allowedGroups??[]),d=new Set;if(null!=l.allowedCircles&&l.allowedCircles.length>0)try{for(let e of(await t.p2pStore.getCircles(l.allowedCircles)).values())null!=e.group_id&&d.add(e.group_id)}catch(e){t.logger?.warn("forwarder: getCircles failed, skipping allowedCircles",{count:l.allowedCircles.length,error:String(e)})}let p=new Set;if(null!=l.allowedDIDs&&l.allowedDIDs.length>0)try{for(let e of(await t.p2pStore.getGroupsForMembers(l.allowedDIDs)).values())for(let r of e)p.add(r.id)}catch(e){t.logger?.warn("forwarder: getGroupsForMembers failed, skipping allowedDIDs",{count:l.allowedDIDs.length,error:String(e)})}let f=o({resolvedRule:{level:l.level,allowedGroupIDs:n,allowedCircleParentGroupIDs:d,allowedDIDGroupIDs:p},senderGroupIDs:s});if(0===f.length)return{forwarded:0,skipped:0};try{a=await t.p2pStore.getCirclesForMember(t.selfDID)}catch(e){return t.logger?.warn("forwarder: getCirclesForMember failed",{selfDID:t.selfDID,error:String(e)}),{forwarded:0,skipped:0}}let c=new Set(f),u=new Map;for(let e of a){if(!c.has(e.group_id))continue;let r=u.get(e.group_id);null!=r?r.push(e):u.set(e.group_id,[e])}let g=new Set;for(let e of u.values())for(let r of e)for(let e of r.catalog_ids??[])g.add(e);let w=new Map;if(g.size>0)try{w=await t.graphStore.getCatalogs(Array.from(g))}catch(e){t.logger?.warn("forwarder: getCatalogs failed, treating all as missing",{count:g.size,error:String(e)})}let D=0,I=0;for(let e of f)try{let o=u.get(e)??[],l=!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)){l=!0;break}}if(l)break}if(!l){I++;continue}let a={docID:t.entry.docID,modelID:t.entry.modelID,ownerDID:t.postState.owner,sourceGroupID:t.sourceGroupID,candidateGroupID:e,postState:t.postState};if(!await i(a)){I++;continue}if(!await t.p2pStore.recordBroadcast({docID:t.entry.docID,version:t.entry.version,mlsGroupID:e}))continue;let s={type:"mutation:apply",entries:[t.entry],senderPeerDID:t.selfDID};await t.scheduleBroadcast(e,s),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,6 +1,8 @@
1
1
  import type { KubunDB } from '@kubun/db';
2
+ import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
2
3
  import type { Logger } from '@kubun/logger';
3
4
  import type { SyncDirection } from '../protocol.js';
5
+ import type { StoreUnreadableMode } from '../types.js';
4
6
  import type { PeerRegistry } from './peer-registry.js';
5
7
  /**
6
8
  * Check if delegation tokens grant the viewer read access to a user's documents.
@@ -13,8 +15,21 @@ import type { PeerRegistry } from './peer-registry.js';
13
15
  export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>): Promise<boolean>;
14
16
  export type CreateSyncHandlersParams = {
15
17
  db: KubunDB;
18
+ graph: GraphInternals;
16
19
  logger: Logger;
17
20
  peerRegistry: PeerRegistry;
21
+ /**
22
+ * Local peer's DID — required when `storeUnreadable === 'drop'` so the
23
+ * receive-time access gate can use it as viewer.
24
+ */
25
+ selfDID?: string;
26
+ /**
27
+ * Receive-side storage mode forwarded to `applySyncMutations`.
28
+ * Defaults to `'persist'` when omitted.
29
+ */
30
+ storeUnreadable?: StoreUnreadableMode;
31
+ /** Server default access level — required when `storeUnreadable === 'drop'`. */
32
+ defaultAccessLevel?: DefaultAccessLevel;
18
33
  };
19
34
  /**
20
35
  * Resolve the effective sync direction given what the initiator requested
@@ -1 +1 @@
1
- import{checkCapability as e}from"@enkaku/capability";import{getGraphStore as t}from"@kubun/store-graph";import{resolveCatalogSyncScopes as n}from"./catalog-scope.js";import{createMerkleSyncChannelHandler as o}from"./merkle-channel.js";export async function checkSyncDelegation(t,n,o){if(!o||0===o.length)return!1;let r=["*","urn:kubun:user:*",`urn:kubun:user:${n}`],l="document/read";for(let a of r)try{return await e({act:l,res:a},{iss:t,sub:n,cap:o}),!0}catch{}for(let a of o)for(let o of r)try{return await e({act:l,res:o},{iss:t,sub:n,cap:a}),!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:r,logger:l,peerRegistry:a}=e;return{"sync/negotiate":async e=>{let o,{scopes:s,delegationTokens:i,catalogIDs:c,knownModelIDs:u,direction:g}=e.param,f=e.message.payload,d=f.sub||f.iss;l.debug("sync/negotiate requested",{scopes:s,catalogIDs:c,viewerDID:d});let p=[],D=await t(r);if(null!=c&&c.length>0){let e=await n(r,c,u);if(e.modelIDs.length>0)if(null!=e.owners)for(let t of e.modelIDs)for(let n of e.owners){let e=d===n,o=!e&&await checkSyncDelegation(d,n,i);(e||o)&&p.push({modelID:t,ownerDID:n})}else for(let t of e.modelIDs)for(let e of(await D.getDistinctOwnersForModel(t))){let n=d===e,o=!n&&await checkSyncDelegation(d,e,i);(n||o)&&p.push({modelID:t,ownerDID:e})}e.missingClusterIDs.length>0&&(o=await D.getClusters(e.missingClusterIDs))}if(null!=s)for(let e of s){let t=d===e.ownerDID,n=!t&&await checkSyncDelegation(d,e.ownerDID,i);t||n?p.push(e):l.debug("sync/negotiate: scope rejected",{scope:e,viewerDID:d})}let h=new Set,m=p.filter(e=>{let t=`${e.modelID}:${e.ownerDID}`;return!h.has(t)&&(h.add(t),!0)});l.info("sync/negotiate completed",{requested:(s?.length??0)+(c?.length??0),accepted:m.length,missingClusters:null!=o?Object.keys(o).length:0});let w=g??"pull",y=await a.getPeer({peerDID:d,stores:r}),b=y?.allowedDirection??"both",k=negotiateDirection(w,b);return k!==w&&l.info("sync/negotiate: direction downgraded",{requested:w,allowed:b,agreed:k}),{direction:k,acceptedScopes:m,excludedDocumentIDs:[],...null!=o&&{missingClusters:o}}},"sync/merkle-sync":o({db:r,logger:l})}}
1
+ import{checkCapability as e}from"@enkaku/capability";import{getGraphStore as t}from"@kubun/store-graph";import{resolveCatalogSyncScopes as n}from"./catalog-scope.js";import{createMerkleSyncChannelHandler as r}from"./merkle-channel.js";export async function checkSyncDelegation(t,n,r){if(!r||0===r.length)return!1;let o=["*","urn:kubun:user:*",`urn:kubun:user:${n}`],l="document/read";for(let s of o)try{return await e({act:l,res:s},{iss:t,sub:n,cap:r}),!0}catch{}for(let s of r)for(let r of o)try{return await e({act:l,res:r},{iss:t,sub:n,cap:s}),!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:o,graph:l,logger:s,peerRegistry:a}=e,c=e.storeUnreadable??"persist";return{"sync/negotiate":async e=>{let r,{scopes:l,delegationTokens:c,catalogIDs:i,knownModelIDs:u,direction:f}=e.param,d=e.message.payload,g=d.sub||d.iss;s.debug("sync/negotiate requested",{scopes:l,catalogIDs:i,viewerDID:g});let D=[],p=await t(o);if(null!=i&&i.length>0){let e=await n(o,i,u);if(e.modelIDs.length>0)if(null!=e.owners)for(let t of e.modelIDs)for(let n of e.owners){let e=g===n,r=!e&&await checkSyncDelegation(g,n,c);(e||r)&&D.push({modelID:t,ownerDID:n})}else for(let t of e.modelIDs)for(let e of(await p.getDistinctOwnersForModel(t))){let n=g===e,r=!n&&await checkSyncDelegation(g,e,c);(n||r)&&D.push({modelID:t,ownerDID:e})}e.missingClusterIDs.length>0&&(r=await p.getClusters(e.missingClusterIDs))}if(null!=l)for(let e of l){let t=g===e.ownerDID,n=!t&&await checkSyncDelegation(g,e.ownerDID,c);t||n?D.push(e):s.debug("sync/negotiate: scope rejected",{scope:e,viewerDID:g})}let h=new Set,m=D.filter(e=>{let t=`${e.modelID}:${e.ownerDID}`;return!h.has(t)&&(h.add(t),!0)});s.info("sync/negotiate completed",{requested:(l?.length??0)+(i?.length??0),accepted:m.length,missingClusters:null!=r?Object.keys(r).length:0});let w=f??"pull",y=await a.getPeer({peerDID:g,stores:o}),b=y?.allowedDirection??"both",I=negotiateDirection(w,b);return I!==w&&s.info("sync/negotiate: direction downgraded",{requested:w,allowed:b,agreed:I}),{direction:I,acceptedScopes:m,excludedDocumentIDs:[],...null!=r&&{missingClusters:r}}},"sync/merkle-sync":r({db:o,graph:l,logger:s,selfDID:e.selfDID,storeUnreadable:c,defaultAccessLevel:e.defaultAccessLevel})}}
@@ -0,0 +1,24 @@
1
+ import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
+ import { type ProcedureHandlers } from '@enkaku/server';
3
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
4
+ import type { HubConnection } from '../hub/hub-connection.js';
5
+ import type { SyncProtocol } from '../protocol.js';
6
+ export type HubTunnelSyncListenerParams = {
7
+ hubConnection: HubConnection;
8
+ registry: GroupHandleRegistry;
9
+ groupID: string;
10
+ localDID: string;
11
+ peerDID: string;
12
+ syncHandlers: ProcedureHandlers<SyncProtocol>;
13
+ getRandomID?: () => string;
14
+ idleTimeoutMs?: number;
15
+ reconnectTimeoutMs?: number;
16
+ inboxCapacity?: number;
17
+ onEvent?: ObservabilityEventListener;
18
+ };
19
+ export declare class HubTunnelSyncListener {
20
+ #private;
21
+ constructor(params: HubTunnelSyncListenerParams);
22
+ start(): void;
23
+ stop(): Promise<void>;
24
+ }
@@ -0,0 +1 @@
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;#p;#u=!1;#l=!1;#d;#D;#y;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.syncHandlers,this.#o=t.getRandomID,this.#c=t.idleTimeoutMs,this.#a=t.reconnectTimeoutMs,this.#h=t.inboxCapacity,this.#p=t.onEvent}start(){this.#u||this.#l||(this.#u=!0,this.#y=new s({registry:this.#e,groupID:this.#s}),this.#D=this.#t.tunnelMessageStream({peerDID:this.#i}),this.#m())}async stop(){if(this.#l)return;this.#l=!0;let t=this.#d;if(this.#d=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.#y=void 0}#m(){let s;if(this.#l)return;let n=this.#y;if(null==n)throw Error("HubTunnelSyncListener: encryptor not initialized; call start() first");let i=t({hub:this.#I(),encryptor:n,groupID:this.#s,sessionID:{auto:!0},localDID:this.#n,peerDID:this.#i,idleTimeoutMs:this.#c,reconnectTimeoutMs:this.#a,inboxCapacity:this.#h,onEvent:this.#p,onSessionEnd:()=>{s?.dispose().catch(()=>{})}});s=i;let r=new e({getRandomID:this.#o,handlers:this.#r,transports:[i]}),o={transport:i,server:r};this.#d=o,i.events.on("disposed",()=>{this.#d===o&&(this.#d=void 0),r.dispose().catch(()=>{}),this.#m()})}#I(){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)})}}}}
@@ -0,0 +1,36 @@
1
+ import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
+ import type { ClientTransportOf } from '@enkaku/protocol';
3
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
4
+ import type { HubConnection } from '../hub/hub-connection.js';
5
+ import type { SyncProtocol } from '../protocol.js';
6
+ import type { SyncTransportProvider } from './sync-client.js';
7
+ export type HubTunnelSyncProviderParams = {
8
+ hubConnection: HubConnection;
9
+ registry: GroupHandleRegistry;
10
+ groupID: string;
11
+ localDID: string;
12
+ peerDID: string;
13
+ idleTimeoutMs?: number;
14
+ reconnectTimeoutMs?: number;
15
+ inboxCapacity?: number;
16
+ onEvent?: ObservabilityEventListener;
17
+ };
18
+ export declare class HubTunnelSyncProvider implements SyncTransportProvider {
19
+ #private;
20
+ constructor(params: HubTunnelSyncProviderParams);
21
+ /**
22
+ * Build a fresh client transport for one tunnel sync session.
23
+ *
24
+ * **Concurrent-call constraint:** the underlying `HubConnection` keys its
25
+ * tunnel inbox by `peerDID`, and only one inbox per peer can be registered
26
+ * at a time. Callers must therefore fully dispose the previous transport
27
+ * (via `client.dispose()`) before invoking `createSyncTransport` again for
28
+ * the same provider instance — otherwise the second call throws
29
+ * `Error('tunnelMessageStream: peerDID already registered')`.
30
+ *
31
+ * The default `SyncManager.merkleSyncWithPeer` flow already awaits
32
+ * `client.dispose()` in its `finally` block, so sequential sync calls are
33
+ * safe. Concurrent calls are not.
34
+ */
35
+ createSyncTransport(signal?: AbortSignal): ClientTransportOf<SyncProtocol>;
36
+ }
@@ -0,0 +1 @@
1
+ import{createEncryptedHubTunnelTransport as e}from"@enkaku/hub-tunnel";import{MLSEncryptor as t}from"../groups/mls-encryptor.js";export class HubTunnelSyncProvider{#e;#t;#n;#r;#o;#i;#s;#c;#u;constructor(e){this.#e=e.hubConnection,this.#t=e.registry,this.#n=e.groupID,this.#r=e.localDID,this.#o=e.peerDID,this.#i=e.idleTimeoutMs,this.#s=e.reconnectTimeoutMs,this.#c=e.inboxCapacity,this.#u=e.onEvent}createSyncTransport(n){let r=crypto.randomUUID(),o=new t({registry:this.#t,groupID:this.#n});return e({hub:this.#a(),encryptor:o,groupID:this.#n,sessionID:r,localDID:this.#r,peerDID:this.#o,signal:n,idleTimeoutMs:this.#i,reconnectTimeoutMs:this.#s,inboxCapacity:this.#c,onEvent:this.#u})}#a(){let e=this.#e,t=this.#o;return{send:async t=>e.send({recipients:t.recipients,payload:t.payload}),receive(n){let r=e.tunnelMessageStream({peerDID:t}),o=r[Symbol.asyncIterator](),i={next:()=>o.next(),return:()=>(r.return(),Promise.resolve({value:void 0,done:!0}))};return{[Symbol.asyncIterator]:()=>i,return:()=>{r.return()}}},events:{subscribe:t=>e.events.on("lifecycle",e=>{let n=function(e){switch(e.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(e);void 0!==n&&t(n)})}}}}
@@ -1,12 +1,53 @@
1
1
  import type { KubunDB } from '@kubun/db';
2
+ import { type DefaultAccessLevel, type GraphInternals } from '@kubun/engine';
3
+ import type { StoreUnreadableMode } from '../types.js';
2
4
  export type ApplySyncMutationsParams = {
3
5
  db: KubunDB;
6
+ graph: GraphInternals;
4
7
  mutationJWTs: Array<string>;
8
+ /**
9
+ * Local peer's DID — required when `storeUnreadable === 'drop'` to feed the
10
+ * receive-time access gate. Optional otherwise.
11
+ */
12
+ selfDID?: string;
13
+ /**
14
+ * Receiver storage mode for incoming sync mutations.
15
+ * - `'persist'` (default) — every mutation the engine accepts persists.
16
+ * - `'drop'` — apply a receive-time read-access check using `selfDID` as
17
+ * viewer; mutations the local peer cannot read are not persisted.
18
+ */
19
+ storeUnreadable?: StoreUnreadableMode;
20
+ /** Server default access level — required when `storeUnreadable === 'drop'`. */
21
+ defaultAccessLevel?: DefaultAccessLevel;
5
22
  };
6
23
  export type ApplySyncMutationsResult = {
7
24
  applied: number;
8
25
  rejected: number;
9
26
  pending: number;
10
27
  skipped: number;
28
+ /** Count of mutations denied by the receive-time access gate. */
29
+ dropped: number;
11
30
  };
31
+ /**
32
+ * Apply mutation JWTs received from a peer via Merkle sync.
33
+ *
34
+ * Routes the successful-apply path through {@link GraphInternals.applyVerifiedMutation}
35
+ * with `origin: 'peer'` so GraphQL subscriptions (and other engine event
36
+ * consumers) fire for peer-received documents. The engine handles JWT
37
+ * verification, validator cache, applying the mutation, and inserting the
38
+ * `status: 'applied'` mutation log entry.
39
+ *
40
+ * Paths that bypass the engine (manual mutation-log insert):
41
+ * - **skipped**: mutation hash already seen — no-op.
42
+ * - **pending**: change mutation for a document that doesn't exist yet;
43
+ * parked in the log with `status: 'pending'` to be resolved when the
44
+ * set arrives.
45
+ * - **rejected**: JWT verify/validate fails, or engine throws during apply;
46
+ * recorded with `status: 'rejected'`.
47
+ *
48
+ * We still verify + validate the JWT up front to peek at `mutation.typ` for
49
+ * the pending-path routing decision. The engine re-verifies on the success
50
+ * path (defense in depth + avoids plumbing verified payloads through the
51
+ * public API).
52
+ */
12
53
  export declare function applySyncMutations(params: ApplySyncMutationsParams): Promise<ApplySyncMutationsResult>;