@kubun/plugin-p2p 0.8.2 → 0.9.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/group.js +1 -1
- package/lib/context/hub.d.ts +4 -0
- package/lib/context/hub.js +1 -0
- package/lib/context/join.js +1 -1
- package/lib/context/sync.js +1 -1
- package/lib/context/types.d.ts +12 -0
- package/lib/groups/broadcast-service.d.ts +71 -17
- package/lib/groups/broadcast-service.js +1 -1
- package/lib/groups/broadcast.d.ts +112 -5
- package/lib/groups/broadcast.js +1 -1
- package/lib/groups/events.d.ts +11 -0
- package/lib/groups/group-handle-registry.d.ts +90 -0
- package/lib/groups/group-handle-registry.js +1 -0
- package/lib/groups/invite-payload.d.ts +5 -1
- package/lib/groups/join-utils.d.ts +2 -8
- package/lib/groups/join-utils.js +1 -1
- package/lib/groups/manager.d.ts +20 -14
- package/lib/groups/manager.js +1 -1
- package/lib/groups/mls-codec.d.ts +7 -0
- package/lib/groups/mls-codec.js +1 -0
- package/lib/groups/mls-encryptor.d.ts +25 -0
- package/lib/groups/mls-encryptor.js +1 -0
- package/lib/hub/connection-pool.d.ts +24 -0
- package/lib/hub/connection-pool.js +1 -1
- package/lib/hub/did-observing-transport.d.ts +64 -0
- package/lib/hub/did-observing-transport.js +1 -0
- package/lib/hub/errors.d.ts +28 -0
- package/lib/hub/errors.js +1 -0
- package/lib/hub/forward-remote-broadcast.d.ts +15 -0
- package/lib/hub/forward-remote-broadcast.js +1 -0
- package/lib/hub/group-channel.d.ts +29 -21
- package/lib/hub/group-channel.js +1 -1
- package/lib/hub/http-client.d.ts +17 -0
- package/lib/hub/http-client.js +1 -0
- package/lib/hub/hub-connection.d.ts +96 -0
- package/lib/hub/hub-connection.js +1 -0
- package/lib/hub/manager.d.ts +117 -0
- package/lib/hub/manager.js +1 -0
- package/lib/hub/receive-handler.d.ts +3 -6
- package/lib/hub/receive-handler.js +1 -1
- package/lib/hub/relay-manager.d.ts +85 -2
- package/lib/hub/relay-manager.js +1 -1
- package/lib/hub/send-handler.d.ts +12 -16
- package/lib/hub/send-handler.js +1 -1
- package/lib/hub/tunnel-inbox.d.ts +20 -0
- package/lib/hub/tunnel-inbox.js +1 -0
- package/lib/hub/wait-for-gate.d.ts +14 -0
- package/lib/hub/wait-for-gate.js +1 -0
- package/lib/hub/wiring.d.ts +73 -0
- package/lib/hub/wiring.js +1 -0
- package/lib/index.d.ts +102 -6
- package/lib/index.js +1 -1
- package/lib/schema.js +68 -20
- package/lib/sync/broadcast-queue.d.ts +59 -0
- package/lib/sync/broadcast-queue.js +1 -0
- package/lib/sync/broadcast-sender.d.ts +52 -0
- package/lib/sync/broadcast-sender.js +1 -0
- package/lib/sync/catalog-match.d.ts +13 -0
- package/lib/sync/catalog-match.js +1 -0
- package/lib/sync/forwarder.d.ts +96 -0
- package/lib/sync/forwarder.js +1 -0
- package/lib/sync/handlers.d.ts +15 -0
- package/lib/sync/handlers.js +1 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
- package/lib/sync/hub-tunnel-sync-listener.js +1 -0
- package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
- package/lib/sync/hub-tunnel-sync-provider.js +1 -0
- package/lib/sync/merkle-apply.d.ts +41 -0
- package/lib/sync/merkle-apply.js +1 -1
- package/lib/sync/merkle-channel.d.ts +15 -0
- package/lib/sync/merkle-channel.js +1 -1
- package/lib/sync/receive-access-gate.d.ts +36 -0
- package/lib/sync/receive-access-gate.js +1 -0
- package/lib/sync/scope-resolver.d.ts +32 -0
- package/lib/sync/scope-resolver.js +1 -0
- package/lib/sync/sync-manager.d.ts +10 -0
- package/lib/sync/sync-manager.js +1 -1
- package/lib/types.d.ts +95 -26
- package/lib/util/mutex.d.ts +4 -0
- package/lib/util/mutex.js +1 -0
- package/package.json +40 -37
package/lib/context/group.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{HLC as e}from"@kubun/hlc";import{getGraphStore as
|
|
1
|
+
import{HLC as e}from"@kubun/hlc";import{getGraphStore as r}from"@kubun/store-graph";import{getP2PStore as t}from"@kubun/store-p2p";import{decodeFullJoinRequest as a,decodeInvitePayload as i,decodeJoinRequest as o,encodeInvitePayload as c}from"../groups/invite-payload.js";import{finalizeJoinedGroup as l}from"../groups/join-utils.js";import{checkPeerAccess as d,toISO as n}from"./types.js";export function createGroupContext(s,m){let p=null,u=null,g=()=>p??=t(m.stores),w=()=>u??=r(m.stores);return{create:async e=>{let r=await m.groupManager.createGroup({stores:m.stores,identity:m.identity,name:e.name,description:e.description,createdBy:s.viewerDID,hubs:e.hubs?.map(e=>({url:e.url,serverDID:e.serverDID??void 0}))}),t=await g(),a=await t.getGroup(r.groupID);if(null==a)throw Error("Failed to create group");let i={id:a.id,name:a.name,description:a.description,createdBy:a.created_by,createdAt:n(a.created_at)};return await m.emitter.emit("groupJoined",i),i},list:async()=>{let e=await g();return(await e.listGroups()).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:n(e.created_at)}))},get:async e=>{let r=await g(),t=await r.getGroup(e);return null==t?null:{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:n(t.created_at)}},addMember:async(e,r,t)=>{let a=await g();await a.addGroupMember({group_id:e,member_did:r,role:t});let i=await a.getGroupMember(e,r);if(null==i)throw Error("Failed to add group member");let o={groupID:i.group_id,memberDID:i.member_did,role:i.role,createdAt:n(i.created_at)};return await m.emitter.emit("groupMemberJoined",{...o,groupID:o.groupID}),o},listMembers:async e=>{let r=await g();return(await r.listGroupMembers(e)).map(e=>({groupID:e.group_id,memberDID:e.member_did,role:e.role,createdAt:n(e.created_at)}))},createCircle:async e=>{let r=await m.groupManager.createCircle({stores:m.stores,groupID:e.groupID,name:e.name,description:e.description}),t=await g(),a=await t.getCircle(r.circleID);if(null==a)throw Error("Failed to create circle");let i={id:a.id,groupID:a.group_id,name:a.name,description:a.description,createdAt:n(a.created_at)};return await m.emitter.emit("circleCreated",{...i,groupID:i.groupID}),m.stores.onCommit(()=>m.scheduleBroadcast(a.group_id,{type:"circle:create",circle:{id:a.id,groupID:a.group_id,name:a.name,description:a.description,catalogIDs:a.catalog_ids,hlc:a.hlc}})),i},listCircles:async e=>{let r=await g();return(await r.listCirclesByGroup(e)).map(e=>({id:e.id,groupID:e.group_id,name:e.name,description:e.description,createdAt:n(e.created_at)}))},addCircleMember:async(r,t,a)=>{let i=await g(),o=e.serialize(m.hlc.now());await i.addCircleMember({circle_id:r,member_did:t,role:a,hlc:o});let c=(await i.listCircleMembers(r)).find(e=>e.member_did===t);if(null==c)throw Error("Failed to add circle member");let l={circleID:c.circle_id,memberDID:c.member_did,role:c.role,createdAt:n(c.created_at)};await m.emitter.emit("circleMemberAdded",{...l,circleID:l.circleID});let d=await i.getCircle(r);if(null!=d)if("admin"===c.role||"member"===c.role){let e=c.role;m.stores.onCommit(()=>m.scheduleBroadcast(d.group_id,{type:"member:add",member:{circleID:c.circle_id,memberDID:c.member_did,role:e,hlc:c.hlc}}))}else m.logger.warn("addCircleMember: skipping broadcast for unrecognized role",{circleID:r,memberDID:t,role:c.role});return l},createCatalog:async r=>{let t=await w(),a=m.runtime.getRandomID(),i=e.serialize(m.hlc.now());await t.createCatalog({id:a,owner_did:s.viewerDID,name:r.name,description:r.description??"",filter_criteria:JSON.parse(r.filterCriteria),hlc:i});let o=await t.getCatalog(a);if(null==o)throw Error("Failed to create catalog");return{id:o.id,ownerDID:o.owner_did,name:o.name,description:o.description,filterCriteria:JSON.stringify(o.filter_criteria),createdAt:n(o.created_at)}},listCatalogs:async e=>{let r=await w();if(null!=e)return(await r.listCatalogs(e)).map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:n(e.created_at)}));let t=await g(),a=new Set((await t.listGroups()).map(e=>e.created_by));return(await Promise.all(Array.from(a).map(e=>r.listCatalogs(e)))).flat().map(e=>({id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:JSON.stringify(e.filter_criteria),createdAt:n(e.created_at)}))},invite:async(e,r)=>{d(s.viewerDID,m.identity.id,m.autoAcceptPeers);let t=await g(),a=m.identity,i=o(r),l=await m.groupManager.inviteToGroup({stores:m.stores,groupID:e,identity:a,recipientDID:i.did,recipientKeyPackage:i.publicPackage,permission:"member"}),n=await t.getGroup(e),p=(await t.listHubsByGroupID(e)).map(e=>null==e.server_did?{url:e.url}:{url:e.url,serverDID:e.server_did}),u=c({groupID:e,groupName:n?.name??"",suggestedHubs:p,invite:l.invite,welcomeMessage:l.welcomeMessage,ratchetTree:l.ratchetTree});return await m.emitter.emit("groupMemberJoined",{groupID:e,memberDID:i.did,role:"member",createdAt:new Date().toISOString()}),{invitePayload:u,groupID:e,groupName:n?.name??""}},join:async(e,r)=>{let t=m.identity,o=i(e),c=a(r),d={publicPackage:c.publicPackage,privatePackage:c.privatePackage,ownerDID:c.did};return await m.groupManager.joinGroup({stores:m.stores,identity:t,groupID:o.groupID,groupName:o.groupName,hubs:o.suggestedHubs,invite:o.invite,keyPackageBundle:d,welcomeMessage:o.welcomeMessage,ratchetTree:o.ratchetTree}),await l({stores:m.stores,emitter:m.emitter,invite:o})},leave:async e=>{let r=await g(),t=await r.getGroup(e),a=m.identity;return await m.groupManager.leaveGroup({stores:m.stores,groupID:e,identity:a}),null!=t&&await m.emitter.emit("groupLeft",{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:n(t.created_at),groupID:e}),{groupID:e}},removeMember:async(e,r)=>(await m.groupManager.removeGroupMember({stores:m.stores,groupID:e,memberDID:r}),await m.emitter.emit("groupMemberLeft",{groupID:e,memberDID:r,role:"",createdAt:new Date().toISOString()}),{groupID:e}),update:async e=>{await m.groupManager.updateGroup({stores:m.stores,groupID:e.groupID,update:{name:e.name,description:e.description}});let r=await g(),t=await r.getGroup(e.groupID);if(null==t)throw Error(`Group ${e.groupID} not found`);let a={id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:n(t.created_at)};return await m.emitter.emit("groupDataChanged",{...a,groupID:e.groupID}),m.stores.onCommit(()=>m.scheduleBroadcast(t.id,{type:"group:update",groupID:t.id,update:{name:e.name,description:e.description,hlc:t.hlc}})),a},updateCircle:async e=>{await m.groupManager.updateCircle({stores:m.stores,circleID:e.circleID,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs}});let r=await g(),t=await r.getCircle(e.circleID);if(null==t)throw Error(`Circle ${e.circleID} not found`);let a={id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:n(t.created_at)};return null!=e.catalogIDs?await m.emitter.emit("circleCatalogsChanged",{...a,circleID:e.circleID}):await m.emitter.emit("circleDataChanged",{...a,circleID:e.circleID}),m.stores.onCommit(()=>m.scheduleBroadcast(t.group_id,{type:"circle:update",circleID:t.id,update:{name:e.name,description:e.description,catalogIDs:e.catalogIDs,hlc:t.hlc}})),a},updateCatalog:async r=>{let t=await w(),a={hlc:e.serialize(m.hlc.now())};null!=r.name&&(a.name=r.name),null!=r.filterCriteria&&(a.filter_criteria=JSON.parse(r.filterCriteria)),await t.updateCatalog(r.catalogID,a);let i=await t.getCatalog(r.catalogID);if(null==i)throw Error(`Catalog ${r.catalogID} not found`);return{id:i.id,ownerDID:i.owner_did,name:i.name,description:i.description,filterCriteria:JSON.stringify(i.filter_criteria),createdAt:n(i.created_at)}},deleteCatalog:async e=>{let r=await w();return await r.deleteCatalog(e),{deletedID:e}},deleteCircle:async r=>{let t=await g(),a=await t.getCircle(r);if(await m.groupManager.deleteCircle({stores:m.stores,circleID:r}),null!=a){await m.emitter.emit("circleDeleted",{id:a.id,groupID:a.group_id,name:a.name,description:a.description,createdAt:n(a.created_at)});let t=e.serialize(m.hlc.now());m.stores.onCommit(()=>m.scheduleBroadcast(a.group_id,{type:"circle:delete",circleID:r,hlc:t}))}return{deletedID:r}},removeCircleMember:async(r,t)=>{let a=await g(),[i,o]=await Promise.all([a.getCircle(r),a.getCircleMember(r,t)]);if(await m.groupManager.removeCircleMember({stores:m.stores,circleID:r,memberDID:t}),await m.emitter.emit("circleMemberRemoved",{circleID:r,memberDID:t,role:o?.role??"",createdAt:null!=o?n(o.created_at):new Date().toISOString()}),null!=i){let a=e.serialize(m.hlc.now());m.stores.onCommit(()=>m.scheduleBroadcast(i.group_id,{type:"member:remove",circleID:r,memberDID:t,hlc:a}))}return{circleID:r,memberDID:t}}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{getP2PStore as e}from"@kubun/store-p2p";import{bindHubToGroup as t,createHub as r,deleteHub as s,getHub as a,listHubs as u,listHubsByGroup as i,unbindHubFromGroup as l,updateHub as n}from"../hub/manager.js";import{toISO as o}from"./types.js";function d(e){return{id:e.id,label:e.label,url:e.url,serverDID:e.serverDID,createdAt:e.createdAt.toISOString(),updatedAt:null==e.updatedAt?null:e.updatedAt.toISOString()}}export function createHubContext(c,p){return{list:async()=>(await u({stores:p.stores})).map(d),get:async e=>{let t=await a({stores:p.stores,id:e});return null==t?null:d(t)},create:async e=>d(await r({stores:p.stores,url:e.url,label:e.label,serverDID:e.serverDID})),update:async e=>d(await n({stores:p.stores,id:e.id,url:e.url,label:e.label,serverDID:e.serverDID})),delete:async(e,t)=>{let r=await s({stores:p.stores,id:e,force:t});return{success:r.success,boundGroupIDs:r.boundGroupIDs}},bind:async({hubID:e,groupID:r})=>{let s=await t({stores:p.stores,hubID:e,groupID:r});if(s){let t=await a({stores:p.stores,id:e});null!=t&&await p.emitter.emit("hubBound",{groupID:r,hubID:e,hubURL:t.url,serverDID:t.serverDID})}return s},unbind:async({hubID:e,groupID:t})=>{let r=await i({stores:p.stores,groupID:t}),s=r.find(t=>t.id===e)?.url??null,a=await l({stores:p.stores,hubID:e,groupID:t});return a&&null!=s&&await p.emitter.emit("hubUnbound",{groupID:t,hubID:e,hubURL:s}),a},listByGroup:async e=>(await i({stores:p.stores,groupID:e})).map(d),listGroups:async t=>{let r=await e(p.stores);return(await r.listGroupsByHubID(t)).map(e=>({id:e.id,name:e.name,description:e.description,createdBy:e.created_by,createdAt:o(e.created_at)}))}}}
|
package/lib/context/join.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as i}from"../groups/invite-payload.js";import{
|
|
1
|
+
import{createKeyPackageBundle as e}from"@enkaku/group";import{decodeInvitePayload as t,encodeJoinRequest as i}from"../groups/invite-payload.js";import{finalizeJoinedGroup as r}from"../groups/join-utils.js";import{checkPeerAccess as a}from"./types.js";export function createJoinContext(o,s){return{prepareRequest:async()=>{a(o.viewerDID,s.identity.id,s.autoAcceptPeers);let t=s.identity,r=await e(t);return s.pendingJoinRequests.set(o.viewerDID,{publicPackage:r.publicPackage,privatePackage:r.privatePackage,did:t.id}),{joinRequest:i({did:t.id,publicPackage:r.publicPackage})}},complete:async e=>{a(o.viewerDID,s.identity.id,s.autoAcceptPeers);let i=s.pendingJoinRequests.get(o.viewerDID);if(null==i)throw Error("No pending join request found — call prepareJoinRequest first");let n=s.identity,p=t(e),u={publicPackage:i.publicPackage,privatePackage:i.privatePackage,ownerDID:n.id};await s.groupManager.joinGroup({stores:s.stores,identity:n,groupID:p.groupID,groupName:p.groupName,hubs:p.suggestedHubs,invite:p.invite,keyPackageBundle:u,welcomeMessage:p.welcomeMessage,ratchetTree:p.ratchetTree});let c=await r({stores:s.stores,emitter:s.emitter,invite:p});return s.pendingJoinRequests.delete(o.viewerDID),c}}}
|
package/lib/context/sync.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function createSyncContext(e,s){return{
|
|
1
|
+
export function createSyncContext(e,s){return{addPeer:e=>s.syncManager.addPeer({config:e,stores:s.stores}),removePeer:e=>s.syncManager.removePeer({peerDID:e,stores:s.stores}),listPeers:()=>s.syncManager.listPeers({stores:s.stores}),getStatus:e=>s.syncManager.getStatus({peerDID:e,stores:s.stores})}}
|
package/lib/context/types.d.ts
CHANGED
|
@@ -4,7 +4,10 @@ import type { OwnIdentity } from '@enkaku/token';
|
|
|
4
4
|
import type { StoreProvider } from '@kubun/db';
|
|
5
5
|
import type { Adapter } from '@kubun/db-adapter';
|
|
6
6
|
import type { HLC } from '@kubun/hlc';
|
|
7
|
+
import type { Logger } from '@kubun/logger';
|
|
8
|
+
import type { GroupBroadcastMessage } from '../groups/broadcast.js';
|
|
7
9
|
import type { GroupEventEmitter } from '../groups/events.js';
|
|
10
|
+
import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
|
|
8
11
|
import type { GroupManager } from '../groups/manager.js';
|
|
9
12
|
import type { SyncManager } from '../sync/sync-manager.js';
|
|
10
13
|
export type PendingJoinRequest = {
|
|
@@ -15,6 +18,7 @@ export type PendingJoinRequest = {
|
|
|
15
18
|
export type ContextDeps = {
|
|
16
19
|
identity: OwnIdentity;
|
|
17
20
|
groupManager: GroupManager;
|
|
21
|
+
registry: GroupHandleRegistry;
|
|
18
22
|
syncManager: SyncManager;
|
|
19
23
|
stores: StoreProvider;
|
|
20
24
|
adapter: Adapter;
|
|
@@ -23,6 +27,14 @@ export type ContextDeps = {
|
|
|
23
27
|
pendingJoinRequests: Map<string, PendingJoinRequest>;
|
|
24
28
|
runtime: Runtime;
|
|
25
29
|
autoAcceptPeers?: Array<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Fire-and-forget hub broadcast trigger. Wired to `HubWiring.scheduleBroadcast`
|
|
32
|
+
* when the p2p plugin is configured with `hub`; otherwise a no-op from
|
|
33
|
+
* `DISABLED_HUB`. Handlers invoke this via `stores.onCommit(...)` so the hub
|
|
34
|
+
* send runs after the mutation transaction commits.
|
|
35
|
+
*/
|
|
36
|
+
scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
|
|
37
|
+
logger: Logger;
|
|
26
38
|
};
|
|
27
39
|
export declare function toISO(timestamp: unknown): string;
|
|
28
40
|
/**
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from '@enkaku/event';
|
|
2
|
+
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
3
|
+
import type { Logger } from '@kubun/logger';
|
|
2
4
|
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
3
5
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
+
import type { ForwardingConfig } from '../sync/forwarder.js';
|
|
7
|
+
import type { StoreUnreadableMode } from '../types.js';
|
|
8
|
+
import { type AffectedRow, type GroupBroadcastMessage } from './broadcast.js';
|
|
9
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
6
10
|
/**
|
|
7
11
|
* Thrown when a received broadcast fails to decrypt (MLS restore, decrypt,
|
|
8
12
|
* or deserialize). Callers should typically ack-and-skip these, since the
|
|
@@ -25,6 +29,17 @@ export type BroadcastEvent = {
|
|
|
25
29
|
groupID: string;
|
|
26
30
|
message: GroupBroadcastMessage;
|
|
27
31
|
applied: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Row affected by the broadcast. After-state for create / update,
|
|
34
|
+
* before-state for delete / remove. Undefined when `applied === false`.
|
|
35
|
+
*/
|
|
36
|
+
affected?: AffectedRow;
|
|
37
|
+
/**
|
|
38
|
+
* Count of `mutation:apply` entries the local broadcast log already had.
|
|
39
|
+
* Only set on the `mutation:apply` branch; absent on control-plane
|
|
40
|
+
* variants.
|
|
41
|
+
*/
|
|
42
|
+
duplicates?: number;
|
|
28
43
|
};
|
|
29
44
|
export type BroadcastServiceEvents = {
|
|
30
45
|
broadcast: BroadcastEvent;
|
|
@@ -32,45 +47,84 @@ export type BroadcastServiceEvents = {
|
|
|
32
47
|
export type SendBroadcastParams = {
|
|
33
48
|
groupID: string;
|
|
34
49
|
message: GroupBroadcastMessage;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Wire-send hook invoked with the MLS-encrypted payload INSIDE the
|
|
52
|
+
* registry's per-group lock. A throw here propagates out of `prepareSend`
|
|
53
|
+
* and the registry skips persist — so a failed send leaves the device's
|
|
54
|
+
* MLS state at its pre-encrypt epoch, preventing the wedge where local
|
|
55
|
+
* state outpaces recipients.
|
|
56
|
+
*/
|
|
57
|
+
send: (encrypted: Uint8Array) => Promise<void>;
|
|
40
58
|
};
|
|
41
59
|
export type ReceiveBroadcastParams = {
|
|
42
60
|
groupID: string;
|
|
43
61
|
encrypted: Uint8Array;
|
|
44
|
-
groupState: SerializedMLSGroupState;
|
|
45
62
|
};
|
|
46
63
|
export type ReceiveBroadcastResult = {
|
|
47
64
|
message: GroupBroadcastMessage;
|
|
48
65
|
applied: boolean;
|
|
49
|
-
|
|
66
|
+
affected?: AffectedRow;
|
|
67
|
+
duplicates?: number;
|
|
50
68
|
};
|
|
51
69
|
/**
|
|
52
70
|
* BroadcastService handles MLS encrypt/decrypt for group broadcasts
|
|
53
71
|
* and applies received messages to the local DB.
|
|
54
72
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
73
|
+
* Encrypt and decrypt go through `GroupHandleRegistry.withHandle` so MLS
|
|
74
|
+
* state mutations serialize through the per-group mutex with write-through
|
|
75
|
+
* persist. The DB-apply step (`processBroadcast`) for receives runs OUTSIDE
|
|
76
|
+
* the lock so a slow apply does not block subsequent encrypt/decrypt on the
|
|
77
|
+
* same group.
|
|
58
78
|
*/
|
|
59
79
|
export type BroadcastServiceParams = {
|
|
60
80
|
p2pStore: P2PStoreAPI;
|
|
61
81
|
graphStore: GraphStoreAPI;
|
|
82
|
+
/**
|
|
83
|
+
* Engine graph internals — required so `mutation:apply` broadcasts route
|
|
84
|
+
* through {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`.
|
|
85
|
+
*/
|
|
86
|
+
graph: GraphInternals;
|
|
87
|
+
/** Self DID for `mutation:apply` loopback detection. */
|
|
88
|
+
selfDID: string;
|
|
89
|
+
/** Single canonical access point for MLS `GroupHandle` instances. */
|
|
90
|
+
registry: GroupHandleRegistry;
|
|
91
|
+
/**
|
|
92
|
+
* Receiver storage mode for incoming `mutation:apply` entries. Defaults
|
|
93
|
+
* to `'persist'`. When `'drop'`, `defaultAccessLevel` must be supplied
|
|
94
|
+
* so the gate factory can resolve server-side default access rules.
|
|
95
|
+
*/
|
|
96
|
+
storeUnreadable?: StoreUnreadableMode;
|
|
97
|
+
/** Server default access level — required when `storeUnreadable === 'drop'`. */
|
|
98
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
99
|
+
/**
|
|
100
|
+
* Forwarding config. Threaded into `processBroadcast` per call.
|
|
101
|
+
* `false` / undefined → forwarding disabled (default).
|
|
102
|
+
*/
|
|
103
|
+
forwarding?: ForwardingConfig;
|
|
104
|
+
/**
|
|
105
|
+
* Hub forwarding hook — required when `forwarding` is set. Called once per
|
|
106
|
+
* surviving candidate group on the receive path.
|
|
107
|
+
*/
|
|
108
|
+
scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
|
|
109
|
+
logger?: Logger;
|
|
62
110
|
};
|
|
63
111
|
export declare class BroadcastService extends EventEmitter<BroadcastServiceEvents> {
|
|
64
112
|
#private;
|
|
65
113
|
constructor(params: BroadcastServiceParams);
|
|
66
114
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
115
|
+
* Encrypt + send a broadcast for a group, then persist the advanced MLS state.
|
|
116
|
+
*
|
|
117
|
+
* Failure semantics: send happens inside the registry's per-group lock. A
|
|
118
|
+
* throw from `send` propagates out and the registry skips persist — so the
|
|
119
|
+
* device's MLS state stays at its pre-encrypt epoch and the next attempt
|
|
120
|
+
* re-derives a fresh handle from the prior generation. Wedge protection
|
|
121
|
+
* intact.
|
|
69
122
|
*/
|
|
70
|
-
prepareSend(params: SendBroadcastParams): Promise<
|
|
123
|
+
prepareSend(params: SendBroadcastParams): Promise<void>;
|
|
71
124
|
/**
|
|
72
|
-
* Process a received broadcast: MLS decrypt
|
|
73
|
-
*
|
|
125
|
+
* Process a received broadcast: MLS decrypt INSIDE the registry lock,
|
|
126
|
+
* then apply to the DB OUTSIDE the lock so a slow apply doesn't block
|
|
127
|
+
* subsequent encrypt/decrypt on the same group.
|
|
74
128
|
*/
|
|
75
129
|
processReceived(params: ReceiveBroadcastParams): Promise<ReceiveBroadcastResult>;
|
|
76
130
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{EventEmitter as r}from"@enkaku/event";import{processBroadcast as e}from"./broadcast.js";import{deserializeBroadcast as t,serializeBroadcast as a}from"./broadcast-codec.js";import{
|
|
1
|
+
import{EventEmitter as r}from"@enkaku/event";import{processBroadcast as e}from"./broadcast.js";import{deserializeBroadcast as t,serializeBroadcast as a}from"./broadcast-codec.js";import{mlsDecryptFramed as s,mlsEncryptFramed as o}from"./mls-codec.js";export class DecryptError extends Error{cause;constructor(r,e){super(r),this.name="DecryptError",this.cause=e}}export class ApplyError extends Error{cause;constructor(r,e){super(r),this.name="ApplyError",this.cause=e}}export class BroadcastService extends r{#r;#e;constructor(r){super(),this.#r={p2pStore:r.p2pStore,graphStore:r.graphStore,graph:r.graph,selfDID:r.selfDID,storeUnreadable:r.storeUnreadable,defaultAccessLevel:r.defaultAccessLevel,forwarding:r.forwarding,scheduleBroadcast:r.scheduleBroadcast,logger:r.logger},this.#e=r.registry}async prepareSend(r){let{groupID:e,message:t,send:s}=r,c=a(t);await this.#e.withHandle(e,async r=>{let{framed:e}=await o(r,c);await s(e)})}async processReceived(r){let a,o,{groupID:c,encrypted:i}=r;try{a=await this.#e.withHandle(c,async r=>{let e=await s(r,i);return t(e)})}catch(r){throw new DecryptError("Failed to decrypt broadcast",r)}try{o=await e(this.#r,a,c)}catch(r){throw new ApplyError("Failed to apply broadcast",r)}let{applied:p,affected:d,duplicates:l}=o;return await this.emit("broadcast",{groupID:c,message:a,applied:p,affected:d,duplicates:l}),{message:a,applied:p,affected:d,duplicates:l}}}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
1
3
|
import type { CatalogRecord, CircleMemberRecord, CircleRecord } from '@kubun/protocol';
|
|
2
|
-
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
3
|
-
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
4
|
+
import type { Catalog, GraphStoreAPI } from '@kubun/store-graph';
|
|
5
|
+
import type { Circle, CircleMember, Group, P2PStoreAPI } from '@kubun/store-p2p';
|
|
6
|
+
import { type ForwardingConfig } from '../sync/forwarder.js';
|
|
7
|
+
import type { StoreUnreadableMode } from '../types.js';
|
|
8
|
+
export type MutationApplyEntry = {
|
|
9
|
+
mutationJWT: string;
|
|
10
|
+
docID: string;
|
|
11
|
+
/** HLC string identifying this mutation's version. */
|
|
12
|
+
version: string;
|
|
13
|
+
modelID: string;
|
|
14
|
+
};
|
|
4
15
|
export type GroupBroadcastMessage = {
|
|
5
16
|
type: 'circle:create';
|
|
6
17
|
circle: CircleRecord;
|
|
@@ -47,18 +58,114 @@ export type GroupBroadcastMessage = {
|
|
|
47
58
|
update: {
|
|
48
59
|
name?: string;
|
|
49
60
|
description?: string;
|
|
50
|
-
hubURLs?: Array<string>;
|
|
51
61
|
hlc: string;
|
|
52
62
|
};
|
|
63
|
+
} | {
|
|
64
|
+
type: 'mutation:apply';
|
|
65
|
+
entries: Array<MutationApplyEntry>;
|
|
66
|
+
/** DID of the peer that authored this broadcast — used for loopback drop. */
|
|
67
|
+
senderPeerDID: string;
|
|
68
|
+
};
|
|
69
|
+
export type AffectedRow = {
|
|
70
|
+
kind: 'circle';
|
|
71
|
+
row: Circle;
|
|
72
|
+
} | {
|
|
73
|
+
kind: 'member';
|
|
74
|
+
row: CircleMember;
|
|
75
|
+
} | {
|
|
76
|
+
kind: 'catalog';
|
|
77
|
+
row: Catalog;
|
|
78
|
+
} | {
|
|
79
|
+
kind: 'group';
|
|
80
|
+
row: Group;
|
|
81
|
+
};
|
|
82
|
+
export type ProcessBroadcastResult = {
|
|
83
|
+
applied: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Row snapshot. After-state for create / update, before-state for
|
|
86
|
+
* delete / remove. Undefined when `applied === false`.
|
|
87
|
+
*/
|
|
88
|
+
affected?: AffectedRow;
|
|
89
|
+
/**
|
|
90
|
+
* Count of `mutation:apply` entries denied by the receive-time access
|
|
91
|
+
* gate. Only set on the `mutation:apply` branch; other branches do not
|
|
92
|
+
* gate. Always `0` when `storeUnreadable === 'persist'` (the default).
|
|
93
|
+
*/
|
|
94
|
+
dropped?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Count of forwarded broadcast targets across all `mutation:apply`
|
|
97
|
+
* entries. Only set on the `mutation:apply` branch when `forwarding` is
|
|
98
|
+
* configured and not `false`. `0` when forwarding is disabled or no
|
|
99
|
+
* candidate group survived the gates.
|
|
100
|
+
*/
|
|
101
|
+
forwarded?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Count of `mutation:apply` entries where `recordBroadcast` returned
|
|
104
|
+
* `false` (already-recorded duplicate). Only set on the `mutation:apply`
|
|
105
|
+
* branch; other branches do not call `recordBroadcast`. Always defined
|
|
106
|
+
* (possibly `0`) on `mutation:apply`.
|
|
107
|
+
*/
|
|
108
|
+
duplicates?: number;
|
|
53
109
|
};
|
|
54
110
|
export type ProcessBroadcastParams = {
|
|
55
111
|
p2pStore: P2PStoreAPI;
|
|
56
112
|
graphStore: GraphStoreAPI;
|
|
113
|
+
/**
|
|
114
|
+
* Engine graph internals — required to apply `mutation:apply` entries via
|
|
115
|
+
* {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`, which
|
|
116
|
+
* fires engine lifecycle events (document:saved, engine:document:*,
|
|
117
|
+
* engine:mutation:received) so GraphQL subscribers see peer-authored docs.
|
|
118
|
+
*/
|
|
119
|
+
graph: GraphInternals;
|
|
120
|
+
/** Self DID for `mutation:apply` loopback detection. */
|
|
121
|
+
selfDID: string;
|
|
122
|
+
/**
|
|
123
|
+
* Receiver storage mode for incoming `mutation:apply` entries.
|
|
124
|
+
* - `'persist'` (default) — every entry the engine accepts is persisted;
|
|
125
|
+
* access enforcement is left for query time.
|
|
126
|
+
* - `'drop'` — apply a receive-time read-access check using `selfDID` as
|
|
127
|
+
* viewer; entries the local peer cannot read are not persisted.
|
|
128
|
+
* Only consulted on the `mutation:apply` branch — control-plane variants
|
|
129
|
+
* are unaffected.
|
|
130
|
+
*/
|
|
131
|
+
storeUnreadable?: StoreUnreadableMode;
|
|
132
|
+
/**
|
|
133
|
+
* Server default access level. Required when `storeUnreadable === 'drop'`
|
|
134
|
+
* to feed the access-rule resolver. Ignored under `'persist'`.
|
|
135
|
+
*/
|
|
136
|
+
defaultAccessLevel?: DefaultAccessLevel;
|
|
137
|
+
/**
|
|
138
|
+
* Forwarding config. When omitted or `false` the forwarding pipeline is
|
|
139
|
+
* skipped entirely. When `true` or a {@link ForwardFilter}, applied
|
|
140
|
+
* entries are evaluated for re-broadcast to
|
|
141
|
+
* other MLS groups the local peer is a member of (excluding the source
|
|
142
|
+
* group), gated by access + catalog + dedup.
|
|
143
|
+
*/
|
|
144
|
+
forwarding?: ForwardingConfig;
|
|
145
|
+
/**
|
|
146
|
+
* Hub forwarding hook — required when `forwarding` is set. Called once per
|
|
147
|
+
* surviving candidate group with a single-entry `mutation:apply` broadcast
|
|
148
|
+
* carrying `senderPeerDID = selfDID`.
|
|
149
|
+
*/
|
|
150
|
+
scheduleBroadcast?: (groupID: string, message: GroupBroadcastMessage) => void | Promise<void>;
|
|
151
|
+
/** Optional logger — warns on per-entry apply failures. */
|
|
152
|
+
logger?: Logger;
|
|
57
153
|
};
|
|
58
154
|
/**
|
|
59
155
|
* Process a received broadcast message, applying it to the local database
|
|
60
156
|
* with HLC-based last-write-wins conflict resolution.
|
|
61
157
|
*
|
|
62
|
-
* Returns
|
|
158
|
+
* Returns `{ applied, affected? }`. `applied` is true when the message
|
|
159
|
+
* mutated local state. `affected` carries the row snapshot needed by
|
|
160
|
+
* downstream event mapping: after-state for create / update, before-state
|
|
161
|
+
* for delete / remove.
|
|
162
|
+
*
|
|
163
|
+
* Readback note: for create / update the post-apply row is fetched with a
|
|
164
|
+
* separate `get*` call rather than a txn-scoped read. If an unrelated
|
|
165
|
+
* concurrent write to the same row lands between apply and readback, the
|
|
166
|
+
* emitted `affected.row` reflects that later write. `BroadcastService`
|
|
167
|
+
* serializes receives, so the realistic exposure is only a same-row local
|
|
168
|
+
* mutation between apply and readback, which is acceptable for event
|
|
169
|
+
* payload fidelity.
|
|
63
170
|
*/
|
|
64
|
-
export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage): Promise<
|
|
171
|
+
export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage, groupID: string): Promise<ProcessBroadcastResult>;
|
package/lib/groups/broadcast.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{HLC as e}from"@kubun/hlc";function
|
|
1
|
+
import{HLC as e}from"@kubun/hlc";import{evaluateAndForward as a}from"../sync/forwarder.js";import{createReceiveAccessGate as l}from"../sync/receive-access-gate.js";function r(a,l){return e.compare(e.parse(a),e.parse(l))>0}export async function processBroadcast(e,t,i){let{p2pStore:c,graphStore:d}=e;switch(t.type){case"circle:create":{let e=await c.getCircle(t.circle.id);if(null!=e){if(!r(t.circle.hlc,e.hlc))return{applied:!1};await c.updateCircle(t.circle.id,{name:t.circle.name,description:t.circle.description,catalog_ids:t.circle.catalogIDs,hlc:t.circle.hlc});let a=await c.getCircle(t.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}await c.createCircle({id:t.circle.id,group_id:t.circle.groupID,name:t.circle.name,description:t.circle.description,catalog_ids:t.circle.catalogIDs,hlc:t.circle.hlc});let a=await c.getCircle(t.circle.id);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:update":{let e=await c.getCircle(t.circleID);if(null==e||!r(t.update.hlc,e.hlc))return{applied:!1};await c.updateCircle(t.circleID,{name:t.update.name,description:t.update.description,catalog_ids:t.update.catalogIDs,hlc:t.update.hlc});let a=await c.getCircle(t.circleID);return null!=a?{applied:!0,affected:{kind:"circle",row:a}}:{applied:!0}}case"circle:delete":{let e=await c.getCircle(t.circleID);if(null==e||!r(t.hlc,e.hlc))return{applied:!1};return await c.deleteCircle(t.circleID),{applied:!0,affected:{kind:"circle",row:e}}}case"member:add":{await c.addCircleMember({circle_id:t.member.circleID,member_did:t.member.memberDID,role:t.member.role,hlc:t.member.hlc});let e=await c.getCircleMember(t.member.circleID,t.member.memberDID);return null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"member:remove":{let e=await c.getCircleMember(t.circleID,t.memberDID);return await c.removeCircleMember(t.circleID,t.memberDID),null!=e?{applied:!0,affected:{kind:"member",row:e}}:{applied:!0}}case"catalog:create":{let e=await d.getCatalog(t.catalog.id);if(null!=e){if(!r(t.catalog.hlc,e.hlc))return{applied:!1};await d.updateCatalog(t.catalog.id,{name:t.catalog.name,description:t.catalog.description,filter_criteria:t.catalog.filterCriteria,hlc:t.catalog.hlc});let a=await d.getCatalog(t.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}await d.createCatalog({id:t.catalog.id,owner_did:t.catalog.ownerDID,name:t.catalog.name,description:t.catalog.description,filter_criteria:t.catalog.filterCriteria,hlc:t.catalog.hlc});let a=await d.getCatalog(t.catalog.id);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:update":{let e=await d.getCatalog(t.catalogID);if(null==e||!r(t.update.hlc,e.hlc))return{applied:!1};await d.updateCatalog(t.catalogID,{name:t.update.name,description:t.update.description,filter_criteria:t.update.filterCriteria,hlc:t.update.hlc});let a=await d.getCatalog(t.catalogID);return null!=a?{applied:!0,affected:{kind:"catalog",row:a}}:{applied:!0}}case"catalog:delete":{let e=await d.getCatalog(t.catalogID);if(null==e||!r(t.hlc,e.hlc))return{applied:!1};return await d.deleteCatalog(t.catalogID),{applied:!0,affected:{kind:"catalog",row:e}}}case"group:update":{let e=await c.getGroup(t.groupID);if(null==e||!r(t.update.hlc,e.hlc))return{applied:!1};await c.updateGroup(t.groupID,{name:t.update.name,description:t.update.description,hlc:t.update.hlc});let a=await c.getGroup(t.groupID);return null!=a?{applied:!0,affected:{kind:"group",row:a}}:{applied:!0}}case"mutation:apply":{if(t.senderPeerDID===e.selfDID)return{applied:!1};let r="drop"===(e.storeUnreadable??"persist")?l({selfDID:e.selfDID,db:{getUserModelAccessDefault:(e,a,l)=>d.getUserModelAccessDefault(e,a,l),isMemberOfAnyCircle:(e,a)=>c.isMemberOfAnyCircle(e,a),isMemberOfAnyGroup:(e,a)=>c.isMemberOfAnyGroup(e,a)},defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"}}):void 0,o=null!=e.forwarding&&!1!==e.forwarding&&null!=e.scheduleBroadcast,n=!1,p=0,u=0,s=0;for(let l of t.entries)try{let t=await e.graph.applyVerifiedMutation({token:l.mutationJWT,origin:"peer",...null!=r?{accessGate:r}:{}});if(t.dropped){p++;continue}let g=!1;try{let e=await c.recordBroadcast({docID:l.docID,version:l.version,mlsGroupID:i});g=!0,!e&&s++}catch(a){e.logger?.warn("mutation:apply recordBroadcast failed, skipping forward",{docID:l.docID,version:l.version,groupID:i,error:a})}if(n=!0,!g)continue;if(o&&null!=e.forwarding&&null!=e.scheduleBroadcast&&null!=t.document)try{let r=await a({entry:l,postState:t.document,sourceGroupID:i,selfDID:e.selfDID,p2pStore:c,graphStore:d,defaultAccessLevel:e.defaultAccessLevel??{read:"only_owner",write:"only_owner"},forwarding:e.forwarding,scheduleBroadcast:e.scheduleBroadcast,logger:e.logger});u+=r.forwarded}catch(a){e.logger?.warn("mutation:apply forwarding failed, skipping entry",{docID:l.docID,version:l.version,groupID:i,error:a})}}catch(a){e.logger?.warn("mutation:apply entry failed, skipping",{docID:l.docID,version:l.version,modelID:l.modelID,groupID:i,error:a})}let g={applied:n,dropped:p,duplicates:s};return o&&(g.forwarded=u),g}}}
|
package/lib/groups/events.d.ts
CHANGED
|
@@ -32,6 +32,17 @@ export type GroupEventMap = {
|
|
|
32
32
|
circleCatalogsChanged: CircleData & {
|
|
33
33
|
circleID: string;
|
|
34
34
|
};
|
|
35
|
+
hubBound: {
|
|
36
|
+
groupID: string;
|
|
37
|
+
hubID: string;
|
|
38
|
+
hubURL: string;
|
|
39
|
+
serverDID: string | null;
|
|
40
|
+
};
|
|
41
|
+
hubUnbound: {
|
|
42
|
+
groupID: string;
|
|
43
|
+
hubID: string;
|
|
44
|
+
hubURL: string;
|
|
45
|
+
};
|
|
35
46
|
};
|
|
36
47
|
export type GroupEventEmitter = EventEmitter<GroupEventMap>;
|
|
37
48
|
export declare function createGroupEventEmitter(): GroupEventEmitter;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { GroupHandle } from '@enkaku/group';
|
|
2
|
+
import type { StoreProvider } from '@kubun/db';
|
|
3
|
+
import { type Logger } from '@kubun/logger';
|
|
4
|
+
export type GroupHandleRegistryParams = {
|
|
5
|
+
stores: StoreProvider;
|
|
6
|
+
deviceID: string;
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
};
|
|
9
|
+
export type SeedParams = {
|
|
10
|
+
groupID: string;
|
|
11
|
+
handle: GroupHandle;
|
|
12
|
+
/**
|
|
13
|
+
* Pass the transactional `StoreProvider` (the `tx` from
|
|
14
|
+
* `stores.withTransaction(async (tx) => ...)`) so the initial MLS row
|
|
15
|
+
* write rolls back with the rest of the create/join flow on transaction
|
|
16
|
+
* abort. The cache install is deferred to `stores.onCommit` so a rollback
|
|
17
|
+
* leaves the cache empty.
|
|
18
|
+
*/
|
|
19
|
+
stores: StoreProvider;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Single canonical access point for a device's MLS `GroupHandle` instances.
|
|
23
|
+
*
|
|
24
|
+
* Owns the per-group async mutex covering encrypt/decrypt and epoch-bumping
|
|
25
|
+
* operations, and persists the (possibly mutated) handle on every successful
|
|
26
|
+
* `withHandle` callback. On callback throw, persist is skipped; the in-memory
|
|
27
|
+
* handle retains its mutated state so the caller can decide whether to retry.
|
|
28
|
+
*
|
|
29
|
+
* All five mutation paths (manager member ops, context handlers, broadcast
|
|
30
|
+
* service encrypt/decrypt, hub-tunnel sync listener, hub-tunnel sync provider)
|
|
31
|
+
* route MLS state through this registry. Spec:
|
|
32
|
+
* `docs/superpowers/specs/2026-05-09-group-handle-registry-design.md`.
|
|
33
|
+
*/
|
|
34
|
+
export declare class GroupHandleRegistry {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(params: GroupHandleRegistryParams);
|
|
37
|
+
/**
|
|
38
|
+
* Acquire the canonical handle for `groupID`, run `fn` under the per-group
|
|
39
|
+
* mutex, persist the (possibly mutated) handle on success, release.
|
|
40
|
+
* On throw, persist is skipped; in-memory handle retains its mutated state.
|
|
41
|
+
*
|
|
42
|
+
* Use this for in-place mutating ops (encrypt/decrypt) and reads. For
|
|
43
|
+
* epoch-bumping ops that produce a fresh `GroupHandle` (commitInvite,
|
|
44
|
+
* removeMember, processCommit), use `withHandleReplacing` instead.
|
|
45
|
+
*
|
|
46
|
+
* `options.stores` overrides the constructor-stored `StoreProvider` for the
|
|
47
|
+
* restore + persist DB calls. Callers running inside an engine transaction
|
|
48
|
+
* (e.g. context handlers under `mutateGraph`) MUST pass the transactional
|
|
49
|
+
* `tx` so reads + writes share the tx's connection. Without this, SQLite's
|
|
50
|
+
* write lock held by the outer tx blocks the registry's persist forever.
|
|
51
|
+
*/
|
|
52
|
+
withHandle<T>(groupID: string, fn: (handle: GroupHandle) => Promise<T>, options?: {
|
|
53
|
+
stores?: StoreProvider;
|
|
54
|
+
}): Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* Like `withHandle`, but the callback returns `{ result, updated }`.
|
|
57
|
+
* Registry replaces its cached handle with `updated` before persisting on
|
|
58
|
+
* success. Required for `@enkaku/group` epoch ops (`commitInvite`,
|
|
59
|
+
* `removeMember`, `processCommit`) which construct a fresh `GroupHandle`
|
|
60
|
+
* rather than mutating the input.
|
|
61
|
+
*
|
|
62
|
+
* `options.stores` follows the same semantics as `withHandle.options.stores`.
|
|
63
|
+
*/
|
|
64
|
+
withHandleReplacing<T>(groupID: string, fn: (handle: GroupHandle) => Promise<{
|
|
65
|
+
result: T;
|
|
66
|
+
updated: GroupHandle;
|
|
67
|
+
}>, options?: {
|
|
68
|
+
stores?: StoreProvider;
|
|
69
|
+
}): Promise<T>;
|
|
70
|
+
/**
|
|
71
|
+
* Drop the cached handle for `groupID`. Next `withHandle` re-restores from DB.
|
|
72
|
+
* Called by `leaveGroup` after the MLS state row is deleted, and by tests.
|
|
73
|
+
*/
|
|
74
|
+
invalidate(groupID: string): void;
|
|
75
|
+
/**
|
|
76
|
+
* Prime the cache with a freshly-built handle and persist the initial row
|
|
77
|
+
* via the supplied transactional `StoreProvider`. Called by `createGroup`
|
|
78
|
+
* and `joinGroup` so the row write rolls back with the rest of the
|
|
79
|
+
* create/join transaction on `withTransaction` abort. The cache install is
|
|
80
|
+
* deferred to `stores.onCommit` so a rollback leaves the cache empty.
|
|
81
|
+
*
|
|
82
|
+
* Must NOT be called concurrently with `withHandle` / `withHandleReplacing`
|
|
83
|
+
* on the same `groupID`: the `seed` row write is uncommitted until the
|
|
84
|
+
* surrounding transaction completes, so a concurrent `withHandle` whose
|
|
85
|
+
* mutex region runs before commit would `#restore` from a missing row.
|
|
86
|
+
* `createGroup` and `joinGroup` only seed for fresh groups not yet visible
|
|
87
|
+
* to other callers, so this constraint is naturally satisfied in practice.
|
|
88
|
+
*/
|
|
89
|
+
seed(params: SeedParams): Promise<void>;
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{getKubunLogger as e}from"@kubun/logger";import{getP2PStore as t}from"@kubun/store-p2p";import{createMutex as r}from"../util/mutex.js";import{restoreMLSGroupHandle as s}from"./mls-group-handle.js";import{fromMLSStateRow as a,serializeMLSGroupState as i,toMLSStateInsert as l}from"./mls-state.js";export class GroupHandleRegistry{#e;#t;#r;#s=new Map;constructor(t){this.#e=t.stores,this.#t=t.deviceID,this.#r=t.logger??e("plugin-p2p:group-handle-registry")}withHandle(e,t,r){let s=r?.stores??this.#e,a=this.#a(e);return a.mutex.run(async()=>{null==a.handle&&(a.handle=await this.#i(s,e));let r=await t(a.handle);return await this.#l(s,e,a.handle),r})}withHandleReplacing(e,t,r){let s=r?.stores??this.#e,a=this.#a(e);return a.mutex.run(async()=>{null==a.handle&&(a.handle=await this.#i(s,e));let{result:r,updated:i}=await t(a.handle);return a.handle=i,await this.#l(s,e,i),r})}invalidate(e){this.#s.delete(e)}async seed(e){let{groupID:r,handle:s,stores:a}=e,n=this.#s.get(r);if(n?.handle!=null)throw Error(`GroupHandleRegistry.seed: handle already cached for group ${r}`);let o=i(s),h=await t(a);await h.saveMLSState(l(o,r,this.#t)),a.onCommit(()=>{let e=this.#a(r);null==e.handle&&(e.handle=s)})}#a(e){let t=this.#s.get(e);return null==t&&(t={handle:null,mutex:r()},this.#s.set(e,t)),t}async #i(e,r){let i=await t(e),l=await i.getMLSState(r,this.#t);if(null==l)throw Error(`No MLS state for group ${r}`);return await s(a(l))}async #l(e,r,s){let a=i(s),n=await t(e);await n.saveMLSState(l(a,r,this.#t))}}
|
|
@@ -8,10 +8,14 @@ export type FullJoinRequestPayload = {
|
|
|
8
8
|
publicPackage: KeyPackageBundle['publicPackage'];
|
|
9
9
|
privatePackage: KeyPackageBundle['privatePackage'];
|
|
10
10
|
};
|
|
11
|
+
export type SuggestedHub = {
|
|
12
|
+
url: string;
|
|
13
|
+
serverDID?: string;
|
|
14
|
+
};
|
|
11
15
|
export type InvitePayload = {
|
|
12
16
|
groupID: string;
|
|
13
17
|
groupName: string;
|
|
14
|
-
|
|
18
|
+
suggestedHubs?: Array<SuggestedHub>;
|
|
15
19
|
invite: Invite;
|
|
16
20
|
welcomeMessage: unknown;
|
|
17
21
|
ratchetTree: unknown;
|
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import type { OwnIdentity } from '@enkaku/token';
|
|
2
1
|
import type { StoreProvider } from '@kubun/db';
|
|
3
|
-
import { HLC } from '@kubun/hlc';
|
|
4
2
|
import type { GroupData } from '../types.js';
|
|
5
3
|
import type { GroupEventEmitter } from './events.js';
|
|
6
|
-
export type
|
|
4
|
+
export type FinalizeJoinedGroupParams = {
|
|
7
5
|
stores: StoreProvider;
|
|
8
|
-
hlc: HLC;
|
|
9
6
|
emitter: GroupEventEmitter;
|
|
10
|
-
identity: OwnIdentity;
|
|
11
7
|
invite: {
|
|
12
8
|
groupID: string;
|
|
13
|
-
groupName: string;
|
|
14
|
-
hubURLs: Array<string>;
|
|
15
9
|
};
|
|
16
10
|
};
|
|
17
|
-
export declare function
|
|
11
|
+
export declare function finalizeJoinedGroup(params: FinalizeJoinedGroupParams): Promise<{
|
|
18
12
|
group: GroupData | null;
|
|
19
13
|
}>;
|
package/lib/groups/join-utils.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{getP2PStore as e}from"@kubun/store-p2p";import{toISO as t}from"../context/types.js";export async function finalizeJoinedGroup(r){let{stores:i,emitter:o,invite:n}=r,a=await e(i),p=await a.getGroup(n.groupID),u=null!=p?{id:p.id,name:p.name,description:p.description,createdBy:p.created_by,createdAt:t(p.created_at)}:null;return null!=u&&await o.emit("groupJoined",u),{group:u}}
|