@kubun/plugin-p2p 0.10.1 → 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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +3 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +105 -91
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +5 -1
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +61 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +20 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +0 -6
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +17 -48
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +439 -2
- package/lib/sync/authorize.d.ts +1 -1
- package/lib/sync/authorize.js +73 -1
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +3 -2
- package/lib/sync/errors.js +19 -1
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +2 -2
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +1 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +3 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +11 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.js +21 -1
- package/package.json +47 -40
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
package/lib/context/group.js
CHANGED
|
@@ -1 +1,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 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 { 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
|
+
}
|