@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.
Files changed (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -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
+ }