@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.
Files changed (81) hide show
  1. package/lib/context/group.js +1 -1
  2. package/lib/context/hub.d.ts +4 -0
  3. package/lib/context/hub.js +1 -0
  4. package/lib/context/join.js +1 -1
  5. package/lib/context/sync.js +1 -1
  6. package/lib/context/types.d.ts +12 -0
  7. package/lib/groups/broadcast-service.d.ts +71 -17
  8. package/lib/groups/broadcast-service.js +1 -1
  9. package/lib/groups/broadcast.d.ts +112 -5
  10. package/lib/groups/broadcast.js +1 -1
  11. package/lib/groups/events.d.ts +11 -0
  12. package/lib/groups/group-handle-registry.d.ts +90 -0
  13. package/lib/groups/group-handle-registry.js +1 -0
  14. package/lib/groups/invite-payload.d.ts +5 -1
  15. package/lib/groups/join-utils.d.ts +2 -8
  16. package/lib/groups/join-utils.js +1 -1
  17. package/lib/groups/manager.d.ts +20 -14
  18. package/lib/groups/manager.js +1 -1
  19. package/lib/groups/mls-codec.d.ts +7 -0
  20. package/lib/groups/mls-codec.js +1 -0
  21. package/lib/groups/mls-encryptor.d.ts +25 -0
  22. package/lib/groups/mls-encryptor.js +1 -0
  23. package/lib/hub/connection-pool.d.ts +24 -0
  24. package/lib/hub/connection-pool.js +1 -1
  25. package/lib/hub/did-observing-transport.d.ts +64 -0
  26. package/lib/hub/did-observing-transport.js +1 -0
  27. package/lib/hub/errors.d.ts +28 -0
  28. package/lib/hub/errors.js +1 -0
  29. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  30. package/lib/hub/forward-remote-broadcast.js +1 -0
  31. package/lib/hub/group-channel.d.ts +29 -21
  32. package/lib/hub/group-channel.js +1 -1
  33. package/lib/hub/http-client.d.ts +17 -0
  34. package/lib/hub/http-client.js +1 -0
  35. package/lib/hub/hub-connection.d.ts +96 -0
  36. package/lib/hub/hub-connection.js +1 -0
  37. package/lib/hub/manager.d.ts +117 -0
  38. package/lib/hub/manager.js +1 -0
  39. package/lib/hub/receive-handler.d.ts +3 -6
  40. package/lib/hub/receive-handler.js +1 -1
  41. package/lib/hub/relay-manager.d.ts +85 -2
  42. package/lib/hub/relay-manager.js +1 -1
  43. package/lib/hub/send-handler.d.ts +12 -16
  44. package/lib/hub/send-handler.js +1 -1
  45. package/lib/hub/tunnel-inbox.d.ts +20 -0
  46. package/lib/hub/tunnel-inbox.js +1 -0
  47. package/lib/hub/wait-for-gate.d.ts +14 -0
  48. package/lib/hub/wait-for-gate.js +1 -0
  49. package/lib/hub/wiring.d.ts +73 -0
  50. package/lib/hub/wiring.js +1 -0
  51. package/lib/index.d.ts +102 -6
  52. package/lib/index.js +1 -1
  53. package/lib/schema.js +68 -20
  54. package/lib/sync/broadcast-queue.d.ts +59 -0
  55. package/lib/sync/broadcast-queue.js +1 -0
  56. package/lib/sync/broadcast-sender.d.ts +52 -0
  57. package/lib/sync/broadcast-sender.js +1 -0
  58. package/lib/sync/catalog-match.d.ts +13 -0
  59. package/lib/sync/catalog-match.js +1 -0
  60. package/lib/sync/forwarder.d.ts +96 -0
  61. package/lib/sync/forwarder.js +1 -0
  62. package/lib/sync/handlers.d.ts +15 -0
  63. package/lib/sync/handlers.js +1 -1
  64. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  65. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  66. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  67. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  68. package/lib/sync/merkle-apply.d.ts +41 -0
  69. package/lib/sync/merkle-apply.js +1 -1
  70. package/lib/sync/merkle-channel.d.ts +15 -0
  71. package/lib/sync/merkle-channel.js +1 -1
  72. package/lib/sync/receive-access-gate.d.ts +36 -0
  73. package/lib/sync/receive-access-gate.js +1 -0
  74. package/lib/sync/scope-resolver.d.ts +32 -0
  75. package/lib/sync/scope-resolver.js +1 -0
  76. package/lib/sync/sync-manager.d.ts +10 -0
  77. package/lib/sync/sync-manager.js +1 -1
  78. package/lib/types.d.ts +95 -26
  79. package/lib/util/mutex.d.ts +4 -0
  80. package/lib/util/mutex.js +1 -0
  81. package/package.json +40 -37
@@ -1,22 +1,29 @@
1
- import { type GroupHandle, type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
1
+ import { type GroupPermission, type Invite, type KeyPackageBundle } from '@enkaku/group';
2
2
  import type { Identity, OwnIdentity } from '@enkaku/token';
3
3
  import type { StoreProvider } from '@kubun/db';
4
4
  import type { GroupBroadcastMessage } from './broadcast.js';
5
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
6
+ import type { SuggestedHub } from './invite-payload.js';
5
7
  export type GroupManagerParams = {
6
8
  identity: Identity;
7
9
  getRandomID: () => string;
10
+ /**
11
+ * Single canonical access point for the device's MLS `GroupHandle` instances.
12
+ * Required: all member ops + createGroup/joinGroup route MLS state through
13
+ * the registry's per-group mutex with write-through persist.
14
+ */
15
+ registry: GroupHandleRegistry;
8
16
  };
9
17
  export type CreateGroupParams = {
10
18
  stores: StoreProvider;
11
19
  identity: OwnIdentity;
12
20
  name: string;
13
21
  description?: string;
14
- hubURLs?: Array<string>;
22
+ hubs?: Array<SuggestedHub>;
15
23
  createdBy?: string;
16
24
  };
17
25
  export type CreateGroupResult = {
18
26
  groupID: string;
19
- groupHandle: GroupHandle;
20
27
  };
21
28
  export type UpdateGroupParams = {
22
29
  stores: StoreProvider;
@@ -24,7 +31,6 @@ export type UpdateGroupParams = {
24
31
  update: {
25
32
  name?: string;
26
33
  description?: string;
27
- hubURLs?: Array<string>;
28
34
  };
29
35
  };
30
36
  export type UpdateGroupResult = {
@@ -34,7 +40,6 @@ export type InviteToGroupParams = {
34
40
  stores: StoreProvider;
35
41
  groupID: string;
36
42
  identity: OwnIdentity;
37
- groupHandle: GroupHandle;
38
43
  recipientDID: string;
39
44
  recipientKeyPackage: KeyPackageBundle['publicPackage'];
40
45
  permission: GroupPermission;
@@ -43,42 +48,43 @@ export type InviteToGroupResult = {
43
48
  invite: Invite;
44
49
  welcomeMessage: unknown;
45
50
  commitMessage: unknown;
46
- updatedGroupHandle: GroupHandle;
51
+ /**
52
+ * Ratchet tree from the post-commit handle. Required by `joinGroup`
53
+ * callers that build an invite payload — they no longer hold a
54
+ * `GroupHandle` reference, so the manager surfaces the tree directly.
55
+ * Typed `unknown` to match `@enkaku/group`'s `ProcessWelcomeParams.ratchetTree`,
56
+ * which is opaque on that side too.
57
+ */
58
+ ratchetTree: unknown;
47
59
  };
48
60
  export type JoinGroupParams = {
49
61
  stores: StoreProvider;
50
62
  identity: OwnIdentity;
51
63
  groupID: string;
52
64
  groupName: string;
53
- hubURLs: Array<string>;
65
+ hubs?: Array<SuggestedHub>;
54
66
  invite: Invite;
55
67
  keyPackageBundle: KeyPackageBundle;
56
68
  welcomeMessage: unknown;
57
69
  ratchetTree: unknown;
58
70
  };
59
- export type JoinGroupResult = {
60
- groupHandle: GroupHandle;
61
- };
71
+ export type JoinGroupResult = undefined;
62
72
  export type RemoveMemberParams = {
63
73
  stores: StoreProvider;
64
74
  groupID: string;
65
- groupHandle: GroupHandle;
66
75
  leafIndex: number;
67
76
  memberDID: string;
68
77
  };
69
78
  export type RemoveMemberResult = {
70
79
  commitMessage: unknown;
71
- updatedGroupHandle: GroupHandle;
72
80
  };
73
81
  export type RemoveGroupMemberParams = {
74
82
  stores: StoreProvider;
75
83
  groupID: string;
76
- groupHandle: GroupHandle;
77
84
  memberDID: string;
78
85
  };
79
86
  export type RemoveGroupMemberResult = {
80
87
  commitMessage: unknown;
81
- updatedGroupHandle: GroupHandle;
82
88
  };
83
89
  export type LeaveGroupParams = {
84
90
  stores: StoreProvider;
@@ -1 +1 @@
1
- import{commitInvite as e,createGroup as t,createInvite as r,processWelcome as a,removeMember as i}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as c}from"@kubun/store-p2p";import{serializeMLSGroupState as d}from"./mls-state.js";export class GroupManager{#e;#t;#r;constructor(e){this.#e=e.identity.id,this.#t=e.getRandomID,this.#r=new o({nodeID:e.identity.id})}async #a(e,t,r){let a=d(r),i=await c(e);await i.saveMLSState({group_id:t,device_id:this.#e,mls_state:a.mlsState,credential:a.credential,epoch:a.epoch,root_capability:a.rootCapability})}async createGroup(e){let r=this.#t(),{group:a}=await t(e.identity,r),i=await c(e.stores);return await i.createGroup({id:r,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hub_urls:e.hubURLs??[],hlc:o.serialize(this.#r.now())}),await this.#a(e.stores,r,a),await i.addGroupMember({group_id:r,member_did:e.identity.id,role:"admin"}),{groupID:r,groupHandle:a}}async updateGroup(e){let t=o.serialize(this.#r.now()),r=await c(e.stores);return await r.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hub_urls:e.update.hubURLs,hlc:t}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:t}}}}async inviteToGroup(t){let{invite:a}=await r({group:t.groupHandle,identity:t.identity,recipientDID:t.recipientDID,permission:t.permission}),{commitMessage:i,welcomeMessage:o,newGroup:s}=await e(t.groupHandle,t.recipientKeyPackage);await this.#a(t.stores,t.groupID,s);let d=await c(t.stores);return await d.addGroupMember({group_id:t.groupID,member_did:t.recipientDID,role:"admin"===t.permission?"admin":"member"}),{invite:a,welcomeMessage:o,commitMessage:i,updatedGroupHandle:s}}async joinGroup(e){let{group:t}=await a({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree});await this.#a(e.stores,e.groupID,t);let r=await c(e.stores);return null==await r.getGroup(e.groupID)&&await r.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hub_urls:e.hubURLs,hlc:o.serialize(this.#r.now())}),await r.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member"}),{groupHandle:t}}async removeMember(e){let{commitMessage:t,newGroup:r}=await i(e.groupHandle,e.leafIndex);await this.#a(e.stores,e.groupID,r);let a=await c(e.stores);return await a.removeGroupMember(e.groupID,e.memberDID),{commitMessage:t,updatedGroupHandle:r}}async removeGroupMember(e){let t=e.groupHandle.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:r,newGroup:a}=await i(e.groupHandle,t);await this.#a(e.stores,e.groupID,a);let o=await c(e.stores);return await o.removeGroupMember(e.groupID,e.memberDID),{commitMessage:r,updatedGroupHandle:a}}async leaveGroup(e){let t=await c(e.stores);await t.deleteMLSState(e.groupID,this.#e),await t.removeGroupMember(e.groupID,e.identity.id)}async loadGroup(e,t){let r=await c(e),a=await r.getMLSState(t,this.#e);if(null!=a)return{epoch:a.epoch,credential:a.credential}}async createCircle(e){let t=this.#t(),r=o.serialize(this.#r.now()),a=e.catalogIDs??[],i=await c(e.stores);return await i.createCircle({id:t,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:a,hlc:r}),{circleID:t,broadcast:{type:"circle:create",circle:{id:t,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:a,hlc:r}}}}async updateCircle(e){let t=o.serialize(this.#r.now()),r=[],a=await c(e.stores);if(null!=e.update.catalogIDs){let t=await a.getCircle(e.circleID),i=new Set(null!=t?t.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!i.has(e)),c=await s(e.stores);for(let e of(await Promise.all(o.map(e=>c.getCatalog(e)))))null!=e&&r.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await a.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:t});let i={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:t}};return{broadcast:i,broadcasts:[i,...r]}}async deleteCircle(e){let t=o.serialize(this.#r.now()),r=await c(e.stores);return await r.deleteCircle(e.circleID),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:t}}}async addCircleMember(e){let t=await c(e.stores);if(!await t.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let r=o.serialize(this.#r.now());return await t.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:r}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:r}}}}async removeCircleMember(e){let t=o.serialize(this.#r.now()),r=await c(e.stores);return await r.removeCircleMember(e.circleID,e.memberDID),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:t}}}}
1
+ import{commitInvite as e,createGroup as r,createInvite as t,processWelcome as i,removeMember as a}from"@enkaku/group";import{HLC as o}from"@kubun/hlc";import{getGraphStore as s}from"@kubun/store-graph";import{getP2PStore as c}from"@kubun/store-p2p";import{bindHubToGroup as l,upsertHub as n}from"../hub/manager.js";export class GroupManager{#e;#r;#t;#i;constructor(e){this.#e=e.identity.id,this.#r=e.getRandomID,this.#t=new o({nodeID:e.identity.id}),this.#i=e.registry}async createGroup(e){let t=this.#r(),{group:i}=await r(e.identity,t),a=e.hubs??[];return await e.stores.withTransaction(async r=>{let s=await c(r);for(let c of(await s.createGroup({id:t,name:e.name,description:e.description??"",created_by:e.createdBy??e.identity.id,hlc:o.serialize(this.#t.now())}),await this.#i.seed({groupID:t,handle:i,stores:r}),await s.addGroupMember({group_id:t,member_did:e.identity.id,role:"admin"}),a)){let e=await n({stores:r,url:c.url,serverDID:c.serverDID??null});await l({stores:r,hubID:e.id,groupID:t})}}),{groupID:t}}async updateGroup(e){let r=o.serialize(this.#t.now()),t=await c(e.stores);return await t.updateGroup(e.groupID,{name:e.update.name,description:e.update.description,hlc:r}),{broadcast:{type:"group:update",groupID:e.groupID,update:{...e.update,hlc:r}}}}async inviteToGroup(r){let i=await this.#i.withHandleReplacing(r.groupID,async i=>{let{invite:a}=await t({group:i,identity:r.identity,recipientDID:r.recipientDID,permission:r.permission}),{commitMessage:o,welcomeMessage:s,newGroup:c}=await e(i,r.recipientKeyPackage);return{result:{invite:a,commitMessage:o,welcomeMessage:s,ratchetTree:c.state.ratchetTree},updated:c}},{stores:r.stores}),a=await c(r.stores);return await a.addGroupMember({group_id:r.groupID,member_did:r.recipientDID,role:"admin"===r.permission?"admin":"member"}),i}async joinGroup(e){let{group:r}=await i({identity:e.identity,invite:e.invite,welcome:e.welcomeMessage,keyPackageBundle:e.keyPackageBundle,ratchetTree:e.ratchetTree}),t=e.hubs??[];await e.stores.withTransaction(async i=>{await this.#i.seed({groupID:e.groupID,handle:r,stores:i});let a=await c(i);for(let r of(null==await a.getGroup(e.groupID)&&await a.createGroup({id:e.groupID,name:e.groupName,description:"",created_by:e.identity.id,hlc:o.serialize(this.#t.now())}),await a.addGroupMember({group_id:e.groupID,member_did:e.identity.id,role:"member"}),t)){let t=await n({stores:i,url:r.url,serverDID:r.serverDID??null});await l({stores:i,hubID:t.id,groupID:e.groupID})}})}async removeMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let{commitMessage:t,newGroup:i}=await a(r,e.leafIndex);return{result:{commitMessage:t},updated:i}},{stores:e.stores}),t=await c(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID),{commitMessage:r}}async removeGroupMember(e){let{commitMessage:r}=await this.#i.withHandleReplacing(e.groupID,async r=>{let t=r.findMemberLeafIndex(e.memberDID);if(null==t)throw Error(`Member ${e.memberDID} not found in MLS group`);let{commitMessage:i,newGroup:o}=await a(r,t);return{result:{commitMessage:i},updated:o}},{stores:e.stores}),t=await c(e.stores);return await t.removeGroupMember(e.groupID,e.memberDID),{commitMessage:r}}async leaveGroup(e){let r=await c(e.stores);await r.deleteMLSState(e.groupID,this.#e),await r.removeGroupMember(e.groupID,e.identity.id),this.#i.invalidate(e.groupID)}async loadGroup(e,r){let t=await c(e),i=await t.getMLSState(r,this.#e);if(null!=i)return{epoch:i.epoch,credential:i.credential}}async createCircle(e){let r=this.#r(),t=o.serialize(this.#t.now()),i=e.catalogIDs??[],a=await c(e.stores);return await a.createCircle({id:r,group_id:e.groupID,name:e.name,description:e.description??"",catalog_ids:i,hlc:t}),{circleID:r,broadcast:{type:"circle:create",circle:{id:r,groupID:e.groupID,name:e.name,description:e.description??"",catalogIDs:i,hlc:t}}}}async updateCircle(e){let r=o.serialize(this.#t.now()),t=[],i=await c(e.stores);if(null!=e.update.catalogIDs){let r=await i.getCircle(e.circleID),a=new Set(null!=r?r.catalog_ids:[]),o=e.update.catalogIDs.filter(e=>!a.has(e)),c=await s(e.stores);for(let e of(await Promise.all(o.map(e=>c.getCatalog(e)))))null!=e&&t.push({type:"catalog:create",catalog:{id:e.id,ownerDID:e.owner_did,name:e.name,description:e.description,filterCriteria:e.filter_criteria,hlc:e.hlc}})}await i.updateCircle(e.circleID,{name:e.update.name,description:e.update.description,catalog_ids:e.update.catalogIDs,hlc:r});let a={type:"circle:update",circleID:e.circleID,update:{...e.update,hlc:r}};return{broadcast:a,broadcasts:[a,...t]}}async deleteCircle(e){let r=o.serialize(this.#t.now()),t=await c(e.stores);return await t.deleteCircle(e.circleID),{broadcast:{type:"circle:delete",circleID:e.circleID,hlc:r}}}async addCircleMember(e){let r=await c(e.stores);if(!await r.isGroupMember(e.groupID,e.memberDID))throw Error(`${e.memberDID} is not a member of group ${e.groupID}`);let t=o.serialize(this.#t.now());return await r.addCircleMember({circle_id:e.circleID,member_did:e.memberDID,role:e.role,hlc:t}),{broadcast:{type:"member:add",member:{circleID:e.circleID,memberDID:e.memberDID,role:e.role,hlc:t}}}}async removeCircleMember(e){let r=o.serialize(this.#t.now()),t=await c(e.stores);return await t.removeCircleMember(e.circleID,e.memberDID),{broadcast:{type:"member:remove",circleID:e.circleID,memberDID:e.memberDID,hlc:r}}}}
@@ -0,0 +1,7 @@
1
+ import type { GroupHandle } from '@enkaku/group';
2
+ export type MLSEncryptFramedResult = {
3
+ framed: Uint8Array;
4
+ consumed: Array<Uint8Array>;
5
+ };
6
+ export declare function mlsEncryptFramed(handle: GroupHandle, plaintext: Uint8Array): Promise<MLSEncryptFramedResult>;
7
+ export declare function mlsDecryptFramed(handle: GroupHandle, framed: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1 @@
1
+ import{replacer as e,reviver as r}from"./mls-json.js";let t=new TextEncoder,n=new TextDecoder;export async function mlsEncryptFramed(r,n){let{message:c,consumed:o}=await r.encrypt(n);return{framed:t.encode(JSON.stringify(c,e)),consumed:o}}export async function mlsDecryptFramed(e,t){let c=JSON.parse(n.decode(t),r);return await e.decrypt(c)}
@@ -0,0 +1,25 @@
1
+ import type { Encryptor } from '@enkaku/hub-tunnel';
2
+ import type { GroupHandleRegistry } from './group-handle-registry.js';
3
+ export type MLSEncryptorParams = {
4
+ registry: GroupHandleRegistry;
5
+ groupID: string;
6
+ };
7
+ /**
8
+ * Adapts the `GroupHandleRegistry` to the `@enkaku/hub-tunnel` `Encryptor`
9
+ * interface. The registry's per-group mutex serializes encrypt + decrypt
10
+ * across all encryptor instances bound to the same `(registry, groupID)`.
11
+ *
12
+ * Wedge-protection note: encrypt persists the advanced ratchet immediately
13
+ * on callback success — BEFORE the wire send happens inside the hub-tunnel
14
+ * transport. If the wire send fails, the persisted state is already at
15
+ * generation N+1; the next encrypt advances to N+2 and generation N is
16
+ * "burned" without wire egress. This is the documented forward-secrecy
17
+ * trade-off for tunnel sessions; the broadcast path (`BroadcastService.
18
+ * prepareSend`) inverts the order by pulling send into the registry callback.
19
+ */
20
+ export declare class MLSEncryptor implements Encryptor {
21
+ #private;
22
+ constructor(params: MLSEncryptorParams);
23
+ encrypt(plaintext: Uint8Array): Promise<Uint8Array>;
24
+ decrypt(ciphertext: Uint8Array): Promise<Uint8Array>;
25
+ }
@@ -0,0 +1 @@
1
+ import{mlsDecryptFramed as r,mlsEncryptFramed as t}from"./mls-codec.js";export class MLSEncryptor{#r;#t;constructor(r){this.#r=r.registry,this.#t=r.groupID}encrypt(r){return this.#r.withHandle(this.#t,async s=>{let{framed:e}=await t(s,r);return e})}decrypt(t){return this.#r.withHandle(this.#t,s=>r(s,t))}}
@@ -6,9 +6,33 @@ export type HubClientRef = {
6
6
  client: Client<HubProtocol>;
7
7
  refID: string;
8
8
  };
9
+ /**
10
+ * Fired once per freshly-spawned hub client when its first signed response
11
+ * arrives. The pool binds the hub URL into the call site; consumers (hub
12
+ * manager `captureServerDID`) use the URL to look up the local hub row and
13
+ * apply the conflict matrix. Errors are caught and logged — capture never
14
+ * blocks message forwarding, even on `HubServerDIDConflictError`.
15
+ */
16
+ export type OnServerDIDObserved = (params: {
17
+ hubURL: string;
18
+ serverDID: string;
19
+ }) => void | Promise<void>;
20
+ /**
21
+ * Resolves the locally-pinned `server_did` for a hub URL at spawn time. When
22
+ * the hub row for this URL exists and has `server_did` set, the pool passes
23
+ * it as `expectedServerDID` to the client factory so the wrapper enforces
24
+ * the pin. Returning `null` falls back to TOFU.
25
+ *
26
+ * Resolution happens fresh on every spawn so `updateHub({ serverDID: null })`
27
+ * re-arms TOFU automatically: the next disposed/evicted client triggers a
28
+ * fresh resolve.
29
+ */
30
+ export type ResolvePinnedServerDID = (hubURL: string) => Promise<string | null>;
9
31
  export type HubConnectionPoolParams = {
10
32
  createHubClient: CreateHubClient;
11
33
  logger?: Logger;
34
+ onServerDIDObserved?: OnServerDIDObserved;
35
+ resolvePinnedDID?: ResolvePinnedServerDID;
12
36
  };
13
37
  export declare class HubConnectionPool {
14
38
  #private;
@@ -1 +1 @@
1
- export class HubConnectionPool{#e;#t;#i=new Map;#s=0;constructor(e){this.#e=e.createHubClient,this.#t=e.logger}async acquire(e){let t=this.#i.get(e);if(null!=t&&null!=t.disposing&&(await t.disposing,this.#i.get(e)===t&&this.#i.delete(e),t=void 0),null==t){let i=await this.#e(e),s={client:i,refs:new Set};t=s,this.#i.set(e,s),i.disposed.then(()=>{this.#i.get(e)===s&&(this.#t?.debug("hub client disposed externally, evicting pool entry",{hubURL:e,outstandingRefs:[...s.refs]}),this.#i.delete(e))}).catch(()=>{})}let i=String(++this.#s);return t.refs.add(i),{client:t.client,refID:i}}async release(e,t){let i=this.#i.get(e);if(null==i)return void this.#t?.debug("HubConnectionPool.release: unknown hubURL",{hubURL:e,refID:t});if(!i.refs.has(t))return void this.#t?.debug("HubConnectionPool.release: unknown refID",{hubURL:e,refID:t});if(i.refs.delete(t),i.refs.size>0)return;let s=i.client.dispose();i.disposing=s;try{await s}finally{this.#i.get(e)===i&&this.#i.delete(e)}}async disposeAll(){let e=Array.from(this.#i.entries());await Promise.all(e.map(async([e,t])=>{t.refs.size>0&&this.#t?.warn("Disposing hub client with outstanding refs",{hubURL:e,refs:Array.from(t.refs)}),await t.client.dispose()})),this.#i.clear()}}
1
+ export class HubConnectionPool{#e;#t;#i;#r;#s=new Map;#n=0;constructor(e){this.#e=e.createHubClient,this.#t=e.logger,this.#i=e.onServerDIDObserved,this.#r=e.resolvePinnedDID}async acquire(e){let t=this.#s.get(e);if(null!=t&&null!=t.disposing&&(await t.disposing,this.#s.get(e)===t&&this.#s.delete(e),t=void 0),null==t){let i,r=this.#i;if(null!=this.#r)try{let t=await this.#r(e);null!=t&&(i=t)}catch(t){this.#t?.warn("failed to resolve pinned server DID, falling back to TOFU",{hubURL:e,error:t})}let s=await this.#e(e,{onServerDID:null==r?void 0:async t=>{try{await r({hubURL:e,serverDID:t})}catch(i){this.#t?.warn("hub server DID capture failed",{hubURL:e,serverDID:t,error:i})}},expectedServerDID:i,onMismatch:null==i?void 0:e=>{this.#t?.warn("hub server DID mismatch — pinning enforcement triggered",{hubURL:e.hubURL,expectedServerDID:e.expectedServerDID,observedServerDID:e.observedServerDID})}}),n={client:s,refs:new Set};t=n,this.#s.set(e,n),s.disposed.then(()=>{this.#s.get(e)===n&&(this.#t?.debug("hub client disposed externally, evicting pool entry",{hubURL:e,outstandingRefs:[...n.refs]}),this.#s.delete(e))}).catch(()=>{})}let i=String(++this.#n);return t.refs.add(i),{client:t.client,refID:i}}async release(e,t){let i=this.#s.get(e);if(null==i)return void this.#t?.debug("HubConnectionPool.release: unknown hubURL",{hubURL:e,refID:t});if(!i.refs.has(t))return void this.#t?.debug("HubConnectionPool.release: unknown refID",{hubURL:e,refID:t});if(i.refs.delete(t),i.refs.size>0)return;let r=i.client.dispose();i.disposing=r;try{await r}finally{this.#s.get(e)===i&&this.#s.delete(e)}}async disposeAll(){let e=Array.from(this.#s.entries());await Promise.all(e.map(async([e,t])=>{t.refs.size>0&&this.#t?.warn("Disposing hub client with outstanding refs",{hubURL:e,refs:Array.from(t.refs)}),await t.client.dispose()})),this.#s.clear()}}
@@ -0,0 +1,64 @@
1
+ import { Disposer } from '@enkaku/async';
2
+ import type { EventEmitter } from '@enkaku/event';
3
+ import type { AnyClientMessageOf, AnyServerMessageOf, ClientTransportOf, ProtocolDefinition } from '@enkaku/protocol';
4
+ import type { TransportEvents } from '@enkaku/transport';
5
+ export type ServerDIDObserver = (serverDID: string) => void | Promise<void>;
6
+ export type ServerDIDMismatchObserver = (params: {
7
+ hubURL: string;
8
+ expectedServerDID: string;
9
+ observedServerDID: string;
10
+ }) => void;
11
+ export type DIDObservingTransportParams<Protocol extends ProtocolDefinition> = {
12
+ inner: ClientTransportOf<Protocol>;
13
+ /**
14
+ * Fired on the first message bearing `payload.iss` when no `expectedServerDID`
15
+ * is set (TOFU capture). When `expectedServerDID` is set and matches, the
16
+ * callback is NOT fired — the pin is already authoritative.
17
+ */
18
+ onServerDID: ServerDIDObserver;
19
+ /**
20
+ * Pinned hub server DID. When set, the wrapper enforces it: a non-matching
21
+ * first observed `iss` triggers a permanent failed state — `read()` rejects
22
+ * with `HubServerDIDMismatchError`, the offending message is NOT delivered
23
+ * to the inner Client, and every subsequent `read()` re-raises the same
24
+ * error. When unset, the wrapper falls back to TOFU capture.
25
+ */
26
+ expectedServerDID?: string;
27
+ /** Hub URL — only used to populate `HubServerDIDMismatchError` for callers. */
28
+ hubURL?: string;
29
+ /**
30
+ * Optional side-channel notification fired on mismatch. Lets the pool log /
31
+ * evict without having to inspect the rejected `read()` cause. Inner
32
+ * transport disposal is the pool's job, not the wrapper's.
33
+ */
34
+ onMismatch?: ServerDIDMismatchObserver;
35
+ };
36
+ /**
37
+ * Wraps a `ClientTransportOf<Protocol>` to enforce hub-server-DID
38
+ * TOFU + pinned-mismatch semantics on the receive side.
39
+ *
40
+ * - `expectedServerDID == null`: TOFU mode. First message bearing
41
+ * `payload.iss` fires `onServerDID(iss)` exactly once; subsequent messages
42
+ * never re-fire, even with a differing `iss`. Every message is forwarded
43
+ * to the inner Client untouched.
44
+ * - `expectedServerDID != null` and the first observed `iss` matches: the
45
+ * pin is already authoritative; `onServerDID` is NOT fired and forwarding
46
+ * continues normally.
47
+ * - `expectedServerDID != null` and the first observed `iss` differs: the
48
+ * wrapper enters a permanent failed state (`HubServerDIDMismatchError`).
49
+ * The current `read()` call AND every subsequent `read()` reject with the
50
+ * error; the offending message is NOT delivered. Inner-transport lifecycle
51
+ * stays the pool's responsibility — the wrapper does not dispose its inner.
52
+ *
53
+ * Extends `Disposer` and matches the `ClientTransportOf<Protocol>` shape
54
+ * structurally so it can be passed straight to `Client<Protocol>`.
55
+ */
56
+ export declare class DIDObservingTransport<Protocol extends ProtocolDefinition> extends Disposer {
57
+ #private;
58
+ constructor(params: DIDObservingTransportParams<Protocol>);
59
+ get events(): EventEmitter<TransportEvents>;
60
+ getWritable(): WritableStream<AnyClientMessageOf<Protocol>>;
61
+ read(): Promise<ReadableStreamReadResult<AnyServerMessageOf<Protocol>>>;
62
+ write(value: AnyClientMessageOf<Protocol>): Promise<void>;
63
+ [Symbol.asyncIterator](): AsyncIterator<AnyServerMessageOf<Protocol>, AnyServerMessageOf<Protocol> | null>;
64
+ }
@@ -0,0 +1 @@
1
+ import{Disposer as e}from"@enkaku/async";import{HubServerDIDMismatchError as r}from"./errors.js";export class DIDObservingTransport extends e{#e;#r;#t;#n;#i;#s=!1;#l=null;constructor(e){super({signal:e.inner.signal,dispose:async r=>{await e.inner.dispose(r)}}),this.#e=e.inner,this.#r=e.onServerDID,this.#t=e.expectedServerDID,this.#n=e.hubURL,this.#i=e.onMismatch}get events(){return this.#e.events}getWritable(){return this.#e.getWritable()}async read(){if(null!=this.#l)throw this.#l;let e=await this.#e.read(),r=this.#a(e);if(null!=r)throw r;return e}async write(e){await this.#e.write(e)}[Symbol.asyncIterator](){return{next:async()=>{let e=await this.read();return e.done?{done:!0,value:e.value??null}:{done:!1,value:e.value}}}}#a(e){if(this.#s||e.done||null==e.value)return null;let t=e.value.payload,n=t?.iss;if("string"!=typeof n||0===n.length)return null;if(this.#s=!0,null!=this.#t){if(n===this.#t)return null;let e=new r({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n});if(this.#l=e,null!=this.#i)try{this.#i({hubURL:this.#n??"",expectedServerDID:this.#t,observedServerDID:n})}catch{}return e}return Promise.resolve().then(()=>this.#r(n)).catch(()=>{}),null}}
@@ -0,0 +1,28 @@
1
+ export declare class ReconnectingError extends Error {
2
+ readonly hubURL: string;
3
+ readonly attempt: number;
4
+ constructor(hubURL: string, attempt: number);
5
+ }
6
+ /**
7
+ * Thrown by `DIDObservingTransport.read()` when the first observed `payload.iss`
8
+ * does not match the pinned `expectedServerDID`. The transport enters a
9
+ * permanent failed state: no message bearing the offending `iss` is delivered
10
+ * to the inner Client, and every subsequent `read()` rejects with the same
11
+ * error. In-flight Enkaku RPCs see this error as the `cause` of the Client's
12
+ * "Transport read failed" abort.
13
+ *
14
+ * Distinct from `HubServerDIDConflictError` (raised by manager-level
15
+ * `captureServerDID` when the pin row already differs from the observed DID
16
+ * on the TOFU capture path); this class is raised client-side, before any
17
+ * message is delivered, when a row was already pinned at connect time.
18
+ */
19
+ export declare class HubServerDIDMismatchError extends Error {
20
+ readonly hubURL: string;
21
+ readonly expectedServerDID: string;
22
+ readonly observedServerDID: string;
23
+ constructor(params: {
24
+ hubURL: string;
25
+ expectedServerDID: string;
26
+ observedServerDID: string;
27
+ });
28
+ }
@@ -0,0 +1 @@
1
+ export class ReconnectingError extends Error{hubURL;attempt;constructor(e,r){super(`broadcast wait timed out: hub ${e} reconnecting (attempt ${r})`),this.name="ReconnectingError",this.hubURL=e,this.attempt=r}}export class HubServerDIDMismatchError extends Error{hubURL;expectedServerDID;observedServerDID;constructor(e){super(`hub at ${e.hubURL} signed responses with ${e.observedServerDID}; pinned to ${e.expectedServerDID}`),this.name="HubServerDIDMismatchError",this.hubURL=e.hubURL,this.expectedServerDID=e.expectedServerDID,this.observedServerDID=e.observedServerDID}}
@@ -0,0 +1,15 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { BroadcastEvent } from '../groups/broadcast-service.js';
3
+ import type { GroupEventEmitter } from '../groups/events.js';
4
+ /**
5
+ * Map a `BroadcastEvent` from `BroadcastService` into the matching
6
+ * `GroupEventEmitter` event.
7
+ *
8
+ * - Events with `applied === false` are ignored (no state change).
9
+ * - `catalog:*` messages are currently a no-op. Catalog send-side broadcasts
10
+ * and event propagation are deferred to a future task (see design spec).
11
+ * - Payloads are built from `event.affected.row`, which `processBroadcast`
12
+ * captures at the appropriate moment (after-state for create / update,
13
+ * before-state for delete / remove).
14
+ */
15
+ export declare function forwardRemoteBroadcast(emitter: GroupEventEmitter, event: BroadcastEvent, logger?: Logger): Promise<void>;
@@ -0,0 +1 @@
1
+ import{toISO as e}from"../context/types.js";export async function forwardRemoteBroadcast(r,t,a){if(!t.applied)return;let{message:i,affected:c}=t;if(null==c)return void a?.warn("remote broadcast applied but affected row missing",{type:i.type});switch(i.type){case"circle:create":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleCreated",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"circle:update":{if("circle"!==c.kind)return;let{row:t}=c,a=null!=i.update.catalogIDs?"circleCatalogsChanged":"circleDataChanged";await r.emit(a,{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at),circleID:t.id});return}case"circle:delete":{if("circle"!==c.kind)return;let{row:t}=c;await r.emit("circleDeleted",{id:t.id,groupID:t.group_id,name:t.name,description:t.description,createdAt:e(t.created_at)});return}case"member:add":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberAdded",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"member:remove":{if("member"!==c.kind)return;let{row:t}=c;await r.emit("circleMemberRemoved",{circleID:t.circle_id,memberDID:t.member_did,role:t.role,createdAt:e(t.created_at)});return}case"group:update":{if("group"!==c.kind)return;let{row:t}=c;await r.emit("groupDataChanged",{id:t.id,name:t.name,description:t.description,createdBy:t.created_by,createdAt:e(t.created_at),groupID:t.id});return}case"catalog:create":case"catalog:update":case"catalog:delete":return void a?.debug("catalog broadcast ignored (not yet wired)",{type:i.type})}}
@@ -1,26 +1,9 @@
1
+ import { Disposer } from '@enkaku/async';
1
2
  import type { Logger } from '@kubun/logger';
2
3
  import type { P2PStoreAPI } from '@kubun/store-p2p';
3
4
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
4
5
  import type { BroadcastService } from '../groups/broadcast-service.js';
5
- import type { HubConnectionPool } from './connection-pool.js';
6
- export type GroupChannelParams = {
7
- groupID: string;
8
- hubURL: string;
9
- deviceID: string;
10
- pool: HubConnectionPool;
11
- broadcastService: BroadcastService;
12
- p2pStore: P2PStoreAPI;
13
- logger?: Logger;
14
- ackFlushMs: number;
15
- ackFlushMax: number;
16
- backoffBaseMs: number;
17
- backoffMaxMs: number;
18
- backoffJitter: number;
19
- };
20
- export type Mutex = {
21
- run<T>(fn: () => Promise<T>): Promise<T>;
22
- };
23
- export declare function createMutex(): Mutex;
6
+ import type { HubConnection } from './hub-connection.js';
24
7
  export type AckBatchParams = {
25
8
  flushMs: number;
26
9
  flushMax: number;
@@ -42,10 +25,35 @@ export declare class AckBatch {
42
25
  }
43
26
  export declare function computeBackoff(attempt: number, base: number, max: number, jitter: number): number;
44
27
  export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
45
- export declare class GroupChannel {
28
+ export type GroupChannelParams = {
29
+ groupID: string;
30
+ deviceID: string;
31
+ hubConnection: HubConnection;
32
+ broadcastService: BroadcastService;
33
+ p2pStore: P2PStoreAPI;
34
+ logger?: Logger;
35
+ };
36
+ /**
37
+ * Per-group handle: thin wrapper around a shared `HubConnection`.
38
+ *
39
+ * `open()` subscribes to the connection (issuing `hub/group/join` once),
40
+ * `close()` unsubscribes (drains the per-group mutex then issues
41
+ * `hub/group/leave`), and `broadcast()` runs the MLS encrypt+send pipeline
42
+ * inside `HubConnection.runInGroup()` so the per-group mutex serializes send
43
+ * and receive on the same MLS state.
44
+ *
45
+ * All transport state (refID, ack batching, receive loop, reconnect/backoff)
46
+ * lives on `HubConnection` and is shared across every channel for that
47
+ * `hubURL`. A peer with N groups uses ONE `hub/receive` stream per
48
+ * `(deviceDID, hubURL)` rather than N — this preserves the hub-server's
49
+ * single-writer-per-DID invariant for bridge peers.
50
+ */
51
+ export declare class GroupChannel extends Disposer {
46
52
  #private;
47
53
  constructor(params: GroupChannelParams);
48
54
  open(): Promise<void>;
49
55
  close(): Promise<void>;
50
- broadcast(message: GroupBroadcastMessage): Promise<void>;
56
+ broadcast(message: GroupBroadcastMessage, opts?: {
57
+ timeoutMs?: number;
58
+ }): Promise<void>;
51
59
  }
@@ -1 +1 @@
1
- import{handleReceivedMessage as e}from"./receive-handler.js";import{handleSendBroadcast as t}from"./send-handler.js";export function createMutex(){let e=Promise.resolve();return{run(t){let r=e.then(()=>t());return e=r.catch(()=>{}),r}}}export class AckBatch{#e;#t;#r;#i;#s=new Set;#o=null;constructor(e){this.#e=e.flushMs,this.#t=e.flushMax,this.#r=e.send,this.#i=e.logger}add(e){let t=0===this.#s.size;if(this.#s.add(e),this.#s.size>=this.#t){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.flush();return}t&&null===this.#o&&(this.#o=setTimeout(()=>{this.#o=null,this.flush()},this.#e))}async flush(){if(null!==this.#o&&(clearTimeout(this.#o),this.#o=null),0===this.#s.size)return;let e=Array.from(this.#s);this.#s.clear();try{await this.#r(e)}catch(t){this.#i?.error("ack batch send failed",{error:t,count:e.length})}}dispose(){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.#s.clear()}}export function computeBackoff(e,t,r,i){let s=Math.min(t*2**e*(1+i*(2*Math.random()-1)),r);return s<0?0:s}export function abortableSleep(e,t){return new Promise((r,i)=>{if(t.aborted)return void i(t.reason??Error("aborted"));let s=setTimeout(()=>{t.removeEventListener("abort",o),r()},e),o=()=>{clearTimeout(s),t.removeEventListener("abort",o),i(t.reason??Error("aborted"))};t.addEventListener("abort",o)})}let r=Symbol("disposed");export class GroupChannel{#a;#h;#l;#n;#c;#u;#i;#p;#g;#d;#f;#b;#I;#D=new AbortController;#m=null;#k=null;#v=null;#M=null;#y=null;#w=0;#x=!1;#C=!1;constructor(e){this.#a=e.groupID,this.#h=e.hubURL,this.#l=e.deviceID,this.#n=e.pool,this.#c=e.broadcastService,this.#u=e.p2pStore,this.#i=e.logger,this.#p=e.ackFlushMs,this.#g=e.ackFlushMax,this.#d=e.backoffBaseMs,this.#f=e.backoffMaxMs,this.#b=e.backoffJitter,this.#I=createMutex()}async open(){if(this.#x)throw Error("GroupChannel already opened");try{await this.#S(),this.#x=!0}catch(e){if(null!=this.#k){try{await this.#n.release(this.#h,this.#k)}catch(e){this.#i?.debug("pool release after open failure failed",{groupID:this.#a,error:e})}this.#k=null}throw this.#m=null,e}}async #S(){if(null!=this.#k){let e=this.#k;this.#k=null,this.#m=null;try{await this.#n.release(this.#h,e)}catch(e){this.#i?.debug("pool release before reconnect failed",{groupID:this.#a,error:e})}}let{client:e,refID:t}=await this.#n.acquire(this.#h);this.#m=e,this.#k=t;let r=await this.#u.getMLSState(this.#a,this.#l);if(null==r)throw Error(`MLS state missing for group ${this.#a}; cannot join hub group`);await e.request("hub/group/join",{param:{groupID:this.#a,credential:r.credential}});let i=e.createChannel("hub/receive",{param:{groupIDs:[this.#a]},signal:this.#D.signal});this.#v=i,i.catch(()=>{}),this.#M=new AckBatch({flushMs:this.#p,flushMax:this.#g,send:async e=>{await i.send({ack:e})},logger:this.#i}),this.#y=this.#L(e).catch(e=>{this.#i?.debug("receive task tail error",{groupID:this.#a,error:e})}),this.#C=!0}async #L(t){let i=this.#v;if(null==i)return;let s=i.readable.getReader();try{for(;!this.#D.signal.aborted;){let i=s.read(),o=t.disposed.then(()=>r),a=await Promise.race([i,o]);if("symbol"==typeof a){this.#i?.debug("receive loop observed hub client disposal",{groupID:this.#a});break}let{done:h,value:l}=a;if(h)break;(await this.#I.run(()=>e({groupID:this.#a,deviceID:this.#l,message:l,p2pStore:this.#u,broadcastService:this.#c,logger:this.#i}))).ack&&this.#M?.add(l.sequenceID),this.#w=0}}catch(e){this.#i?.warn("hub receive loop error",{groupID:this.#a,error:e})}finally{try{s.releaseLock()}catch{}}this.#C=!1,this.#D.signal.aborted||this.#B()}#B(){this.#C=!1,this.#y=this.#R().catch(e=>{this.#i?.debug("reconnect task tail error",{groupID:this.#a,error:e})})}async #R(){for(;!this.#D.signal.aborted;){this.#C=!1;let e=computeBackoff(this.#w++,this.#d,this.#f,this.#b);this.#i?.debug("reconnect scheduled",{groupID:this.#a,delay:e,attempt:this.#w});try{await abortableSleep(e,this.#D.signal)}catch{return}try{this.#M?.dispose(),this.#M=null,this.#v=null,await this.#S(),this.#w=0;return}catch(e){this.#i?.warn("reconnect attempt failed",{groupID:this.#a,attempt:this.#w,error:e})}}}async close(){if(!this.#x)return;this.#C=!1;let e=this.#M;this.#M=null;try{await e?.flush()}catch(e){this.#i?.debug("ack flush during close failed",{groupID:this.#a,error:e})}e?.dispose();try{await this.#m?.request("hub/group/leave",{param:{groupID:this.#a}})}catch(e){this.#i?.warn("hub/group/leave failed",{groupID:this.#a,error:e})}try{this.#v?.close()}catch{}if(!this.#D.signal.aborted)try{this.#D.abort()}catch{}try{await this.#y}catch{}if(null!=this.#k){try{await this.#n.release(this.#h,this.#k)}catch(e){this.#i?.debug("pool release during close failed",{groupID:this.#a,error:e})}this.#k=null}this.#m=null,this.#v=null,this.#y=null,this.#x=!1}broadcast(e){return this.#x&&this.#C&&null!=this.#m?this.#I.run(async()=>{if(!this.#x||!this.#C||null==this.#m)throw Error(`GroupChannel for ${this.#a} is not ready for broadcast`);let r=this.#m;return t({groupID:this.#a,deviceID:this.#l,message:e,p2pStore:this.#u,broadcastService:this.#c,hubClient:r,logger:this.#i})}):Promise.reject(Error(`GroupChannel for ${this.#a} is not ready for broadcast`))}}
1
+ import{Disposer as e}from"@enkaku/async";import{handleReceivedMessage as t}from"./receive-handler.js";import{handleSendBroadcast as r}from"./send-handler.js";export class AckBatch{#e;#t;#r;#i;#s=new Set;#o=null;constructor(e){this.#e=e.flushMs,this.#t=e.flushMax,this.#r=e.send,this.#i=e.logger}add(e){let t=0===this.#s.size;if(this.#s.add(e),this.#s.size>=this.#t){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.flush();return}t&&null===this.#o&&(this.#o=setTimeout(()=>{this.#o=null,this.flush()},this.#e))}async flush(){if(null!==this.#o&&(clearTimeout(this.#o),this.#o=null),0===this.#s.size)return;let e=Array.from(this.#s);this.#s.clear();try{await this.#r(e)}catch(t){this.#i?.error("ack batch send failed",{error:t,count:e.length})}}dispose(){null!==this.#o&&(clearTimeout(this.#o),this.#o=null),this.#s.clear()}}export function computeBackoff(e,t,r,i){let s=Math.min(t*2**e*(1+i*(2*Math.random()-1)),r);return s<0?0:s}export function abortableSleep(e,t){return new Promise((r,i)=>{if(t.aborted)return void i(t.reason??Error("aborted"));let s=setTimeout(()=>{t.removeEventListener("abort",o),r()},e),o=()=>{clearTimeout(s),t.removeEventListener("abort",o),i(t.reason??Error("aborted"))};t.addEventListener("abort",o)})}export class GroupChannel extends e{#n;#h;#a;#u;#c;#i;#l=!1;constructor(e){super({dispose:async()=>{await this.#d()}}),this.#n=e.groupID,this.#h=e.deviceID,this.#a=e.hubConnection,this.#u=e.broadcastService,this.#c=e.p2pStore,this.#i=e.logger}async open(){if(this.#l)throw Error("GroupChannel already opened");let e=await this.#c.getMLSState(this.#n,this.#h);if(null==e)throw Error(`MLS state missing for group ${this.#n}; cannot join hub group`);await this.#a.subscribe(this.#n,e.credential,e=>this.#p(e)),this.#l=!0}#p(e){return t({groupID:this.#n,message:e,broadcastService:this.#u,logger:this.#i})}async close(){this.#l&&await this.dispose()}async #d(){if(this.#l){try{await this.#a.unsubscribe(this.#n)}catch(e){this.#i?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#l=!1}}broadcast(e,t){return this.#l?this.#a.runInGroup(this.#n,async()=>{if(!this.#l)throw Error(`GroupChannel for ${this.#n} is not ready for broadcast`);await r({groupID:this.#n,message:e,broadcastService:this.#u,send:(e,r)=>this.#a.broadcast(e,r,{timeoutMs:t?.timeoutMs}),logger:this.#i})},{timeoutMs:t?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for broadcast`))}}
@@ -0,0 +1,17 @@
1
+ import type { Identity } from '@enkaku/token';
2
+ import { type ServerDIDObserver } from './did-observing-transport.js';
3
+ import type { CreateHubClient } from './relay-manager.js';
4
+ export type CreateHTTPHubClientParams = {
5
+ identity: Identity;
6
+ fetch?: typeof globalThis.fetch;
7
+ serverID?: string;
8
+ /**
9
+ * Fired once per hub client when the first signed response from the server
10
+ * arrives carrying `payload.iss = serverDID`. Subsequent responses do NOT
11
+ * re-fire this hook, even if `iss` differs. Used by `HubConnectionPool` to
12
+ * drive TOFU pinning into the hub manager. Per-call `onServerDID` from
13
+ * `CreateHubClientOptions` overrides this factory-level default.
14
+ */
15
+ onServerDID?: ServerDIDObserver;
16
+ };
17
+ export declare function createHTTPHubClient(params: CreateHTTPHubClientParams): CreateHubClient;
@@ -0,0 +1 @@
1
+ import{Client as e}from"@enkaku/client";import{ClientTransport as r}from"@enkaku/http-client-transport";import{DIDObservingTransport as t}from"./did-observing-transport.js";export function createHTTPHubClient(n){return async(o,i)=>{let l=new r({url:o,fetch:n.fetch}),u=i?.onServerDID??n.onServerDID,D=i?.expectedServerDID,c=i?.onMismatch;return new e({transport:null!=u||null!=D||null!=c?new t({inner:l,onServerDID:u??(()=>{}),expectedServerDID:D,hubURL:o,onMismatch:c}):l,identity:n.identity,serverID:n.serverID})}}
@@ -0,0 +1,96 @@
1
+ import { Disposer } from '@enkaku/async';
2
+ import { EventEmitter } from '@enkaku/event';
3
+ import type { StoredMessage } from '@enkaku/hub-protocol';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { HubConnectionPool } from './connection-pool.js';
6
+ import type { ReceivedHubMessage } from './receive-handler.js';
7
+ export type TunnelMessageStream = AsyncIterable<StoredMessage> & {
8
+ return(): void;
9
+ };
10
+ export type TunnelMessageStreamParams = {
11
+ peerDID: string;
12
+ };
13
+ export type SubscriptionHandler = (message: ReceivedHubMessage) => Promise<{
14
+ ack: boolean;
15
+ }>;
16
+ export type ConnectionLifecycleEvent = {
17
+ type: 'connected';
18
+ } | {
19
+ type: 'disconnected';
20
+ reason?: string;
21
+ } | {
22
+ type: 'error';
23
+ error: unknown;
24
+ } | {
25
+ type: 'reconnecting';
26
+ attempt: number;
27
+ delayMs: number;
28
+ };
29
+ type HubConnectionEvents = {
30
+ lifecycle: ConnectionLifecycleEvent;
31
+ };
32
+ export type HubConnectionParams = {
33
+ hubURL: string;
34
+ deviceID: string;
35
+ pool: HubConnectionPool;
36
+ logger?: Logger;
37
+ ackFlushMs?: number;
38
+ ackFlushMax?: number;
39
+ backoffBaseMs?: number;
40
+ backoffMaxMs?: number;
41
+ backoffJitter?: number;
42
+ };
43
+ export declare class HubConnection extends Disposer {
44
+ #private;
45
+ constructor(params: HubConnectionParams);
46
+ get events(): EventEmitter<HubConnectionEvents>;
47
+ open(): Promise<void>;
48
+ subscribe(groupID: string, credential: string, handler: SubscriptionHandler): Promise<void>;
49
+ unsubscribe(groupID: string): Promise<void>;
50
+ broadcast(groupID: string, encryptedPayload: string, opts?: {
51
+ timeoutMs?: number;
52
+ }): Promise<void>;
53
+ /**
54
+ * Send an opaque tunnel-style message to explicit recipients via
55
+ * `hub/send`. No `groupID` is attached at the hub level — recipients
56
+ * receive the message on the tunnel-routing path (see
57
+ * {@link tunnelMessageStream}). Used by hub-mediated document sync.
58
+ */
59
+ send(params: {
60
+ recipients: Array<string>;
61
+ payload: Uint8Array;
62
+ timeoutMs?: number;
63
+ }): Promise<{
64
+ sequenceID: string;
65
+ }>;
66
+ /**
67
+ * Run `fn` inside the same per-group mutex used by receive `#dispatch`. This
68
+ * lets callers (e.g. `GroupChannel.broadcast`) serialize MLS-state-mutating
69
+ * pipelines with incoming-message handling on the same group, avoiding races
70
+ * between concurrent send-encrypt and receive-decrypt against shared MLS
71
+ * group state.
72
+ *
73
+ * The gate-await ensures the subscription map is settled before lookup —
74
+ * i.e. mid-reconnect callers wait for the subscription to be (re)registered
75
+ * rather than throwing.
76
+ */
77
+ runInGroup<T>(groupID: string, fn: () => Promise<T>, opts?: {
78
+ timeoutMs?: number;
79
+ }): Promise<T>;
80
+ /**
81
+ * Register a per-peer tunnel inbox and return a disposable async iterable
82
+ * of {@link StoredMessage} values received from that peer over the tunnel
83
+ * (i.e. direct hub messages with no `groupID`).
84
+ *
85
+ * Used by the hub-mediated document sync server adapter (see Q3 of the
86
+ * hub-mediated document sync plan). The hub's receive-side dispatcher
87
+ * keys tunnel routing on `senderDID` because hub-level messages cannot
88
+ * reveal the in-frame `sessionID` (the body is encrypted). P2P pairs
89
+ * `peerDID` and `sessionID` at session creation, so peer-keying is
90
+ * sufficient. Concurrent tunnel sessions to the same peer are not
91
+ * supported and are the caller's responsibility to avoid.
92
+ */
93
+ tunnelMessageStream({ peerDID }: TunnelMessageStreamParams): TunnelMessageStream;
94
+ close(): Promise<void>;
95
+ }
96
+ export {};
@@ -0,0 +1 @@
1
+ import{Disposer as e}from"@enkaku/async";import{fromB64 as t,toB64 as s}from"@enkaku/codec";import{EventEmitter as n}from"@enkaku/event";import{createMutex as i}from"../util/mutex.js";import{ReconnectingError as r}from"./errors.js";import{AckBatch as o,abortableSleep as h,computeBackoff as a}from"./group-channel.js";import{TunnelInbox as c}from"./tunnel-inbox.js";import{waitForGate as l}from"./wait-for-gate.js";let u=Symbol("disposed");export class HubConnection extends e{#e;#t;#s;#n;#i;#r;#o;#h;#a;#c=null;#l=null;#u=null;#b=null;#p=null;#d=new Map;#f=!1;#g=0;#m=null;#w=new Map;#y;constructor(e){super({dispose:async()=>{await this.#I()}}),this.#e=e.hubURL,this.#t=e.deviceID,this.#s=e.pool,this.#n=e.logger,this.#i=e.ackFlushMs??500,this.#r=e.ackFlushMax??10,this.#o=e.backoffBaseMs??1e3,this.#h=e.backoffMaxMs??6e4,this.#a=e.backoffJitter??.25,this.#y=new n}get events(){return this.#y}async #D(e){try{await this.#y.emit("lifecycle",e)}catch(t){this.#n?.warn("hub-connection lifecycle subscriber threw",{hubURL:this.#e,type:e.type,error:t})}}async open(){if(this.#f)throw Error("HubConnection already opened");try{await this.#L(),this.#f=!0,await this.#D({type:"connected"})}catch(e){if(null!=this.#l){try{await this.#s.release(this.#e,this.#l)}catch(e){this.#n?.debug("hub-connection pool release after open failure failed",{hubURL:this.#e,error:e})}this.#l=null}throw this.#c=null,e}}async #L(){if(null!=this.#l){let e=this.#l;this.#l=null,this.#c=null;try{await this.#s.release(this.#e,e)}catch(e){this.#n?.debug("hub-connection pool release before reconnect failed",{hubURL:this.#e,error:e})}}let{client:e,refID:t}=await this.#s.acquire(this.#e);this.#c=e,this.#l=t;let s=e.createChannel("hub/receive",{param:{},signal:this.signal});this.#u=s,s.catch(()=>{}),this.#b=new o({flushMs:this.#i,flushMax:this.#r,send:async e=>{await s.send({ack:e})},logger:this.#n}),this.#p=this.#R(e).catch(e=>{this.#n?.debug("hub-connection receive task tail error",{hubURL:this.#e,error:e})});let n=[...this.#d.entries()];if(n.length>0){let t=await Promise.allSettled(n.map(async([t,s])=>{await e.request("hub/group/join",{param:{groupID:t,credential:s.credential}})}));for(let e=0;e<t.length;e++){let s=t[e];if("rejected"===s.status){let t=n[e][0];this.#n?.warn("hub-connection rejoin failed",{hubURL:this.#e,groupID:t,error:s.reason})}}}}async subscribe(e,t,s){if(await l(this.#m,void 0),!this.#f||null==this.#c)throw Error("HubConnection not open");if(this.#d.has(e))throw Error(`already subscribed to group ${e}`);await this.#c.request("hub/group/join",{param:{groupID:e,credential:t}}),this.#d.set(e,{credential:t,handler:s,mutex:i()})}async unsubscribe(e){await l(this.#m,void 0);let t=this.#c;if(!this.#f||null==t)throw Error("HubConnection not open");let s=this.#d.get(e);if(null==s)throw Error(`not subscribed to group ${e}`);await s.mutex.run(async()=>{}),await t.request("hub/group/leave",{param:{groupID:e}}),this.#d.delete(e)}async broadcast(e,t,s){if(await l(this.#m,s?.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f||null==this.#c)throw Error("HubConnection not open");await this.#c.request("hub/group/send",{param:{groupID:e,payload:t}})}async send(e){if(await l(this.#m,e.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f||null==this.#c)throw Error("HubConnection not open");return{sequenceID:(await this.#c.request("hub/send",{param:{recipients:e.recipients,payload:s(e.payload)}})).sequenceID}}async runInGroup(e,t,s){if(await l(this.#m,s?.timeoutMs,()=>new r(this.#e,this.#g)),!this.#f)throw Error("HubConnection not open");let n=this.#d.get(e);if(null==n)throw Error(`not subscribed to group ${e}`);return n.mutex.run(t)}tunnelMessageStream({peerDID:e,isAllowedSender:t,onRejected:s}){if(this.#w.has(e))throw Error("tunnelMessageStream: peerDID already registered");let n=new c,i={inbox:n,isAllowedSender:t??(()=>!0),onRejected:s};this.#w.set(e,i);let r=!1;return{[Symbol.asyncIterator]:()=>n.iterator(),return:()=>{r||(r=!0,this.#w.get(e)===i&&this.#w.delete(e),n.close())}}}async #R(e){let t=this.#u;if(null==t)return;let s=t.readable.getReader(),n=null;try{for(;!this.signal.aborted;){let t=s.read(),n=e.disposed.then(()=>u),i=await Promise.race([t,n]);if("symbol"==typeof i){this.#n?.debug("hub-connection receive loop observed hub client disposal",{hubURL:this.#e});break}let{done:r,value:o}=i;if(r)break;this.#k(o),this.#g=0}}catch(e){n=e,this.#n?.warn("hub-connection receive loop error",{hubURL:this.#e,error:e})}finally{try{s.releaseLock()}catch{}}if(!this.signal.aborted){let e=n instanceof Error&&n.message?n.message:"receive loop ended";await this.#D({type:"disconnected",reason:e}),this.#U()}}#U(){this.#p=this.#M().catch(e=>{this.#n?.debug("hub-connection reconnect task tail error",{hubURL:this.#e,error:e})})}async #M(){let e=()=>{};this.#m=new Promise(t=>{e=t});try{for(;!this.signal.aborted;){let e=a(this.#g++,this.#o,this.#h,this.#a);this.#n?.debug("hub-connection reconnect scheduled",{hubURL:this.#e,delay:e,attempt:this.#g});try{await h(e,this.signal)}catch{return}await this.#D({type:"reconnecting",attempt:this.#g,delayMs:e});try{this.#b?.dispose(),this.#b=null,this.#u=null,await this.#L(),this.#g=0,await this.#D({type:"connected"});return}catch(e){this.#n?.warn("hub-connection reconnect attempt failed",{hubURL:this.#e,attempt:this.#g,error:e}),await this.#D({type:"error",error:e})}}}finally{this.#m=null,e()}}#k(e){if(null==e.groupID){let s=this.#w.get(e.senderDID);if(null!=s){if(!s.isAllowedSender(e.senderDID)){this.#n?.debug("tunnel frame rejected: sender not member",{hubURL:this.#e,sequenceID:e.sequenceID,senderDID:e.senderDID});try{s.onRejected?.("sender-not-member",e.senderDID)}catch(t){this.#n?.warn("tunnel onRejected callback threw",{hubURL:this.#e,senderDID:e.senderDID,error:t})}this.#b?.add(e.sequenceID);return}let n={sequenceID:e.sequenceID,senderDID:e.senderDID,payload:t(e.payload)};s.inbox.push(n),this.#b?.add(e.sequenceID);return}this.#n?.debug("direct message received, no handler",{hubURL:this.#e,sequenceID:e.sequenceID}),this.#b?.add(e.sequenceID);return}let s=this.#d.get(e.groupID);null==s?this.#n?.debug("message for unsubscribed group",{hubURL:this.#e,groupID:e.groupID,sequenceID:e.sequenceID}):s.mutex.run(()=>s.handler(e)).then(t=>{t.ack&&this.#b?.add(e.sequenceID)}).catch(t=>{this.#n?.warn("hub-connection subscription handler error",{hubURL:this.#e,groupID:e.groupID,sequenceID:e.sequenceID,error:t})})}async close(){this.#f&&await this.dispose()}async #I(){if(!this.#f)return;let e=this.#b;if(this.#b=null,null!=e){try{await e.flush()}catch(e){this.#n?.debug("hub-connection ack flush during close failed",{hubURL:this.#e,error:e})}e.dispose()}try{this.#u?.close()}catch{}try{await this.#p}catch{}let t=[...this.#d.values()].map(e=>e.mutex.run(async()=>{}).catch(()=>{}));if(await Promise.allSettled(t),null!=this.#l){try{await this.#s.release(this.#e,this.#l)}catch(e){this.#n?.debug("hub-connection pool release during close failed",{hubURL:this.#e,error:e})}this.#l=null}for(let e of(this.#c=null,this.#u=null,this.#p=null,this.#d.clear(),this.#w.values()))e.inbox.close();this.#w.clear(),this.#f=!1}}