@kubun/plugin-p2p 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/delegation.d.ts +4 -0
- package/lib/context/delegation.js +1 -0
- package/lib/context/group.js +1 -1
- package/lib/context/hub.js +1 -1
- package/lib/context/join.js +1 -1
- package/lib/context/types.d.ts +25 -2
- package/lib/groups/broadcast-service.d.ts +137 -4
- package/lib/groups/broadcast-service.js +1 -1
- package/lib/groups/broadcast.d.ts +120 -0
- package/lib/groups/broadcast.js +1 -1
- package/lib/groups/events.d.ts +16 -5
- package/lib/groups/events.js +1 -1
- package/lib/groups/group-handle-registry.d.ts +8 -0
- package/lib/groups/group-handle-registry.js +1 -1
- package/lib/groups/group-health-monitor.d.ts +45 -0
- package/lib/groups/group-health-monitor.js +1 -0
- package/lib/groups/invite-payload.d.ts +16 -0
- package/lib/groups/join-utils.d.ts +68 -2
- package/lib/groups/join-utils.js +1 -1
- package/lib/groups/manager.d.ts +15 -4
- package/lib/groups/manager.js +1 -1
- package/lib/groups/rejoin-codec.d.ts +14 -0
- package/lib/groups/rejoin-codec.js +1 -0
- package/lib/groups/store-received-grant.d.ts +47 -0
- package/lib/groups/store-received-grant.js +1 -0
- package/lib/groups/store-received-revocation.d.ts +46 -0
- package/lib/groups/store-received-revocation.js +1 -0
- package/lib/groups/wire-frame.d.ts +34 -0
- package/lib/groups/wire-frame.js +1 -0
- package/lib/hub/circle-catchup-requester.d.ts +58 -0
- package/lib/hub/circle-catchup-requester.js +1 -0
- package/lib/hub/circle-catchup-responder.d.ts +50 -0
- package/lib/hub/circle-catchup-responder.js +1 -0
- package/lib/hub/epoch-stale-detector.d.ts +18 -0
- package/lib/hub/epoch-stale-detector.js +1 -0
- package/lib/hub/errors.d.ts +7 -5
- package/lib/hub/errors.js +1 -1
- package/lib/hub/forward-remote-broadcast.d.ts +3 -3
- package/lib/hub/forward-remote-broadcast.js +1 -1
- package/lib/hub/group-channel.d.ts +30 -0
- package/lib/hub/group-channel.js +1 -1
- package/lib/hub/hub-connection.js +1 -1
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +1 -1
- package/lib/hub/receive-handler.d.ts +20 -2
- package/lib/hub/receive-handler.js +1 -1
- package/lib/hub/rejoin-manager.d.ts +78 -0
- package/lib/hub/rejoin-manager.js +1 -0
- package/lib/hub/rejoin-responder.d.ts +32 -0
- package/lib/hub/rejoin-responder.js +1 -0
- package/lib/hub/relay-manager.d.ts +23 -1
- package/lib/hub/relay-manager.js +1 -1
- package/lib/hub/send-handler.d.ts +16 -0
- package/lib/hub/send-handler.js +1 -1
- package/lib/hub/wiring.d.ts +33 -3
- package/lib/hub/wiring.js +1 -1
- package/lib/index.d.ts +19 -5
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +7 -0
- package/lib/protocol.js +1 -1
- package/lib/schema.d.ts +2 -2
- package/lib/schema.js +79 -3
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +1 -0
- package/lib/sync/broadcast-sender.js +1 -1
- package/lib/sync/errors.d.ts +11 -0
- package/lib/sync/errors.js +1 -0
- package/lib/sync/forwarder.d.ts +1 -0
- package/lib/sync/forwarder.js +1 -1
- package/lib/sync/handlers.d.ts +3 -18
- package/lib/sync/handlers.js +1 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +2 -0
- package/lib/sync/hub-tunnel-sync-listener.js +1 -1
- package/lib/sync/merkle-apply.js +1 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +1 -1
- package/lib/sync/peer-registry.d.ts +7 -0
- package/lib/sync/peer-registry.js +1 -1
- package/lib/sync/sync-client.d.ts +2 -0
- package/lib/sync/sync-client.js +1 -1
- package/lib/sync/sync-manager.js +1 -1
- package/lib/types.d.ts +122 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +1 -0
- package/package.json +41 -41
- package/lib/sync/catalog-match.d.ts +0 -13
- package/lib/sync/catalog-match.js +0 -1
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ExecutionContext } from '@kubun/engine';
|
|
2
|
+
import type { DelegationRequestContext } from '../types.js';
|
|
3
|
+
import type { ContextDeps } from './types.js';
|
|
4
|
+
export declare function createDelegationContext(_ctx: ExecutionContext, deps: ContextDeps): DelegationRequestContext;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{getGraphStore as e}from"@kubun/store-graph";import{getP2PStore as t}from"@kubun/store-p2p";import{GraphQLError as r}from"graphql";export function createDelegationContext(r,u){let a=null,i=null,d=()=>a??=t(u.stores),c=()=>i??=e(u.stores);return{listOwnTokens:async({viewerDID:e,grantor:t,covers:r})=>{let n=await d(),u=Math.floor(Date.now()/1e3),a=await n.getHeldTokens({audience:e,atTime:u}),i=null!=t?a.filter(e=>e.grantor===t):a,s=i;return null!=r&&(null!=r.docID||null!=r.modelID)&&(s=await o(i,r,c)),s.map(l)},listOwnRevokedTokens:async({viewerDID:e,grantor:t})=>{let r=await d(),o=await r.getHeldRevocations({audience:e});return(null!=t?o.filter(e=>e.grantor===t):o).map(n)}}}function n(e){return{jti:e.jti,grantor:e.grantor,audience:e.audience,revokerDID:e.revoker_did,revokedAt:e.revoked_iat,verifiedAt:e.verified_at,capExp:e.cap_exp,groupID:e.group_id}}async function o(e,t,n){let o=null,l=null;if(null!=t.docID){l=t.docID;let e=await n(),u=null;try{u=await e.getDocument(t.docID)}catch{return[]}if(null==u)return[];if(o=u.model,null!=t.modelID&&t.modelID!==o)throw new r(`Document ${l} has model ${o}, not ${t.modelID}`,{extensions:{code:"DELEGATION_TOKEN_COVERS_MISMATCH"}})}else null!=t.modelID&&(o=t.modelID);if(null==o)return e;let u=new Map,a=await n(),i=u.get(o);null==i&&(i=await a.getModelInterfaces(o),u.set(o,i));let d=new Set;for(let e of(d.add(`urn:kubun:model:${o}`),i))d.add(`urn:kubun:model:${e}`);let c=null!=l?`urn:kubun:document:${l}`:null;return e.filter(e=>!!("*"===e.resource||e.resource===`urn:kubun:user:${e.grantor}`||d.has(e.resource))||null!=c&&e.resource===c)}function l(e){return{jwt:e.token,jti:e.jti,grantor:e.grantor,audience:e.audience,resource:e.resource,exp:e.exp}}
|
package/lib/context/group.js
CHANGED
|
@@ -1 +1 @@
|
|
|
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}}}}
|
|
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}}}
|
package/lib/context/hub.js
CHANGED
|
@@ -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
|
|
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)}}
|
package/lib/context/join.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as
|
|
1
|
+
import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as r}from"../groups/invite-payload.js";import{applyInviteGrants as i,applyInviteRevocations as o,finalizeJoinedGroup as a}from"../groups/join-utils.js";import{checkPeerAccess as s}from"./types.js";export function createJoinContext(g,n){return{prepareRequest:async()=>{s(g.viewerDID,n.identity.id,n.autoAcceptPeers);let t=n.identity,i=await e(t);return n.pendingJoinRequests.set(g.viewerDID,{publicPackage:i.publicPackage,privatePackage:i.privatePackage,did:t.id}),{joinRequest:r({did:t.id,publicPackage:i.publicPackage})}},complete:async e=>{s(g.viewerDID,n.identity.id,n.autoAcceptPeers);let r=n.pendingJoinRequests.get(g.viewerDID);if(null==r)throw Error("No pending join request found — call prepareJoinRequest first");let p=n.identity,u=t(e),c={publicPackage:r.publicPackage,privatePackage:r.privatePackage,ownerDID:p.id};await n.groupManager.joinGroup({stores:n.stores,identity:p,groupID:u.groupID,groupName:u.groupName,hubs:u.suggestedHubs,invite:u.invite,keyPackageBundle:c,welcomeMessage:u.welcomeMessage,ratchetTree:u.ratchetTree}),await i({stores:n.stores,selfDID:p.id,groupID:u.groupID,grants:u.grants,hlc:n.hlc,emitter:n.emitter,logger:n.logger}),await o({stores:n.stores,selfDID:p.id,groupID:u.groupID,revocations:u.revocations,hlc:n.hlc,emitter:n.emitter,logger:n.logger});let l=await a({stores:n.stores,emitter:n.emitter,invite:u});return n.pendingJoinRequests.delete(g.viewerDID),l}}}
|
package/lib/context/types.d.ts
CHANGED
|
@@ -6,10 +6,12 @@ import type { Adapter } from '@kubun/db-adapter';
|
|
|
6
6
|
import type { HLC } from '@kubun/hlc';
|
|
7
7
|
import type { Logger } from '@kubun/logger';
|
|
8
8
|
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
9
|
-
import type {
|
|
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'];
|
|
@@ -23,7 +25,7 @@ export type ContextDeps = {
|
|
|
23
25
|
stores: StoreProvider;
|
|
24
26
|
adapter: Adapter;
|
|
25
27
|
hlc: HLC;
|
|
26
|
-
emitter:
|
|
28
|
+
emitter: P2PEventEmitter;
|
|
27
29
|
pendingJoinRequests: Map<string, PendingJoinRequest>;
|
|
28
30
|
runtime: Runtime;
|
|
29
31
|
autoAcceptPeers?: Array<string>;
|
|
@@ -34,6 +36,27 @@ export type ContextDeps = {
|
|
|
34
36
|
* send runs after the mutation transaction commits.
|
|
35
37
|
*/
|
|
36
38
|
scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Awaitable broadcast (encrypt + send completed on resolve). Wired to
|
|
41
|
+
* `HubWiring.broadcastNow`; a no-op resolve under `DISABLED_HUB`. Used where
|
|
42
|
+
* the send must complete before a following local op — e.g. a leave-request
|
|
43
|
+
* encrypted while the MLS handle is still live, before `leaveGroup`.
|
|
44
|
+
*/
|
|
45
|
+
broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Fire-and-forget fan-out of an MLS handshake Commit to a group's existing
|
|
48
|
+
* members. Wired to `HubWiring.scheduleSendCommit`; a no-op under
|
|
49
|
+
* `DISABLED_HUB`. Handlers invoke it via `stores.onCommit(...)` so the send
|
|
50
|
+
* runs after the mutation transaction commits.
|
|
51
|
+
*/
|
|
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>;
|
|
37
60
|
logger: Logger;
|
|
38
61
|
};
|
|
39
62
|
export declare function toISO(timestamp: unknown): string;
|
|
@@ -1,11 +1,14 @@
|
|
|
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';
|
|
4
|
+
import { HLC } from '@kubun/hlc';
|
|
3
5
|
import type { Logger } from '@kubun/logger';
|
|
4
6
|
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
5
7
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
6
8
|
import type { ForwardingConfig } from '../sync/forwarder.js';
|
|
7
9
|
import type { StoreUnreadableMode } from '../types.js';
|
|
8
|
-
import { type AffectedRow, type GroupBroadcastMessage } from './broadcast.js';
|
|
10
|
+
import { type AffectedRow, type ApplyCatchupReplyResult, type GroupBroadcastMessage } from './broadcast.js';
|
|
11
|
+
import type { P2PEventEmitter } from './events.js';
|
|
9
12
|
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
10
13
|
/**
|
|
11
14
|
* Thrown when a received broadcast fails to decrypt (MLS restore, decrypt,
|
|
@@ -13,8 +16,20 @@ import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
|
13
16
|
* message is unrecoverable and redelivery will not help.
|
|
14
17
|
*/
|
|
15
18
|
export declare class DecryptError extends Error {
|
|
16
|
-
|
|
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;
|
|
17
31
|
constructor(message: string, cause: unknown);
|
|
32
|
+
get cause(): unknown;
|
|
18
33
|
}
|
|
19
34
|
/**
|
|
20
35
|
* Thrown when a successfully decrypted broadcast fails to apply to the local
|
|
@@ -22,8 +37,21 @@ export declare class DecryptError extends Error {
|
|
|
22
37
|
* redelivered after the transient failure clears.
|
|
23
38
|
*/
|
|
24
39
|
export declare class ApplyError extends Error {
|
|
25
|
-
|
|
40
|
+
#private;
|
|
26
41
|
constructor(message: string, cause: unknown);
|
|
42
|
+
get cause(): unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when a received MLS Commit targets a future epoch the local handle
|
|
46
|
+
* has not reached yet (a gap in the in-order Commit stream). Callers should
|
|
47
|
+
* NOT ack — the hub redelivers in `sequence_id` order, so the gap-filling
|
|
48
|
+
* Commit arrives first and this one applies on a later delivery.
|
|
49
|
+
*/
|
|
50
|
+
export declare class CommitOutOfOrderError extends Error {
|
|
51
|
+
#private;
|
|
52
|
+
constructor(messageEpoch: bigint, handleEpoch: bigint);
|
|
53
|
+
get messageEpoch(): bigint;
|
|
54
|
+
get handleEpoch(): bigint;
|
|
27
55
|
}
|
|
28
56
|
export type BroadcastEvent = {
|
|
29
57
|
groupID: string;
|
|
@@ -56,16 +84,69 @@ export type SendBroadcastParams = {
|
|
|
56
84
|
*/
|
|
57
85
|
send: (encrypted: Uint8Array) => Promise<void>;
|
|
58
86
|
};
|
|
87
|
+
export type SendCommitParams = {
|
|
88
|
+
groupID: string;
|
|
89
|
+
/**
|
|
90
|
+
* Canonical MLS-encoded handshake Commit bytes (an encrypted
|
|
91
|
+
* `PrivateMessage`) produced by the committer's `GroupManager` call.
|
|
92
|
+
*/
|
|
93
|
+
commitBytes: Uint8Array;
|
|
94
|
+
/** Wire-send hook; receives the `mls`-framed bytes. */
|
|
95
|
+
send: (framed: Uint8Array) => Promise<void>;
|
|
96
|
+
};
|
|
59
97
|
export type ReceiveBroadcastParams = {
|
|
60
98
|
groupID: string;
|
|
61
99
|
encrypted: Uint8Array;
|
|
62
100
|
};
|
|
63
|
-
export type
|
|
101
|
+
export type ReceiveAppResult = {
|
|
102
|
+
kind: 'app';
|
|
64
103
|
message: GroupBroadcastMessage;
|
|
65
104
|
applied: boolean;
|
|
66
105
|
affected?: AffectedRow;
|
|
67
106
|
duplicates?: number;
|
|
68
107
|
};
|
|
108
|
+
/**
|
|
109
|
+
* Result of processing a received MLS handshake Commit (`mls` wire frame).
|
|
110
|
+
* `removed`/`added` are the membership deltas derived by diffing the group
|
|
111
|
+
* roster before and after applying the Commit. `removed` DIDs are tombstoned
|
|
112
|
+
* via `markMemberRemoved` so the apply-time membership check can reject their
|
|
113
|
+
* post-removal writes.
|
|
114
|
+
*/
|
|
115
|
+
export type ReceiveCommitResult = {
|
|
116
|
+
kind: 'mls';
|
|
117
|
+
epoch: bigint;
|
|
118
|
+
removed: Array<string>;
|
|
119
|
+
added: Array<string>;
|
|
120
|
+
};
|
|
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
|
+
};
|
|
69
150
|
/**
|
|
70
151
|
* BroadcastService handles MLS encrypt/decrypt for group broadcasts
|
|
71
152
|
* and applies received messages to the local DB.
|
|
@@ -88,6 +169,15 @@ export type BroadcastServiceParams = {
|
|
|
88
169
|
selfDID: string;
|
|
89
170
|
/** Single canonical access point for MLS `GroupHandle` instances. */
|
|
90
171
|
registry: GroupHandleRegistry;
|
|
172
|
+
/**
|
|
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).
|
|
179
|
+
*/
|
|
180
|
+
hlc: HLC;
|
|
91
181
|
/**
|
|
92
182
|
* Receiver storage mode for incoming `mutation:apply` entries. Defaults
|
|
93
183
|
* to `'persist'`. When `'drop'`, `defaultAccessLevel` must be supplied
|
|
@@ -106,6 +196,11 @@ export type BroadcastServiceParams = {
|
|
|
106
196
|
* surviving candidate group on the receive path.
|
|
107
197
|
*/
|
|
108
198
|
scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
|
|
199
|
+
/**
|
|
200
|
+
* Emitter forwarded to `processBroadcast` so `delegation:share` receives
|
|
201
|
+
* surface a `delegationTokenAdded` event to local subscribers.
|
|
202
|
+
*/
|
|
203
|
+
emitter?: P2PEventEmitter;
|
|
109
204
|
logger?: Logger;
|
|
110
205
|
};
|
|
111
206
|
export declare class BroadcastService extends EventEmitter<BroadcastServiceEvents> {
|
|
@@ -121,10 +216,48 @@ export declare class BroadcastService extends EventEmitter<BroadcastServiceEvent
|
|
|
121
216
|
* intact.
|
|
122
217
|
*/
|
|
123
218
|
prepareSend(params: SendBroadcastParams): Promise<void>;
|
|
219
|
+
/**
|
|
220
|
+
* Send a pre-produced MLS handshake Commit to the group, tagged `mls` on the
|
|
221
|
+
* wire. The Commit is already a self-protected (encrypted `PrivateMessage`)
|
|
222
|
+
* framed message produced by the committer's `GroupManager` call — it is NOT
|
|
223
|
+
* re-encrypted through the application path, so this does not touch the
|
|
224
|
+
* handle. The committer's MLS state has already advanced + persisted; the
|
|
225
|
+
* hub's durable + ordered mailbox carries the Commit to existing members and
|
|
226
|
+
* replays it on reconnect.
|
|
227
|
+
*/
|
|
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>;
|
|
124
247
|
/**
|
|
125
248
|
* Process a received broadcast: MLS decrypt INSIDE the registry lock,
|
|
126
249
|
* then apply to the DB OUTSIDE the lock so a slow apply doesn't block
|
|
127
250
|
* subsequent encrypt/decrypt on the same group.
|
|
128
251
|
*/
|
|
129
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>;
|
|
130
263
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{EventEmitter as r}from"@enkaku/event";import{
|
|
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}})}}
|
|
@@ -5,6 +5,7 @@ import type { Catalog, GraphStoreAPI } from '@kubun/store-graph';
|
|
|
5
5
|
import type { Circle, CircleMember, Group, P2PStoreAPI } from '@kubun/store-p2p';
|
|
6
6
|
import { type ForwardingConfig } from '../sync/forwarder.js';
|
|
7
7
|
import type { StoreUnreadableMode } from '../types.js';
|
|
8
|
+
import type { P2PEventEmitter } from './events.js';
|
|
8
9
|
export type MutationApplyEntry = {
|
|
9
10
|
mutationJWT: string;
|
|
10
11
|
docID: string;
|
|
@@ -12,6 +13,23 @@ export type MutationApplyEntry = {
|
|
|
12
13
|
version: string;
|
|
13
14
|
modelID: string;
|
|
14
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
|
+
};
|
|
15
33
|
export type GroupBroadcastMessage = {
|
|
16
34
|
type: 'circle:create';
|
|
17
35
|
circle: CircleRecord;
|
|
@@ -52,6 +70,37 @@ export type GroupBroadcastMessage = {
|
|
|
52
70
|
type: 'catalog:delete';
|
|
53
71
|
catalogID: string;
|
|
54
72
|
hlc: string;
|
|
73
|
+
} | {
|
|
74
|
+
/**
|
|
75
|
+
* Distributes a minted `document/write` capability token to group
|
|
76
|
+
* co-members. The recipient verifies the token's signature and stores a
|
|
77
|
+
* held row only when the token's audience (`aud`) is the receiving
|
|
78
|
+
* device, so the engine's auto-attach can reuse it on later mutations.
|
|
79
|
+
*/
|
|
80
|
+
type: 'delegation:share';
|
|
81
|
+
/** Stringified capability JWT carrying the `document/write` grant. */
|
|
82
|
+
token: string;
|
|
83
|
+
/** Group whose broadcast channel carries this grant. */
|
|
84
|
+
groupID: string;
|
|
85
|
+
/** HLC stamped by the grantor; mirrors the grantor-side issued row. */
|
|
86
|
+
hlc: string;
|
|
87
|
+
} | {
|
|
88
|
+
/**
|
|
89
|
+
* Distributes a signed revocation record for a previously minted
|
|
90
|
+
* `document/write` capability. The recipient verifies the token's
|
|
91
|
+
* signature and stores the revocation; verification against the
|
|
92
|
+
* referenced cap's `iss` flips `verified_at` once the cap is known.
|
|
93
|
+
* Revocation is binary and reactive — once verified locally, any
|
|
94
|
+
* subsequent mutation under the revoked `jti` is denied regardless
|
|
95
|
+
* of `mutation.atTime`.
|
|
96
|
+
*/
|
|
97
|
+
type: 'delegation:revoke';
|
|
98
|
+
/** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
|
|
99
|
+
token: string;
|
|
100
|
+
/** Group whose broadcast channel carries this revocation. */
|
|
101
|
+
groupID: string;
|
|
102
|
+
/** HLC stamped by the revoker; mirrors the revoker-side stored row. */
|
|
103
|
+
hlc: string;
|
|
55
104
|
} | {
|
|
56
105
|
type: 'group:update';
|
|
57
106
|
groupID: string;
|
|
@@ -60,11 +109,43 @@ export type GroupBroadcastMessage = {
|
|
|
60
109
|
description?: string;
|
|
61
110
|
hlc: string;
|
|
62
111
|
};
|
|
112
|
+
} | {
|
|
113
|
+
/**
|
|
114
|
+
* A member announces a voluntary leave. MLS forbids self-removal, so this
|
|
115
|
+
* is an advisory notification (not an authoritative removal): receivers
|
|
116
|
+
* surface a `groupLeaveRequested` event for an admin to act on by issuing
|
|
117
|
+
* a real `removeMember`. Deliberately does NOT tombstone — a forged
|
|
118
|
+
* leave-request must not be able to evict an arbitrary member.
|
|
119
|
+
*/
|
|
120
|
+
type: 'group:leaveRequest';
|
|
121
|
+
groupID: string;
|
|
122
|
+
memberDID: string;
|
|
123
|
+
hlc: string;
|
|
63
124
|
} | {
|
|
64
125
|
type: 'mutation:apply';
|
|
65
126
|
entries: Array<MutationApplyEntry>;
|
|
66
127
|
/** DID of the peer that authored this broadcast — used for loopback drop. */
|
|
67
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>;
|
|
68
149
|
};
|
|
69
150
|
export type AffectedRow = {
|
|
70
151
|
kind: 'circle';
|
|
@@ -148,6 +229,12 @@ export type ProcessBroadcastParams = {
|
|
|
148
229
|
* carrying `senderPeerDID = selfDID`.
|
|
149
230
|
*/
|
|
150
231
|
scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
|
|
232
|
+
/**
|
|
233
|
+
* Emitter forwarded to `storeReceivedGrant` on the `delegation:share` branch
|
|
234
|
+
* so consumers of `ownDelegationTokenAdded` see broadcast-delivered grants.
|
|
235
|
+
* Absent in tests that don't exercise the subscription path.
|
|
236
|
+
*/
|
|
237
|
+
emitter?: P2PEventEmitter;
|
|
151
238
|
/** Optional logger — warns on per-entry apply failures. */
|
|
152
239
|
logger?: Logger;
|
|
153
240
|
};
|
|
@@ -169,3 +256,36 @@ export type ProcessBroadcastParams = {
|
|
|
169
256
|
* payload fidelity.
|
|
170
257
|
*/
|
|
171
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>;
|
package/lib/groups/broadcast.js
CHANGED
|
@@ -1 +1 @@
|
|
|
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
|
|
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}}
|
package/lib/groups/events.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from '@enkaku/event';
|
|
2
|
-
import type { CircleData, CircleMemberData, GroupData, GroupMemberData } from '../
|
|
3
|
-
export type
|
|
2
|
+
import type { CircleData, CircleMemberData, DelegationData, GroupData, GroupHealthData, GroupMemberData, RevokedDelegationData } from '../types.js';
|
|
3
|
+
export type P2PEventMap = {
|
|
4
4
|
groupJoined: GroupData;
|
|
5
5
|
groupLeft: GroupData & {
|
|
6
6
|
groupID: string;
|
|
@@ -14,6 +14,14 @@ export type GroupEventMap = {
|
|
|
14
14
|
groupMemberLeft: GroupMemberData & {
|
|
15
15
|
groupID: string;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* A member announced a voluntary leave (received `group:leaveRequest`).
|
|
19
|
+
* Advisory: MLS forbids self-removal, so the leaf lingers until an admin
|
|
20
|
+
* issues a real `removeMember`. Admin apps may act on this; not auto-evicted.
|
|
21
|
+
*/
|
|
22
|
+
groupLeaveRequested: GroupMemberData & {
|
|
23
|
+
groupID: string;
|
|
24
|
+
};
|
|
17
25
|
circleCreated: CircleData & {
|
|
18
26
|
groupID: string;
|
|
19
27
|
};
|
|
@@ -43,7 +51,10 @@ export type GroupEventMap = {
|
|
|
43
51
|
hubID: string;
|
|
44
52
|
hubURL: string;
|
|
45
53
|
};
|
|
54
|
+
delegationTokenAdded: DelegationData;
|
|
55
|
+
delegationTokenRevoked: RevokedDelegationData;
|
|
56
|
+
groupHealthChanged: GroupHealthData;
|
|
46
57
|
};
|
|
47
|
-
export type
|
|
48
|
-
export declare function
|
|
49
|
-
export declare function createFilteredGenerator<K extends keyof
|
|
58
|
+
export type P2PEventEmitter = EventEmitter<P2PEventMap>;
|
|
59
|
+
export declare function createP2PEventEmitter(): P2PEventEmitter;
|
|
60
|
+
export declare function createFilteredGenerator<K extends keyof P2PEventMap>(emitter: P2PEventEmitter, eventName: K, filter?: (data: P2PEventMap[K]) => boolean): AsyncGenerator<P2PEventMap[K], void, void>;
|
package/lib/groups/events.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{EventEmitter as e}from"@enkaku/event";import{fromEmitter as r}from"@enkaku/generator";export function
|
|
1
|
+
import{EventEmitter as e}from"@enkaku/event";import{fromEmitter as r}from"@enkaku/generator";export function createP2PEventEmitter(){return new e}export function createFilteredGenerator(e,t,n){return r(e,t,{filter:n})}
|
|
@@ -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
|
|
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})}}
|