@kubun/plugin-p2p 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/lib/context/group.js +1 -1
  2. package/lib/context/hub.d.ts +4 -0
  3. package/lib/context/hub.js +1 -0
  4. package/lib/context/join.js +1 -1
  5. package/lib/context/sync.js +1 -1
  6. package/lib/context/types.d.ts +12 -0
  7. package/lib/groups/broadcast-service.d.ts +71 -17
  8. package/lib/groups/broadcast-service.js +1 -1
  9. package/lib/groups/broadcast.d.ts +112 -5
  10. package/lib/groups/broadcast.js +1 -1
  11. package/lib/groups/events.d.ts +11 -0
  12. package/lib/groups/group-handle-registry.d.ts +90 -0
  13. package/lib/groups/group-handle-registry.js +1 -0
  14. package/lib/groups/invite-payload.d.ts +5 -1
  15. package/lib/groups/join-utils.d.ts +2 -8
  16. package/lib/groups/join-utils.js +1 -1
  17. package/lib/groups/manager.d.ts +20 -14
  18. package/lib/groups/manager.js +1 -1
  19. package/lib/groups/mls-codec.d.ts +7 -0
  20. package/lib/groups/mls-codec.js +1 -0
  21. package/lib/groups/mls-encryptor.d.ts +25 -0
  22. package/lib/groups/mls-encryptor.js +1 -0
  23. package/lib/hub/connection-pool.d.ts +24 -0
  24. package/lib/hub/connection-pool.js +1 -1
  25. package/lib/hub/did-observing-transport.d.ts +64 -0
  26. package/lib/hub/did-observing-transport.js +1 -0
  27. package/lib/hub/errors.d.ts +28 -0
  28. package/lib/hub/errors.js +1 -0
  29. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  30. package/lib/hub/forward-remote-broadcast.js +1 -0
  31. package/lib/hub/group-channel.d.ts +29 -21
  32. package/lib/hub/group-channel.js +1 -1
  33. package/lib/hub/http-client.d.ts +17 -0
  34. package/lib/hub/http-client.js +1 -0
  35. package/lib/hub/hub-connection.d.ts +96 -0
  36. package/lib/hub/hub-connection.js +1 -0
  37. package/lib/hub/manager.d.ts +117 -0
  38. package/lib/hub/manager.js +1 -0
  39. package/lib/hub/receive-handler.d.ts +3 -6
  40. package/lib/hub/receive-handler.js +1 -1
  41. package/lib/hub/relay-manager.d.ts +85 -2
  42. package/lib/hub/relay-manager.js +1 -1
  43. package/lib/hub/send-handler.d.ts +12 -16
  44. package/lib/hub/send-handler.js +1 -1
  45. package/lib/hub/tunnel-inbox.d.ts +20 -0
  46. package/lib/hub/tunnel-inbox.js +1 -0
  47. package/lib/hub/wait-for-gate.d.ts +14 -0
  48. package/lib/hub/wait-for-gate.js +1 -0
  49. package/lib/hub/wiring.d.ts +73 -0
  50. package/lib/hub/wiring.js +1 -0
  51. package/lib/index.d.ts +102 -6
  52. package/lib/index.js +1 -1
  53. package/lib/schema.js +68 -20
  54. package/lib/sync/broadcast-queue.d.ts +59 -0
  55. package/lib/sync/broadcast-queue.js +1 -0
  56. package/lib/sync/broadcast-sender.d.ts +52 -0
  57. package/lib/sync/broadcast-sender.js +1 -0
  58. package/lib/sync/catalog-match.d.ts +13 -0
  59. package/lib/sync/catalog-match.js +1 -0
  60. package/lib/sync/forwarder.d.ts +96 -0
  61. package/lib/sync/forwarder.js +1 -0
  62. package/lib/sync/handlers.d.ts +15 -0
  63. package/lib/sync/handlers.js +1 -1
  64. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  65. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  66. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  67. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  68. package/lib/sync/merkle-apply.d.ts +41 -0
  69. package/lib/sync/merkle-apply.js +1 -1
  70. package/lib/sync/merkle-channel.d.ts +15 -0
  71. package/lib/sync/merkle-channel.js +1 -1
  72. package/lib/sync/receive-access-gate.d.ts +36 -0
  73. package/lib/sync/receive-access-gate.js +1 -0
  74. package/lib/sync/scope-resolver.d.ts +32 -0
  75. package/lib/sync/scope-resolver.js +1 -0
  76. package/lib/sync/sync-manager.d.ts +10 -0
  77. package/lib/sync/sync-manager.js +1 -1
  78. package/lib/types.d.ts +95 -26
  79. package/lib/util/mutex.d.ts +4 -0
  80. package/lib/util/mutex.js +1 -0
  81. package/package.json +40 -37
@@ -1 +1 @@
1
- import{HLC as e}from"@kubun/hlc";import{getGraphStore as t}from"@kubun/store-graph";import{getP2PStore as r}from"@kubun/store-p2p";import{decodeFullJoinRequest as a,decodeInvitePayload as i,decodeJoinRequest as o,encodeInvitePayload as c}from"../groups/invite-payload.js";import{createGroupFromJoin as n}from"../groups/join-utils.js";import{restoreMLSGroupHandle as d}from"../groups/mls-group-handle.js";import{checkPeerAccess as l,normalizeCredential as p,toISO as s}from"./types.js";export function createGroupContext(m,u){let g=null,w=null,D=()=>g??=r(u.stores),I=()=>w??=t(u.stores);return{p2pCreateGroup:async e=>{let t=await u.groupManager.createGroup({stores:u.stores,identity:u.identity,name:e.name,description:e.description,createdBy:m.viewerDID}),r=await D(),a=await r.getGroup(t.groupID);if(null==a)throw Error("Failed to create group");let i={id:a.id,name:a.name,description:a.description,createdBy:a.created_by,createdAt:s(a.created_at)};return await u.emitter.emit("groupJoined",i),i},p2pGetGroups:async()=>{let e=await D();return(await e.listGroups()).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:s(e.created_at)}))},p2pGetGroup:async e=>{let t=await D(),r=await t.getGroup(e);return null==r?null:{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:s(r.created_at)}},p2pAddGroupMember:async(e,t,r)=>{let a=await D();await a.addGroupMember({group_id:e,member_did:t,role:r});let i=await a.getGroupMember(e,t);if(null==i)throw Error("Failed to add group member");let o={groupID:i.group_id,memberDID:i.member_did,role:i.role,createdAt:s(i.created_at)};return await u.emitter.emit("groupMemberJoined",{...o,groupID:o.groupID}),o},p2pGetGroupMembers:async e=>{let t=await D();return(await t.listGroupMembers(e)).map(e=>({groupID:e.group_id,memberDID:e.member_did,role:e.role,createdAt:s(e.created_at)}))},p2pCreateCircle:async e=>{let t=await u.groupManager.createCircle({stores:u.stores,groupID:e.groupID,name:e.name,description:e.description}),r=await D(),a=await r.getCircle(t.circleID);if(null==a)throw Error("Failed to create circle");let i={id:a.id,groupID:a.group_id,name:a.name,description:a.description,createdAt:s(a.created_at)};return await u.emitter.emit("circleCreated",{...i,groupID:i.groupID}),i},p2pGetCircles:async e=>{let t=await D();return(await t.listCirclesByGroup(e)).map(e=>({id:e.id,groupID:e.group_id,name:e.name,description:e.description,createdAt:s(e.created_at)}))},p2pAddCircleMember:async(t,r,a)=>{let i=await D(),o=e.serialize(u.hlc.now());await i.addCircleMember({circle_id:t,member_did:r,role:a,hlc:o});let c=(await i.listCircleMembers(t)).find(e=>e.member_did===r);if(null==c)throw Error("Failed to add circle member");let n={circleID:c.circle_id,memberDID:c.member_did,role:c.role,createdAt:s(c.created_at)};return await u.emitter.emit("circleMemberAdded",{...n,circleID:n.circleID}),n},p2pCreateCatalog:async t=>{let r=await I(),a=u.runtime.getRandomID(),i=e.serialize(u.hlc.now());await r.createCatalog({id:a,owner_did:m.viewerDID,name:t.name,description:t.description??"",filter_criteria:JSON.parse(t.filterCriteria),hlc:i});let o=await r.getCatalog(a);if(null==o)throw Error("Failed to create catalog");return{id:o.id,ownerDID:o.owner_did,name:o.name,description:o.description,filterCriteria:JSON.stringify(o.filter_criteria),createdAt:s(o.created_at)}},p2pGetCatalogs:async e=>{let t=await I();if(null!=e)return(await t.listCatalogs(e)).map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:s(e.created_at)}));let r=await D(),a=new Set((await r.listGroups()).map(e=>e.created_by));return(await Promise.all(Array.from(a).map(e=>t.listCatalogs(e)))).flat().map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:s(e.created_at)}))},p2pInviteToGroup:async(e,t)=>{l(m.viewerDID,u.identity.id,u.autoAcceptPeers);let r=await D(),a=u.identity,i=await r.getMLSState(e,a.id);if(null==i)throw Error(`No MLS state for group ${e}`);let n=await d({mlsState:i.mls_state,credential:p(i.credential),epoch:i.epoch,rootCapability:i.root_capability}),s=o(t),g=await u.groupManager.inviteToGroup({stores:u.stores,groupID:e,identity:a,groupHandle:n,recipientDID:s.did,recipientKeyPackage:s.publicPackage,permission:"member"}),w=await r.getGroup(e),I=c({groupID:e,groupName:w?.name??"",hubURLs:w?.hub_urls??[],invite:g.invite,welcomeMessage:g.welcomeMessage,ratchetTree:g.updatedGroupHandle.state.ratchetTree});return await u.emitter.emit("groupMemberJoined",{groupID:e,memberDID:s.did,role:"member",createdAt:new Date().toISOString()}),{invitePayload:I,groupID:e,groupName:w?.name??""}},p2pJoinGroup:async(e,t)=>{let r=u.identity,o=i(e),c=a(t),d={publicPackage:c.publicPackage,privatePackage:c.privatePackage,ownerDID:c.did};return await u.groupManager.joinGroup({stores:u.stores,identity:r,groupID:o.groupID,groupName:o.groupName,hubURLs:o.hubURLs,invite:o.invite,keyPackageBundle:d,welcomeMessage:o.welcomeMessage,ratchetTree:o.ratchetTree}),await n({stores:u.stores,hlc:u.hlc,emitter:u.emitter,identity:r,invite:o})},p2pLeaveGroup:async e=>{let t=await D(),r=await t.getGroup(e),a=u.identity;return await u.groupManager.leaveGroup({stores:u.stores,groupID:e,identity:a}),null!=r&&await u.emitter.emit("groupLeft",{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:s(r.created_at),groupID:e}),{groupID:e}},p2pRemoveGroupMember:async(e,t)=>{let r=await D(),a=u.identity,i=await r.getMLSState(e,a.id);if(null==i)throw Error(`No MLS state for group ${e}`);let o=await d({mlsState:i.mls_state,credential:p(i.credential),epoch:i.epoch,rootCapability:i.root_capability});return await u.groupManager.removeGroupMember({stores:u.stores,groupID:e,groupHandle:o,memberDID:t}),await u.emitter.emit("groupMemberLeft",{groupID:e,memberDID:t,role:"",createdAt:new Date().toISOString()}),{groupID:e}},p2pUpdateGroup:async e=>{await u.groupManager.updateGroup({stores:u.stores,groupID:e.groupID,update:{name:e.name,description:e.description}});let t=await D(),r=await t.getGroup(e.groupID);if(null==r)throw Error(`Group ${e.groupID} not found`);let a={id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:s(r.created_at)};return await u.emitter.emit("groupDataChanged",{...a,groupID:e.groupID}),a},p2pUpdateCircle:async e=>{await u.groupManager.updateCircle({stores:u.stores,circleID:e.circleID,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs}});let t=await D(),r=await t.getCircle(e.circleID);if(null==r)throw Error(`Circle ${e.circleID} not found`);let a={id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:s(r.created_at)};return null!=e.catalogIDs?await u.emitter.emit("circleCatalogsChanged",{...a,circleID:e.circleID}):await u.emitter.emit("circleDataChanged",{...a,circleID:e.circleID}),a},p2pUpdateCatalog:async t=>{let r=await I(),a={hlc:e.serialize(u.hlc.now())};null!=t.name&&(a.name=t.name),null!=t.filterCriteria&&(a.filter_criteria=JSON.parse(t.filterCriteria)),await r.updateCatalog(t.catalogID,a);let i=await r.getCatalog(t.catalogID);if(null==i)throw Error(`Catalog ${t.catalogID} not found`);return{id:i.id,ownerDID:i.owner_did,name:i.name,description:i.description,filterCriteria:JSON.stringify(i.filter_criteria),createdAt:s(i.created_at)}},p2pDeleteCatalog:async e=>{let t=await I();return await t.deleteCatalog(e),{deletedID:e}},p2pDeleteCircle:async e=>{let t=await D(),r=await t.getCircle(e);return await u.groupManager.deleteCircle({stores:u.stores,circleID:e}),null!=r&&await u.emitter.emit("circleDeleted",{id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:s(r.created_at)}),{deletedID:e}},p2pRemoveCircleMember:async(e,t)=>(await u.groupManager.removeCircleMember({stores:u.stores,circleID:e,memberDID:t}),await u.emitter.emit("circleMemberRemoved",{circleID:e,memberDID:t,role:"",createdAt:new Date().toISOString()}),{circleID:e,memberDID:t})}}
1
+ import{HLC as e}from"@kubun/hlc";import{getGraphStore as r}from"@kubun/store-graph";import{getP2PStore as t}from"@kubun/store-p2p";import{decodeFullJoinRequest as a,decodeInvitePayload as i,decodeJoinRequest as o,encodeInvitePayload as c}from"../groups/invite-payload.js";import{finalizeJoinedGroup as l}from"../groups/join-utils.js";import{checkPeerAccess as d,toISO as n}from"./types.js";export function createGroupContext(s,m){let p=null,u=null,g=()=>p??=t(m.stores),w=()=>u??=r(m.stores);return{create:async e=>{let r=await m.groupManager.createGroup({stores:m.stores,identity:m.identity,name:e.name,description:e.description,createdBy:s.viewerDID,hubs:e.hubs?.map(e=>({url:e.url,serverDID:e.serverDID??void 0}))}),t=await g(),a=await t.getGroup(r.groupID);if(null==a)throw Error("Failed to create group");let i={id:a.id,name:a.name,description:a.description,createdBy:a.created_by,createdAt:n(a.created_at)};return await m.emitter.emit("groupJoined",i),i},list:async()=>{let e=await g();return(await e.listGroups()).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:n(e.created_at)}))},get:async e=>{let r=await g(),t=await r.getGroup(e);return null==t?null:{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:n(t.created_at)}},addMember:async(e,r,t)=>{let a=await g();await a.addGroupMember({group_id:e,member_did:r,role:t});let i=await a.getGroupMember(e,r);if(null==i)throw Error("Failed to add group member");let o={groupID:i.group_id,memberDID:i.member_did,role:i.role,createdAt:n(i.created_at)};return await m.emitter.emit("groupMemberJoined",{...o,groupID:o.groupID}),o},listMembers:async e=>{let r=await g();return(await r.listGroupMembers(e)).map(e=>({groupID:e.group_id,memberDID:e.member_did,role:e.role,createdAt:n(e.created_at)}))},createCircle:async e=>{let r=await m.groupManager.createCircle({stores:m.stores,groupID:e.groupID,name:e.name,description:e.description}),t=await g(),a=await t.getCircle(r.circleID);if(null==a)throw Error("Failed to create circle");let i={id:a.id,groupID:a.group_id,name:a.name,description:a.description,createdAt:n(a.created_at)};return await m.emitter.emit("circleCreated",{...i,groupID:i.groupID}),m.stores.onCommit(()=>m.scheduleBroadcast(a.group_id,{type:"circle:create",circle:{id:a.id,groupID:a.group_id,name:a.name,description:a.description,catalogIDs:a.catalog_ids,hlc:a.hlc}})),i},listCircles:async e=>{let r=await g();return(await r.listCirclesByGroup(e)).map(e=>({id:e.id,groupID:e.group_id,name:e.name,description:e.description,createdAt:n(e.created_at)}))},addCircleMember:async(r,t,a)=>{let i=await g(),o=e.serialize(m.hlc.now());await i.addCircleMember({circle_id:r,member_did:t,role:a,hlc:o});let c=(await i.listCircleMembers(r)).find(e=>e.member_did===t);if(null==c)throw Error("Failed to add circle member");let l={circleID:c.circle_id,memberDID:c.member_did,role:c.role,createdAt:n(c.created_at)};await m.emitter.emit("circleMemberAdded",{...l,circleID:l.circleID});let d=await i.getCircle(r);if(null!=d)if("admin"===c.role||"member"===c.role){let e=c.role;m.stores.onCommit(()=>m.scheduleBroadcast(d.group_id,{type:"member:add",member:{circleID:c.circle_id,memberDID:c.member_did,role:e,hlc:c.hlc}}))}else m.logger.warn("addCircleMember: skipping broadcast for unrecognized role",{circleID:r,memberDID:t,role:c.role});return l},createCatalog:async r=>{let t=await w(),a=m.runtime.getRandomID(),i=e.serialize(m.hlc.now());await t.createCatalog({id:a,owner_did:s.viewerDID,name:r.name,description:r.description??"",filter_criteria:JSON.parse(r.filterCriteria),hlc:i});let o=await t.getCatalog(a);if(null==o)throw Error("Failed to create catalog");return{id:o.id,ownerDID:o.owner_did,name:o.name,description:o.description,filterCriteria:JSON.stringify(o.filter_criteria),createdAt:n(o.created_at)}},listCatalogs:async e=>{let r=await w();if(null!=e)return(await r.listCatalogs(e)).map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:n(e.created_at)}));let t=await g(),a=new Set((await t.listGroups()).map(e=>e.created_by));return(await Promise.all(Array.from(a).map(e=>r.listCatalogs(e)))).flat().map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:n(e.created_at)}))},invite:async(e,r)=>{d(s.viewerDID,m.identity.id,m.autoAcceptPeers);let t=await g(),a=m.identity,i=o(r),l=await m.groupManager.inviteToGroup({stores:m.stores,groupID:e,identity:a,recipientDID:i.did,recipientKeyPackage:i.publicPackage,permission:"member"}),n=await t.getGroup(e),p=(await t.listHubsByGroupID(e)).map(e=>null==e.server_did?{url:e.url}:{url:e.url,serverDID:e.server_did}),u=c({groupID:e,groupName:n?.name??"",suggestedHubs:p,invite:l.invite,welcomeMessage:l.welcomeMessage,ratchetTree:l.ratchetTree});return await m.emitter.emit("groupMemberJoined",{groupID:e,memberDID:i.did,role:"member",createdAt:new Date().toISOString()}),{invitePayload:u,groupID:e,groupName:n?.name??""}},join:async(e,r)=>{let t=m.identity,o=i(e),c=a(r),d={publicPackage:c.publicPackage,privatePackage:c.privatePackage,ownerDID:c.did};return await m.groupManager.joinGroup({stores:m.stores,identity:t,groupID:o.groupID,groupName:o.groupName,hubs:o.suggestedHubs,invite:o.invite,keyPackageBundle:d,welcomeMessage:o.welcomeMessage,ratchetTree:o.ratchetTree}),await l({stores:m.stores,emitter:m.emitter,invite:o})},leave:async e=>{let r=await g(),t=await r.getGroup(e),a=m.identity;return await m.groupManager.leaveGroup({stores:m.stores,groupID:e,identity:a}),null!=t&&await m.emitter.emit("groupLeft",{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:n(t.created_at),groupID:e}),{groupID:e}},removeMember:async(e,r)=>(await m.groupManager.removeGroupMember({stores:m.stores,groupID:e,memberDID:r}),await m.emitter.emit("groupMemberLeft",{groupID:e,memberDID:r,role:"",createdAt:new Date().toISOString()}),{groupID:e}),update:async e=>{await m.groupManager.updateGroup({stores:m.stores,groupID:e.groupID,update:{name:e.name,description:e.description}});let r=await g(),t=await r.getGroup(e.groupID);if(null==t)throw Error(`Group ${e.groupID} not found`);let a={id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:n(t.created_at)};return await m.emitter.emit("groupDataChanged",{...a,groupID:e.groupID}),m.stores.onCommit(()=>m.scheduleBroadcast(t.id,{type:"group:update",groupID:t.id,update:{name:e.name,description:e.description,hlc:t.hlc}})),a},updateCircle:async e=>{await m.groupManager.updateCircle({stores:m.stores,circleID:e.circleID,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs}});let r=await g(),t=await r.getCircle(e.circleID);if(null==t)throw Error(`Circle ${e.circleID} not found`);let a={id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:n(t.created_at)};return null!=e.catalogIDs?await m.emitter.emit("circleCatalogsChanged",{...a,circleID:e.circleID}):await m.emitter.emit("circleDataChanged",{...a,circleID:e.circleID}),m.stores.onCommit(()=>m.scheduleBroadcast(t.group_id,{type:"circle:update",circleID:t.id,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs,hlc:t.hlc}})),a},updateCatalog:async r=>{let t=await w(),a={hlc:e.serialize(m.hlc.now())};null!=r.name&&(a.name=r.name),null!=r.filterCriteria&&(a.filter_criteria=JSON.parse(r.filterCriteria)),await t.updateCatalog(r.catalogID,a);let i=await t.getCatalog(r.catalogID);if(null==i)throw Error(`Catalog ${r.catalogID} not found`);return{id:i.id,ownerDID:i.owner_did,name:i.name,description:i.description,filterCriteria:JSON.stringify(i.filter_criteria),createdAt:n(i.created_at)}},deleteCatalog:async e=>{let r=await w();return await r.deleteCatalog(e),{deletedID:e}},deleteCircle:async r=>{let t=await g(),a=await t.getCircle(r);if(await m.groupManager.deleteCircle({stores:m.stores,circleID:r}),null!=a){await m.emitter.emit("circleDeleted",{id:a.id,groupID:a.group_id,name:a.name,description:a.description,createdAt:n(a.created_at)});let t=e.serialize(m.hlc.now());m.stores.onCommit(()=>m.scheduleBroadcast(a.group_id,{type:"circle:delete",circleID:r,hlc:t}))}return{deletedID:r}},removeCircleMember:async(r,t)=>{let a=await g(),[i,o]=await Promise.all([a.getCircle(r),a.getCircleMember(r,t)]);if(await m.groupManager.removeCircleMember({stores:m.stores,circleID:r,memberDID:t}),await m.emitter.emit("circleMemberRemoved",{circleID:r,memberDID:t,role:o?.role??"",createdAt:null!=o?n(o.created_at):new Date().toISOString()}),null!=i){let a=e.serialize(m.hlc.now());m.stores.onCommit(()=>m.scheduleBroadcast(i.group_id,{type:"member:remove",circleID:r,memberDID:t,hlc:a}))}return{circleID:r,memberDID:t}}}}
@@ -0,0 +1,4 @@
1
+ import type { ExecutionContext } from '@kubun/engine';
2
+ import type { HubRequestContext } from '../types.js';
3
+ import type { ContextDeps } from './types.js';
4
+ export declare function createHubContext(_ctx: ExecutionContext, deps: ContextDeps): HubRequestContext;
@@ -0,0 +1 @@
1
+ import{getP2PStore as e}from"@kubun/store-p2p";import{bindHubToGroup as t,createHub as r,deleteHub as s,getHub as a,listHubs as u,listHubsByGroup as i,unbindHubFromGroup as l,updateHub as n}from"../hub/manager.js";import{toISO as o}from"./types.js";function d(e){return{id:e.id,label:e.label,url:e.url,serverDID:e.serverDID,createdAt:e.createdAt.toISOString(),updatedAt:null==e.updatedAt?null:e.updatedAt.toISOString()}}export function createHubContext(c,p){return{list:async()=>(await u({stores:p.stores})).map(d),get:async e=>{let t=await a({stores:p.stores,id:e});return null==t?null:d(t)},create:async e=>d(await r({stores:p.stores,url:e.url,label:e.label,serverDID:e.serverDID})),update:async e=>d(await n({stores:p.stores,id:e.id,url:e.url,label:e.label,serverDID:e.serverDID})),delete:async(e,t)=>{let r=await s({stores:p.stores,id:e,force:t});return{success:r.success,boundGroupIDs:r.boundGroupIDs}},bind:async({hubID:e,groupID:r})=>{let s=await t({stores:p.stores,hubID:e,groupID:r});if(s){let t=await a({stores:p.stores,id:e});null!=t&&await p.emitter.emit("hubBound",{groupID:r,hubID:e,hubURL:t.url,serverDID:t.serverDID})}return s},unbind:async({hubID:e,groupID:t})=>{let r=await i({stores:p.stores,groupID:t}),s=r.find(t=>t.id===e)?.url??null,a=await l({stores:p.stores,hubID:e,groupID:t});return a&&null!=s&&await p.emitter.emit("hubUnbound",{groupID:t,hubID:e,hubURL:s}),a},listByGroup:async e=>(await i({stores:p.stores,groupID:e})).map(d),listGroups:async t=>{let r=await e(p.stores);return(await r.listGroupsByHubID(t)).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:o(e.created_at)}))}}}
@@ -1 +1 @@
1
- import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as i}from"../groups/invite-payload.js";import{createGroupFromJoin as r}from"../groups/join-utils.js";import{checkPeerAccess as a}from"./types.js";export function createJoinContext(o,n){return{p2pPrepareJoinRequest:async()=>{a(o.viewerDID,n.identity.id,n.autoAcceptPeers);let t=n.identity,r=await e(t);return n.pendingJoinRequests.set(o.viewerDID,{publicPackage:r.publicPackage,privatePackage:r.privatePackage,did:t.id}),{joinRequest:i({did:t.id,publicPackage:r.publicPackage})}},p2pCompleteJoin:async e=>{a(o.viewerDID,n.identity.id,n.autoAcceptPeers);let i=n.pendingJoinRequests.get(o.viewerDID);if(null==i)throw Error("No pending join request found — call prepareJoinRequest first");let p=n.identity,s=t(e),u={publicPackage:i.publicPackage,privatePackage:i.privatePackage,ownerDID:p.id};await n.groupManager.joinGroup({stores:n.stores,identity:p,groupID:s.groupID,groupName:s.groupName,hubURLs:s.hubURLs,invite:s.invite,keyPackageBundle:u,welcomeMessage:s.welcomeMessage,ratchetTree:s.ratchetTree});let c=await r({stores:n.stores,hlc:n.hlc,emitter:n.emitter,identity:p,invite:s});return n.pendingJoinRequests.delete(o.viewerDID),c}}}
1
+ import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as i}from"../groups/invite-payload.js";import{finalizeJoinedGroup as r}from"../groups/join-utils.js";import{checkPeerAccess as a}from"./types.js";export function createJoinContext(o,s){return{prepareRequest:async()=>{a(o.viewerDID,s.identity.id,s.autoAcceptPeers);let t=s.identity,r=await e(t);return s.pendingJoinRequests.set(o.viewerDID,{publicPackage:r.publicPackage,privatePackage:r.privatePackage,did:t.id}),{joinRequest:i({did:t.id,publicPackage:r.publicPackage})}},complete:async e=>{a(o.viewerDID,s.identity.id,s.autoAcceptPeers);let i=s.pendingJoinRequests.get(o.viewerDID);if(null==i)throw Error("No pending join request found — call prepareJoinRequest first");let n=s.identity,p=t(e),u={publicPackage:i.publicPackage,privatePackage:i.privatePackage,ownerDID:n.id};await s.groupManager.joinGroup({stores:s.stores,identity:n,groupID:p.groupID,groupName:p.groupName,hubs:p.suggestedHubs,invite:p.invite,keyPackageBundle:u,welcomeMessage:p.welcomeMessage,ratchetTree:p.ratchetTree});let c=await r({stores:s.stores,emitter:s.emitter,invite:p});return s.pendingJoinRequests.delete(o.viewerDID),c}}}
@@ -1 +1 @@
1
- export function createSyncContext(e,s){return{syncAddPeer:e=>s.syncManager.addPeer({config:e,stores:s.stores}),syncRemovePeer:e=>s.syncManager.removePeer({peerDID:e,stores:s.stores}),syncListPeers:()=>s.syncManager.listPeers({stores:s.stores}),syncGetStatus:e=>s.syncManager.getStatus({peerDID:e,stores:s.stores})}}
1
+ export function createSyncContext(e,s){return{addPeer:e=>s.syncManager.addPeer({config:e,stores:s.stores}),removePeer:e=>s.syncManager.removePeer({peerDID:e,stores:s.stores}),listPeers:()=>s.syncManager.listPeers({stores:s.stores}),getStatus:e=>s.syncManager.getStatus({peerDID:e,stores:s.stores})}}
@@ -4,7 +4,10 @@ import type { OwnIdentity } from '@enkaku/token';
4
4
  import type { StoreProvider } from '@kubun/db';
5
5
  import type { Adapter } from '@kubun/db-adapter';
6
6
  import type { HLC } from '@kubun/hlc';
7
+ import type { Logger } from '@kubun/logger';
8
+ import type { GroupBroadcastMessage } from '../groups/broadcast.js';
7
9
  import type { GroupEventEmitter } from '../groups/events.js';
10
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
8
11
  import type { GroupManager } from '../groups/manager.js';
9
12
  import type { SyncManager } from '../sync/sync-manager.js';
10
13
  export type PendingJoinRequest = {
@@ -15,6 +18,7 @@ export type PendingJoinRequest = {
15
18
  export type ContextDeps = {
16
19
  identity: OwnIdentity;
17
20
  groupManager: GroupManager;
21
+ registry: GroupHandleRegistry;
18
22
  syncManager: SyncManager;
19
23
  stores: StoreProvider;
20
24
  adapter: Adapter;
@@ -23,6 +27,14 @@ export type ContextDeps = {
23
27
  pendingJoinRequests: Map<string, PendingJoinRequest>;
24
28
  runtime: Runtime;
25
29
  autoAcceptPeers?: Array<string>;
30
+ /**
31
+ * Fire-and-forget hub broadcast trigger. Wired to `HubWiring.scheduleBroadcast`
32
+ * when the p2p plugin is configured with `hub`; otherwise a no-op from
33
+ * `DISABLED_HUB`. Handlers invoke this via `stores.onCommit(...)` so the hub
34
+ * send runs after the mutation transaction commits.
35
+ */
36
+ scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
37
+ logger: Logger;
26
38
  };
27
39
  export declare function toISO(timestamp: unknown): string;
28
40
  /**
@@ -1,8 +1,12 @@
1
1
  import { EventEmitter } from '@enkaku/event';
2
+ import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
+ import type { Logger } from '@kubun/logger';
2
4
  import type { GraphStoreAPI } from '@kubun/store-graph';
3
5
  import type { P2PStoreAPI } from '@kubun/store-p2p';
4
- import { type GroupBroadcastMessage } from './broadcast.js';
5
- import { type SerializedMLSGroupState } from './mls-state.js';
6
+ import type { ForwardingConfig } from '../sync/forwarder.js';
7
+ import type { StoreUnreadableMode } from '../types.js';
8
+ import { type AffectedRow, type GroupBroadcastMessage } from './broadcast.js';
9
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
6
10
  /**
7
11
  * Thrown when a received broadcast fails to decrypt (MLS restore, decrypt,
8
12
  * or deserialize). Callers should typically ack-and-skip these, since the
@@ -25,6 +29,17 @@ export type BroadcastEvent = {
25
29
  groupID: string;
26
30
  message: GroupBroadcastMessage;
27
31
  applied: boolean;
32
+ /**
33
+ * Row affected by the broadcast. After-state for create / update,
34
+ * before-state for delete / remove. Undefined when `applied === false`.
35
+ */
36
+ affected?: AffectedRow;
37
+ /**
38
+ * Count of `mutation:apply` entries the local broadcast log already had.
39
+ * Only set on the `mutation:apply` branch; absent on control-plane
40
+ * variants.
41
+ */
42
+ duplicates?: number;
28
43
  };
29
44
  export type BroadcastServiceEvents = {
30
45
  broadcast: BroadcastEvent;
@@ -32,45 +47,84 @@ export type BroadcastServiceEvents = {
32
47
  export type SendBroadcastParams = {
33
48
  groupID: string;
34
49
  message: GroupBroadcastMessage;
35
- groupState: SerializedMLSGroupState;
36
- };
37
- export type SendBroadcastResult = {
38
- encrypted: Uint8Array;
39
- updatedGroupState: SerializedMLSGroupState;
50
+ /**
51
+ * Wire-send hook invoked with the MLS-encrypted payload INSIDE the
52
+ * registry's per-group lock. A throw here propagates out of `prepareSend`
53
+ * and the registry skips persist — so a failed send leaves the device's
54
+ * MLS state at its pre-encrypt epoch, preventing the wedge where local
55
+ * state outpaces recipients.
56
+ */
57
+ send: (encrypted: Uint8Array) => Promise<void>;
40
58
  };
41
59
  export type ReceiveBroadcastParams = {
42
60
  groupID: string;
43
61
  encrypted: Uint8Array;
44
- groupState: SerializedMLSGroupState;
45
62
  };
46
63
  export type ReceiveBroadcastResult = {
47
64
  message: GroupBroadcastMessage;
48
65
  applied: boolean;
49
- updatedGroupState: SerializedMLSGroupState;
66
+ affected?: AffectedRow;
67
+ duplicates?: number;
50
68
  };
51
69
  /**
52
70
  * BroadcastService handles MLS encrypt/decrypt for group broadcasts
53
71
  * and applies received messages to the local DB.
54
72
  *
55
- * It does NOT manage hub transport directly -- callers are responsible
56
- * for sending encrypted bytes via hub and delivering received bytes
57
- * from hub. This keeps the service testable without hub infrastructure.
73
+ * Encrypt and decrypt go through `GroupHandleRegistry.withHandle` so MLS
74
+ * state mutations serialize through the per-group mutex with write-through
75
+ * persist. The DB-apply step (`processBroadcast`) for receives runs OUTSIDE
76
+ * the lock so a slow apply does not block subsequent encrypt/decrypt on the
77
+ * same group.
58
78
  */
59
79
  export type BroadcastServiceParams = {
60
80
  p2pStore: P2PStoreAPI;
61
81
  graphStore: GraphStoreAPI;
82
+ /**
83
+ * Engine graph internals — required so `mutation:apply` broadcasts route
84
+ * through {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`.
85
+ */
86
+ graph: GraphInternals;
87
+ /** Self DID for `mutation:apply` loopback detection. */
88
+ selfDID: string;
89
+ /** Single canonical access point for MLS `GroupHandle` instances. */
90
+ registry: GroupHandleRegistry;
91
+ /**
92
+ * Receiver storage mode for incoming `mutation:apply` entries. Defaults
93
+ * to `'persist'`. When `'drop'`, `defaultAccessLevel` must be supplied
94
+ * so the gate factory can resolve server-side default access rules.
95
+ */
96
+ storeUnreadable?: StoreUnreadableMode;
97
+ /** Server default access level — required when `storeUnreadable === 'drop'`. */
98
+ defaultAccessLevel?: DefaultAccessLevel;
99
+ /**
100
+ * Forwarding config. Threaded into `processBroadcast` per call.
101
+ * `false` / undefined → forwarding disabled (default).
102
+ */
103
+ forwarding?: ForwardingConfig;
104
+ /**
105
+ * Hub forwarding hook — required when `forwarding` is set. Called once per
106
+ * surviving candidate group on the receive path.
107
+ */
108
+ scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
109
+ logger?: Logger;
62
110
  };
63
111
  export declare class BroadcastService extends EventEmitter<BroadcastServiceEvents> {
64
112
  #private;
65
113
  constructor(params: BroadcastServiceParams);
66
114
  /**
67
- * Prepare a broadcast for sending: serialize + MLS encrypt.
68
- * Returns encrypted bytes to send via hub and updated MLS state to persist.
115
+ * Encrypt + send a broadcast for a group, then persist the advanced MLS state.
116
+ *
117
+ * Failure semantics: send happens inside the registry's per-group lock. A
118
+ * throw from `send` propagates out and the registry skips persist — so the
119
+ * device's MLS state stays at its pre-encrypt epoch and the next attempt
120
+ * re-derives a fresh handle from the prior generation. Wedge protection
121
+ * intact.
69
122
  */
70
- prepareSend(params: SendBroadcastParams): Promise<SendBroadcastResult>;
123
+ prepareSend(params: SendBroadcastParams): Promise<void>;
71
124
  /**
72
- * Process a received broadcast: MLS decrypt + deserialize + apply to DB.
73
- * Returns the decoded message, whether it was applied, and updated MLS state.
125
+ * Process a received broadcast: MLS decrypt INSIDE the registry lock,
126
+ * then apply to the DB OUTSIDE the lock so a slow apply doesn't block
127
+ * subsequent encrypt/decrypt on the same group.
74
128
  */
75
129
  processReceived(params: ReceiveBroadcastParams): Promise<ReceiveBroadcastResult>;
76
130
  }
@@ -1 +1 @@
1
- import{EventEmitter as r}from"@enkaku/event";import{processBroadcast as e}from"./broadcast.js";import{deserializeBroadcast as t,serializeBroadcast as a}from"./broadcast-codec.js";import{restoreMLSGroupHandle as o}from"./mls-group-handle.js";import{replacer as s,reviver as c}from"./mls-json.js";import{serializeMLSGroupState as p}from"./mls-state.js";export class DecryptError extends Error{cause;constructor(r,e){super(r),this.name="DecryptError",this.cause=e}}export class ApplyError extends Error{cause;constructor(r,e){super(r),this.name="ApplyError",this.cause=e}}export class BroadcastService extends r{#r;constructor(r){super(),this.#r={p2pStore:r.p2pStore,graphStore:r.graphStore}}async prepareSend(r){let{message:e,groupState:t}=r,c=a(e),d=await o(t),{message:i}=await d.encrypt(c),n=p(d);return{encrypted:new TextEncoder().encode(JSON.stringify(i,s)),updatedGroupState:n}}async processReceived(r){let a,s,d,{groupID:i,encrypted:n,groupState:m}=r;try{a=await o(m);let r=JSON.parse(new TextDecoder().decode(n),c),e=await a.decrypt(r);s=t(e)}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{d=await e(this.#r,s)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let l=p(a);return await this.emit("broadcast",{groupID:i,message:s,applied:d}),{message:s,applied:d,updatedGroupState:l}}}
1
+ import{EventEmitter as r}from"@enkaku/event";import{processBroadcast as e}from"./broadcast.js";import{deserializeBroadcast as t,serializeBroadcast as a}from"./broadcast-codec.js";import{mlsDecryptFramed as s,mlsEncryptFramed as o}from"./mls-codec.js";export class DecryptError extends Error{cause;constructor(r,e){super(r),this.name="DecryptError",this.cause=e}}export class ApplyError extends Error{cause;constructor(r,e){super(r),this.name="ApplyError",this.cause=e}}export class BroadcastService extends r{#r;#e;constructor(r){super(),this.#r={p2pStore:r.p2pStore,graphStore:r.graphStore,graph:r.graph,selfDID:r.selfDID,storeUnreadable:r.storeUnreadable,defaultAccessLevel:r.defaultAccessLevel,forwarding:r.forwarding,scheduleBroadcast:r.scheduleBroadcast,logger:r.logger},this.#e=r.registry}async prepareSend(r){let{groupID:e,message:t,send:s}=r,c=a(t);await this.#e.withHandle(e,async r=>{let{framed:e}=await o(r,c);await s(e)})}async processReceived(r){let a,o,{groupID:c,encrypted:i}=r;try{a=await this.#e.withHandle(c,async r=>{let e=await s(r,i);return t(e)})}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{o=await e(this.#r,a,c)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let{applied:p,affected:d,duplicates:l}=o;return await this.emit("broadcast",{groupID:c,message:a,applied:p,affected:d,duplicates:l}),{message:a,applied:p,affected:d,duplicates:l}}}
@@ -1,6 +1,17 @@
1
+ import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
2
+ import type { Logger } from '@kubun/logger';
1
3
  import type { CatalogRecord, CircleMemberRecord, CircleRecord } from '@kubun/protocol';
2
- import type { GraphStoreAPI } from '@kubun/store-graph';
3
- import type { P2PStoreAPI } from '@kubun/store-p2p';
4
+ import type { Catalog, GraphStoreAPI } from '@kubun/store-graph';
5
+ import type { Circle, CircleMember, Group, P2PStoreAPI } from '@kubun/store-p2p';
6
+ import { type ForwardingConfig } from '../sync/forwarder.js';
7
+ import type { StoreUnreadableMode } from '../types.js';
8
+ export type MutationApplyEntry = {
9
+ mutationJWT: string;
10
+ docID: string;
11
+ /** HLC string identifying this mutation's version. */
12
+ version: string;
13
+ modelID: string;
14
+ };
4
15
  export type GroupBroadcastMessage = {
5
16
  type: 'circle:create';
6
17
  circle: CircleRecord;
@@ -47,18 +58,114 @@ export type GroupBroadcastMessage = {
47
58
  update: {
48
59
  name?: string;
49
60
  description?: string;
50
- hubURLs?: Array<string>;
51
61
  hlc: string;
52
62
  };
63
+ } | {
64
+ type: 'mutation:apply';
65
+ entries: Array<MutationApplyEntry>;
66
+ /** DID of the peer that authored this broadcast — used for loopback drop. */
67
+ senderPeerDID: string;
68
+ };
69
+ export type AffectedRow = {
70
+ kind: 'circle';
71
+ row: Circle;
72
+ } | {
73
+ kind: 'member';
74
+ row: CircleMember;
75
+ } | {
76
+ kind: 'catalog';
77
+ row: Catalog;
78
+ } | {
79
+ kind: 'group';
80
+ row: Group;
81
+ };
82
+ export type ProcessBroadcastResult = {
83
+ applied: boolean;
84
+ /**
85
+ * Row snapshot. After-state for create / update, before-state for
86
+ * delete / remove. Undefined when `applied === false`.
87
+ */
88
+ affected?: AffectedRow;
89
+ /**
90
+ * Count of `mutation:apply` entries denied by the receive-time access
91
+ * gate. Only set on the `mutation:apply` branch; other branches do not
92
+ * gate. Always `0` when `storeUnreadable === 'persist'` (the default).
93
+ */
94
+ dropped?: number;
95
+ /**
96
+ * Count of forwarded broadcast targets across all `mutation:apply`
97
+ * entries. Only set on the `mutation:apply` branch when `forwarding` is
98
+ * configured and not `false`. `0` when forwarding is disabled or no
99
+ * candidate group survived the gates.
100
+ */
101
+ forwarded?: number;
102
+ /**
103
+ * Count of `mutation:apply` entries where `recordBroadcast` returned
104
+ * `false` (already-recorded duplicate). Only set on the `mutation:apply`
105
+ * branch; other branches do not call `recordBroadcast`. Always defined
106
+ * (possibly `0`) on `mutation:apply`.
107
+ */
108
+ duplicates?: number;
53
109
  };
54
110
  export type ProcessBroadcastParams = {
55
111
  p2pStore: P2PStoreAPI;
56
112
  graphStore: GraphStoreAPI;
113
+ /**
114
+ * Engine graph internals — required to apply `mutation:apply` entries via
115
+ * {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`, which
116
+ * fires engine lifecycle events (document:saved, engine:document:*,
117
+ * engine:mutation:received) so GraphQL subscribers see peer-authored docs.
118
+ */
119
+ graph: GraphInternals;
120
+ /** Self DID for `mutation:apply` loopback detection. */
121
+ selfDID: string;
122
+ /**
123
+ * Receiver storage mode for incoming `mutation:apply` entries.
124
+ * - `'persist'` (default) — every entry the engine accepts is persisted;
125
+ * access enforcement is left for query time.
126
+ * - `'drop'` — apply a receive-time read-access check using `selfDID` as
127
+ * viewer; entries the local peer cannot read are not persisted.
128
+ * Only consulted on the `mutation:apply` branch — control-plane variants
129
+ * are unaffected.
130
+ */
131
+ storeUnreadable?: StoreUnreadableMode;
132
+ /**
133
+ * Server default access level. Required when `storeUnreadable === 'drop'`
134
+ * to feed the access-rule resolver. Ignored under `'persist'`.
135
+ */
136
+ defaultAccessLevel?: DefaultAccessLevel;
137
+ /**
138
+ * Forwarding config. When omitted or `false` the forwarding pipeline is
139
+ * skipped entirely. When `true` or a {@link ForwardFilter}, applied
140
+ * entries are evaluated for re-broadcast to
141
+ * other MLS groups the local peer is a member of (excluding the source
142
+ * group), gated by access + catalog + dedup.
143
+ */
144
+ forwarding?: ForwardingConfig;
145
+ /**
146
+ * Hub forwarding hook — required when `forwarding` is set. Called once per
147
+ * surviving candidate group with a single-entry `mutation:apply` broadcast
148
+ * carrying `senderPeerDID = selfDID`.
149
+ */
150
+ scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
151
+ /** Optional logger — warns on per-entry apply failures. */
152
+ logger?: Logger;
57
153
  };
58
154
  /**
59
155
  * Process a received broadcast message, applying it to the local database
60
156
  * with HLC-based last-write-wins conflict resolution.
61
157
  *
62
- * Returns true if the message was applied, false if it was skipped (stale HLC or missing target).
158
+ * Returns `{ applied, affected? }`. `applied` is true when the message
159
+ * mutated local state. `affected` carries the row snapshot needed by
160
+ * downstream event mapping: after-state for create / update, before-state
161
+ * for delete / remove.
162
+ *
163
+ * Readback note: for create / update the post-apply row is fetched with a
164
+ * separate `get*` call rather than a txn-scoped read. If an unrelated
165
+ * concurrent write to the same row lands between apply and readback, the
166
+ * emitted `affected.row` reflects that later write. `BroadcastService`
167
+ * serializes receives, so the realistic exposure is only a same-row local
168
+ * mutation between apply and readback, which is acceptable for event
169
+ * payload fidelity.
63
170
  */
64
- export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage): Promise<boolean>;
171
+ export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage, groupID: string): Promise<ProcessBroadcastResult>;
@@ -1 +1 @@
1
- import{HLC as e}from"@kubun/hlc";function a(a,c){return e.compare(e.parse(a),e.parse(c))>0}export async function processBroadcast(e,c){let{p2pStore:t,graphStore:r}=e;switch(c.type){case"circle:create":{let e=await t.getCircle(c.circle.id);if(null!=e){if(!a(c.circle.hlc,e.hlc))return!1;return await t.updateCircle(c.circle.id,{name:c.circle.name,description:c.circle.description,catalog_ids:c.circle.catalogIDs,hlc:c.circle.hlc}),!0}return await t.createCircle({id:c.circle.id,group_id:c.circle.groupID,name:c.circle.name,description:c.circle.description,catalog_ids:c.circle.catalogIDs,hlc:c.circle.hlc}),!0}case"circle:update":{let e=await t.getCircle(c.circleID);if(null==e||!a(c.update.hlc,e.hlc))return!1;return await t.updateCircle(c.circleID,{name:c.update.name,description:c.update.description,catalog_ids:c.update.catalogIDs,hlc:c.update.hlc}),!0}case"circle:delete":{let e=await t.getCircle(c.circleID);if(null==e||!a(c.hlc,e.hlc))return!1;return await t.deleteCircle(c.circleID),!0}case"member:add":return await t.addCircleMember({circle_id:c.member.circleID,member_did:c.member.memberDID,role:c.member.role,hlc:c.member.hlc}),!0;case"member:remove":return await t.removeCircleMember(c.circleID,c.memberDID),!0;case"catalog:create":{let e=await r.getCatalog(c.catalog.id);if(null!=e){if(!a(c.catalog.hlc,e.hlc))return!1;return await r.updateCatalog(c.catalog.id,{name:c.catalog.name,description:c.catalog.description,filter_criteria:c.catalog.filterCriteria,hlc:c.catalog.hlc}),!0}return await r.createCatalog({id:c.catalog.id,owner_did:c.catalog.ownerDID,name:c.catalog.name,description:c.catalog.description,filter_criteria:c.catalog.filterCriteria,hlc:c.catalog.hlc}),!0}case"catalog:update":{let e=await r.getCatalog(c.catalogID);if(null==e||!a(c.update.hlc,e.hlc))return!1;return await r.updateCatalog(c.catalogID,{name:c.update.name,description:c.update.description,filter_criteria:c.update.filterCriteria,hlc:c.update.hlc}),!0}case"catalog:delete":{let e=await r.getCatalog(c.catalogID);if(null==e||!a(c.hlc,e.hlc))return!1;return await r.deleteCatalog(c.catalogID),!0}case"group:update":{let e=await t.getGroup(c.groupID);if(null==e||!a(c.update.hlc,e.hlc))return!1;return await t.updateGroup(c.groupID,{name:c.update.name,description:c.update.description,hub_urls:c.update.hubURLs,hlc:c.update.hlc}),!0}}}
1
+ import{HLC as e}from"@kubun/hlc";import{evaluateAndForward as a}from"../sync/forwarder.js";import{createReceiveAccessGate as l}from"../sync/receive-access-gate.js";function r(a,l){return e.compare(e.parse(a),e.parse(l))>0}export async function processBroadcast(e,t,i){let{p2pStore:c,graphStore:d}=e;switch(t.type){case"circle:create":{let e=await c.getCircle(t.circle.id);if(null!=e){if(!r(t.circle.hlc,e.hlc))return{applied:!1};await c.updateCircle(t.circle.id,{name:t.circle.name,description:t.circle.description,catalog_ids:t.circle.catalogIDs,hlc:t.circle.hlc});let a=await c.getCircle(t.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}await c.createCircle({id:t.circle.id,group_id:t.circle.groupID,name:t.circle.name,description:t.circle.description,catalog_ids:t.circle.catalogIDs,hlc:t.circle.hlc});let a=await c.getCircle(t.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:update":{let e=await c.getCircle(t.circleID);if(null==e||!r(t.update.hlc,e.hlc))return{applied:!1};await c.updateCircle(t.circleID,{name:t.update.name,description:t.update.description,catalog_ids:t.update.catalogIDs,hlc:t.update.hlc});let a=await c.getCircle(t.circleID);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:delete":{let e=await c.getCircle(t.circleID);if(null==e||!r(t.hlc,e.hlc))return{applied:!1};return await c.deleteCircle(t.circleID),{applied:!0,affected:{kind:"circle",row:e}}}case"member:add":{await c.addCircleMember({circle_id:t.member.circleID,member_did:t.member.memberDID,role:t.member.role,hlc:t.member.hlc});let e=await c.getCircleMember(t.member.circleID,t.member.memberDID);return null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"member:remove":{let e=await c.getCircleMember(t.circleID,t.memberDID);return await c.removeCircleMember(t.circleID,t.memberDID),null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"catalog:create":{let e=await d.getCatalog(t.catalog.id);if(null!=e){if(!r(t.catalog.hlc,e.hlc))return{applied:!1};await d.updateCatalog(t.catalog.id,{name:t.catalog.name,description:t.catalog.description,filter_criteria:t.catalog.filterCriteria,hlc:t.catalog.hlc});let a=await d.getCatalog(t.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}await d.createCatalog({id:t.catalog.id,owner_did:t.catalog.ownerDID,name:t.catalog.name,description:t.catalog.description,filter_criteria:t.catalog.filterCriteria,hlc:t.catalog.hlc});let a=await d.getCatalog(t.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:update":{let e=await d.getCatalog(t.catalogID);if(null==e||!r(t.update.hlc,e.hlc))return{applied:!1};await d.updateCatalog(t.catalogID,{name:t.update.name,description:t.update.description,filter_criteria:t.update.filterCriteria,hlc:t.update.hlc});let a=await d.getCatalog(t.catalogID);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:delete":{let e=await d.getCatalog(t.catalogID);if(null==e||!r(t.hlc,e.hlc))return{applied:!1};return await d.deleteCatalog(t.catalogID),{applied:!0,affected:{kind:"catalog",row:e}}}case"group:update":{let e=await c.getGroup(t.groupID);if(null==e||!r(t.update.hlc,e.hlc))return{applied:!1};await c.updateGroup(t.groupID,{name:t.update.name,description:t.update.description,hlc:t.update.hlc});let a=await c.getGroup(t.groupID);return null!=a?{applied:!0,affected:{kind:"group",row:a}}:{applied:!0}}case"mutation:apply":{if(t.senderPeerDID===e.selfDID)return{applied:!1};let r="drop"===(e.storeUnreadable??"persist")?l({selfDID:e.selfDID,db:{getUserModelAccessDefault:(e,a,l)=>d.getUserModelAccessDefault(e,a,l),isMemberOfAnyCircle:(e,a)=>c.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>c.isMemberOfAnyGroup(e,a)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}}):void 0,o=null!=e.forwarding&&!1!==e.forwarding&&null!=e.scheduleBroadcast,n=!1,p=0,u=0,s=0;for(let l of t.entries)try{let t=await e.graph.applyVerifiedMutation({token:l.mutationJWT,origin:"peer",...null!=r?{accessGate:r}:{}});if(t.dropped){p++;continue}let g=!1;try{let e=await c.recordBroadcast({docID:l.docID,version:l.version,mlsGroupID:i});g=!0,!e&&s++}catch(a){e.logger?.warn("mutation:apply recordBroadcast failed, skipping forward",{docID:l.docID,version:l.version,groupID:i,error:a})}if(n=!0,!g)continue;if(o&&null!=e.forwarding&&null!=e.scheduleBroadcast&&null!=t.document)try{let r=await a({entry:l,postState:t.document,sourceGroupID:i,selfDID:e.selfDID,p2pStore:c,graphStore:d,defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"},forwarding:e.forwarding,scheduleBroadcast:e.scheduleBroadcast,logger:e.logger});u+=r.forwarded}catch(a){e.logger?.warn("mutation:apply forwarding failed, skipping entry",{docID:l.docID,version:l.version,groupID:i,error:a})}}catch(a){e.logger?.warn("mutation:apply entry failed, skipping",{docID:l.docID,version:l.version,modelID:l.modelID,groupID:i,error:a})}let g={applied:n,dropped:p,duplicates:s};return o&&(g.forwarded=u),g}}}
@@ -32,6 +32,17 @@ export type GroupEventMap = {
32
32
  circleCatalogsChanged: CircleData & {
33
33
  circleID: string;
34
34
  };
35
+ hubBound: {
36
+ groupID: string;
37
+ hubID: string;
38
+ hubURL: string;
39
+ serverDID: string | null;
40
+ };
41
+ hubUnbound: {
42
+ groupID: string;
43
+ hubID: string;
44
+ hubURL: string;
45
+ };
35
46
  };
36
47
  export type GroupEventEmitter = EventEmitter<GroupEventMap>;
37
48
  export declare function createGroupEventEmitter(): GroupEventEmitter;
@@ -0,0 +1,90 @@
1
+ import type { GroupHandle } from '@enkaku/group';
2
+ import type { StoreProvider } from '@kubun/db';
3
+ import { type Logger } from '@kubun/logger';
4
+ export type GroupHandleRegistryParams = {
5
+ stores: StoreProvider;
6
+ deviceID: string;
7
+ logger?: Logger;
8
+ };
9
+ export type SeedParams = {
10
+ groupID: string;
11
+ handle: GroupHandle;
12
+ /**
13
+ * Pass the transactional `StoreProvider` (the `tx` from
14
+ * `stores.withTransaction(async (tx) => ...)`) so the initial MLS row
15
+ * write rolls back with the rest of the create/join flow on transaction
16
+ * abort. The cache install is deferred to `stores.onCommit` so a rollback
17
+ * leaves the cache empty.
18
+ */
19
+ stores: StoreProvider;
20
+ };
21
+ /**
22
+ * Single canonical access point for a device's MLS `GroupHandle` instances.
23
+ *
24
+ * Owns the per-group async mutex covering encrypt/decrypt and epoch-bumping
25
+ * operations, and persists the (possibly mutated) handle on every successful
26
+ * `withHandle` callback. On callback throw, persist is skipped; the in-memory
27
+ * handle retains its mutated state so the caller can decide whether to retry.
28
+ *
29
+ * All five mutation paths (manager member ops, context handlers, broadcast
30
+ * service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
31
+ * route MLS state through this registry. Spec:
32
+ * `docs/superpowers/specs/2026-05-09-group-handle-registry-design.md`.
33
+ */
34
+ export declare class GroupHandleRegistry {
35
+ #private;
36
+ constructor(params: GroupHandleRegistryParams);
37
+ /**
38
+ * Acquire the canonical handle for `groupID`, run `fn` under the per-group
39
+ * mutex, persist the (possibly mutated) handle on success, release.
40
+ * On throw, persist is skipped; in-memory handle retains its mutated state.
41
+ *
42
+ * Use this for in-place mutating ops (encrypt/decrypt) and reads. For
43
+ * epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
44
+ * removeMember, processCommit), use `withHandleReplacing` instead.
45
+ *
46
+ * `options.stores` overrides the constructor-stored `StoreProvider` for the
47
+ * restore + persist DB calls. Callers running inside an engine transaction
48
+ * (e.g. context handlers under `mutateGraph`) MUST pass the transactional
49
+ * `tx` so reads + writes share the tx's connection. Without this, SQLite's
50
+ * write lock held by the outer tx blocks the registry's persist forever.
51
+ */
52
+ withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
53
+ stores?: StoreProvider;
54
+ }): Promise<T>;
55
+ /**
56
+ * Like `withHandle`, but the callback returns `{ result, updated }`.
57
+ * Registry replaces its cached handle with `updated` before persisting on
58
+ * success. Required for `@enkaku/group` epoch ops (`commitInvite`,
59
+ * `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
60
+ * rather than mutating the input.
61
+ *
62
+ * `options.stores` follows the same semantics as `withHandle.options.stores`.
63
+ */
64
+ withHandleReplacing<T>(groupID: string, fn: (handle: GroupHandle) => Promise<{
65
+ result: T;
66
+ updated: GroupHandle;
67
+ }>, options?: {
68
+ stores?: StoreProvider;
69
+ }): Promise<T>;
70
+ /**
71
+ * Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
72
+ * Called by `leaveGroup` after the MLS state row is deleted, and by tests.
73
+ */
74
+ invalidate(groupID: string): void;
75
+ /**
76
+ * Prime the cache with a freshly-built handle and persist the initial row
77
+ * via the supplied transactional `StoreProvider`. Called by `createGroup`
78
+ * and `joinGroup` so the row write rolls back with the rest of the
79
+ * create/join transaction on `withTransaction` abort. The cache install is
80
+ * deferred to `stores.onCommit` so a rollback leaves the cache empty.
81
+ *
82
+ * Must NOT be called concurrently with `withHandle` / `withHandleReplacing`
83
+ * on the same `groupID`: the `seed` row write is uncommitted until the
84
+ * surrounding transaction completes, so a concurrent `withHandle` whose
85
+ * mutex region runs before commit would `#restore` from a missing row.
86
+ * `createGroup` and `joinGroup` only seed for fresh groups not yet visible
87
+ * to other callers, so this constraint is naturally satisfied in practice.
88
+ */
89
+ seed(params: SeedParams): Promise<void>;
90
+ }
@@ -0,0 +1 @@
1
+ import{getKubunLogger as e}from"@kubun/logger";import{getP2PStore as t}from"@kubun/store-p2p";import{createMutex as r}from"../util/mutex.js";import{restoreMLSGroupHandle as s}from"./mls-group-handle.js";import{fromMLSStateRow as a,serializeMLSGroupState as i,toMLSStateInsert as l}from"./mls-state.js";export class GroupHandleRegistry{#e;#t;#r;#s=new Map;constructor(t){this.#e=t.stores,this.#t=t.deviceID,this.#r=t.logger??e("plugin-p2p:group-handle-registry")}withHandle(e,t,r){let s=r?.stores??this.#e,a=this.#a(e);return a.mutex.run(async()=>{null==a.handle&&(a.handle=await this.#i(s,e));let r=await t(a.handle);return await this.#l(s,e,a.handle),r})}withHandleReplacing(e,t,r){let s=r?.stores??this.#e,a=this.#a(e);return a.mutex.run(async()=>{null==a.handle&&(a.handle=await this.#i(s,e));let{result:r,updated:i}=await t(a.handle);return a.handle=i,await this.#l(s,e,i),r})}invalidate(e){this.#s.delete(e)}async seed(e){let{groupID:r,handle:s,stores:a}=e,n=this.#s.get(r);if(n?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=i(s),h=await t(a);await h.saveMLSState(l(o,r,this.#t)),a.onCommit(()=>{let e=this.#a(r);null==e.handle&&(e.handle=s)})}#a(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #i(e,r){let i=await t(e),l=await i.getMLSState(r,this.#t);if(null==l)throw Error(`No MLS state for group ${r}`);return await s(a(l))}async #l(e,r,s){let a=i(s),n=await t(e);await n.saveMLSState(l(a,r,this.#t))}}
@@ -8,10 +8,14 @@ export type FullJoinRequestPayload = {
8
8
  publicPackage: KeyPackageBundle['publicPackage'];
9
9
  privatePackage: KeyPackageBundle['privatePackage'];
10
10
  };
11
+ export type SuggestedHub = {
12
+ url: string;
13
+ serverDID?: string;
14
+ };
11
15
  export type InvitePayload = {
12
16
  groupID: string;
13
17
  groupName: string;
14
- hubURLs: Array<string>;
18
+ suggestedHubs?: Array<SuggestedHub>;
15
19
  invite: Invite;
16
20
  welcomeMessage: unknown;
17
21
  ratchetTree: unknown;
@@ -1,19 +1,13 @@
1
- import type { OwnIdentity } from '@enkaku/token';
2
1
  import type { StoreProvider } from '@kubun/db';
3
- import { HLC } from '@kubun/hlc';
4
2
  import type { GroupData } from '../types.js';
5
3
  import type { GroupEventEmitter } from './events.js';
6
- export type CreateGroupFromJoinParams = {
4
+ export type FinalizeJoinedGroupParams = {
7
5
  stores: StoreProvider;
8
- hlc: HLC;
9
6
  emitter: GroupEventEmitter;
10
- identity: OwnIdentity;
11
7
  invite: {
12
8
  groupID: string;
13
- groupName: string;
14
- hubURLs: Array<string>;
15
9
  };
16
10
  };
17
- export declare function createGroupFromJoin(params: CreateGroupFromJoinParams): Promise<{
11
+ export declare function finalizeJoinedGroup(params: FinalizeJoinedGroupParams): Promise<{
18
12
  group: GroupData | null;
19
13
  }>;
@@ -1 +1 @@
1
- import{HLC as e}from"@kubun/hlc";import{getP2PStore as r}from"@kubun/store-p2p";import{toISO as t}from"../context/types.js";export async function createGroupFromJoin(o){let{stores:a,hlc:i,emitter:u,identity:n,invite:p}=o,c=await r(a);null==await c.getGroup(p.groupID)&&await c.createGroup({id:p.groupID,name:p.groupName,description:"",created_by:n.id,hub_urls:p.hubURLs,hlc:e.serialize(i.now())});let l=await c.getGroup(p.groupID),d=null!=l?{id:l.id,name:l.name,description:l.description,createdBy:l.created_by,createdAt:t(l.created_at)}:null;return null!=d&&await u.emit("groupJoined",d),{group:d}}
1
+ import{getP2PStore as e}from"@kubun/store-p2p";import{toISO as t}from"../context/types.js";export async function finalizeJoinedGroup(r){let{stores:i,emitter:o,invite:n}=r,a=await e(i),p=await a.getGroup(n.groupID),u=null!=p?{id:p.id,name:p.name,description:p.description,createdBy:p.created_by,createdAt:t(p.created_at)}:null;return null!=u&&await o.emit("groupJoined",u),{group:u}}