@kubun/plugin-p2p 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +59 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A view over one device's hub whose drain carries one peer's frames only.
3
+ *
4
+ * A device dialing two peers at once receives both answers on the same topic —
5
+ * its own dialer topic for the (group, epoch) — so an unfiltered drain hands
6
+ * each session the other's frames. Opening one consumes an MLS ratchet
7
+ * generation and DESTROYS it for the session it belonged to, which starves that
8
+ * session to its idle timeout with nothing raised anywhere.
9
+ *
10
+ * The filtering is asked of the hub rather than done here: a sink parks a
11
+ * single waiter, so a view that filtered by calling
12
+ * `next()` on the consumer's behalf would take that slot and starve the session
13
+ * it was meant to protect — measurably, even when it drops nothing at all. That
14
+ * is why this is not {@link createPeerScopedHubView}, which a RESPONDER uses:
15
+ * a responder's drain is long-lived and shared across its spawns, and it owns
16
+ * the topic subscription for its whole life. A caller's is neither, so
17
+ * `subscribe` and `unsubscribe` pass straight through to the session's own
18
+ * lifecycle.
19
+ */ export function createSenderScopedHubView({ hub, peerDID, publishTimeoutMs }) {
20
+ const publish = async (params)=>{
21
+ const sent = hub.publish(params);
22
+ if (publishTimeoutMs == null) {
23
+ return await sent;
24
+ }
25
+ let timer;
26
+ try {
27
+ return await Promise.race([
28
+ sent,
29
+ new Promise((_resolve, reject)=>{
30
+ timer = setTimeout(()=>{
31
+ reject(new Error(`hub tunnel publish to ${peerDID} did not reach a hub within ${publishTimeoutMs}ms`));
32
+ }, publishTimeoutMs);
33
+ })
34
+ ]);
35
+ } finally{
36
+ if (timer != null) {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+ };
41
+ const view = {
42
+ publish,
43
+ subscribe: (subscriberDID, topicID, options)=>hub.subscribe(subscriberDID, topicID, options),
44
+ unsubscribe: (subscriberDID, topicID)=>hub.unsubscribe?.(subscriberDID, topicID),
45
+ // Returned as the hub built it: the consumer must hold the sink's waiter
46
+ // itself. The caller's own scope is forwarded rather than replaced — the
47
+ // transport asks for its `receiveTopicID`, and a session that is handed the
48
+ // frames of the OTHER role on the same peer destroys them just as surely as
49
+ // one handed another peer's.
50
+ receive: (subscriberDID, options)=>hub.receive(subscriberDID, {
51
+ ...options,
52
+ senderDID: peerDID
53
+ }),
54
+ ...hub.events != null ? {
55
+ events: hub.events
56
+ } : {}
57
+ };
58
+ return view;
59
+ }
@@ -0,0 +1,67 @@
1
+ import type { StoreProvider } from '@kubun/db';
2
+ import type { Logger } from '@kubun/logger';
3
+ import type { P2PEventEmitter } from '../groups/events.js';
4
+ /**
5
+ * What a hub advertises at `GET /info`: the DID every token addressed to it must
6
+ * name, and where its protocols are mounted. Same shape a device serves for its
7
+ * own peer surface, so one reader handles both.
8
+ */
9
+ export type HubInfo = {
10
+ did: string;
11
+ protocols: Record<string, string>;
12
+ };
13
+ export type FetchHubInfoParams = {
14
+ hubURL: string;
15
+ fetch: typeof globalThis.fetch;
16
+ timeoutMs?: number;
17
+ /** Caller's own signal, e.g. a connection being disposed mid-discovery. */
18
+ signal?: AbortSignal;
19
+ };
20
+ /**
21
+ * Read a hub's `/info`. Resolved against the origin, so it works whether the
22
+ * configured URL is the origin or the relay mount path under it. Throws on an
23
+ * unreachable hub, a non-2xx reply, or a body that is not the advertised shape.
24
+ */
25
+ export declare function fetchHubInfo(params: FetchHubInfoParams): Promise<HubInfo>;
26
+ export type HubServerDIDResolver = {
27
+ /**
28
+ * The DID to address this hub's tokens to. The recorded `server_did` when the
29
+ * row has one, otherwise discovered from `/info` and recorded. `undefined`
30
+ * when the hub cannot be discovered — the caller then dials without an
31
+ * audience and lets the hub refuse it. `signal` abandons an in-flight read.
32
+ */
33
+ resolve: (hubURL: string, signal?: AbortSignal) => Promise<string | undefined>;
34
+ /**
35
+ * Called after the hub refused a request with `EK02`, which conflates a stale
36
+ * audience with a refused caller. Re-reads `/info`: a DID that differs from
37
+ * the recorded one is adopted and reported, so the next dial addresses the
38
+ * hub's current identity; a DID that matches means the refusal was about who
39
+ * asked, and nothing is changed.
40
+ */
41
+ refresh: (hubURL: string) => Promise<void>;
42
+ };
43
+ export type CreateHubServerDIDResolverParams = {
44
+ stores: StoreProvider;
45
+ emitter: P2PEventEmitter;
46
+ fetch: typeof globalThis.fetch;
47
+ logger?: Logger;
48
+ /**
49
+ * Shortest gap between two `/info` reads for one hub URL. Bounds the discovery
50
+ * traffic of a device the hub keeps refusing: it retries the connection on its
51
+ * own backoff, but must not re-ask who the hub is on every attempt.
52
+ */
53
+ refreshIntervalMs?: number;
54
+ /** Per-request timeout for an `/info` read. */
55
+ infoTimeoutMs?: number;
56
+ };
57
+ /**
58
+ * Resolves the audience a device's hub tokens carry, treating the recorded
59
+ * `server_did` as a cache rather than a pin.
60
+ *
61
+ * A hub redeployed without a persisted key comes up under a new DID; that is a
62
+ * routine operation, not an attack, so a stale record is refreshed from `/info`
63
+ * and reported to the application instead of failing the connection. Replaces
64
+ * the earlier trust-on-first-use capture, which took the DID from a response
65
+ * signer and refused every later connection whose signer differed.
66
+ */
67
+ export declare function createHubServerDIDResolver(params: CreateHubServerDIDResolverParams): HubServerDIDResolver;
@@ -0,0 +1,131 @@
1
+ import { getP2PStore } from '@kubun/store-p2p';
2
+ function isHubInfo(value) {
3
+ if (value == null || typeof value !== 'object') return false;
4
+ const candidate = value;
5
+ return typeof candidate.did === 'string' && candidate.did.length > 0 && typeof candidate.protocols === 'object';
6
+ }
7
+ /** Only an HTTP(S) hub has an `/info` to read; an in-process one is dialled directly. */ function isDiscoverable(hubURL) {
8
+ try {
9
+ const protocol = new URL(hubURL).protocol;
10
+ return protocol === 'http:' || protocol === 'https:';
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+ const DEFAULT_INFO_TIMEOUT_MS = 5000;
16
+ /**
17
+ * Read a hub's `/info`. Resolved against the origin, so it works whether the
18
+ * configured URL is the origin or the relay mount path under it. Throws on an
19
+ * unreachable hub, a non-2xx reply, or a body that is not the advertised shape.
20
+ */ export async function fetchHubInfo(params) {
21
+ const infoURL = new URL('/info', params.hubURL).toString();
22
+ const timeout = AbortSignal.timeout(params.timeoutMs ?? DEFAULT_INFO_TIMEOUT_MS);
23
+ const response = await params.fetch(infoURL, {
24
+ signal: params.signal == null ? timeout : AbortSignal.any([
25
+ timeout,
26
+ params.signal
27
+ ])
28
+ });
29
+ if (!response.ok) {
30
+ throw new Error(`hub /info at ${infoURL} responded with status ${response.status}`);
31
+ }
32
+ const body = await response.json();
33
+ if (!isHubInfo(body)) {
34
+ throw new Error(`hub /info at ${infoURL} returned an unexpected shape`);
35
+ }
36
+ return body;
37
+ }
38
+ const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
39
+ /**
40
+ * Resolves the audience a device's hub tokens carry, treating the recorded
41
+ * `server_did` as a cache rather than a pin.
42
+ *
43
+ * A hub redeployed without a persisted key comes up under a new DID; that is a
44
+ * routine operation, not an attack, so a stale record is refreshed from `/info`
45
+ * and reported to the application instead of failing the connection. Replaces
46
+ * the earlier trust-on-first-use capture, which took the DID from a response
47
+ * signer and refused every later connection whose signer differed.
48
+ */ export function createHubServerDIDResolver(params) {
49
+ const refreshIntervalMs = params.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
50
+ // hub URL → when `/info` was last read for it.
51
+ const lastReadAt = new Map();
52
+ const readInfo = async (hubURL, signal)=>{
53
+ lastReadAt.set(hubURL, Date.now());
54
+ try {
55
+ return await fetchHubInfo({
56
+ hubURL,
57
+ fetch: params.fetch,
58
+ ...params.infoTimeoutMs == null ? {} : {
59
+ timeoutMs: params.infoTimeoutMs
60
+ },
61
+ ...signal == null ? {} : {
62
+ signal
63
+ }
64
+ });
65
+ } catch (error) {
66
+ params.logger?.warn('hub /info read failed', {
67
+ hubURL,
68
+ error
69
+ });
70
+ return undefined;
71
+ }
72
+ };
73
+ return {
74
+ resolve: async (hubURL, signal)=>{
75
+ const store = await getP2PStore(params.stores);
76
+ const row = await store.getHubByURL(hubURL);
77
+ if (row?.server_did != null) {
78
+ return row.server_did;
79
+ }
80
+ if (!isDiscoverable(hubURL)) {
81
+ return undefined;
82
+ }
83
+ const info = await readInfo(hubURL, signal);
84
+ if (info == null) {
85
+ return undefined;
86
+ }
87
+ if (row != null) {
88
+ await store.updateHub(row.id, {
89
+ server_did: info.did
90
+ });
91
+ }
92
+ return info.did;
93
+ },
94
+ refresh: async (hubURL)=>{
95
+ if (!isDiscoverable(hubURL)) {
96
+ return;
97
+ }
98
+ const lastRead = lastReadAt.get(hubURL);
99
+ if (lastRead != null && Date.now() - lastRead < refreshIntervalMs) {
100
+ return;
101
+ }
102
+ const store = await getP2PStore(params.stores);
103
+ const row = await store.getHubByURL(hubURL);
104
+ const recorded = row?.server_did ?? null;
105
+ const info = await readInfo(hubURL);
106
+ if (info == null || info.did === recorded) {
107
+ return;
108
+ }
109
+ if (row != null) {
110
+ await store.updateHub(row.id, {
111
+ server_did: info.did
112
+ });
113
+ }
114
+ if (recorded == null) {
115
+ // Nothing was recorded, so nothing changed from the application's view:
116
+ // this is the first identity it learns for the hub.
117
+ return;
118
+ }
119
+ await params.emitter.emit('hubServerDIDChanged', {
120
+ hubURL,
121
+ previousServerDID: recorded,
122
+ serverDID: info.did
123
+ }).catch((error)=>{
124
+ params.logger?.error('hubServerDIDChanged emit failed', {
125
+ hubURL,
126
+ error
127
+ });
128
+ });
129
+ }
130
+ };
131
+ }
@@ -1,13 +1,20 @@
1
+ import type { ProcedureHandlers } from '@enkaku/server';
1
2
  import type { OwnIdentity } from '@kokuin/token';
2
3
  import type { KubunDB } from '@kubun/db';
3
4
  import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
4
5
  import type { HLC } from '@kubun/hlc';
5
6
  import type { Logger } from '@kubun/logger';
7
+ import type { LaneResult, PendingCommit } from '@kumiai/rpc';
8
+ import type { Runtime } from '@sozai/runtime';
6
9
  import type { GroupBroadcastMessage } from '../groups/broadcast.js';
7
10
  import type { P2PEventEmitter } from '../groups/events.js';
8
11
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
9
- import type { RejoinResult, StoreUnreadableMode } from '../types.js';
10
- import type { OnServerDIDObserved } from './did-observing-transport.js';
12
+ import type { BuildLedgerRedrive } from '../groups/group-peer-manager.js';
13
+ import type { PeerPresence } from '../groups/peer-presence.js';
14
+ import type { SyncProtocol } from '../protocol.js';
15
+ import type { ForwardingConfig } from '../sync/forwarder.js';
16
+ import type { SyncTransportProvider } from '../sync/sync-client.js';
17
+ import type { LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult, StoreUnreadableMode } from '../types.js';
11
18
  import type { CreateHubClient } from './http-client.js';
12
19
  export type HubWiring = {
13
20
  ready: Promise<void>;
@@ -20,22 +27,50 @@ export type HubWiring = {
20
27
  */
21
28
  broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
22
29
  /**
23
- * Fire-and-forget fan-out of a pre-produced MLS handshake Commit to a
24
- * group's existing members. Wired to `GroupPeerManager.sendCommit`.
30
+ * Drive a commit through the group's canonical commit hub and await its
31
+ * outcome. Wired to
32
+ * `GroupPeerManager.commit`. The lane builds against the live handle,
33
+ * publishes to the commit log, and adopts the advance only on acceptance. A
34
+ * group with no hub bound commits against its in-process loopback log, so this
35
+ * rejects only when the group has no lane at all.
25
36
  */
26
- scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
37
+ commitToGroup: (groupID: string, build: () => Promise<PendingCommit>) => Promise<LaneResult>;
27
38
  /**
28
39
  * Run stale-device MLS epoch recovery for an active group. Wired to
29
- * `GroupPeerManager.rejoinGroup`; deep recovery is deferred so this currently
30
- * reports `recovery-failed`.
40
+ * `GroupPeerManager.rejoinGroup`, which recovers through the rendezvous when a
41
+ * live member answers — reporting `recovered` on success, or `recovery-failed`
42
+ * (without throwing) when no responder is reachable.
31
43
  */
32
44
  rejoinGroup: (groupID: string) => Promise<RejoinResult>;
45
+ /**
46
+ * Gather the group's control ledger from its current members and fold missing
47
+ * entries locally. Wired to `GroupPeerManager.requestLedgerCatchup`; a no-op
48
+ * all-zero summary for a group with no live hub peer to gather from.
49
+ */
50
+ requestLedgerCatchup: (groupID: string, options?: LedgerCatchupOptions) => Promise<LedgerCatchupSummary>;
51
+ /**
52
+ * This device's peer-lane presence, resolved through the same late-bound
53
+ * `ready` every other lane operation waits on. The manager owns it because the
54
+ * internal triggers fire from there.
55
+ */
56
+ presence: () => Promise<PeerPresence>;
57
+ /** See {@link GroupPeerManager.retryHubs}. */
58
+ retryHubs: () => Promise<boolean>;
59
+ /**
60
+ * Build the transport for one directed sync session to a co-member, relayed by
61
+ * the group's hub. `undefined` when the group has no hub bound — there is no
62
+ * tunnel to offer, and the caller falls back to a registered URL.
63
+ *
64
+ * A route and nothing more: it grants the peer no access, and the session it
65
+ * carries is authorized per document at the far end exactly as an HTTP one is.
66
+ */
67
+ syncTransportTo: (groupID: string, peerDID: string) => Promise<SyncTransportProvider | undefined>;
33
68
  dispose: () => Promise<void>;
34
69
  };
35
70
  export type SetupHubRelayParams = {
36
71
  identity: OwnIdentity;
37
- /** Fresh correlation ids for group-rpc. */
38
- getRandomID: () => string;
72
+ /** Host runtime, forwarded to each peer for fresh group-rpc correlation ids. */
73
+ runtime: Runtime;
39
74
  db: KubunDB;
40
75
  /**
41
76
  * Engine graph internals — threaded through to the per-group peer apply path
@@ -47,6 +82,12 @@ export type SetupHubRelayParams = {
47
82
  createHubClient: CreateHubClient;
48
83
  /** Single canonical access point for MLS `GroupHandle` instances. */
49
84
  registry: GroupHandleRegistry;
85
+ /**
86
+ * Rebuilds a lost `ledger` commit's surviving tokens into a fresh commit for
87
+ * the seam to re-drive once. Sourced from the group manager's ledger write
88
+ * path — see {@link BuildLedgerRedrive}.
89
+ */
90
+ buildLedgerRedrive: BuildLedgerRedrive;
50
91
  logger: Logger;
51
92
  /**
52
93
  * Receive-side storage mode. Forwarded to the apply path. Defaults to
@@ -56,17 +97,33 @@ export type SetupHubRelayParams = {
56
97
  /** Server default access level — required when `storeUnreadable === 'drop'`. */
57
98
  defaultAccessLevel?: DefaultAccessLevel;
58
99
  /**
59
- * Fired with the observed `serverDID` after the first signed response from a
60
- * freshly-spawned client, hub URL bound. Lets callers route capture into
61
- * `captureServerDID` for TOFU pinning; omitting it skips capture.
100
+ * Per-peer N+1 re-broadcast config, forwarded to the apply path together with
101
+ * the `scheduleBroadcast` hook it needs the receive path gates on both, so
102
+ * one without the other is a silent no-op. Omitted or `false` disables it.
62
103
  */
63
- onServerDIDObserved?: OnServerDIDObserved;
104
+ forwarding?: ForwardingConfig;
64
105
  /**
65
106
  * Device-wide monotonic clock forwarded to the apply path for stamping
66
107
  * `removed_at_hlc` on roster reconciliation. Required: production passes the
67
108
  * engine's single instance so roster writes share the device's one clock.
68
109
  */
69
110
  hlc: HLC;
111
+ /**
112
+ * Reconnect-backoff overrides forwarded to every hub adapter. Test-only; when
113
+ * omitted the adapter runs on its production defaults.
114
+ */
115
+ hubReconnectBackoff?: {
116
+ baseMs?: number;
117
+ maxMs?: number;
118
+ jitter?: number;
119
+ };
120
+ /** @see DEFAULT_TUNNEL_IDLE_TIMEOUT_MS */
121
+ tunnelIdleTimeoutMs?: number;
122
+ /**
123
+ * Sync procedures served over an inbound hub tunnel. The same handlers the
124
+ * direct and HTTP transports serve; omitted, this device dials tunnels but
125
+ * answers none.
126
+ */
127
+ syncHandlers?: ProcedureHandlers<SyncProtocol>;
70
128
  };
71
129
  export declare function setupHubRelay(params: SetupHubRelayParams): HubWiring;
72
- export declare const DISABLED_HUB: HubWiring;
package/lib/hub/wiring.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { createGroupPeerManager } from '../groups/group-peer-manager.js';
2
+ import { HubTunnelSyncProvider } from '../sync/hub-tunnel-sync-provider.js';
3
+ /**
4
+ * How long a directed tunnel session waits with nothing arriving before it
5
+ * gives up. Idle, not total: any frame resets it, so a long sync that is
6
+ * progressing never trips it and only a peer that never answers does.
7
+ *
8
+ * The cost this buys is paid by automatic catch-up, which tries the next
9
+ * candidate after each one — so a group whose devices are all off spends this
10
+ * once per candidate before reporting `no-route`.
11
+ */ const DEFAULT_TUNNEL_IDLE_TIMEOUT_MS = 30_000;
2
12
  export function setupHubRelay(params) {
3
- const { identity, getRandomID, db, graph, emitter, createHubClient, registry, logger, storeUnreadable, defaultAccessLevel, onServerDIDObserved, hlc } = params;
13
+ const { identity, runtime, db, graph, emitter, createHubClient, registry, buildLedgerRedrive, logger, storeUnreadable, defaultAccessLevel, forwarding, hlc, hubReconnectBackoff, tunnelIdleTimeoutMs } = params;
4
14
  const unsubscribes = [];
5
15
  // Late-bound `ready` resolving to the live manager. The fire-and-forget
6
16
  // schedulers chain off it so a caller can enqueue work before boot completes;
@@ -20,20 +30,44 @@ export function setupHubRelay(params) {
20
30
  const manager = await ready;
21
31
  await manager.broadcast(groupID, message);
22
32
  };
23
- const scheduleSendCommit = (groupID, commitBytes)=>{
24
- void ready.then((manager)=>manager.sendCommit(groupID, commitBytes).catch((error)=>{
25
- logger.error('sendCommit failed', {
26
- groupID,
27
- error
28
- });
29
- }), ()=>{
30
- // ready rejected; setup failure already logged below
31
- });
33
+ const commitToGroup = async (groupID, build)=>{
34
+ const manager = await ready;
35
+ return await manager.commit(groupID, build);
32
36
  };
33
37
  const rejoinGroup = async (groupID)=>{
34
38
  const manager = await ready;
35
39
  return await manager.rejoinGroup(groupID);
36
40
  };
41
+ const requestLedgerCatchup = async (groupID, options)=>{
42
+ const manager = await ready;
43
+ return await manager.requestLedgerCatchup(groupID, options);
44
+ };
45
+ const presence = async ()=>(await ready).presence;
46
+ const retryHubs = async ()=>await (await ready).retryHubs();
47
+ const syncTransportTo = async (groupID, peerDID)=>{
48
+ const hub = (await ready).tunnelHub(groupID);
49
+ if (hub == null) {
50
+ return undefined;
51
+ }
52
+ return new HubTunnelSyncProvider({
53
+ hub,
54
+ registry,
55
+ groupID,
56
+ localDID: identity.id,
57
+ peerDID,
58
+ runtime,
59
+ // A tunnel session against a device that is off publishes its first frame
60
+ // and then waits on a mailbox nobody is draining. Unbounded, that is a
61
+ // catch-up that never returns and a peer screen that never settles — so
62
+ // the deadline is the transport's, not the caller's, and it is what makes
63
+ // "unreachable" an outcome instead of a hang.
64
+ idleTimeoutMs: tunnelIdleTimeoutMs ?? DEFAULT_TUNNEL_IDLE_TIMEOUT_MS,
65
+ // Without this the session's frame drops reach a listener nobody set, and
66
+ // a dial that is handed nothing reports only the dependency's bare
67
+ // `TimeoutInterruption` half a minute later.
68
+ logger: logger.getChild('tunnel-session')
69
+ });
70
+ };
37
71
  ready = (async ()=>{
38
72
  const [p2pStore, graphStore] = await Promise.all([
39
73
  db.getStore('p2p'),
@@ -42,17 +76,32 @@ export function setupHubRelay(params) {
42
76
  const manager = createGroupPeerManager({
43
77
  createHubClient,
44
78
  registry,
79
+ buildLedgerRedrive,
45
80
  stores: db,
46
81
  graphStore,
47
82
  graph,
48
83
  hlc,
49
84
  localDID: identity.id,
85
+ identity,
50
86
  emitter,
51
- getRandomID,
87
+ runtime,
52
88
  logger,
53
89
  storeUnreadable,
54
90
  defaultAccessLevel,
55
- onServerDIDObserved
91
+ forwarding,
92
+ // The manager's own fan-out, reached through the same late-bound `ready`
93
+ // every other scheduler chains off — a forward is one more broadcast, so
94
+ // it takes the path a locally-authored one already takes.
95
+ scheduleBroadcast,
96
+ ...hubReconnectBackoff != null ? {
97
+ hubReconnectBackoff
98
+ } : {},
99
+ // The answering half of the tunnel. Without handlers the manager stands up
100
+ // no listeners, and a dialled route reaches a mailbox nobody drains.
101
+ ...params.syncHandlers != null ? {
102
+ syncHandlers: params.syncHandlers
103
+ } : {},
104
+ tunnelIdleTimeoutMs: tunnelIdleTimeoutMs ?? DEFAULT_TUNNEL_IDLE_TIMEOUT_MS
56
105
  });
57
106
  unsubscribes.push(emitter.on('groupJoined', (group)=>manager.addGroup(group.id).catch((error)=>{
58
107
  logger.error('addGroup failed', {
@@ -60,6 +109,20 @@ export function setupHubRelay(params) {
60
109
  error
61
110
  });
62
111
  })));
112
+ // The roster IS the listener set, so the projection that records it is the
113
+ // honest trigger. The epoch-change trigger stays for the topics it moves;
114
+ // it fires too early to be relied on for membership.
115
+ for (const event of [
116
+ 'groupMemberJoined',
117
+ 'groupMemberLeft'
118
+ ]){
119
+ unsubscribes.push(emitter.on(event, ({ groupID })=>manager.reconcileTunnelListeners(groupID).catch((error)=>{
120
+ logger.error('tunnel listener reconcile failed', {
121
+ groupID,
122
+ error
123
+ });
124
+ })));
125
+ }
63
126
  unsubscribes.push(emitter.on('groupLeft', (group)=>manager.removeGroup(group.groupID).catch((error)=>{
64
127
  logger.error('removeGroup failed', {
65
128
  groupID: group.groupID,
@@ -100,8 +163,12 @@ export function setupHubRelay(params) {
100
163
  }),
101
164
  scheduleBroadcast,
102
165
  broadcastNow,
103
- scheduleSendCommit,
166
+ commitToGroup,
104
167
  rejoinGroup,
168
+ requestLedgerCatchup,
169
+ presence,
170
+ retryHubs,
171
+ syncTransportTo,
105
172
  dispose: async ()=>{
106
173
  for (const off of unsubscribes){
107
174
  try {
@@ -120,13 +187,3 @@ export function setupHubRelay(params) {
120
187
  }
121
188
  };
122
189
  }
123
- export const DISABLED_HUB = {
124
- ready: Promise.resolve(),
125
- scheduleBroadcast: ()=>{},
126
- broadcastNow: async ()=>{},
127
- scheduleSendCommit: ()=>{},
128
- rejoinGroup: async ()=>({
129
- status: 'recovery-failed'
130
- }),
131
- dispose: async ()=>{}
132
- };
package/lib/index.d.ts CHANGED
@@ -4,22 +4,32 @@ import { type BroadcastBatchConfig } from './sync/broadcast-queue.js';
4
4
  import { type PushSyncConfig } from './sync/broadcast-sender.js';
5
5
  import type { ForwardingConfig } from './sync/forwarder.js';
6
6
  import type { StoreUnreadableMode } from './types.js';
7
- export type { ContextDeps, PendingJoinRequest } from './context/types.js';
8
- export { ADMIN_ROLE_ENTRY_TYPE, ADMIN_ROSTER_PROJECTION, type AdminRoleValue, type AdminRosterState, adminRosterReducer, applyAdminEntryIncremental, foldAdminRoster, type ProjectAdminRosterParams, projectAdminRoster, projectAdminRosterFromStore, reprojectAdminRoster, } from './groups/admin-roster.js';
7
+ export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, type RequireGroupAdminParams, requireGroupAdmin, } from './context/require-admin.js';
8
+ export type { ContextDeps } from './context/types.js';
9
+ export { ADMIN_ROLE_ENTRY_TYPE, type AdminRoleValue, type AdminRosterState, foldAdminRoster, } from './groups/admin-roster.js';
9
10
  export { type AffectedRow, type GroupBroadcastMessage, type MutationApplyEntry, type ProcessBroadcastParams, type ProcessBroadcastResult, processBroadcast, } from './groups/broadcast.js';
10
11
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
11
12
  export { createFilteredGenerator, createP2PEventEmitter, type P2PEventEmitter, type P2PEventMap, } from './groups/events.js';
12
- export { type GroupAnchor, groupAnchorCapabilities, readGroupAnchor, } from './groups/group-anchor.js';
13
+ export { buildKubunGroupAnchorExtension, type GroupAnchorApp, readRecoverySecret, } from './groups/group-anchor.js';
13
14
  export { GroupHandleRegistry, type GroupHandleRegistryParams, type SeedParams, } from './groups/group-handle-registry.js';
15
+ export type { PeerAvailability } from './groups/group-protocols.js';
14
16
  export { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeFullJoinRequest, encodeInvitePayload, encodeJoinRequest, type FullJoinRequestPayload, type InvitePayload, type JoinRequestPayload, type SuggestedHub, } from './groups/invite-payload.js';
15
- export { type LedgerEntry, ledgerEntryDigest, signLedgerEntry, type VerifiedLedgerEntry, verifyLedgerEntry, } from './groups/ledger-entry.js';
16
- export { type FoldInput, foldLedger, type LedgerReducer, } from './groups/ledger-fold.js';
17
- export { type CreateGroupParams, type CreateGroupResult, GroupManager, type GroupManagerParams, type InviteToGroupParams, type InviteToGroupResult, type JoinGroupParams, type JoinGroupResult, type LeaveGroupParams, type LeaveGroupResult, type RemoveGroupMemberParams, type RemoveGroupMemberResult, type RemoveMemberParams, type RemoveMemberResult, } from './groups/manager.js';
17
+ export { resolveJoinRequestDID } from './groups/join-request-identity.js';
18
+ export { entryOrd, type FoldInput, foldLedgerEntries, type LedgerEntry, type LedgerReducer, ledgerEntryDigest, type PositionedFoldInput, signLedgerEntry, type VerifiedLedgerEntry, verifyLedgerEntry, } from './groups/ledger.js';
19
+ export type { CreateGroupParams, CreateGroupResult, GroupManagerParams, InviteToGroupParams, InviteToGroupResult, JoinGroupParams, JoinGroupResult, LeaveGroupParams, LeaveGroupResult, } from './groups/manager.js';
18
20
  export { MLSEncryptor, type MLSEncryptorParams } from './groups/mls-encryptor.js';
19
21
  export { type MLSGroupHandle, restoreMLSGroupHandle } from './groups/mls-group-handle.js';
20
22
  export { replacer as mlsJSONReplacer, reviver as mlsJSONReviver } from './groups/mls-json.js';
21
23
  export { deserializeMLSGroupState, type MLSGroupState, type SerializedMLSGroupState, serializeMLSGroupState, } from './groups/mls-state.js';
22
- export type { SyncClientMessage, SyncProtocol, SyncServerMessage } from './protocol.js';
24
+ export { MERKLE_SYNC_PROTOCOL } from './groups/peer-selection.js';
25
+ export { type CreateHTTPHubClientParams, type CreateHubClient, type CreateHubClientOptions, createHTTPHubClient, } from './hub/http-client.js';
26
+ export { type BindHubToGroupParams, bindHubToGroup, type CreateHubParams, createHub, type DeleteHubParams, type DeleteHubResult, deleteHub, type GetHubParams, getHub, type Hub, type ListGroupsByHubParams, type ListHubsByGroupParams, type ListHubsParams, listGroupsByHub, listHubs, listHubsByGroup, type UnbindHubFromGroupParams, type UpdateHubParams, type UpsertHubParams, unbindHubFromGroup, updateHub, upsertHub, } from './hub/manager.js';
27
+ export { type CreateHubServerDIDResolverParams, createHubServerDIDResolver, type FetchHubInfoParams, fetchHubInfo, type HubInfo, type HubServerDIDResolver, } from './hub/server-did.js';
28
+ export { type HubWiring, type SetupHubRelayParams, setupHubRelay, } from './hub/wiring.js';
29
+ export { type PeerConnection, PeerConnectionRegistry, } from './peer/connection-registry.js';
30
+ export { type CreatePeerHandlersParams, createPeerHandlers } from './peer/handlers.js';
31
+ export type { PeerClientMessage, PeerGroupsResult, PeerProtocol, PeerServerMessage, SyncClientMessage, SyncProtocol, SyncServerMessage, } from './protocol.js';
32
+ export { peerProtocol } from './protocol.js';
23
33
  export { createP2PSchemaExtension } from './schema.js';
24
34
  export { type BroadcastBatchConfig, type BroadcastQueue, type BroadcastQueueParams, createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG, } from './sync/broadcast-queue.js';
25
35
  export { type BroadcastSenderParams, DEFAULT_PUSH_SYNC_CONFIG, type PushSyncConfig, wireBroadcastSender, } from './sync/broadcast-sender.js';
@@ -30,13 +40,9 @@ export { checkSyncDelegation } from './sync/handlers.js';
30
40
  export { HubTunnelSyncListener, type HubTunnelSyncListenerParams, } from './sync/hub-tunnel-sync-listener.js';
31
41
  export { HubTunnelSyncProvider, type HubTunnelSyncProviderParams, } from './sync/hub-tunnel-sync-provider.js';
32
42
  export { negotiateDirection, type PeerConfig, type PeerConfigWithID, PeerRegistry, } from './sync/peer-registry.js';
33
- export { SyncClient, type SyncClientParams, type SyncScope, type SyncTransportProvider, } from './sync/sync-client.js';
43
+ export { SyncClient, type SyncClientParams, type SyncRoute, type SyncScope, type SyncServerResolver, type SyncTransportProvider, } from './sync/sync-client.js';
34
44
  export { type SyncEvent, SyncManager, type SyncManagerParams, type SyncSessionInfo, type SyncStatus, } from './sync/sync-manager.js';
35
- export { DIDObservingTransport, type DIDObservingTransportParams, type OnServerDIDObserved, type ServerDIDObserver, } from './hub/did-observing-transport.js';
36
- export { type CreateHTTPHubClientParams, type CreateHubClient, type CreateHubClientOptions, createHTTPHubClient, } from './hub/http-client.js';
37
- 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';
38
- export { DISABLED_HUB, type HubWiring, type SetupHubRelayParams, setupHubRelay, } from './hub/wiring.js';
39
- 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';
45
+ export type { AddCircleMemberInput, CatalogData, CircleData, CircleMemberData, CreateCircleInput, DeleteCatalogData, DeleteCircleInput, GatheredPeer, GroupData, GroupMemberData, GroupRequestContext, HubServerDIDChangedData, InviteToGroupData, InviteToGroupInput, JoinGroupData, LeaveGroupData, LocalPeerAnnouncement, P2PContext, P2PJoinRequestContext, PeerCatchUpData, PeerGatherOptions, PresenceReason, RemoveCircleMemberInput, RemoveGroupMemberInput, StoreUnreadableMode, SyncPluginAPI, SyncRequestContext, SyncSessionResult, UpdateCircleInput, UpdateGroupInput, } from './types.js';
40
46
  export type ReceiveConfig = {
41
47
  /**
42
48
  * Receiver storage mode for incoming peer mutations.
@@ -56,6 +62,15 @@ export type ReceiveConfig = {
56
62
  export declare const DEFAULT_RECEIVE_CONFIG: ReceiveConfig;
57
63
  export type P2PPluginOptions = {
58
64
  autoAcceptPeers?: Array<string>;
65
+ /**
66
+ * How long `requestInviteToGroup` waits for its own Add commit to settle
67
+ * before returning the truthful pending shape (`invitePayload: null`,
68
+ * `request.status: PENDING`) instead of blocking further — the parked case,
69
+ * inviter offline. Test-only escape hatch — production omits it and runs on
70
+ * the 30s default; lets a headless test drive the park-then-pending path
71
+ * without waiting out the full production timeout.
72
+ */
73
+ inviteCommitTimeoutMs?: number;
59
74
  /**
60
75
  * Expose the sync protocol over the HTTP plugin transport.
61
76
  * - `true` / `{}` — enable at the default protocol path `'sync'`. CORS
@@ -84,6 +99,17 @@ export type P2PPluginOptions = {
84
99
  */
85
100
  hub?: boolean | {
86
101
  createHubClient?: CreateHubClient;
102
+ /**
103
+ * Reconnect-backoff overrides for every hub adapter this device spawns.
104
+ * Test-only escape hatch — production omits it and runs on the adapter's
105
+ * defaults. Lets a headless multi-device test drive an offline/reconnect
106
+ * cycle without waiting out the ~1 s production base delay.
107
+ */
108
+ reconnectBackoff?: {
109
+ baseMs?: number;
110
+ maxMs?: number;
111
+ jitter?: number;
112
+ };
87
113
  };
88
114
  /**
89
115
  * Batch broadcast queue config. Sender-side mutation:apply broadcasts are
@@ -133,5 +159,14 @@ export type P2PPluginOptions = {
133
159
  * regardless of whether locally-authored mutations are broadcast.
134
160
  */
135
161
  forwarding?: ForwardingConfig;
162
+ /**
163
+ * How long a directed tunnel sync session waits with nothing arriving before
164
+ * giving up — how fast an unreachable peer is reported as one. Idle, not
165
+ * total: a session that keeps receiving frames never trips it.
166
+ *
167
+ * Test-only escape hatch; production omits it and runs on the 30s default,
168
+ * which a headless test would otherwise have to wait out per offline peer.
169
+ */
170
+ tunnelIdleTimeoutMs?: number;
136
171
  };
137
172
  export declare function createP2PPlugin(options?: P2PPluginOptions): (params: PluginFactoryParams) => KubunPlugin;