@kubun/plugin-p2p 0.8.3 → 0.10.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 (88) 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.d.ts +4 -0
  5. package/lib/context/hub.js +1 -0
  6. package/lib/context/join.js +1 -1
  7. package/lib/context/types.d.ts +28 -2
  8. package/lib/groups/broadcast-service.d.ts +130 -17
  9. package/lib/groups/broadcast-service.js +1 -1
  10. package/lib/groups/broadcast.d.ts +162 -5
  11. package/lib/groups/broadcast.js +1 -1
  12. package/lib/groups/events.d.ts +26 -5
  13. package/lib/groups/events.js +1 -1
  14. package/lib/groups/group-handle-registry.d.ts +90 -0
  15. package/lib/groups/group-handle-registry.js +1 -0
  16. package/lib/groups/invite-payload.d.ts +21 -1
  17. package/lib/groups/join-utils.d.ts +68 -2
  18. package/lib/groups/join-utils.js +1 -1
  19. package/lib/groups/manager.d.ts +26 -18
  20. package/lib/groups/manager.js +1 -1
  21. package/lib/groups/mls-codec.d.ts +7 -0
  22. package/lib/groups/mls-codec.js +1 -0
  23. package/lib/groups/mls-encryptor.d.ts +25 -0
  24. package/lib/groups/mls-encryptor.js +1 -0
  25. package/lib/groups/store-received-grant.d.ts +47 -0
  26. package/lib/groups/store-received-grant.js +1 -0
  27. package/lib/groups/store-received-revocation.d.ts +46 -0
  28. package/lib/groups/store-received-revocation.js +1 -0
  29. package/lib/groups/wire-frame.d.ts +32 -0
  30. package/lib/groups/wire-frame.js +1 -0
  31. package/lib/hub/connection-pool.d.ts +24 -0
  32. package/lib/hub/connection-pool.js +1 -1
  33. package/lib/hub/did-observing-transport.d.ts +64 -0
  34. package/lib/hub/did-observing-transport.js +1 -0
  35. package/lib/hub/errors.d.ts +28 -0
  36. package/lib/hub/errors.js +1 -0
  37. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  38. package/lib/hub/forward-remote-broadcast.js +1 -0
  39. package/lib/hub/group-channel.d.ts +37 -21
  40. package/lib/hub/group-channel.js +1 -1
  41. package/lib/hub/http-client.d.ts +17 -0
  42. package/lib/hub/http-client.js +1 -0
  43. package/lib/hub/hub-connection.d.ts +96 -0
  44. package/lib/hub/hub-connection.js +1 -0
  45. package/lib/hub/manager.d.ts +117 -0
  46. package/lib/hub/manager.js +1 -0
  47. package/lib/hub/receive-handler.d.ts +3 -6
  48. package/lib/hub/receive-handler.js +1 -1
  49. package/lib/hub/relay-manager.d.ts +92 -2
  50. package/lib/hub/relay-manager.js +1 -1
  51. package/lib/hub/send-handler.d.ts +28 -16
  52. package/lib/hub/send-handler.js +1 -1
  53. package/lib/hub/tunnel-inbox.d.ts +20 -0
  54. package/lib/hub/tunnel-inbox.js +1 -0
  55. package/lib/hub/wait-for-gate.d.ts +14 -0
  56. package/lib/hub/wait-for-gate.js +1 -0
  57. package/lib/hub/wiring.d.ts +85 -0
  58. package/lib/hub/wiring.js +1 -0
  59. package/lib/index.d.ts +102 -7
  60. package/lib/index.js +1 -1
  61. package/lib/schema.d.ts +2 -2
  62. package/lib/schema.js +113 -21
  63. package/lib/sync/broadcast-queue.d.ts +59 -0
  64. package/lib/sync/broadcast-queue.js +1 -0
  65. package/lib/sync/broadcast-sender.d.ts +52 -0
  66. package/lib/sync/broadcast-sender.js +1 -0
  67. package/lib/sync/forwarder.d.ts +97 -0
  68. package/lib/sync/forwarder.js +1 -0
  69. package/lib/sync/handlers.d.ts +20 -1
  70. package/lib/sync/handlers.js +1 -1
  71. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  72. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  73. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  74. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  75. package/lib/sync/merkle-apply.d.ts +41 -0
  76. package/lib/sync/merkle-apply.js +1 -1
  77. package/lib/sync/merkle-channel.d.ts +15 -0
  78. package/lib/sync/merkle-channel.js +1 -1
  79. package/lib/sync/receive-access-gate.d.ts +36 -0
  80. package/lib/sync/receive-access-gate.js +1 -0
  81. package/lib/sync/scope-resolver.d.ts +32 -0
  82. package/lib/sync/scope-resolver.js +1 -0
  83. package/lib/sync/sync-manager.d.ts +10 -0
  84. package/lib/sync/sync-manager.js +1 -1
  85. package/lib/types.d.ts +151 -1
  86. package/lib/util/mutex.d.ts +4 -0
  87. package/lib/util/mutex.js +1 -0
  88. package/package.json +40 -37
@@ -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 { P2PEventEmitter } from '../groups/events.js';
4
+ /**
5
+ * Map a `BroadcastEvent` from `BroadcastService` into the matching
6
+ * `P2PEventEmitter` 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: P2PEventEmitter, 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("delegation:share"!==i.type){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,43 @@ 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>;
59
+ /**
60
+ * Fan out a pre-produced MLS handshake Commit to the group's existing
61
+ * members. Mirrors {@link broadcast} but routes through `handleSendCommit`
62
+ * (no application encryption — the Commit is already self-protected).
63
+ */
64
+ sendCommit(commitBytes: Uint8Array, opts?: {
65
+ timeoutMs?: number;
66
+ }): Promise<void>;
51
67
  }
@@ -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,handleSendCommit as i}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;#d=!1;constructor(e){super({dispose:async()=>{await this.#l()}}),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.#d)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.#d=!0}#p(e){return t({groupID:this.#n,message:e,broadcastService:this.#u,logger:this.#i})}async close(){this.#d&&await this.dispose()}async #l(){if(this.#d){try{await this.#a.unsubscribe(this.#n)}catch(e){this.#i?.warn("hub-connection unsubscribe failed during close",{groupID:this.#n,error:e})}this.#d=!1}}broadcast(e,t){return this.#d?this.#a.runInGroup(this.#n,async()=>{if(!this.#d)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`))}sendCommit(e,t){return this.#d?this.#a.runInGroup(this.#n,async()=>{if(!this.#d)throw Error(`GroupChannel for ${this.#n} is not ready for sendCommit`);await i({groupID:this.#n,commitBytes: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 sendCommit`))}}
@@ -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;#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}}
@@ -0,0 +1,117 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ export type Hub = {
3
+ id: string;
4
+ label: string | null;
5
+ url: string;
6
+ serverDID: string | null;
7
+ createdAt: Date;
8
+ updatedAt: Date | null;
9
+ };
10
+ export type CreateHubParams = {
11
+ stores: StoreProvider;
12
+ url: string;
13
+ label?: string | null;
14
+ serverDID?: string | null;
15
+ };
16
+ export type UpdateHubParams = {
17
+ stores: StoreProvider;
18
+ id: string;
19
+ label?: string | null;
20
+ url?: string;
21
+ serverDID?: string | null;
22
+ };
23
+ export type DeleteHubParams = {
24
+ stores: StoreProvider;
25
+ id: string;
26
+ force?: boolean;
27
+ };
28
+ export type DeleteHubResult = {
29
+ success: boolean;
30
+ boundGroupIDs: Array<string>;
31
+ };
32
+ export type GetHubParams = {
33
+ stores: StoreProvider;
34
+ id: string;
35
+ };
36
+ export type ListHubsParams = {
37
+ stores: StoreProvider;
38
+ };
39
+ export type BindHubToGroupParams = {
40
+ stores: StoreProvider;
41
+ hubID: string;
42
+ groupID: string;
43
+ };
44
+ export type UnbindHubFromGroupParams = {
45
+ stores: StoreProvider;
46
+ hubID: string;
47
+ groupID: string;
48
+ };
49
+ export type ListHubsByGroupParams = {
50
+ stores: StoreProvider;
51
+ groupID: string;
52
+ };
53
+ export type ListGroupsByHubParams = {
54
+ stores: StoreProvider;
55
+ hubID: string;
56
+ };
57
+ export type CaptureServerDIDParams = {
58
+ stores: StoreProvider;
59
+ hubURL: string;
60
+ serverDID: string;
61
+ };
62
+ export type UpsertHubParams = {
63
+ stores: StoreProvider;
64
+ url: string;
65
+ serverDID?: string | null;
66
+ };
67
+ export declare class HubServerDIDConflictError extends Error {
68
+ readonly hubID: string;
69
+ readonly url: string;
70
+ readonly existingServerDID: string;
71
+ readonly providedServerDID: string;
72
+ constructor(params: {
73
+ hubID: string;
74
+ url: string;
75
+ existingServerDID: string;
76
+ providedServerDID: string;
77
+ });
78
+ }
79
+ export declare function createHub(params: CreateHubParams): Promise<Hub>;
80
+ /**
81
+ * Idempotent register-or-pin entry-point. Applies the conflict matrix:
82
+ *
83
+ * - URL not registered → insert a fresh row with `serverDID` (or null) and
84
+ * return it.
85
+ * - existing row, `server_did === null`, provided null → no-op.
86
+ * - existing row, `server_did === null`, provided DID-A → upgrade pin to
87
+ * DID-A.
88
+ * - existing row, `server_did === DID-A`, provided null or matching DID-A →
89
+ * no-op (no silent downgrade).
90
+ * - existing row, `server_did === DID-A`, provided DID-B → throws
91
+ * `HubServerDIDConflictError`. Caller must explicit `updateHub` to change
92
+ * a pinned DID.
93
+ *
94
+ * Unlike `createHub`, `upsertHub` takes no `label` — it is for atomic
95
+ * register+bind paths where the inviter only ships connectivity + pin.
96
+ */
97
+ export declare function upsertHub(params: UpsertHubParams): Promise<Hub>;
98
+ export declare function updateHub(params: UpdateHubParams): Promise<Hub>;
99
+ export declare function deleteHub(params: DeleteHubParams): Promise<DeleteHubResult>;
100
+ export declare function getHub(params: GetHubParams): Promise<Hub | null>;
101
+ export declare function listHubs(params: ListHubsParams): Promise<Array<Hub>>;
102
+ export declare function bindHubToGroup(params: BindHubToGroupParams): Promise<boolean>;
103
+ export declare function unbindHubFromGroup(params: UnbindHubFromGroupParams): Promise<boolean>;
104
+ export declare function listHubsByGroup(params: ListHubsByGroupParams): Promise<Array<Hub>>;
105
+ export declare function listGroupsByHub(params: ListGroupsByHubParams): Promise<Array<string>>;
106
+ /**
107
+ * TOFU capture path. Looks up the local hub row by URL and applies the
108
+ * conflict matrix:
109
+ * - URL not registered → no-op, returns `false`.
110
+ * - existing `server_did` is null → upgrade to `serverDID`, returns `true`.
111
+ * - existing `server_did` matches `serverDID` → no-op, returns `false`.
112
+ * - existing `server_did` differs from `serverDID` → throws
113
+ * `HubServerDIDConflictError`. Pinned-mismatch enforcement (hard-fail
114
+ * before any message is delivered) lives in the transport wrapper and
115
+ * surfaces `HubServerDIDMismatchError` to callers.
116
+ */
117
+ export declare function captureServerDID(params: CaptureServerDIDParams): Promise<boolean>;
@@ -0,0 +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,5 +1,4 @@
1
1
  import type { Logger } from '@kubun/logger';
2
- import type { P2PStoreAPI } from '@kubun/store-p2p';
3
2
  import { type BroadcastService } from '../groups/broadcast-service.js';
4
3
  /**
5
4
  * Shape of a message pushed by `hub/receive`. Matches `@enkaku/hub-protocol`
@@ -14,9 +13,7 @@ export type ReceivedHubMessage = {
14
13
  };
15
14
  export type HandleReceivedMessageParams = {
16
15
  groupID: string;
17
- deviceID: string;
18
16
  message: ReceivedHubMessage;
19
- p2pStore: P2PStoreAPI;
20
17
  broadcastService: BroadcastService;
21
18
  logger?: Logger;
22
19
  };
@@ -29,8 +26,8 @@ export type HandleReceivedMessageResult = {
29
26
  * Returns `{ ack: true }` when the message has been processed to a state where
30
27
  * redelivery serves no purpose (applied successfully, or decrypt failed with
31
28
  * no recovery path). Returns `{ ack: false }` when the hub should redeliver
32
- * (transient apply or save failure, MLS state missing, unexpected error).
33
- * Skips with `{ ack: false }` on groupID mismatch so the message stays queued
34
- * for whichever recipient it was intended for.
29
+ * (transient apply failure or unexpected error). Skips with `{ ack: false }`
30
+ * on groupID mismatch so the message stays queued for whichever recipient it
31
+ * was intended for.
35
32
  */
36
33
  export declare function handleReceivedMessage(params: HandleReceivedMessageParams): Promise<HandleReceivedMessageResult>;
@@ -1 +1 @@
1
- import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,DecryptError as a}from"../groups/broadcast-service.js";import{fromMLSStateRow as s,toMLSStateInsert as t}from"../groups/mls-state.js";export async function handleReceivedMessage(c){let n,{groupID:u,deviceID:i,message:o,p2pStore:p,broadcastService:l,logger:g}=c;if(o.groupID!==u)return g?.debug("hub message groupID mismatch, skipping",{groupID:u,messageGroupID:o.groupID,sequenceID:o.sequenceID}),{ack:!1};let d=await p.getMLSState(u,i);if(null==d)return g?.warn("mls state missing for group, skipping message",{groupID:u,sequenceID:o.sequenceID}),{ack:!1};let m=s(d),D=e(o.payload);try{n=await l.processReceived({groupID:u,encrypted:D,groupState:m})}catch(e){if(e instanceof a)return g?.debug("decrypt failed, acking and skipping",{groupID:u,sequenceID:o.sequenceID,error:e}),{ack:!0};if(e instanceof r)return g?.error("broadcast apply failed, will redeliver",{groupID:u,sequenceID:o.sequenceID,error:e}),{ack:!1};return g?.error("unexpected error processing hub message",{groupID:u,sequenceID:o.sequenceID,error:e}),{ack:!1}}try{await p.saveMLSState(t(n.updatedGroupState,u,i))}catch(e){return g?.error("save mls state failed after apply, will redeliver",{groupID:u,sequenceID:o.sequenceID,error:e}),{ack:!1}}return{ack:!0}}
1
+ import{fromB64 as e}from"@enkaku/codec";import{ApplyError as r,CommitOutOfOrderError as c,DecryptError as s}from"../groups/broadcast-service.js";export async function handleReceivedMessage(a){let{groupID:n,message:o,broadcastService:i,logger:t}=a;if(o.groupID!==n)return t?.debug("hub message groupID mismatch, skipping",{groupID:n,messageGroupID:o.groupID,sequenceID:o.sequenceID}),{ack:!1};let u=e(o.payload);try{await i.processReceived({groupID:n,encrypted:u})}catch(e){if(e instanceof s)return t?.debug("decrypt failed, acking and skipping",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!0};if(e instanceof c)return t?.debug("mls commit out of order, will redeliver after gap fills",{groupID:n,sequenceID:o.sequenceID,messageEpoch:e.messageEpoch.toString(),handleEpoch:e.handleEpoch.toString()}),{ack:!1};if(e instanceof r)return t?.error("broadcast apply failed, will redeliver",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!1};return t?.error("unexpected error processing hub message",{groupID:n,sequenceID:o.sequenceID,error:e}),{ack:!1}}return{ack:!0}}
@@ -1,12 +1,65 @@
1
1
  import type { Client } from '@enkaku/client';
2
+ import { EventEmitter } from '@enkaku/event';
2
3
  import type { HubProtocol } from '@enkaku/hub-protocol';
3
4
  import type { Logger } from '@kubun/logger';
4
5
  import type { P2PStoreAPI } from '@kubun/store-p2p';
5
6
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
7
  import type { BroadcastService } from '../groups/broadcast-service.js';
8
+ import { type OnServerDIDObserved } from './connection-pool.js';
9
+ import type { ServerDIDMismatchObserver, ServerDIDObserver } from './did-observing-transport.js';
7
10
  import { GroupChannel, type GroupChannelParams } from './group-channel.js';
8
- export type CreateHubClient = (hubURL: string) => Promise<Client<HubProtocol>>;
11
+ import { HubConnection, type HubConnectionParams } from './hub-connection.js';
12
+ export type CreateHubClientOptions = {
13
+ /**
14
+ * Per-call observer fired once when the first signed response arrives. Set
15
+ * by `HubConnectionPool` so the pool can route the observed server DID
16
+ * through its `onServerDIDObserved` handler with the hub URL bound. Factories
17
+ * that don't honour this opt simply skip TOFU capture for pool-spawned
18
+ * clients.
19
+ */
20
+ onServerDID?: ServerDIDObserver;
21
+ /**
22
+ * Pinned `server_did` resolved by the pool from the local hub row at spawn
23
+ * time. When set, the underlying `DIDObservingTransport` enforces it: a
24
+ * non-matching first response raises `HubServerDIDMismatchError` and no
25
+ * message is delivered to the inner Client (hard-fail path). When unset,
26
+ * the wrapper falls back to TOFU capture via `onServerDID`.
27
+ */
28
+ expectedServerDID?: string;
29
+ /**
30
+ * Side-channel notifier fired when a pinned-DID mismatch is detected. Lets
31
+ * the pool log + evict on top of the typed error that propagates through
32
+ * the read loop.
33
+ */
34
+ onMismatch?: ServerDIDMismatchObserver;
35
+ };
36
+ export type CreateHubClient = (hubURL: string, opts?: CreateHubClientOptions) => Promise<Client<HubProtocol>>;
9
37
  export type CreateGroupChannel = (params: GroupChannelParams) => GroupChannel;
38
+ export type CreateHubConnection = (params: HubConnectionParams) => HubConnection;
39
+ export type RelayEvent = {
40
+ type: 'connected';
41
+ hubURL: string;
42
+ groupIDs: Array<string>;
43
+ } | {
44
+ type: 'disconnected';
45
+ hubURL: string;
46
+ groupIDs: Array<string>;
47
+ reason?: string;
48
+ } | {
49
+ type: 'error';
50
+ hubURL: string;
51
+ groupIDs: Array<string>;
52
+ error: unknown;
53
+ } | {
54
+ type: 'reconnecting';
55
+ hubURL: string;
56
+ groupIDs: Array<string>;
57
+ attempt: number;
58
+ delayMs: number;
59
+ };
60
+ type RelayManagerEvents = {
61
+ relay: RelayEvent;
62
+ };
10
63
  export type HubRelayManagerParams = {
11
64
  p2pStore: P2PStoreAPI;
12
65
  broadcastService: BroadcastService;
@@ -18,10 +71,21 @@ export type HubRelayManagerParams = {
18
71
  backoffBaseMs?: number;
19
72
  backoffMaxMs?: number;
20
73
  backoffJitter?: number;
74
+ broadcastTimeoutMs?: number;
21
75
  createGroupChannel?: CreateGroupChannel;
76
+ createHubConnection?: CreateHubConnection;
77
+ /**
78
+ * Forwarded to the underlying `HubConnectionPool`. Fired with the observed
79
+ * `serverDID` after the first signed response from a freshly-spawned client.
80
+ * Used by the hub manager to apply the TOFU capture conflict matrix (the
81
+ * `null → DID-A` upgrade). Errors are caught and logged by the pool;
82
+ * pinned-mismatch enforcement is a separate path inside the wrapper.
83
+ */
84
+ onServerDIDObserved?: OnServerDIDObserved;
22
85
  };
23
86
  export declare class HubRelayManager {
24
87
  #private;
88
+ readonly relayEvents: EventEmitter<RelayManagerEvents>;
25
89
  constructor(params: HubRelayManagerParams);
26
90
  /**
27
91
  * Open channels for each group in parallel. Per-group failures are logged
@@ -31,7 +95,33 @@ export declare class HubRelayManager {
31
95
  */
32
96
  start(groupIDs: Array<string>): Promise<void>;
33
97
  addGroup(groupID: string): Promise<void>;
98
+ /**
99
+ * Post-join binding-add hook. Single-hub regime — when the group already
100
+ * has an active channel:
101
+ * - same hubURL → debug log, no-op (idempotent re-bind);
102
+ * - different hubURL → warn, no-op (extra binding deferred until
103
+ * multi-hub support lands).
104
+ * When the group has no active channel yet (e.g. binding was created
105
+ * before relay setup ran), open one for the supplied hub.
106
+ */
107
+ addBinding(groupID: string, hubURL: string): Promise<void>;
108
+ /**
109
+ * Post-join binding-remove hook. No-op when the unbound hub is not the
110
+ * group's active hub (single-hub regime: unbinding a non-active hub leaves
111
+ * the channel intact). When it matches, behaves like `removeGroup`.
112
+ */
113
+ removeBinding(groupID: string, hubURL: string): Promise<void>;
34
114
  removeGroup(groupID: string): Promise<void>;
35
- broadcast(groupID: string, message: GroupBroadcastMessage): Promise<void>;
115
+ broadcast(groupID: string, message: GroupBroadcastMessage, opts?: {
116
+ timeoutMs?: number;
117
+ }): Promise<void>;
118
+ /**
119
+ * Fan out a pre-produced MLS handshake Commit to a group's existing members
120
+ * via its active channel. Mirrors {@link broadcast}.
121
+ */
122
+ sendCommit(groupID: string, commitBytes: Uint8Array, opts?: {
123
+ timeoutMs?: number;
124
+ }): Promise<void>;
36
125
  stop(): Promise<void>;
37
126
  }
127
+ export {};