@kubun/plugin-p2p 0.10.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 (64) hide show
  1. package/lib/context/group.js +1 -1
  2. package/lib/context/hub.js +1 -1
  3. package/lib/context/types.d.ts +9 -0
  4. package/lib/groups/broadcast-service.d.ts +84 -10
  5. package/lib/groups/broadcast-service.js +1 -1
  6. package/lib/groups/broadcast.d.ts +70 -0
  7. package/lib/groups/broadcast.js +1 -1
  8. package/lib/groups/events.d.ts +2 -1
  9. package/lib/groups/group-handle-registry.d.ts +8 -0
  10. package/lib/groups/group-handle-registry.js +1 -1
  11. package/lib/groups/group-health-monitor.d.ts +45 -0
  12. package/lib/groups/group-health-monitor.js +1 -0
  13. package/lib/groups/manager.d.ts +9 -0
  14. package/lib/groups/manager.js +1 -1
  15. package/lib/groups/rejoin-codec.d.ts +14 -0
  16. package/lib/groups/rejoin-codec.js +1 -0
  17. package/lib/groups/wire-frame.d.ts +2 -0
  18. package/lib/groups/wire-frame.js +1 -1
  19. package/lib/hub/circle-catchup-requester.d.ts +58 -0
  20. package/lib/hub/circle-catchup-requester.js +1 -0
  21. package/lib/hub/circle-catchup-responder.d.ts +50 -0
  22. package/lib/hub/circle-catchup-responder.js +1 -0
  23. package/lib/hub/epoch-stale-detector.d.ts +18 -0
  24. package/lib/hub/epoch-stale-detector.js +1 -0
  25. package/lib/hub/errors.d.ts +7 -5
  26. package/lib/hub/errors.js +1 -1
  27. package/lib/hub/group-channel.d.ts +22 -0
  28. package/lib/hub/group-channel.js +1 -1
  29. package/lib/hub/manager.d.ts +5 -4
  30. package/lib/hub/manager.js +1 -1
  31. package/lib/hub/receive-handler.d.ts +20 -2
  32. package/lib/hub/receive-handler.js +1 -1
  33. package/lib/hub/rejoin-manager.d.ts +78 -0
  34. package/lib/hub/rejoin-manager.js +1 -0
  35. package/lib/hub/rejoin-responder.d.ts +32 -0
  36. package/lib/hub/rejoin-responder.js +1 -0
  37. package/lib/hub/relay-manager.d.ts +16 -1
  38. package/lib/hub/relay-manager.js +1 -1
  39. package/lib/hub/wiring.d.ts +19 -1
  40. package/lib/hub/wiring.js +1 -1
  41. package/lib/index.d.ts +18 -3
  42. package/lib/index.js +1 -1
  43. package/lib/protocol.d.ts +7 -0
  44. package/lib/protocol.js +1 -1
  45. package/lib/schema.js +34 -2
  46. package/lib/sync/authorize.d.ts +25 -0
  47. package/lib/sync/authorize.js +1 -0
  48. package/lib/sync/errors.d.ts +11 -0
  49. package/lib/sync/errors.js +1 -0
  50. package/lib/sync/handlers.d.ts +3 -22
  51. package/lib/sync/handlers.js +1 -1
  52. package/lib/sync/hub-tunnel-sync-listener.d.ts +2 -0
  53. package/lib/sync/hub-tunnel-sync-listener.js +1 -1
  54. package/lib/sync/merkle-channel.d.ts +19 -0
  55. package/lib/sync/merkle-channel.js +1 -1
  56. package/lib/sync/peer-registry.d.ts +7 -0
  57. package/lib/sync/peer-registry.js +1 -1
  58. package/lib/sync/sync-client.d.ts +2 -0
  59. package/lib/sync/sync-client.js +1 -1
  60. package/lib/sync/sync-manager.js +1 -1
  61. package/lib/types.d.ts +30 -0
  62. package/lib/utils.d.ts +5 -0
  63. package/lib/utils.js +1 -0
  64. package/package.json +12 -12
@@ -1 +1 @@
1
- import{createCapability as e,createRevocationRecord as t}from"@enkaku/capability";import{stringifyToken as r}from"@enkaku/token";import{HLC as i}from"@kubun/hlc";import{getGraphStore as a}from"@kubun/store-graph";import{getP2PStore as o}from"@kubun/store-p2p";import{GraphQLError as d}from"graphql";import{decodeFullJoinRequest as l,decodeInvitePayload as c,decodeJoinRequest as n,encodeInvitePayload as s}from"../groups/invite-payload.js";import{applyInviteGrants as m,applyInviteRevocations as u,finalizeJoinedGroup as p}from"../groups/join-utils.js";import{checkPeerAccess as g,toISO as w}from"./types.js";export function createGroupContext(D,I){let y=null,_=null,h=()=>y??=o(I.stores),b=()=>_??=a(I.stores);return{create:async e=>{let t=await I.groupManager.createGroup({stores:I.stores,identity:I.identity,name:e.name,description:e.description,createdBy:D.viewerDID,hubs:e.hubs?.map(e=>({url:e.url,serverDID:e.serverDID??void 0}))}),r=await h(),i=await r.getGroup(t.groupID);if(null==i)throw Error("Failed to create group");let a={id:i.id,name:i.name,description:i.description,createdBy:i.created_by,createdAt:w(i.created_at)};return await I.emitter.emit("groupJoined",a),a},list:async()=>{let e=await h();return(await e.listGroups()).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:w(e.created_at)}))},get:async e=>{let t=await h(),r=await t.getGroup(e);return null==r?null:{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at)}},addMember:async(e,t,r)=>{let a=await h();await a.addGroupMember({group_id:e,member_did:t,role:r,hlc:i.serialize(I.hlc.now())});let o=await a.getGroupMember(e,t);if(null==o)throw Error("Failed to add group member");let d={groupID:o.group_id,memberDID:o.member_did,role:o.role,createdAt:w(o.created_at)};return await I.emitter.emit("groupMemberJoined",{...d,groupID:d.groupID}),d},listMembers:async e=>{let t=await h();return(await t.listGroupMembers(e)).map(e=>({groupID:e.group_id,memberDID:e.member_did,role:e.role,createdAt:w(e.created_at)}))},createCircle:async e=>{let t=await I.groupManager.createCircle({stores:I.stores,groupID:e.groupID,name:e.name,description:e.description}),r=await h(),i=await r.getCircle(t.circleID);if(null==i)throw Error("Failed to create circle");let a={id:i.id,groupID:i.group_id,name:i.name,description:i.description,createdAt:w(i.created_at)};return await I.emitter.emit("circleCreated",{...a,groupID:a.groupID}),I.stores.onCommit(()=>I.scheduleBroadcast(i.group_id,{type:"circle:create",circle:{id:i.id,groupID:i.group_id,name:i.name,description:i.description,catalogIDs:i.catalog_ids,hlc:i.hlc}})),a},listCircles:async e=>{let t=await h();return(await t.listCirclesByGroup(e)).map(e=>({id:e.id,groupID:e.group_id,name:e.name,description:e.description,createdAt:w(e.created_at)}))},addCircleMember:async(e,t,r)=>{let a=await h(),o=i.serialize(I.hlc.now());await a.addCircleMember({circle_id:e,member_did:t,role:r,hlc:o});let d=(await a.listCircleMembers(e)).find(e=>e.member_did===t);if(null==d)throw Error("Failed to add circle member");let l={circleID:d.circle_id,memberDID:d.member_did,role:d.role,createdAt:w(d.created_at)};await I.emitter.emit("circleMemberAdded",{...l,circleID:l.circleID});let c=await a.getCircle(e);if(null!=c)if("admin"===d.role||"member"===d.role){let e=d.role;I.stores.onCommit(()=>I.scheduleBroadcast(c.group_id,{type:"member:add",member:{circleID:d.circle_id,memberDID:d.member_did,role:e,hlc:d.hlc}}))}else I.logger.warn("addCircleMember: skipping broadcast for unrecognized role",{circleID:e,memberDID:t,role:d.role});return l},createCatalog:async e=>{let t=await b(),r=I.runtime.getRandomID(),a=i.serialize(I.hlc.now());await t.createCatalog({id:r,owner_did:D.viewerDID,name:e.name,description:e.description??"",filter_criteria:JSON.parse(e.filterCriteria),hlc:a});let o=await t.getCatalog(r);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:w(o.created_at)}},listCatalogs:async e=>{let t=await b();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:w(e.created_at)}));let r=await h(),i=new Set((await r.listGroups()).map(e=>e.created_by));return(await Promise.all(Array.from(i).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:w(e.created_at)}))},invite:async(e,t,r,i)=>{g(D.viewerDID,I.identity.id,I.autoAcceptPeers);let a=await h(),o=I.identity,d=n(t),l=await I.groupManager.inviteToGroup({stores:I.stores,groupID:e,identity:o,recipientDID:d.did,recipientKeyPackage:d.publicPackage,permission:"member"}),c=await a.getGroup(e),m=(await a.listHubsByGroupID(e)).map(e=>null==e.server_did?{url:e.url}:{url:e.url,serverDID:e.server_did}),u=s({groupID:e,groupName:c?.name??"",suggestedHubs:m,invite:l.invite,welcomeMessage:l.welcomeMessage,ratchetTree:l.ratchetTree,...null!=r&&r.length>0?{grants:r}:{},...null!=i&&i.length>0?{revocations:i}:{}});return await I.emitter.emit("groupMemberJoined",{groupID:e,memberDID:d.did,role:"member",createdAt:new Date().toISOString()}),I.stores.onCommit(()=>I.scheduleSendCommit(e,l.commitMessage)),{invitePayload:u,groupID:e,groupName:c?.name??""}},join:async(e,t)=>{let r=I.identity,i=c(e),a=l(t),o={publicPackage:a.publicPackage,privatePackage:a.privatePackage,ownerDID:a.did};return await I.groupManager.joinGroup({stores:I.stores,identity:r,groupID:i.groupID,groupName:i.groupName,hubs:i.suggestedHubs,invite:i.invite,keyPackageBundle:o,welcomeMessage:i.welcomeMessage,ratchetTree:i.ratchetTree}),await m({stores:I.stores,selfDID:r.id,groupID:i.groupID,grants:i.grants,hlc:I.hlc,emitter:I.emitter,logger:I.logger}),await u({stores:I.stores,selfDID:r.id,groupID:i.groupID,revocations:i.revocations,hlc:I.hlc,emitter:I.emitter,logger:I.logger}),await p({stores:I.stores,emitter:I.emitter,invite:i})},leave:async e=>{let t=await h(),r=await t.getGroup(e),a=I.identity;try{await I.broadcastNow(e,{type:"group:leaveRequest",groupID:e,memberDID:a.id,hlc:i.serialize(I.hlc.now())})}catch(t){I.logger.warn("leave-request broadcast failed",{groupID:e,error:t})}return await I.groupManager.leaveGroup({stores:I.stores,groupID:e,identity:a}),null!=r&&await I.emitter.emit("groupLeft",{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at),groupID:e}),{groupID:e}},removeMember:async(e,t)=>{let{commitMessage:r}=await I.groupManager.removeGroupMember({stores:I.stores,groupID:e,memberDID:t});return await I.emitter.emit("groupMemberLeft",{groupID:e,memberDID:t,role:"",createdAt:new Date().toISOString()}),I.stores.onCommit(()=>I.scheduleSendCommit(e,r)),{groupID:e}},update:async e=>{await I.groupManager.updateGroup({stores:I.stores,groupID:e.groupID,update:{name:e.name,description:e.description}});let t=await h(),r=await t.getGroup(e.groupID);if(null==r)throw Error(`Group ${e.groupID} not found`);let i={id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at)};return await I.emitter.emit("groupDataChanged",{...i,groupID:e.groupID}),I.stores.onCommit(()=>I.scheduleBroadcast(r.id,{type:"group:update",groupID:r.id,update:{name:e.name,description:e.description,hlc:r.hlc}})),i},updateCircle:async e=>{await I.groupManager.updateCircle({stores:I.stores,circleID:e.circleID,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs}});let t=await h(),r=await t.getCircle(e.circleID);if(null==r)throw Error(`Circle ${e.circleID} not found`);let i={id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:w(r.created_at)};return null!=e.catalogIDs?await I.emitter.emit("circleCatalogsChanged",{...i,circleID:e.circleID}):await I.emitter.emit("circleDataChanged",{...i,circleID:e.circleID}),I.stores.onCommit(()=>I.scheduleBroadcast(r.group_id,{type:"circle:update",circleID:r.id,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs,hlc:r.hlc}})),i},updateCatalog:async e=>{let t=await b(),r={hlc:i.serialize(I.hlc.now())};null!=e.name&&(r.name=e.name),null!=e.filterCriteria&&(r.filter_criteria=JSON.parse(e.filterCriteria)),await t.updateCatalog(e.catalogID,r);let a=await t.getCatalog(e.catalogID);if(null==a)throw Error(`Catalog ${e.catalogID} not found`);return{id:a.id,ownerDID:a.owner_did,name:a.name,description:a.description,filterCriteria:JSON.stringify(a.filter_criteria),createdAt:w(a.created_at)}},deleteCatalog:async e=>{let t=await b();return await t.deleteCatalog(e),{deletedID:e}},deleteCircle:async e=>{let t=await h(),r=await t.getCircle(e);if(await I.groupManager.deleteCircle({stores:I.stores,circleID:e}),null!=r){await I.emitter.emit("circleDeleted",{id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:w(r.created_at)});let t=i.serialize(I.hlc.now());I.stores.onCommit(()=>I.scheduleBroadcast(r.group_id,{type:"circle:delete",circleID:e,hlc:t}))}return{deletedID:e}},removeCircleMember:async(e,t)=>{let r=await h(),[a,o]=await Promise.all([r.getCircle(e),r.getCircleMember(e,t)]);if(await I.groupManager.removeCircleMember({stores:I.stores,circleID:e,memberDID:t}),await I.emitter.emit("circleMemberRemoved",{circleID:e,memberDID:t,role:o?.role??"",createdAt:null!=o?w(o.created_at):new Date().toISOString()}),null!=a){let r=i.serialize(I.hlc.now());I.stores.onCommit(()=>I.scheduleBroadcast(a.group_id,{type:"member:remove",circleID:e,memberDID:t,hlc:r}))}return{circleID:e,memberDID:t}},grantWriteCapability:async({to:t,res:a,groupID:o,expiresIn:d})=>{let l=await h(),c=Math.floor(Date.now()/1e3),n=c+(d??2592e3),s=I.runtime.getRandomID(),m=r(await e(I.identity,{sub:I.identity.id,aud:t,act:"document/write",res:a,iat:c,exp:n,jti:s})),u=i.serialize(I.hlc.now());return await l.addDelegationToken({jti:s,grantor:I.identity.id,audience:t,token:m,resource:a,act:"document/write",exp:n,group_id:o,hlc:u})&&await I.emitter.emit("delegationTokenAdded",{jwt:m,jti:s,grantor:I.identity.id,audience:t,resource:a,exp:n}),I.stores.onCommit(()=>I.scheduleBroadcast(o,{type:"delegation:share",token:m,groupID:o,hlc:u})),m},revokeCapability:async({jti:e})=>{let a=await h(),o=await a.getDelegationTokenByJti(e);if(null==o)throw new d(`No capability found locally for jti ${e}`,{extensions:{code:"REVOCATION_CAP_UNKNOWN"}});if(o.grantor!==D.viewerDID)throw new d(`Only the capability issuer can revoke jti ${e}`,{extensions:{code:"REVOCATION_UNAUTHORIZED"}});let l=await t(I.identity,e),c=r(await I.identity.signToken(l)),n=i.serialize(I.hlc.now()),s=await a.addRevocation({jti:e,revoker_did:I.identity.id,revoked_iat:l.iat,revocation_token:c,verified_at:l.iat,cap_exp:o.exp,group_id:o.group_id,hlc:n});return s&&await I.emitter.emit("delegationTokenRevoked",{jti:e,grantor:o.grantor,audience:o.audience,revokerDID:I.identity.id,revokedAt:l.iat,verifiedAt:l.iat,capExp:o.exp,groupID:o.group_id}),I.stores.onCommit(()=>I.scheduleBroadcast(o.group_id,{type:"delegation:revoke",token:c,groupID:o.group_id,hlc:n})),s}}}
1
+ import{createCapability as e,createRevocationRecord as t}from"@enkaku/capability";import{stringifyToken as r}from"@enkaku/token";import{HLC as i}from"@kubun/hlc";import{getGraphStore as a}from"@kubun/store-graph";import{getP2PStore as o}from"@kubun/store-p2p";import{GraphQLError as d}from"graphql";import{decodeFullJoinRequest as n,decodeInvitePayload as c,decodeJoinRequest as l,encodeInvitePayload as s}from"../groups/invite-payload.js";import{applyInviteGrants as m,applyInviteRevocations as u,finalizeJoinedGroup as p}from"../groups/join-utils.js";import{checkPeerAccess as g,toISO as w}from"./types.js";export function createGroupContext(D,I){let y=null,_=null,b=()=>y??=o(I.stores),h=()=>_??=a(I.stores);return{create:async e=>{let t=await I.groupManager.createGroup({stores:I.stores,identity:I.identity,name:e.name,description:e.description,createdBy:D.viewerDID,hubs:e.hubs?.map(e=>({url:e.url,serverDID:e.serverDID??void 0}))}),r=await b(),i=await r.getGroup(t.groupID);if(null==i)throw Error("Failed to create group");let a={id:i.id,name:i.name,description:i.description,createdBy:i.created_by,createdAt:w(i.created_at)};return await I.emitter.emit("groupJoined",a),a},list:async()=>{let e=await b();return(await e.listGroups()).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:w(e.created_at)}))},get:async e=>{let t=await b(),r=await t.getGroup(e);return null==r?null:{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at)}},addMember:async(e,t,r)=>{let a=await b();await a.addGroupMember({group_id:e,member_did:t,role:r,hlc:i.serialize(I.hlc.now())});let o=await a.getGroupMember(e,t);if(null==o)throw Error("Failed to add group member");let d={groupID:o.group_id,memberDID:o.member_did,role:o.role,createdAt:w(o.created_at)};return await I.emitter.emit("groupMemberJoined",{...d,groupID:d.groupID}),d},listMembers:async e=>{let t=await b();return(await t.listGroupMembers(e)).map(e=>({groupID:e.group_id,memberDID:e.member_did,role:e.role,createdAt:w(e.created_at)}))},createCircle:async e=>{let t=await I.groupManager.createCircle({stores:I.stores,groupID:e.groupID,name:e.name,description:e.description}),r=await b(),i=await r.getCircle(t.circleID);if(null==i)throw Error("Failed to create circle");let a={id:i.id,groupID:i.group_id,name:i.name,description:i.description,createdAt:w(i.created_at)};return await I.emitter.emit("circleCreated",{...a,groupID:a.groupID}),I.stores.onCommit(()=>I.scheduleBroadcast(i.group_id,{type:"circle:create",circle:{id:i.id,groupID:i.group_id,name:i.name,description:i.description,catalogIDs:i.catalog_ids,hlc:i.hlc}})),a},listCircles:async e=>{let t=await b();return(await t.listCirclesByGroup(e)).map(e=>({id:e.id,groupID:e.group_id,name:e.name,description:e.description,createdAt:w(e.created_at)}))},addCircleMember:async(e,t,r)=>{let a=await b(),o=i.serialize(I.hlc.now());await a.addCircleMember({circle_id:e,member_did:t,role:r,hlc:o});let d=(await a.listCircleMembers(e)).find(e=>e.member_did===t);if(null==d)throw Error("Failed to add circle member");let n={circleID:d.circle_id,memberDID:d.member_did,role:d.role,createdAt:w(d.created_at)};await I.emitter.emit("circleMemberAdded",{...n,circleID:n.circleID});let c=await a.getCircle(e);if(null!=c)if("admin"===d.role||"member"===d.role){let e=d.role;I.stores.onCommit(()=>I.scheduleBroadcast(c.group_id,{type:"member:add",member:{circleID:d.circle_id,memberDID:d.member_did,role:e,hlc:d.hlc}}))}else I.logger.warn("addCircleMember: skipping broadcast for unrecognized role",{circleID:e,memberDID:t,role:d.role});return n},createCatalog:async e=>{let t=await h(),r=I.runtime.getRandomID(),a=i.serialize(I.hlc.now());await t.createCatalog({id:r,owner_did:D.viewerDID,name:e.name,description:e.description??"",filter_criteria:JSON.parse(e.filterCriteria),hlc:a});let o=await t.getCatalog(r);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:w(o.created_at)}},listCatalogs:async e=>{let t=await h();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:w(e.created_at)}));let r=await b(),i=new Set((await r.listGroups()).map(e=>e.created_by));return(await Promise.all(Array.from(i).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:w(e.created_at)}))},invite:async(e,t,r,i)=>{g(D.viewerDID,I.identity.id,I.autoAcceptPeers);let a=await b(),o=I.identity,d=l(t),n=await I.groupManager.inviteToGroup({stores:I.stores,groupID:e,identity:o,recipientDID:d.did,recipientKeyPackage:d.publicPackage,permission:"member"}),c=await a.getGroup(e),m=(await a.listHubsByGroupID(e)).map(e=>null==e.server_did?{url:e.url}:{url:e.url,serverDID:e.server_did}),u=s({groupID:e,groupName:c?.name??"",suggestedHubs:m,invite:n.invite,welcomeMessage:n.welcomeMessage,ratchetTree:n.ratchetTree,...null!=r&&r.length>0?{grants:r}:{},...null!=i&&i.length>0?{revocations:i}:{}});return await I.emitter.emit("groupMemberJoined",{groupID:e,memberDID:d.did,role:"member",createdAt:new Date().toISOString()}),I.stores.onCommit(()=>I.scheduleSendCommit(e,n.commitMessage)),{invitePayload:u,groupID:e,groupName:c?.name??""}},join:async(e,t)=>{let r=I.identity,i=c(e),a=n(t),o={publicPackage:a.publicPackage,privatePackage:a.privatePackage,ownerDID:a.did};return await I.groupManager.joinGroup({stores:I.stores,identity:r,groupID:i.groupID,groupName:i.groupName,hubs:i.suggestedHubs,invite:i.invite,keyPackageBundle:o,welcomeMessage:i.welcomeMessage,ratchetTree:i.ratchetTree}),await m({stores:I.stores,selfDID:r.id,groupID:i.groupID,grants:i.grants,hlc:I.hlc,emitter:I.emitter,logger:I.logger}),await u({stores:I.stores,selfDID:r.id,groupID:i.groupID,revocations:i.revocations,hlc:I.hlc,emitter:I.emitter,logger:I.logger}),await p({stores:I.stores,emitter:I.emitter,invite:i})},leave:async e=>{let t=await b(),r=await t.getGroup(e),a=I.identity;try{await I.broadcastNow(e,{type:"group:leaveRequest",groupID:e,memberDID:a.id,hlc:i.serialize(I.hlc.now())})}catch(t){I.logger.warn("leave-request broadcast failed",{groupID:e,error:t})}return await I.groupManager.leaveGroup({stores:I.stores,groupID:e,identity:a}),null!=r&&await I.emitter.emit("groupLeft",{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at),groupID:e}),{groupID:e}},removeMember:async(e,t)=>{let{commitMessage:r}=await I.groupManager.removeGroupMember({stores:I.stores,groupID:e,memberDID:t});return await I.emitter.emit("groupMemberLeft",{groupID:e,memberDID:t,role:"",createdAt:new Date().toISOString()}),I.stores.onCommit(()=>I.scheduleSendCommit(e,r)),{groupID:e}},update:async e=>{await I.groupManager.updateGroup({stores:I.stores,groupID:e.groupID,update:{name:e.name,description:e.description}});let t=await b(),r=await t.getGroup(e.groupID);if(null==r)throw Error(`Group ${e.groupID} not found`);let i={id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at)};return await I.emitter.emit("groupDataChanged",{...i,groupID:e.groupID}),I.stores.onCommit(()=>I.scheduleBroadcast(r.id,{type:"group:update",groupID:r.id,update:{name:e.name,description:e.description,hlc:r.hlc}})),i},updateCircle:async e=>{await I.groupManager.updateCircle({stores:I.stores,circleID:e.circleID,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs}});let t=await b(),r=await t.getCircle(e.circleID);if(null==r)throw Error(`Circle ${e.circleID} not found`);let i={id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:w(r.created_at)};return null!=e.catalogIDs?await I.emitter.emit("circleCatalogsChanged",{...i,circleID:e.circleID}):await I.emitter.emit("circleDataChanged",{...i,circleID:e.circleID}),I.stores.onCommit(()=>I.scheduleBroadcast(r.group_id,{type:"circle:update",circleID:r.id,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs,hlc:r.hlc}})),i},updateCatalog:async e=>{let t=await h(),r={hlc:i.serialize(I.hlc.now())};null!=e.name&&(r.name=e.name),null!=e.filterCriteria&&(r.filter_criteria=JSON.parse(e.filterCriteria)),await t.updateCatalog(e.catalogID,r);let a=await t.getCatalog(e.catalogID);if(null==a)throw Error(`Catalog ${e.catalogID} not found`);return{id:a.id,ownerDID:a.owner_did,name:a.name,description:a.description,filterCriteria:JSON.stringify(a.filter_criteria),createdAt:w(a.created_at)}},deleteCatalog:async e=>{let t=await h();return await t.deleteCatalog(e),{deletedID:e}},deleteCircle:async e=>{let t=await b(),r=await t.getCircle(e),{broadcast:i}=await I.groupManager.deleteCircle({stores:I.stores,circleID:e});return null!=r&&(await I.emitter.emit("circleDeleted",{id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:w(r.created_at)}),I.stores.onCommit(()=>I.scheduleBroadcast(r.group_id,i))),{deletedID:e}},removeCircleMember:async(e,t)=>{let r=await b(),[a,o]=await Promise.all([r.getCircle(e),r.getCircleMember(e,t)]);if(await I.groupManager.removeCircleMember({stores:I.stores,circleID:e,memberDID:t}),await I.emitter.emit("circleMemberRemoved",{circleID:e,memberDID:t,role:o?.role??"",createdAt:null!=o?w(o.created_at):new Date().toISOString()}),null!=a){let r=i.serialize(I.hlc.now());I.stores.onCommit(()=>I.scheduleBroadcast(a.group_id,{type:"member:remove",circleID:e,memberDID:t,hlc:r}))}return{circleID:e,memberDID:t}},grantWriteCapability:async({to:t,res:a,groupID:o,expiresIn:d})=>{let n=await b(),c=Math.floor(Date.now()/1e3),l=c+(d??2592e3),s=I.runtime.getRandomID(),m=r(await e(I.identity,{sub:I.identity.id,aud:t,act:"document/write",res:a,iat:c,exp:l,jti:s})),u=i.serialize(I.hlc.now());return await n.addDelegationToken({jti:s,grantor:I.identity.id,audience:t,token:m,resource:a,act:"document/write",exp:l,group_id:o,hlc:u})&&await I.emitter.emit("delegationTokenAdded",{jwt:m,jti:s,grantor:I.identity.id,audience:t,resource:a,exp:l}),I.stores.onCommit(()=>I.scheduleBroadcast(o,{type:"delegation:share",token:m,groupID:o,hlc:u})),m},revokeCapability:async({jti:e})=>{let a=await b(),o=await a.getDelegationTokenByJti(e);if(null==o)throw new d(`No capability found locally for jti ${e}`,{extensions:{code:"REVOCATION_CAP_UNKNOWN"}});if(o.grantor!==D.viewerDID)throw new d(`Only the capability issuer can revoke jti ${e}`,{extensions:{code:"REVOCATION_UNAUTHORIZED"}});let n=await t(I.identity,e),c=r(await I.identity.signToken(n)),l=i.serialize(I.hlc.now()),s=await a.addRevocation({jti:e,revoker_did:I.identity.id,revoked_iat:n.iat,revocation_token:c,verified_at:n.iat,cap_exp:o.exp,group_id:o.group_id,hlc:l});return s&&await I.emitter.emit("delegationTokenRevoked",{jti:e,grantor:o.grantor,audience:o.audience,revokerDID:I.identity.id,revokedAt:n.iat,verifiedAt:n.iat,capExp:o.exp,groupID:o.group_id}),I.stores.onCommit(()=>I.scheduleBroadcast(o.group_id,{type:"delegation:revoke",token:c,groupID:o.group_id,hlc:l})),s}}}
@@ -1 +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
+ import{getP2PStore as e}from"@kubun/store-p2p";import{bindHubToGroup as t,createHub as r,deleteHub as s,getHub as a,listHubs as o,listHubsByGroup as u,unbindHubFromGroup as i,updateHub as n}from"../hub/manager.js";import{toISO as l}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 o({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 u({stores:p.stores,groupID:t}),s=r.find(t=>t.id===e)?.url??null,a=await i({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 u({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:l(e.created_at)}))},getGroupHealth:e=>p.monitor.getHealth(e),rejoinGroup:e=>p.rejoinGroup(e)}}
@@ -8,8 +8,10 @@ import type { Logger } from '@kubun/logger';
8
8
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
9
9
  import type { P2PEventEmitter } from '../groups/events.js';
10
10
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
11
+ import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
11
12
  import type { GroupManager } from '../groups/manager.js';
12
13
  import type { SyncManager } from '../sync/sync-manager.js';
14
+ import type { RejoinResult } from '../types.js';
13
15
  export type PendingJoinRequest = {
14
16
  publicPackage: KeyPackageBundle['publicPackage'];
15
17
  privatePackage: KeyPackageBundle['privatePackage'];
@@ -48,6 +50,13 @@ export type ContextDeps = {
48
50
  * runs after the mutation transaction commits.
49
51
  */
50
52
  scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
53
+ /** Shared group-health monitor; source for the `groupHealth` query. */
54
+ monitor: GroupHealthMonitor;
55
+ /**
56
+ * Run stale-device recovery for a group. Wired to `HubWiring.rejoinGroup`;
57
+ * a no-op `recovery-failed` under `DISABLED_HUB`.
58
+ */
59
+ rejoinGroup: (groupID: string) => Promise<RejoinResult>;
51
60
  logger: Logger;
52
61
  };
53
62
  export declare function toISO(timestamp: unknown): string;
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from '@enkaku/event';
2
+ import { type GroupHandle, type MemberCredential } from '@enkaku/group';
2
3
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
3
4
  import { HLC } from '@kubun/hlc';
4
5
  import type { Logger } from '@kubun/logger';
@@ -6,7 +7,7 @@ import type { GraphStoreAPI } from '@kubun/store-graph';
6
7
  import type { P2PStoreAPI } from '@kubun/store-p2p';
7
8
  import type { ForwardingConfig } from '../sync/forwarder.js';
8
9
  import type { StoreUnreadableMode } from '../types.js';
9
- import { type AffectedRow, type GroupBroadcastMessage } from './broadcast.js';
10
+ import { type AffectedRow, type ApplyCatchupReplyResult, type GroupBroadcastMessage } from './broadcast.js';
10
11
  import type { P2PEventEmitter } from './events.js';
11
12
  import type { GroupHandleRegistry } from './group-handle-registry.js';
12
13
  /**
@@ -15,8 +16,20 @@ import type { GroupHandleRegistry } from './group-handle-registry.js';
15
16
  * message is unrecoverable and redelivery will not help.
16
17
  */
17
18
  export declare class DecryptError extends Error {
18
- readonly cause: unknown;
19
+ #private;
20
+ constructor(message: string, cause: unknown);
21
+ get cause(): unknown;
22
+ }
23
+ /**
24
+ * Decode failure on a rejoin control frame. Distinct from {@link DecryptError}
25
+ * so the receive path classifies it `control`, not `decrypt-failed` — a corrupt
26
+ * frame is not evidence of epoch-staleness and must not drive the health
27
+ * detector. Ack-and-skip: redelivery can't fix a malformed frame.
28
+ */
29
+ export declare class MalformedControlError extends Error {
30
+ #private;
19
31
  constructor(message: string, cause: unknown);
32
+ get cause(): unknown;
20
33
  }
21
34
  /**
22
35
  * Thrown when a successfully decrypted broadcast fails to apply to the local
@@ -24,8 +37,9 @@ export declare class DecryptError extends Error {
24
37
  * redelivered after the transient failure clears.
25
38
  */
26
39
  export declare class ApplyError extends Error {
27
- readonly cause: unknown;
40
+ #private;
28
41
  constructor(message: string, cause: unknown);
42
+ get cause(): unknown;
29
43
  }
30
44
  /**
31
45
  * Thrown when a received MLS Commit targets a future epoch the local handle
@@ -34,9 +48,10 @@ export declare class ApplyError extends Error {
34
48
  * Commit arrives first and this one applies on a later delivery.
35
49
  */
36
50
  export declare class CommitOutOfOrderError extends Error {
37
- readonly messageEpoch: bigint;
38
- readonly handleEpoch: bigint;
51
+ #private;
39
52
  constructor(messageEpoch: bigint, handleEpoch: bigint);
53
+ get messageEpoch(): bigint;
54
+ get handleEpoch(): bigint;
40
55
  }
41
56
  export type BroadcastEvent = {
42
57
  groupID: string;
@@ -103,7 +118,35 @@ export type ReceiveCommitResult = {
103
118
  removed: Array<string>;
104
119
  added: Array<string>;
105
120
  };
106
- export type ReceiveBroadcastResult = ReceiveAppResult | ReceiveCommitResult;
121
+ /**
122
+ * A received `rejoin-request`: a stale device asking current members for fresh
123
+ * GroupInfo. Cleartext; does not touch the MLS handle.
124
+ */
125
+ export type ReceiveRejoinRequestResult = {
126
+ kind: 'rejoin-request';
127
+ requestID: string;
128
+ };
129
+ /**
130
+ * A current member's `rejoin-groupinfo` reply carrying fresh GroupInfo. Consumed
131
+ * by the requester's rejoin flow; also drives responder suppression on peers.
132
+ */
133
+ export type ReceiveRejoinGroupInfoResult = {
134
+ kind: 'rejoin-groupinfo';
135
+ requestID: string;
136
+ groupInfo: Uint8Array;
137
+ };
138
+ export type ReceiveBroadcastResult = ReceiveAppResult | ReceiveCommitResult | ReceiveRejoinRequestResult | ReceiveRejoinGroupInfoResult;
139
+ export type SendRejoinRequestParams = {
140
+ requestID: string;
141
+ /** Wire-send hook; receives the `rejoinRequest`-framed cleartext bytes. */
142
+ send: (framed: Uint8Array) => Promise<void>;
143
+ };
144
+ export type SendRejoinGroupInfoParams = {
145
+ requestID: string;
146
+ groupInfo: Uint8Array;
147
+ /** Wire-send hook; receives the `rejoinGroupInfo`-framed cleartext bytes. */
148
+ send: (framed: Uint8Array) => Promise<void>;
149
+ };
107
150
  /**
108
151
  * BroadcastService handles MLS encrypt/decrypt for group broadcasts
109
152
  * and applies received messages to the local DB.
@@ -127,11 +170,14 @@ export type BroadcastServiceParams = {
127
170
  /** Single canonical access point for MLS `GroupHandle` instances. */
128
171
  registry: GroupHandleRegistry;
129
172
  /**
130
- * Local HLC clock for stamping the `removed_at_hlc` of members dropped by a
131
- * received MLS Commit (the receive side has no HLC otherwise). Defaults to a
132
- * fresh clock keyed on `selfDID` when omitted.
173
+ * Device-wide monotonic clock for stamping the `removed_at_hlc` of members
174
+ * dropped by a received MLS Commit (the receive side has no HLC otherwise).
175
+ * Required: production passes the engine's single instance so every write
176
+ * from this device advances one shared clock, preventing two same-millisecond
177
+ * writes from minting identical timestamps (the second would silently lose
178
+ * under last-writer-wins).
133
179
  */
134
- hlc?: HLC;
180
+ hlc: HLC;
135
181
  /**
136
182
  * Receiver storage mode for incoming `mutation:apply` entries. Defaults
137
183
  * to `'persist'`. When `'drop'`, `defaultAccessLevel` must be supplied
@@ -180,10 +226,38 @@ export declare class BroadcastService extends EventEmitter<BroadcastServiceEvent
180
226
  * replays it on reconnect.
181
227
  */
182
228
  prepareSendCommit(params: SendCommitParams): Promise<void>;
229
+ /**
230
+ * Export a fresh `MLSMessage(GroupInfo)` from the current handle. Read-only:
231
+ * takes the registry lock but does not advance or persist state.
232
+ */
233
+ exportGroupInfo(groupID: string): Promise<Uint8Array>;
234
+ readCredential(groupID: string): Promise<MemberCredential>;
235
+ /**
236
+ * Swap+persist the cached handle (under the per-group mutex), then reconcile
237
+ * the store roster. The swap is security-critical; a reconcile failure is
238
+ * logged and swallowed so it can't reject the rejoin after the handle is
239
+ * installed (which would skip `markRecovered` and wedge the monitor) — the
240
+ * next rejoin re-runs the idempotent reconcile.
241
+ */
242
+ replaceHandle(groupID: string, handle: GroupHandle): Promise<void>;
243
+ /** Send a `rejoin-request` (cleartext) to the group channel. */
244
+ prepareSendRejoinRequest(params: SendRejoinRequestParams): Promise<void>;
245
+ /** Send a `rejoin-groupinfo` reply (cleartext) to the group channel. */
246
+ prepareSendRejoinGroupInfo(params: SendRejoinGroupInfoParams): Promise<void>;
183
247
  /**
184
248
  * Process a received broadcast: MLS decrypt INSIDE the registry lock,
185
249
  * then apply to the DB OUTSIDE the lock so a slow apply doesn't block
186
250
  * subsequent encrypt/decrypt on the same group.
187
251
  */
188
252
  processReceived(params: ReceiveBroadcastParams): Promise<ReceiveBroadcastResult>;
253
+ /**
254
+ * Apply a `circle-catchup:reply` batch (circles + circle members, including
255
+ * tombstoned) row-by-row through the existing handlers. Lock-free: like
256
+ * `processReceived`'s post-decrypt apply, this runs OUTSIDE the registry's
257
+ * per-group lock — it touches no MLS handle, and each row apply is idempotent
258
+ * and order-independent, so a late or duplicate reply converges harmlessly.
259
+ */
260
+ applyCatchupReply(groupID: string, reply: Extract<GroupBroadcastMessage, {
261
+ type: 'circle-catchup:reply';
262
+ }>): Promise<ApplyCatchupReplyResult>;
189
263
  }
@@ -1 +1 @@
1
- import{EventEmitter as r}from"@enkaku/event";import{readMessageEpoch as e}from"@enkaku/group";import{HLC as t}from"@kubun/hlc";import{processBroadcast as a}from"./broadcast.js";import{deserializeBroadcast as o,serializeBroadcast as s}from"./broadcast-codec.js";import{mlsDecryptFramed as i,mlsEncryptFramed as c}from"./mls-codec.js";import{frameWire as p,unframeWire as m,WireKind as d}from"./wire-frame.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 CommitOutOfOrderError extends Error{messageEpoch;handleEpoch;constructor(r,e){super(`MLS commit epoch ${r} ahead of handle epoch ${e}`),this.name="CommitOutOfOrderError",this.messageEpoch=r,this.handleEpoch=e}}export class BroadcastService extends r{#r;#e;#t;constructor(r){super(),this.#t=r.hlc??new t({nodeID:r.selfDID}),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,emitter:r.emitter,logger:r.logger},this.#e=r.registry}async prepareSend(r){let{groupID:e,message:t,send:a}=r,o=s(t);await this.#e.withHandle(e,async r=>{let{framed:e}=await c(r,o);await a(p(d.app,e))})}async prepareSendCommit(r){let{commitBytes:e,send:t}=r;await t(p(d.mls,e))}async processReceived(r){let e,t,s,c,{groupID:p,encrypted:l}=r;try{({kind:e,payload:t}=m(l))}catch(r){throw new DecryptError("Malformed group wire frame",r)}if(e===d.mls)return await this.#a(p,t);try{s=await this.#e.withHandle(p,async r=>{let e=await i(r,t);return o(e)})}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{c=await a(this.#r,s,p)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let{applied:h,affected:n,duplicates:u}=c;return await this.emit("broadcast",{groupID:p,message:s,applied:h,affected:n,duplicates:u}),{kind:"app",message:s,applied:h,affected:n,duplicates:u}}async #a(r,a){return await this.#e.withHandle(r,async o=>{let s=e(a),i=o.epoch;if(null!=s&&s<i)return{kind:"mls",epoch:i,removed:[],added:[]};if(null!=s&&s>i)throw new CommitOutOfOrderError(s,i);let c=o.listMembers();try{await o.processMessage(a)}catch(r){throw new DecryptError("Failed to process MLS commit",r)}let p=o.listMembers(),m=new Set(c.map(r=>r.id)),d=new Set(p.map(r=>r.id)),l=c.filter(r=>!d.has(r.id)).map(r=>r.id),h=p.filter(r=>!m.has(r.id)).map(r=>r.id),n=t.serialize(this.#t.now()),{p2pStore:u}=this.#r;for(let e of l)await u.markMemberRemoved(r,e,n);for(let e of h)await u.addGroupMember({group_id:r,member_did:e,role:"member",hlc:n});return{kind:"mls",epoch:o.epoch,removed:l,added:h}})}}
1
+ import{EventEmitter as r}from"@enkaku/event";import{exportGroupInfo as e,readMessageEpoch as t}from"@enkaku/group";import{HLC as a}from"@kubun/hlc";import{applyCatchupReply as o,processBroadcast as s}from"./broadcast.js";import{deserializeBroadcast as i,serializeBroadcast as c}from"./broadcast-codec.js";import{mlsDecryptFramed as n,mlsEncryptFramed as l}from"./mls-codec.js";import{decodeRejoinGroupInfo as d,decodeRejoinRequest as m,encodeRejoinGroupInfo as p,encodeRejoinRequest as h}from"./rejoin-codec.js";import{frameWire as u,unframeWire as w,WireKind as f}from"./wire-frame.js";export class DecryptError extends Error{#r;constructor(r,e){super(r),this.name="DecryptError",this.#r=e}get cause(){return this.#r}}export class MalformedControlError extends Error{#r;constructor(r,e){super(r),this.name="MalformedControlError",this.#r=e}get cause(){return this.#r}}export class ApplyError extends Error{#r;constructor(r,e){super(r),this.name="ApplyError",this.#r=e}get cause(){return this.#r}}export class CommitOutOfOrderError extends Error{#e;#t;constructor(r,e){super(`MLS commit epoch ${r} ahead of handle epoch ${e}`),this.name="CommitOutOfOrderError",this.#e=r,this.#t=e}get messageEpoch(){return this.#e}get handleEpoch(){return this.#t}}export class BroadcastService extends r{#a;#o;#s;constructor(r){super(),this.#s=r.hlc,this.#a={p2pStore:r.p2pStore,graphStore:r.graphStore,graph:r.graph,selfDID:r.selfDID,storeUnreadable:r.storeUnreadable,defaultAccessLevel:r.defaultAccessLevel,forwarding:r.forwarding,scheduleBroadcast:r.scheduleBroadcast,emitter:r.emitter,logger:r.logger},this.#o=r.registry}async prepareSend(r){let{groupID:e,message:t,send:a}=r,o=c(t);await this.#o.withHandle(e,async r=>{let{framed:e}=await l(r,o);await a(u(f.app,e))})}async prepareSendCommit(r){let{commitBytes:e,send:t}=r;await t(u(f.mls,e))}async exportGroupInfo(r){return await this.#o.withHandle(r,async r=>{let{groupInfo:t}=await e({group:r});return t})}async readCredential(r){return await this.#o.withHandle(r,async r=>r.credential)}async replaceHandle(r,e){await this.#o.replaceHandle(r,e);try{await this.#i(r,e)}catch(e){this.#a.logger?.warn("rejoin: store roster reconcile failed",{groupID:r,error:e})}}async #i(r,e){let{p2pStore:t}=this.#a,o=e.listMembers().map(r=>r.id),s=new Set(o),i=await t.listGroupMembers(r),c=new Set(i.map(r=>r.member_did)),n=a.serialize(this.#s.now());for(let e of i)s.has(e.member_did)||await t.markMemberRemoved(r,e.member_did,n);for(let e of o)c.has(e)||await t.addGroupMember({group_id:r,member_did:e,role:"member",hlc:n})}async prepareSendRejoinRequest(r){let{requestID:e,send:t}=r;await t(u(f.rejoinRequest,h({requestID:e})))}async prepareSendRejoinGroupInfo(r){let{requestID:e,groupInfo:t,send:a}=r;await a(u(f.rejoinGroupInfo,p({requestID:e,groupInfo:t})))}async processReceived(r){let e,t,a,o,{groupID:c,encrypted:l}=r;try{({kind:e,payload:t}=w(l))}catch(r){throw new DecryptError("Malformed group wire frame",r)}if(e===f.mls)return await this.#c(c,t);if(e===f.rejoinRequest)try{let{requestID:r}=m(t);return{kind:"rejoin-request",requestID:r}}catch(r){throw new MalformedControlError("Malformed rejoin-request frame",r)}if(e===f.rejoinGroupInfo)try{let{requestID:r,groupInfo:e}=d(t);return{kind:"rejoin-groupinfo",requestID:r,groupInfo:e}}catch(r){throw new MalformedControlError("Malformed rejoin-groupinfo frame",r)}try{a=await this.#o.withHandle(c,async r=>{let e=await n(r,t);return i(e)})}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{o=await s(this.#a,a,c)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let{applied:p,affected:h,duplicates:u}=o;return await this.emit("broadcast",{groupID:c,message:a,applied:p,affected:h,duplicates:u}),{kind:"app",message:a,applied:p,affected:h,duplicates:u}}async applyCatchupReply(r,e){return await o(this.#a,e,r)}async #c(r,e){return await this.#o.withHandle(r,async o=>{let s=t(e),i=o.epoch;if(null!=s&&s<i)return{kind:"mls",epoch:i,removed:[],added:[]};if(null!=s&&s>i)throw new CommitOutOfOrderError(s,i);let c=o.listMembers();try{await o.processMessage(e)}catch(r){throw new DecryptError("Failed to process MLS commit",r)}let n=o.listMembers(),l=new Set(c.map(r=>r.id)),d=new Set(n.map(r=>r.id)),m=c.filter(r=>!d.has(r.id)).map(r=>r.id),p=n.filter(r=>!l.has(r.id)).map(r=>r.id),h=a.serialize(this.#s.now()),{p2pStore:u}=this.#a;for(let e of m)await u.markMemberRemoved(r,e,h);for(let e of p)await u.addGroupMember({group_id:r,member_did:e,role:"member",hlc:h});return{kind:"mls",epoch:o.epoch,removed:m,added:p}})}}
@@ -13,6 +13,23 @@ export type MutationApplyEntry = {
13
13
  version: string;
14
14
  modelID: string;
15
15
  };
16
+ /**
17
+ * A circle row in a catch-up snapshot. Mirrors the wire form of `circle:create`
18
+ * (matching {@link CircleRecord} field names) plus `removedAtHLC` so tombstoned
19
+ * circles carry their removal HLC. `null` when the circle is active.
20
+ */
21
+ export type CircleSnapshot = CircleRecord & {
22
+ removedAtHLC: string | null;
23
+ };
24
+ /**
25
+ * A circle-member row in a catch-up snapshot. Mirrors the wire form of
26
+ * `member:add` (matching {@link CircleMemberRecord} field names) plus
27
+ * `removedAtHLC` so tombstoned memberships carry their removal HLC. `null` when
28
+ * the membership is active.
29
+ */
30
+ export type CircleMemberSnapshot = CircleMemberRecord & {
31
+ removedAtHLC: string | null;
32
+ };
16
33
  export type GroupBroadcastMessage = {
17
34
  type: 'circle:create';
18
35
  circle: CircleRecord;
@@ -109,6 +126,26 @@ export type GroupBroadcastMessage = {
109
126
  entries: Array<MutationApplyEntry>;
110
127
  /** DID of the peer that authored this broadcast — used for loopback drop. */
111
128
  senderPeerDID: string;
129
+ } | {
130
+ /**
131
+ * A rejoining peer asks current members for a snapshot of the group's
132
+ * circle + circle-member rows so it can recover state it missed while
133
+ * absent. `requestID` correlates the eventual batched reply.
134
+ */
135
+ type: 'circle-catchup:request';
136
+ requestID: string;
137
+ } | {
138
+ /**
139
+ * A current member's batched response to a `circle-catchup:request`. One
140
+ * frame carrying the group's current circle + circle-member rows,
141
+ * INCLUDING tombstoned entries (each row's `removedAtHLC` distinguishes
142
+ * active from removed). The requester correlates it via `requestID` and
143
+ * applies each row through the existing circle/member broadcast handlers.
144
+ */
145
+ type: 'circle-catchup:reply';
146
+ requestID: string;
147
+ circles: Array<CircleSnapshot>;
148
+ circleMembers: Array<CircleMemberSnapshot>;
112
149
  };
113
150
  export type AffectedRow = {
114
151
  kind: 'circle';
@@ -219,3 +256,36 @@ export type ProcessBroadcastParams = {
219
256
  * payload fidelity.
220
257
  */
221
258
  export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage, groupID: string): Promise<ProcessBroadcastResult>;
259
+ /**
260
+ * Summary of a batched catch-up apply: how many rows mutated local state.
261
+ */
262
+ export type ApplyCatchupReplyResult = {
263
+ /** Count of circle rows whose synthesized message mutated local state. */
264
+ circlesApplied: number;
265
+ /** Count of member rows whose synthesized message mutated local state. */
266
+ membersApplied: number;
267
+ };
268
+ /**
269
+ * Apply a batched catch-up reply row-by-row through the existing per-row
270
+ * broadcast handlers. Each snapshot row is turned into the single equivalent
271
+ * {@link GroupBroadcastMessage} and fed through {@link processBroadcast}, so all
272
+ * conflict resolution (HLC last-write-wins for circle create/update, the
273
+ * earliest-wins tombstone guards for circle/member removal) is the exact same
274
+ * code the live broadcast path runs. No additional merge logic lives here.
275
+ *
276
+ * A tombstoned circle replays a `circle:create` (so a rejoiner that never saw
277
+ * the circle materializes the row) immediately followed by a `circle:delete`
278
+ * stamped with the row's `removedAtHLC`. The delete's earliest-wins guard
279
+ * converges the tombstone; the create is a no-op when a newer local row already
280
+ * exists. An active circle replays a single `circle:create` (the apply path
281
+ * routes create -> update for an existing id). Member rows map symmetrically to
282
+ * `member:add` / `member:remove` — `member:add` materializes the row so the
283
+ * subsequent `member:remove` has a row to tombstone.
284
+ *
285
+ * Idempotent and order-independent: re-applying the same batch, or applying its
286
+ * rows in any order, lands the same final state because every synthesized
287
+ * message runs the same LWW / earliest-wins guards.
288
+ */
289
+ export declare function applyCatchupReply(params: ProcessBroadcastParams, reply: Extract<GroupBroadcastMessage, {
290
+ type: 'circle-catchup:reply';
291
+ }>, groupID: string): Promise<ApplyCatchupReplyResult>;
@@ -1 +1 @@
1
- import{HLC as e}from"@kubun/hlc";import{evaluateAndForward as a,rethrowIfProgrammerError as r}from"../sync/forwarder.js";import{createReceiveAccessGate as l}from"../sync/receive-access-gate.js";import{storeReceivedGrant as t}from"./store-received-grant.js";import{storeReceivedRevocation as i}from"./store-received-revocation.js";function c(a,r){return e.compare(e.parse(a),e.parse(r))>0}export async function processBroadcast(e,o,d){let{p2pStore:n,graphStore:p}=e;switch(o.type){case"circle:create":{let e=await n.getCircle(o.circle.id);if(null!=e){if(!c(o.circle.hlc,e.hlc))return{applied:!1};await n.updateCircle(o.circle.id,{name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let a=await n.getCircle(o.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}await n.createCircle({id:o.circle.id,group_id:o.circle.groupID,name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let a=await n.getCircle(o.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:update":{let e=await n.getCircle(o.circleID);if(null==e||!c(o.update.hlc,e.hlc))return{applied:!1};await n.updateCircle(o.circleID,{name:o.update.name,description:o.update.description,catalog_ids:o.update.catalogIDs,hlc:o.update.hlc});let a=await n.getCircle(o.circleID);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:delete":{let e=await n.getCircle(o.circleID);if(null==e||!c(o.hlc,e.hlc))return{applied:!1};return await n.deleteCircle(o.circleID),{applied:!0,affected:{kind:"circle",row:e}}}case"member:add":{await n.addCircleMember({circle_id:o.member.circleID,member_did:o.member.memberDID,role:o.member.role,hlc:o.member.hlc});let e=await n.getCircleMember(o.member.circleID,o.member.memberDID);return null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"member:remove":{let e=await n.getCircleMember(o.circleID,o.memberDID);return await n.removeCircleMember(o.circleID,o.memberDID),null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"catalog:create":{let e=await p.getCatalog(o.catalog.id);if(null!=e){if(!c(o.catalog.hlc,e.hlc))return{applied:!1};await p.updateCatalog(o.catalog.id,{name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let a=await p.getCatalog(o.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}await p.createCatalog({id:o.catalog.id,owner_did:o.catalog.ownerDID,name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let a=await p.getCatalog(o.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:update":{let e=await p.getCatalog(o.catalogID);if(null==e||!c(o.update.hlc,e.hlc))return{applied:!1};await p.updateCatalog(o.catalogID,{name:o.update.name,description:o.update.description,filter_criteria:o.update.filterCriteria,hlc:o.update.hlc});let a=await p.getCatalog(o.catalogID);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:delete":{let e=await p.getCatalog(o.catalogID);if(null==e||!c(o.hlc,e.hlc))return{applied:!1};return await p.deleteCatalog(o.catalogID),{applied:!0,affected:{kind:"catalog",row:e}}}case"delegation:share":return{applied:await t({p2pStore:n,token:o.token,groupID:o.groupID,hlc:o.hlc,selfDID:e.selfDID,...null!=e.emitter?{emitter:e.emitter}:{},...null!=e.logger?{logger:e.logger}:{}})};case"delegation:revoke":return{applied:await i({p2pStore:n,token:o.token,groupID:o.groupID,hlc:o.hlc,...null!=e.selfDID?{selfDID:e.selfDID}:{},...null!=e.emitter?{emitter:e.emitter}:{},...null!=e.logger?{logger:e.logger}:{}})};case"group:update":{let e=await n.getGroup(o.groupID);if(null==e||!c(o.update.hlc,e.hlc))return{applied:!1};await n.updateGroup(o.groupID,{name:o.update.name,description:o.update.description,hlc:o.update.hlc});let a=await n.getGroup(o.groupID);return null!=a?{applied:!0,affected:{kind:"group",row:a}}:{applied:!0}}case"group:leaveRequest":return await e.emitter?.emit("groupLeaveRequested",{groupID:o.groupID,memberDID:o.memberDID,role:"",createdAt:new Date().toISOString()}),{applied:!0};case"mutation:apply":{if(o.senderPeerDID===e.selfDID)return{applied:!1};let t="drop"===(e.storeUnreadable??"persist")?l({selfDID:e.selfDID,db:{getUserModelAccessDefault:(e,a,r)=>p.getUserModelAccessDefault(e,a,r),isMemberOfAnyCircle:(e,a)=>n.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>n.isMemberOfAnyGroup(e,a),getModelInterfaces:e=>p.getModelInterfaces(e)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}}):void 0,i=null!=e.forwarding&&!1!==e.forwarding&&null!=e.scheduleBroadcast,c=!1,u=0,g=0,s=0;for(let l of o.entries)try{let o=await e.graph.applyVerifiedMutation({token:l.mutationJWT,origin:"peer",arrivalGroupID:d,...null!=t?{accessGate:t}:{}});if(o.dropped){u++;continue}let m=!1;try{let e=await n.recordBroadcast({docID:l.docID,version:l.version,mlsGroupID:d});m=!0,!e&&s++}catch(a){r(a),e.logger?.warn("mutation:apply recordBroadcast failed, skipping forward",{docID:l.docID,version:l.version,groupID:d,error:a})}if(c=!0,!m)continue;if(i&&null!=e.forwarding&&null!=e.scheduleBroadcast&&null!=o.document)try{let r=await a({entry:l,postState:o.document,sourceGroupID:d,selfDID:e.selfDID,p2pStore:n,graphStore:p,defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"},forwarding:e.forwarding,scheduleBroadcast:e.scheduleBroadcast,logger:e.logger});g+=r.forwarded}catch(a){r(a),e.logger?.warn("mutation:apply forwarding failed, skipping entry",{docID:l.docID,version:l.version,groupID:d,error:a})}}catch(a){r(a),e.logger?.warn("mutation:apply entry failed, skipping",{docID:l.docID,version:l.version,modelID:l.modelID,groupID:d,error:a})}let m={applied:c,dropped:u,duplicates:s};return i&&(m.forwarded=g),m}}}
1
+ import{HLC as e}from"@kubun/hlc";import{evaluateAndForward as r,rethrowIfProgrammerError as a}from"../sync/forwarder.js";import{createReceiveAccessGate as l}from"../sync/receive-access-gate.js";import{storeReceivedGrant as t}from"./store-received-grant.js";import{storeReceivedRevocation as c}from"./store-received-revocation.js";function i(r,a){return e.compare(e.parse(r),e.parse(a))>0}export async function processBroadcast(e,o,d){let{p2pStore:p,graphStore:n}=e;switch(o.type){case"circle:create":{let e=await p.getCircle(o.circle.id,{includeRemoved:!0});if(null!=e){let r=e.removed_at_hlc??e.hlc;if(!i(o.circle.hlc,r))return{applied:!1};await p.updateCircle(o.circle.id,{name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let a=await p.getCircle(o.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}await p.createCircle({id:o.circle.id,group_id:o.circle.groupID,name:o.circle.name,description:o.circle.description,catalog_ids:o.circle.catalogIDs,hlc:o.circle.hlc});let r=await p.getCircle(o.circle.id);return null!=r?{applied:!0,affected:{kind:"circle",row:r}}:{applied:!0}}case"circle:update":{let e=await p.getCircle(o.circleID,{includeRemoved:!0});if(null==e)return{applied:!1};let r=e.removed_at_hlc??e.hlc;if(!i(o.update.hlc,r))return{applied:!1};await p.updateCircle(o.circleID,{name:o.update.name,description:o.update.description,catalog_ids:o.update.catalogIDs,hlc:o.update.hlc});let a=await p.getCircle(o.circleID);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:delete":{let e=await p.getCircle(o.circleID,{includeRemoved:!0});if(null==e||!i(o.hlc,e.hlc))return{applied:!1};return await p.markCircleRemoved(o.circleID,o.hlc),{applied:!0,affected:{kind:"circle",row:e}}}case"member:add":{let e=await p.getCircleMember(o.member.circleID,o.member.memberDID);if(null!=e){let r=e.removed_at_hlc??e.hlc;if(!i(o.member.hlc,r))return{applied:!1}}await p.addCircleMember({circle_id:o.member.circleID,member_did:o.member.memberDID,role:o.member.role,hlc:o.member.hlc});let r=await p.getCircleMember(o.member.circleID,o.member.memberDID);return null!=r?{applied:!0,affected:{kind:"member",row:r}}:{applied:!0}}case"member:remove":{let e=await p.getCircleMember(o.circleID,o.memberDID);return await p.removeCircleMember(o.circleID,o.memberDID,o.hlc),null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"catalog:create":{let e=await n.getCatalog(o.catalog.id);if(null!=e){if(!i(o.catalog.hlc,e.hlc))return{applied:!1};await n.updateCatalog(o.catalog.id,{name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let r=await n.getCatalog(o.catalog.id);return null!=r?{applied:!0,affected:{kind:"catalog",row:r}}:{applied:!0}}await n.createCatalog({id:o.catalog.id,owner_did:o.catalog.ownerDID,name:o.catalog.name,description:o.catalog.description,filter_criteria:o.catalog.filterCriteria,hlc:o.catalog.hlc});let r=await n.getCatalog(o.catalog.id);return null!=r?{applied:!0,affected:{kind:"catalog",row:r}}:{applied:!0}}case"catalog:update":{let e=await n.getCatalog(o.catalogID);if(null==e||!i(o.update.hlc,e.hlc))return{applied:!1};await n.updateCatalog(o.catalogID,{name:o.update.name,description:o.update.description,filter_criteria:o.update.filterCriteria,hlc:o.update.hlc});let r=await n.getCatalog(o.catalogID);return null!=r?{applied:!0,affected:{kind:"catalog",row:r}}:{applied:!0}}case"catalog:delete":{let e=await n.getCatalog(o.catalogID);if(null==e||!i(o.hlc,e.hlc))return{applied:!1};return await n.deleteCatalog(o.catalogID),{applied:!0,affected:{kind:"catalog",row:e}}}case"delegation:share":return{applied:await t({p2pStore:p,token:o.token,groupID:o.groupID,hlc:o.hlc,selfDID:e.selfDID,...null!=e.emitter?{emitter:e.emitter}:{},...null!=e.logger?{logger:e.logger}:{}})};case"delegation:revoke":return{applied:await c({p2pStore:p,token:o.token,groupID:o.groupID,hlc:o.hlc,...null!=e.selfDID?{selfDID:e.selfDID}:{},...null!=e.emitter?{emitter:e.emitter}:{},...null!=e.logger?{logger:e.logger}:{}})};case"group:update":{let e=await p.getGroup(o.groupID);if(null==e||!i(o.update.hlc,e.hlc))return{applied:!1};await p.updateGroup(o.groupID,{name:o.update.name,description:o.update.description,hlc:o.update.hlc});let r=await p.getGroup(o.groupID);return null!=r?{applied:!0,affected:{kind:"group",row:r}}:{applied:!0}}case"group:leaveRequest":return await e.emitter?.emit("groupLeaveRequested",{groupID:o.groupID,memberDID:o.memberDID,role:"",createdAt:new Date().toISOString()}),{applied:!0};case"mutation:apply":{if(o.senderPeerDID===e.selfDID)return{applied:!1};let t="drop"===(e.storeUnreadable??"persist")?l({selfDID:e.selfDID,db:{getUserModelAccessDefault:(e,r,a)=>n.getUserModelAccessDefault(e,r,a),isMemberOfAnyCircle:(e,r)=>p.isMemberOfAnyCircle(e,r),isMemberOfAnyGroup:(e,r)=>p.isMemberOfAnyGroup(e,r),getModelInterfaces:e=>n.getModelInterfaces(e)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}}):void 0,c=null!=e.forwarding&&!1!==e.forwarding&&null!=e.scheduleBroadcast,i=!1,u=0,s=0,m=0;for(let l of o.entries)try{let o=await e.graph.applyVerifiedMutation({token:l.mutationJWT,origin:"peer",arrivalGroupID:d,...null!=t?{accessGate:t}:{}});if(o.dropped){u++;continue}let g=!1;try{let e=await p.recordBroadcast({docID:l.docID,version:l.version,mlsGroupID:d});g=!0,!e&&m++}catch(r){a(r),e.logger?.warn("mutation:apply recordBroadcast failed, skipping forward",{docID:l.docID,version:l.version,groupID:d,error:r})}if(i=!0,!g)continue;if(c&&null!=e.forwarding&&null!=e.scheduleBroadcast&&null!=o.document)try{let a=await r({entry:l,postState:o.document,sourceGroupID:d,selfDID:e.selfDID,p2pStore:p,graphStore:n,defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"},forwarding:e.forwarding,scheduleBroadcast:e.scheduleBroadcast,logger:e.logger});s+=a.forwarded}catch(r){a(r),e.logger?.warn("mutation:apply forwarding failed, skipping entry",{docID:l.docID,version:l.version,groupID:d,error:r})}}catch(r){a(r),e.logger?.warn("mutation:apply entry failed, skipping",{docID:l.docID,version:l.version,modelID:l.modelID,groupID:d,error:r})}let g={applied:i,dropped:u,duplicates:m};return c&&(g.forwarded=s),g}case"circle-catchup:request":case"circle-catchup:reply":return{applied:!1}}}export async function applyCatchupReply(e,r,a){let l=0,t=0;for(let t of r.circles){let r={type:"circle:create",circle:{id:t.id,groupID:t.groupID,name:t.name,description:t.description,catalogIDs:t.catalogIDs,hlc:t.hlc}},c=(await processBroadcast(e,r,a)).applied;if(null!=t.removedAtHLC){let r={type:"circle:delete",circleID:t.id,hlc:t.removedAtHLC},l=await processBroadcast(e,r,a);c=c||l.applied}c&&l++}for(let l of r.circleMembers){let r={type:"member:add",member:{circleID:l.circleID,memberDID:l.memberDID,role:l.role,hlc:l.hlc}},c=(await processBroadcast(e,r,a)).applied;if(null!=l.removedAtHLC){let r={type:"member:remove",circleID:l.circleID,memberDID:l.memberDID,hlc:l.removedAtHLC},t=await processBroadcast(e,r,a);c=c||t.applied}c&&t++}return{circlesApplied:l,membersApplied:t}}
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from '@enkaku/event';
2
- import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupMemberData, RevokedDelegationData } from '../types.js';
2
+ import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupHealthData, GroupMemberData, RevokedDelegationData } from '../types.js';
3
3
  export type P2PEventMap = {
4
4
  groupJoined: GroupData;
5
5
  groupLeft: GroupData & {
@@ -53,6 +53,7 @@ export type P2PEventMap = {
53
53
  };
54
54
  delegationTokenAdded: DelegationData;
55
55
  delegationTokenRevoked: RevokedDelegationData;
56
+ groupHealthChanged: GroupHealthData;
56
57
  };
57
58
  export type P2PEventEmitter = EventEmitter<P2PEventMap>;
58
59
  export declare function createP2PEventEmitter(): P2PEventEmitter;
@@ -72,6 +72,14 @@ export declare class GroupHandleRegistry {
72
72
  * Called by `leaveGroup` after the MLS state row is deleted, and by tests.
73
73
  */
74
74
  invalidate(groupID: string): void;
75
+ /**
76
+ * Atomically swap+persist the cached handle under the per-group mutex, so a
77
+ * concurrent decrypt runs strictly before (stale handle, fails harmlessly) or
78
+ * after (new handle), never interleaved.
79
+ */
80
+ replaceHandle(groupID: string, handle: GroupHandle, options?: {
81
+ stores?: StoreProvider;
82
+ }): Promise<void>;
75
83
  /**
76
84
  * Prime the cache with a freshly-built handle and persist the initial row
77
85
  * via the supplied transactional `StoreProvider`. Called by `createGroup`
@@ -1 +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 i,serializeMLSGroupState as a,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){return this.#i(e,r,async(r,s)=>{let i=await t(r.handle);return await this.#a(s,e,r.handle),i})}withHandleReplacing(e,t,r){return this.#i(e,r,async(r,s)=>{let{result:i,updated:a}=await t(r.handle);return r.handle=a,await this.#a(s,e,a),i})}#i(e,t,r){let s=t?.stores??this.#e,i=this.#l(e);return i.mutex.run(async()=>(null==i.handle&&(i.handle=await this.#n(s,e)),r(i,s)))}invalidate(e){this.#s.delete(e)}async seed(e){let{groupID:r,handle:s,stores:i}=e,n=this.#s.get(r);if(n?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=a(s),h=await t(i);await h.saveMLSState(l(o,r,this.#t)),i.onCommit(()=>{let e=this.#l(r);null==e.handle&&(e.handle=s)})}#l(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #n(e,r){let a=await t(e),l=await a.getMLSState(r,this.#t);if(null==l)throw this.#r.debug("restore: no MLS state",{groupID:r,deviceID:this.#t}),Error(`No MLS state for group ${r}`);return this.#r.debug("restore: MLS state",{groupID:r,deviceID:this.#t,epoch:l.epoch}),await s(i(l))}async #a(e,r,s){let i=a(s),n=await t(e);await n.saveMLSState(l(i,r,this.#t)),this.#r.debug("persist: MLS state",{groupID:r,deviceID:this.#t,epoch:i.epoch})}}
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 i,serializeMLSGroupState as a,toMLSStateInsert as n}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){return this.#i(e,r,async(r,s)=>{let i=await t(r.handle);return await this.#a(s,e,r.handle),i})}withHandleReplacing(e,t,r){return this.#i(e,r,async(r,s)=>{let{result:i,updated:a}=await t(r.handle);return r.handle=a,await this.#a(s,e,a),i})}#i(e,t,r){let s=t?.stores??this.#e,i=this.#n(e);return i.mutex.run(async()=>(null==i.handle&&(i.handle=await this.#l(s,e)),r(i,s)))}invalidate(e){this.#s.delete(e)}replaceHandle(e,t,r){return this.#i(e,r,async(r,s)=>{r.handle=t,await this.#a(s,e,t)})}async seed(e){let{groupID:r,handle:s,stores:i}=e,l=this.#s.get(r);if(l?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=a(s),h=await t(i);await h.saveMLSState(n(o,r,this.#t)),i.onCommit(()=>{let e=this.#n(r);null==e.handle&&(e.handle=s)})}#n(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #l(e,r){let a=await t(e),n=await a.getMLSState(r,this.#t);if(null==n)throw this.#r.debug("restore: no MLS state",{groupID:r,deviceID:this.#t}),Error(`No MLS state for group ${r}`);return this.#r.debug("restore: MLS state",{groupID:r,deviceID:this.#t,epoch:n.epoch}),await s(i(n))}async #a(e,r,s){let i=a(s),l=await t(e);await l.saveMLSState(n(i,r,this.#t)),this.#r.debug("persist: MLS state",{groupID:r,deviceID:this.#t,epoch:i.epoch})}}
@@ -0,0 +1,45 @@
1
+ import type { GroupHealthCondition, GroupHealthSnapshot, GroupHealthState } from '../types.js';
2
+ import type { P2PEventEmitter } from './events.js';
3
+ export type GroupHealthMonitorParams = {
4
+ emitter: P2PEventEmitter;
5
+ /** Tuning for the `epoch-stale` condition. Defaults to a threshold of 3. */
6
+ epochStale?: {
7
+ threshold?: number;
8
+ };
9
+ /** Clock for `lastTransitionAt` stamps. Defaults to `Date.now`. */
10
+ now?: () => number;
11
+ };
12
+ /**
13
+ * Per-group health state machine. Counts/latches failure signals per
14
+ * `(groupID, condition)` and emits `groupHealthChanged` on transitions.
15
+ * Crypto- and persistence-free: callers classify failures before signalling.
16
+ *
17
+ * State is in-memory per-process — a restart re-derives it from the next signal.
18
+ * Transitions are synchronous (no `await`), so calls cannot interleave; signals
19
+ * arriving while a rejoin is in flight (`recovering`) are ignored — the recovery
20
+ * owns the group until it resolves.
21
+ */
22
+ export declare class GroupHealthMonitor {
23
+ #private;
24
+ constructor(params: GroupHealthMonitorParams);
25
+ /**
26
+ * Record a failure observation for a condition. Below the condition's
27
+ * threshold this only increments the counter; on reaching it the group
28
+ * transitions to `degraded` (emitted once, then latched). Ignored while the
29
+ * group is `recovering` (the rejoin owns it) or already `degraded`/failed.
30
+ */
31
+ signal(groupID: string, condition: GroupHealthCondition): void;
32
+ /**
33
+ * Clear a condition to healthy and re-arm (counter reset), driven by the
34
+ * success path. Silent: only recovery (via the mark* methods) is surfaced.
35
+ */
36
+ reset(groupID: string, condition: GroupHealthCondition): void;
37
+ markRecovering(groupID: string): void;
38
+ /** Emit the transient `recovered` event, then settle the phase to `healthy`. */
39
+ markRecovered(groupID: string): void;
40
+ /** Leave the group `recovery-failed` until a later success resets it. */
41
+ markRecoveryFailed(groupID: string): void;
42
+ /** Aggregate state: the first non-healthy condition phase, else `healthy`. */
43
+ getState(groupID: string): GroupHealthState;
44
+ getHealth(groupID: string): GroupHealthSnapshot;
45
+ }
@@ -0,0 +1 @@
1
+ export class GroupHealthMonitor{#e;#t;#s;#h=new Map;#a=new Map;constructor(e){this.#e=e.emitter,this.#t=e.epochStale?.threshold??3,this.#s=e.now??Date.now}signal(e,t){let s=this.#r(e,t);"healthy"===s.phase&&(s.count+=1,s.count>=this.#i(t)&&(s.phase="degraded",this.#o(e),this.#l(e,t,"degraded")))}reset(e,t){let s=this.#r(e,t);s.count=0,"healthy"!==s.phase&&(s.phase="healthy",this.#o(e))}markRecovering(e){this.#r(e,"epoch-stale").phase="recovering",this.#o(e),this.#l(e,"epoch-stale","recovering")}markRecovered(e){let t=this.#r(e,"epoch-stale");t.count=0,t.phase="healthy",this.#o(e),this.#l(e,"epoch-stale","recovered")}markRecoveryFailed(e){this.#r(e,"epoch-stale").phase="recovery-failed",this.#o(e),this.#l(e,"epoch-stale","recovery-failed")}getState(e){let t=this.#h.get(e);if(null==t)return"healthy";for(let e of t.values())if("healthy"!==e.phase)return e.phase;return"healthy"}getHealth(e){return{state:this.getState(e),lastTransitionAt:this.#a.get(e)??null}}#o(e){this.#a.set(e,this.#s())}#i(e){return"epoch-stale"===e?this.#t:1/0}#r(e,t){let s=this.#h.get(e);null==s&&(s=new Map,this.#h.set(e,s));let h=s.get(t);return null==h&&(h={phase:"healthy",count:0},s.set(t,h)),h}#l(e,t,s){this.#e.emit("groupHealthChanged",{groupID:e,condition:t,state:s})}}
@@ -1,6 +1,7 @@
1
1
  import { type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
2
2
  import type { Identity, OwnIdentity } from '@enkaku/token';
3
3
  import type { StoreProvider } from '@kubun/db';
4
+ import { HLC } from '@kubun/hlc';
4
5
  import type { GroupBroadcastMessage } from './broadcast.js';
5
6
  import type { GroupHandleRegistry } from './group-handle-registry.js';
6
7
  import type { SuggestedHub } from './invite-payload.js';
@@ -13,6 +14,14 @@ export type GroupManagerParams = {
13
14
  * the registry's per-group mutex with write-through persist.
14
15
  */
15
16
  registry: GroupHandleRegistry;
17
+ /**
18
+ * Device-wide monotonic clock for stamping group/circle/member metadata.
19
+ * Required: production passes the engine's single instance so every write
20
+ * from this device — graph mutations and group metadata alike — advances one
21
+ * shared clock, preventing two same-millisecond writes from minting identical
22
+ * timestamps (the second would silently lose under last-writer-wins).
23
+ */
24
+ hlc: HLC;
16
25
  };
17
26
  export type CreateGroupParams = {
18
27
  stores: StoreProvider;
@@ -1 +1 @@
1
- import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{bindHubToGroup as c,upsertHub as n}from"../hub/manager.js";export class GroupManager{#e;#r;#t;#i;constructor(e){this.#e=e.identity.id,this.#r=e.getRandomID,this.#t=new o({nodeID:e.identity.id}),this.#i=e.registry}async createGroup(e){let t=this.#r(),{group:i}=await r(e.identity,t),a=e.hubs??[];return await e.stores.withTransaction(async r=>{let s=await l(r);for(let l of(await s.createGroup({id:t,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hlc:o.serialize(this.#t.now())}),await this.#i.seed({groupID:t,handle:i,stores:r}),await s.addGroupMember({group_id:t,member_did:e.identity.id,role:"admin",hlc:o.serialize(this.#t.now())}),a)){let e=await n({stores:r,url:l.url,serverDID:l.serverDID??null});await c({stores:r,hubID:e.id,groupID:t})}}),{groupID:t}}async updateGroup(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hlc:r}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:r}}}}async inviteToGroup(r){let i=await this.#i.withHandleReplacing(r.groupID,async i=>{let{invite:a}=await t({group:i,identity:r.identity,recipientDID:r.recipientDID,permission:r.permission}),{commitMessage:o,welcomeMessage:s,newGroup:l}=await e(i,r.recipientKeyPackage);return{result:{invite:a,commitMessage:o,welcomeMessage:s,ratchetTree:l.state.ratchetTree},updated:l}},{stores:r.stores}),a=await l(r.stores);return await a.addGroupMember({group_id:r.groupID,member_did:r.recipientDID,role:"admin"===r.permission?"admin":"member",hlc:o.serialize(this.#t.now())}),i}async joinGroup(e){let{group:r}=await i({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree}),t=e.hubs??[];await e.stores.withTransaction(async i=>{await this.#i.seed({groupID:e.groupID,handle:r,stores:i});let a=await l(i);for(let r of(null==await a.getGroup(e.groupID)&&await a.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hlc:o.serialize(this.#t.now())}),await a.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member",hlc:o.serialize(this.#t.now())}),t)){let t=await n({stores:i,url:r.url,serverDID:r.serverDID??null});await c({stores:i,hubID:t.id,groupID:e.groupID})}})}async removeMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let{commitMessage:t,newGroup:i}=await a(r,e.leafIndex);return{result:{commitMessage:t},updated:i}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async removeGroupMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let t=r.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:i,newGroup:o}=await a(r,t);return{result:{commitMessage:i},updated:o}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async leaveGroup(e){let r=await l(e.stores);await r.deleteMLSState(e.groupID,this.#e),await r.removeGroupMember(e.groupID,e.identity.id,o.serialize(this.#t.now())),e.stores.onCommit(()=>this.#i.invalidate(e.groupID))}async loadGroup(e,r){let t=await l(e),i=await t.getMLSState(r,this.#e);if(null!=i)return{epoch:i.epoch,credential:i.credential}}async createCircle(e){let r=this.#r(),t=o.serialize(this.#t.now()),i=e.catalogIDs??[],a=await l(e.stores);return await a.createCircle({id:r,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:i,hlc:t}),{circleID:r,broadcast:{type:"circle:create",circle:{id:r,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:i,hlc:t}}}}async updateCircle(e){let r=o.serialize(this.#t.now()),t=[],i=await l(e.stores);if(null!=e.update.catalogIDs){let r=await i.getCircle(e.circleID),a=new Set(null!=r?r.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!a.has(e)),l=await s(e.stores);for(let e of(await Promise.all(o.map(e=>l.getCatalog(e)))))null!=e&&t.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await i.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:r});let a={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:r}};return{broadcast:a,broadcasts:[a,...t]}}async deleteCircle(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.deleteCircle(e.circleID),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:r}}}async addCircleMember(e){let r=await l(e.stores);if(!await r.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let t=o.serialize(this.#t.now());return await r.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:t}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:t}}}}async removeCircleMember(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.removeCircleMember(e.circleID,e.memberDID),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:r}}}}
1
+ import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as l}from"@kubun/store-p2p";import{bindHubToGroup as c,upsertHub as n}from"../hub/manager.js";export class GroupManager{#e;#r;#t;#i;constructor(e){this.#e=e.identity.id,this.#r=e.getRandomID,this.#t=e.hlc,this.#i=e.registry}async createGroup(e){let t=this.#r(),{group:i}=await r(e.identity,t),a=e.hubs??[];return await e.stores.withTransaction(async r=>{let s=await l(r);for(let l of(await s.createGroup({id:t,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hlc:o.serialize(this.#t.now())}),await this.#i.seed({groupID:t,handle:i,stores:r}),await s.addGroupMember({group_id:t,member_did:e.identity.id,role:"admin",hlc:o.serialize(this.#t.now())}),a)){let e=await n({stores:r,url:l.url,serverDID:l.serverDID??null});await c({stores:r,hubID:e.id,groupID:t})}}),{groupID:t}}async updateGroup(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hlc:r}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:r}}}}async inviteToGroup(r){let i=await this.#i.withHandleReplacing(r.groupID,async i=>{let{invite:a}=await t({group:i,identity:r.identity,recipientDID:r.recipientDID,permission:r.permission}),{commitMessage:o,welcomeMessage:s,newGroup:l}=await e(i,r.recipientKeyPackage);return{result:{invite:a,commitMessage:o,welcomeMessage:s,ratchetTree:l.state.ratchetTree},updated:l}},{stores:r.stores}),a=await l(r.stores);return await a.addGroupMember({group_id:r.groupID,member_did:r.recipientDID,role:"admin"===r.permission?"admin":"member",hlc:o.serialize(this.#t.now())}),i}async joinGroup(e){let{group:r}=await i({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree}),t=e.hubs??[];await e.stores.withTransaction(async i=>{await this.#i.seed({groupID:e.groupID,handle:r,stores:i});let a=await l(i);for(let r of(null==await a.getGroup(e.groupID)&&await a.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hlc:o.serialize(this.#t.now())}),await a.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member",hlc:o.serialize(this.#t.now())}),t)){let t=await n({stores:i,url:r.url,serverDID:r.serverDID??null});await c({stores:i,hubID:t.id,groupID:e.groupID})}})}async removeMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let{commitMessage:t,newGroup:i}=await a(r,e.leafIndex);return{result:{commitMessage:t},updated:i}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async removeGroupMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let t=r.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:i,newGroup:o}=await a(r,t);return{result:{commitMessage:i},updated:o}},{stores:e.stores}),t=await l(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID,o.serialize(this.#t.now())),{commitMessage:r}}async leaveGroup(e){let r=await l(e.stores);await r.deleteMLSState(e.groupID,this.#e),await r.removeGroupMember(e.groupID,e.identity.id,o.serialize(this.#t.now())),e.stores.onCommit(()=>this.#i.invalidate(e.groupID))}async loadGroup(e,r){let t=await l(e),i=await t.getMLSState(r,this.#e);if(null!=i)return{epoch:i.epoch,credential:i.credential}}async createCircle(e){let r=this.#r(),t=o.serialize(this.#t.now()),i=e.catalogIDs??[],a=await l(e.stores);return await a.createCircle({id:r,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:i,hlc:t}),{circleID:r,broadcast:{type:"circle:create",circle:{id:r,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:i,hlc:t}}}}async updateCircle(e){let r=o.serialize(this.#t.now()),t=[],i=await l(e.stores);if(null!=e.update.catalogIDs){let r=await i.getCircle(e.circleID),a=new Set(null!=r?r.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!a.has(e)),l=await s(e.stores);for(let e of(await Promise.all(o.map(e=>l.getCatalog(e)))))null!=e&&t.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await i.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:r});let a={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:r}};return{broadcast:a,broadcasts:[a,...t]}}async deleteCircle(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.markCircleRemoved(e.circleID,r),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:r}}}async addCircleMember(e){let r=await l(e.stores);if(!await r.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let t=o.serialize(this.#t.now());return await r.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:t}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:t}}}}async removeCircleMember(e){let r=o.serialize(this.#t.now()),t=await l(e.stores);return await t.removeCircleMember(e.circleID,e.memberDID,r),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:r}}}}
@@ -0,0 +1,14 @@
1
+ /** A stale device's request for a fresh GroupInfo. Cleartext on the wire. */
2
+ export type RejoinRequest = {
3
+ requestID: string;
4
+ };
5
+ /** A current member's reply carrying a framed `MLSMessage(GroupInfo)`. */
6
+ export type RejoinGroupInfo = {
7
+ requestID: string;
8
+ /** Framed `MLSMessage(GroupInfo)` bytes. */
9
+ groupInfo: Uint8Array;
10
+ };
11
+ export declare function encodeRejoinRequest(request: RejoinRequest): Uint8Array;
12
+ export declare function decodeRejoinRequest(bytes: Uint8Array): RejoinRequest;
13
+ export declare function encodeRejoinGroupInfo(message: RejoinGroupInfo): Uint8Array;
14
+ export declare function decodeRejoinGroupInfo(bytes: Uint8Array): RejoinGroupInfo;
@@ -0,0 +1 @@
1
+ import{replacer as e,reviver as r}from"./mls-json.js";let o=new TextEncoder,n=new TextDecoder;export function encodeRejoinRequest(r){return o.encode(JSON.stringify(r,e))}export function decodeRejoinRequest(e){let o=JSON.parse(n.decode(e),r);if("string"!=typeof o?.requestID)throw Error("invalid rejoin-request: missing requestID");return{requestID:o.requestID}}export function encodeRejoinGroupInfo(r){return o.encode(JSON.stringify(r,e))}export function decodeRejoinGroupInfo(e){let o=JSON.parse(n.decode(e),r);if("string"!=typeof o?.requestID)throw Error("invalid rejoin-groupinfo: missing requestID");if(!(o.groupInfo instanceof Uint8Array))throw Error("invalid rejoin-groupinfo: missing groupInfo bytes");return{requestID:o.requestID,groupInfo:o.groupInfo}}
@@ -17,6 +17,8 @@
17
17
  export declare const WireKind: {
18
18
  readonly app: 0;
19
19
  readonly mls: 1;
20
+ readonly rejoinRequest: 2;
21
+ readonly rejoinGroupInfo: 3;
20
22
  };
21
23
  export type WireKind = (typeof WireKind)[keyof typeof WireKind];
22
24
  /** Prepend the `kind` byte to a payload. */
@@ -1 +1 @@
1
- export const WireKind={app:0,mls:1};export function frameWire(r,e){let n=new Uint8Array(e.length+1);return n[0]=r,n.set(e,1),n}export function unframeWire(r){if(0===r.length)throw Error("empty wire frame");let e=r[0];if(e!==WireKind.app&&e!==WireKind.mls)throw Error(`unknown wire frame kind: ${e}`);return{kind:e,payload:r.subarray(1)}}
1
+ export const WireKind={app:0,mls:1,rejoinRequest:2,rejoinGroupInfo:3};let r=new Set(Object.values(WireKind));export function frameWire(r,e){let n=new Uint8Array(e.length+1);return n[0]=r,n.set(e,1),n}export function unframeWire(e){if(0===e.length)throw Error("empty wire frame");let n=e[0];if(!r.has(n))throw Error(`unknown wire frame kind: ${n}`);return{kind:n,payload:e.subarray(1)}}