@kubun/plugin-p2p 0.9.0 → 0.10.1

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 (87) hide show
  1. package/lib/context/delegation.d.ts +4 -0
  2. package/lib/context/delegation.js +1 -0
  3. package/lib/context/group.js +1 -1
  4. package/lib/context/hub.js +1 -1
  5. package/lib/context/join.js +1 -1
  6. package/lib/context/types.d.ts +25 -2
  7. package/lib/groups/broadcast-service.d.ts +137 -4
  8. package/lib/groups/broadcast-service.js +1 -1
  9. package/lib/groups/broadcast.d.ts +120 -0
  10. package/lib/groups/broadcast.js +1 -1
  11. package/lib/groups/events.d.ts +16 -5
  12. package/lib/groups/events.js +1 -1
  13. package/lib/groups/group-handle-registry.d.ts +8 -0
  14. package/lib/groups/group-handle-registry.js +1 -1
  15. package/lib/groups/group-health-monitor.d.ts +45 -0
  16. package/lib/groups/group-health-monitor.js +1 -0
  17. package/lib/groups/invite-payload.d.ts +16 -0
  18. package/lib/groups/join-utils.d.ts +68 -2
  19. package/lib/groups/join-utils.js +1 -1
  20. package/lib/groups/manager.d.ts +15 -4
  21. package/lib/groups/manager.js +1 -1
  22. package/lib/groups/rejoin-codec.d.ts +14 -0
  23. package/lib/groups/rejoin-codec.js +1 -0
  24. package/lib/groups/store-received-grant.d.ts +47 -0
  25. package/lib/groups/store-received-grant.js +1 -0
  26. package/lib/groups/store-received-revocation.d.ts +46 -0
  27. package/lib/groups/store-received-revocation.js +1 -0
  28. package/lib/groups/wire-frame.d.ts +34 -0
  29. package/lib/groups/wire-frame.js +1 -0
  30. package/lib/hub/circle-catchup-requester.d.ts +58 -0
  31. package/lib/hub/circle-catchup-requester.js +1 -0
  32. package/lib/hub/circle-catchup-responder.d.ts +50 -0
  33. package/lib/hub/circle-catchup-responder.js +1 -0
  34. package/lib/hub/epoch-stale-detector.d.ts +18 -0
  35. package/lib/hub/epoch-stale-detector.js +1 -0
  36. package/lib/hub/errors.d.ts +7 -5
  37. package/lib/hub/errors.js +1 -1
  38. package/lib/hub/forward-remote-broadcast.d.ts +3 -3
  39. package/lib/hub/forward-remote-broadcast.js +1 -1
  40. package/lib/hub/group-channel.d.ts +30 -0
  41. package/lib/hub/group-channel.js +1 -1
  42. package/lib/hub/hub-connection.js +1 -1
  43. package/lib/hub/manager.d.ts +5 -4
  44. package/lib/hub/manager.js +1 -1
  45. package/lib/hub/receive-handler.d.ts +20 -2
  46. package/lib/hub/receive-handler.js +1 -1
  47. package/lib/hub/rejoin-manager.d.ts +78 -0
  48. package/lib/hub/rejoin-manager.js +1 -0
  49. package/lib/hub/rejoin-responder.d.ts +32 -0
  50. package/lib/hub/rejoin-responder.js +1 -0
  51. package/lib/hub/relay-manager.d.ts +23 -1
  52. package/lib/hub/relay-manager.js +1 -1
  53. package/lib/hub/send-handler.d.ts +16 -0
  54. package/lib/hub/send-handler.js +1 -1
  55. package/lib/hub/wiring.d.ts +33 -3
  56. package/lib/hub/wiring.js +1 -1
  57. package/lib/index.d.ts +19 -5
  58. package/lib/index.js +1 -1
  59. package/lib/protocol.d.ts +7 -0
  60. package/lib/protocol.js +1 -1
  61. package/lib/schema.d.ts +2 -2
  62. package/lib/schema.js +79 -3
  63. package/lib/sync/authorize.d.ts +25 -0
  64. package/lib/sync/authorize.js +1 -0
  65. package/lib/sync/broadcast-sender.js +1 -1
  66. package/lib/sync/errors.d.ts +11 -0
  67. package/lib/sync/errors.js +1 -0
  68. package/lib/sync/forwarder.d.ts +1 -0
  69. package/lib/sync/forwarder.js +1 -1
  70. package/lib/sync/handlers.d.ts +3 -18
  71. package/lib/sync/handlers.js +1 -1
  72. package/lib/sync/hub-tunnel-sync-listener.d.ts +2 -0
  73. package/lib/sync/hub-tunnel-sync-listener.js +1 -1
  74. package/lib/sync/merkle-apply.js +1 -1
  75. package/lib/sync/merkle-channel.d.ts +19 -0
  76. package/lib/sync/merkle-channel.js +1 -1
  77. package/lib/sync/peer-registry.d.ts +7 -0
  78. package/lib/sync/peer-registry.js +1 -1
  79. package/lib/sync/sync-client.d.ts +2 -0
  80. package/lib/sync/sync-client.js +1 -1
  81. package/lib/sync/sync-manager.js +1 -1
  82. package/lib/types.d.ts +122 -1
  83. package/lib/utils.d.ts +5 -0
  84. package/lib/utils.js +1 -0
  85. package/package.json +41 -41
  86. package/lib/sync/catalog-match.d.ts +0 -13
  87. package/lib/sync/catalog-match.js +0 -1
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{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})})}}}}
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{p2pStoreDefinition as s}from"@kubun/store-p2p";import{createDelegationContext as n}from"./context/delegation.js";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{createP2PEventEmitter as d}from"./groups/events.js";import{GroupHandleRegistry as u}from"./groups/group-handle-registry.js";import{GroupHealthMonitor as l}from"./groups/group-health-monitor.js";import{GroupManager as g}from"./groups/manager.js";import{forwardRemoteBroadcast as m}from"./hub/forward-remote-broadcast.js";import{createHTTPHubClient as y}from"./hub/http-client.js";import{captureServerDID as b}from"./hub/manager.js";import{DISABLED_HUB as f,setupHubRelay as h}from"./hub/wiring.js";import{createP2PSchemaExtension as j}from"./schema.js";import{createBroadcastQueue as v,DEFAULT_BROADCAST_BATCH_CONFIG as x}from"./sync/broadcast-queue.js";import{DEFAULT_PUSH_SYNC_CONFIG as w,wireBroadcastSender as S}from"./sync/broadcast-sender.js";import{createSyncHandlers as D}from"./sync/handlers.js";import{SyncManager as R}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,createP2PEventEmitter}from"./groups/events.js";export{GroupHandleRegistry}from"./groups/group-handle-registry.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{resolveCatalogSyncScopes}from"./sync/catalog-scope.js";export{SyncAccessDeniedError}from"./sync/errors.js";export{evaluateAndForward}from"./sync/forwarder.js";export{checkSyncDelegation}from"./sync/handlers.js";export{HubTunnelSyncListener}from"./sync/hub-tunnel-sync-listener.js";export{HubTunnelSyncProvider}from"./sync/hub-tunnel-sync-provider.js";export{negotiateDirection,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 E;let H,L,B,C;I.db.register(s);let G=I.db.adapter;if(!(t(E=I.identity)&&"privateKey"in E))throw Error("createP2PPlugin requires an OwnIdentity (full identity with privateKey) — received a narrower Identity");let A=I.identity,F=I.hlc,T={...DEFAULT_RECEIVE_CONFIG,...P?.receive},U=P?.defaultAccessLevel??{read:"only_owner",write:"only_owner"},_=new u({stores:I.db,deviceID:A.id,logger:I.getLogger("group-handle-registry")}),k=new g({identity:A,getRandomID:I.runtime.getRandomID,registry:_,hlc:F}),O=d(),q=new l({emitter:O}),N=new R({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:T.storeUnreadable,defaultAccessLevel:U}),M=D({db:I.db,graph:I.graph,logger:I.getLogger("sync-handlers"),peerRegistry:N.peerRegistry,selfDID:A.id,storeUnreadable:T.storeUnreadable,defaultAccessLevel:U}),J=new Map,z=[],K=I.getLogger("hub-relay"),V=f,W={...w,...P?.pushSync};if(P?.hub){let e="object"==typeof P.hub&&null!=P.hub.createHubClient?P.hub.createHubClient:y({identity:A,fetch:I.runtime.fetch});V=h({identity:A,getRandomID:I.runtime.getRandomID,db:I.db,hlc:F,graph:I.graph,emitter:O,createHubClient:e,registry:_,monitor:q,onRemoteBroadcast:e=>{m(O,e,K).catch(e=>{K.error("forwardRemoteBroadcast failed",{error:e})})},logger:K,storeUnreadable:T.storeUnreadable,defaultAccessLevel:U,forwarding:P.forwarding,onServerDIDObserved:({hubURL:e,serverDID:r})=>b({stores:I.db,hubURL:e,serverDID:r}).then(()=>void 0)}),W.enabled&&(L=v({config:{...x,...P.batch},scheduleBroadcast:V.scheduleBroadcast,logger:I.getLogger("broadcast-queue")}),H=S({db:I.db,eventBus:I.eventBus,selfDID:A.id,queue:L,logger:I.getLogger("broadcast-sender")}))}let Q={hubReady:V.ready,addPeer:e=>N.addPeer({config:e,stores:I.db}),removePeer:e=>N.removePeer({peerDID:e,stores:I.db}),listPeers:()=>N.listPeers({stores:I.db}),syncWithPeer:e=>N.merkleSyncWithPeer({...e,stores:I.db}),getStatus:e=>N.getStatus({peerDID:e,stores:I.db}),onSyncEvent:e=>N.onSyncEvent(e),createSyncTransport:function(e){let t=new o({signal:e}),s=new r({getRandomID:I.runtime.getRandomID,handlers:M,identity:A,accessRules:{"sync/*":{allow:!0}},logger:I.getLogger("sync-server"),transports:[t.server],signal:e});return z.push(s),t.client}};if(P?.http){let t=P.http,o=("string"==typeof t?t:"object"==typeof t?t.path??"sync":"sync").replace(/^\//,""),s="object"==typeof t?t.allowedOrigin??["*"]:["*"];Q.syncReady=(async()=>{let t=await I.engine.getAPI("http");await t.listening,B=new e({allowedOrigin:s}),C=new r({getRandomID:I.runtime.getRandomID,handlers:M,identity:A,accessRules:{"sync/*":{allow:!0}},logger:I.getLogger("sync-http-server"),transports:[B]}),z.push(C),t.registerProtocol(o,B.fetch.bind(B))})()}return{name:"p2p",schemaExtension:e=>j(O),api:Q,createContextFactory:()=>(e,r)=>{let t={identity:A,groupManager:k,registry:_,syncManager:N,stores:r,adapter:G,hlc:F,emitter:O,pendingJoinRequests:J,runtime:I.runtime,autoAcceptPeers:P?.autoAcceptPeers,scheduleBroadcast:V.scheduleBroadcast,broadcastNow:V.broadcastNow,scheduleSendCommit:V.scheduleSendCommit,monitor:q,rejoinGroup:V.rejoinGroup,logger:I.getLogger("context")};return{group:a(e,t),sync:p(e,t),join:c(e,t),hub:i(e,t),delegation:n(e,t)}},dispose:async()=>{if(null!=H)try{H()}catch{}if(null!=L)try{L.dispose()}catch{}let e=(async()=>{for(let e of z)await e.dispose();null!=B&&await B.dispose()})(),r=I.getLogger("dispose"),t=await Promise.allSettled([e,N.dispose(),V.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/protocol.d.ts CHANGED
@@ -129,6 +129,13 @@ export declare const syncProtocol: {
129
129
  readonly type: "string";
130
130
  readonly enum: readonly ["push", "pull", "both"];
131
131
  };
132
+ readonly delegationTokens: {
133
+ readonly type: "array";
134
+ readonly items: {
135
+ readonly type: "string";
136
+ };
137
+ readonly description: "Delegation JWTs authorizing the caller for non-owned scopes";
138
+ };
132
139
  };
133
140
  readonly required: readonly ["scopes", "excludedDocumentIDs", "tree", "direction"];
134
141
  readonly additionalProperties: false;
package/lib/protocol.js CHANGED
@@ -1 +1 @@
1
- export const syncProtocol={"sync/negotiate":{type:"request",param:{type:"object",properties:{scopes:{type:"array",items:{type:"object",properties:{modelID:{type:"string"},ownerDID:{type:"string"}},required:["modelID","ownerDID"],additionalProperties:!1},description:"Requested sync scopes as (model, owner) pairs"},delegationTokens:{type:"array",items:{type:"string"},description:"JWT delegation tokens for authorization"},catalogIDs:{type:"array",items:{type:"string"},description:"Catalog IDs to resolve sync scope from criteria"},knownModelIDs:{type:"array",items:{type:"string"},description:"Model IDs the requester already has — used to identify missing clusters"},direction:{type:"string",enum:["push","pull","both"]}},required:["delegationTokens"],additionalProperties:!1},result:{type:"object",properties:{direction:{type:"string",enum:["push","pull","both"]},acceptedScopes:{type:"array",items:{type:"object",properties:{modelID:{type:"string"},ownerDID:{type:"string"}},required:["modelID","ownerDID"],additionalProperties:!1}},excludedDocumentIDs:{type:"array",items:{type:"string"}},missingClusters:{type:"object",description:"Cluster definitions for models the requester does not have yet",additionalProperties:!0}},required:["acceptedScopes","excludedDocumentIDs"],additionalProperties:!1}},"sync/merkle-sync":{type:"channel",param:{type:"object",properties:{scopes:{type:"array",items:{type:"object",properties:{modelID:{type:"string"},ownerDID:{type:"string"}},required:["modelID","ownerDID"],additionalProperties:!1}},excludedDocumentIDs:{type:"array",items:{type:"string"}},tree:{type:"object",description:"Merkle tree bucket hashes from requester",additionalProperties:{type:"string"}},direction:{type:"string",enum:["push","pull","both"]}},required:["scopes","excludedDocumentIDs","tree","direction"],additionalProperties:!1},send:{type:"object",properties:{type:{type:"string",enum:["mutations","complete"]},mutationJWTs:{type:"array",items:{type:"string"},description:"Signed mutation JWTs for divergent time buckets"}},required:["type"],additionalProperties:!1},receive:{type:"object",properties:{type:{type:"string",enum:["tree","mutations","complete"]},tree:{type:"object",description:"Responder merkle tree bucket hashes",additionalProperties:{type:"string"}},mutationJWTs:{type:"array",items:{type:"string"},description:"Signed mutation JWTs for divergent time buckets"}},required:["type"],additionalProperties:!1},result:{type:"object",properties:{success:{type:"boolean"},divergentBuckets:{type:"number"},mutationsSent:{type:"number"},mutationsReceived:{type:"number"}},required:["success","divergentBuckets","mutationsSent","mutationsReceived"],additionalProperties:!1}}};
1
+ export const syncProtocol={"sync/negotiate":{type:"request",param:{type:"object",properties:{scopes:{type:"array",items:{type:"object",properties:{modelID:{type:"string"},ownerDID:{type:"string"}},required:["modelID","ownerDID"],additionalProperties:!1},description:"Requested sync scopes as (model, owner) pairs"},delegationTokens:{type:"array",items:{type:"string"},description:"JWT delegation tokens for authorization"},catalogIDs:{type:"array",items:{type:"string"},description:"Catalog IDs to resolve sync scope from criteria"},knownModelIDs:{type:"array",items:{type:"string"},description:"Model IDs the requester already has — used to identify missing clusters"},direction:{type:"string",enum:["push","pull","both"]}},required:["delegationTokens"],additionalProperties:!1},result:{type:"object",properties:{direction:{type:"string",enum:["push","pull","both"]},acceptedScopes:{type:"array",items:{type:"object",properties:{modelID:{type:"string"},ownerDID:{type:"string"}},required:["modelID","ownerDID"],additionalProperties:!1}},excludedDocumentIDs:{type:"array",items:{type:"string"}},missingClusters:{type:"object",description:"Cluster definitions for models the requester does not have yet",additionalProperties:!0}},required:["acceptedScopes","excludedDocumentIDs"],additionalProperties:!1}},"sync/merkle-sync":{type:"channel",param:{type:"object",properties:{scopes:{type:"array",items:{type:"object",properties:{modelID:{type:"string"},ownerDID:{type:"string"}},required:["modelID","ownerDID"],additionalProperties:!1}},excludedDocumentIDs:{type:"array",items:{type:"string"}},tree:{type:"object",description:"Merkle tree bucket hashes from requester",additionalProperties:{type:"string"}},direction:{type:"string",enum:["push","pull","both"]},delegationTokens:{type:"array",items:{type:"string"},description:"Delegation JWTs authorizing the caller for non-owned scopes"}},required:["scopes","excludedDocumentIDs","tree","direction"],additionalProperties:!1},send:{type:"object",properties:{type:{type:"string",enum:["mutations","complete"]},mutationJWTs:{type:"array",items:{type:"string"},description:"Signed mutation JWTs for divergent time buckets"}},required:["type"],additionalProperties:!1},receive:{type:"object",properties:{type:{type:"string",enum:["tree","mutations","complete"]},tree:{type:"object",description:"Responder merkle tree bucket hashes",additionalProperties:{type:"string"}},mutationJWTs:{type:"array",items:{type:"string"},description:"Signed mutation JWTs for divergent time buckets"}},required:["type"],additionalProperties:!1},result:{type:"object",properties:{success:{type:"boolean"},divergentBuckets:{type:"number"},mutationsSent:{type:"number"},mutationsReceived:{type:"number"}},required:["success","divergentBuckets","mutationsSent","mutationsReceived"],additionalProperties:!1}}};
package/lib/schema.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import type { SchemaExtension } from '@kubun/engine';
2
- import { type GroupEventEmitter } from './groups/events.js';
3
- export declare function createP2PSchemaExtension(emitter: GroupEventEmitter): SchemaExtension;
2
+ import { type P2PEventEmitter } from './groups/events.js';
3
+ export declare function createP2PSchemaExtension(emitter: P2PEventEmitter): SchemaExtension;
package/lib/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import{createFilteredGenerator as e}from"./groups/events.js";function r(e){if(null==e.p2p)throw Error("p2p plugin is not wired into this context");return e.p2p}let t=`
1
+ import{createFilteredGenerator as e}from"./groups/events.js";function r(e){if(null==e.p2p)throw Error("p2p plugin is not wired into this context");return e.p2p}let t={healthy:"HEALTHY",degraded:"DEGRADED",recovering:"RECOVERING",recovered:"RECOVERED","recovery-failed":"RECOVERY_FAILED"},o={"epoch-stale":"EPOCH_STALE"};async function*a(){}let i=`
2
2
  type PeerGroup {
3
3
  id: ID!
4
4
  name: String!
@@ -173,6 +173,44 @@ type DeleteHubPayload {
173
173
  boundGroupIDs: [ID!]!
174
174
  }
175
175
 
176
+ type DelegationToken {
177
+ jwt: String!
178
+ jti: String!
179
+ grantor: ID!
180
+ audience: ID!
181
+ resource: String!
182
+ exp: Int!
183
+ }
184
+
185
+ input DelegationTokenCovers {
186
+ modelID: ID
187
+ docID: ID
188
+ }
189
+
190
+ """
191
+ A verified revocation of a delegation token addressed to the viewer.
192
+ Only surfaces while the underlying held capability still exists locally;
193
+ revocations whose cap was deleted or never arrived are excluded by the
194
+ audience-derivation join.
195
+ """
196
+ type RevokedDelegationToken {
197
+ jti: ID!
198
+ grantor: ID!
199
+ audience: ID!
200
+ revokerDID: DID!
201
+ "Issuer's revocation-record timestamp (iat), Unix epoch seconds."
202
+ revokedAt: Int!
203
+ """
204
+ Wall-clock time the revocation became binding, Unix epoch seconds. For
205
+ self-issued revocations this equals revokedAt (verified at mint time);
206
+ for received revocations it is the receiver's clock at the moment the
207
+ cap-vs-revoker cross-check succeeded.
208
+ """
209
+ verifiedAt: Int!
210
+ capExp: Int
211
+ groupID: ID!
212
+ }
213
+
176
214
  extend type Query {
177
215
  groups: [PeerGroup!]!
178
216
  group(id: ID!): PeerGroup
@@ -182,6 +220,38 @@ extend type Query {
182
220
  hub(id: ID!): PeerHub
183
221
  syncPeers: [SyncPeer!]!
184
222
  syncStatus: SyncStatus!
223
+ ownDelegationTokens(grantor: ID, covers: DelegationTokenCovers): [DelegationToken!]!
224
+ ownRevokedDelegationTokens(grantor: ID): [RevokedDelegationToken!]!
225
+ groupHealth(groupID: ID!): GroupHealthStatus!
226
+ }
227
+
228
+ enum GroupHealthCondition {
229
+ EPOCH_STALE
230
+ }
231
+
232
+ enum GroupHealthState {
233
+ HEALTHY
234
+ DEGRADED
235
+ RECOVERING
236
+ RECOVERED
237
+ RECOVERY_FAILED
238
+ }
239
+
240
+ type GroupHealthEvent {
241
+ groupID: ID!
242
+ condition: GroupHealthCondition!
243
+ state: GroupHealthState!
244
+ }
245
+
246
+ type GroupHealthStatus {
247
+ groupID: ID!
248
+ state: GroupHealthState!
249
+ lastTransitionAt: DateTimeISO
250
+ }
251
+
252
+ type RejoinGroupPayload {
253
+ groupID: ID!
254
+ state: GroupHealthState!
185
255
  }
186
256
 
187
257
  extend type Mutation {
@@ -190,7 +260,7 @@ extend type Mutation {
190
260
  createCircle(groupID: ID!, name: String!, description: String): PeerCircle!
191
261
  addCircleMember(circleID: ID!, memberDID: DID!, role: String!): PeerCircleMember!
192
262
  createCatalog(name: String!, description: String, filterCriteria: String!): PeerCatalog!
193
- inviteToGroup(groupID: ID!, joinRequest: String!): InviteToGroupPayload!
263
+ inviteToGroup(groupID: ID!, joinRequest: String!, grants: [String!], revocations: [String!]): InviteToGroupPayload!
194
264
  joinGroup(invitePayload: String!, joinRequestPayload: String!): JoinGroupPayload!
195
265
  prepareJoinRequest: PrepareJoinRequestPayload!
196
266
  completeJoin(invitePayload: String!): CompleteJoinPayload!
@@ -202,6 +272,8 @@ extend type Mutation {
202
272
  deleteCatalog(catalogID: ID!): DeleteCatalogPayload!
203
273
  deleteCircle(circleID: ID!): DeleteCirclePayload!
204
274
  removeCircleMember(circleID: ID!, memberDID: DID!): RemoveCircleMemberPayload!
275
+ grantWriteCapability(to: DID!, res: String!, groupID: ID!, expiresIn: Int): String!
276
+ revokeCapability(jti: ID!): Boolean!
205
277
  addSyncPeer(peerDID: DID!, endpoint: URL!, mode: String, priority: String, trustLevel: String): Boolean!
206
278
  removeSyncPeer(peerDID: DID!): RemoveSyncPeerPayload!
207
279
  createHub(input: CreateHubInput!): PeerHub!
@@ -209,6 +281,7 @@ extend type Mutation {
209
281
  deleteHub(id: ID!, force: Boolean): DeleteHubPayload!
210
282
  bindHubToGroup(hubID: ID!, groupID: ID!): Boolean!
211
283
  unbindHubFromGroup(hubID: ID!, groupID: ID!): Boolean!
284
+ rejoinGroup(groupID: ID!): RejoinGroupPayload!
212
285
  }
213
286
 
214
287
  extend type PeerGroup {
@@ -231,5 +304,8 @@ extend type Subscription {
231
304
  circleMemberAdded(circleID: ID!): PeerCircleMember!
232
305
  circleMemberRemoved(circleID: ID!): PeerCircleMember!
233
306
  circleCatalogsChanged(circleID: ID!): PeerCircle!
307
+ ownDelegationTokenAdded: DelegationToken!
308
+ ownDelegationTokenRevoked: RevokedDelegationToken!
309
+ groupHealthChanged(groupID: ID): GroupHealthEvent!
234
310
  }
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)}}}}}
311
+ `;export function createP2PSchemaExtension(n){return{sdl:i,resolvers:{queryFields:{groups:async(e,t,o)=>await r(o).group.list(),group:async(e,t,o)=>await r(o).group.get(t.id),groupMembers:async(e,t,o)=>await r(o).group.listMembers(t.groupID),circles:async(e,t,o)=>await r(o).group.listCircles(t.groupID),hubs:async(e,t,o)=>await r(o).hub.list(),hub:async(e,t,o)=>await r(o).hub.get(t.id),syncPeers:async(e,t,o)=>await r(o).sync.listPeers(),syncStatus:(e,t,o)=>{let a=r(o).sync.getStatus();return{...a,lastSyncByPeer:JSON.stringify(a.lastSyncByPeer)}},groupHealth:(e,o,a)=>{let i=o.groupID,{state:n,lastTransitionAt:u}=r(a).hub.getGroupHealth(i);return{groupID:i,state:t[n],lastTransitionAt:null==u?null:new Date(u).toISOString()}},ownDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let a=r.grantor??null,i=r.covers??null;return await t.p2p.delegation.listOwnTokens({viewerDID:o,grantor:a,covers:i})},ownRevokedDelegationTokens:async(e,r,t)=>{let o=t.getViewer();if(null==o||null==t.p2p)return[];let a=r.grantor??null;return await t.p2p.delegation.listOwnRevokedTokens({viewerDID:o,grantor:a})}},mutationFields:{createGroup:async(e,t,o)=>{let a=t.input;return{group:await r(o).group.create(a)}},addGroupMember:async(e,t,o)=>await r(o).group.addMember(t.groupID,t.memberDID,t.role),createCircle:async(e,t,o)=>await r(o).group.createCircle({groupID:t.groupID,name:t.name,description:t.description}),addCircleMember:async(e,t,o)=>await r(o).group.addCircleMember(t.circleID,t.memberDID,t.role),createCatalog:async(e,t,o)=>await r(o).group.createCatalog({name:t.name,description:t.description,filterCriteria:t.filterCriteria}),inviteToGroup:async(e,t,o)=>await r(o).group.invite(t.groupID,t.joinRequest,t.grants,t.revocations),joinGroup:async(e,t,o)=>await r(o).group.join(t.invitePayload,t.joinRequestPayload),prepareJoinRequest:async(e,t,o)=>await r(o).join.prepareRequest(),completeJoin:async(e,t,o)=>await r(o).join.complete(t.invitePayload),leaveGroup:async(e,t,o)=>await r(o).group.leave(t.groupID),removeGroupMember:async(e,t,o)=>await r(o).group.removeMember(t.groupID,t.memberDID),updateCatalog:async(e,t,o)=>{let a=t.input;return await r(o).group.updateCatalog(a)},updateGroup:async(e,t,o)=>{let a=t.input;return{group:await r(o).group.update(a)}},updateCircle:async(e,t,o)=>{let a=t.input;return{circle:await r(o).group.updateCircle(a)}},deleteCatalog:async(e,t,o)=>await r(o).group.deleteCatalog(t.catalogID),deleteCircle:async(e,t,o)=>await r(o).group.deleteCircle(t.circleID),removeCircleMember:async(e,t,o)=>await r(o).group.removeCircleMember(t.circleID,t.memberDID),grantWriteCapability:async(e,t,o)=>await r(o).group.grantWriteCapability({to:t.to,res:t.res,groupID:t.groupID,expiresIn:t.expiresIn}),revokeCapability:async(e,t,o)=>await r(o).group.revokeCapability({jti:t.jti}),addSyncPeer:async(e,t,o)=>(await r(o).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,o)=>(await r(o).sync.removePeer(t.peerDID),{success:!0}),createHub:async(e,t,o)=>{let a=t.input;return await r(o).hub.create(a)},updateHub:async(e,t,o)=>{let a=t.input;return await r(o).hub.update(a)},deleteHub:async(e,t,o)=>await r(o).hub.delete(t.id,t.force??!1),bindHubToGroup:async(e,t,o)=>await r(o).hub.bind({hubID:t.hubID,groupID:t.groupID}),unbindHubFromGroup:async(e,t,o)=>await r(o).hub.unbind({hubID:t.hubID,groupID:t.groupID}),rejoinGroup:(e,t,o)=>{let a=t.groupID,i=r(o).hub,{state:n}=i.getGroupHealth(a);return"healthy"!==n&&"recovering"!==n&&i.rejoinGroup(a),{groupID:a,state:"healthy"===n?"HEALTHY":"RECOVERING"}}},typeFields:{PeerGroup:{hubs:async(e,t,o)=>await r(o).hub.listByGroup(e.id)},PeerHub:{groups:async(e,t,o)=>await r(o).hub.listGroups(e.id)}},subscriptionFields:{groupJoined:{resolve:e=>e,subscribe:()=>e(n,"groupJoined")},groupLeft:{resolve:e=>e,subscribe:()=>e(n,"groupLeft")},groupDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(n,"groupDataChanged",e=>e.groupID===t.groupID)},groupMemberJoined:{resolve:e=>e,subscribe:(r,t)=>e(n,"groupMemberJoined",e=>e.groupID===t.groupID)},groupMemberLeft:{resolve:e=>e,subscribe:(r,t)=>e(n,"groupMemberLeft",e=>e.groupID===t.groupID)},circleCreated:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleCreated",e=>e.groupID===t.groupID)},circleDeleted:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleDeleted",e=>e.groupID===t.groupID)},circleDataChanged:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleDataChanged",e=>e.circleID===t.circleID)},circleMemberAdded:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleMemberAdded",e=>e.circleID===t.circleID)},circleMemberRemoved:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleMemberRemoved",e=>e.circleID===t.circleID)},circleCatalogsChanged:{resolve:e=>e,subscribe:(r,t)=>e(n,"circleCatalogsChanged",e=>e.circleID===t.circleID)},ownDelegationTokenAdded:{resolve:e=>e,subscribe:(r,t,o)=>{let i=o.getViewer();return null==i?a():e(n,"delegationTokenAdded",e=>e.audience===i)}},ownDelegationTokenRevoked:{resolve:e=>e,subscribe:(r,t,o)=>{let i=o.getViewer();return null==i?a():e(n,"delegationTokenRevoked",e=>e.audience===i)}},groupHealthChanged:{resolve:e=>({groupID:e.groupID,condition:o[e.condition],state:t[e.state]}),subscribe:(r,t)=>{let o=t.groupID;return e(n,"groupHealthChanged",null==o?void 0:e=>e.groupID===o)}}}}}}
@@ -0,0 +1,25 @@
1
+ import { type VerifyTokenHook } from '@enkaku/capability';
2
+ /**
3
+ * Check if delegation tokens grant the viewer read access to a user's documents.
4
+ *
5
+ * @param viewerDID - The DID of the requesting viewer
6
+ * @param ownerDID - The DID of the document owner
7
+ * @param delegationTokens - Array of delegation JWT tokens
8
+ * @param revocationChecker - Optional per-leaf-capability verification hook.
9
+ * When set, capabilities whose `jti` has been revoked cause the check to
10
+ * fail closed.
11
+ * @returns true if any token grants read access
12
+ */
13
+ export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>, revocationChecker?: VerifyTokenHook): Promise<boolean>;
14
+ export type AuthorizeScopeParams = {
15
+ viewerDID: string;
16
+ ownerDID: string;
17
+ delegationTokens: Array<string>;
18
+ revocationChecker?: VerifyTokenHook;
19
+ };
20
+ /**
21
+ * Authorize a single sync scope: the viewer must be the owner, or hold a
22
+ * delegation token granting read access to the owner's documents. Shared by
23
+ * negotiate and merkle-sync so the two access gates cannot drift apart.
24
+ */
25
+ export declare function authorizeScope(params: AuthorizeScopeParams): Promise<boolean>;
@@ -0,0 +1 @@
1
+ import{checkCapability as e}from"@enkaku/capability";export async function checkSyncDelegation(r,t,n,o){if(!n||0===n.length)return!1;let c=["*","urn:kubun:user:*",`urn:kubun:user:${t}`],a="document/read";for(let u of c)try{return await e({act:a,res:u},{iss:r,sub:t,cap:n},{verifyToken:o}),!0}catch{}for(let u of n)for(let n of c)try{return await e({act:a,res:n},{iss:r,sub:t,cap:u},{verifyToken:o}),!0}catch{}return!1}export async function authorizeScope(e){return e.viewerDID===e.ownerDID||checkSyncDelegation(e.viewerDID,e.ownerDID,e.delegationTokens,e.revocationChecker)}
@@ -1 +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)}}
1
+ import{catalogMatchesDoc as e,resolveAccessRule as r}from"@kubun/engine";import{DocumentID as t}from"@kubun/id";import{getGraphStore as o}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";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 t=>{try{await i(t,{...e,defaultAccessLevel:r})}catch(r){e.logger?.warn("broadcast-sender handler failed",{documentID:t.documentID,error:String(r)})}})}async function i(n,i){let s=await o(i.db),d=await l(i.db),u=await s.getDocument(t.fromString(n.documentID));if(null==u)return;let f=await r(u,u.model,u.owner,"read",{getUserModelAccessDefault:(e,r,t)=>s.getUserModelAccessDefault(e,r,t),isMemberOfAnyCircle:(e,r)=>d.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>d.isMemberOfAnyGroup(e,r),getModelInterfaces:e=>s.getModelInterfaces(e)},i.defaultAccessLevel);if("only_owner"===f.level)return;let c=await d.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 t=w.get(r);null!=t&&g.push({groupID:e.group_id,criteria:t.filter_criteria})}let D=await Promise.all(g.map(async r=>({groupID:r.groupID,matched:await e(r.criteria,u,d)}))),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 d.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 d.getGroupsForMembers(f.allowedDIDs)).values())for(let r of e)y.add(r.id);let _=a({resolvedRule:{level:f.level,allowedGroupIDs:b,allowedCircleParentGroupIDs:v,allowedDIDGroupIDs:y},senderGroupIDs:I});if(0!==_.length)for(let e of _){if(!await d.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,11 @@
1
+ /**
2
+ * Thrown by the sync handlers when a request is denied before any store
3
+ * read — for example a forged subject, an unauthorized scope, or a
4
+ * disallowed direction. Tagged with the protocol-level error code `KB08`
5
+ * so server-side logging and tests can assert the reason.
6
+ */
7
+ export declare class SyncAccessDeniedError extends Error {
8
+ readonly code: "KB08";
9
+ readonly reason: string;
10
+ constructor(reason: string, message?: string);
11
+ }
@@ -0,0 +1 @@
1
+ export class SyncAccessDeniedError extends Error{code="KB08";reason;constructor(e,r){super(r??`Sync access denied: ${e}`),this.name="SyncAccessDeniedError",this.reason=e}}
@@ -4,6 +4,7 @@ import type { DocumentNode } from '@kubun/protocol';
4
4
  import type { GraphStoreAPI } from '@kubun/store-graph';
5
5
  import type { P2PStoreAPI } from '@kubun/store-p2p';
6
6
  import type { GroupBroadcastMessage, MutationApplyEntry } from '../groups/broadcast.js';
7
+ export declare function rethrowIfProgrammerError(error: unknown): void;
7
8
  /**
8
9
  * Context passed to a {@link ForwardFilter} for each candidate forwarding scope.
9
10
  *
@@ -1 +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
+ 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,18 +1,10 @@
1
1
  import type { KubunDB } from '@kubun/db';
2
2
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
3
  import type { Logger } from '@kubun/logger';
4
- import type { SyncDirection } from '../protocol.js';
5
4
  import type { StoreUnreadableMode } from '../types.js';
6
- import type { PeerRegistry } from './peer-registry.js';
7
- /**
8
- * Check if delegation tokens grant the viewer read access to a user's documents.
9
- *
10
- * @param viewerDID - The DID of the requesting viewer
11
- * @param ownerDID - The DID of the document owner
12
- * @param delegationTokens - Array of delegation JWT tokens
13
- * @returns true if any token grants read access
14
- */
15
- export declare function checkSyncDelegation(viewerDID: string, ownerDID: string, delegationTokens: Array<string>): Promise<boolean>;
5
+ import { checkSyncDelegation } from './authorize.js';
6
+ import { type PeerRegistry } from './peer-registry.js';
7
+ export { checkSyncDelegation };
16
8
  export type CreateSyncHandlersParams = {
17
9
  db: KubunDB;
18
10
  graph: GraphInternals;
@@ -31,13 +23,6 @@ export type CreateSyncHandlersParams = {
31
23
  /** Server default access level — required when `storeUnreadable === 'drop'`. */
32
24
  defaultAccessLevel?: DefaultAccessLevel;
33
25
  };
34
- /**
35
- * Resolve the effective sync direction given what the initiator requested
36
- * and what the responder's per-peer policy allows.
37
- * If allowed is 'both' (or unset), the request passes through.
38
- * Otherwise the responder's policy wins.
39
- */
40
- export declare function negotiateDirection(requested: SyncDirection, allowed: SyncDirection): SyncDirection;
41
26
  /**
42
27
  * Create sync protocol handlers served by plugin-p2p's own Enkaku server.
43
28
  */
@@ -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 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})}}
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,5 +1,6 @@
1
1
  import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
2
  import { type ProcedureHandlers } from '@enkaku/server';
3
+ import type { OwnIdentity } from '@enkaku/token';
3
4
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
4
5
  import type { HubConnection } from '../hub/hub-connection.js';
5
6
  import type { SyncProtocol } from '../protocol.js';
@@ -9,6 +10,7 @@ export type HubTunnelSyncListenerParams = {
9
10
  groupID: string;
10
11
  localDID: string;
11
12
  peerDID: string;
13
+ identity: OwnIdentity;
12
14
  syncHandlers: ProcedureHandlers<SyncProtocol>;
13
15
  getRandomID?: () => string;
14
16
  idleTimeoutMs?: number;
@@ -1 +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)})}}}}
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 +1 @@
1
- import{asType as t,createValidator as e}from"@enkaku/schema";import{verifyToken as a}from"@enkaku/token";import{computeMutationHash as i}from"@kubun/engine";import{DocumentID as o}from"@kubun/id";import{documentMutation as n}from"@kubun/protocol";import{getGraphStore as r}from"@kubun/store-graph";import{getP2PStore as u}from"@kubun/store-p2p";import{createReceiveAccessGate as s}from"./receive-access-gate.js";let l=e(n);export async function applySyncMutations(e){let n,{db:c,graph:p,mutationJWTs:d}=e,m=await r(c);if("drop"===(e.storeUnreadable??"persist")){if(null==e.selfDID)throw Error("applySyncMutations: 'storeUnreadable: drop' requires selfDID");let t=await u(c);n=s({selfDID:e.selfDID,db:{getUserModelAccessDefault:(t,e,a)=>m.getUserModelAccessDefault(t,e,a),isMemberOfAnyCircle:(e,a)=>t.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>t.isMemberOfAnyGroup(e,a)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}})}let f=0,h=0,y=0,w=0,g=0;for(let e of d){let r,u=i(e);if(await m.hasMutationHash(u)){w++;continue}try{let i=await a(e);r=t(l,i.payload)}catch{h++;continue}let s=r.sub,c=o.fromString(s).model.toString(),d=o.fromString(s);if(null==await m.getDocument(d)&&"change"===r.typ){await m.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"pending"}),y++;continue}try{if((await p.applyVerifiedMutation({token:e,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){g++;continue}if(f++,"set"===r.typ)for(let t of(await m.getPendingMutations(s)))try{if((await p.applyVerifiedMutation({token:t.mutation_jwt,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){await m.updateMutationStatus(t.mutation_hash,"rejected"),g++;continue}await m.updateMutationStatus(t.mutation_hash,"applied"),f++}catch{await m.updateMutationStatus(t.mutation_hash,"rejected"),h++}}catch{await m.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"rejected"}),h++}}return{applied:f,rejected:h,pending:y,skipped:w,dropped:g}}
1
+ import{asType as t,createValidator as e}from"@enkaku/schema";import{verifyToken as a}from"@enkaku/token";import{computeMutationHash as i}from"@kubun/engine";import{DocumentID as o}from"@kubun/id";import{documentMutation as n}from"@kubun/protocol";import{getGraphStore as r}from"@kubun/store-graph";import{getP2PStore as u}from"@kubun/store-p2p";import{createReceiveAccessGate as s}from"./receive-access-gate.js";let l=e(n);export async function applySyncMutations(e){let n,{db:c,graph:d,mutationJWTs:p}=e,f=await r(c);if("drop"===(e.storeUnreadable??"persist")){if(null==e.selfDID)throw Error("applySyncMutations: 'storeUnreadable: drop' requires selfDID");let t=await u(c);n=s({selfDID:e.selfDID,db:{getUserModelAccessDefault:(t,e,a)=>f.getUserModelAccessDefault(t,e,a),isMemberOfAnyCircle:(e,a)=>t.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>t.isMemberOfAnyGroup(e,a),getModelInterfaces:t=>f.getModelInterfaces(t)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}})}let m=0,h=0,y=0,w=0,g=0;for(let e of p){let r,u=i(e);if(await f.hasMutationHash(u)){w++;continue}try{let i=await a(e);r=t(l,i.payload)}catch{h++;continue}let s=r.sub,c=o.fromString(s).model.toString(),p=o.fromString(s);if(null==await f.getDocument(p)&&"change"===r.typ){await f.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"pending"}),y++;continue}try{if((await d.applyVerifiedMutation({token:e,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){g++;continue}if(m++,"set"===r.typ)for(let t of(await f.getPendingMutations(s)))try{if((await d.applyVerifiedMutation({token:t.mutation_jwt,origin:"peer",...null!=n?{accessGate:n}:{}})).dropped){await f.updateMutationStatus(t.mutation_hash,"rejected"),g++;continue}await f.updateMutationStatus(t.mutation_hash,"applied"),m++}catch{await f.updateMutationStatus(t.mutation_hash,"rejected"),h++}}catch{await f.insertMutationLogEntry({mutation_hash:u,model_id:c,document_id:s,author_did:r.iss,hlc:r.hlc,mutation_jwt:e,status:"rejected"}),h++}}return{applied:m,rejected:h,pending:y,skipped:w,dropped:g}}
@@ -1,14 +1,20 @@
1
+ import type { VerifyTokenHook } from '@enkaku/capability';
2
+ import type { SignedPayload } from '@enkaku/token';
1
3
  import type { KubunDB } from '@kubun/db';
2
4
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
5
  import type { Logger } from '@kubun/logger';
4
6
  import type { SyncMerkleSyncParams, SyncMerkleSyncReceive, SyncMerkleSyncResult, SyncMerkleSyncSend } from '../protocol.js';
5
7
  import type { StoreUnreadableMode } from '../types.js';
8
+ import { type PeerRegistry } from './peer-registry.js';
6
9
  /**
7
10
  * Minimal handler context shape for sync/merkle-sync channel.
8
11
  * Matches the Enkaku ChannelHandlerContext at runtime.
9
12
  */
10
13
  type MerkleSyncChannelContext = {
11
14
  param: SyncMerkleSyncParams;
15
+ message: {
16
+ payload: SignedPayload;
17
+ };
12
18
  readable: ReadableStream<SyncMerkleSyncSend>;
13
19
  writable: WritableStream<SyncMerkleSyncReceive>;
14
20
  };
@@ -16,6 +22,13 @@ export type CreateMerkleSyncChannelHandlerParams = {
16
22
  db: KubunDB;
17
23
  graph: GraphInternals;
18
24
  logger: Logger;
25
+ /**
26
+ * Per-peer policy registry. The requested direction is re-resolved against
27
+ * the peer's `allowedDirection` here: merkle-sync is the fail-closed backstop
28
+ * for a client that skips negotiate, so a disallowed direction is denied
29
+ * rather than downgraded.
30
+ */
31
+ peerRegistry: PeerRegistry;
19
32
  /**
20
33
  * Local peer's DID — required when `storeUnreadable === 'drop'` so the
21
34
  * receive-time access gate can use it as viewer.
@@ -28,6 +41,12 @@ export type CreateMerkleSyncChannelHandlerParams = {
28
41
  storeUnreadable?: StoreUnreadableMode;
29
42
  /** Server default access level — required when `storeUnreadable === 'drop'`. */
30
43
  defaultAccessLevel?: DefaultAccessLevel;
44
+ /**
45
+ * Resolves the revocation checker used to authorize delegated scopes. Shared
46
+ * with the negotiate handler so the two access gates use the same revocation
47
+ * state and cannot drift.
48
+ */
49
+ getRevocationChecker?: () => Promise<VerifyTokenHook>;
31
50
  };
32
51
  export declare function createMerkleSyncChannelHandler(params: CreateMerkleSyncChannelHandlerParams): (ctx: MerkleSyncChannelContext) => Promise<SyncMerkleSyncResult>;
33
52
  export {};
@@ -1 +1 @@
1
- import{getGraphStore as e}from"@kubun/store-graph";import{applySyncMutations as t}from"./merkle-apply.js";import{buildMerkleTree as r,findDivergentBuckets as a,getTimeBuckets as l,SYNC_BATCH_SIZE as n}from"./merkle-tree.js";export function createMerkleSyncChannelHandler(i){let{db:s,graph:c,logger:o}=i,u=i.storeUnreadable??"persist";return async m=>{let{scopes:p,excludedDocumentIDs:y,tree:f,direction:w}=m.param,d=m.writable.getWriter();o.info("sync/merkle-sync channel started",{scopes:p,excludedDocumentIDs:y,direction:w});let k=0,h=0,g=0;try{let b=await e(s),v=await b.getDocumentIDsForScope(p,y),D=await b.getMutationLogForDocuments(v),W=r(D);await d.write({type:"tree",tree:W.buckets});let J={root:f.root??"",buckets:f},L=a(W,J);k=L.length;let S=async()=>{if("pull"===w||"both"===w){let e=new Set(L),t=D.filter(t=>{let{minute:r}=l(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=n){let r=t.slice(e,e+n);await d.write({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),h+=r.length}await d.write({type:"complete"})}else await d.write({type:"complete"})},T=async()=>{let e=[],t=m.readable.getReader();try{for(;;){let{done:r,value:a}=await t.read();if(r)break;if("mutations"===a.type&&null!=a.mutationJWTs)e.push(...a.mutationJWTs);else if("complete"===a.type)break}}finally{t.releaseLock()}return e},[,j]=await Promise.all([S(),T()]);if(j.length>0){let e=await t({db:s,graph:c,mutationJWTs:j,selfDID:i.selfDID,storeUnreadable:u,defaultAccessLevel:i.defaultAccessLevel});g=e.applied+e.pending,o.info("sync/merkle-sync: applied received mutations",{result:e})}}catch(e){return o.error("sync/merkle-sync channel error",{error:e}),{success:!1,divergentBuckets:0,mutationsSent:0,mutationsReceived:0}}finally{try{await d.close()}catch{}}return o.info("sync/merkle-sync channel completed",{divergentBuckets:k,mutationsSent:h,mutationsReceived:g}),{success:!0,divergentBuckets:k,mutationsSent:h,mutationsReceived:g}}}
1
+ import{getGraphStore as e}from"@kubun/store-graph";import{authorizeScope as t}from"./authorize.js";import{SyncAccessDeniedError as r}from"./errors.js";import{applySyncMutations as i}from"./merkle-apply.js";import{buildMerkleTree as n,findDivergentBuckets as a,getTimeBuckets as s,SYNC_BATCH_SIZE as o}from"./merkle-tree.js";import{negotiateDirection as l}from"./peer-registry.js";export function createMerkleSyncChannelHandler(c){let{db:d,graph:u,logger:m}=c,p=c.storeUnreadable??"persist";return async y=>{let{scopes:f,excludedDocumentIDs:w,tree:g,direction:h}=y.param,k=y.message?.payload;if(k?.iss==null)throw new r("unsigned","Sync access denied: missing verified signer");if(null!=k.sub&&k.sub!==k.iss)throw new r("forged-subject","Sync access denied: signed payload subject does not match issuer");let D=k.iss,b=null!=c.getRevocationChecker?await c.getRevocationChecker():void 0;for(let e of f)if(!await t({viewerDID:D,ownerDID:e.ownerDID,delegationTokens:y.param.delegationTokens??[],revocationChecker:b}))throw new r("scope-denied",`Sync access denied: caller not authorized for scope ${e.modelID}/${e.ownerDID}`);let v=h??"pull",j=await c.peerRegistry.getPeer({peerDID:D,stores:d});if(l(v,j?.allowedDirection??"both")!==v)throw new r("direction-denied",`Sync access denied: direction ${v} not permitted by peer policy`);let I=y.writable.getWriter();m.info("sync/merkle-sync channel started",{scopes:f,excludedDocumentIDs:w,direction:h});let S=0,T=0,R=0;try{let t=await e(d),r=await t.getDocumentIDsForScope(f,w),l=await t.getMutationLogForDocuments(r),k=n(l);await I.write({type:"tree",tree:k.buckets});let D={root:g.root??"",buckets:g},b=a(k,D);S=b.length;let v=async()=>{if("pull"===h||"both"===h){let e=new Set(b),t=l.filter(t=>{let{minute:r}=s(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=o){let r=t.slice(e,e+o);await I.write({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),T+=r.length}await I.write({type:"complete"})}else await I.write({type:"complete"})},j=async()=>{let e=[],t=y.readable.getReader();try{for(;;){let{done:r,value:i}=await t.read();if(r)break;if("mutations"===i.type&&null!=i.mutationJWTs)e.push(...i.mutationJWTs);else if("complete"===i.type)break}}finally{t.releaseLock()}return e},[,W]=await Promise.all([v(),j()]);if(W.length>0){let e=await i({db:d,graph:u,mutationJWTs:W,selfDID:c.selfDID,storeUnreadable:p,defaultAccessLevel:c.defaultAccessLevel});R=e.applied+e.pending,m.info("sync/merkle-sync: applied received mutations",{result:e})}}catch(e){return m.error("sync/merkle-sync channel error",{error:e}),{success:!1,divergentBuckets:0,mutationsSent:0,mutationsReceived:0}}finally{try{await I.close()}catch{}}return m.info("sync/merkle-sync channel completed",{divergentBuckets:S,mutationsSent:T,mutationsReceived:R}),{success:!0,divergentBuckets:S,mutationsSent:T,mutationsReceived:R}}}
@@ -1,6 +1,13 @@
1
1
  import type { Runtime } from '@enkaku/runtime';
2
2
  import type { StoreProvider } from '@kubun/db';
3
3
  import type { SyncDirection } from '../protocol.js';
4
+ /**
5
+ * Resolve the effective sync direction given what the initiator requested
6
+ * and what the responder's per-peer policy allows.
7
+ * If allowed is 'both' (or unset), the request passes through.
8
+ * Otherwise the responder's policy wins.
9
+ */
10
+ export declare function negotiateDirection(requested: SyncDirection, allowed: SyncDirection): SyncDirection;
4
11
  export type PeerConfig = {
5
12
  peerDID: string;
6
13
  endpoint: string;
@@ -1 +1 @@
1
- import{getP2PStore as e}from"@kubun/store-p2p";function t(e){var t,r;let i="string"==typeof(t=e.config)?JSON.parse(t):t??{};return{id:e.id,peerDID:e.peer_did,endpoint:e.endpoint,mode:e.mode,allowedUsers:"string"==typeof(r=e.allowed_users)?JSON.parse(r):r,priority:e.priority,trustLevel:e.trust_level,allowedDirection:i.allowedDirection,createdAt:e.created_at,updatedAt:e.updated_at}}export class PeerRegistry{#e;constructor(e){this.#e=e.runtime}async addPeer(t){let r=await e(t.stores);await r.addSyncPeer({id:this.#e.getRandomID(),peer_did:t.config.peerDID,endpoint:t.config.endpoint,mode:t.config.mode,allowed_users:t.config.allowedUsers,priority:t.config.priority,trust_level:t.config.trustLevel,config:{allowedDirection:t.config.allowedDirection},created_at:Date.now(),updated_at:Date.now()})}async getPeer(r){let i=await e(r.stores),o=await i.getSyncPeer(r.peerDID);if(o)return t(o)}async listPeers(r){let i=await e(r.stores);return(await i.listSyncPeers()).map(e=>t(e))}async updatePeer(t){let r=await this.getPeer({peerDID:t.peerDID,stores:t.stores});if(!r)throw Error(`Peer ${t.peerDID} not found`);let i={...r,...t.updates},o=await e(t.stores);await o.updateSyncPeer(t.peerDID,{endpoint:i.endpoint,mode:i.mode,allowed_users:i.allowedUsers,priority:i.priority,trust_level:i.trustLevel,config:{allowedDirection:i.allowedDirection}})}async removePeer(t){let r=await e(t.stores);await r.removeSyncPeer(t.peerDID)}async isPeerAllowed(e){return void 0!==await this.getPeer({peerDID:e.peerDID,stores:e.stores})}}
1
+ import{getP2PStore as e}from"@kubun/store-p2p";export function negotiateDirection(e,t){return"both"===t?e:"both"===e?t:e===t?e:t}function t(e){var t,r;let o="string"==typeof(t=e.config)?JSON.parse(t):t??{};return{id:e.id,peerDID:e.peer_did,endpoint:e.endpoint,mode:e.mode,allowedUsers:"string"==typeof(r=e.allowed_users)?JSON.parse(r):r,priority:e.priority,trustLevel:e.trust_level,allowedDirection:o.allowedDirection,createdAt:e.created_at,updatedAt:e.updated_at}}export class PeerRegistry{#e;constructor(e){this.#e=e.runtime}async addPeer(t){let r=await e(t.stores);await r.addSyncPeer({id:this.#e.getRandomID(),peer_did:t.config.peerDID,endpoint:t.config.endpoint,mode:t.config.mode,allowed_users:t.config.allowedUsers,priority:t.config.priority,trust_level:t.config.trustLevel,config:{allowedDirection:t.config.allowedDirection},created_at:Date.now(),updated_at:Date.now()})}async getPeer(r){let o=await e(r.stores),i=await o.getSyncPeer(r.peerDID);if(i)return t(i)}async listPeers(r){let o=await e(r.stores);return(await o.listSyncPeers()).map(e=>t(e))}async updatePeer(t){let r=await this.getPeer({peerDID:t.peerDID,stores:t.stores});if(!r)throw Error(`Peer ${t.peerDID} not found`);let o={...r,...t.updates},i=await e(t.stores);await i.updateSyncPeer(t.peerDID,{endpoint:o.endpoint,mode:o.mode,allowed_users:o.allowedUsers,priority:o.priority,trust_level:o.trustLevel,config:{allowedDirection:o.allowedDirection}})}async removePeer(t){let r=await e(t.stores);await r.removeSyncPeer(t.peerDID)}async isPeerAllowed(e){return void 0!==await this.getPeer({peerDID:e.peerDID,stores:e.stores})}}
@@ -62,6 +62,8 @@ export declare class SyncClient {
62
62
  hlc: string;
63
63
  mutation_jwt: string;
64
64
  }>;
65
+ /** Delegation JWTs authorizing the caller for non-owned scopes. */
66
+ delegationTokens?: Array<string>;
65
67
  }): Promise<{
66
68
  mutationJWTs: Array<string>;
67
69
  divergentBuckets: number;
@@ -1 +1 @@
1
- import{Client as e}from"@enkaku/client";import{ClientTransport as t}from"@enkaku/http-client-transport";import{findDivergentBuckets as r,getTimeBuckets as n,SYNC_BATCH_SIZE as i}from"./merkle-tree.js";export class SyncClient{#e;#t;#r;#n;constructor(e){this.#e=e.runtime,this.#t=e.identity,this.#r=e.logger,this.#n=e.serverResolver}async connect(e,t){if(e.startsWith("direct://"))return this.#i(e);if(e.startsWith("http://")||e.startsWith("https://"))return this.#s(e,t);throw Error(`Unsupported endpoint scheme: ${e}`)}#s(r,n){let i=new t({url:r,fetch:this.#e.fetch});return new e({runtime:this.#e,identity:this.#t,logger:this.#r.getChild("sync-client"),serverID:n,transport:i})}#i(t){let r=t.replace("direct://","");if(!this.#n)throw Error("Server resolver not configured for direct transport");let n=this.#n(r);if(!n)throw Error(`Server not found: ${r}`);let i=n.createSyncTransport();return new e({runtime:this.#e,identity:this.#t,logger:this.#r.getChild("sync-client"),serverID:r,transport:i})}async negotiate(e,t,r=[],n,i){this.#r.debug("Negotiate: sending {scopeCount} scopes",{scopeCount:t.length});let s=await e.request("sync/negotiate",{param:{scopes:t,delegationTokens:r,knownModelIDs:n,direction:i}});return this.#r.debug("Negotiate: accepted {acceptedCount} scopes, excluded {excludedCount} docs",{acceptedCount:s.acceptedScopes.length,excludedCount:s.excludedDocumentIDs.length}),s}async merkleSync(e,t){let{scopes:s,excludedDocumentIDs:o,localTree:c,direction:l,localEntries:a}=t;this.#r.debug("MerkleSync: creating channel with {buckets} local buckets",{buckets:Object.keys(c.buckets).length});let u=e.createChannel("sync/merkle-sync",{param:{scopes:s,excludedDocumentIDs:o,tree:c.buckets,direction:l}}),h=u.readable.getReader(),d=await h.read();if(d.done)throw Error("MerkleSync: channel closed before receiving remote tree");let g=d.value;if("tree"!==g.type)throw Error(`MerkleSync: expected 'tree' message, got '${g.type}'`);let p=r(c,{root:g.tree?.root??"",buckets:g.tree??{}}),m=p.length;this.#r.debug("MerkleSync: found {count} divergent buckets",{count:m});let y=0,f=async()=>{if("push"===l||"both"===l){let e=new Set(p),t=a.filter(t=>{let{minute:r}=n(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=i){let r=t.slice(e,e+i);await u.send({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),y+=r.length}}await u.send({type:"complete"})},k=async()=>{let e=[];for(;;){let{done:t,value:r}=await h.read();if(t)break;if("mutations"===r.type&&null!=r.mutationJWTs)e.push(...r.mutationJWTs);else if("complete"===r.type)break}return h.releaseLock(),e},[,v]=await Promise.all([f(),k()]),w=await u;if(null!=w&&!w.success)throw Error("MerkleSync: responder reported failure");return this.#r.debug("MerkleSync: completed with {received} received, {sent} sent",{received:v.length,sent:y}),{mutationJWTs:v,divergentBuckets:m,mutationsSent:y}}}
1
+ import{Client as e}from"@enkaku/client";import{ClientTransport as t}from"@enkaku/http-client-transport";import{findDivergentBuckets as r,getTimeBuckets as n,SYNC_BATCH_SIZE as i}from"./merkle-tree.js";export class SyncClient{#e;#t;#r;#n;constructor(e){this.#e=e.runtime,this.#t=e.identity,this.#r=e.logger,this.#n=e.serverResolver}async connect(e,t){if(e.startsWith("direct://"))return this.#i(e);if(e.startsWith("http://")||e.startsWith("https://"))return this.#s(e,t);throw Error(`Unsupported endpoint scheme: ${e}`)}#s(r,n){if(null==n||""===n)throw Error("HTTP sync transport requires a serverID (peer DID) to authenticate; without it the server rejects all requests");let i=new t({url:r,fetch:this.#e.fetch});return new e({runtime:this.#e,identity:this.#t,logger:this.#r.getChild("sync-client"),serverID:n,transport:i})}#i(t){let r=t.replace("direct://","");if(!this.#n)throw Error("Server resolver not configured for direct transport");let n=this.#n(r);if(!n)throw Error(`Server not found: ${r}`);let i=n.createSyncTransport();return new e({runtime:this.#e,identity:this.#t,logger:this.#r.getChild("sync-client"),serverID:r,transport:i})}async negotiate(e,t,r=[],n,i){this.#r.debug("Negotiate: sending {scopeCount} scopes",{scopeCount:t.length});let s=await e.request("sync/negotiate",{param:{scopes:t,delegationTokens:r,knownModelIDs:n,direction:i}});return this.#r.debug("Negotiate: accepted {acceptedCount} scopes, excluded {excludedCount} docs",{acceptedCount:s.acceptedScopes.length,excludedCount:s.excludedDocumentIDs.length}),s}async merkleSync(e,t){let{scopes:s,excludedDocumentIDs:o,localTree:c,direction:l,localEntries:a,delegationTokens:u}=t;this.#r.debug("MerkleSync: creating channel with {buckets} local buckets",{buckets:Object.keys(c.buckets).length});let h=e.createChannel("sync/merkle-sync",{param:{scopes:s,excludedDocumentIDs:o,tree:c.buckets,direction:l,...null!=u&&{delegationTokens:u}}}),d=h.readable.getReader(),g=await d.read();if(g.done)throw Error("MerkleSync: channel closed before receiving remote tree");let p=g.value;if("tree"!==p.type)throw Error(`MerkleSync: expected 'tree' message, got '${p.type}'`);let m=r(c,{root:p.tree?.root??"",buckets:p.tree??{}}),y=m.length;this.#r.debug("MerkleSync: found {count} divergent buckets",{count:y});let f=0,k=async()=>{if("push"===l||"both"===l){let e=new Set(m),t=a.filter(t=>{let{minute:r}=n(t.hlc);return e.has(r)});for(let e=0;e<t.length;e+=i){let r=t.slice(e,e+i);await h.send({type:"mutations",mutationJWTs:r.map(e=>e.mutation_jwt)}),f+=r.length}}await h.send({type:"complete"})},v=async()=>{let e=[];for(;;){let{done:t,value:r}=await d.read();if(t)break;if("mutations"===r.type&&null!=r.mutationJWTs)e.push(...r.mutationJWTs);else if("complete"===r.type)break}return d.releaseLock(),e},[,w]=await Promise.all([k(),v()]),b=await h;if(null!=b&&!b.success)throw Error("MerkleSync: responder reported failure");return this.#r.debug("MerkleSync: completed with {received} received, {sent} sent",{received:w.length,sent:f}),{mutationJWTs:w,divergentBuckets:y,mutationsSent:f}}}
@@ -1 +1 @@
1
- import{EventEmitter as e}from"@enkaku/event";import{isSigningIdentity as t}from"@enkaku/token";import{getGraphStore as s}from"@kubun/store-graph";import{applySyncMutations as r}from"./merkle-apply.js";import{buildMerkleTree as i}from"./merkle-tree.js";import{PeerRegistry as n}from"./peer-registry.js";import{SyncClient as o}from"./sync-client.js";export class SyncManager{#e;#t;#s;#r;#i;#n=new e;#o=new Map;#a=new Map;#l;#c;#g;#p;constructor(e){this.#e=e.deployClusters,this.#t=e.graph,this.#s=e.runtime,this.#l=e.identity,this.#r=e.logger,this.#i=new n({runtime:e.runtime}),this.#c=e.serverResolver,this.#g=e.storeUnreadable??"persist",this.#p=e.defaultAccessLevel}get peerRegistry(){return this.#i}setIdentity(e){this.#l=e}setServerResolver(e){this.#c=e}async addPeer(e){await this.#i.addPeer(e),this.#r.info("Peer added",{peerDID:e.config.peerDID})}async removePeer(e){await this.#i.removePeer(e),this.#r.info("Peer removed",{peerDID:e.peerDID})}async updatePeerConfig(e){await this.#i.updatePeer(e),this.#r.info("Peer updated",{peerDID:e.peerDID})}async listPeers(e){return this.#i.listPeers(e)}async getPeer(e){return this.#i.getPeer(e)}async merkleSyncWithPeer(e){let n,{peerDID:a,scopes:l,delegationTokens:c=[],knownModelIDs:g,direction:p}=e;this.#r.info("Starting Merkle sync with peer",{peerDID:a,scopes:l});let m=await this.#i.getPeer({peerDID:a,stores:e.stores});if(!m)throw Error(`Peer ${a} not found`);let d=`merkle-sync-${a}-${Date.now()}`,h={peerID:a,startTime:Date.now(),documentsAttempted:0,documentsCompleted:0};this.#o.set(d,h),this.#m({type:"started",peerID:a,timestamp:Date.now()});try{let u=this.#l;if(!t(u))throw Error("Signing identity required for Merkle sync");let y=new o({runtime:this.#s,identity:u,logger:this.#r,serverResolver:this.#c});n=await y.connect(m.endpoint,a);let v=p??"pull";this.#r.info("Negotiating sync scopes",{scopes:l,direction:v});let{acceptedScopes:f,excludedDocumentIDs:D,missingClusters:w,direction:k}=await y.negotiate(n,l,c,g,v);if(0===f.length)return this.#r.info("No scopes accepted by peer"),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#o.delete(d),{sessionID:d,divergentBuckets:0,messagesReceived:0,messagesSent:0,missingClusters:w};null!=w&&Object.keys(w).length>0&&(null!=this.#e?(this.#r.info("Deploying missing clusters from peer",{clusterCount:Object.keys(w).length}),await this.#e(w)):this.#r.warn("Missing clusters received but no deployClusters callback configured",{clusterCount:Object.keys(w).length})),this.#r.info("Building local Merkle tree",{acceptedScopes:f});let S=await s(e.stores),P=await S.getDocumentIDsForScope(f,D),R=await S.getMutationLogForDocuments(P),I=i(R),b=k??v;this.#r.info("Requesting Merkle sync from peer",{localTreeBuckets:Object.keys(I.buckets).length,direction:b});let C=await y.merkleSync(n,{scopes:f,excludedDocumentIDs:D,localTree:I,direction:b,localEntries:R});return C.mutationJWTs.length>0&&(this.#r.info("Applying sync mutations",{mutations:C.mutationJWTs.length}),h.documentsCompleted=(await r({db:e.stores,graph:this.#t,mutationJWTs:C.mutationJWTs,selfDID:this.#l.id,storeUnreadable:this.#g,defaultAccessLevel:this.#p})).applied),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#r.info("Merkle sync completed",{divergentBuckets:C.divergentBuckets,mutationsReceived:C.mutationJWTs.length}),{sessionID:d,divergentBuckets:C.divergentBuckets,messagesReceived:C.mutationJWTs.length,messagesSent:C.mutationsSent,missingClusters:w}}catch(e){throw this.#r.error("Merkle sync failed",{peerDID:a,error:e}),this.#m({type:"error",peerID:a,timestamp:Date.now(),error:e instanceof Error?e.message:String(e)}),e}finally{if(null!=n)try{n.abort("SyncComplete"),await n.dispose()}catch{}this.#o.delete(d)}}getStatus(e){let{peerDID:t}=e,s=Array.from(this.#o.values()).filter(e=>!t||e.peerID===t).map(e=>({peerID:e.peerID,startTime:e.startTime,documentsAttempted:e.documentsAttempted,documentsCompleted:e.documentsCompleted})),r={};for(let[e,s]of this.#a.entries())t&&e!==t||(r[e]=s);return{activeSessions:s,lastSyncByPeer:r}}onSyncEvent(e){return this.#n.on("sync",e)}#m(e){this.#n.emit("sync",e).catch(e=>{this.#r.error("Error in sync event listener",{error:e})})}async dispose(){this.#o.clear(),this.#a.clear()}}
1
+ import{EventEmitter as e}from"@enkaku/event";import{isSigningIdentity as t}from"@enkaku/token";import{getGraphStore as s}from"@kubun/store-graph";import{applySyncMutations as r}from"./merkle-apply.js";import{buildMerkleTree as i}from"./merkle-tree.js";import{PeerRegistry as n}from"./peer-registry.js";import{SyncClient as o}from"./sync-client.js";export class SyncManager{#e;#t;#s;#r;#i;#n=new e;#o=new Map;#a=new Map;#l;#c;#g;#p;constructor(e){this.#e=e.deployClusters,this.#t=e.graph,this.#s=e.runtime,this.#l=e.identity,this.#r=e.logger,this.#i=new n({runtime:e.runtime}),this.#c=e.serverResolver,this.#g=e.storeUnreadable??"persist",this.#p=e.defaultAccessLevel}get peerRegistry(){return this.#i}setIdentity(e){this.#l=e}setServerResolver(e){this.#c=e}async addPeer(e){await this.#i.addPeer(e),this.#r.info("Peer added",{peerDID:e.config.peerDID})}async removePeer(e){await this.#i.removePeer(e),this.#r.info("Peer removed",{peerDID:e.peerDID})}async updatePeerConfig(e){await this.#i.updatePeer(e),this.#r.info("Peer updated",{peerDID:e.peerDID})}async listPeers(e){return this.#i.listPeers(e)}async getPeer(e){return this.#i.getPeer(e)}async merkleSyncWithPeer(e){let n,{peerDID:a,scopes:l,delegationTokens:c=[],knownModelIDs:g,direction:p}=e;this.#r.info("Starting Merkle sync with peer",{peerDID:a,scopes:l});let m=await this.#i.getPeer({peerDID:a,stores:e.stores});if(!m)throw Error(`Peer ${a} not found`);let d=`merkle-sync-${a}-${Date.now()}`,h={peerID:a,startTime:Date.now(),documentsAttempted:0,documentsCompleted:0};this.#o.set(d,h),this.#m({type:"started",peerID:a,timestamp:Date.now()});try{let u=this.#l;if(!t(u))throw Error("Signing identity required for Merkle sync");let y=new o({runtime:this.#s,identity:u,logger:this.#r,serverResolver:this.#c});n=await y.connect(m.endpoint,a);let v=p??"pull";this.#r.info("Negotiating sync scopes",{scopes:l,direction:v});let{acceptedScopes:f,excludedDocumentIDs:D,missingClusters:w,direction:k}=await y.negotiate(n,l,c,g,v);if(0===f.length)return this.#r.info("No scopes accepted by peer"),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#o.delete(d),{sessionID:d,divergentBuckets:0,messagesReceived:0,messagesSent:0,missingClusters:w};null!=w&&Object.keys(w).length>0&&(null!=this.#e?(this.#r.info("Deploying missing clusters from peer",{clusterCount:Object.keys(w).length}),await this.#e(w)):this.#r.warn("Missing clusters received but no deployClusters callback configured",{clusterCount:Object.keys(w).length})),this.#r.info("Building local Merkle tree",{acceptedScopes:f});let S=await s(e.stores),P=await S.getDocumentIDsForScope(f,D),R=await S.getMutationLogForDocuments(P),I=i(R),b=k??v;this.#r.info("Requesting Merkle sync from peer",{localTreeBuckets:Object.keys(I.buckets).length,direction:b});let C=await y.merkleSync(n,{scopes:f,excludedDocumentIDs:D,localTree:I,direction:b,localEntries:R,delegationTokens:c});return C.mutationJWTs.length>0&&(this.#r.info("Applying sync mutations",{mutations:C.mutationJWTs.length}),h.documentsCompleted=(await r({db:e.stores,graph:this.#t,mutationJWTs:C.mutationJWTs,selfDID:this.#l.id,storeUnreadable:this.#g,defaultAccessLevel:this.#p})).applied),this.#a.set(a,Date.now()),this.#m({type:"completed",peerID:a,timestamp:Date.now()}),this.#r.info("Merkle sync completed",{divergentBuckets:C.divergentBuckets,mutationsReceived:C.mutationJWTs.length}),{sessionID:d,divergentBuckets:C.divergentBuckets,messagesReceived:C.mutationJWTs.length,messagesSent:C.mutationsSent,missingClusters:w}}catch(e){throw this.#r.error("Merkle sync failed",{peerDID:a,error:e}),this.#m({type:"error",peerID:a,timestamp:Date.now(),error:e instanceof Error?e.message:String(e)}),e}finally{if(null!=n)try{n.abort("SyncComplete"),await n.dispose()}catch{}this.#o.delete(d)}}getStatus(e){let{peerDID:t}=e,s=Array.from(this.#o.values()).filter(e=>!t||e.peerID===t).map(e=>({peerID:e.peerID,startTime:e.startTime,documentsAttempted:e.documentsAttempted,documentsCompleted:e.documentsCompleted})),r={};for(let[e,s]of this.#a.entries())t&&e!==t||(r[e]=s);return{activeSessions:s,lastSyncByPeer:r}}onSyncEvent(e){return this.#n.on("sync",e)}#m(e){this.#n.emit("sync",e).catch(e=>{this.#r.error("Error in sync event listener",{error:e})})}async dispose(){this.#o.clear(),this.#a.clear()}}