@kubun/plugin-p2p 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
package/lib/context/group.js
CHANGED
|
@@ -1 +1,1646 @@
|
|
|
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
|
+
import { createCapability, createRevocationRecord } from '@kokuin/capability';
|
|
2
|
+
import { normalizeDID, stringifyToken } from '@kokuin/token';
|
|
3
|
+
import { HLC } from '@kubun/hlc';
|
|
4
|
+
import { MAX_CAP_TTL_SECONDS, getDelegationStore as resolveDelegationStore } from '@kubun/store-delegation';
|
|
5
|
+
import { getGraphStore as resolveGraphStore } from '@kubun/store-graph';
|
|
6
|
+
import { getP2PStore as resolveP2PStore } from '@kubun/store-p2p';
|
|
7
|
+
import { adminCount, readGroupAnchor } from '@kumiai/mls';
|
|
8
|
+
import { GraphQLError } from 'graphql';
|
|
9
|
+
import { ADMIN_ROLE_ENTRY_TYPE } from '../groups/admin-roster.js';
|
|
10
|
+
import { signCatalogSet } from '../groups/catalog-token.js';
|
|
11
|
+
import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, encodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from '../groups/circle-reducers.js';
|
|
12
|
+
import { awaitControlRequestSettled, settleControlRequest, toControlRequestData } from '../groups/control-request.js';
|
|
13
|
+
import { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeInvitePayload } from '../groups/invite-payload.js';
|
|
14
|
+
import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
|
|
15
|
+
import { applyInviteControlState, applyInviteGrants, applyInviteRevocations, collectInviteSeeds, finalizeJoinedGroup } from '../groups/join-utils.js';
|
|
16
|
+
import { signLedgerEntry } from '../groups/ledger.js';
|
|
17
|
+
import { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, requireGroupAdmin } from './require-admin.js';
|
|
18
|
+
import { checkPeerAccess, toCircleData, toISO } from './types.js';
|
|
19
|
+
/** Project a stored catalog row into the GraphQL `PeerCatalog` shape. */ function toCatalogData(row) {
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
ownerDID: row.owner_did,
|
|
23
|
+
name: row.name,
|
|
24
|
+
description: row.description,
|
|
25
|
+
filterCriteria: JSON.stringify(row.filter_criteria),
|
|
26
|
+
active: row.active === 1,
|
|
27
|
+
sourceGroupID: row.source_group_id,
|
|
28
|
+
sourceCircleID: row.source_circle_id,
|
|
29
|
+
createdAt: toISO(row.created_at)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Default write-grant lifetime in seconds (30 days).
|
|
34
|
+
*
|
|
35
|
+
* Sits exactly at `MAX_CAP_TTL_SECONDS`. Raising it above that ceiling mints
|
|
36
|
+
* silently — the mint guard below tests the `expiresIn` argument, not the
|
|
37
|
+
* effective lifetime, so a raised default never reaches it. Every receiver then
|
|
38
|
+
* drops the grant, which is a harder failure to trace than a throw.
|
|
39
|
+
*/ const DEFAULT_GRANT_EXPIRES_IN = 2_592_000;
|
|
40
|
+
/**
|
|
41
|
+
* How long an invite waits for its Add commit to land before refusing.
|
|
42
|
+
*
|
|
43
|
+
* Sized for the commit lane, not for a local write: the Add goes to the group's
|
|
44
|
+
* commit log and can be rebased by a concurrent commit before it is accepted, so
|
|
45
|
+
* the bound has to cover several attempts plus their round-trips. It is a
|
|
46
|
+
* refusal deadline, not a latency target — an invite that waits this long has
|
|
47
|
+
* hit a real fault, and the caller can retry.
|
|
48
|
+
*/ const INVITE_COMMIT_TIMEOUT = 30_000;
|
|
49
|
+
export function createGroupContext(ctx, deps) {
|
|
50
|
+
let p2pStorePromise = null;
|
|
51
|
+
let delegationStorePromise = null;
|
|
52
|
+
let graphStorePromise = null;
|
|
53
|
+
const getP2PStore = ()=>p2pStorePromise ??= resolveP2PStore(deps.stores);
|
|
54
|
+
const getDelegationStore = ()=>delegationStorePromise ??= resolveDelegationStore(deps.stores);
|
|
55
|
+
const getGraphStore = ()=>graphStorePromise ??= resolveGraphStore(deps.stores);
|
|
56
|
+
/**
|
|
57
|
+
* Mint the identity of a new circle.
|
|
58
|
+
*
|
|
59
|
+
* The ONE place a circle id comes from, so the source can be changed without
|
|
60
|
+
* hunting call sites: the id is handed to the caller before the commit
|
|
61
|
+
* carrying the definition lands, and every later step names the circle by it.
|
|
62
|
+
*/ const mintCircleID = ()=>deps.runtime.getRandomID();
|
|
63
|
+
/**
|
|
64
|
+
* Mint an already-settled `no_op` request: the state the caller asked for is
|
|
65
|
+
* the state that already holds, so there is no entry to sign, no commit to
|
|
66
|
+
* drive and nothing to broadcast. The caller still gets a real, resolvable
|
|
67
|
+
* request — a terminal status IS the outcome, and "nothing to do" is an
|
|
68
|
+
* outcome an app must be able to read back.
|
|
69
|
+
*
|
|
70
|
+
* The enqueue and the settle share one transaction so a crash can never leave
|
|
71
|
+
* a request that reports `pending` for work that will never run.
|
|
72
|
+
*/ const settleNoOpRequest = async (groupID)=>{
|
|
73
|
+
const requestID = deps.runtime.getRandomID();
|
|
74
|
+
const request = await deps.stores.withTransaction(async (tx)=>{
|
|
75
|
+
const txStore = await resolveP2PStore(tx);
|
|
76
|
+
const at = new Date().toISOString();
|
|
77
|
+
await txStore.enqueueControlRequest({
|
|
78
|
+
id: requestID,
|
|
79
|
+
groupID,
|
|
80
|
+
kind: 'ledger',
|
|
81
|
+
enqueuedAt: at
|
|
82
|
+
});
|
|
83
|
+
await txStore.settleControlRequest({
|
|
84
|
+
id: requestID,
|
|
85
|
+
outcome: 'no_op',
|
|
86
|
+
settledAt: at
|
|
87
|
+
});
|
|
88
|
+
const row = await txStore.getControlRequest(requestID);
|
|
89
|
+
if (row == null) {
|
|
90
|
+
throw new Error('Failed to read the control request after settling it');
|
|
91
|
+
}
|
|
92
|
+
return toControlRequestData(row);
|
|
93
|
+
});
|
|
94
|
+
// Announce only once the row is durable: a settle emitted from inside the
|
|
95
|
+
// transaction would tell subscribers about a row a rollback erases.
|
|
96
|
+
deps.stores.onCommit(()=>{
|
|
97
|
+
deps.emitter.emit('controlRequestSettled', request).catch((error)=>{
|
|
98
|
+
deps.logger.error('controlRequestSettled emit failed', {
|
|
99
|
+
groupID,
|
|
100
|
+
error
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
return request;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Report a commit lane's refusal, from inside the lane.
|
|
108
|
+
*
|
|
109
|
+
* Takes no store provider, on purpose. Every caller runs inside a
|
|
110
|
+
* `stores.onCommit` callback, and those fire AFTER the mutation's transaction
|
|
111
|
+
* has committed — under `mutateGraph` `deps.stores` IS that transaction, so a
|
|
112
|
+
* settle written through it waits on a transaction that is already over and
|
|
113
|
+
* never lands. Silently: `settleControlRequest` swallows everything, so the
|
|
114
|
+
* row stays `pending`, `controlRequestSettled` never fires, and every waiter
|
|
115
|
+
* times out instead of learning the outcome. `deps.deviceStores` exists for
|
|
116
|
+
* exactly this, and a helper that closes over it is what stops the next
|
|
117
|
+
* deferred settle reaching for the wrong provider.
|
|
118
|
+
*/ const settleRejectedFromLane = (requestID, error)=>{
|
|
119
|
+
void settleControlRequest({
|
|
120
|
+
stores: deps.deviceStores,
|
|
121
|
+
requestID,
|
|
122
|
+
outcome: 'rejected',
|
|
123
|
+
error: error instanceof Error ? error.message : String(error),
|
|
124
|
+
emitter: deps.emitter,
|
|
125
|
+
logger: deps.logger
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* Queue the request that will report a signed entry's outcome, then drive the
|
|
130
|
+
* MLS commit that enacts it.
|
|
131
|
+
*
|
|
132
|
+
* Nothing is projected, broadcast or emitted here, and there is nowhere to write
|
|
133
|
+
* the entry: an entry enters the group's ledger by being ENACTED. This device
|
|
134
|
+
* then folds its own committed entry through the same `ingestVerifiedLedgerEntry`
|
|
135
|
+
* a receiver runs, from the same signed token.
|
|
136
|
+
*/ const enqueueLedgerEntryRequest = async (params)=>{
|
|
137
|
+
const { groupID, token } = params;
|
|
138
|
+
const requestID = deps.runtime.getRandomID();
|
|
139
|
+
const enqueuedAt = new Date().toISOString();
|
|
140
|
+
await deps.stores.withTransaction(async (tx)=>{
|
|
141
|
+
const txStore = await resolveP2PStore(tx);
|
|
142
|
+
await txStore.enqueueControlRequest({
|
|
143
|
+
id: requestID,
|
|
144
|
+
groupID,
|
|
145
|
+
kind: 'ledger',
|
|
146
|
+
enqueuedAt
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// The commit is the ONLY delivery: the token rides the commit's own frame as
|
|
150
|
+
// a sealed body, so every co-member resolves it, folds it and emits from
|
|
151
|
+
// that frame. A separate `ledger:entry` broadcast would race the commit and
|
|
152
|
+
// hand co-members an entry the group has not yet accepted, so none is sent.
|
|
153
|
+
//
|
|
154
|
+
// It is driven AFTER the request row is durable, and decoupled from this
|
|
155
|
+
// resolver's write transaction — the local MLS handle advances only when the
|
|
156
|
+
// hub accepts — so it is deferred to the outermost commit. Deferring is what
|
|
157
|
+
// guarantees the request exists to be settled by the time the lane can
|
|
158
|
+
// settle it.
|
|
159
|
+
const build = deps.groupManager.buildEnactLedgerCommit({
|
|
160
|
+
groupID,
|
|
161
|
+
tokens: [
|
|
162
|
+
token
|
|
163
|
+
],
|
|
164
|
+
requestID
|
|
165
|
+
});
|
|
166
|
+
deps.stores.onCommit(()=>{
|
|
167
|
+
void deps.commitToGroup(groupID, build).catch((error)=>{
|
|
168
|
+
deps.logger.error('enact-ledger commit lane failed', {
|
|
169
|
+
groupID,
|
|
170
|
+
error
|
|
171
|
+
});
|
|
172
|
+
// A lane throw is the group refusing the entry or the deadline
|
|
173
|
+
// expiring — the request is over and the app must be told. The local
|
|
174
|
+
// admin gate is NOT this: it throws before anything is enqueued, so a
|
|
175
|
+
// refused caller has no row to settle.
|
|
176
|
+
settleRejectedFromLane(requestID, error);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// Built from the values just written rather than read back: this handler
|
|
180
|
+
// runs inside `mutateGraph`'s write transaction, so the enqueued row is not
|
|
181
|
+
// yet visible on any other connection.
|
|
182
|
+
return {
|
|
183
|
+
id: requestID,
|
|
184
|
+
groupID,
|
|
185
|
+
kind: 'ledger',
|
|
186
|
+
status: 'pending',
|
|
187
|
+
error: null,
|
|
188
|
+
enqueuedAt,
|
|
189
|
+
settledAt: null
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
create: async (input)=>{
|
|
194
|
+
const result = await deps.groupManager.createGroup({
|
|
195
|
+
stores: deps.stores,
|
|
196
|
+
identity: deps.identity,
|
|
197
|
+
name: input.name,
|
|
198
|
+
description: input.description,
|
|
199
|
+
createdBy: ctx.viewerDID,
|
|
200
|
+
hubs: input.hubs?.map((h)=>({
|
|
201
|
+
url: h.url,
|
|
202
|
+
serverDID: h.serverDID ?? undefined
|
|
203
|
+
}))
|
|
204
|
+
});
|
|
205
|
+
const store = await getP2PStore();
|
|
206
|
+
const row = await store.getGroup(result.groupID);
|
|
207
|
+
if (row == null) throw new Error('Failed to create group');
|
|
208
|
+
const groupData = {
|
|
209
|
+
id: row.id,
|
|
210
|
+
name: row.name,
|
|
211
|
+
description: row.description,
|
|
212
|
+
createdBy: row.created_by,
|
|
213
|
+
createdAt: toISO(row.created_at)
|
|
214
|
+
};
|
|
215
|
+
// Defer past the outermost commit: the `groupJoined` listener reads the P2P
|
|
216
|
+
// store on the main connection — inline deadlocks single-conn SQLite / reads
|
|
217
|
+
// uncommitted on Postgres, and a rollback then emits nothing.
|
|
218
|
+
deps.stores.onCommit(()=>{
|
|
219
|
+
deps.emitter.emit('groupJoined', groupData).catch((error)=>{
|
|
220
|
+
deps.logger.error('groupJoined emit failed', {
|
|
221
|
+
groupID: groupData.id,
|
|
222
|
+
error
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
return groupData;
|
|
227
|
+
},
|
|
228
|
+
list: async ()=>{
|
|
229
|
+
const store = await getP2PStore();
|
|
230
|
+
const rows = await store.listGroups();
|
|
231
|
+
return rows.map((row)=>({
|
|
232
|
+
id: row.id,
|
|
233
|
+
name: row.name,
|
|
234
|
+
description: row.description,
|
|
235
|
+
createdBy: row.created_by,
|
|
236
|
+
createdAt: toISO(row.created_at)
|
|
237
|
+
}));
|
|
238
|
+
},
|
|
239
|
+
get: async (id)=>{
|
|
240
|
+
const store = await getP2PStore();
|
|
241
|
+
const row = await store.getGroup(id);
|
|
242
|
+
if (row == null) return null;
|
|
243
|
+
return {
|
|
244
|
+
id: row.id,
|
|
245
|
+
name: row.name,
|
|
246
|
+
description: row.description,
|
|
247
|
+
createdBy: row.created_by,
|
|
248
|
+
createdAt: toISO(row.created_at)
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
requireAdmin: async (groupID, action)=>{
|
|
252
|
+
const store = await getP2PStore();
|
|
253
|
+
await requireGroupAdmin({
|
|
254
|
+
store,
|
|
255
|
+
groupID,
|
|
256
|
+
did: deps.identity.id,
|
|
257
|
+
action
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
requireCircleAdmin: async (circleID, action)=>{
|
|
261
|
+
const store = await getP2PStore();
|
|
262
|
+
// Tombstoned circles count: deleting an already-deleted circle is a no-op
|
|
263
|
+
// the gate still has to run ahead of, and skipping them here would send
|
|
264
|
+
// that path back through a context-method gate whose extensions the
|
|
265
|
+
// rollback wrapper strips.
|
|
266
|
+
const circle = await store.getCircle(circleID, {
|
|
267
|
+
includeRemoved: true
|
|
268
|
+
});
|
|
269
|
+
if (circle == null) {
|
|
270
|
+
// A gate that returns for an unknown id passes every caller it cannot
|
|
271
|
+
// place in a group. Safe today only because each op re-reads the circle
|
|
272
|
+
// and refuses, so the gate would be the only thing between a stranger and
|
|
273
|
+
// an operation if one ever stopped.
|
|
274
|
+
throw new Error(`Failed to ${action}: circle not found`);
|
|
275
|
+
}
|
|
276
|
+
await requireGroupAdmin({
|
|
277
|
+
store,
|
|
278
|
+
groupID: circle.group_id,
|
|
279
|
+
did: deps.identity.id,
|
|
280
|
+
action
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
isAdmin: async (groupID, did)=>{
|
|
284
|
+
const store = await getP2PStore();
|
|
285
|
+
// The same call `requireGroupAdmin` makes, deliberately: one membership
|
|
286
|
+
// row is the single answer to "may this DID act as an admin", so what a
|
|
287
|
+
// read surface reports and what the gate enforces cannot drift apart.
|
|
288
|
+
return await store.isGroupAdmin(groupID, did);
|
|
289
|
+
},
|
|
290
|
+
requestSetMemberRole: async ({ groupID, memberDID, role })=>{
|
|
291
|
+
const store = await getP2PStore();
|
|
292
|
+
// Read the anchor through the engine transaction's stores: under
|
|
293
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
294
|
+
// registry's restore/persist must share its connection — a separate
|
|
295
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
296
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
297
|
+
stores: deps.stores
|
|
298
|
+
});
|
|
299
|
+
if (anchor == null) {
|
|
300
|
+
throw new Error('cannot set member role: group has no genesis anchor');
|
|
301
|
+
}
|
|
302
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
303
|
+
const selfDID = deps.identity.id;
|
|
304
|
+
// Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
|
|
305
|
+
// authority — a non-admin's entry is dropped at fold time — but checking
|
|
306
|
+
// here lets the caller fail loudly instead of silently emitting a no-op.
|
|
307
|
+
// The GraphQL resolver runs this same gate before its rollback wrapper so
|
|
308
|
+
// the refusal keeps its code; repeating it here covers direct callers.
|
|
309
|
+
await requireGroupAdmin({
|
|
310
|
+
store,
|
|
311
|
+
groupID,
|
|
312
|
+
did: selfDID,
|
|
313
|
+
action: 'set member roles'
|
|
314
|
+
});
|
|
315
|
+
if (role === 'admin') {
|
|
316
|
+
// Admin implies membership: a grant must target an existing member.
|
|
317
|
+
if (!await store.isGroupMemberAtHLC(groupID, memberDID, hlc)) {
|
|
318
|
+
throw new Error('cannot grant admin to a non-member');
|
|
319
|
+
}
|
|
320
|
+
} else {
|
|
321
|
+
if (!await store.isGroupAdmin(groupID, memberDID)) {
|
|
322
|
+
// Demoting a DID that is not currently an admin is a no-op; don't mint
|
|
323
|
+
// a dead ledger entry + broadcast for it. The caller still gets a real,
|
|
324
|
+
// resolvable request — one that is already settled, because the state
|
|
325
|
+
// it asked for is the state that already holds. No lane runs, so
|
|
326
|
+
// nothing here may touch the commit path.
|
|
327
|
+
const existing = await store.getGroupMember(groupID, memberDID);
|
|
328
|
+
if (existing == null) {
|
|
329
|
+
throw new Error('cannot change role: not a group member');
|
|
330
|
+
}
|
|
331
|
+
return await settleNoOpRequest(groupID);
|
|
332
|
+
}
|
|
333
|
+
// Refuse the last admin's demotion where the caller can see it, the same
|
|
334
|
+
// guard `leave` and `requestRemoveMember` apply to their own paths. The
|
|
335
|
+
// group would refuse it anyway — the fold drops an entry that empties the
|
|
336
|
+
// admin set — but that refusal arrives asynchronously, on the lane, as a
|
|
337
|
+
// `rejected` settle the caller has to wait for, and a group with no admin
|
|
338
|
+
// can never grant, revoke or remove again, so this is fail-closed.
|
|
339
|
+
//
|
|
340
|
+
// The survivor set comes from the same projected rows the authority check
|
|
341
|
+
// above reads, exactly as `requestRemoveMember` counts its own: a grant
|
|
342
|
+
// reaches the ledger when it is signed but the projection only when its
|
|
343
|
+
// commit is enacted, so counting ledger admins would let the only admin
|
|
344
|
+
// who can still act be demoted in favour of one who cannot yet.
|
|
345
|
+
const members = await store.listGroupMembers(groupID);
|
|
346
|
+
const survivors = members.filter((member)=>member.role === 'admin' && normalizeDID(member.member_did) !== normalizeDID(memberDID));
|
|
347
|
+
if (survivors.length === 0) {
|
|
348
|
+
throw new GraphQLError('cannot demote: this is the only admin — promote another member first', {
|
|
349
|
+
extensions: {
|
|
350
|
+
code: GROUP_CONTROL_DENIED,
|
|
351
|
+
reason: LAST_GROUP_ADMIN,
|
|
352
|
+
groupID
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const value = role === 'admin' ? 'admin' : 'member';
|
|
358
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
359
|
+
type: ADMIN_ROLE_ENTRY_TYPE,
|
|
360
|
+
subject: memberDID,
|
|
361
|
+
value,
|
|
362
|
+
groupID,
|
|
363
|
+
ord: hlc
|
|
364
|
+
});
|
|
365
|
+
// The `role` column and `groupMemberRoleChanged` are both written by the
|
|
366
|
+
// roster mirror when the commit lands, so what comes back is the PENDING
|
|
367
|
+
// request, not a member row.
|
|
368
|
+
const requestID = deps.runtime.getRandomID();
|
|
369
|
+
const enqueuedAt = new Date().toISOString();
|
|
370
|
+
await deps.stores.withTransaction(async (tx)=>{
|
|
371
|
+
const txStore = await resolveP2PStore(tx);
|
|
372
|
+
await txStore.enqueueControlRequest({
|
|
373
|
+
id: requestID,
|
|
374
|
+
groupID,
|
|
375
|
+
kind: 'ledger',
|
|
376
|
+
enqueuedAt
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
// The commit is the ONLY delivery of the role change: without it a newly
|
|
380
|
+
// promoted admin is an admin nowhere and every commit they author is refused.
|
|
381
|
+
// Driven AFTER the request row is durable and deferred to the outermost
|
|
382
|
+
// commit, because the local MLS handle advances only when the hub accepts. A
|
|
383
|
+
// missing commit hub is logged on the lane's promise, not fatal.
|
|
384
|
+
const build = deps.groupManager.buildEnactLedgerCommit({
|
|
385
|
+
groupID,
|
|
386
|
+
tokens: [
|
|
387
|
+
token
|
|
388
|
+
],
|
|
389
|
+
requestID
|
|
390
|
+
});
|
|
391
|
+
deps.stores.onCommit(()=>{
|
|
392
|
+
void deps.commitToGroup(groupID, build).catch((error)=>{
|
|
393
|
+
deps.logger.error('enact-ledger commit lane failed', {
|
|
394
|
+
groupID,
|
|
395
|
+
error
|
|
396
|
+
});
|
|
397
|
+
// A lane throw is the group refusing the entry or the deadline
|
|
398
|
+
// expiring — the request is over and the app must be told. The local
|
|
399
|
+
// admin gate is NOT this: it throws above, before anything is
|
|
400
|
+
// enqueued, so a refused caller has no row to settle.
|
|
401
|
+
settleRejectedFromLane(requestID, error);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
// Built from the values just written rather than read back: this handler
|
|
405
|
+
// runs inside `mutateGraph`'s write transaction, so the enqueued row is
|
|
406
|
+
// not yet visible on any other connection.
|
|
407
|
+
return {
|
|
408
|
+
id: requestID,
|
|
409
|
+
groupID,
|
|
410
|
+
kind: 'ledger',
|
|
411
|
+
status: 'pending',
|
|
412
|
+
error: null,
|
|
413
|
+
enqueuedAt,
|
|
414
|
+
settledAt: null
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
listMembers: async (groupID)=>{
|
|
418
|
+
const store = await getP2PStore();
|
|
419
|
+
const rows = await store.listGroupMembers(groupID);
|
|
420
|
+
return rows.map((row)=>({
|
|
421
|
+
groupID: row.group_id,
|
|
422
|
+
memberDID: row.member_did,
|
|
423
|
+
role: row.role,
|
|
424
|
+
createdAt: toISO(row.created_at)
|
|
425
|
+
}));
|
|
426
|
+
},
|
|
427
|
+
requestCreateCircle: async (input)=>{
|
|
428
|
+
const groupID = input.groupID;
|
|
429
|
+
const store = await getP2PStore();
|
|
430
|
+
// Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
|
|
431
|
+
// authority — a non-admin's entry is dropped at fold time — but checking
|
|
432
|
+
// here lets the caller fail loudly instead of receiving a request that
|
|
433
|
+
// settles `committed` for a circle no peer ever projects.
|
|
434
|
+
await requireGroupAdmin({
|
|
435
|
+
store,
|
|
436
|
+
groupID,
|
|
437
|
+
did: deps.identity.id,
|
|
438
|
+
action: 'create a circle'
|
|
439
|
+
});
|
|
440
|
+
// Read the anchor through the engine transaction's stores: under
|
|
441
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
442
|
+
// registry's restore/persist must share its connection — a separate
|
|
443
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
444
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
445
|
+
stores: deps.stores
|
|
446
|
+
});
|
|
447
|
+
if (anchor == null) {
|
|
448
|
+
throw new Error('cannot create circle: group has no genesis anchor');
|
|
449
|
+
}
|
|
450
|
+
// The caller is handed the id back before the commit lands, because every
|
|
451
|
+
// step that follows a create — membership, catalogs, the invite seeds —
|
|
452
|
+
// names the circle by id. An internal caller may supply a DERIVED id so
|
|
453
|
+
// its own retry converges on this circle; see `CreateCircleInput`.
|
|
454
|
+
const circleID = input.circleID ?? mintCircleID();
|
|
455
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
456
|
+
const value = {
|
|
457
|
+
name: input.name,
|
|
458
|
+
description: input.description ?? '',
|
|
459
|
+
catalogIDs: []
|
|
460
|
+
};
|
|
461
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
462
|
+
type: CIRCLE_DEF_ENTRY_TYPE,
|
|
463
|
+
subject: circleID,
|
|
464
|
+
value,
|
|
465
|
+
groupID,
|
|
466
|
+
ord: hlc
|
|
467
|
+
});
|
|
468
|
+
// No no-op branch: the subject is freshly minted, so there is no prior
|
|
469
|
+
// state for the requested one to already equal.
|
|
470
|
+
const request = await enqueueLedgerEntryRequest({
|
|
471
|
+
groupID,
|
|
472
|
+
token
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
request,
|
|
476
|
+
circleID
|
|
477
|
+
};
|
|
478
|
+
},
|
|
479
|
+
listCircles: async (groupID)=>{
|
|
480
|
+
const store = await getP2PStore();
|
|
481
|
+
const rows = await store.listCirclesByGroup(groupID);
|
|
482
|
+
return rows.map(toCircleData);
|
|
483
|
+
},
|
|
484
|
+
requestAddCircleMember: async ({ circleID, memberDID, role })=>{
|
|
485
|
+
const store = await getP2PStore();
|
|
486
|
+
const circleRow = await store.getCircle(circleID);
|
|
487
|
+
if (circleRow == null) throw new Error('Failed to add circle member: circle not found');
|
|
488
|
+
const groupID = circleRow.group_id;
|
|
489
|
+
// Defense-in-depth + fail-loud UX, as in the other circle producers: an
|
|
490
|
+
// ungated non-admin's entry would ride a commit the group accepts, so the
|
|
491
|
+
// request would settle `committed` while every peer's fold drops it.
|
|
492
|
+
//
|
|
493
|
+
// It runs BEFORE the no-op check: answering a non-admin `no_op` would both
|
|
494
|
+
// grant them an outcome they have no authority to ask for and leak whether
|
|
495
|
+
// the membership they named already held.
|
|
496
|
+
await requireGroupAdmin({
|
|
497
|
+
store,
|
|
498
|
+
groupID,
|
|
499
|
+
did: deps.identity.id,
|
|
500
|
+
action: 'add a circle member'
|
|
501
|
+
});
|
|
502
|
+
// No-op when the DID is already an active member at exactly this role: the
|
|
503
|
+
// entry would fold to the state that already holds. A tombstoned row is NOT
|
|
504
|
+
// a no-op — re-adding it is a revival, and only a fresh entry performs one.
|
|
505
|
+
const existingMember = await store.getCircleMember(circleID, memberDID);
|
|
506
|
+
if (existingMember != null && existingMember.removed_at_hlc == null && existingMember.role === role) {
|
|
507
|
+
return await settleNoOpRequest(groupID);
|
|
508
|
+
}
|
|
509
|
+
// Read the anchor through the engine transaction's stores: under
|
|
510
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
511
|
+
// registry's restore/persist must share its connection — a separate
|
|
512
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
513
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
514
|
+
stores: deps.stores
|
|
515
|
+
});
|
|
516
|
+
if (anchor == null) {
|
|
517
|
+
throw new Error('cannot add circle member: group has no genesis anchor');
|
|
518
|
+
}
|
|
519
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
520
|
+
const subject = encodeCircleMemberSubject(circleID, memberDID);
|
|
521
|
+
const value = {
|
|
522
|
+
role
|
|
523
|
+
};
|
|
524
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
525
|
+
type: CIRCLE_MEMBER_ENTRY_TYPE,
|
|
526
|
+
subject,
|
|
527
|
+
value,
|
|
528
|
+
groupID,
|
|
529
|
+
ord: hlc
|
|
530
|
+
});
|
|
531
|
+
return await enqueueLedgerEntryRequest({
|
|
532
|
+
groupID,
|
|
533
|
+
token
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
createCatalog: async (p)=>{
|
|
537
|
+
const graphStore = await getGraphStore();
|
|
538
|
+
const id = p.catalogID ?? deps.runtime.getRandomID();
|
|
539
|
+
const hlcStr = HLC.serialize(deps.hlc.now());
|
|
540
|
+
const filterCriteria = JSON.parse(p.filterCriteria);
|
|
541
|
+
// Sign a self-contained `catalog:set` token at creation so the catalog can
|
|
542
|
+
// be forwarded (invite seed / broadcast) with creator-bound provenance a
|
|
543
|
+
// receiver re-verifies. The signed record carries every authoritative field.
|
|
544
|
+
const signedToken = await signCatalogSet(deps.identity, {
|
|
545
|
+
id,
|
|
546
|
+
ownerDID: ctx.viewerDID,
|
|
547
|
+
name: p.name,
|
|
548
|
+
description: p.description ?? '',
|
|
549
|
+
filterCriteria,
|
|
550
|
+
hlc: hlcStr
|
|
551
|
+
});
|
|
552
|
+
await graphStore.createCatalog({
|
|
553
|
+
id,
|
|
554
|
+
owner_did: ctx.viewerDID,
|
|
555
|
+
name: p.name,
|
|
556
|
+
description: p.description ?? '',
|
|
557
|
+
filter_criteria: filterCriteria,
|
|
558
|
+
hlc: hlcStr,
|
|
559
|
+
signed_token: signedToken
|
|
560
|
+
});
|
|
561
|
+
const row = await graphStore.getCatalog(id);
|
|
562
|
+
if (row == null) throw new Error('Failed to create catalog');
|
|
563
|
+
return toCatalogData(row);
|
|
564
|
+
},
|
|
565
|
+
listStoredCatalogs: async (filter)=>{
|
|
566
|
+
const graphStore = await getGraphStore();
|
|
567
|
+
const rows = await graphStore.listStoredCatalogs(filter);
|
|
568
|
+
return rows.map(toCatalogData);
|
|
569
|
+
},
|
|
570
|
+
listControlRequests: async (groupID)=>{
|
|
571
|
+
const store = await getP2PStore();
|
|
572
|
+
const rows = await store.getControlRequests(groupID);
|
|
573
|
+
return rows.map(toControlRequestData);
|
|
574
|
+
},
|
|
575
|
+
getControlRequest: async (id)=>{
|
|
576
|
+
const store = await getP2PStore();
|
|
577
|
+
const row = await store.getControlRequest(id);
|
|
578
|
+
return row == null ? null : toControlRequestData(row);
|
|
579
|
+
},
|
|
580
|
+
ackControlRequest: async (id)=>{
|
|
581
|
+
const store = await getP2PStore();
|
|
582
|
+
await store.ackControlRequest(id);
|
|
583
|
+
},
|
|
584
|
+
awaitSettle: async (id, timeoutMs)=>await awaitControlRequestSettled({
|
|
585
|
+
stores: deps.stores,
|
|
586
|
+
emitter: deps.emitter,
|
|
587
|
+
requestID: id,
|
|
588
|
+
timeoutMs
|
|
589
|
+
}),
|
|
590
|
+
activateCatalogSync: async (catalogID)=>{
|
|
591
|
+
const graphStore = await getGraphStore();
|
|
592
|
+
if (await graphStore.getCatalog(catalogID) == null) {
|
|
593
|
+
throw new Error(`Catalog ${catalogID} not found`);
|
|
594
|
+
}
|
|
595
|
+
await graphStore.setCatalogActive(catalogID, true);
|
|
596
|
+
const row = await graphStore.getCatalog(catalogID);
|
|
597
|
+
if (row == null) throw new Error(`Catalog ${catalogID} not found`);
|
|
598
|
+
return toCatalogData(row);
|
|
599
|
+
},
|
|
600
|
+
deactivateCatalogSync: async (catalogID)=>{
|
|
601
|
+
const graphStore = await getGraphStore();
|
|
602
|
+
if (await graphStore.getCatalog(catalogID) == null) {
|
|
603
|
+
throw new Error(`Catalog ${catalogID} not found`);
|
|
604
|
+
}
|
|
605
|
+
await graphStore.setCatalogActive(catalogID, false);
|
|
606
|
+
const row = await graphStore.getCatalog(catalogID);
|
|
607
|
+
if (row == null) throw new Error(`Catalog ${catalogID} not found`);
|
|
608
|
+
return toCatalogData(row);
|
|
609
|
+
},
|
|
610
|
+
requestInvite: async (input)=>{
|
|
611
|
+
const { groupID, joinRequest, grants, revocations } = input;
|
|
612
|
+
checkPeerAccess(ctx.viewerDID, deps.identity.id, deps.autoAcceptPeers);
|
|
613
|
+
const store = await getP2PStore();
|
|
614
|
+
// Inviting adds a member to the MLS group — an admin action. kumiai already
|
|
615
|
+
// gates the committer at BUILD time (`createInvite` throws "the inviter must
|
|
616
|
+
// be an admin in the group roster"), so without this gate a non-admin's
|
|
617
|
+
// invite still fails — but only after enqueuing a request and reaching the
|
|
618
|
+
// lane, which then settles `rejected` on the build throw. This gate makes it
|
|
619
|
+
// fail fast and clean (`NOT_GROUP_ADMIN` before any row is written), and it
|
|
620
|
+
// is the local half of the defense-in-depth the receive side completes: the
|
|
621
|
+
// one path kumiai's build gate cannot catch is an inviter admin at build who
|
|
622
|
+
// is demoted before co-members fold the commit, which the receive policy
|
|
623
|
+
// refuses (`CommitRejectedError`, group-mls.ts).
|
|
624
|
+
await requireGroupAdmin({
|
|
625
|
+
store,
|
|
626
|
+
groupID,
|
|
627
|
+
did: deps.identity.id,
|
|
628
|
+
action: 'invite a member to the group'
|
|
629
|
+
});
|
|
630
|
+
const ownIdentity = deps.identity;
|
|
631
|
+
const joinReq = decodeJoinRequest(joinRequest);
|
|
632
|
+
// The recipient comes from the KeyPackage's own credential, never from the
|
|
633
|
+
// request's `did` field. The two halves go to different subsystems — the
|
|
634
|
+
// KeyPackage becomes an MLS leaf, this DID becomes a membership row and a
|
|
635
|
+
// role entry — so a forged pairing names one identity in the ratchet and
|
|
636
|
+
// another in the projections, and nothing downstream reconciles them: a
|
|
637
|
+
// co-member folds the row from the roster diff while the inviter writes
|
|
638
|
+
// the one it was handed. Resolving here makes every caller safe rather
|
|
639
|
+
// than each having to remember.
|
|
640
|
+
const recipientDID = resolveJoinRequestDID(joinRequest);
|
|
641
|
+
const groupRow = await store.getGroup(groupID);
|
|
642
|
+
if (groupRow == null) throw new Error('Failed to invite member: group not found');
|
|
643
|
+
const groupName = groupRow.name;
|
|
644
|
+
const boundHubs = await store.listHubsByGroupID(groupID);
|
|
645
|
+
const suggestedHubs = boundHubs.map((hub)=>hub.server_did == null ? {
|
|
646
|
+
url: hub.url
|
|
647
|
+
} : {
|
|
648
|
+
url: hub.url,
|
|
649
|
+
serverDID: hub.server_did
|
|
650
|
+
});
|
|
651
|
+
// Seed the joiner with the group's control state so it is current the
|
|
652
|
+
// instant it joins — the ledger (circles/members/admin roster), the
|
|
653
|
+
// inviter's own model access-defaults, and the group's catalogs — without
|
|
654
|
+
// waiting for a broadcast or a catch-up round-trip.
|
|
655
|
+
//
|
|
656
|
+
// Collected ONCE, here, rather than per attempt inside the encoder: they
|
|
657
|
+
// are the caller's snapshot of the group's control state, and nothing in
|
|
658
|
+
// them turns on which attempt the group accepts. Re-collecting per rebase
|
|
659
|
+
// would only make the payload vary between attempts for no gain.
|
|
660
|
+
const seeds = await collectInviteSeeds({
|
|
661
|
+
stores: deps.stores,
|
|
662
|
+
identity: ownIdentity,
|
|
663
|
+
ownerDID: ctx.viewerDID,
|
|
664
|
+
groupID,
|
|
665
|
+
logger: deps.logger
|
|
666
|
+
});
|
|
667
|
+
const requestID = deps.runtime.getRandomID();
|
|
668
|
+
const enqueuedAt = new Date().toISOString();
|
|
669
|
+
await deps.stores.withTransaction(async (tx)=>{
|
|
670
|
+
const txStore = await resolveP2PStore(tx);
|
|
671
|
+
await txStore.enqueueControlRequest({
|
|
672
|
+
id: requestID,
|
|
673
|
+
groupID,
|
|
674
|
+
kind: 'invite',
|
|
675
|
+
enqueuedAt
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
const build = deps.groupManager.buildInviteCommit({
|
|
679
|
+
groupID,
|
|
680
|
+
identity: ownIdentity,
|
|
681
|
+
recipientDID,
|
|
682
|
+
recipientKeyPackage: joinReq.publicPackage,
|
|
683
|
+
permission: 'member',
|
|
684
|
+
requestID,
|
|
685
|
+
encodeInvitePayload: async (material)=>encodeInvitePayload({
|
|
686
|
+
groupID,
|
|
687
|
+
groupName,
|
|
688
|
+
suggestedHubs,
|
|
689
|
+
invite: material.invite,
|
|
690
|
+
welcomeMessage: material.welcomeMessage,
|
|
691
|
+
ratchetTree: material.ratchetTree,
|
|
692
|
+
...grants != null && grants.length > 0 ? {
|
|
693
|
+
grants
|
|
694
|
+
} : {},
|
|
695
|
+
...revocations != null && revocations.length > 0 ? {
|
|
696
|
+
revocations
|
|
697
|
+
} : {},
|
|
698
|
+
...seeds.accessDefaults.length > 0 ? {
|
|
699
|
+
accessDefaults: seeds.accessDefaults
|
|
700
|
+
} : {},
|
|
701
|
+
...seeds.catalogs.length > 0 ? {
|
|
702
|
+
catalogs: seeds.catalogs
|
|
703
|
+
} : {}
|
|
704
|
+
})
|
|
705
|
+
});
|
|
706
|
+
deps.stores.onCommit(()=>{
|
|
707
|
+
void deps.commitToGroup(groupID, build).catch((error)=>{
|
|
708
|
+
deps.logger.error('invite commit lane failed', {
|
|
709
|
+
groupID,
|
|
710
|
+
error
|
|
711
|
+
});
|
|
712
|
+
settleRejectedFromLane(requestID, error);
|
|
713
|
+
});
|
|
714
|
+
});
|
|
715
|
+
let settled;
|
|
716
|
+
try {
|
|
717
|
+
settled = await awaitControlRequestSettled({
|
|
718
|
+
stores: deps.stores,
|
|
719
|
+
emitter: deps.emitter,
|
|
720
|
+
requestID,
|
|
721
|
+
timeoutMs: deps.inviteCommitTimeoutMs ?? INVITE_COMMIT_TIMEOUT
|
|
722
|
+
});
|
|
723
|
+
} catch (waitError) {
|
|
724
|
+
// The wait elapsed without a settle. A PARKED Add commit (the inviter's
|
|
725
|
+
// hub is offline) leaves the request `pending` and the commit still in
|
|
726
|
+
// flight — it may yet land and add the member — so this is NOT a failure
|
|
727
|
+
// to report. Read the authoritative row: only a still-`pending` request
|
|
728
|
+
// returns the truthful pending shape below; anything else fell through
|
|
729
|
+
// the wait's own settle/timeout race and is handled by the status branch.
|
|
730
|
+
const parkedRow = await store.getControlRequest(requestID);
|
|
731
|
+
if (parkedRow == null) {
|
|
732
|
+
throw waitError;
|
|
733
|
+
}
|
|
734
|
+
settled = toControlRequestData(parkedRow);
|
|
735
|
+
}
|
|
736
|
+
if (settled.status === 'pending') {
|
|
737
|
+
// Truthful pending result — NOT a throw: the caller learns the invite has
|
|
738
|
+
// not landed yet rather than being told it failed. `request` carries that
|
|
739
|
+
// status back. No payload rides a pending result: the commit that would
|
|
740
|
+
// produce it has not been accepted, so there is nothing to hand over yet.
|
|
741
|
+
return {
|
|
742
|
+
request: settled,
|
|
743
|
+
invitePayload: null,
|
|
744
|
+
groupID,
|
|
745
|
+
groupName
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (settled.status !== 'committed') {
|
|
749
|
+
// A definitively-failed invite (`rejected`/`lost`/`no_op`) SHOULD report
|
|
750
|
+
// failure — a settled-negative is a real error, not a pending outcome.
|
|
751
|
+
throw new Error(`group/invite: the add commit did not land (${settled.status}${settled.error == null ? '' : ` — ${settled.error}`})`);
|
|
752
|
+
}
|
|
753
|
+
// Read the payload from the settled row rather than from the callback that
|
|
754
|
+
// fired alongside it: this is the one route both the live path and a
|
|
755
|
+
// replayed adopt write to, so a caller answered after a restart is
|
|
756
|
+
// answered identically.
|
|
757
|
+
const invitePayload = await store.getControlRequestResult(requestID);
|
|
758
|
+
if (invitePayload == null) {
|
|
759
|
+
throw new Error('group/invite: the add commit landed without an invite payload');
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
request: settled,
|
|
763
|
+
invitePayload,
|
|
764
|
+
groupID,
|
|
765
|
+
groupName
|
|
766
|
+
};
|
|
767
|
+
},
|
|
768
|
+
join: async (invitePayloadStr, joinRequestPayloadStr)=>{
|
|
769
|
+
const ownIdentity = deps.identity;
|
|
770
|
+
const invite = decodeInvitePayload(invitePayloadStr);
|
|
771
|
+
// Decode the full join request to get the key package bundle (including private keys)
|
|
772
|
+
const fullJoinReq = decodeFullJoinRequest(joinRequestPayloadStr);
|
|
773
|
+
const keyPackageBundle = {
|
|
774
|
+
publicPackage: fullJoinReq.publicPackage,
|
|
775
|
+
privatePackage: fullJoinReq.privatePackage,
|
|
776
|
+
ownerDID: fullJoinReq.did
|
|
777
|
+
};
|
|
778
|
+
await deps.groupManager.joinGroup({
|
|
779
|
+
stores: deps.stores,
|
|
780
|
+
identity: ownIdentity,
|
|
781
|
+
groupID: invite.groupID,
|
|
782
|
+
groupName: invite.groupName,
|
|
783
|
+
hubs: invite.suggestedHubs,
|
|
784
|
+
invite: invite.invite,
|
|
785
|
+
keyPackageBundle,
|
|
786
|
+
welcomeMessage: invite.welcomeMessage,
|
|
787
|
+
ratchetTree: invite.ratchetTree
|
|
788
|
+
});
|
|
789
|
+
await applyInviteGrants({
|
|
790
|
+
stores: deps.stores,
|
|
791
|
+
selfDID: ownIdentity.id,
|
|
792
|
+
groupID: invite.groupID,
|
|
793
|
+
grants: invite.grants,
|
|
794
|
+
hlc: deps.hlc,
|
|
795
|
+
emitter: deps.emitter,
|
|
796
|
+
logger: deps.logger
|
|
797
|
+
});
|
|
798
|
+
await applyInviteRevocations({
|
|
799
|
+
stores: deps.stores,
|
|
800
|
+
selfDID: ownIdentity.id,
|
|
801
|
+
groupID: invite.groupID,
|
|
802
|
+
revocations: invite.revocations,
|
|
803
|
+
hlc: deps.hlc,
|
|
804
|
+
emitter: deps.emitter,
|
|
805
|
+
logger: deps.logger
|
|
806
|
+
});
|
|
807
|
+
// Fold the invite-carried control state (ledger, access-defaults,
|
|
808
|
+
// catalogs) now the MLS handle exists, so the genesis anchor is readable
|
|
809
|
+
// and the joiner projects circles/members at join. Reads the anchor
|
|
810
|
+
// through the engine transaction's stores so the registry shares its
|
|
811
|
+
// connection (a separate connection deadlocks single-connection SQLite).
|
|
812
|
+
await applyInviteControlState({
|
|
813
|
+
stores: deps.stores,
|
|
814
|
+
groupID: invite.groupID,
|
|
815
|
+
accessDefaults: invite.accessDefaults,
|
|
816
|
+
catalogs: invite.catalogs,
|
|
817
|
+
hlc: deps.hlc,
|
|
818
|
+
getGroupAnchor: (gid)=>deps.registry.readHandle(gid, (handle)=>readGroupAnchor(handle), {
|
|
819
|
+
stores: deps.stores
|
|
820
|
+
}),
|
|
821
|
+
getGroupLedger: (gid)=>deps.registry.readHandle(gid, (handle)=>handle.ledger, {
|
|
822
|
+
stores: deps.stores
|
|
823
|
+
}),
|
|
824
|
+
logger: deps.logger
|
|
825
|
+
});
|
|
826
|
+
return await finalizeJoinedGroup({
|
|
827
|
+
stores: deps.stores,
|
|
828
|
+
emitter: deps.emitter,
|
|
829
|
+
invite,
|
|
830
|
+
logger: deps.logger
|
|
831
|
+
});
|
|
832
|
+
},
|
|
833
|
+
leave: async (groupID)=>{
|
|
834
|
+
// One HLC orders BOTH the MLS self-removal tombstone and the admin-role
|
|
835
|
+
// self-revocation enacted below. Sharing a single stamp is what lets a
|
|
836
|
+
// receiver's deterministic fold keep this leaver's control entries signed
|
|
837
|
+
// at or before departure and drop everything they sign strictly later.
|
|
838
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
839
|
+
const store = await getP2PStore();
|
|
840
|
+
const groupRow = await store.getGroup(groupID);
|
|
841
|
+
const ownIdentity = deps.identity;
|
|
842
|
+
// Anchor and roster in one lock: both come from the live handle, which
|
|
843
|
+
// `leaveGroup` tears down. Under `mutateGraph` this handler already runs
|
|
844
|
+
// inside the engine transaction, so the registry's restore/persist must
|
|
845
|
+
// share its connection (a separate connection blocks on the outer write
|
|
846
|
+
// lock under SQLite).
|
|
847
|
+
const { anchor, roster } = await deps.registry.readHandle(groupID, async (handle)=>({
|
|
848
|
+
anchor: readGroupAnchor(handle),
|
|
849
|
+
roster: handle.roster
|
|
850
|
+
}), {
|
|
851
|
+
stores: deps.stores
|
|
852
|
+
});
|
|
853
|
+
// Authority is read from the HANDLE's roster, not the membership row's
|
|
854
|
+
// `role`, because the demotion below has to be one the group will enact:
|
|
855
|
+
// `commitLedgerEntries` judges the roster its own ledger folds to and
|
|
856
|
+
// refuses an entry that would empty the admin set. Reading the mirrored
|
|
857
|
+
// column instead lets this producer mint an entry the commit then refuses,
|
|
858
|
+
// and a lane failure is logged rather than returned — so the caller would
|
|
859
|
+
// watch a leave succeed and nothing happen.
|
|
860
|
+
const selfIsAdmin = anchor != null && roster.roles.get(normalizeDID(ownIdentity.id)) === 'admin';
|
|
861
|
+
// Revoke the leaver's own control authority as it goes. Only admins fold
|
|
862
|
+
// authoritatively, so only an admin needs revoking — a non-admin leave
|
|
863
|
+
// enacts no ledger entry and drives no commit.
|
|
864
|
+
let revokeToken = null;
|
|
865
|
+
// A group the leaver is alone in has nobody to revoke to and no state to
|
|
866
|
+
// converge: leaving it is deleting it. Nothing is enacted, so a solo owner
|
|
867
|
+
// is never trapped in their own group by the guard below.
|
|
868
|
+
// From the ROSTER, like the authority above it and for the same reason: a
|
|
869
|
+
// projection that lags reports a group this device is alone in, which skips
|
|
870
|
+
// the last-admin guard entirely and lets the only admin walk out of a group
|
|
871
|
+
// that still has members.
|
|
872
|
+
const hasCoMembers = [
|
|
873
|
+
...roster.roles.keys()
|
|
874
|
+
].some((did)=>normalizeDID(did) !== normalizeDID(ownIdentity.id));
|
|
875
|
+
if (selfIsAdmin && anchor != null && hasCoMembers) {
|
|
876
|
+
// Refuse the last admin's departure where the caller can see it. The
|
|
877
|
+
// group would refuse it anyway — one admin means this demotion empties
|
|
878
|
+
// the admin set — but that refusal arrives on the lane, asynchronously,
|
|
879
|
+
// as a log line. The app's move is to promote a successor first.
|
|
880
|
+
if (adminCount(roster) <= 1) {
|
|
881
|
+
throw new GraphQLError('cannot leave: you are the only admin — promote another member first', {
|
|
882
|
+
extensions: {
|
|
883
|
+
code: GROUP_CONTROL_DENIED,
|
|
884
|
+
reason: LAST_GROUP_ADMIN,
|
|
885
|
+
groupID
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
revokeToken = await signLedgerEntry(deps.identity, {
|
|
890
|
+
type: ADMIN_ROLE_ENTRY_TYPE,
|
|
891
|
+
subject: ownIdentity.id,
|
|
892
|
+
value: 'member',
|
|
893
|
+
groupID,
|
|
894
|
+
ord: hlc
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const leftData = groupRow != null ? {
|
|
898
|
+
id: groupRow.id,
|
|
899
|
+
name: groupRow.name,
|
|
900
|
+
description: groupRow.description,
|
|
901
|
+
createdBy: groupRow.created_by,
|
|
902
|
+
createdAt: toISO(groupRow.created_at),
|
|
903
|
+
groupID
|
|
904
|
+
} : null;
|
|
905
|
+
// Announce the departure, then tear the MLS state down. The order is
|
|
906
|
+
// load-bearing: the announcement is encrypted to the group, and teardown
|
|
907
|
+
// deletes the key it encrypts under. Advisory and best-effort — a failed
|
|
908
|
+
// notify must not block the leave itself.
|
|
909
|
+
const announceAndTearDown = async ()=>{
|
|
910
|
+
try {
|
|
911
|
+
await deps.broadcastNow(groupID, {
|
|
912
|
+
type: 'group:leaveRequest',
|
|
913
|
+
groupID,
|
|
914
|
+
memberDID: ownIdentity.id,
|
|
915
|
+
hlc
|
|
916
|
+
});
|
|
917
|
+
} catch (error) {
|
|
918
|
+
deps.logger.warn('leave-request broadcast failed', {
|
|
919
|
+
groupID,
|
|
920
|
+
error
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
// `deviceStores`, never `stores`: this runs after the commit lane has
|
|
924
|
+
// resolved, so under `mutateGraph` the request's transaction is long
|
|
925
|
+
// over and a write through it would never land — silently, since
|
|
926
|
+
// nothing throws and the lane's `catch` sees no error. The departed
|
|
927
|
+
// admin would keep its MLS state, and so the group's key.
|
|
928
|
+
await deps.groupManager.leaveGroup({
|
|
929
|
+
stores: deps.deviceStores,
|
|
930
|
+
groupID,
|
|
931
|
+
identity: ownIdentity,
|
|
932
|
+
hlc
|
|
933
|
+
});
|
|
934
|
+
};
|
|
935
|
+
// The same order with the local rows already written: announce first, then
|
|
936
|
+
// drop the key that the announcement was encrypted under.
|
|
937
|
+
const announceThenDropKey = async ()=>{
|
|
938
|
+
try {
|
|
939
|
+
await deps.broadcastNow(groupID, {
|
|
940
|
+
type: 'group:leaveRequest',
|
|
941
|
+
groupID,
|
|
942
|
+
memberDID: ownIdentity.id,
|
|
943
|
+
hlc
|
|
944
|
+
});
|
|
945
|
+
} catch (error) {
|
|
946
|
+
deps.logger.warn('leave-request broadcast failed', {
|
|
947
|
+
groupID,
|
|
948
|
+
error
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
await deps.groupManager.tearDownGroupMLS(deps.deviceStores, groupID);
|
|
952
|
+
};
|
|
953
|
+
const emitGroupLeft = ()=>{
|
|
954
|
+
if (leftData == null) return;
|
|
955
|
+
deps.emitter.emit('groupLeft', leftData).catch((error)=>{
|
|
956
|
+
deps.logger.error('groupLeft emit failed', {
|
|
957
|
+
groupID,
|
|
958
|
+
error
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
};
|
|
962
|
+
if (revokeToken == null) {
|
|
963
|
+
// Nothing to enact, so nothing to confirm — but still nothing that may
|
|
964
|
+
// be AWAITED here. The announcement publishes on the group's lane, which
|
|
965
|
+
// encrypts under the live handle, and this handler runs inside the
|
|
966
|
+
// engine's write transaction: the lane's own reads take a second
|
|
967
|
+
// connection and block on the outer write lock, so awaiting it hangs the
|
|
968
|
+
// mutation for good on a hub-bound group. Deferred to the outermost
|
|
969
|
+
// commit for exactly the reason the self-revoke path below is.
|
|
970
|
+
//
|
|
971
|
+
// The LOCAL record of the departure is written here rather than out
|
|
972
|
+
// there, because it must be true the moment this returns: a screen that
|
|
973
|
+
// navigates away on success would otherwise still be told this device
|
|
974
|
+
// belongs to the group it just left.
|
|
975
|
+
await deps.groupManager.markGroupLeft({
|
|
976
|
+
stores: deps.stores,
|
|
977
|
+
groupID,
|
|
978
|
+
identity: ownIdentity,
|
|
979
|
+
hlc
|
|
980
|
+
});
|
|
981
|
+
deps.stores.onCommit(()=>{
|
|
982
|
+
// A tick past the commit hook, not just past the transaction: KubunDB
|
|
983
|
+
// fires a non-transactional `onCommit` SYNCHRONOUSLY, so a store write
|
|
984
|
+
// made from inside it still lands while the outer write is finalizing
|
|
985
|
+
// and blocks on the connection it is holding.
|
|
986
|
+
setTimeout(()=>{
|
|
987
|
+
void announceThenDropKey().then(emitGroupLeft).catch((error)=>{
|
|
988
|
+
deps.logger.error('leave teardown failed', {
|
|
989
|
+
groupID,
|
|
990
|
+
error
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
}, 0);
|
|
994
|
+
});
|
|
995
|
+
return {
|
|
996
|
+
groupID
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
// The commit is the ONLY delivery of the self-demotion, and the leave waits
|
|
1000
|
+
// on it. A revocation that reached co-members by an ephemeral broadcast
|
|
1001
|
+
// alone was held by whoever happened to be listening and by no one else,
|
|
1002
|
+
// and it never entered the group's authenticated ledger at all — so a peer
|
|
1003
|
+
// that rebuilt from that ledger folded the departed admin back to admin.
|
|
1004
|
+
//
|
|
1005
|
+
// Local state therefore moves only once the group has taken the demotion:
|
|
1006
|
+
// teardown runs on the lane's success, and the entry lands in this device's
|
|
1007
|
+
// own store through the same fold every co-member runs. A lane that never
|
|
1008
|
+
// lands leaves the leaver in the group, still an admin — the state the
|
|
1009
|
+
// group agrees on — rather than a member of nothing who is an admin
|
|
1010
|
+
// everywhere.
|
|
1011
|
+
const requestID = deps.runtime.getRandomID();
|
|
1012
|
+
const enqueuedAt = new Date().toISOString();
|
|
1013
|
+
await deps.stores.withTransaction(async (tx)=>{
|
|
1014
|
+
const txStore = await resolveP2PStore(tx);
|
|
1015
|
+
await txStore.enqueueControlRequest({
|
|
1016
|
+
id: requestID,
|
|
1017
|
+
groupID,
|
|
1018
|
+
kind: 'ledger',
|
|
1019
|
+
enqueuedAt
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
const build = deps.groupManager.buildEnactLedgerCommit({
|
|
1023
|
+
groupID,
|
|
1024
|
+
tokens: [
|
|
1025
|
+
revokeToken
|
|
1026
|
+
],
|
|
1027
|
+
requestID
|
|
1028
|
+
});
|
|
1029
|
+
// Deferred to the outermost commit for the reason every other lane is:
|
|
1030
|
+
// awaiting it inside this write transaction deadlocks the single
|
|
1031
|
+
// connection the lane's own reads need.
|
|
1032
|
+
deps.stores.onCommit(()=>{
|
|
1033
|
+
void deps.commitToGroup(groupID, build).then(async ()=>{
|
|
1034
|
+
await announceAndTearDown();
|
|
1035
|
+
// Already past the outermost commit here, so the emit needs no
|
|
1036
|
+
// further deferral: the listener's reads run on their own connection.
|
|
1037
|
+
emitGroupLeft();
|
|
1038
|
+
}).catch((error)=>{
|
|
1039
|
+
deps.logger.error('leave self-revoke commit lane failed', {
|
|
1040
|
+
groupID,
|
|
1041
|
+
error
|
|
1042
|
+
});
|
|
1043
|
+
settleRejectedFromLane(requestID, error);
|
|
1044
|
+
});
|
|
1045
|
+
});
|
|
1046
|
+
return {
|
|
1047
|
+
groupID
|
|
1048
|
+
};
|
|
1049
|
+
},
|
|
1050
|
+
requestRemoveMember: async (input)=>{
|
|
1051
|
+
const { groupID, memberDID } = input;
|
|
1052
|
+
// One HLC orders BOTH the MLS-roster tombstone the accepted commit writes
|
|
1053
|
+
// and the admin-role revocation signed below to ride it. Sharing a single
|
|
1054
|
+
// stamp is what makes the revocation drop exactly the ex-admin's control
|
|
1055
|
+
// entries signed after the removal: the deterministic fold keeps entries
|
|
1056
|
+
// at or before this HLC and drops everything signed strictly later.
|
|
1057
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1058
|
+
// Read the anchor through the engine transaction's stores: under
|
|
1059
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
1060
|
+
// registry's restore/persist must share its connection — a separate
|
|
1061
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
1062
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
1063
|
+
stores: deps.stores
|
|
1064
|
+
});
|
|
1065
|
+
if (anchor == null) {
|
|
1066
|
+
throw new Error('cannot remove member: group has no genesis anchor');
|
|
1067
|
+
}
|
|
1068
|
+
const store = await getP2PStore();
|
|
1069
|
+
const selfDID = deps.identity.id;
|
|
1070
|
+
// Only a current admin may remove a member. This gate runs before anything
|
|
1071
|
+
// is signed or enqueued, so a non-admin attempt leaves no request behind;
|
|
1072
|
+
// the schema-layer `failTransactionFatal` wrap turns the throw into a
|
|
1073
|
+
// full-transaction rollback and forwards the message to the caller. The
|
|
1074
|
+
// resolver runs the same gate ahead of that wrap so the refusal keeps its
|
|
1075
|
+
// code; repeating it here covers direct callers.
|
|
1076
|
+
await requireGroupAdmin({
|
|
1077
|
+
store,
|
|
1078
|
+
groupID,
|
|
1079
|
+
did: selfDID,
|
|
1080
|
+
action: 'remove a member'
|
|
1081
|
+
});
|
|
1082
|
+
// Resolve whether the removed DID is an admin from the roster as it stands
|
|
1083
|
+
// NOW: this is the only point at which they are still a member, and the
|
|
1084
|
+
// answer decides whether the commit must carry a demotion at all.
|
|
1085
|
+
const removedIsAdmin = await store.isGroupAdmin(groupID, memberDID);
|
|
1086
|
+
// Refuse to remove the last admin: check what survives once the removed DID
|
|
1087
|
+
// is dropped. A group with no admins can never grant, revoke, or remove
|
|
1088
|
+
// again, so this is fail-closed.
|
|
1089
|
+
//
|
|
1090
|
+
// The survivor set is read from the same projected rows the authority check
|
|
1091
|
+
// reads, never from the ledger. A grant reaches the ledger the moment it is
|
|
1092
|
+
// signed but reaches the projection only when its commit is enacted, so the
|
|
1093
|
+
// ledger holds admins who cannot yet act. Counting those as survivors would
|
|
1094
|
+
// permit removing the only admin who can — and if the pending grant's commit
|
|
1095
|
+
// never lands, the group is left with no effective admin at all.
|
|
1096
|
+
if (removedIsAdmin) {
|
|
1097
|
+
const members = await store.listGroupMembers(groupID);
|
|
1098
|
+
const removedDID = normalizeDID(memberDID);
|
|
1099
|
+
const survivors = members.filter((member)=>member.role === 'admin' && normalizeDID(member.member_did) !== removedDID);
|
|
1100
|
+
if (survivors.length === 0) {
|
|
1101
|
+
// The same refusal `leave` and `requestSetMemberRole` raise, carrying
|
|
1102
|
+
// the same pair: one rule, three paths, and an app matching on the
|
|
1103
|
+
// code has to reach it from all three.
|
|
1104
|
+
throw new GraphQLError('cannot remove the last admin — promote another member first', {
|
|
1105
|
+
extensions: {
|
|
1106
|
+
code: GROUP_CONTROL_DENIED,
|
|
1107
|
+
reason: LAST_GROUP_ADMIN,
|
|
1108
|
+
groupID
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Revoke the ex-admin's control authority on the commit that evicts them.
|
|
1114
|
+
// Only admins fold authoritatively, so only an admin needs revoking — a
|
|
1115
|
+
// non-admin removal carries no ledger entry at all.
|
|
1116
|
+
//
|
|
1117
|
+
// Minted BEFORE the removal so it can ride that very commit: a receiver
|
|
1118
|
+
// refuses a Remove whose target is still an admin in the roster the commit's
|
|
1119
|
+
// entries fold to, so an admin can only be evicted by a commit that also
|
|
1120
|
+
// demotes them.
|
|
1121
|
+
//
|
|
1122
|
+
// The token is NOT appended here. On the lane the accepted commit's
|
|
1123
|
+
// `onAccepted` is its writer, and only that path knows the group took this
|
|
1124
|
+
// attempt: a row written here would survive a lane that never lands,
|
|
1125
|
+
// demoting the target in THIS device's admin-roster fold while every
|
|
1126
|
+
// co-member still folds them as an admin.
|
|
1127
|
+
let revokeToken = null;
|
|
1128
|
+
if (removedIsAdmin) {
|
|
1129
|
+
revokeToken = await signLedgerEntry(deps.identity, {
|
|
1130
|
+
type: ADMIN_ROLE_ENTRY_TYPE,
|
|
1131
|
+
subject: memberDID,
|
|
1132
|
+
value: 'member',
|
|
1133
|
+
groupID,
|
|
1134
|
+
ord: hlc
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
// The request is enqueued before the lane is armed so a crash can never
|
|
1138
|
+
// leave a commit driving toward a request that does not exist.
|
|
1139
|
+
const requestID = deps.runtime.getRandomID();
|
|
1140
|
+
const enqueuedAt = new Date().toISOString();
|
|
1141
|
+
await deps.stores.withTransaction(async (tx)=>{
|
|
1142
|
+
const txStore = await resolveP2PStore(tx);
|
|
1143
|
+
await txStore.enqueueControlRequest({
|
|
1144
|
+
id: requestID,
|
|
1145
|
+
groupID,
|
|
1146
|
+
kind: 'remove',
|
|
1147
|
+
enqueuedAt
|
|
1148
|
+
});
|
|
1149
|
+
});
|
|
1150
|
+
// The commit is the ONLY delivery of the demotion: the token rides the
|
|
1151
|
+
// commit's own frame as a sealed body, so every co-member resolves it,
|
|
1152
|
+
// folds it and emits from that frame. A separate `ledger:entry` broadcast
|
|
1153
|
+
// would race the commit and hand co-members a demotion the group has not
|
|
1154
|
+
// yet accepted, so none is sent.
|
|
1155
|
+
//
|
|
1156
|
+
// The tombstone and the announcement move with it: they happen in the
|
|
1157
|
+
// lane's `onAccepted`, so nothing here observes a removal the group never
|
|
1158
|
+
// took. Driving is deferred to the outermost commit — awaiting the lane
|
|
1159
|
+
// from inside this write transaction would deadlock the single connection
|
|
1160
|
+
// the lane's own reads need.
|
|
1161
|
+
const build = deps.groupManager.buildRemoveCommit({
|
|
1162
|
+
groupID,
|
|
1163
|
+
memberDID,
|
|
1164
|
+
hlc,
|
|
1165
|
+
requestID,
|
|
1166
|
+
...revokeToken != null ? {
|
|
1167
|
+
ledgerEntries: [
|
|
1168
|
+
revokeToken
|
|
1169
|
+
]
|
|
1170
|
+
} : {}
|
|
1171
|
+
});
|
|
1172
|
+
deps.stores.onCommit(()=>{
|
|
1173
|
+
void deps.commitToGroup(groupID, build).catch((error)=>{
|
|
1174
|
+
deps.logger.error('remove-member commit lane failed', {
|
|
1175
|
+
groupID,
|
|
1176
|
+
error
|
|
1177
|
+
});
|
|
1178
|
+
// A lane throw is the group refusing the removal or the deadline
|
|
1179
|
+
// expiring — the request is over and the app must be told. The local
|
|
1180
|
+
// admin gate is NOT this: it throws above, before anything is
|
|
1181
|
+
// enqueued, so a refused caller has no row to settle.
|
|
1182
|
+
settleRejectedFromLane(requestID, error);
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
// Built from the values just written rather than read back: this handler
|
|
1186
|
+
// runs inside `mutateGraph`'s write transaction, so the enqueued row is
|
|
1187
|
+
// not yet visible on any other connection.
|
|
1188
|
+
return {
|
|
1189
|
+
id: requestID,
|
|
1190
|
+
groupID,
|
|
1191
|
+
kind: 'remove',
|
|
1192
|
+
status: 'pending',
|
|
1193
|
+
error: null,
|
|
1194
|
+
enqueuedAt,
|
|
1195
|
+
settledAt: null
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
requestUpdate: async (input)=>{
|
|
1199
|
+
const groupID = input.groupID;
|
|
1200
|
+
const store = await getP2PStore();
|
|
1201
|
+
// Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
|
|
1202
|
+
// authority — a non-admin's entry is dropped at fold time — but checking
|
|
1203
|
+
// here lets the caller fail loudly instead of receiving a request that
|
|
1204
|
+
// settles `committed` for a change the fold silently discarded.
|
|
1205
|
+
//
|
|
1206
|
+
// It runs BEFORE the no-op check: answering a non-admin `no_op` would
|
|
1207
|
+
// both grant them an outcome they have no authority to ask for and leak
|
|
1208
|
+
// whether the state they named already held.
|
|
1209
|
+
await requireGroupAdmin({
|
|
1210
|
+
store,
|
|
1211
|
+
groupID,
|
|
1212
|
+
did: deps.identity.id,
|
|
1213
|
+
action: 'update a group'
|
|
1214
|
+
});
|
|
1215
|
+
// `group.settings` folds per field: carry ONLY the fields the caller set.
|
|
1216
|
+
// An explicit empty string (or empty list) clears the field; an omitted
|
|
1217
|
+
// field is left out of the value entirely so the prior value survives.
|
|
1218
|
+
const value = {};
|
|
1219
|
+
if (input.name !== undefined) value.name = input.name;
|
|
1220
|
+
if (input.description !== undefined) value.description = input.description;
|
|
1221
|
+
if (input.openCircleIDs !== undefined) value.openCircleIDs = input.openCircleIDs;
|
|
1222
|
+
// The commit-hub designation carries only its hub identity (URL + optional
|
|
1223
|
+
// server DID), never a local hub-row id: it is the shared identity every
|
|
1224
|
+
// device folds and matches against its own bound hubs. An absent server DID
|
|
1225
|
+
// is left off entirely rather than carried as null.
|
|
1226
|
+
if (input.commitHub !== undefined) {
|
|
1227
|
+
value.commitHub = input.commitHub.serverDID != null ? {
|
|
1228
|
+
url: input.commitHub.url,
|
|
1229
|
+
serverDID: input.commitHub.serverDID
|
|
1230
|
+
} : {
|
|
1231
|
+
url: input.commitHub.url
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
// No-op when the caller set no field: every `UpdateGroupInput` field but
|
|
1235
|
+
// `groupID` is optional, so an input carrying only `groupID` asks for
|
|
1236
|
+
// nothing. Minting an empty-value `group.settings` entry would drive a
|
|
1237
|
+
// commit that folds to the state that already holds.
|
|
1238
|
+
if (Object.keys(value).length === 0) {
|
|
1239
|
+
return await settleNoOpRequest(groupID);
|
|
1240
|
+
}
|
|
1241
|
+
// Read the anchor through the engine transaction's stores: under
|
|
1242
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
1243
|
+
// registry's restore/persist must share its connection — a separate
|
|
1244
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
1245
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
1246
|
+
stores: deps.stores
|
|
1247
|
+
});
|
|
1248
|
+
if (anchor == null) {
|
|
1249
|
+
throw new Error('cannot update group: group has no genesis anchor');
|
|
1250
|
+
}
|
|
1251
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1252
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
1253
|
+
type: GROUP_SETTINGS_ENTRY_TYPE,
|
|
1254
|
+
subject: groupID,
|
|
1255
|
+
value,
|
|
1256
|
+
groupID,
|
|
1257
|
+
ord: hlc
|
|
1258
|
+
});
|
|
1259
|
+
return await enqueueLedgerEntryRequest({
|
|
1260
|
+
groupID,
|
|
1261
|
+
token
|
|
1262
|
+
});
|
|
1263
|
+
},
|
|
1264
|
+
requestUpdateCircle: async (input)=>{
|
|
1265
|
+
const store = await getP2PStore();
|
|
1266
|
+
const existing = await store.getCircle(input.circleID);
|
|
1267
|
+
if (existing == null) throw new Error(`Circle ${input.circleID} not found`);
|
|
1268
|
+
const groupID = existing.group_id;
|
|
1269
|
+
// Defense-in-depth + fail-loud UX, and it runs BEFORE the no-op check so a
|
|
1270
|
+
// caller with no authority is refused rather than told `no_op`, which would
|
|
1271
|
+
// leak whether the definition they named already held.
|
|
1272
|
+
await requireGroupAdmin({
|
|
1273
|
+
store,
|
|
1274
|
+
groupID,
|
|
1275
|
+
did: deps.identity.id,
|
|
1276
|
+
action: 'update a circle'
|
|
1277
|
+
});
|
|
1278
|
+
// No-op when the caller set no field: every `UpdateCircleInput` field but
|
|
1279
|
+
// `circleID` is optional, so an input carrying only `circleID` asks for
|
|
1280
|
+
// nothing, and the merge below would re-mint the definition that already
|
|
1281
|
+
// holds. Naming a field whose value is unchanged is NOT this case — it
|
|
1282
|
+
// still mints an entry, and the fold that lands it decides from the value
|
|
1283
|
+
// diff whether anything moved.
|
|
1284
|
+
if (input.name === undefined && input.description === undefined && input.catalogIDs === undefined) {
|
|
1285
|
+
return await settleNoOpRequest(groupID);
|
|
1286
|
+
}
|
|
1287
|
+
// Read the anchor through the engine transaction's stores: under
|
|
1288
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
1289
|
+
// registry's restore/persist must share its connection — a separate
|
|
1290
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
1291
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
1292
|
+
stores: deps.stores
|
|
1293
|
+
});
|
|
1294
|
+
if (anchor == null) {
|
|
1295
|
+
throw new Error('cannot update circle: group has no genesis anchor');
|
|
1296
|
+
}
|
|
1297
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1298
|
+
// `circle.def` is whole-value LWW: the winning entry replaces the projected
|
|
1299
|
+
// definition outright. Merge the partial patch over the existing definition
|
|
1300
|
+
// so a name-only update preserves the prior description and catalogIDs.
|
|
1301
|
+
const value = {
|
|
1302
|
+
name: input.name ?? existing.name,
|
|
1303
|
+
description: input.description ?? existing.description,
|
|
1304
|
+
catalogIDs: input.catalogIDs ?? existing.catalog_ids
|
|
1305
|
+
};
|
|
1306
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
1307
|
+
type: CIRCLE_DEF_ENTRY_TYPE,
|
|
1308
|
+
subject: input.circleID,
|
|
1309
|
+
value,
|
|
1310
|
+
groupID,
|
|
1311
|
+
ord: hlc
|
|
1312
|
+
});
|
|
1313
|
+
// Which event this becomes is NOT decided here. The fold that lands the
|
|
1314
|
+
// entry compares the projected catalog list before and after and emits
|
|
1315
|
+
// `circleCatalogsChanged` only when it actually moved. That is the only
|
|
1316
|
+
// predicate every peer can evaluate — a remote peer never sees this input —
|
|
1317
|
+
// so naming `catalogIDs` with the list the circle already carries emits
|
|
1318
|
+
// `circleDataChanged`, not `circleCatalogsChanged`.
|
|
1319
|
+
return await enqueueLedgerEntryRequest({
|
|
1320
|
+
groupID,
|
|
1321
|
+
token
|
|
1322
|
+
});
|
|
1323
|
+
},
|
|
1324
|
+
updateCatalog: async (p)=>{
|
|
1325
|
+
const graphStore = await getGraphStore();
|
|
1326
|
+
// A catalog is owned by its creator (its `owner_did`, bound and signed at
|
|
1327
|
+
// creation). A device holds foreign-owned catalogs from invite seeds and
|
|
1328
|
+
// discovery, so key alone is not authority to change one — verify ownership
|
|
1329
|
+
// before touching the row.
|
|
1330
|
+
const owned = await graphStore.getCatalog(p.catalogID);
|
|
1331
|
+
if (owned == null) throw new Error(`Catalog ${p.catalogID} not found`);
|
|
1332
|
+
if (owned.owner_did !== ctx.viewerDID) {
|
|
1333
|
+
throw new Error(`Catalog ${p.catalogID} is not owned by this device`);
|
|
1334
|
+
}
|
|
1335
|
+
const hlcStr = HLC.serialize(deps.hlc.now());
|
|
1336
|
+
const name = p.name ?? owned.name;
|
|
1337
|
+
const filterCriteria = p.filterCriteria != null ? JSON.parse(p.filterCriteria) : owned.filter_criteria;
|
|
1338
|
+
// Re-sign the whole record, exactly as creation does. The stored token is
|
|
1339
|
+
// forwarded VERBATIM in an invite seed and matched field-by-field against
|
|
1340
|
+
// the plaintext row it travels with, so a token left describing the record
|
|
1341
|
+
// before this edit does not merely go stale — it makes the catalog
|
|
1342
|
+
// unshareable, silently, on the receiving side. `hlc` alone moving is
|
|
1343
|
+
// enough to break that match, so every update re-signs, not only one that
|
|
1344
|
+
// changes the criteria.
|
|
1345
|
+
const signedToken = await signCatalogSet(deps.identity, {
|
|
1346
|
+
id: p.catalogID,
|
|
1347
|
+
ownerDID: ctx.viewerDID,
|
|
1348
|
+
name,
|
|
1349
|
+
description: owned.description,
|
|
1350
|
+
filterCriteria,
|
|
1351
|
+
hlc: hlcStr
|
|
1352
|
+
});
|
|
1353
|
+
await graphStore.updateCatalog(p.catalogID, {
|
|
1354
|
+
name,
|
|
1355
|
+
description: owned.description,
|
|
1356
|
+
filter_criteria: filterCriteria,
|
|
1357
|
+
hlc: hlcStr,
|
|
1358
|
+
signed_token: signedToken
|
|
1359
|
+
});
|
|
1360
|
+
const row = await graphStore.getCatalog(p.catalogID);
|
|
1361
|
+
if (row == null) throw new Error(`Catalog ${p.catalogID} not found`);
|
|
1362
|
+
return toCatalogData(row);
|
|
1363
|
+
},
|
|
1364
|
+
deleteCatalog: async (catalogID)=>{
|
|
1365
|
+
const graphStore = await getGraphStore();
|
|
1366
|
+
// Owner-scoped, as `updateCatalog`: a device holding a foreign-owned catalog
|
|
1367
|
+
// is not authorized to delete it — verify ownership before the row goes.
|
|
1368
|
+
const owned = await graphStore.getCatalog(catalogID);
|
|
1369
|
+
if (owned == null) throw new Error(`Catalog ${catalogID} not found`);
|
|
1370
|
+
if (owned.owner_did !== ctx.viewerDID) {
|
|
1371
|
+
throw new Error(`Catalog ${catalogID} is not owned by this device`);
|
|
1372
|
+
}
|
|
1373
|
+
await graphStore.deleteCatalog(catalogID);
|
|
1374
|
+
return {
|
|
1375
|
+
deletedID: catalogID
|
|
1376
|
+
};
|
|
1377
|
+
},
|
|
1378
|
+
requestDeleteCircle: async ({ circleID })=>{
|
|
1379
|
+
const store = await getP2PStore();
|
|
1380
|
+
const existing = await store.getCircle(circleID, {
|
|
1381
|
+
includeRemoved: true
|
|
1382
|
+
});
|
|
1383
|
+
if (existing == null) throw new Error(`Circle ${circleID} not found`);
|
|
1384
|
+
const groupID = existing.group_id;
|
|
1385
|
+
// Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
|
|
1386
|
+
// authority — a non-admin's entry is dropped at fold time — but checking
|
|
1387
|
+
// here lets the caller fail loudly instead of receiving a request that
|
|
1388
|
+
// settles `committed` for a change the fold silently discarded.
|
|
1389
|
+
await requireGroupAdmin({
|
|
1390
|
+
store,
|
|
1391
|
+
groupID,
|
|
1392
|
+
did: deps.identity.id,
|
|
1393
|
+
action: 'delete a circle'
|
|
1394
|
+
});
|
|
1395
|
+
// No-op when the circle is already tombstoned: minting a dead `removed`
|
|
1396
|
+
// entry would only churn the ledger and converge to the same projected
|
|
1397
|
+
// state. The gate runs first, so a non-admin is refused either way.
|
|
1398
|
+
if (existing.removed_at_hlc != null) {
|
|
1399
|
+
return await settleNoOpRequest(groupID);
|
|
1400
|
+
}
|
|
1401
|
+
// Read the anchor through the engine transaction's stores: under
|
|
1402
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
1403
|
+
// registry's restore/persist must share its connection — a separate
|
|
1404
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
1405
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
1406
|
+
stores: deps.stores
|
|
1407
|
+
});
|
|
1408
|
+
if (anchor == null) {
|
|
1409
|
+
throw new Error('cannot delete circle: group has no genesis anchor');
|
|
1410
|
+
}
|
|
1411
|
+
// One HLC stamps the durable tombstone entry, the broadcast token and the
|
|
1412
|
+
// commit body, so the HLC every co-member folds and the one this device
|
|
1413
|
+
// folds back off its own commit are identical.
|
|
1414
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1415
|
+
const value = {
|
|
1416
|
+
removed: true
|
|
1417
|
+
};
|
|
1418
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
1419
|
+
type: CIRCLE_DEF_ENTRY_TYPE,
|
|
1420
|
+
subject: circleID,
|
|
1421
|
+
value,
|
|
1422
|
+
groupID,
|
|
1423
|
+
ord: hlc
|
|
1424
|
+
});
|
|
1425
|
+
return await enqueueLedgerEntryRequest({
|
|
1426
|
+
groupID,
|
|
1427
|
+
token
|
|
1428
|
+
});
|
|
1429
|
+
},
|
|
1430
|
+
requestRemoveCircleMember: async ({ circleID, memberDID })=>{
|
|
1431
|
+
const store = await getP2PStore();
|
|
1432
|
+
const circleRow = await store.getCircle(circleID);
|
|
1433
|
+
if (circleRow == null) throw new Error('Failed to remove circle member: circle not found');
|
|
1434
|
+
const groupID = circleRow.group_id;
|
|
1435
|
+
// Defense-in-depth + fail-loud UX, as in `requestDeleteCircle`: without it
|
|
1436
|
+
// a non-admin's entry rides a commit the hub accepts, so the request would
|
|
1437
|
+
// settle `committed` while every peer's fold drops the entry.
|
|
1438
|
+
await requireGroupAdmin({
|
|
1439
|
+
store,
|
|
1440
|
+
groupID,
|
|
1441
|
+
did: deps.identity.id,
|
|
1442
|
+
action: 'remove a circle member'
|
|
1443
|
+
});
|
|
1444
|
+
const existing = await store.getCircleMember(circleID, memberDID);
|
|
1445
|
+
// No-op when the target is not an active member: a never-added DID
|
|
1446
|
+
// (absent row) or an already-tombstoned one.
|
|
1447
|
+
if (existing == null || existing.removed_at_hlc != null) {
|
|
1448
|
+
return await settleNoOpRequest(groupID);
|
|
1449
|
+
}
|
|
1450
|
+
// Read the anchor through the engine transaction's stores: under
|
|
1451
|
+
// `mutateGraph` this handler already runs inside that transaction, so the
|
|
1452
|
+
// registry's restore/persist must share its connection — a separate
|
|
1453
|
+
// connection blocks forever on the outer write lock (single-connection SQLite).
|
|
1454
|
+
const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
|
|
1455
|
+
stores: deps.stores
|
|
1456
|
+
});
|
|
1457
|
+
if (anchor == null) {
|
|
1458
|
+
throw new Error('cannot remove circle member: group has no genesis anchor');
|
|
1459
|
+
}
|
|
1460
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1461
|
+
const subject = encodeCircleMemberSubject(circleID, memberDID);
|
|
1462
|
+
const value = {
|
|
1463
|
+
removed: true
|
|
1464
|
+
};
|
|
1465
|
+
const token = await signLedgerEntry(deps.identity, {
|
|
1466
|
+
type: CIRCLE_MEMBER_ENTRY_TYPE,
|
|
1467
|
+
subject,
|
|
1468
|
+
value,
|
|
1469
|
+
groupID,
|
|
1470
|
+
ord: hlc
|
|
1471
|
+
});
|
|
1472
|
+
return await enqueueLedgerEntryRequest({
|
|
1473
|
+
groupID,
|
|
1474
|
+
token
|
|
1475
|
+
});
|
|
1476
|
+
},
|
|
1477
|
+
grantWriteCapability: async ({ to, res, groupID, expiresIn })=>{
|
|
1478
|
+
// Refused rather than clamped: silently shortening a capability the caller
|
|
1479
|
+
// asked for produces a grant that stops working on a date it was never
|
|
1480
|
+
// told about.
|
|
1481
|
+
if (expiresIn != null && expiresIn > MAX_CAP_TTL_SECONDS) {
|
|
1482
|
+
throw new GraphQLError(`expiresIn ${expiresIn} exceeds the maximum capability lifetime of ${MAX_CAP_TTL_SECONDS} seconds`, {
|
|
1483
|
+
extensions: {
|
|
1484
|
+
code: 'GRANT_TTL_TOO_LONG'
|
|
1485
|
+
}
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
const [store, delegationStore] = await Promise.all([
|
|
1489
|
+
getP2PStore(),
|
|
1490
|
+
getDelegationStore()
|
|
1491
|
+
]);
|
|
1492
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
1493
|
+
const exp = iat + (expiresIn ?? DEFAULT_GRANT_EXPIRES_IN);
|
|
1494
|
+
const jti = deps.runtime.getRandomID();
|
|
1495
|
+
const cap = await createCapability(deps.identity, {
|
|
1496
|
+
sub: deps.identity.id,
|
|
1497
|
+
aud: to,
|
|
1498
|
+
act: 'document/write',
|
|
1499
|
+
res,
|
|
1500
|
+
iat,
|
|
1501
|
+
exp,
|
|
1502
|
+
jti
|
|
1503
|
+
});
|
|
1504
|
+
const token = stringifyToken(cap);
|
|
1505
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1506
|
+
const changed = await delegationStore.addDelegationToken({
|
|
1507
|
+
jti,
|
|
1508
|
+
grantor: deps.identity.id,
|
|
1509
|
+
audience: to,
|
|
1510
|
+
token,
|
|
1511
|
+
resource: res,
|
|
1512
|
+
act: 'document/write',
|
|
1513
|
+
exp,
|
|
1514
|
+
hlc
|
|
1515
|
+
});
|
|
1516
|
+
// Record the group this grant belongs to on the p2p edge, keyed by `jti`,
|
|
1517
|
+
// so the p2p layer can resolve the cap's group for revoke-broadcast
|
|
1518
|
+
// targeting and listing enrichment.
|
|
1519
|
+
await store.addGroupDelegation({
|
|
1520
|
+
group_id: groupID,
|
|
1521
|
+
jti
|
|
1522
|
+
});
|
|
1523
|
+
// Surface the just-issued held row to local subscribers of
|
|
1524
|
+
// `ownDelegationTokenAdded`. Case B clients (delegate devices belonging
|
|
1525
|
+
// to the same identity as the grantor) listen on this emitter and
|
|
1526
|
+
// receive the event for tokens addressed to them; the grantor's own
|
|
1527
|
+
// subscriptions also see the issued row.
|
|
1528
|
+
//
|
|
1529
|
+
// The freshly-minted token always carries a new random `jti` and a new
|
|
1530
|
+
// hlc, so `changed` is true in practice — the gate matches the
|
|
1531
|
+
// broadcast-path producer for consistency.
|
|
1532
|
+
if (changed) {
|
|
1533
|
+
await deps.emitter.emit('delegationTokenAdded', {
|
|
1534
|
+
jwt: token,
|
|
1535
|
+
jti,
|
|
1536
|
+
grantor: deps.identity.id,
|
|
1537
|
+
audience: to,
|
|
1538
|
+
resource: res,
|
|
1539
|
+
exp
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
// Distribute the grant to group co-members over the same control channel
|
|
1543
|
+
// circle/member ops use. The recipient stores it iff its audience matches,
|
|
1544
|
+
// so its engine can auto-attach the token on later writes. Same hlc as the
|
|
1545
|
+
// grantor-side issued row keeps LWW consistent across devices.
|
|
1546
|
+
deps.stores.onCommit(()=>deps.scheduleBroadcast(groupID, {
|
|
1547
|
+
type: 'delegation:share',
|
|
1548
|
+
token,
|
|
1549
|
+
groupID,
|
|
1550
|
+
hlc
|
|
1551
|
+
}));
|
|
1552
|
+
return token;
|
|
1553
|
+
},
|
|
1554
|
+
revokeCapability: async ({ jti })=>{
|
|
1555
|
+
const [store, delegationStore] = await Promise.all([
|
|
1556
|
+
getP2PStore(),
|
|
1557
|
+
getDelegationStore()
|
|
1558
|
+
]);
|
|
1559
|
+
// Scoped to the viewer: a co-member can mint a capability reusing a `jti`
|
|
1560
|
+
// it saw broadcast, so an unscoped read can hand back someone else's row
|
|
1561
|
+
// and deny the issuer its own revocation.
|
|
1562
|
+
const cap = await delegationStore.getDelegationTokenByGrantorJTI({
|
|
1563
|
+
jti,
|
|
1564
|
+
grantor: ctx.viewerDID
|
|
1565
|
+
});
|
|
1566
|
+
if (cap == null) {
|
|
1567
|
+
// Both branches deny, but callers act on them differently: rows under
|
|
1568
|
+
// this `jti` from other grantors mean the viewer is not the issuer,
|
|
1569
|
+
// while none at all means the capability is unknown here — the
|
|
1570
|
+
// multi-device-grantor case (the issuing device is not the one running
|
|
1571
|
+
// this resolver) is not yet supported.
|
|
1572
|
+
const others = await delegationStore.listDelegationTokensByJTI(jti);
|
|
1573
|
+
if (others.length > 0) {
|
|
1574
|
+
throw new GraphQLError(`Only the capability issuer can revoke jti ${jti}`, {
|
|
1575
|
+
extensions: {
|
|
1576
|
+
code: 'REVOCATION_UNAUTHORIZED'
|
|
1577
|
+
}
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
throw new GraphQLError(`No capability found locally for jti ${jti}`, {
|
|
1581
|
+
extensions: {
|
|
1582
|
+
code: 'REVOCATION_CAP_UNKNOWN'
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
// Resolve the group the cap was granted in from the p2p edge; it targets
|
|
1587
|
+
// the revoke broadcast and enriches the emitted event. A missing edge
|
|
1588
|
+
// means the group is unknown locally — the broadcast then cannot be
|
|
1589
|
+
// scoped, so treat it like the missing-cap case.
|
|
1590
|
+
const groupID = await store.getGroupIDByJTI(jti);
|
|
1591
|
+
if (groupID == null) {
|
|
1592
|
+
throw new GraphQLError(`No group known locally for capability jti ${jti}`, {
|
|
1593
|
+
extensions: {
|
|
1594
|
+
code: 'REVOCATION_CAP_UNKNOWN'
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
// `createRevocationRecord` signs the claims itself, so the record is
|
|
1599
|
+
// already the signed token the store persists and the broadcast carries.
|
|
1600
|
+
const record = await createRevocationRecord(deps.identity, jti);
|
|
1601
|
+
const revokedAt = record.payload.iat;
|
|
1602
|
+
const token = stringifyToken(record);
|
|
1603
|
+
const hlc = HLC.serialize(deps.hlc.now());
|
|
1604
|
+
// Self-issued revocations are verified by construction (the revoker is
|
|
1605
|
+
// the cap's issuer, checked above); seed `verified_at` with the
|
|
1606
|
+
// record's iat so the revocation gate fires immediately without a
|
|
1607
|
+
// separate verification round-trip.
|
|
1608
|
+
const changed = await delegationStore.addRevocation({
|
|
1609
|
+
jti,
|
|
1610
|
+
revoker_did: deps.identity.id,
|
|
1611
|
+
revoked_iat: revokedAt,
|
|
1612
|
+
revocation_token: token,
|
|
1613
|
+
verified_at: revokedAt,
|
|
1614
|
+
cap_exp: cap.exp,
|
|
1615
|
+
hlc
|
|
1616
|
+
});
|
|
1617
|
+
// Surface the verified revocation to local subscribers of
|
|
1618
|
+
// `ownDelegationTokenRevoked`. A self-mint is verified-by-construction
|
|
1619
|
+
// (authority check above), so the emit gate is simply `changed` — the
|
|
1620
|
+
// same idempotence semantic that suppresses duplicate broadcast-path
|
|
1621
|
+
// events on LWW no-ops.
|
|
1622
|
+
if (changed) {
|
|
1623
|
+
await deps.emitter.emit('delegationTokenRevoked', {
|
|
1624
|
+
jti,
|
|
1625
|
+
grantor: cap.grantor,
|
|
1626
|
+
audience: cap.audience,
|
|
1627
|
+
revokerDID: deps.identity.id,
|
|
1628
|
+
revokedAt: revokedAt,
|
|
1629
|
+
verifiedAt: revokedAt,
|
|
1630
|
+
capExp: cap.exp,
|
|
1631
|
+
groupID
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
// Distribute the revocation to group co-members over the same control
|
|
1635
|
+
// channel that carries delegation:share. Same hlc as the local row keeps
|
|
1636
|
+
// LWW consistent across devices.
|
|
1637
|
+
deps.stores.onCommit(()=>deps.scheduleBroadcast(groupID, {
|
|
1638
|
+
type: 'delegation:revoke',
|
|
1639
|
+
token,
|
|
1640
|
+
groupID,
|
|
1641
|
+
hlc
|
|
1642
|
+
}));
|
|
1643
|
+
return changed;
|
|
1644
|
+
}
|
|
1645
|
+
};
|
|
1646
|
+
}
|