@kubun/plugin-p2p 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/lib/context/group.js +1 -1
  2. package/lib/context/hub.d.ts +4 -0
  3. package/lib/context/hub.js +1 -0
  4. package/lib/context/join.js +1 -1
  5. package/lib/context/types.d.ts +12 -0
  6. package/lib/groups/broadcast-service.d.ts +71 -17
  7. package/lib/groups/broadcast-service.js +1 -1
  8. package/lib/groups/broadcast.d.ts +112 -5
  9. package/lib/groups/broadcast.js +1 -1
  10. package/lib/groups/events.d.ts +11 -0
  11. package/lib/groups/group-handle-registry.d.ts +90 -0
  12. package/lib/groups/group-handle-registry.js +1 -0
  13. package/lib/groups/invite-payload.d.ts +5 -1
  14. package/lib/groups/manager.d.ts +20 -14
  15. package/lib/groups/manager.js +1 -1
  16. package/lib/groups/mls-codec.d.ts +7 -0
  17. package/lib/groups/mls-codec.js +1 -0
  18. package/lib/groups/mls-encryptor.d.ts +25 -0
  19. package/lib/groups/mls-encryptor.js +1 -0
  20. package/lib/hub/connection-pool.d.ts +24 -0
  21. package/lib/hub/connection-pool.js +1 -1
  22. package/lib/hub/did-observing-transport.d.ts +64 -0
  23. package/lib/hub/did-observing-transport.js +1 -0
  24. package/lib/hub/errors.d.ts +28 -0
  25. package/lib/hub/errors.js +1 -0
  26. package/lib/hub/forward-remote-broadcast.d.ts +15 -0
  27. package/lib/hub/forward-remote-broadcast.js +1 -0
  28. package/lib/hub/group-channel.d.ts +29 -21
  29. package/lib/hub/group-channel.js +1 -1
  30. package/lib/hub/http-client.d.ts +17 -0
  31. package/lib/hub/http-client.js +1 -0
  32. package/lib/hub/hub-connection.d.ts +96 -0
  33. package/lib/hub/hub-connection.js +1 -0
  34. package/lib/hub/manager.d.ts +117 -0
  35. package/lib/hub/manager.js +1 -0
  36. package/lib/hub/receive-handler.d.ts +3 -6
  37. package/lib/hub/receive-handler.js +1 -1
  38. package/lib/hub/relay-manager.d.ts +85 -2
  39. package/lib/hub/relay-manager.js +1 -1
  40. package/lib/hub/send-handler.d.ts +12 -16
  41. package/lib/hub/send-handler.js +1 -1
  42. package/lib/hub/tunnel-inbox.d.ts +20 -0
  43. package/lib/hub/tunnel-inbox.js +1 -0
  44. package/lib/hub/wait-for-gate.d.ts +14 -0
  45. package/lib/hub/wait-for-gate.js +1 -0
  46. package/lib/hub/wiring.d.ts +73 -0
  47. package/lib/hub/wiring.js +1 -0
  48. package/lib/index.d.ts +102 -6
  49. package/lib/index.js +1 -1
  50. package/lib/schema.js +67 -19
  51. package/lib/sync/broadcast-queue.d.ts +59 -0
  52. package/lib/sync/broadcast-queue.js +1 -0
  53. package/lib/sync/broadcast-sender.d.ts +52 -0
  54. package/lib/sync/broadcast-sender.js +1 -0
  55. package/lib/sync/catalog-match.d.ts +13 -0
  56. package/lib/sync/catalog-match.js +1 -0
  57. package/lib/sync/forwarder.d.ts +96 -0
  58. package/lib/sync/forwarder.js +1 -0
  59. package/lib/sync/handlers.d.ts +15 -0
  60. package/lib/sync/handlers.js +1 -1
  61. package/lib/sync/hub-tunnel-sync-listener.d.ts +24 -0
  62. package/lib/sync/hub-tunnel-sync-listener.js +1 -0
  63. package/lib/sync/hub-tunnel-sync-provider.d.ts +36 -0
  64. package/lib/sync/hub-tunnel-sync-provider.js +1 -0
  65. package/lib/sync/merkle-apply.d.ts +41 -0
  66. package/lib/sync/merkle-apply.js +1 -1
  67. package/lib/sync/merkle-channel.d.ts +15 -0
  68. package/lib/sync/merkle-channel.js +1 -1
  69. package/lib/sync/receive-access-gate.d.ts +36 -0
  70. package/lib/sync/receive-access-gate.js +1 -0
  71. package/lib/sync/scope-resolver.d.ts +32 -0
  72. package/lib/sync/scope-resolver.js +1 -0
  73. package/lib/sync/sync-manager.d.ts +10 -0
  74. package/lib/sync/sync-manager.js +1 -1
  75. package/lib/types.d.ts +59 -0
  76. package/lib/util/mutex.d.ts +4 -0
  77. package/lib/util/mutex.js +1 -0
  78. package/package.json +40 -37
@@ -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,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,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,26 @@ 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>;
36
118
  stop(): Promise<void>;
37
119
  }
120
+ export {};
@@ -1 +1 @@
1
- import{HubConnectionPool as e}from"./connection-pool.js";import{GroupChannel as a}from"./group-channel.js";export class HubRelayManager{#e;#a;#t;#s;#r;#o;#i;#l;#c;#h;#u;#p=new Map;constructor(t){this.#e=t.p2pStore,this.#a=t.broadcastService,this.#t=t.deviceID,this.#r=t.logger,this.#o=t.ackFlushMs??500,this.#i=t.ackFlushMax??10,this.#l=t.backoffBaseMs??1e3,this.#c=t.backoffMaxMs??6e4,this.#h=t.backoffJitter??.25,this.#s=new e({createHubClient:t.createHubClient,logger:t.logger}),this.#u=t.createGroupChannel??(e=>new a(e))}async start(e){(await Promise.allSettled(e.map(e=>this.addGroup(e)))).forEach((a,t)=>{"rejected"===a.status&&this.#r?.error("addGroup failed during start",{groupID:e[t],error:a.reason})})}async addGroup(e){if(this.#p.has(e))return void this.#r?.debug("group already added",{groupID:e});let a=await this.#e.getGroup(e);if(null==a)throw Error(`Group ${e} not found`);let t=a.hub_urls;if(null==t||0===t.length)return void this.#r?.warn("group has no hub URLs, skipping",{groupID:e});t.length>1&&this.#r?.warn("multi-hub not yet supported, using first",{groupID:e,hubURLs:t});let s=this.#u({groupID:e,hubURL:t[0],deviceID:this.#t,pool:this.#s,broadcastService:this.#a,p2pStore:this.#e,logger:this.#r,ackFlushMs:this.#o,ackFlushMax:this.#i,backoffBaseMs:this.#l,backoffMaxMs:this.#c,backoffJitter:this.#h});await s.open(),this.#p.set(e,s)}async removeGroup(e){let a=this.#p.get(e);null!=a&&(this.#p.delete(e),await a.close())}async broadcast(e,a){let t=this.#p.get(e);if(null==t)throw Error(`Group ${e} is not active in HubRelayManager`);await t.broadcast(a)}async stop(){let e=[...this.#p.values()];this.#p.clear(),(await Promise.allSettled(e.map(e=>e.close()))).forEach(e=>{"rejected"===e.status&&this.#r?.warn("GroupChannel close failed",{error:e.reason})}),await this.#s.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 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,28 +1,24 @@
1
- import type { Client } from '@enkaku/client';
2
- import type { HubProtocol } from '@enkaku/hub-protocol';
3
1
  import type { Logger } from '@kubun/logger';
4
- import type { P2PStoreAPI } from '@kubun/store-p2p';
5
2
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
3
  import type { BroadcastService } from '../groups/broadcast-service.js';
7
4
  export type HandleSendBroadcastParams = {
8
5
  groupID: string;
9
- deviceID: string;
10
6
  message: GroupBroadcastMessage;
11
- p2pStore: P2PStoreAPI;
12
7
  broadcastService: BroadcastService;
13
- hubClient: Client<HubProtocol>;
8
+ /**
9
+ * Wire-send callback. Receives the groupID and the base64-encoded encrypted
10
+ * MLS application message, and is expected to fan it out via the underlying
11
+ * hub transport (e.g. `HubConnection.broadcast`). Decoupling from the hub
12
+ * client lets callers serialize the send through a per-group mutex shared
13
+ * with the receive path.
14
+ */
15
+ send: (groupID: string, payloadB64: string) => Promise<void>;
14
16
  logger?: Logger;
15
17
  };
16
18
  /**
17
- * Encrypt + send a broadcast for a group, then persist the advanced MLS state.
18
- *
19
- * Failure semantics:
20
- * - Missing MLS state for (group, device) throw, caller cannot broadcast.
21
- * - `prepareSend` failure → throw, no save (epoch unchanged on disk).
22
- * - `hub/group/send` failure → throw, do NOT save advanced MLS state.
23
- * Prevents an MLS epoch wedge where local state outpaces recipients.
24
- * - `saveMLSState` failure → throw. State has advanced in memory; on next call
25
- * the load-from-disk path will re-derive a fresh handle from the prior epoch
26
- * and re-encrypt. (Wedge protection only applies pre-save by design.)
19
+ * Encrypt + send a broadcast for a group. MLS state persistence is handled
20
+ * by the registry inside `BroadcastService.prepareSend`. Wedge protection
21
+ * (failed send → no advance of persisted state) is preserved because send
22
+ * runs INSIDE the registry's per-group lock and a throw skips persist.
27
23
  */
28
24
  export declare function handleSendBroadcast(params: HandleSendBroadcastParams): Promise<void>;
@@ -1 +1 @@
1
- import{toB64 as a}from"@enkaku/codec";import{fromMLSStateRow as r,toMLSStateInsert as t}from"../groups/mls-state.js";export async function handleSendBroadcast(e){let{groupID:o,deviceID:s,message:n,p2pStore:i,broadcastService:p,hubClient:d,logger:u}=e,c=await i.getMLSState(o,s);if(null==c)throw Error(`MLS state missing for group ${o}; cannot broadcast`);let l=r(c),{encrypted:m,updatedGroupState:S}=await p.prepareSend({groupID:o,message:n,groupState:l});try{await d.request("hub/group/send",{param:{groupID:o,payload:a(m)}})}catch(a){throw u?.error("hub/group/send failed",{groupID:o,error:a}),a}await i.saveMLSState(t(S,o,s))}
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}}
@@ -0,0 +1,20 @@
1
+ import type { StoredMessage } from '@enkaku/hub-protocol';
2
+ /**
3
+ * FIFO queue used by `HubConnection.tunnelMessageStream` to deliver synthetic /
4
+ * forwarded `StoredMessage` values to tunnel transports. Callers `push` messages;
5
+ * each consumer drains via an `AsyncIterator` returned from
6
+ * {@link TunnelInbox.iterator}. Disposal is idempotent: once `close` runs,
7
+ * subsequent pushes are dropped and pending iteration resolves to `done`.
8
+ *
9
+ * Multiple sequential iterators are supported (one consumer at a time, but
10
+ * iterators may be created and returned across the inbox's lifetime). Each
11
+ * iterator tracks its own pending waiter so `iterator.return()` drains only
12
+ * that iterator's waiter — leaving queued messages and other consumers'
13
+ * waiters intact for future iterators.
14
+ */
15
+ export declare class TunnelInbox {
16
+ #private;
17
+ push(message: StoredMessage): void;
18
+ close(): void;
19
+ iterator(onReturn?: () => void): AsyncIterator<StoredMessage>;
20
+ }
@@ -0,0 +1 @@
1
+ export class TunnelInbox{#e=[];#s=[];#t=!1;push(e){if(this.#t)return;let s=this.#s.shift();null!=s?s.resolve({value:e,done:!1}):this.#e.push(e)}close(){if(!this.#t)for(this.#t=!0;this.#s.length>0;){let e=this.#s.shift();e?.resolve({value:void 0,done:!0})}}iterator(e){let s=!1,t=new Set,i=()=>{if(!s){for(let e of(s=!0,t)){let s=this.#s.indexOf(e);s>=0&&this.#s.splice(s,1),e.resolve({value:void 0,done:!0})}t.clear(),e?.()}};return{next:()=>{let e;return s?Promise.resolve({value:void 0,done:!0}):this.#e.length>0?Promise.resolve({value:this.#e.shift(),done:!1}):this.#t?(i(),Promise.resolve({value:void 0,done:!0})):new Promise(s=>{e={resolve:s},t.add(e),this.#s.push(e)}).then(s=>(null!=e&&t.delete(e),!0===s.done&&i(),s))},return:()=>(i(),Promise.resolve({value:void 0,done:!0}))}}}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Wait for a reconnect gate (or similar boolean-style gate) to clear, with an
3
+ * optional timeout. If `gate` is null the call returns immediately without
4
+ * allocating any timer or signal — this is the hot path while the connection
5
+ * is healthy. If `timeoutMs` is undefined we simply await the gate without
6
+ * arming a timer (callers with no timeout can also omit `onTimeout` to skip
7
+ * the closure allocation). Both set: race the gate against
8
+ * `AbortSignal.timeout`; the first to settle wins.
9
+ *
10
+ * `onTimeout` is invoked lazily — only when the timeout actually fires — so
11
+ * callers can capture mutable state (e.g. current reconnect attempt) at the
12
+ * moment of failure rather than at call time.
13
+ */
14
+ export declare function waitForGate(gate: Promise<void> | null, timeoutMs: number | undefined, onTimeout?: () => Error): Promise<void>;
@@ -0,0 +1 @@
1
+ export async function waitForGate(e,t,r){if(null==e)return;if(null==t)return void await e;if(null==r)throw Error("waitForGate: onTimeout required when timeoutMs is set");let i=AbortSignal.timeout(t),n=null,o=new Promise((e,t)=>{i.aborted?t(r()):(n=()=>{t(r())},i.addEventListener("abort",n,{once:!0}))});try{await Promise.race([e,o])}finally{null!=n&&i.removeEventListener("abort",n)}}
@@ -0,0 +1,73 @@
1
+ import type { OwnIdentity } from '@enkaku/token';
2
+ import type { KubunDB } from '@kubun/db';
3
+ import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
+ import type { Logger } from '@kubun/logger';
5
+ import type { GroupBroadcastMessage } from '../groups/broadcast.js';
6
+ import { type BroadcastEvent } from '../groups/broadcast-service.js';
7
+ import type { GroupEventEmitter } from '../groups/events.js';
8
+ import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
9
+ import type { ForwardingConfig } from '../sync/forwarder.js';
10
+ import type { StoreUnreadableMode } from '../types.js';
11
+ import type { OnServerDIDObserved } from './connection-pool.js';
12
+ import type { GroupChannel, GroupChannelParams } from './group-channel.js';
13
+ import type { HubConnection, HubConnectionParams } from './hub-connection.js';
14
+ import { type CreateHubClient } from './relay-manager.js';
15
+ export type HubWiring = {
16
+ ready: Promise<void>;
17
+ scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
18
+ dispose: () => Promise<void>;
19
+ };
20
+ export type SetupHubRelayParams = {
21
+ identity: OwnIdentity;
22
+ db: KubunDB;
23
+ /**
24
+ * Engine graph internals — threaded through to `BroadcastService` so the
25
+ * `mutation:apply` broadcast variant can route peer-authored mutations
26
+ * through the engine with `origin: 'peer'`.
27
+ */
28
+ graph: GraphInternals;
29
+ emitter: GroupEventEmitter;
30
+ createHubClient: CreateHubClient;
31
+ onRemoteBroadcast: (event: BroadcastEvent) => void;
32
+ /**
33
+ * Single canonical access point for MLS `GroupHandle` instances. Threaded
34
+ * into `BroadcastService` so encrypt/decrypt go through the per-group
35
+ * mutex once Phase 3 lands. Currently stored but unused.
36
+ */
37
+ registry: GroupHandleRegistry;
38
+ logger: Logger;
39
+ /**
40
+ * Optional testing seam forwarded to `HubRelayManager`. Production code
41
+ * leaves this unset so the manager uses its default `GroupChannel`
42
+ * constructor.
43
+ */
44
+ createGroupChannel?: (params: GroupChannelParams) => GroupChannel;
45
+ /**
46
+ * Optional testing seam forwarded to `HubRelayManager`. Production code
47
+ * leaves this unset so the manager uses its default `HubConnection`
48
+ * constructor.
49
+ */
50
+ createHubConnection?: (params: HubConnectionParams) => HubConnection;
51
+ /**
52
+ * Receive-side storage mode. Forwarded to `BroadcastService` →
53
+ * `processBroadcast`. Defaults to `'persist'` when omitted.
54
+ */
55
+ storeUnreadable?: StoreUnreadableMode;
56
+ /** Server default access level — required when `storeUnreadable === 'drop'`. */
57
+ defaultAccessLevel?: DefaultAccessLevel;
58
+ /**
59
+ * Forwarding config. Threaded through to `BroadcastService` →
60
+ * `processBroadcast` along with the hub's own `scheduleBroadcast` hook.
61
+ * Default `false` disables the receive-time forwarding pipeline.
62
+ */
63
+ forwarding?: ForwardingConfig;
64
+ /**
65
+ * Forwarded to `HubRelayManager` → `HubConnectionPool`. Fired with the
66
+ * observed `serverDID` after the first signed response from a freshly-spawned
67
+ * client. Lets callers route capture into `captureServerDID` for TOFU
68
+ * pinning; omitting it skips capture entirely.
69
+ */
70
+ onServerDIDObserved?: OnServerDIDObserved;
71
+ };
72
+ export declare function setupHubRelay(params: SetupHubRelayParams): HubWiring;
73
+ export declare const DISABLED_HUB: HubWiring;
@@ -0,0 +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()=>{}};
package/lib/index.d.ts CHANGED
@@ -1,26 +1,122 @@
1
- import type { KubunPlugin, PluginFactoryParams } from '@kubun/engine';
1
+ import type { DefaultAccessLevel, KubunPlugin, PluginFactoryParams } from '@kubun/engine';
2
+ import type { CreateHubClient } from './hub/relay-manager.js';
3
+ import { type BroadcastBatchConfig } from './sync/broadcast-queue.js';
4
+ import { type PushSyncConfig } from './sync/broadcast-sender.js';
5
+ import type { ForwardingConfig } from './sync/forwarder.js';
6
+ import type { StoreUnreadableMode } from './types.js';
2
7
  export type { ContextDeps, PendingJoinRequest } from './context/types.js';
3
- export { type GroupBroadcastMessage, type ProcessBroadcastParams, processBroadcast, } from './groups/broadcast.js';
8
+ export { type AffectedRow, type GroupBroadcastMessage, type MutationApplyEntry, type ProcessBroadcastParams, type ProcessBroadcastResult, processBroadcast, } from './groups/broadcast.js';
4
9
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
5
- export { ApplyError, type BroadcastEvent, BroadcastService, type BroadcastServiceEvents, type BroadcastServiceParams, DecryptError, type ReceiveBroadcastParams, type ReceiveBroadcastResult, type SendBroadcastParams, type SendBroadcastResult, } from './groups/broadcast-service.js';
10
+ export { ApplyError, type BroadcastEvent, BroadcastService, type BroadcastServiceEvents, type BroadcastServiceParams, DecryptError, type ReceiveBroadcastParams, type ReceiveBroadcastResult, type SendBroadcastParams, } from './groups/broadcast-service.js';
6
11
  export { createFilteredGenerator, createGroupEventEmitter, type GroupEventEmitter, type GroupEventMap, } from './groups/events.js';
7
- export { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeFullJoinRequest, encodeInvitePayload, encodeJoinRequest, type FullJoinRequestPayload, type InvitePayload, type JoinRequestPayload, } from './groups/invite-payload.js';
12
+ export { GroupHandleRegistry, type GroupHandleRegistryParams, type SeedParams, } from './groups/group-handle-registry.js';
13
+ export { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeFullJoinRequest, encodeInvitePayload, encodeJoinRequest, type FullJoinRequestPayload, type InvitePayload, type JoinRequestPayload, type SuggestedHub, } from './groups/invite-payload.js';
8
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';
15
+ export { MLSEncryptor, type MLSEncryptorParams } from './groups/mls-encryptor.js';
9
16
  export { type MLSGroupHandle, restoreMLSGroupHandle } from './groups/mls-group-handle.js';
10
17
  export { replacer as mlsJSONReplacer, reviver as mlsJSONReviver } from './groups/mls-json.js';
11
18
  export { deserializeMLSGroupState, type MLSGroupState, type SerializedMLSGroupState, serializeMLSGroupState, } from './groups/mls-state.js';
12
19
  export type { SyncClientMessage, SyncProtocol, SyncServerMessage } from './protocol.js';
13
20
  export { createP2PSchemaExtension } from './schema.js';
21
+ export { type BroadcastBatchConfig, type BroadcastQueue, type BroadcastQueueParams, createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG, } from './sync/broadcast-queue.js';
22
+ export { type BroadcastSenderParams, DEFAULT_PUSH_SYNC_CONFIG, type PushSyncConfig, wireBroadcastSender, } from './sync/broadcast-sender.js';
23
+ export { catalogMatchesDoc } from './sync/catalog-match.js';
14
24
  export { type CatalogSyncScope, resolveCatalogSyncScopes } from './sync/catalog-scope.js';
25
+ export { type EvaluateAndForwardParams, type EvaluateAndForwardResult, evaluateAndForward, type ForwardContext, type ForwardFilter, type ForwardingConfig, } from './sync/forwarder.js';
15
26
  export { checkSyncDelegation, negotiateDirection } from './sync/handlers.js';
27
+ export { HubTunnelSyncListener, type HubTunnelSyncListenerParams, } from './sync/hub-tunnel-sync-listener.js';
28
+ export { HubTunnelSyncProvider, type HubTunnelSyncProviderParams, } from './sync/hub-tunnel-sync-provider.js';
16
29
  export { type PeerConfig, type PeerConfigWithID, PeerRegistry } from './sync/peer-registry.js';
17
30
  export { SyncClient, type SyncClientParams, type SyncScope, type SyncTransportProvider, } from './sync/sync-client.js';
18
31
  export { type SyncEvent, SyncManager, type SyncManagerParams, type SyncSessionInfo, type SyncStatus, } from './sync/sync-manager.js';
32
+ export type { OnServerDIDObserved } from './hub/connection-pool.js';
33
+ export { DIDObservingTransport, type DIDObservingTransportParams, type ServerDIDObserver, } from './hub/did-observing-transport.js';
34
+ export { ReconnectingError } from './hub/errors.js';
19
35
  export type { GroupChannel, GroupChannelParams } from './hub/group-channel.js';
20
- export { type CreateGroupChannel, type CreateHubClient, HubRelayManager, type HubRelayManagerParams, } from './hub/relay-manager.js';
21
- export type { CatalogData, CircleData, CircleMemberData, DeleteCatalogData, DeleteCircleData, GroupData, GroupMemberData, GroupRequestContext, InviteToGroupData, JoinGroupData, LeaveGroupData, P2PContext, P2PJoinRequestContext, RemoveCircleMemberData, RemoveGroupMemberData, SyncPluginAPI, SyncRequestContext, SyncSessionResult, UpdateCircleInput, UpdateGroupInput, } from './types.js';
36
+ export { type CreateHTTPHubClientParams, createHTTPHubClient } from './hub/http-client.js';
37
+ export type { ConnectionLifecycleEvent } from './hub/hub-connection.js';
38
+ export { type BindHubToGroupParams, bindHubToGroup, type CaptureServerDIDParams, type CreateHubParams, captureServerDID, createHub, type DeleteHubParams, type DeleteHubResult, deleteHub, type GetHubParams, getHub, type Hub, HubServerDIDConflictError, type ListGroupsByHubParams, type ListHubsByGroupParams, type ListHubsParams, listGroupsByHub, listHubs, listHubsByGroup, type UnbindHubFromGroupParams, type UpdateHubParams, type UpsertHubParams, unbindHubFromGroup, updateHub, upsertHub, } from './hub/manager.js';
39
+ export { type CreateGroupChannel, type CreateHubClient, type CreateHubClientOptions, type CreateHubConnection, HubRelayManager, type HubRelayManagerParams, type RelayEvent, } from './hub/relay-manager.js';
40
+ export { DISABLED_HUB, type HubWiring, type SetupHubRelayParams, setupHubRelay, } from './hub/wiring.js';
41
+ export type { CatalogData, CircleData, CircleMemberData, DeleteCatalogData, DeleteCircleData, GroupData, GroupMemberData, GroupRequestContext, InviteToGroupData, JoinGroupData, LeaveGroupData, P2PContext, P2PJoinRequestContext, RemoveCircleMemberData, RemoveGroupMemberData, StoreUnreadableMode, SyncPluginAPI, SyncRequestContext, SyncSessionResult, UpdateCircleInput, UpdateGroupInput, } from './types.js';
42
+ export type ReceiveConfig = {
43
+ /**
44
+ * Receiver storage mode for incoming peer mutations.
45
+ * - `'persist'` (default) — store every doc that passes catalog/group
46
+ * checks. Access enforced at query time; unreadable docs remain in DB
47
+ * but filtered.
48
+ * - `'drop'` — apply read-access check at receive time using the local
49
+ * peer's own DID as viewer. Mutations the local peer cannot read are
50
+ * not persisted.
51
+ */
52
+ storeUnreadable: StoreUnreadableMode;
53
+ };
54
+ /**
55
+ * Default receive config. Under `'persist'` no gate is built and no extra
56
+ * DB lookups occur during receive.
57
+ */
58
+ export declare const DEFAULT_RECEIVE_CONFIG: ReceiveConfig;
22
59
  export type P2PPluginOptions = {
23
60
  autoAcceptPeers?: Array<string>;
24
61
  http?: boolean | string;
62
+ /**
63
+ * Enable hub-mediated group broadcasts.
64
+ * - `true` / `{}` — build a default HTTP hub client factory from
65
+ * `params.identity` + `params.runtime.fetch`.
66
+ * - `{ createHubClient }` — caller supplies a custom factory.
67
+ * - absent / `false` — hub relay disabled; all broadcast call-sites are
68
+ * no-ops.
69
+ */
70
+ hub?: boolean | {
71
+ createHubClient?: CreateHubClient;
72
+ };
73
+ /**
74
+ * Batch broadcast queue config. Sender-side mutation:apply broadcasts are
75
+ * accumulated per target MLS group and flushed when window/count/byte
76
+ * thresholds trip. Defaults from {@link DEFAULT_BROADCAST_BATCH_CONFIG}.
77
+ * Set `enabled: false` to bypass the queue and emit one broadcast per
78
+ * mutation per scope.
79
+ */
80
+ batch?: Partial<BroadcastBatchConfig>;
81
+ /**
82
+ * Push-sync config. Gates the local `engine:mutation:authored` subscriber
83
+ * that drives `mutation:apply` broadcasts.
84
+ * - When `enabled: false` (default), no `mutation:apply` broadcasts are
85
+ * emitted from this peer when local mutations land — even if `hub` is
86
+ * configured. Merkle-pull continues to work, and the receive path
87
+ * (incoming `mutation:apply` broadcasts) is unaffected.
88
+ * - When `enabled: true`, locally-authored mutations fan out per-scope
89
+ * through the hub.
90
+ *
91
+ * Defaults from {@link DEFAULT_PUSH_SYNC_CONFIG}.
92
+ */
93
+ pushSync?: Partial<PushSyncConfig>;
94
+ /**
95
+ * Receive-side storage config. Controls whether peer mutations the local
96
+ * peer cannot read are persisted to the local DB or dropped at receive
97
+ * time. Defaults to {@link DEFAULT_RECEIVE_CONFIG} (persist).
98
+ */
99
+ receive?: Partial<ReceiveConfig>;
100
+ /**
101
+ * Server default access level used by the receive-time access gate when
102
+ * `receive.storeUnreadable === 'drop'` and no document or per-user-model
103
+ * default rule is declared. Defaults to `{ read: 'only_owner', write:
104
+ * 'only_owner' }` (no fan-out for undeclared models). Ignored under
105
+ * `'persist'`.
106
+ */
107
+ defaultAccessLevel?: DefaultAccessLevel;
108
+ /**
109
+ * Forwarding config. Controls per-peer N+1 re-broadcast.
110
+ * - `false` (default) — incoming `mutation:apply` broadcasts are NOT
111
+ * re-broadcast.
112
+ * - `true` — re-broadcast each applied entry to every other MLS group the
113
+ * local peer belongs to whose access + catalog gates pass.
114
+ * - {@link ForwardFilter} — same as `true`, plus per-candidate filter
115
+ * callback that further narrows. Filter receives a {@link ForwardContext}.
116
+ *
117
+ * Push-sync config (`pushSync.enabled`) is orthogonal — forwarding fires
118
+ * regardless of whether locally-authored mutations are broadcast.
119
+ */
120
+ forwarding?: ForwardingConfig;
25
121
  };
26
122
  export declare function createP2PPlugin(options?: P2PPluginOptions): (params: PluginFactoryParams) => KubunPlugin;