@kubun/plugin-p2p 0.10.0 → 0.11.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 (136) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +12 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +122 -38
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +7 -2
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +69 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +56 -0
  28. package/lib/groups/group-health-monitor.js +139 -0
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +29 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +4 -8
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.d.ts +5 -4
  73. package/lib/hub/manager.js +198 -1
  74. package/lib/hub/wiring.d.ts +28 -41
  75. package/lib/hub/wiring.js +132 -1
  76. package/lib/index.d.ts +29 -13
  77. package/lib/index.js +392 -1
  78. package/lib/protocol.d.ts +8 -1
  79. package/lib/protocol.js +251 -1
  80. package/lib/schema.d.ts +2 -1
  81. package/lib/schema.js +471 -2
  82. package/lib/sync/authorize.d.ts +25 -0
  83. package/lib/sync/authorize.js +73 -0
  84. package/lib/sync/broadcast-queue.js +183 -1
  85. package/lib/sync/broadcast-sender.js +179 -1
  86. package/lib/sync/catalog-scope.js +68 -1
  87. package/lib/sync/errors.d.ts +12 -0
  88. package/lib/sync/errors.js +19 -0
  89. package/lib/sync/forwarder.js +277 -1
  90. package/lib/sync/handlers.d.ts +3 -22
  91. package/lib/sync/handlers.js +160 -1
  92. package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
  93. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  94. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  95. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  96. package/lib/sync/merkle-apply.d.ts +5 -0
  97. package/lib/sync/merkle-apply.js +194 -1
  98. package/lib/sync/merkle-channel.d.ts +19 -0
  99. package/lib/sync/merkle-channel.js +182 -1
  100. package/lib/sync/merkle-tree.js +120 -1
  101. package/lib/sync/peer-registry.d.ts +8 -1
  102. package/lib/sync/peer-registry.js +107 -1
  103. package/lib/sync/receive-access-gate.js +24 -1
  104. package/lib/sync/scope-resolver.js +41 -1
  105. package/lib/sync/sync-client.d.ts +5 -3
  106. package/lib/sync/sync-client.js +192 -1
  107. package/lib/sync/sync-manager.d.ts +2 -2
  108. package/lib/sync/sync-manager.js +272 -1
  109. package/lib/types.d.ts +41 -0
  110. package/lib/types.js +2 -1
  111. package/lib/util/mutex.js +10 -1
  112. package/lib/utils.d.ts +5 -0
  113. package/lib/utils.js +21 -0
  114. package/package.json +48 -41
  115. package/lib/groups/broadcast-service.d.ts +0 -189
  116. package/lib/groups/broadcast-service.js +0 -1
  117. package/lib/groups/wire-frame.d.ts +0 -32
  118. package/lib/groups/wire-frame.js +0 -1
  119. package/lib/hub/connection-pool.d.ts +0 -43
  120. package/lib/hub/connection-pool.js +0 -1
  121. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  122. package/lib/hub/forward-remote-broadcast.js +0 -1
  123. package/lib/hub/group-channel.d.ts +0 -67
  124. package/lib/hub/group-channel.js +0 -1
  125. package/lib/hub/hub-connection.d.ts +0 -96
  126. package/lib/hub/hub-connection.js +0 -1
  127. package/lib/hub/receive-handler.d.ts +0 -33
  128. package/lib/hub/receive-handler.js +0 -1
  129. package/lib/hub/relay-manager.d.ts +0 -127
  130. package/lib/hub/relay-manager.js +0 -1
  131. package/lib/hub/send-handler.d.ts +0 -40
  132. package/lib/hub/send-handler.js +0 -1
  133. package/lib/hub/tunnel-inbox.d.ts +0 -20
  134. package/lib/hub/tunnel-inbox.js +0 -1
  135. package/lib/hub/wait-for-gate.d.ts +0 -14
  136. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,1319 @@
1
- import{createCapability as e,createRevocationRecord as t}from"@enkaku/capability";import{stringifyToken as r}from"@enkaku/token";import{HLC as i}from"@kubun/hlc";import{getGraphStore as a}from"@kubun/store-graph";import{getP2PStore as o}from"@kubun/store-p2p";import{GraphQLError as d}from"graphql";import{decodeFullJoinRequest as l,decodeInvitePayload as c,decodeJoinRequest as n,encodeInvitePayload as s}from"../groups/invite-payload.js";import{applyInviteGrants as m,applyInviteRevocations as u,finalizeJoinedGroup as p}from"../groups/join-utils.js";import{checkPeerAccess as g,toISO as w}from"./types.js";export function createGroupContext(D,I){let y=null,_=null,h=()=>y??=o(I.stores),b=()=>_??=a(I.stores);return{create:async e=>{let t=await I.groupManager.createGroup({stores:I.stores,identity:I.identity,name:e.name,description:e.description,createdBy:D.viewerDID,hubs:e.hubs?.map(e=>({url:e.url,serverDID:e.serverDID??void 0}))}),r=await h(),i=await r.getGroup(t.groupID);if(null==i)throw Error("Failed to create group");let a={id:i.id,name:i.name,description:i.description,createdBy:i.created_by,createdAt:w(i.created_at)};return await I.emitter.emit("groupJoined",a),a},list:async()=>{let e=await h();return(await e.listGroups()).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:w(e.created_at)}))},get:async e=>{let t=await h(),r=await t.getGroup(e);return null==r?null:{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at)}},addMember:async(e,t,r)=>{let a=await h();await a.addGroupMember({group_id:e,member_did:t,role:r,hlc:i.serialize(I.hlc.now())});let o=await a.getGroupMember(e,t);if(null==o)throw Error("Failed to add group member");let d={groupID:o.group_id,memberDID:o.member_did,role:o.role,createdAt:w(o.created_at)};return await I.emitter.emit("groupMemberJoined",{...d,groupID:d.groupID}),d},listMembers:async e=>{let t=await h();return(await t.listGroupMembers(e)).map(e=>({groupID:e.group_id,memberDID:e.member_did,role:e.role,createdAt:w(e.created_at)}))},createCircle:async e=>{let t=await I.groupManager.createCircle({stores:I.stores,groupID:e.groupID,name:e.name,description:e.description}),r=await h(),i=await r.getCircle(t.circleID);if(null==i)throw Error("Failed to create circle");let a={id:i.id,groupID:i.group_id,name:i.name,description:i.description,createdAt:w(i.created_at)};return await I.emitter.emit("circleCreated",{...a,groupID:a.groupID}),I.stores.onCommit(()=>I.scheduleBroadcast(i.group_id,{type:"circle:create",circle:{id:i.id,groupID:i.group_id,name:i.name,description:i.description,catalogIDs:i.catalog_ids,hlc:i.hlc}})),a},listCircles:async e=>{let t=await h();return(await t.listCirclesByGroup(e)).map(e=>({id:e.id,groupID:e.group_id,name:e.name,description:e.description,createdAt:w(e.created_at)}))},addCircleMember:async(e,t,r)=>{let a=await h(),o=i.serialize(I.hlc.now());await a.addCircleMember({circle_id:e,member_did:t,role:r,hlc:o});let d=(await a.listCircleMembers(e)).find(e=>e.member_did===t);if(null==d)throw Error("Failed to add circle member");let l={circleID:d.circle_id,memberDID:d.member_did,role:d.role,createdAt:w(d.created_at)};await I.emitter.emit("circleMemberAdded",{...l,circleID:l.circleID});let c=await a.getCircle(e);if(null!=c)if("admin"===d.role||"member"===d.role){let e=d.role;I.stores.onCommit(()=>I.scheduleBroadcast(c.group_id,{type:"member:add",member:{circleID:d.circle_id,memberDID:d.member_did,role:e,hlc:d.hlc}}))}else I.logger.warn("addCircleMember: skipping broadcast for unrecognized role",{circleID:e,memberDID:t,role:d.role});return l},createCatalog:async e=>{let t=await b(),r=I.runtime.getRandomID(),a=i.serialize(I.hlc.now());await t.createCatalog({id:r,owner_did:D.viewerDID,name:e.name,description:e.description??"",filter_criteria:JSON.parse(e.filterCriteria),hlc:a});let o=await t.getCatalog(r);if(null==o)throw Error("Failed to create catalog");return{id:o.id,ownerDID:o.owner_did,name:o.name,description:o.description,filterCriteria:JSON.stringify(o.filter_criteria),createdAt:w(o.created_at)}},listCatalogs:async e=>{let t=await b();if(null!=e)return(await t.listCatalogs(e)).map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:w(e.created_at)}));let r=await h(),i=new Set((await r.listGroups()).map(e=>e.created_by));return(await Promise.all(Array.from(i).map(e=>t.listCatalogs(e)))).flat().map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:w(e.created_at)}))},invite:async(e,t,r,i)=>{g(D.viewerDID,I.identity.id,I.autoAcceptPeers);let a=await h(),o=I.identity,d=n(t),l=await I.groupManager.inviteToGroup({stores:I.stores,groupID:e,identity:o,recipientDID:d.did,recipientKeyPackage:d.publicPackage,permission:"member"}),c=await a.getGroup(e),m=(await a.listHubsByGroupID(e)).map(e=>null==e.server_did?{url:e.url}:{url:e.url,serverDID:e.server_did}),u=s({groupID:e,groupName:c?.name??"",suggestedHubs:m,invite:l.invite,welcomeMessage:l.welcomeMessage,ratchetTree:l.ratchetTree,...null!=r&&r.length>0?{grants:r}:{},...null!=i&&i.length>0?{revocations:i}:{}});return await I.emitter.emit("groupMemberJoined",{groupID:e,memberDID:d.did,role:"member",createdAt:new Date().toISOString()}),I.stores.onCommit(()=>I.scheduleSendCommit(e,l.commitMessage)),{invitePayload:u,groupID:e,groupName:c?.name??""}},join:async(e,t)=>{let r=I.identity,i=c(e),a=l(t),o={publicPackage:a.publicPackage,privatePackage:a.privatePackage,ownerDID:a.did};return await I.groupManager.joinGroup({stores:I.stores,identity:r,groupID:i.groupID,groupName:i.groupName,hubs:i.suggestedHubs,invite:i.invite,keyPackageBundle:o,welcomeMessage:i.welcomeMessage,ratchetTree:i.ratchetTree}),await m({stores:I.stores,selfDID:r.id,groupID:i.groupID,grants:i.grants,hlc:I.hlc,emitter:I.emitter,logger:I.logger}),await u({stores:I.stores,selfDID:r.id,groupID:i.groupID,revocations:i.revocations,hlc:I.hlc,emitter:I.emitter,logger:I.logger}),await p({stores:I.stores,emitter:I.emitter,invite:i})},leave:async e=>{let t=await h(),r=await t.getGroup(e),a=I.identity;try{await I.broadcastNow(e,{type:"group:leaveRequest",groupID:e,memberDID:a.id,hlc:i.serialize(I.hlc.now())})}catch(t){I.logger.warn("leave-request broadcast failed",{groupID:e,error:t})}return await I.groupManager.leaveGroup({stores:I.stores,groupID:e,identity:a}),null!=r&&await I.emitter.emit("groupLeft",{id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at),groupID:e}),{groupID:e}},removeMember:async(e,t)=>{let{commitMessage:r}=await I.groupManager.removeGroupMember({stores:I.stores,groupID:e,memberDID:t});return await I.emitter.emit("groupMemberLeft",{groupID:e,memberDID:t,role:"",createdAt:new Date().toISOString()}),I.stores.onCommit(()=>I.scheduleSendCommit(e,r)),{groupID:e}},update:async e=>{await I.groupManager.updateGroup({stores:I.stores,groupID:e.groupID,update:{name:e.name,description:e.description}});let t=await h(),r=await t.getGroup(e.groupID);if(null==r)throw Error(`Group ${e.groupID} not found`);let i={id:r.id,name:r.name,description:r.description,createdBy:r.created_by,createdAt:w(r.created_at)};return await I.emitter.emit("groupDataChanged",{...i,groupID:e.groupID}),I.stores.onCommit(()=>I.scheduleBroadcast(r.id,{type:"group:update",groupID:r.id,update:{name:e.name,description:e.description,hlc:r.hlc}})),i},updateCircle:async e=>{await I.groupManager.updateCircle({stores:I.stores,circleID:e.circleID,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs}});let t=await h(),r=await t.getCircle(e.circleID);if(null==r)throw Error(`Circle ${e.circleID} not found`);let i={id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:w(r.created_at)};return null!=e.catalogIDs?await I.emitter.emit("circleCatalogsChanged",{...i,circleID:e.circleID}):await I.emitter.emit("circleDataChanged",{...i,circleID:e.circleID}),I.stores.onCommit(()=>I.scheduleBroadcast(r.group_id,{type:"circle:update",circleID:r.id,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs,hlc:r.hlc}})),i},updateCatalog:async e=>{let t=await b(),r={hlc:i.serialize(I.hlc.now())};null!=e.name&&(r.name=e.name),null!=e.filterCriteria&&(r.filter_criteria=JSON.parse(e.filterCriteria)),await t.updateCatalog(e.catalogID,r);let a=await t.getCatalog(e.catalogID);if(null==a)throw Error(`Catalog ${e.catalogID} not found`);return{id:a.id,ownerDID:a.owner_did,name:a.name,description:a.description,filterCriteria:JSON.stringify(a.filter_criteria),createdAt:w(a.created_at)}},deleteCatalog:async e=>{let t=await b();return await t.deleteCatalog(e),{deletedID:e}},deleteCircle:async e=>{let t=await h(),r=await t.getCircle(e);if(await I.groupManager.deleteCircle({stores:I.stores,circleID:e}),null!=r){await I.emitter.emit("circleDeleted",{id:r.id,groupID:r.group_id,name:r.name,description:r.description,createdAt:w(r.created_at)});let t=i.serialize(I.hlc.now());I.stores.onCommit(()=>I.scheduleBroadcast(r.group_id,{type:"circle:delete",circleID:e,hlc:t}))}return{deletedID:e}},removeCircleMember:async(e,t)=>{let r=await h(),[a,o]=await Promise.all([r.getCircle(e),r.getCircleMember(e,t)]);if(await I.groupManager.removeCircleMember({stores:I.stores,circleID:e,memberDID:t}),await I.emitter.emit("circleMemberRemoved",{circleID:e,memberDID:t,role:o?.role??"",createdAt:null!=o?w(o.created_at):new Date().toISOString()}),null!=a){let r=i.serialize(I.hlc.now());I.stores.onCommit(()=>I.scheduleBroadcast(a.group_id,{type:"member:remove",circleID:e,memberDID:t,hlc:r}))}return{circleID:e,memberDID:t}},grantWriteCapability:async({to:t,res:a,groupID:o,expiresIn:d})=>{let l=await h(),c=Math.floor(Date.now()/1e3),n=c+(d??2592e3),s=I.runtime.getRandomID(),m=r(await e(I.identity,{sub:I.identity.id,aud:t,act:"document/write",res:a,iat:c,exp:n,jti:s})),u=i.serialize(I.hlc.now());return await l.addDelegationToken({jti:s,grantor:I.identity.id,audience:t,token:m,resource:a,act:"document/write",exp:n,group_id:o,hlc:u})&&await I.emitter.emit("delegationTokenAdded",{jwt:m,jti:s,grantor:I.identity.id,audience:t,resource:a,exp:n}),I.stores.onCommit(()=>I.scheduleBroadcast(o,{type:"delegation:share",token:m,groupID:o,hlc:u})),m},revokeCapability:async({jti:e})=>{let a=await h(),o=await a.getDelegationTokenByJti(e);if(null==o)throw new d(`No capability found locally for jti ${e}`,{extensions:{code:"REVOCATION_CAP_UNKNOWN"}});if(o.grantor!==D.viewerDID)throw new d(`Only the capability issuer can revoke jti ${e}`,{extensions:{code:"REVOCATION_UNAUTHORIZED"}});let l=await t(I.identity,e),c=r(await I.identity.signToken(l)),n=i.serialize(I.hlc.now()),s=await a.addRevocation({jti:e,revoker_did:I.identity.id,revoked_iat:l.iat,revocation_token:c,verified_at:l.iat,cap_exp:o.exp,group_id:o.group_id,hlc:n});return s&&await I.emitter.emit("delegationTokenRevoked",{jti:e,grantor:o.grantor,audience:o.audience,revokerDID:I.identity.id,revokedAt:l.iat,verifiedAt:l.iat,capExp:o.exp,groupID:o.group_id}),I.stores.onCommit(()=>I.scheduleBroadcast(o.group_id,{type:"delegation:revoke",token:c,groupID:o.group_id,hlc:n})),s}}}
1
+ import { createCapability, createRevocationRecord } from '@kokuin/capability';
2
+ import { normalizeDID, stringifyToken } from '@kokuin/token';
3
+ import { HLC } from '@kubun/hlc';
4
+ import { 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 { GraphQLError } from 'graphql';
8
+ import { ADMIN_ROLE_ENTRY_TYPE, foldAdminRoster, isLedgerAdminAtHLC, loadVerifiedAdminEntries, reprojectAdminRoster } from '../groups/admin-roster.js';
9
+ import { reprojectCircleDefs, reprojectCircleMembers, reprojectGroupSettings } from '../groups/circle-projection.js';
10
+ import { CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, encodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from '../groups/circle-reducers.js';
11
+ import { readGroupAnchor } from '../groups/group-anchor.js';
12
+ import { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeInvitePayload } from '../groups/invite-payload.js';
13
+ import { applyInviteGrants, applyInviteRevocations, finalizeJoinedGroup } from '../groups/join-utils.js';
14
+ import { ledgerEntryDigest, signLedgerEntry } from '../groups/ledger-entry.js';
15
+ import { checkPeerAccess, toISO } from './types.js';
16
+ /** Default write-grant lifetime in seconds (30 days). */ const DEFAULT_GRANT_EXPIRES_IN = 2_592_000;
17
+ export function createGroupContext(ctx, deps) {
18
+ let p2pStorePromise = null;
19
+ let delegationStorePromise = null;
20
+ let graphStorePromise = null;
21
+ const getP2PStore = ()=>p2pStorePromise ??= resolveP2PStore(deps.stores);
22
+ const getDelegationStore = ()=>delegationStorePromise ??= resolveDelegationStore(deps.stores);
23
+ const getGraphStore = ()=>graphStorePromise ??= resolveGraphStore(deps.stores);
24
+ return {
25
+ create: async (input)=>{
26
+ const result = await deps.groupManager.createGroup({
27
+ stores: deps.stores,
28
+ identity: deps.identity,
29
+ name: input.name,
30
+ description: input.description,
31
+ createdBy: ctx.viewerDID,
32
+ hubs: input.hubs?.map((h)=>({
33
+ url: h.url,
34
+ serverDID: h.serverDID ?? undefined
35
+ }))
36
+ });
37
+ const store = await getP2PStore();
38
+ const row = await store.getGroup(result.groupID);
39
+ if (row == null) throw new Error('Failed to create group');
40
+ const groupData = {
41
+ id: row.id,
42
+ name: row.name,
43
+ description: row.description,
44
+ createdBy: row.created_by,
45
+ createdAt: toISO(row.created_at)
46
+ };
47
+ // Defer past the outermost commit: the `groupJoined` listener reads the P2P
48
+ // store on the main connection — inline deadlocks single-conn SQLite / reads
49
+ // uncommitted on Postgres, and a rollback then emits nothing.
50
+ deps.stores.onCommit(()=>{
51
+ deps.emitter.emit('groupJoined', groupData).catch((error)=>{
52
+ deps.logger.error('groupJoined emit failed', {
53
+ groupID: groupData.id,
54
+ error
55
+ });
56
+ });
57
+ });
58
+ return groupData;
59
+ },
60
+ list: async ()=>{
61
+ const store = await getP2PStore();
62
+ const rows = await store.listGroups();
63
+ return rows.map((row)=>({
64
+ id: row.id,
65
+ name: row.name,
66
+ description: row.description,
67
+ createdBy: row.created_by,
68
+ createdAt: toISO(row.created_at)
69
+ }));
70
+ },
71
+ get: async (id)=>{
72
+ const store = await getP2PStore();
73
+ const row = await store.getGroup(id);
74
+ if (row == null) return null;
75
+ return {
76
+ id: row.id,
77
+ name: row.name,
78
+ description: row.description,
79
+ createdBy: row.created_by,
80
+ createdAt: toISO(row.created_at)
81
+ };
82
+ },
83
+ addMember: async (groupID, memberDID, role)=>{
84
+ const store = await getP2PStore();
85
+ await store.addGroupMember({
86
+ group_id: groupID,
87
+ member_did: memberDID,
88
+ role,
89
+ hlc: HLC.serialize(deps.hlc.now())
90
+ });
91
+ const row = await store.getGroupMember(groupID, memberDID);
92
+ if (row == null) throw new Error('Failed to add group member');
93
+ const memberData = {
94
+ groupID: row.group_id,
95
+ memberDID: row.member_did,
96
+ role: row.role,
97
+ createdAt: toISO(row.created_at)
98
+ };
99
+ await deps.emitter.emit('groupMemberJoined', {
100
+ ...memberData,
101
+ groupID: memberData.groupID
102
+ });
103
+ return memberData;
104
+ },
105
+ setMemberRole: async ({ groupID, memberDID, role })=>{
106
+ const store = await getP2PStore();
107
+ // Read the anchor through the engine transaction's stores: under
108
+ // `mutateGraph` this handler already runs inside that transaction, so the
109
+ // registry's restore/persist must share its connection — a separate
110
+ // connection blocks forever on the outer write lock (single-connection SQLite).
111
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
112
+ stores: deps.stores
113
+ });
114
+ if (anchor == null) {
115
+ throw new Error('cannot set member role: group has no genesis anchor');
116
+ }
117
+ const hlc = HLC.serialize(deps.hlc.now());
118
+ const selfDID = deps.identity.id;
119
+ // Defense-in-depth + fail-loud UX. The fold on every peer is the ultimate
120
+ // authority — a non-admin's entry is dropped at fold time — but checking
121
+ // here lets the caller fail loudly instead of silently emitting a no-op.
122
+ if (!await isLedgerAdminAtHLC({
123
+ store,
124
+ groupID,
125
+ anchor,
126
+ did: selfDID,
127
+ atHLC: hlc,
128
+ logger: deps.logger
129
+ })) {
130
+ throw new Error('not authorized: only a group admin can set member roles');
131
+ }
132
+ if (role === 'admin') {
133
+ // Admin implies membership: a grant must target an existing member.
134
+ if (!await store.isGroupMemberAtHLC(groupID, memberDID, hlc)) {
135
+ throw new Error('cannot grant admin to a non-member');
136
+ }
137
+ } else if (!await isLedgerAdminAtHLC({
138
+ store,
139
+ groupID,
140
+ anchor,
141
+ did: memberDID,
142
+ atHLC: hlc,
143
+ logger: deps.logger
144
+ })) {
145
+ // Demoting a DID that is not currently an admin is a no-op; don't mint a
146
+ // dead ledger entry + broadcast for it.
147
+ const existing = await store.getGroupMember(groupID, memberDID);
148
+ if (existing == null) {
149
+ throw new Error('cannot change role: not a group member');
150
+ }
151
+ return {
152
+ groupID: existing.group_id,
153
+ memberDID: existing.member_did,
154
+ role: existing.role,
155
+ createdAt: toISO(existing.created_at)
156
+ };
157
+ }
158
+ const value = role === 'admin' ? 'admin' : 'revoked';
159
+ const token = await signLedgerEntry(deps.identity, {
160
+ type: ADMIN_ROLE_ENTRY_TYPE,
161
+ subject: memberDID,
162
+ value,
163
+ hlc
164
+ });
165
+ const entryID = ledgerEntryDigest(token);
166
+ // Append the durable entry and reproject the admin overlay atomically, so a
167
+ // crash can never leave the durable ledger and the projected `role` column
168
+ // diverged. Reads inside the transaction use the transaction-bound store.
169
+ // `appendLedgerEntry` normalizes `issuer_did`, so the raw self DID is passed.
170
+ const memberData = await deps.stores.withTransaction(async (tx)=>{
171
+ const txStore = await resolveP2PStore(tx);
172
+ await txStore.appendLedgerEntry({
173
+ group_id: groupID,
174
+ entry_id: entryID,
175
+ type: ADMIN_ROLE_ENTRY_TYPE,
176
+ subject: memberDID,
177
+ value,
178
+ hlc,
179
+ issuer_did: selfDID,
180
+ signed_token: token
181
+ });
182
+ await reprojectAdminRoster({
183
+ store: txStore,
184
+ groupID,
185
+ anchor,
186
+ logger: deps.logger
187
+ });
188
+ const row = await txStore.getGroupMember(groupID, memberDID);
189
+ if (row == null) throw new Error('Failed to read group member after role change');
190
+ return {
191
+ groupID: row.group_id,
192
+ memberDID: row.member_did,
193
+ role: row.role,
194
+ createdAt: toISO(row.created_at)
195
+ };
196
+ });
197
+ // Record-before-send: defer the fan-out (so co-members fold the same entry
198
+ // against their own anchor) and the local emit to the OUTERMOST commit. Under
199
+ // `mutateGraph` this handler runs inside the engine's write transaction, so
200
+ // the inner `withTransaction` above nests inline and does NOT commit on its
201
+ // own — the outer transaction commits later. Registering via `onCommit` keeps
202
+ // a rolled-back role change from ever being broadcast or observed locally.
203
+ deps.stores.onCommit(()=>{
204
+ deps.scheduleBroadcast(groupID, {
205
+ type: 'ledger:entry',
206
+ groupID,
207
+ token
208
+ });
209
+ void deps.emitter.emit('groupMemberRoleChanged', {
210
+ ...memberData,
211
+ groupID: memberData.groupID
212
+ });
213
+ });
214
+ return memberData;
215
+ },
216
+ listMembers: async (groupID)=>{
217
+ const store = await getP2PStore();
218
+ const rows = await store.listGroupMembers(groupID);
219
+ return rows.map((row)=>({
220
+ groupID: row.group_id,
221
+ memberDID: row.member_did,
222
+ role: row.role,
223
+ createdAt: toISO(row.created_at)
224
+ }));
225
+ },
226
+ createCircle: async (p)=>{
227
+ const groupID = p.groupID;
228
+ // Read the anchor through the engine transaction's stores: under
229
+ // `mutateGraph` this handler already runs inside that transaction, so the
230
+ // registry's restore/persist must share its connection — a separate
231
+ // connection blocks forever on the outer write lock (single-connection SQLite).
232
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
233
+ stores: deps.stores
234
+ });
235
+ if (anchor == null) {
236
+ throw new Error('cannot create circle: group has no genesis anchor');
237
+ }
238
+ const circleID = deps.runtime.getRandomID();
239
+ const hlc = HLC.serialize(deps.hlc.now());
240
+ const value = {
241
+ name: p.name,
242
+ description: p.description ?? '',
243
+ catalogIDs: []
244
+ };
245
+ const token = await signLedgerEntry(deps.identity, {
246
+ type: CIRCLE_DEF_ENTRY_TYPE,
247
+ subject: circleID,
248
+ value,
249
+ hlc
250
+ });
251
+ const entryID = ledgerEntryDigest(token);
252
+ // Append the signed entry and reproject the circle-definition overlay
253
+ // atomically so a crash can never leave the durable ledger and the
254
+ // projected row diverged. `appendLedgerEntry` normalizes `issuer_did`, so
255
+ // the raw self DID is passed.
256
+ const circleData = await deps.stores.withTransaction(async (tx)=>{
257
+ const txStore = await resolveP2PStore(tx);
258
+ await txStore.appendLedgerEntry({
259
+ group_id: groupID,
260
+ entry_id: entryID,
261
+ type: CIRCLE_DEF_ENTRY_TYPE,
262
+ subject: circleID,
263
+ value,
264
+ hlc,
265
+ issuer_did: deps.identity.id,
266
+ signed_token: token
267
+ });
268
+ await reprojectCircleDefs({
269
+ store: txStore,
270
+ groupID,
271
+ anchor,
272
+ logger: deps.logger
273
+ });
274
+ const row = await txStore.getCircle(circleID);
275
+ if (row == null) throw new Error('Failed to read circle after create');
276
+ return {
277
+ id: row.id,
278
+ groupID: row.group_id,
279
+ name: row.name,
280
+ description: row.description,
281
+ createdAt: toISO(row.created_at)
282
+ };
283
+ });
284
+ // Record-before-send: defer the fan-out (so co-members fold the same entry
285
+ // against their own anchor) and the local emit to the OUTERMOST commit. Under
286
+ // `mutateGraph` this handler runs inside the engine's write transaction, so
287
+ // the inner `withTransaction` above nests inline and does NOT commit on its
288
+ // own — the outer transaction commits later. Registering via `onCommit` keeps
289
+ // a rolled-back creation from ever being broadcast or observed locally.
290
+ deps.stores.onCommit(()=>{
291
+ deps.scheduleBroadcast(groupID, {
292
+ type: 'ledger:entry',
293
+ groupID,
294
+ token
295
+ });
296
+ void deps.emitter.emit('circleCreated', {
297
+ ...circleData,
298
+ groupID: circleData.groupID
299
+ });
300
+ });
301
+ return circleData;
302
+ },
303
+ listCircles: async (groupID)=>{
304
+ const store = await getP2PStore();
305
+ const rows = await store.listCirclesByGroup(groupID);
306
+ return rows.map((row)=>({
307
+ id: row.id,
308
+ groupID: row.group_id,
309
+ name: row.name,
310
+ description: row.description,
311
+ createdAt: toISO(row.created_at)
312
+ }));
313
+ },
314
+ addCircleMember: async (circleID, memberDID, role)=>{
315
+ const store = await getP2PStore();
316
+ const circleRow = await store.getCircle(circleID);
317
+ if (circleRow == null) throw new Error('Failed to add circle member: circle not found');
318
+ const groupID = circleRow.group_id;
319
+ // Read the anchor through the engine transaction's stores: under
320
+ // `mutateGraph` this handler already runs inside that transaction, so the
321
+ // registry's restore/persist must share its connection — a separate
322
+ // connection blocks forever on the outer write lock (single-connection SQLite).
323
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
324
+ stores: deps.stores
325
+ });
326
+ if (anchor == null) {
327
+ throw new Error('cannot add circle member: group has no genesis anchor');
328
+ }
329
+ const hlc = HLC.serialize(deps.hlc.now());
330
+ const subject = encodeCircleMemberSubject(circleID, memberDID);
331
+ const value = {
332
+ role
333
+ };
334
+ const token = await signLedgerEntry(deps.identity, {
335
+ type: CIRCLE_MEMBER_ENTRY_TYPE,
336
+ subject,
337
+ value,
338
+ hlc
339
+ });
340
+ const entryID = ledgerEntryDigest(token);
341
+ // Append the signed entry and reproject the circle-member overlay
342
+ // atomically so a crash can never leave the durable ledger and the
343
+ // projected row diverged. `appendLedgerEntry` normalizes `issuer_did`, so
344
+ // the raw self DID is passed.
345
+ const circleMemberData = await deps.stores.withTransaction(async (tx)=>{
346
+ const txStore = await resolveP2PStore(tx);
347
+ await txStore.appendLedgerEntry({
348
+ group_id: groupID,
349
+ entry_id: entryID,
350
+ type: CIRCLE_MEMBER_ENTRY_TYPE,
351
+ subject,
352
+ value,
353
+ hlc,
354
+ issuer_did: deps.identity.id,
355
+ signed_token: token
356
+ });
357
+ await reprojectCircleMembers({
358
+ store: txStore,
359
+ groupID,
360
+ anchor,
361
+ logger: deps.logger
362
+ });
363
+ const row = await txStore.getCircleMember(circleID, memberDID);
364
+ if (row == null) throw new Error('Failed to read circle member after add');
365
+ return {
366
+ circleID: row.circle_id,
367
+ memberDID: row.member_did,
368
+ role: row.role,
369
+ createdAt: toISO(row.created_at)
370
+ };
371
+ });
372
+ // Record-before-send: defer the fan-out (so co-members fold the same entry
373
+ // against their own anchor) and the local emit to the OUTERMOST commit. Under
374
+ // `mutateGraph` this handler runs inside the engine's write transaction, so
375
+ // the inner `withTransaction` above nests inline and does NOT commit on its
376
+ // own — the outer transaction commits later. Registering via `onCommit` keeps
377
+ // a rolled-back addition from ever being broadcast or observed locally.
378
+ deps.stores.onCommit(()=>{
379
+ deps.scheduleBroadcast(groupID, {
380
+ type: 'ledger:entry',
381
+ groupID,
382
+ token
383
+ });
384
+ void deps.emitter.emit('circleMemberAdded', {
385
+ ...circleMemberData,
386
+ circleID: circleMemberData.circleID
387
+ });
388
+ });
389
+ return circleMemberData;
390
+ },
391
+ createCatalog: async (p)=>{
392
+ const graphStore = await getGraphStore();
393
+ const id = deps.runtime.getRandomID();
394
+ const hlcStr = HLC.serialize(deps.hlc.now());
395
+ await graphStore.createCatalog({
396
+ id,
397
+ owner_did: ctx.viewerDID,
398
+ name: p.name,
399
+ description: p.description ?? '',
400
+ filter_criteria: JSON.parse(p.filterCriteria),
401
+ hlc: hlcStr
402
+ });
403
+ const row = await graphStore.getCatalog(id);
404
+ if (row == null) throw new Error('Failed to create catalog');
405
+ return {
406
+ id: row.id,
407
+ ownerDID: row.owner_did,
408
+ name: row.name,
409
+ description: row.description,
410
+ filterCriteria: JSON.stringify(row.filter_criteria),
411
+ createdAt: toISO(row.created_at)
412
+ };
413
+ },
414
+ listCatalogs: async (ownerDID)=>{
415
+ const graphStore = await getGraphStore();
416
+ if (ownerDID != null) {
417
+ const rows = await graphStore.listCatalogs(ownerDID);
418
+ return rows.map((row)=>({
419
+ id: row.id,
420
+ ownerDID: row.owner_did,
421
+ name: row.name,
422
+ description: row.description,
423
+ filterCriteria: JSON.stringify(row.filter_criteria),
424
+ createdAt: toISO(row.created_at)
425
+ }));
426
+ }
427
+ // listCatalogs requires ownerDID -- for unfiltered, list groups then catalogs
428
+ const store = await getP2PStore();
429
+ const groups = await store.listGroups();
430
+ const ownerDIDs = new Set(groups.map((s)=>s.created_by));
431
+ const allCatalogs = await Promise.all(Array.from(ownerDIDs).map((did)=>graphStore.listCatalogs(did)));
432
+ return allCatalogs.flat().map((row)=>({
433
+ id: row.id,
434
+ ownerDID: row.owner_did,
435
+ name: row.name,
436
+ description: row.description,
437
+ filterCriteria: JSON.stringify(row.filter_criteria),
438
+ createdAt: toISO(row.created_at)
439
+ }));
440
+ },
441
+ invite: async (groupID, joinRequest, grants, revocations)=>{
442
+ checkPeerAccess(ctx.viewerDID, deps.identity.id, deps.autoAcceptPeers);
443
+ const store = await getP2PStore();
444
+ const ownIdentity = deps.identity;
445
+ const joinReq = decodeJoinRequest(joinRequest);
446
+ const result = await deps.groupManager.inviteToGroup({
447
+ stores: deps.stores,
448
+ groupID,
449
+ identity: ownIdentity,
450
+ recipientDID: joinReq.did,
451
+ recipientKeyPackage: joinReq.publicPackage,
452
+ permission: 'member'
453
+ });
454
+ // Look up group name for the invite payload
455
+ const groupRow = await store.getGroup(groupID);
456
+ const boundHubs = await store.listHubsByGroupID(groupID);
457
+ const suggestedHubs = boundHubs.map((hub)=>hub.server_did == null ? {
458
+ url: hub.url
459
+ } : {
460
+ url: hub.url,
461
+ serverDID: hub.server_did
462
+ });
463
+ const encoded = encodeInvitePayload({
464
+ groupID,
465
+ groupName: groupRow?.name ?? '',
466
+ suggestedHubs,
467
+ invite: result.invite,
468
+ welcomeMessage: result.welcomeMessage,
469
+ ratchetTree: result.ratchetTree,
470
+ ...grants != null && grants.length > 0 ? {
471
+ grants
472
+ } : {},
473
+ ...revocations != null && revocations.length > 0 ? {
474
+ revocations
475
+ } : {}
476
+ });
477
+ await deps.emitter.emit('groupMemberJoined', {
478
+ groupID,
479
+ memberDID: joinReq.did,
480
+ role: 'member',
481
+ createdAt: new Date().toISOString()
482
+ });
483
+ // Fan out the add-commit to existing members so their MLS ratchet +
484
+ // local roster learn about the new member (RFC 9750 §6.1). The joiner
485
+ // itself receives the Welcome out-of-band via the invite payload above.
486
+ deps.stores.onCommit(()=>deps.scheduleSendCommit(groupID, result.commitMessage));
487
+ return {
488
+ invitePayload: encoded,
489
+ groupID,
490
+ groupName: groupRow?.name ?? ''
491
+ };
492
+ },
493
+ join: async (invitePayloadStr, joinRequestPayloadStr)=>{
494
+ const ownIdentity = deps.identity;
495
+ const invite = decodeInvitePayload(invitePayloadStr);
496
+ // Decode the full join request to get the key package bundle (including private keys)
497
+ const fullJoinReq = decodeFullJoinRequest(joinRequestPayloadStr);
498
+ const keyPackageBundle = {
499
+ publicPackage: fullJoinReq.publicPackage,
500
+ privatePackage: fullJoinReq.privatePackage,
501
+ ownerDID: fullJoinReq.did
502
+ };
503
+ await deps.groupManager.joinGroup({
504
+ stores: deps.stores,
505
+ identity: ownIdentity,
506
+ groupID: invite.groupID,
507
+ groupName: invite.groupName,
508
+ hubs: invite.suggestedHubs,
509
+ invite: invite.invite,
510
+ keyPackageBundle,
511
+ welcomeMessage: invite.welcomeMessage,
512
+ ratchetTree: invite.ratchetTree
513
+ });
514
+ await applyInviteGrants({
515
+ stores: deps.stores,
516
+ selfDID: ownIdentity.id,
517
+ groupID: invite.groupID,
518
+ grants: invite.grants,
519
+ hlc: deps.hlc,
520
+ emitter: deps.emitter,
521
+ logger: deps.logger
522
+ });
523
+ await applyInviteRevocations({
524
+ stores: deps.stores,
525
+ selfDID: ownIdentity.id,
526
+ groupID: invite.groupID,
527
+ revocations: invite.revocations,
528
+ hlc: deps.hlc,
529
+ emitter: deps.emitter,
530
+ logger: deps.logger
531
+ });
532
+ return await finalizeJoinedGroup({
533
+ stores: deps.stores,
534
+ emitter: deps.emitter,
535
+ invite,
536
+ logger: deps.logger
537
+ });
538
+ },
539
+ leave: async (groupID)=>{
540
+ // One HLC orders BOTH the MLS self-removal tombstone and the admin-role
541
+ // self-revocation appended below. Sharing a single stamp is what lets a
542
+ // receiver's deterministic fold keep this leaver's control entries signed
543
+ // at or before departure and drop everything they sign strictly later.
544
+ const hlc = HLC.serialize(deps.hlc.now());
545
+ const store = await getP2PStore();
546
+ const groupRow = await store.getGroup(groupID);
547
+ const ownIdentity = deps.identity;
548
+ // Read the anchor while the MLS handle is still live — `leaveGroup` tears
549
+ // it down. Under `mutateGraph` this handler already runs inside the engine
550
+ // transaction, so the registry's restore/persist must share its connection
551
+ // (a separate connection blocks on the outer write lock under SQLite).
552
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
553
+ stores: deps.stores
554
+ });
555
+ // Resolve self-admin BEFORE the self-tombstone: once the tombstone writes
556
+ // `removed_at_hlc == hlc`, membership-at-HLC (which requires
557
+ // `removed_at_hlc > hlc`) would read false and wrongly report the leaver
558
+ // as a non-admin, suppressing the revocation.
559
+ const selfIsAdmin = anchor != null && await isLedgerAdminAtHLC({
560
+ store,
561
+ groupID,
562
+ anchor,
563
+ did: ownIdentity.id,
564
+ atHLC: hlc,
565
+ logger: deps.logger
566
+ });
567
+ // Revoke the leaver's own control authority atomically with the tombstone.
568
+ // Only admins fold authoritatively, so only an admin needs revoking — a
569
+ // non-admin leave appends nothing and broadcasts no ledger entry.
570
+ let revokeToken = null;
571
+ if (selfIsAdmin && anchor != null) {
572
+ const value = 'revoked';
573
+ const token = await signLedgerEntry(deps.identity, {
574
+ type: ADMIN_ROLE_ENTRY_TYPE,
575
+ subject: ownIdentity.id,
576
+ value,
577
+ hlc
578
+ });
579
+ const entryID = ledgerEntryDigest(token);
580
+ // `appendLedgerEntry` normalizes `issuer_did`, so the raw self DID is passed.
581
+ await store.appendLedgerEntry({
582
+ group_id: groupID,
583
+ entry_id: entryID,
584
+ type: ADMIN_ROLE_ENTRY_TYPE,
585
+ subject: ownIdentity.id,
586
+ value,
587
+ hlc,
588
+ issuer_did: ownIdentity.id,
589
+ signed_token: token
590
+ });
591
+ await reprojectAdminRoster({
592
+ store,
593
+ groupID,
594
+ anchor,
595
+ logger: deps.logger
596
+ });
597
+ revokeToken = token;
598
+ }
599
+ // Both broadcasts go out BEFORE MLS teardown — once `leaveGroup` deletes
600
+ // the local MLS handle we can no longer encrypt to the group. They are
601
+ // awaited (not deferred to `onCommit`) so the encrypt happens against the
602
+ // still-live handle; deferring would fail with the handle gone. A pre-commit
603
+ // send that a later rollback orphans is benign here: it is an authentic
604
+ // self-signed revoke converging early, monotonic toward less privilege for
605
+ // a DID that itself invoked leave — symmetric to the leaveRequest below.
606
+ // Best-effort: a failed notify must not block the leave itself.
607
+ try {
608
+ await deps.broadcastNow(groupID, {
609
+ type: 'group:leaveRequest',
610
+ groupID,
611
+ memberDID: ownIdentity.id,
612
+ hlc
613
+ });
614
+ } catch (error) {
615
+ deps.logger.warn('leave-request broadcast failed', {
616
+ groupID,
617
+ error
618
+ });
619
+ }
620
+ if (revokeToken != null) {
621
+ try {
622
+ await deps.broadcastNow(groupID, {
623
+ type: 'ledger:entry',
624
+ groupID,
625
+ token: revokeToken
626
+ });
627
+ } catch (error) {
628
+ deps.logger.warn('self-revoke broadcast failed', {
629
+ groupID,
630
+ error
631
+ });
632
+ }
633
+ }
634
+ await deps.groupManager.leaveGroup({
635
+ stores: deps.stores,
636
+ groupID,
637
+ identity: ownIdentity,
638
+ hlc
639
+ });
640
+ if (groupRow != null) {
641
+ const leftData = {
642
+ id: groupRow.id,
643
+ name: groupRow.name,
644
+ description: groupRow.description,
645
+ createdBy: groupRow.created_by,
646
+ createdAt: toISO(groupRow.created_at),
647
+ groupID
648
+ };
649
+ // Defer past the outermost commit: the `groupLeft` listener reads the P2P
650
+ // store on the main connection (deadlocks single-conn SQLite / uncommitted on PG).
651
+ deps.stores.onCommit(()=>{
652
+ deps.emitter.emit('groupLeft', leftData).catch((error)=>{
653
+ deps.logger.error('groupLeft emit failed', {
654
+ groupID,
655
+ error
656
+ });
657
+ });
658
+ });
659
+ }
660
+ return {
661
+ groupID
662
+ };
663
+ },
664
+ removeMember: async (groupID, memberDID)=>{
665
+ // One HLC orders BOTH the MLS-roster tombstone and the admin-role
666
+ // revocation appended below. Sharing a single stamp is what makes the
667
+ // revocation drop exactly the ex-admin's control entries signed after the
668
+ // removal: the deterministic fold keeps entries at or before this HLC and
669
+ // drops everything the ex-admin signs strictly later.
670
+ const hlc = HLC.serialize(deps.hlc.now());
671
+ // Read the anchor through the engine transaction's stores: under
672
+ // `mutateGraph` this handler already runs inside that transaction, so the
673
+ // registry's restore/persist must share its connection — a separate
674
+ // connection blocks forever on the outer write lock (single-connection SQLite).
675
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
676
+ stores: deps.stores
677
+ });
678
+ if (anchor == null) {
679
+ throw new Error('cannot remove member: group has no genesis anchor');
680
+ }
681
+ const store = await getP2PStore();
682
+ const selfDID = deps.identity.id;
683
+ // Only a current admin may remove a member. This gate runs BEFORE the
684
+ // roster tombstone so a non-admin attempt rolls back without touching the
685
+ // MLS roster; the schema-layer `failTransactionFatal` wrap turns the throw
686
+ // into a full-transaction rollback and forwards the message to the caller.
687
+ if (!await isLedgerAdminAtHLC({
688
+ store,
689
+ groupID,
690
+ anchor,
691
+ did: selfDID,
692
+ atHLC: hlc,
693
+ logger: deps.logger
694
+ })) {
695
+ throw new Error('only a group admin can remove a member');
696
+ }
697
+ // Resolve whether the removed DID is an admin BEFORE the tombstone. Once
698
+ // the roster tombstone writes `removed_at_hlc == hlc`, membership-at-HLC
699
+ // (which requires `removed_at_hlc > hlc`) would read false and wrongly
700
+ // report a removed admin as a non-admin, suppressing the revocation.
701
+ const removedIsAdmin = await isLedgerAdminAtHLC({
702
+ store,
703
+ groupID,
704
+ anchor,
705
+ did: memberDID,
706
+ atHLC: hlc,
707
+ logger: deps.logger
708
+ });
709
+ // Refuse to remove the last admin: fold the admin set at this HLC and check
710
+ // what survives once the removed DID is dropped. A group with no admins can
711
+ // never grant, revoke, or remove again, so this is fail-closed.
712
+ if (removedIsAdmin) {
713
+ const entries = await loadVerifiedAdminEntries(store, groupID, deps.logger);
714
+ const admins = foldAdminRoster(entries, anchor, hlc).admins;
715
+ const survivors = new Set(admins);
716
+ survivors.delete(normalizeDID(memberDID));
717
+ if (survivors.size === 0) {
718
+ throw new Error('cannot remove the last admin');
719
+ }
720
+ }
721
+ // MLS-roster delta + projected tombstone, stamped with the shared HLC.
722
+ const { commitMessage } = await deps.groupManager.removeGroupMember({
723
+ stores: deps.stores,
724
+ groupID,
725
+ memberDID,
726
+ hlc
727
+ });
728
+ // Revoke the ex-admin's control authority atomically with the tombstone.
729
+ // Only admins fold authoritatively, so only an admin needs revoking — a
730
+ // non-admin removal appends nothing and broadcasts no ledger entry.
731
+ let revokeToken = null;
732
+ if (removedIsAdmin) {
733
+ const value = 'revoked';
734
+ const token = await signLedgerEntry(deps.identity, {
735
+ type: ADMIN_ROLE_ENTRY_TYPE,
736
+ subject: memberDID,
737
+ value,
738
+ hlc
739
+ });
740
+ const entryID = ledgerEntryDigest(token);
741
+ // `appendLedgerEntry` normalizes `issuer_did`, so the raw self DID is passed.
742
+ await store.appendLedgerEntry({
743
+ group_id: groupID,
744
+ entry_id: entryID,
745
+ type: ADMIN_ROLE_ENTRY_TYPE,
746
+ subject: memberDID,
747
+ value,
748
+ hlc,
749
+ issuer_did: selfDID,
750
+ signed_token: token
751
+ });
752
+ await reprojectAdminRoster({
753
+ store,
754
+ groupID,
755
+ anchor,
756
+ logger: deps.logger
757
+ });
758
+ revokeToken = token;
759
+ }
760
+ await deps.emitter.emit('groupMemberLeft', {
761
+ groupID,
762
+ memberDID,
763
+ role: '',
764
+ createdAt: new Date().toISOString()
765
+ });
766
+ // Record-before-send: defer the MLS commit fan-out (so existing members
767
+ // advance their MLS epoch and tombstone the removed member) and the revoke
768
+ // broadcast (so co-members fold the same entry against their own anchor) to
769
+ // the OUTERMOST commit. Registering via `onCommit` keeps a rolled-back
770
+ // removal from ever being sent or observed.
771
+ deps.stores.onCommit(()=>{
772
+ deps.scheduleSendCommit(groupID, commitMessage);
773
+ if (revokeToken != null) {
774
+ deps.scheduleBroadcast(groupID, {
775
+ type: 'ledger:entry',
776
+ groupID,
777
+ token: revokeToken
778
+ });
779
+ }
780
+ });
781
+ return {
782
+ groupID
783
+ };
784
+ },
785
+ update: async (input)=>{
786
+ const groupID = input.groupID;
787
+ // Read the anchor through the engine transaction's stores: under
788
+ // `mutateGraph` this handler already runs inside that transaction, so the
789
+ // registry's restore/persist must share its connection — a separate
790
+ // connection blocks forever on the outer write lock (single-connection SQLite).
791
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
792
+ stores: deps.stores
793
+ });
794
+ if (anchor == null) {
795
+ throw new Error('cannot update group: group has no genesis anchor');
796
+ }
797
+ // No-op when the caller set neither field: `UpdateGroupInput` makes both
798
+ // `name` and `description` optional, so an input carrying only `groupID`
799
+ // would otherwise mint an empty-value `group.settings` entry + broadcast
800
+ // that folds to the same projected state. Return the current group row
801
+ // without appending an entry, broadcasting, or emitting.
802
+ if (input.name === undefined && input.description === undefined) {
803
+ const store = await getP2PStore();
804
+ const row = await store.getGroup(groupID);
805
+ if (row == null) throw new Error(`Group ${groupID} not found`);
806
+ return {
807
+ id: row.id,
808
+ name: row.name,
809
+ description: row.description,
810
+ createdBy: row.created_by,
811
+ createdAt: toISO(row.created_at)
812
+ };
813
+ }
814
+ const hlc = HLC.serialize(deps.hlc.now());
815
+ // `group.settings` folds per field: carry ONLY the fields the caller set.
816
+ // An explicit empty string clears the field; an omitted field is left out
817
+ // of the value entirely so the prior value survives.
818
+ const value = {};
819
+ if (input.name !== undefined) value.name = input.name;
820
+ if (input.description !== undefined) value.description = input.description;
821
+ const token = await signLedgerEntry(deps.identity, {
822
+ type: GROUP_SETTINGS_ENTRY_TYPE,
823
+ subject: groupID,
824
+ value,
825
+ hlc
826
+ });
827
+ const entryID = ledgerEntryDigest(token);
828
+ // Append the signed entry and reproject the group-settings overlay
829
+ // atomically so a crash can never leave the durable ledger and the
830
+ // projected group row diverged. `appendLedgerEntry` normalizes
831
+ // `issuer_did`, so the raw self DID is passed.
832
+ const updatedGroupData = await deps.stores.withTransaction(async (tx)=>{
833
+ const txStore = await resolveP2PStore(tx);
834
+ await txStore.appendLedgerEntry({
835
+ group_id: groupID,
836
+ entry_id: entryID,
837
+ type: GROUP_SETTINGS_ENTRY_TYPE,
838
+ subject: groupID,
839
+ value,
840
+ hlc,
841
+ issuer_did: deps.identity.id,
842
+ signed_token: token
843
+ });
844
+ await reprojectGroupSettings({
845
+ store: txStore,
846
+ groupID,
847
+ anchor,
848
+ logger: deps.logger
849
+ });
850
+ const row = await txStore.getGroup(groupID);
851
+ if (row == null) throw new Error(`Group ${groupID} not found`);
852
+ return {
853
+ id: row.id,
854
+ name: row.name,
855
+ description: row.description,
856
+ createdBy: row.created_by,
857
+ createdAt: toISO(row.created_at)
858
+ };
859
+ });
860
+ // Record-before-send: defer the fan-out (so co-members fold the same entry
861
+ // against their own anchor) and the local emit to the OUTERMOST commit. Under
862
+ // `mutateGraph` this handler runs inside the engine's write transaction, so
863
+ // the inner `withTransaction` above nests inline and does NOT commit on its
864
+ // own — the outer transaction commits later. Registering via `onCommit` keeps
865
+ // a rolled-back update from ever being broadcast or observed locally.
866
+ deps.stores.onCommit(()=>{
867
+ deps.scheduleBroadcast(groupID, {
868
+ type: 'ledger:entry',
869
+ groupID,
870
+ token
871
+ });
872
+ void deps.emitter.emit('groupDataChanged', {
873
+ ...updatedGroupData,
874
+ groupID
875
+ });
876
+ });
877
+ return updatedGroupData;
878
+ },
879
+ updateCircle: async (input)=>{
880
+ const store = await getP2PStore();
881
+ const existing = await store.getCircle(input.circleID);
882
+ if (existing == null) throw new Error(`Circle ${input.circleID} not found`);
883
+ const groupID = existing.group_id;
884
+ // Read the anchor through the engine transaction's stores: under
885
+ // `mutateGraph` this handler already runs inside that transaction, so the
886
+ // registry's restore/persist must share its connection — a separate
887
+ // connection blocks forever on the outer write lock (single-connection SQLite).
888
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
889
+ stores: deps.stores
890
+ });
891
+ if (anchor == null) {
892
+ throw new Error('cannot update circle: group has no genesis anchor');
893
+ }
894
+ const hlc = HLC.serialize(deps.hlc.now());
895
+ // `circle.def` is whole-value LWW: the winning entry replaces the projected
896
+ // definition outright. Merge the partial patch over the existing definition
897
+ // so a name-only update preserves the prior description and catalogIDs.
898
+ const value = {
899
+ name: input.name ?? existing.name,
900
+ description: input.description ?? existing.description,
901
+ catalogIDs: input.catalogIDs ?? existing.catalog_ids
902
+ };
903
+ const token = await signLedgerEntry(deps.identity, {
904
+ type: CIRCLE_DEF_ENTRY_TYPE,
905
+ subject: input.circleID,
906
+ value,
907
+ hlc
908
+ });
909
+ const entryID = ledgerEntryDigest(token);
910
+ const updatedCircleData = await deps.stores.withTransaction(async (tx)=>{
911
+ const txStore = await resolveP2PStore(tx);
912
+ await txStore.appendLedgerEntry({
913
+ group_id: groupID,
914
+ entry_id: entryID,
915
+ type: CIRCLE_DEF_ENTRY_TYPE,
916
+ subject: input.circleID,
917
+ value,
918
+ hlc,
919
+ issuer_did: deps.identity.id,
920
+ signed_token: token
921
+ });
922
+ await reprojectCircleDefs({
923
+ store: txStore,
924
+ groupID,
925
+ anchor,
926
+ logger: deps.logger
927
+ });
928
+ const row = await txStore.getCircle(input.circleID);
929
+ if (row == null) throw new Error('Failed to read circle after update');
930
+ return {
931
+ id: row.id,
932
+ groupID: row.group_id,
933
+ name: row.name,
934
+ description: row.description,
935
+ createdAt: toISO(row.created_at)
936
+ };
937
+ });
938
+ // Record-before-send: defer the fan-out (so co-members fold the same entry
939
+ // against their own anchor) and the local emit to the OUTERMOST commit. Under
940
+ // `mutateGraph` this handler runs inside the engine's write transaction, so
941
+ // the inner `withTransaction` above nests inline and does NOT commit on its
942
+ // own — the outer transaction commits later. Registering via `onCommit` keeps
943
+ // a rolled-back update from ever being broadcast or observed locally. Broadcast
944
+ // first, then emit, to match the order the other producers use.
945
+ deps.stores.onCommit(()=>{
946
+ deps.scheduleBroadcast(groupID, {
947
+ type: 'ledger:entry',
948
+ groupID,
949
+ token
950
+ });
951
+ if (input.catalogIDs != null) {
952
+ void deps.emitter.emit('circleCatalogsChanged', {
953
+ ...updatedCircleData,
954
+ circleID: input.circleID
955
+ });
956
+ } else {
957
+ void deps.emitter.emit('circleDataChanged', {
958
+ ...updatedCircleData,
959
+ circleID: input.circleID
960
+ });
961
+ }
962
+ });
963
+ return updatedCircleData;
964
+ },
965
+ updateCatalog: async (p)=>{
966
+ const graphStore = await getGraphStore();
967
+ const hlcStr = HLC.serialize(deps.hlc.now());
968
+ const update = {
969
+ hlc: hlcStr
970
+ };
971
+ if (p.name != null) update.name = p.name;
972
+ if (p.filterCriteria != null) {
973
+ update.filter_criteria = JSON.parse(p.filterCriteria);
974
+ }
975
+ await graphStore.updateCatalog(p.catalogID, update);
976
+ const row = await graphStore.getCatalog(p.catalogID);
977
+ if (row == null) throw new Error(`Catalog ${p.catalogID} not found`);
978
+ return {
979
+ id: row.id,
980
+ ownerDID: row.owner_did,
981
+ name: row.name,
982
+ description: row.description,
983
+ filterCriteria: JSON.stringify(row.filter_criteria),
984
+ createdAt: toISO(row.created_at)
985
+ };
986
+ },
987
+ deleteCatalog: async (catalogID)=>{
988
+ const graphStore = await getGraphStore();
989
+ await graphStore.deleteCatalog(catalogID);
990
+ return {
991
+ deletedID: catalogID
992
+ };
993
+ },
994
+ deleteCircle: async (circleID)=>{
995
+ const store = await getP2PStore();
996
+ const existing = await store.getCircle(circleID, {
997
+ includeRemoved: true
998
+ });
999
+ // No-op when the circle is absent (never created) or already tombstoned:
1000
+ // minting a dead `removed` entry + broadcast would only churn the ledger
1001
+ // and converge to the same projected state.
1002
+ if (existing == null || existing.removed_at_hlc != null) {
1003
+ return {
1004
+ deletedID: circleID
1005
+ };
1006
+ }
1007
+ const groupID = existing.group_id;
1008
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
1009
+ stores: deps.stores
1010
+ });
1011
+ if (anchor == null) {
1012
+ throw new Error('cannot delete circle: group has no genesis anchor');
1013
+ }
1014
+ // One HLC stamps both the durable tombstone entry and the broadcast token,
1015
+ // so the locally projected `removed_at_hlc` and the HLC every co-member
1016
+ // folds are identical — store and wire can never diverge.
1017
+ const hlc = HLC.serialize(deps.hlc.now());
1018
+ const value = {
1019
+ removed: true
1020
+ };
1021
+ const token = await signLedgerEntry(deps.identity, {
1022
+ type: CIRCLE_DEF_ENTRY_TYPE,
1023
+ subject: circleID,
1024
+ value,
1025
+ hlc
1026
+ });
1027
+ const entryID = ledgerEntryDigest(token);
1028
+ await deps.stores.withTransaction(async (tx)=>{
1029
+ const txStore = await resolveP2PStore(tx);
1030
+ await txStore.appendLedgerEntry({
1031
+ group_id: groupID,
1032
+ entry_id: entryID,
1033
+ type: CIRCLE_DEF_ENTRY_TYPE,
1034
+ subject: circleID,
1035
+ value,
1036
+ hlc,
1037
+ issuer_did: deps.identity.id,
1038
+ signed_token: token
1039
+ });
1040
+ await reprojectCircleDefs({
1041
+ store: txStore,
1042
+ groupID,
1043
+ anchor,
1044
+ logger: deps.logger
1045
+ });
1046
+ const row = await txStore.getCircle(circleID, {
1047
+ includeRemoved: true
1048
+ });
1049
+ if (row == null || row.removed_at_hlc == null) {
1050
+ throw new Error('Failed to tombstone circle after delete');
1051
+ }
1052
+ });
1053
+ // Record-before-send: defer the fan-out (so co-members fold the same
1054
+ // tombstone against their own anchor) and the local emit to the OUTERMOST
1055
+ // commit. Under `mutateGraph` this handler runs inside the engine's write
1056
+ // transaction, so the inner `withTransaction` above nests inline and does
1057
+ // NOT commit on its own — the outer transaction commits later. Registering
1058
+ // via `onCommit` keeps a rolled-back deletion from ever being broadcast or
1059
+ // observed locally.
1060
+ deps.stores.onCommit(()=>{
1061
+ deps.scheduleBroadcast(groupID, {
1062
+ type: 'ledger:entry',
1063
+ groupID,
1064
+ token
1065
+ });
1066
+ void deps.emitter.emit('circleDeleted', {
1067
+ id: existing.id,
1068
+ groupID: existing.group_id,
1069
+ name: existing.name,
1070
+ description: existing.description,
1071
+ createdAt: toISO(existing.created_at)
1072
+ });
1073
+ });
1074
+ return {
1075
+ deletedID: circleID
1076
+ };
1077
+ },
1078
+ removeCircleMember: async (circleID, memberDID)=>{
1079
+ const store = await getP2PStore();
1080
+ const existing = await store.getCircleMember(circleID, memberDID);
1081
+ // No-op when the target is not an active member: a never-added DID
1082
+ // (absent row) or an already-tombstoned one. Minting a dead `removed`
1083
+ // entry + broadcast for it would only churn the ledger and converge to
1084
+ // the same projected state, so return the requested pair untouched.
1085
+ if (existing == null || existing.removed_at_hlc != null) {
1086
+ return {
1087
+ circleID,
1088
+ memberDID
1089
+ };
1090
+ }
1091
+ const circleRow = await store.getCircle(circleID);
1092
+ if (circleRow == null) throw new Error('Failed to remove circle member: circle not found');
1093
+ const groupID = circleRow.group_id;
1094
+ // Read the anchor through the engine transaction's stores: under
1095
+ // `mutateGraph` this handler already runs inside that transaction, so the
1096
+ // registry's restore/persist must share its connection — a separate
1097
+ // connection blocks forever on the outer write lock (single-connection SQLite).
1098
+ const anchor = await deps.registry.readHandle(groupID, async (handle)=>readGroupAnchor(handle), {
1099
+ stores: deps.stores
1100
+ });
1101
+ if (anchor == null) {
1102
+ throw new Error('cannot remove circle member: group has no genesis anchor');
1103
+ }
1104
+ // One HLC stamps both the durable tombstone entry and the broadcast token,
1105
+ // so the locally projected `removed_at_hlc` and the HLC every co-member
1106
+ // folds are identical — store and wire can never diverge.
1107
+ const hlc = HLC.serialize(deps.hlc.now());
1108
+ const subject = encodeCircleMemberSubject(circleID, memberDID);
1109
+ const value = {
1110
+ removed: true
1111
+ };
1112
+ const token = await signLedgerEntry(deps.identity, {
1113
+ type: CIRCLE_MEMBER_ENTRY_TYPE,
1114
+ subject,
1115
+ value,
1116
+ hlc
1117
+ });
1118
+ const entryID = ledgerEntryDigest(token);
1119
+ // Append the signed tombstone and reproject the circle-member overlay
1120
+ // atomically so a crash can never leave the durable ledger and the
1121
+ // projected row diverged. `appendLedgerEntry` normalizes `issuer_did`, so
1122
+ // the raw self DID is passed.
1123
+ await deps.stores.withTransaction(async (tx)=>{
1124
+ const txStore = await resolveP2PStore(tx);
1125
+ await txStore.appendLedgerEntry({
1126
+ group_id: groupID,
1127
+ entry_id: entryID,
1128
+ type: CIRCLE_MEMBER_ENTRY_TYPE,
1129
+ subject,
1130
+ value,
1131
+ hlc,
1132
+ issuer_did: deps.identity.id,
1133
+ signed_token: token
1134
+ });
1135
+ await reprojectCircleMembers({
1136
+ store: txStore,
1137
+ groupID,
1138
+ anchor,
1139
+ logger: deps.logger
1140
+ });
1141
+ const row = await txStore.getCircleMember(circleID, memberDID);
1142
+ if (row == null || row.removed_at_hlc == null) {
1143
+ throw new Error('Failed to tombstone circle member after remove');
1144
+ }
1145
+ });
1146
+ // Record-before-send: defer the fan-out (so co-members fold the same
1147
+ // tombstone against their own anchor) and the local emit to the OUTERMOST
1148
+ // commit. Under `mutateGraph` this handler runs inside the engine's write
1149
+ // transaction, so the inner `withTransaction` above nests inline and does
1150
+ // NOT commit on its own — the outer transaction commits later. Registering
1151
+ // via `onCommit` keeps a rolled-back removal from ever being broadcast or
1152
+ // observed locally.
1153
+ deps.stores.onCommit(()=>{
1154
+ deps.scheduleBroadcast(groupID, {
1155
+ type: 'ledger:entry',
1156
+ groupID,
1157
+ token
1158
+ });
1159
+ void deps.emitter.emit('circleMemberRemoved', {
1160
+ circleID,
1161
+ memberDID,
1162
+ role: existing.role,
1163
+ createdAt: toISO(existing.created_at)
1164
+ });
1165
+ });
1166
+ return {
1167
+ circleID,
1168
+ memberDID
1169
+ };
1170
+ },
1171
+ grantWriteCapability: async ({ to, res, groupID, expiresIn })=>{
1172
+ const [store, delegationStore] = await Promise.all([
1173
+ getP2PStore(),
1174
+ getDelegationStore()
1175
+ ]);
1176
+ const iat = Math.floor(Date.now() / 1000);
1177
+ const exp = iat + (expiresIn ?? DEFAULT_GRANT_EXPIRES_IN);
1178
+ const jti = deps.runtime.getRandomID();
1179
+ const cap = await createCapability(deps.identity, {
1180
+ sub: deps.identity.id,
1181
+ aud: to,
1182
+ act: 'document/write',
1183
+ res,
1184
+ iat,
1185
+ exp,
1186
+ jti
1187
+ });
1188
+ const token = stringifyToken(cap);
1189
+ const hlc = HLC.serialize(deps.hlc.now());
1190
+ const changed = await delegationStore.addDelegationToken({
1191
+ jti,
1192
+ grantor: deps.identity.id,
1193
+ audience: to,
1194
+ token,
1195
+ resource: res,
1196
+ act: 'document/write',
1197
+ exp,
1198
+ hlc
1199
+ });
1200
+ // Record the group this grant belongs to on the p2p edge, keyed by `jti`,
1201
+ // so the p2p layer can resolve the cap's group for revoke-broadcast
1202
+ // targeting and listing enrichment.
1203
+ await store.addGroupDelegation({
1204
+ group_id: groupID,
1205
+ jti
1206
+ });
1207
+ // Surface the just-issued held row to local subscribers of
1208
+ // `ownDelegationTokenAdded`. Case B clients (delegate devices belonging
1209
+ // to the same identity as the grantor) listen on this emitter and
1210
+ // receive the event for tokens addressed to them; the grantor's own
1211
+ // subscriptions also see the issued row.
1212
+ //
1213
+ // The freshly-minted token always carries a new random `jti` and a new
1214
+ // hlc, so `changed` is true in practice — the gate matches the
1215
+ // broadcast-path producer for consistency.
1216
+ if (changed) {
1217
+ await deps.emitter.emit('delegationTokenAdded', {
1218
+ jwt: token,
1219
+ jti,
1220
+ grantor: deps.identity.id,
1221
+ audience: to,
1222
+ resource: res,
1223
+ exp
1224
+ });
1225
+ }
1226
+ // Distribute the grant to group co-members over the same control channel
1227
+ // circle/member ops use. The recipient stores it iff its audience matches,
1228
+ // so its engine can auto-attach the token on later writes. Same hlc as the
1229
+ // grantor-side issued row keeps LWW consistent across devices.
1230
+ deps.stores.onCommit(()=>deps.scheduleBroadcast(groupID, {
1231
+ type: 'delegation:share',
1232
+ token,
1233
+ groupID,
1234
+ hlc
1235
+ }));
1236
+ return token;
1237
+ },
1238
+ revokeCapability: async ({ jti })=>{
1239
+ const [store, delegationStore] = await Promise.all([
1240
+ getP2PStore(),
1241
+ getDelegationStore()
1242
+ ]);
1243
+ const cap = await delegationStore.getDelegationTokenByJTI(jti);
1244
+ if (cap == null) {
1245
+ // Multi-device-grantor case (the issuing device is not the one running
1246
+ // this resolver) is not yet supported; surface the missing-local-row
1247
+ // condition explicitly so callers can distinguish it from authz.
1248
+ throw new GraphQLError(`No capability found locally for jti ${jti}`, {
1249
+ extensions: {
1250
+ code: 'REVOCATION_CAP_UNKNOWN'
1251
+ }
1252
+ });
1253
+ }
1254
+ if (cap.grantor !== ctx.viewerDID) {
1255
+ throw new GraphQLError(`Only the capability issuer can revoke jti ${jti}`, {
1256
+ extensions: {
1257
+ code: 'REVOCATION_UNAUTHORIZED'
1258
+ }
1259
+ });
1260
+ }
1261
+ // Resolve the group the cap was granted in from the p2p edge; it targets
1262
+ // the revoke broadcast and enriches the emitted event. A missing edge
1263
+ // means the group is unknown locally — the broadcast then cannot be
1264
+ // scoped, so treat it like the missing-cap case.
1265
+ const groupID = await store.getGroupIDByJTI(jti);
1266
+ if (groupID == null) {
1267
+ throw new GraphQLError(`No group known locally for capability jti ${jti}`, {
1268
+ extensions: {
1269
+ code: 'REVOCATION_CAP_UNKNOWN'
1270
+ }
1271
+ });
1272
+ }
1273
+ const record = await createRevocationRecord(deps.identity, jti);
1274
+ const signed = await deps.identity.signToken(record);
1275
+ const token = stringifyToken(signed);
1276
+ const hlc = HLC.serialize(deps.hlc.now());
1277
+ // Self-issued revocations are verified by construction (the revoker is
1278
+ // the cap's issuer, checked above); seed `verified_at` with the
1279
+ // record's iat so the hot-path `isRevoked` gate fires immediately
1280
+ // without a separate verification round-trip.
1281
+ const changed = await delegationStore.addRevocation({
1282
+ jti,
1283
+ revoker_did: deps.identity.id,
1284
+ revoked_iat: record.iat,
1285
+ revocation_token: token,
1286
+ verified_at: record.iat,
1287
+ cap_exp: cap.exp,
1288
+ hlc
1289
+ });
1290
+ // Surface the verified revocation to local subscribers of
1291
+ // `ownDelegationTokenRevoked`. A self-mint is verified-by-construction
1292
+ // (authority check above), so the emit gate is simply `changed` — the
1293
+ // same idempotence semantic that suppresses duplicate broadcast-path
1294
+ // events on LWW no-ops.
1295
+ if (changed) {
1296
+ await deps.emitter.emit('delegationTokenRevoked', {
1297
+ jti,
1298
+ grantor: cap.grantor,
1299
+ audience: cap.audience,
1300
+ revokerDID: deps.identity.id,
1301
+ revokedAt: record.iat,
1302
+ verifiedAt: record.iat,
1303
+ capExp: cap.exp,
1304
+ groupID
1305
+ });
1306
+ }
1307
+ // Distribute the revocation to group co-members over the same control
1308
+ // channel that carries delegation:share. Same hlc as the local row keeps
1309
+ // LWW consistent across devices.
1310
+ deps.stores.onCommit(()=>deps.scheduleBroadcast(groupID, {
1311
+ type: 'delegation:revoke',
1312
+ token,
1313
+ groupID,
1314
+ hlc
1315
+ }));
1316
+ return changed;
1317
+ }
1318
+ };
1319
+ }