@kubun/plugin-p2p 0.9.0 → 0.10.1

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 (87) hide show
  1. package/lib/context/delegation.d.ts +4 -0
  2. package/lib/context/delegation.js +1 -0
  3. package/lib/context/group.js +1 -1
  4. package/lib/context/hub.js +1 -1
  5. package/lib/context/join.js +1 -1
  6. package/lib/context/types.d.ts +25 -2
  7. package/lib/groups/broadcast-service.d.ts +137 -4
  8. package/lib/groups/broadcast-service.js +1 -1
  9. package/lib/groups/broadcast.d.ts +120 -0
  10. package/lib/groups/broadcast.js +1 -1
  11. package/lib/groups/events.d.ts +16 -5
  12. package/lib/groups/events.js +1 -1
  13. package/lib/groups/group-handle-registry.d.ts +8 -0
  14. package/lib/groups/group-handle-registry.js +1 -1
  15. package/lib/groups/group-health-monitor.d.ts +45 -0
  16. package/lib/groups/group-health-monitor.js +1 -0
  17. package/lib/groups/invite-payload.d.ts +16 -0
  18. package/lib/groups/join-utils.d.ts +68 -2
  19. package/lib/groups/join-utils.js +1 -1
  20. package/lib/groups/manager.d.ts +15 -4
  21. package/lib/groups/manager.js +1 -1
  22. package/lib/groups/rejoin-codec.d.ts +14 -0
  23. package/lib/groups/rejoin-codec.js +1 -0
  24. package/lib/groups/store-received-grant.d.ts +47 -0
  25. package/lib/groups/store-received-grant.js +1 -0
  26. package/lib/groups/store-received-revocation.d.ts +46 -0
  27. package/lib/groups/store-received-revocation.js +1 -0
  28. package/lib/groups/wire-frame.d.ts +34 -0
  29. package/lib/groups/wire-frame.js +1 -0
  30. package/lib/hub/circle-catchup-requester.d.ts +58 -0
  31. package/lib/hub/circle-catchup-requester.js +1 -0
  32. package/lib/hub/circle-catchup-responder.d.ts +50 -0
  33. package/lib/hub/circle-catchup-responder.js +1 -0
  34. package/lib/hub/epoch-stale-detector.d.ts +18 -0
  35. package/lib/hub/epoch-stale-detector.js +1 -0
  36. package/lib/hub/errors.d.ts +7 -5
  37. package/lib/hub/errors.js +1 -1
  38. package/lib/hub/forward-remote-broadcast.d.ts +3 -3
  39. package/lib/hub/forward-remote-broadcast.js +1 -1
  40. package/lib/hub/group-channel.d.ts +30 -0
  41. package/lib/hub/group-channel.js +1 -1
  42. package/lib/hub/hub-connection.js +1 -1
  43. package/lib/hub/manager.d.ts +5 -4
  44. package/lib/hub/manager.js +1 -1
  45. package/lib/hub/receive-handler.d.ts +20 -2
  46. package/lib/hub/receive-handler.js +1 -1
  47. package/lib/hub/rejoin-manager.d.ts +78 -0
  48. package/lib/hub/rejoin-manager.js +1 -0
  49. package/lib/hub/rejoin-responder.d.ts +32 -0
  50. package/lib/hub/rejoin-responder.js +1 -0
  51. package/lib/hub/relay-manager.d.ts +23 -1
  52. package/lib/hub/relay-manager.js +1 -1
  53. package/lib/hub/send-handler.d.ts +16 -0
  54. package/lib/hub/send-handler.js +1 -1
  55. package/lib/hub/wiring.d.ts +33 -3
  56. package/lib/hub/wiring.js +1 -1
  57. package/lib/index.d.ts +19 -5
  58. package/lib/index.js +1 -1
  59. package/lib/protocol.d.ts +7 -0
  60. package/lib/protocol.js +1 -1
  61. package/lib/schema.d.ts +2 -2
  62. package/lib/schema.js +79 -3
  63. package/lib/sync/authorize.d.ts +25 -0
  64. package/lib/sync/authorize.js +1 -0
  65. package/lib/sync/broadcast-sender.js +1 -1
  66. package/lib/sync/errors.d.ts +11 -0
  67. package/lib/sync/errors.js +1 -0
  68. package/lib/sync/forwarder.d.ts +1 -0
  69. package/lib/sync/forwarder.js +1 -1
  70. package/lib/sync/handlers.d.ts +3 -18
  71. package/lib/sync/handlers.js +1 -1
  72. package/lib/sync/hub-tunnel-sync-listener.d.ts +2 -0
  73. package/lib/sync/hub-tunnel-sync-listener.js +1 -1
  74. package/lib/sync/merkle-apply.js +1 -1
  75. package/lib/sync/merkle-channel.d.ts +19 -0
  76. package/lib/sync/merkle-channel.js +1 -1
  77. package/lib/sync/peer-registry.d.ts +7 -0
  78. package/lib/sync/peer-registry.js +1 -1
  79. package/lib/sync/sync-client.d.ts +2 -0
  80. package/lib/sync/sync-client.js +1 -1
  81. package/lib/sync/sync-manager.js +1 -1
  82. package/lib/types.d.ts +122 -1
  83. package/lib/utils.d.ts +5 -0
  84. package/lib/utils.js +1 -0
  85. package/package.json +41 -41
  86. package/lib/sync/catalog-match.d.ts +0 -13
  87. package/lib/sync/catalog-match.js +0 -1
@@ -1,9 +1,13 @@
1
1
  import { Disposer } from '@enkaku/async';
2
+ import type { OwnIdentity } from '@enkaku/token';
2
3
  import type { Logger } from '@kubun/logger';
3
4
  import type { P2PStoreAPI } from '@kubun/store-p2p';
4
5
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
5
6
  import type { BroadcastService } from '../groups/broadcast-service.js';
7
+ import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
8
+ import type { RejoinResult } from '../types.js';
6
9
  import type { HubConnection } from './hub-connection.js';
10
+ import { RejoinManager } from './rejoin-manager.js';
7
11
  export type AckBatchParams = {
8
12
  flushMs: number;
9
13
  flushMax: number;
@@ -32,6 +36,19 @@ export type GroupChannelParams = {
32
36
  broadcastService: BroadcastService;
33
37
  p2pStore: P2PStoreAPI;
34
38
  logger?: Logger;
39
+ /** When provided, receive outcomes feed epoch-stale health detection and the
40
+ * rejoin flow becomes available (both need the shared health monitor). */
41
+ monitor?: GroupHealthMonitor;
42
+ /** Device identity — required by `joinGroupExternal` in the rejoin flow. */
43
+ identity?: OwnIdentity;
44
+ /** Fresh correlation ids for rejoin requests. */
45
+ getRandomID?: () => string;
46
+ /**
47
+ * Pre-built rejoin manager, overriding the one this channel would construct
48
+ * from `monitor`/`identity`/`getRandomID`. Lets the rejoin orchestration be
49
+ * driven without standing up real MLS; production callers leave it unset.
50
+ */
51
+ rejoinManager?: RejoinManager;
35
52
  };
36
53
  /**
37
54
  * Per-group handle: thin wrapper around a shared `HubConnection`.
@@ -52,8 +69,21 @@ export declare class GroupChannel extends Disposer {
52
69
  #private;
53
70
  constructor(params: GroupChannelParams);
54
71
  open(): Promise<void>;
72
+ /**
73
+ * Run stale-device recovery for this group. Available only when constructed
74
+ * with a health monitor + identity; returns `recovery-failed` otherwise.
75
+ */
76
+ rejoinGroup(): Promise<RejoinResult>;
55
77
  close(): Promise<void>;
56
78
  broadcast(message: GroupBroadcastMessage, opts?: {
57
79
  timeoutMs?: number;
58
80
  }): Promise<void>;
81
+ /**
82
+ * Fan out a pre-produced MLS handshake Commit to the group's existing
83
+ * members. Mirrors {@link broadcast} but routes through `handleSendCommit`
84
+ * (no application encryption — the Commit is already self-protected).
85
+ */
86
+ sendCommit(commitBytes: Uint8Array, opts?: {
87
+ timeoutMs?: number;
88
+ }): Promise<void>;
59
89
  }
@@ -1 +1 @@
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`))}}
1
+ import{Disposer as e}from"@enkaku/async";import{toB64 as r}from"@enkaku/codec";import{CircleCatchupRequester as t}from"./circle-catchup-requester.js";import{CircleCatchupResponder as o}from"./circle-catchup-responder.js";import{EpochStaleDetector as s}from"./epoch-stale-detector.js";import{handleReceivedMessage as i}from"./receive-handler.js";import{RejoinManager as n}from"./rejoin-manager.js";import{RejoinResponder as a}from"./rejoin-responder.js";import{handleSendBroadcast as u,handleSendCommit as h}from"./send-handler.js";export class AckBatch{#e;#r;#t;#o;#s=new Set;#i=null;constructor(e){this.#e=e.flushMs,this.#r=e.flushMax,this.#t=e.send,this.#o=e.logger}add(e){let r=0===this.#s.size;if(this.#s.add(e),this.#s.size>=this.#r){null!==this.#i&&(clearTimeout(this.#i),this.#i=null),this.flush();return}r&&null===this.#i&&(this.#i=setTimeout(()=>{this.#i=null,this.flush()},this.#e))}async flush(){if(null!==this.#i&&(clearTimeout(this.#i),this.#i=null),0===this.#s.size)return;let e=Array.from(this.#s);this.#s.clear();try{await this.#t(e)}catch(r){this.#o?.error("ack batch send failed",{error:r,count:e.length})}}dispose(){null!==this.#i&&(clearTimeout(this.#i),this.#i=null),this.#s.clear()}}export function computeBackoff(e,r,t,o){let s=Math.min(r*2**e*(1+o*(2*Math.random()-1)),t);return s<0?0:s}export function abortableSleep(e,r){return new Promise((t,o)=>{if(r.aborted)return void o(r.reason??Error("aborted"));let s=setTimeout(()=>{r.removeEventListener("abort",i),t()},e),i=()=>{clearTimeout(s),r.removeEventListener("abort",i),o(r.reason??Error("aborted"))};r.addEventListener("abort",i)})}export class GroupChannel extends e{#n;#a;#u;#h;#p;#o;#c;#d;#l;#g;#m;#I=!1;constructor(e){super({dispose:async()=>{await this.#f()}}),this.#n=e.groupID,this.#a=e.deviceID,this.#u=e.hubConnection,this.#h=e.broadcastService,this.#p=e.p2pStore,this.#o=e.logger,null!=e.monitor&&(this.#c=new s({groupID:e.groupID,monitor:e.monitor})),this.#d=new a({groupID:e.groupID,exportGroupInfo:()=>this.#h.exportGroupInfo(this.#n),sendGroupInfo:(e,r)=>this.#D(e,r),logger:e.logger}),null!=e.rejoinManager?this.#l=e.rejoinManager:null!=e.monitor&&null!=e.identity&&null!=e.getRandomID&&(this.#l=new n({groupID:e.groupID,identity:e.identity,readCredential:()=>this.#h.readCredential(this.#n),genRequestID:e.getRandomID,sendRequest:e=>this.#b(e),sendCommit:e=>this.sendCommit(e),replaceHandle:e=>this.#h.replaceHandle(this.#n,e),monitor:e.monitor,logger:e.logger})),this.#g=new o({groupID:e.groupID,store:this.#p,sendCatchupReply:(e,r,t)=>this.#y(e,r,t),logger:e.logger}),null!=e.getRandomID&&(this.#m=new t({groupID:e.groupID,genRequestID:e.getRandomID,sendRequest:e=>this.#R(e),applyReply:async e=>{await this.#u.runInGroup(this.#n,()=>this.#h.applyCatchupReply(this.#n,e))},logger:e.logger}))}async open(){if(this.#I)throw Error("GroupChannel already opened");let e=await this.#p.getMLSState(this.#n,this.#a);if(null==e)throw Error(`MLS state missing for group ${this.#n}; cannot join hub group`);await this.#u.subscribe(this.#n,e.credential,e=>this.#j(e)),this.#I=!0}async #j(e){let r=await i({groupID:this.#n,message:e,broadcastService:this.#h,logger:this.#o});this.#c?.onReceiveOutcome(r.outcome,r.sequenceID);let t=r.result;if(t?.kind==="rejoin-request")this.#d.onRejoinRequest(t.requestID);else if(t?.kind==="rejoin-groupinfo")this.#d.onRejoinGroupInfoSeen(t.requestID),this.#l?.onGroupInfo(t.requestID,t.groupInfo);else if(t?.kind==="app"){let e=t.message;"circle-catchup:request"===e.type?this.#g.onCatchupRequest(e.requestID):"circle-catchup:reply"===e.type&&(this.#g.onCatchupReplySeen(e.requestID),this.#m?.onCatchupReply(e.requestID,e))}return{ack:r.ack}}async rejoinGroup(){if(null==this.#l)return{status:"recovery-failed"};let e=await this.#l.rejoin();return"recovered"===e.status&&null!=this.#m&&this.#m.run().catch(e=>{this.#o?.warn("circle catch-up after rejoin failed",{groupID:this.#n,error:e})}),e}#b(e){return this.#I?this.#h.prepareSendRejoinRequest({requestID:e,send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for rejoin request`))}#D(e,t){return this.#I?this.#h.prepareSendRejoinGroupInfo({requestID:e,groupInfo:t,send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for rejoin reply`))}#R(e){return this.#I?this.#h.prepareSend({groupID:this.#n,message:{type:"circle-catchup:request",requestID:e},send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for catch-up request`))}#y(e,t,o){return this.#I?this.#h.prepareSend({groupID:this.#n,message:{type:"circle-catchup:reply",requestID:e,circles:t,circleMembers:o},send:e=>this.#u.broadcast(this.#n,r(e))}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for catch-up reply`))}async close(){this.#I&&await this.dispose()}async #f(){if(this.#d.dispose(),this.#l?.dispose(),this.#g.dispose(),this.#m?.dispose(),this.#I){try{await this.#u.unsubscribe(this.#n)}catch(e){this.#o?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#I=!1}}broadcast(e,r){return this.#I?this.#u.runInGroup(this.#n,async()=>{if(!this.#I)throw Error(`GroupChannel for ${this.#n} is not ready for broadcast`);await u({groupID:this.#n,message:e,broadcastService:this.#h,send:(e,t)=>this.#u.broadcast(e,t,{timeoutMs:r?.timeoutMs}),logger:this.#o})},{timeoutMs:r?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for broadcast`))}sendCommit(e,r){return this.#I?this.#u.runInGroup(this.#n,async()=>{if(!this.#I)throw Error(`GroupChannel for ${this.#n} is not ready for sendCommit`);await h({groupID:this.#n,commitBytes:e,broadcastService:this.#h,send:(e,t)=>this.#u.broadcast(e,t,{timeoutMs:r?.timeoutMs}),logger:this.#o})},{timeoutMs:r?.timeoutMs}):Promise.reject(Error(`GroupChannel for ${this.#n} is not ready for sendCommit`))}}
@@ -1 +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}}
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;#y=new Map;#w;constructor(e){super({dispose:async()=>{await this.#L()}}),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.#w=new n}get events(){return this.#w}async #I(e){try{await this.#w.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.#k(),this.#f=!0,await this.#I({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 #k(){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}){if(this.#y.has(e))throw Error("tunnelMessageStream: peerDID already registered");let t=new c,s={inbox:t};this.#y.set(e,s);let n=!1;return{[Symbol.asyncIterator]:()=>t.iterator(),return:()=>{n||(n=!0,this.#y.get(e)===s&&this.#y.delete(e),t.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.#D(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.#I({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.#I({type:"reconnecting",attempt:this.#g,delayMs:e});try{this.#b?.dispose(),this.#b=null,this.#u=null,await this.#k(),this.#g=0,await this.#I({type:"connected"});return}catch(e){this.#n?.warn("hub-connection reconnect attempt failed",{hubURL:this.#e,attempt:this.#g,error:e}),await this.#I({type:"error",error:e})}}}finally{this.#m=null,e()}}#D(e){if(null==e.groupID){let s=this.#y.get(e.senderDID);if(null!=s){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 #L(){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.#y.values()))e.inbox.close();this.#y.clear(),this.#f=!1}}
@@ -65,16 +65,17 @@ export type UpsertHubParams = {
65
65
  serverDID?: string | null;
66
66
  };
67
67
  export declare class HubServerDIDConflictError extends Error {
68
- readonly hubID: string;
69
- readonly url: string;
70
- readonly existingServerDID: string;
71
- readonly providedServerDID: string;
68
+ #private;
72
69
  constructor(params: {
73
70
  hubID: string;
74
71
  url: string;
75
72
  existingServerDID: string;
76
73
  providedServerDID: string;
77
74
  });
75
+ get hubID(): string;
76
+ get url(): string;
77
+ get existingServerDID(): string;
78
+ get providedServerDID(): string;
78
79
  }
79
80
  export declare function createHub(params: CreateHubParams): Promise<Hub>;
80
81
  /**
@@ -1 +1 @@
1
- import{getP2PStore as r}from"@kubun/store-p2p";export class HubServerDIDConflictError extends Error{hubID;url;existingServerDID;providedServerDID;constructor(r){super(`hub at ${r.url} is pinned to ${r.existingServerDID}; refusing to overwrite with ${r.providedServerDID}`),this.name="HubServerDIDConflictError",this.hubID=r.hubID,this.url=r.url,this.existingServerDID=r.existingServerDID,this.providedServerDID=r.providedServerDID}}function e(r){return{id:r.id,label:r.label,url:r.url,serverDID:r.server_did,createdAt:new Date(r.created_at),updatedAt:null==r.updated_at?null:new Date(r.updated_at)}}export async function createHub(t){let u=await r(t.stores),i=t.serverDID??null,a=await u.getHubByURL(t.url);if(null!=a){if(null!=a.server_did&&null!=i&&a.server_did!==i)throw new HubServerDIDConflictError({hubID:a.id,url:t.url,existingServerDID:a.server_did,providedServerDID:i});if(null==a.server_did&&null!=i){await u.updateHub(a.id,{server_did:i});let r=await u.getHub(a.id);if(null==r)throw Error("Failed to read hub after upgrade");return e(r)}return e(a)}let l=crypto.randomUUID();await u.insertHub({id:l,label:t.label??null,url:t.url,server_did:i});let n=await u.getHub(l);if(null==n)throw Error("Failed to read hub after insert");return e(n)}export async function upsertHub(r){return await createHub({stores:r.stores,url:r.url,serverDID:r.serverDID})}export async function updateHub(t){let u=await r(t.stores),i=await u.getHub(t.id);if(null==i)throw Error(`Hub ${t.id} not found`);if(null!=t.url&&t.url!==i.url){let r=await u.getHubByURL(t.url);if(null!=r&&r.id!==t.id)throw Error(`URL ${t.url} already in use by hub ${r.id}`)}let a={};void 0!==t.label&&(a.label=t.label),void 0!==t.url&&(a.url=t.url),void 0!==t.serverDID&&(a.server_did=t.serverDID),await u.updateHub(t.id,a);let l=await u.getHub(t.id);if(null==l)throw Error("Failed to read hub after update");return e(l)}export async function deleteHub(e){let t=await r(e.stores),u=await t.listGroupIDsByHubID(e.id);return u.length>0&&!e.force?{success:!1,boundGroupIDs:u}:(u.length>0&&await t.deleteGroupHubsByHubID(e.id),await t.deleteHub(e.id),{success:!0,boundGroupIDs:u})}export async function getHub(t){let u=await r(t.stores),i=await u.getHub(t.id);return null==i?null:e(i)}export async function listHubs(t){let u=await r(t.stores);return(await u.listHubs()).map(e)}export async function bindHubToGroup(e){let t=await r(e.stores);return await t.insertGroupHub({group_id:e.groupID,hub_id:e.hubID})}export async function unbindHubFromGroup(e){let t=await r(e.stores);return await t.deleteGroupHub({groupID:e.groupID,hubID:e.hubID})}export async function listHubsByGroup(t){let u=await r(t.stores);return(await u.listHubsByGroupID(t.groupID)).map(e)}export async function listGroupsByHub(e){let t=await r(e.stores);return await t.listGroupIDsByHubID(e.hubID)}export async function captureServerDID(e){let t=await r(e.stores),u=await t.getHubByURL(e.hubURL);if(null==u)return!1;if(null==u.server_did)return await t.updateHub(u.id,{server_did:e.serverDID}),!0;if(u.server_did===e.serverDID)return!1;throw new HubServerDIDConflictError({hubID:u.id,url:e.hubURL,existingServerDID:u.server_did,providedServerDID:e.serverDID})}
1
+ import{getP2PStore as r}from"@kubun/store-p2p";export class HubServerDIDConflictError extends Error{#r;#e;#t;#u;constructor(r){super(`hub at ${r.url} is pinned to ${r.existingServerDID}; refusing to overwrite with ${r.providedServerDID}`),this.name="HubServerDIDConflictError",this.#r=r.hubID,this.#e=r.url,this.#t=r.existingServerDID,this.#u=r.providedServerDID}get hubID(){return this.#r}get url(){return this.#e}get existingServerDID(){return this.#t}get providedServerDID(){return this.#u}}function e(r){return{id:r.id,label:r.label,url:r.url,serverDID:r.server_did,createdAt:new Date(r.created_at),updatedAt:null==r.updated_at?null:new Date(r.updated_at)}}export async function createHub(t){let u=await r(t.stores),i=t.serverDID??null,a=await u.getHubByURL(t.url);if(null!=a){if(null!=a.server_did&&null!=i&&a.server_did!==i)throw new HubServerDIDConflictError({hubID:a.id,url:t.url,existingServerDID:a.server_did,providedServerDID:i});if(null==a.server_did&&null!=i){await u.updateHub(a.id,{server_did:i});let r=await u.getHub(a.id);if(null==r)throw Error("Failed to read hub after upgrade");return e(r)}return e(a)}let l=crypto.randomUUID();await u.insertHub({id:l,label:t.label??null,url:t.url,server_did:i});let n=await u.getHub(l);if(null==n)throw Error("Failed to read hub after insert");return e(n)}export async function upsertHub(r){return await createHub({stores:r.stores,url:r.url,serverDID:r.serverDID})}export async function updateHub(t){let u=await r(t.stores),i=await u.getHub(t.id);if(null==i)throw Error(`Hub ${t.id} not found`);if(null!=t.url&&t.url!==i.url){let r=await u.getHubByURL(t.url);if(null!=r&&r.id!==t.id)throw Error(`URL ${t.url} already in use by hub ${r.id}`)}let a={};void 0!==t.label&&(a.label=t.label),void 0!==t.url&&(a.url=t.url),void 0!==t.serverDID&&(a.server_did=t.serverDID),await u.updateHub(t.id,a);let l=await u.getHub(t.id);if(null==l)throw Error("Failed to read hub after update");return e(l)}export async function deleteHub(e){let t=await r(e.stores),u=await t.listGroupIDsByHubID(e.id);return u.length>0&&!e.force?{success:!1,boundGroupIDs:u}:(u.length>0&&await t.deleteGroupHubsByHubID(e.id),await t.deleteHub(e.id),{success:!0,boundGroupIDs:u})}export async function getHub(t){let u=await r(t.stores),i=await u.getHub(t.id);return null==i?null:e(i)}export async function listHubs(t){let u=await r(t.stores);return(await u.listHubs()).map(e)}export async function bindHubToGroup(e){let t=await r(e.stores);return await t.insertGroupHub({group_id:e.groupID,hub_id:e.hubID})}export async function unbindHubFromGroup(e){let t=await r(e.stores);return await t.deleteGroupHub({groupID:e.groupID,hubID:e.hubID})}export async function listHubsByGroup(t){let u=await r(t.stores);return(await u.listHubsByGroupID(t.groupID)).map(e)}export async function listGroupsByHub(e){let t=await r(e.stores);return await t.listGroupIDsByHubID(e.hubID)}export async function captureServerDID(e){let t=await r(e.stores),u=await t.getHubByURL(e.hubURL);if(null==u)return!1;if(null==u.server_did)return await t.updateHub(u.id,{server_did:e.serverDID}),!0;if(u.server_did===e.serverDID)return!1;throw new HubServerDIDConflictError({hubID:u.id,url:e.hubURL,existingServerDID:u.server_did,providedServerDID:e.serverDID})}
@@ -1,5 +1,5 @@
1
1
  import type { Logger } from '@kubun/logger';
2
- import { type BroadcastService } from '../groups/broadcast-service.js';
2
+ import { type BroadcastService, type ReceiveBroadcastResult } from '../groups/broadcast-service.js';
3
3
  /**
4
4
  * Shape of a message pushed by `hub/receive`. Matches `@enkaku/hub-protocol`
5
5
  * server-to-client push payload.
@@ -17,8 +17,26 @@ export type HandleReceivedMessageParams = {
17
17
  broadcastService: BroadcastService;
18
18
  logger?: Logger;
19
19
  };
20
+ /**
21
+ * Classification of a single received message's processing result. Drives both
22
+ * the ack decision and the epoch-stale health signal (see `EpochStaleDetector`):
23
+ * - `applied` — decrypted/applied cleanly; the device is at the current epoch.
24
+ * - `decrypt-failed` — could not derive the key for the current epoch.
25
+ * - `commit-out-of-order` — a Commit ahead of the local epoch (gap in stream).
26
+ * - `apply-failed` — decrypted but failed to apply to the local DB (transient).
27
+ * - `error` — unexpected failure.
28
+ * - `skipped` — not for this group; no processing attempted.
29
+ * - `control` — a rejoin control frame (request/groupinfo); processed cleanly
30
+ * but NOT a health indicator (must not reset epoch-stale tracking).
31
+ */
32
+ export type ReceiveOutcome = 'applied' | 'decrypt-failed' | 'commit-out-of-order' | 'apply-failed' | 'error' | 'skipped' | 'control';
20
33
  export type HandleReceivedMessageResult = {
21
34
  ack: boolean;
35
+ outcome: ReceiveOutcome;
36
+ sequenceID: string;
37
+ /** The processed result, present on success — lets the channel route rejoin
38
+ * control frames to the responder/requester. */
39
+ result?: ReceiveBroadcastResult;
22
40
  };
23
41
  /**
24
42
  * Apply a single hub-received message for a group.
@@ -28,6 +46,6 @@ export type HandleReceivedMessageResult = {
28
46
  * no recovery path). Returns `{ ack: false }` when the hub should redeliver
29
47
  * (transient apply failure or unexpected error). Skips with `{ ack: false }`
30
48
  * on groupID mismatch so the message stays queued for whichever recipient it
31
- * was intended for.
49
+ * was intended for. The `outcome` classifies the result for health tracking.
32
50
  */
33
51
  export declare function handleReceivedMessage(params: HandleReceivedMessageParams): Promise<HandleReceivedMessageResult>;
@@ -1 +1 @@
1
- import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,DecryptError as c}from"../groups/broadcast-service.js";export async function handleReceivedMessage(s){let{groupID:a,message:n,broadcastService:u,logger:i}=s;if(n.groupID!==a)return i?.debug("hub message groupID mismatch, skipping",{groupID:a,messageGroupID:n.groupID,sequenceID:n.sequenceID}),{ack:!1};let o=e(n.payload);try{await u.processReceived({groupID:a,encrypted:o})}catch(e){if(e instanceof c)return i?.debug("decrypt failed, acking and skipping",{groupID:a,sequenceID:n.sequenceID,error:e}),{ack:!0};if(e instanceof r)return i?.error("broadcast apply failed, will redeliver",{groupID:a,sequenceID:n.sequenceID,error:e}),{ack:!1};return i?.error("unexpected error processing hub message",{groupID:a,sequenceID:n.sequenceID,error:e}),{ack:!1}}return{ack:!0}}
1
+ import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,CommitOutOfOrderError as o,DecryptError as a,MalformedControlError as c}from"../groups/broadcast-service.js";export async function handleReceivedMessage(i){let t,{groupID:n,message:s,broadcastService:p,logger:u}=i,d=s.sequenceID;if(s.groupID!==n)return u?.debug("hub message groupID mismatch, skipping",{groupID:n,messageGroupID:s.groupID,sequenceID:d}),{ack:!1,outcome:"skipped",sequenceID:d};let l=e(s.payload);try{t=await p.processReceived({groupID:n,encrypted:l})}catch(e){if(e instanceof c)return u?.debug("malformed control frame, acking and skipping",{groupID:n,sequenceID:d,error:e}),{ack:!0,outcome:"control",sequenceID:d};if(e instanceof a)return u?.debug("decrypt failed, acking and skipping",{groupID:n,sequenceID:d,error:e}),{ack:!0,outcome:"decrypt-failed",sequenceID:d};if(e instanceof o)return u?.debug("mls commit out of order, will redeliver after gap fills",{groupID:n,sequenceID:d,messageEpoch:e.messageEpoch.toString(),handleEpoch:e.handleEpoch.toString()}),{ack:!1,outcome:"commit-out-of-order",sequenceID:d};if(e instanceof r)return u?.error("broadcast apply failed, will redeliver",{groupID:n,sequenceID:d,error:e}),{ack:!1,outcome:"apply-failed",sequenceID:d};return u?.error("unexpected error processing hub message",{groupID:n,sequenceID:d,error:e}),{ack:!1,outcome:"error",sequenceID:d}}return{ack:!0,outcome:"rejoin-request"===t.kind||"rejoin-groupinfo"===t.kind?"control":"applied",sequenceID:d,result:t}}
@@ -0,0 +1,78 @@
1
+ import { type GroupHandle, type MemberCredential } from '@enkaku/group';
2
+ import type { OwnIdentity } from '@enkaku/token';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
5
+ import type { RejoinResult } from '../types.js';
6
+ export type JoinExternalResult = {
7
+ /** Framed MLSMessage(Commit) bytes to broadcast to existing members. */
8
+ commitMessage: Uint8Array;
9
+ /** Optimistic post-commit handle (NOT yet persisted). */
10
+ pendingHandle: GroupHandle;
11
+ };
12
+ export type RejoinManagerParams = {
13
+ groupID: string;
14
+ identity: OwnIdentity;
15
+ /** Read the stale handle's cached MemberCredential (reused for the rejoin). */
16
+ readCredential: () => Promise<MemberCredential>;
17
+ /** Fresh correlation id per request (incl. retries). */
18
+ genRequestID: () => string;
19
+ /** Broadcast a `rejoin-request` on the group channel. */
20
+ sendRequest: (requestID: string) => Promise<void>;
21
+ /** Broadcast the resync Commit (`mls` frame) to existing members. */
22
+ sendCommit: (commitBytes: Uint8Array) => Promise<void>;
23
+ /** Atomically swap the cached handle for the confirmed one (registry). */
24
+ replaceHandle: (handle: GroupHandle) => Promise<void>;
25
+ monitor: GroupHealthMonitor;
26
+ /**
27
+ * Test seam: defaults to `@enkaku/group` `joinGroupExternal({resync:true})`
28
+ * built from `identity` + `readCredential`. Override to skip real MLS.
29
+ */
30
+ joinExternal?: (groupInfo: Uint8Array) => Promise<JoinExternalResult>;
31
+ /** Test seam: defaults to `@enkaku/group` `inspectGroupInfo`. */
32
+ inspectGroupInfo?: (groupInfo: Uint8Array) => {
33
+ epoch: bigint;
34
+ treeHash: Uint8Array;
35
+ };
36
+ requestTimeoutMs?: number;
37
+ confirmTimeoutMs?: number;
38
+ /** Delay between the candidate-head probe and its re-probe (settle window). */
39
+ confirmSettleMs?: number;
40
+ maxAttempts?: number;
41
+ backoffMs?: (attempt: number) => number;
42
+ logger?: Logger;
43
+ };
44
+ /**
45
+ * Drives a stale device's MLS epoch recovery for one group: per attempt,
46
+ * request GroupInfo → `joinGroupExternal({resync:true})` → broadcast the resync
47
+ * Commit → confirm → `replaceHandle`.
48
+ *
49
+ * Confirm is a tree-hash compare against the current head, because the hub never
50
+ * echoes the sender's own Commit. Three guards keep it reliable under concurrent
51
+ * rejoins (a resync Commit is canonical only for an instant):
52
+ * - behind-responder filter: a probe reply below our epoch can't confirm us.
53
+ * - chase-the-head: a reply above our epoch means we lost the race; re-join the
54
+ * newer GroupInfo next attempt (`{resync:true}` rebuilds the tree — no replay).
55
+ * - settle re-probe: re-probe after a delay and require the head still ours
56
+ * before swapping. Shrinks (not closes) the transient-head window — a Commit
57
+ * landing in the gap leaves us one epoch behind, which detect→rejoin catches.
58
+ * A fall-behind, never a silent fork; a wrong `recovered` is unreachable.
59
+ *
60
+ * Retries capped; exhaustion → `recovery-failed`.
61
+ */
62
+ export declare class RejoinManager {
63
+ #private;
64
+ constructor(params: RejoinManagerParams);
65
+ get inFlight(): boolean;
66
+ /**
67
+ * Route a received `rejoin-groupinfo`: resolve the matching in-flight waiter.
68
+ * Unknown `requestID`s (peers' requests, stale responses) are ignored.
69
+ */
70
+ onGroupInfo(requestID: string, groupInfo: Uint8Array): void;
71
+ /** Run the recovery flow. Idempotent: a concurrent call returns the loser early. */
72
+ rejoin(): Promise<RejoinResult>;
73
+ /**
74
+ * Stop a running rejoin and release timers. Idempotent; the loop checks
75
+ * `#disposed` between attempts, pending waiters resolve `null`. On channel close.
76
+ */
77
+ dispose(): void;
78
+ }
@@ -0,0 +1 @@
1
+ import{inspectGroupInfo as e,joinGroupExternal as t}from"@enkaku/group";import{bytesEqual as r,sleep as i,unref as s}from"../utils.js";export class RejoinManager{#e;#t;#r;#i;#s;#o;#n;#a;#l;#u;#h;#c;#p;#m;#d=new Map;#f=!1;#g=!1;constructor(r){this.#e=r.groupID,this.#t=r.genRequestID,this.#r=r.sendRequest,this.#i=r.sendCommit,this.#s=r.replaceHandle,this.#o=r.monitor,this.#n=r.joinExternal??(async e=>{let i=await r.readCredential(),{commitMessage:s,group:o}=await t({identity:r.identity,groupInfo:e,credential:i,resync:!0});return{commitMessage:s,pendingHandle:o}}),this.#a=r.inspectGroupInfo??e,this.#l=r.requestTimeoutMs??5e3,this.#u=r.confirmTimeoutMs??5e3,this.#h=r.confirmSettleMs??300,this.#c=r.maxAttempts??8,this.#p=r.backoffMs??o,this.#m=r.logger}get inFlight(){return this.#f}onGroupInfo(e,t){let r=this.#d.get(e);null!=r&&(this.#d.delete(e),r.resolve(t))}async rejoin(){if(this.#f)return{status:"recovery-failed"};this.#f=!0,this.#o.markRecovering(this.#e);try{for(let e=0;e<this.#c&&!this.#g&&(await i(this.#p(e)),!this.#g);e++){let e=await this.#w();if("recovered"===e)return this.#o.markRecovered(this.#e),{status:"recovered"}}return this.#o.markRecoveryFailed(this.#e),{status:"recovery-failed"}}finally{this.#f=!1}}async #w(){let e,t=await this.#I(this.#l);if(null==t)return"retry";try{e=await this.#n(t)}catch(e){return this.#m?.warn("rejoin: joinGroupExternal failed",{groupID:this.#e,error:e}),"retry"}let{commitMessage:s,pendingHandle:o}=e,n=o.epoch,a=o.treeHash;try{await this.#i(s)}catch(e){return this.#m?.warn("rejoin: resync Commit send failed",{groupID:this.#e,error:e}),"retry"}let l=await this.#y(n);if(null==l||!(l.epoch===n&&r(l.treeHash,a)))return"retry";await i(this.#h);let u=await this.#y(n);return null==u?"retry":u.epoch===n&&r(u.treeHash,a)?(await this.#s(o),"recovered"):"retry"}async #y(e){let t,r=await this.#I(this.#u);if(null==r)return null;try{t=this.#a(r)}catch(e){return this.#m?.warn("rejoin: confirm GroupInfo inspect failed",{groupID:this.#e,error:e}),null}return t.epoch<e?null:t}async #I(e){let t=this.#t(),r=this.#M(t,e);try{await this.#r(t)}catch(e){return this.#D(t),this.#m?.warn("rejoin: request send failed",{groupID:this.#e,error:e}),null}return r}#M(e,t){return new Promise(r=>{let i=setTimeout(()=>{this.#d.delete(e),r(null)},t);s(i),this.#d.set(e,{resolve:e=>{clearTimeout(i),r(e)},cancel:()=>{clearTimeout(i),r(null)}})})}#D(e){this.#d.delete(e)}dispose(){for(let e of(this.#g=!0,this.#d.values()))e.cancel();this.#d.clear()}}function o(e){return Math.floor(Math.random()*Math.min(500*2**(e-1),5e3))}
@@ -0,0 +1,32 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ export type RejoinResponderParams = {
3
+ groupID: string;
4
+ /** Produce a fresh GroupInfo for this group (current MLS handle). */
5
+ exportGroupInfo: () => Promise<Uint8Array>;
6
+ /** Broadcast a `rejoin-groupinfo` reply to the group channel. */
7
+ sendGroupInfo: (requestID: string, groupInfo: Uint8Array) => Promise<void>;
8
+ /** Jitter delay (ms) before replying. Defaults to a random 0..250ms. */
9
+ nextJitterMs?: () => number;
10
+ /** How long a `requestID` stays suppressed after being answered. */
11
+ suppressTTLMs?: number;
12
+ logger?: Logger;
13
+ };
14
+ /**
15
+ * Answers `rejoin-request`s with a fresh GroupInfo. Every member could reply, so
16
+ * jitter (random pre-reply delay) + suppression (cancel on seeing a peer's reply
17
+ * for the same `requestID`) collapse the storm to ~1 reply. Keyed on `requestID`
18
+ * (fresh per attempt); suppression entries TTL out to bound memory.
19
+ *
20
+ * Suppression collapses only duplicates of one `requestID`, so distinct
21
+ * `requestID`s still draw one reply each — an amplification. A per-window reply
22
+ * cap is tracked in `docs/agents/plans/backlog/rejoin-frame-authentication.md`.
23
+ */
24
+ export declare class RejoinResponder {
25
+ #private;
26
+ constructor(params: RejoinResponderParams);
27
+ /** Handle an inbound `rejoin-request`: schedule a jittered, suppressible reply. */
28
+ onRejoinRequest(requestID: string): void;
29
+ /** Handle an observed `rejoin-groupinfo` (own or peer's): suppress this request. */
30
+ onRejoinGroupInfoSeen(requestID: string): void;
31
+ dispose(): void;
32
+ }
@@ -0,0 +1 @@
1
+ import{unref as e}from"../utils.js";export class RejoinResponder{#e;#s;#t;#r;#p;#o;#i=new Map;#n=new Map;constructor(e){this.#e=e.groupID,this.#s=e.exportGroupInfo,this.#t=e.sendGroupInfo,this.#r=e.nextJitterMs??(()=>Math.floor(250*Math.random())),this.#p=e.suppressTTLMs??5e3,this.#o=e.logger}onRejoinRequest(s){if(this.#n.has(s)||this.#i.has(s))return;let t=setTimeout(()=>{this.#i.delete(s),this.#u(s)},this.#r());e(t),this.#i.set(s,t)}onRejoinGroupInfoSeen(e){let s=this.#i.get(e);null!=s&&(clearTimeout(s),this.#i.delete(e)),this.#h(e)}dispose(){for(let e of this.#i.values())clearTimeout(e);for(let e of(this.#i.clear(),this.#n.values()))clearTimeout(e);this.#n.clear()}async #u(e){if(!this.#n.has(e)){this.#h(e);try{let s=await this.#s();await this.#t(e,s)}catch(s){this.#o?.warn("rejoin responder failed to reply",{groupID:this.#e,requestID:e,error:s})}}}#h(s){let t=this.#n.get(s);null!=t&&clearTimeout(t);let r=setTimeout(()=>{this.#n.delete(s)},this.#p);e(r),this.#n.set(s,r)}}
@@ -1,10 +1,13 @@
1
1
  import type { Client } from '@enkaku/client';
2
2
  import { EventEmitter } from '@enkaku/event';
3
3
  import type { HubProtocol } from '@enkaku/hub-protocol';
4
+ import type { OwnIdentity } from '@enkaku/token';
4
5
  import type { Logger } from '@kubun/logger';
5
6
  import type { P2PStoreAPI } from '@kubun/store-p2p';
6
7
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
7
8
  import type { BroadcastService } from '../groups/broadcast-service.js';
9
+ import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
10
+ import type { RejoinResult } from '../types.js';
8
11
  import { type OnServerDIDObserved } from './connection-pool.js';
9
12
  import type { ServerDIDMismatchObserver, ServerDIDObserver } from './did-observing-transport.js';
10
13
  import { GroupChannel, type GroupChannelParams } from './group-channel.js';
@@ -72,6 +75,12 @@ export type HubRelayManagerParams = {
72
75
  backoffMaxMs?: number;
73
76
  backoffJitter?: number;
74
77
  broadcastTimeoutMs?: number;
78
+ /** When provided, receive outcomes drive epoch-stale health detection. */
79
+ monitor?: GroupHealthMonitor;
80
+ /** Device identity — enables the rejoin flow (needs `joinGroupExternal`). */
81
+ identity?: OwnIdentity;
82
+ /** Fresh correlation ids for rejoin requests. */
83
+ getRandomID?: () => string;
75
84
  createGroupChannel?: CreateGroupChannel;
76
85
  createHubConnection?: CreateHubConnection;
77
86
  /**
@@ -85,7 +94,7 @@ export type HubRelayManagerParams = {
85
94
  };
86
95
  export declare class HubRelayManager {
87
96
  #private;
88
- readonly relayEvents: EventEmitter<RelayManagerEvents>;
97
+ get relayEvents(): EventEmitter<RelayManagerEvents>;
89
98
  constructor(params: HubRelayManagerParams);
90
99
  /**
91
100
  * Open channels for each group in parallel. Per-group failures are logged
@@ -112,9 +121,22 @@ export declare class HubRelayManager {
112
121
  */
113
122
  removeBinding(groupID: string, hubURL: string): Promise<void>;
114
123
  removeGroup(groupID: string): Promise<void>;
124
+ /**
125
+ * Run stale-device MLS epoch recovery for an active group via its channel.
126
+ * Throws if the group is not active; returns `recovery-failed` if the rejoin
127
+ * flow is unavailable (no monitor/identity wired).
128
+ */
129
+ rejoinGroup(groupID: string): Promise<RejoinResult>;
115
130
  broadcast(groupID: string, message: GroupBroadcastMessage, opts?: {
116
131
  timeoutMs?: number;
117
132
  }): Promise<void>;
133
+ /**
134
+ * Fan out a pre-produced MLS handshake Commit to a group's existing members
135
+ * via its active channel. Mirrors {@link broadcast}.
136
+ */
137
+ sendCommit(groupID: string, commitBytes: Uint8Array, opts?: {
138
+ timeoutMs?: number;
139
+ }): Promise<void>;
118
140
  stop(): Promise<void>;
119
141
  }
120
142
  export {};
@@ -1 +1 @@
1
- import{EventEmitter as e}from"@enkaku/event";import{HubConnectionPool as t}from"./connection-pool.js";import{GroupChannel as s}from"./group-channel.js";import{HubConnection as r}from"./hub-connection.js";export class HubRelayManager{#e;#t;#s;#r;#o;#a;#i;#n;#l;#c;#u;#h;#p;#g=new Map;#d=new Map;#b=new Map;#f=new Map;relayEvents;constructor(o){this.#e=o.p2pStore,this.#t=o.broadcastService,this.#s=o.deviceID,this.#o=o.logger,this.#a=o.ackFlushMs??500,this.#i=o.ackFlushMax??10,this.#n=o.backoffBaseMs??1e3,this.#l=o.backoffMaxMs??6e4,this.#c=o.backoffJitter??.25,this.#u=o.broadcastTimeoutMs;let a=o.p2pStore;this.#r=new t({createHubClient:o.createHubClient,logger:o.logger,onServerDIDObserved:o.onServerDIDObserved,resolvePinnedDID:async e=>{let t=await a.getHubByURL(e);return t?.server_did??null}}),this.#h=o.createGroupChannel??(e=>new s(e)),this.#p=o.createHubConnection??(e=>new r(e)),this.relayEvents=new e}async #M(e){let t=this.#g.get(e);if(null!=t)return t;let s=this.#p({hubURL:e,deviceID:this.#s,pool:this.#r,logger:this.#o,ackFlushMs:this.#a,ackFlushMax:this.#i,backoffBaseMs:this.#n,backoffMaxMs:this.#l,backoffJitter:this.#c});try{await s.open()}catch(e){try{await s.dispose()}catch{}throw e}let r=s.events.on("lifecycle",async t=>{let s=Object.freeze([...this.#b.entries()].filter(([,t])=>t===e).map(([e])=>e));await this.relayEvents.emit("relay",{...t,hubURL:e,groupIDs:s})});return this.#f.set(e,r),this.#g.set(e,s),s}async start(e){(await Promise.allSettled(e.map(e=>this.addGroup(e)))).forEach((t,s)=>{"rejected"===t.status&&this.#o?.error("addGroup failed during start",{groupID:e[s],error:t.reason})})}async addGroup(e){if(this.#d.has(e))return void this.#o?.debug("group already added",{groupID:e});if(null==await this.#e.getGroup(e))throw Error(`Group ${e} not found`);let t=await this.#e.listHubsByGroupID(e);0===t.length?this.#o?.warn("group has no hub bindings, skipping",{groupID:e}):(t.length>1&&this.#o?.warn("multi-hub not yet supported, using first",{groupID:e,hubURLs:t.map(e=>e.url)}),await this.#v(e,t[0].url))}async #v(e,t){let s=await this.#M(t),r=this.#h({groupID:e,deviceID:this.#s,hubConnection:s,broadcastService:this.#t,p2pStore:this.#e,logger:this.#o});await r.open(),this.#d.set(e,r),this.#b.set(e,t)}async addBinding(e,t){let s=this.#b.get(e);null!=s?s===t?this.#o?.debug("binding already active",{groupID:e,hubURL:t}):this.#o?.warn("multi-hub not yet supported, ignoring extra binding",{groupID:e,activeHubURL:s,newHubURL:t}):await this.#v(e,t)}async removeBinding(e,t){this.#b.get(e)!==t?this.#o?.debug("binding not active, ignoring removal",{groupID:e,hubURL:t}):await this.removeGroup(e)}async removeGroup(e){let t=this.#d.get(e);null!=t&&(await t.close(),this.#d.delete(e),this.#b.delete(e))}async broadcast(e,t,s){let r=this.#d.get(e);if(null==r)throw Error(`Group ${e} is not active in HubRelayManager`);let o=s?.timeoutMs??this.#u;await r.broadcast(t,{timeoutMs:o})}async stop(){for(let e of this.#f.values())e();this.#f.clear();let e=[...this.#d.values()];this.#d.clear(),this.#b.clear(),(await Promise.allSettled(e.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#o?.warn("GroupChannel close failed",{error:e.reason})});let t=[...this.#g.values()];this.#g.clear(),(await Promise.allSettled(t.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#o?.warn("HubConnection close failed",{error:e.reason})}),await this.#r.disposeAll()}}
1
+ import{EventEmitter as e}from"@enkaku/event";import{HubConnectionPool as t}from"./connection-pool.js";import{GroupChannel as s}from"./group-channel.js";import{HubConnection as o}from"./hub-connection.js";export class HubRelayManager{#e;#t;#s;#o;#r;#i;#a;#n;#l;#c;#u;#h;#g;#p;#d;#b;#f=new Map;#m=new Map;#M=new Map;#v=new Map;#w;get relayEvents(){return this.#w}constructor(r){this.#e=r.p2pStore,this.#t=r.broadcastService,this.#s=r.deviceID,this.#r=r.logger,this.#i=r.ackFlushMs??500,this.#a=r.ackFlushMax??10,this.#n=r.backoffBaseMs??1e3,this.#l=r.backoffMaxMs??6e4,this.#c=r.backoffJitter??.25,this.#u=r.broadcastTimeoutMs,this.#h=r.monitor,this.#g=r.identity,this.#p=r.getRandomID;let i=r.p2pStore;this.#o=new t({createHubClient:r.createHubClient,logger:r.logger,onServerDIDObserved:r.onServerDIDObserved,resolvePinnedDID:async e=>{let t=await i.getHubByURL(e);return t?.server_did??null}}),this.#d=r.createGroupChannel??(e=>new s(e)),this.#b=r.createHubConnection??(e=>new o(e)),this.#w=new e}async #y(e){let t=this.#f.get(e);if(null!=t)return t;let s=this.#b({hubURL:e,deviceID:this.#s,pool:this.#o,logger:this.#r,ackFlushMs:this.#i,ackFlushMax:this.#a,backoffBaseMs:this.#n,backoffMaxMs:this.#l,backoffJitter:this.#c});try{await s.open()}catch(e){try{await s.dispose()}catch{}throw e}let o=s.events.on("lifecycle",async t=>{let s=Object.freeze([...this.#M.entries()].filter(([,t])=>t===e).map(([e])=>e));await this.#w.emit("relay",{...t,hubURL:e,groupIDs:s})});return this.#v.set(e,o),this.#f.set(e,s),s}async start(e){(await Promise.allSettled(e.map(e=>this.addGroup(e)))).forEach((t,s)=>{"rejected"===t.status&&this.#r?.error("addGroup failed during start",{groupID:e[s],error:t.reason})})}async addGroup(e){if(this.#m.has(e))return void this.#r?.debug("group already added",{groupID:e});if(null==await this.#e.getGroup(e))throw Error(`Group ${e} not found`);let t=await this.#e.listHubsByGroupID(e);0===t.length?this.#r?.warn("group has no hub bindings, skipping",{groupID:e}):(t.length>1&&this.#r?.warn("multi-hub not yet supported, using first",{groupID:e,hubURLs:t.map(e=>e.url)}),await this.#k(e,t[0].url))}async #k(e,t){let s=await this.#y(t),o=this.#d({groupID:e,deviceID:this.#s,hubConnection:s,broadcastService:this.#t,p2pStore:this.#e,logger:this.#r,monitor:this.#h,identity:this.#g,getRandomID:this.#p});await o.open(),this.#m.set(e,o),this.#M.set(e,t)}async addBinding(e,t){let s=this.#M.get(e);null!=s?s===t?this.#r?.debug("binding already active",{groupID:e,hubURL:t}):this.#r?.warn("multi-hub not yet supported, ignoring extra binding",{groupID:e,activeHubURL:s,newHubURL:t}):await this.#k(e,t)}async removeBinding(e,t){this.#M.get(e)!==t?this.#r?.debug("binding not active, ignoring removal",{groupID:e,hubURL:t}):await this.removeGroup(e)}async removeGroup(e){let t=this.#m.get(e);null!=t&&(await t.close(),this.#m.delete(e),this.#M.delete(e))}async rejoinGroup(e){let t=this.#m.get(e);if(null==t)throw Error(`Group ${e} is not active in HubRelayManager`);return await t.rejoinGroup()}async broadcast(e,t,s){let o=this.#m.get(e);if(null==o)throw Error(`Group ${e} is not active in HubRelayManager`);let r=s?.timeoutMs??this.#u;await o.broadcast(t,{timeoutMs:r})}async sendCommit(e,t,s){let o=this.#m.get(e);if(null==o)throw Error(`Group ${e} is not active in HubRelayManager`);let r=s?.timeoutMs??this.#u;await o.sendCommit(t,{timeoutMs:r})}async stop(){for(let e of this.#v.values())e();this.#v.clear();let e=[...this.#m.values()];this.#m.clear(),this.#M.clear(),(await Promise.allSettled(e.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#r?.warn("GroupChannel close failed",{error:e.reason})});let t=[...this.#f.values()];this.#f.clear(),(await Promise.allSettled(t.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#r?.warn("HubConnection close failed",{error:e.reason})}),await this.#o.disposeAll()}}
@@ -22,3 +22,19 @@ export type HandleSendBroadcastParams = {
22
22
  * runs INSIDE the registry's per-group lock and a throw skips persist.
23
23
  */
24
24
  export declare function handleSendBroadcast(params: HandleSendBroadcastParams): Promise<void>;
25
+ export type HandleSendCommitParams = {
26
+ groupID: string;
27
+ /** Framed MLSMessage(Commit) bytes from the committer's `GroupManager` call. */
28
+ commitBytes: Uint8Array;
29
+ broadcastService: BroadcastService;
30
+ /** Same wire-send callback as {@link HandleSendBroadcastParams.send}. */
31
+ send: (groupID: string, payloadB64: string) => Promise<void>;
32
+ logger?: Logger;
33
+ };
34
+ /**
35
+ * Fan out a pre-produced MLS handshake Commit to a group's existing members.
36
+ * Unlike {@link handleSendBroadcast} this does not encrypt — the Commit is
37
+ * already a self-protected `PrivateMessage`; `prepareSendCommit` just tags it
38
+ * `mls` on the wire.
39
+ */
40
+ export declare function handleSendCommit(params: HandleSendCommitParams): Promise<void>;
@@ -1 +1 @@
1
- import{toB64 as r}from"@enkaku/codec";export async function handleSendBroadcast(e){let{groupID:a,message:o,broadcastService:n,send:t,logger:d}=e;try{await n.prepareSend({groupID:a,message:o,send:e=>t(a,r(e))})}catch(r){throw d?.error("hub/group/send failed",{groupID:a,error:r}),r}}
1
+ import{toB64 as r}from"@enkaku/codec";export async function handleSendBroadcast(e){let{groupID:o,message:t,broadcastService:a,send:n,logger:d}=e;try{await a.prepareSend({groupID:o,message:t,send:e=>n(o,r(e))})}catch(r){throw d?.error("hub/group/send failed",{groupID:o,error:r}),r}}export async function handleSendCommit(e){let{groupID:o,commitBytes:t,broadcastService:a,send:n,logger:d}=e;try{await a.prepareSendCommit({groupID:o,commitBytes:t,send:e=>n(o,r(e))})}catch(r){throw d?.error("hub/group/send (mls commit) failed",{groupID:o,error:r}),r}}
@@ -1,13 +1,15 @@
1
1
  import type { OwnIdentity } from '@enkaku/token';
2
2
  import type { KubunDB } from '@kubun/db';
3
3
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
+ import type { HLC } from '@kubun/hlc';
4
5
  import type { Logger } from '@kubun/logger';
5
6
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
7
  import { type BroadcastEvent } from '../groups/broadcast-service.js';
7
- import type { GroupEventEmitter } from '../groups/events.js';
8
+ import type { P2PEventEmitter } from '../groups/events.js';
8
9
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
10
+ import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
9
11
  import type { ForwardingConfig } from '../sync/forwarder.js';
10
- import type { StoreUnreadableMode } from '../types.js';
12
+ import type { RejoinResult, StoreUnreadableMode } from '../types.js';
11
13
  import type { OnServerDIDObserved } from './connection-pool.js';
12
14
  import type { GroupChannel, GroupChannelParams } from './group-channel.js';
13
15
  import type { HubConnection, HubConnectionParams } from './hub-connection.js';
@@ -15,10 +17,30 @@ import { type CreateHubClient } from './relay-manager.js';
15
17
  export type HubWiring = {
16
18
  ready: Promise<void>;
17
19
  scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
20
+ /**
21
+ * Awaitable broadcast — resolves only after the message is encrypted + sent.
22
+ * Use when ordering matters relative to a subsequent local op (e.g. a
23
+ * leave-request that must be encrypted with the still-live MLS handle before
24
+ * `leaveGroup` tears it down). Rejects on send failure.
25
+ */
26
+ broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
27
+ /**
28
+ * Fire-and-forget fan-out of a pre-produced MLS handshake Commit to a
29
+ * group's existing members. Wired to `HubRelayManager.sendCommit`.
30
+ */
31
+ scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
32
+ /**
33
+ * Run stale-device MLS epoch recovery for an active group. Wired to
34
+ * `HubRelayManager.rejoinGroup`; a no-op `recovery-failed` under
35
+ * `DISABLED_HUB` (no relay, nothing to rejoin).
36
+ */
37
+ rejoinGroup: (groupID: string) => Promise<RejoinResult>;
18
38
  dispose: () => Promise<void>;
19
39
  };
20
40
  export type SetupHubRelayParams = {
21
41
  identity: OwnIdentity;
42
+ /** Fresh correlation ids for the rejoin flow. */
43
+ getRandomID: () => string;
22
44
  db: KubunDB;
23
45
  /**
24
46
  * Engine graph internals — threaded through to `BroadcastService` so the
@@ -26,7 +48,7 @@ export type SetupHubRelayParams = {
26
48
  * through the engine with `origin: 'peer'`.
27
49
  */
28
50
  graph: GraphInternals;
29
- emitter: GroupEventEmitter;
51
+ emitter: P2PEventEmitter;
30
52
  createHubClient: CreateHubClient;
31
53
  onRemoteBroadcast: (event: BroadcastEvent) => void;
32
54
  /**
@@ -35,6 +57,8 @@ export type SetupHubRelayParams = {
35
57
  * mutex once Phase 3 lands. Currently stored but unused.
36
58
  */
37
59
  registry: GroupHandleRegistry;
60
+ /** Shared health monitor; receive outcomes drive epoch-stale detection. */
61
+ monitor?: GroupHealthMonitor;
38
62
  logger: Logger;
39
63
  /**
40
64
  * Optional testing seam forwarded to `HubRelayManager`. Production code
@@ -68,6 +92,12 @@ export type SetupHubRelayParams = {
68
92
  * pinning; omitting it skips capture entirely.
69
93
  */
70
94
  onServerDIDObserved?: OnServerDIDObserved;
95
+ /**
96
+ * Device-wide monotonic clock forwarded to `BroadcastService` for stamping
97
+ * `removed_at_hlc` on roster reconciliation. Required: production passes the
98
+ * engine's single instance so roster writes share the device's one clock.
99
+ */
100
+ hlc: HLC;
71
101
  };
72
102
  export declare function setupHubRelay(params: SetupHubRelayParams): HubWiring;
73
103
  export declare const DISABLED_HUB: HubWiring;
package/lib/hub/wiring.js CHANGED
@@ -1 +1 @@
1
- import{BroadcastService as r}from"../groups/broadcast-service.js";import{HubRelayManager as e}from"./relay-manager.js";export function setupHubRelay(o){let a,{identity:t,db:d,graph:i,emitter:s,createHubClient:n,onRemoteBroadcast:p,registry:c,logger:u,createGroupChannel:h,createHubConnection:l,storeUnreadable:g,defaultAccessLevel:f,forwarding:m,onServerDIDObserved:y}=o,b=[],D=(r,e)=>{a.then(o=>o.broadcast(r,e).catch(e=>{u.error("broadcast failed",{groupID:r,error:e})}),()=>{})};return(a=(async()=>{let[o,a]=await Promise.all([d.getStore("p2p"),d.getStore("graph")]),v=new r({p2pStore:o,graphStore:a,graph:i,selfDID:t.id,registry:c,storeUnreadable:g,defaultAccessLevel:f,forwarding:m,scheduleBroadcast:D,logger:u}),B=new e({p2pStore:o,broadcastService:v,deviceID:t.id,createHubClient:n,logger:u,createGroupChannel:h,createHubConnection:l,onServerDIDObserved:y});b.push(v.on("broadcast",p)),b.push(s.on("groupJoined",r=>B.addGroup(r.id).catch(e=>{u.error("addGroup failed",{groupID:r.id,error:e})}))),b.push(s.on("groupLeft",r=>B.removeGroup(r.groupID).catch(e=>{u.error("removeGroup failed",{groupID:r.groupID,error:e})}))),b.push(s.on("hubBound",({groupID:r,hubURL:e})=>B.addBinding(r,e).catch(o=>{u.error("addBinding failed",{groupID:r,hubURL:e,error:o})}))),b.push(s.on("hubUnbound",({groupID:r,hubURL:e})=>B.removeBinding(r,e).catch(o=>{u.error("removeBinding failed",{groupID:r,hubURL:e,error:o})})));let w=await o.listGroups();return await B.start(w.map(r=>r.id)),B})()).catch(r=>{u.error("hub relay setup failed",{error:r})}),{ready:a.then(()=>void 0),scheduleBroadcast:D,dispose:async()=>{for(let r of b)try{r()}catch{}b.length=0;try{let r=await a;await r.stop()}catch{}}}}export const DISABLED_HUB={ready:Promise.resolve(),scheduleBroadcast:()=>{},dispose:async()=>{}};
1
+ import{BroadcastService as e}from"../groups/broadcast-service.js";import{HubRelayManager as r}from"./relay-manager.js";export function setupHubRelay(a){let o,{identity:t,getRandomID:d,db:i,graph:s,emitter:n,createHubClient:c,onRemoteBroadcast:u,registry:p,monitor:l,logger:h,createGroupChannel:m,createHubConnection:g,storeUnreadable:f,defaultAccessLevel:y,forwarding:w,onServerDIDObserved:b,hlc:v}=a,D=[],B=(e,r)=>{o.then(a=>a.broadcast(e,r).catch(r=>{h.error("broadcast failed",{groupID:e,error:r})}),()=>{})},G=async(e,r)=>{let a=await o;await a.broadcast(e,r)},I=async e=>{let r=await o;return await r.rejoinGroup(e)};return(o=(async()=>{let[a,o]=await Promise.all([i.getStore("p2p"),i.getStore("graph")]),G=new e({p2pStore:a,graphStore:o,graph:s,selfDID:t.id,registry:p,hlc:v,storeUnreadable:f,defaultAccessLevel:y,forwarding:w,scheduleBroadcast:B,emitter:n,logger:h}),I=new r({p2pStore:a,broadcastService:G,deviceID:t.id,createHubClient:c,monitor:l,identity:t,getRandomID:d,logger:h,createGroupChannel:m,createHubConnection:g,onServerDIDObserved:b});D.push(G.on("broadcast",u)),D.push(n.on("groupJoined",e=>I.addGroup(e.id).catch(r=>{h.error("addGroup failed",{groupID:e.id,error:r})}))),D.push(n.on("groupLeft",e=>I.removeGroup(e.groupID).catch(r=>{h.error("removeGroup failed",{groupID:e.groupID,error:r})}))),D.push(n.on("hubBound",({groupID:e,hubURL:r})=>I.addBinding(e,r).catch(a=>{h.error("addBinding failed",{groupID:e,hubURL:r,error:a})}))),D.push(n.on("hubUnbound",({groupID:e,hubURL:r})=>I.removeBinding(e,r).catch(a=>{h.error("removeBinding failed",{groupID:e,hubURL:r,error:a})})));let S=await a.listGroups();return await I.start(S.map(e=>e.id)),I})()).catch(e=>{h.error("hub relay setup failed",{error:e})}),{ready:o.then(()=>void 0),scheduleBroadcast:B,broadcastNow:G,scheduleSendCommit:(e,r)=>{o.then(a=>a.sendCommit(e,r).catch(r=>{h.error("sendCommit failed",{groupID:e,error:r})}),()=>{})},rejoinGroup:I,dispose:async()=>{for(let e of D)try{e()}catch{}D.length=0;try{let e=await o;await e.stop()}catch{}}}}export const DISABLED_HUB={ready:Promise.resolve(),scheduleBroadcast:()=>{},broadcastNow:async()=>{},scheduleSendCommit:()=>{},rejoinGroup:async()=>({status:"recovery-failed"}),dispose:async()=>{}};
package/lib/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export type { ContextDeps, PendingJoinRequest } from './context/types.js';
8
8
  export { type AffectedRow, type GroupBroadcastMessage, type MutationApplyEntry, type ProcessBroadcastParams, type ProcessBroadcastResult, processBroadcast, } from './groups/broadcast.js';
9
9
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
10
10
  export { ApplyError, type BroadcastEvent, BroadcastService, type BroadcastServiceEvents, type BroadcastServiceParams, DecryptError, type ReceiveBroadcastParams, type ReceiveBroadcastResult, type SendBroadcastParams, } from './groups/broadcast-service.js';
11
- export { createFilteredGenerator, createGroupEventEmitter, type GroupEventEmitter, type GroupEventMap, } from './groups/events.js';
11
+ export { createFilteredGenerator, createP2PEventEmitter, type P2PEventEmitter, type P2PEventMap, } from './groups/events.js';
12
12
  export { GroupHandleRegistry, type GroupHandleRegistryParams, type SeedParams, } from './groups/group-handle-registry.js';
13
13
  export { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeFullJoinRequest, encodeInvitePayload, encodeJoinRequest, type FullJoinRequestPayload, type InvitePayload, type JoinRequestPayload, type SuggestedHub, } from './groups/invite-payload.js';
14
14
  export { type AddCircleMemberParams, type AddCircleMemberResult, type CreateCircleParams, type CreateCircleResult, type CreateGroupParams, type CreateGroupResult, type DeleteCircleParams, type DeleteCircleResult, GroupManager, type GroupManagerParams, type InviteToGroupParams, type InviteToGroupResult, type JoinGroupParams, type JoinGroupResult, type LeaveGroupParams, type LeaveGroupResult, type RemoveCircleMemberParams, type RemoveCircleMemberResult, type RemoveGroupMemberParams, type RemoveGroupMemberResult, type RemoveMemberParams, type RemoveMemberResult, type UpdateCircleParams, type UpdateCircleResult, type UpdateGroupParams, type UpdateGroupResult, } from './groups/manager.js';
@@ -20,13 +20,13 @@ export type { SyncClientMessage, SyncProtocol, SyncServerMessage } from './proto
20
20
  export { createP2PSchemaExtension } from './schema.js';
21
21
  export { type BroadcastBatchConfig, type BroadcastQueue, type BroadcastQueueParams, createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG, } from './sync/broadcast-queue.js';
22
22
  export { type BroadcastSenderParams, DEFAULT_PUSH_SYNC_CONFIG, type PushSyncConfig, wireBroadcastSender, } from './sync/broadcast-sender.js';
23
- export { catalogMatchesDoc } from './sync/catalog-match.js';
24
23
  export { type CatalogSyncScope, resolveCatalogSyncScopes } from './sync/catalog-scope.js';
24
+ export { SyncAccessDeniedError } from './sync/errors.js';
25
25
  export { type EvaluateAndForwardParams, type EvaluateAndForwardResult, evaluateAndForward, type ForwardContext, type ForwardFilter, type ForwardingConfig, } from './sync/forwarder.js';
26
- export { checkSyncDelegation, negotiateDirection } from './sync/handlers.js';
26
+ export { checkSyncDelegation } from './sync/handlers.js';
27
27
  export { HubTunnelSyncListener, type HubTunnelSyncListenerParams, } from './sync/hub-tunnel-sync-listener.js';
28
28
  export { HubTunnelSyncProvider, type HubTunnelSyncProviderParams, } from './sync/hub-tunnel-sync-provider.js';
29
- export { type PeerConfig, type PeerConfigWithID, PeerRegistry } from './sync/peer-registry.js';
29
+ export { negotiateDirection, type PeerConfig, type PeerConfigWithID, PeerRegistry, } from './sync/peer-registry.js';
30
30
  export { SyncClient, type SyncClientParams, type SyncScope, type SyncTransportProvider, } from './sync/sync-client.js';
31
31
  export { type SyncEvent, SyncManager, type SyncManagerParams, type SyncSessionInfo, type SyncStatus, } from './sync/sync-manager.js';
32
32
  export type { OnServerDIDObserved } from './hub/connection-pool.js';
@@ -58,7 +58,21 @@ export type ReceiveConfig = {
58
58
  export declare const DEFAULT_RECEIVE_CONFIG: ReceiveConfig;
59
59
  export type P2PPluginOptions = {
60
60
  autoAcceptPeers?: Array<string>;
61
- http?: boolean | string;
61
+ /**
62
+ * Expose the sync protocol over the HTTP plugin transport.
63
+ * - `true` / `{}` — enable at the default protocol path `'sync'` with CORS
64
+ * open to all origins (`['*']`).
65
+ * - `'name'` / `'/name'` — enable at the given protocol path.
66
+ * - `{ path?, allowedOrigin? }` — `path` is the protocol path (default
67
+ * `'sync'`); `allowedOrigin` restricts CORS origins (default `['*']`).
68
+ * The default is open because the sync server authenticates every request,
69
+ * so CORS is not the security boundary; operators may still restrict it.
70
+ * - absent / `false` — HTTP sync transport disabled.
71
+ */
72
+ http?: boolean | string | {
73
+ path?: string;
74
+ allowedOrigin?: string | Array<string>;
75
+ };
62
76
  /**
63
77
  * Enable hub-mediated group broadcasts.
64
78
  * - `true` / `{}` — build a default HTTP hub client factory from