@kubun/plugin-p2p 0.10.1 → 0.12.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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -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,19 +1,21 @@
1
- import type { OwnIdentity } from '@enkaku/token';
1
+ import type { ProcedureHandlers } from '@enkaku/server';
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
- import { type BroadcastEvent } from '../groups/broadcast-service.js';
8
10
  import type { P2PEventEmitter } from '../groups/events.js';
9
11
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
10
- import type { GroupHealthMonitor } from '../groups/group-health-monitor.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';
11
15
  import type { ForwardingConfig } from '../sync/forwarder.js';
12
- import type { RejoinResult, StoreUnreadableMode } from '../types.js';
13
- import type { OnServerDIDObserved } from './connection-pool.js';
14
- import type { GroupChannel, GroupChannelParams } from './group-channel.js';
15
- import type { HubConnection, HubConnectionParams } from './hub-connection.js';
16
- import { type CreateHubClient } from './relay-manager.js';
16
+ import type { SyncTransportProvider } from '../sync/sync-client.js';
17
+ import type { LedgerCatchupOptions, LedgerCatchupSummary, RejoinResult, StoreUnreadableMode } from '../types.js';
18
+ import type { CreateHubClient } from './http-client.js';
17
19
  export type HubWiring = {
18
20
  ready: Promise<void>;
19
21
  scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
@@ -25,79 +27,103 @@ export type HubWiring = {
25
27
  */
26
28
  broadcastNow: (groupID: string, message: GroupBroadcastMessage) => Promise<void>;
27
29
  /**
28
- * Fire-and-forget fan-out of a pre-produced MLS handshake Commit to a
29
- * group's existing members. Wired to `HubRelayManager.sendCommit`.
30
+ * 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.
30
36
  */
31
- scheduleSendCommit: (groupID: string, commitBytes: Uint8Array) => void;
37
+ commitToGroup: (groupID: string, build: () => Promise<PendingCommit>) => Promise<LaneResult>;
32
38
  /**
33
39
  * Run stale-device MLS epoch recovery for an active group. Wired to
34
- * `HubRelayManager.rejoinGroup`; a no-op `recovery-failed` under
35
- * `DISABLED_HUB` (no relay, nothing to rejoin).
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.
36
43
  */
37
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>;
38
68
  dispose: () => Promise<void>;
39
69
  };
40
70
  export type SetupHubRelayParams = {
41
71
  identity: OwnIdentity;
42
- /** Fresh correlation ids for the rejoin flow. */
43
- getRandomID: () => string;
72
+ /** Host runtime, forwarded to each peer for fresh group-rpc correlation ids. */
73
+ runtime: Runtime;
44
74
  db: KubunDB;
45
75
  /**
46
- * Engine graph internals — threaded through to `BroadcastService` so the
47
- * `mutation:apply` broadcast variant can route peer-authored mutations
48
- * through the engine with `origin: 'peer'`.
76
+ * Engine graph internals — threaded through to the per-group peer apply path
77
+ * so a `sync/mutationApply` frame routes peer-authored mutations through the
78
+ * engine with `origin: 'peer'`.
49
79
  */
50
80
  graph: GraphInternals;
51
81
  emitter: P2PEventEmitter;
52
82
  createHubClient: CreateHubClient;
53
- onRemoteBroadcast: (event: BroadcastEvent) => void;
54
- /**
55
- * Single canonical access point for MLS `GroupHandle` instances. Threaded
56
- * into `BroadcastService` so encrypt/decrypt go through the per-group
57
- * mutex once Phase 3 lands. Currently stored but unused.
58
- */
83
+ /** Single canonical access point for MLS `GroupHandle` instances. */
59
84
  registry: GroupHandleRegistry;
60
- /** Shared health monitor; receive outcomes drive epoch-stale detection. */
61
- monitor?: GroupHealthMonitor;
62
- logger: Logger;
63
85
  /**
64
- * Optional testing seam forwarded to `HubRelayManager`. Production code
65
- * leaves this unset so the manager uses its default `GroupChannel`
66
- * constructor.
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}.
67
89
  */
68
- createGroupChannel?: (params: GroupChannelParams) => GroupChannel;
69
- /**
70
- * Optional testing seam forwarded to `HubRelayManager`. Production code
71
- * leaves this unset so the manager uses its default `HubConnection`
72
- * constructor.
73
- */
74
- createHubConnection?: (params: HubConnectionParams) => HubConnection;
90
+ buildLedgerRedrive: BuildLedgerRedrive;
91
+ logger: Logger;
75
92
  /**
76
- * Receive-side storage mode. Forwarded to `BroadcastService`
77
- * `processBroadcast`. Defaults to `'persist'` when omitted.
93
+ * Receive-side storage mode. Forwarded to the apply path. Defaults to
94
+ * `'persist'` when omitted.
78
95
  */
79
96
  storeUnreadable?: StoreUnreadableMode;
80
97
  /** Server default access level — required when `storeUnreadable === 'drop'`. */
81
98
  defaultAccessLevel?: DefaultAccessLevel;
82
99
  /**
83
- * Forwarding config. Threaded through to `BroadcastService`
84
- * `processBroadcast` along with the hub's own `scheduleBroadcast` hook.
85
- * Default `false` disables the receive-time forwarding pipeline.
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.
86
103
  */
87
104
  forwarding?: ForwardingConfig;
88
105
  /**
89
- * Forwarded to `HubRelayManager` `HubConnectionPool`. Fired with the
90
- * observed `serverDID` after the first signed response from a freshly-spawned
91
- * client. Lets callers route capture into `captureServerDID` for TOFU
92
- * pinning; omitting it skips capture entirely.
93
- */
94
- onServerDIDObserved?: OnServerDIDObserved;
95
- /**
96
- * Device-wide monotonic clock forwarded to `BroadcastService` for stamping
106
+ * Device-wide monotonic clock forwarded to the apply path for stamping
97
107
  * `removed_at_hlc` on roster reconciliation. Required: production passes the
98
108
  * engine's single instance so roster writes share the device's one clock.
99
109
  */
100
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>;
101
128
  };
102
129
  export declare function setupHubRelay(params: SetupHubRelayParams): HubWiring;
103
- export declare const DISABLED_HUB: HubWiring;
package/lib/hub/wiring.js CHANGED
@@ -1 +1,189 @@
1
- import{BroadcastService as e}from"../groups/broadcast-service.js";import{HubRelayManager as r}from"./relay-manager.js";export function setupHubRelay(a){let o,{identity:t,getRandomID:d,db:i,graph:s,emitter:n,createHubClient:c,onRemoteBroadcast:u,registry:p,monitor:l,logger:h,createGroupChannel:m,createHubConnection:g,storeUnreadable:f,defaultAccessLevel:y,forwarding:w,onServerDIDObserved:b,hlc:v}=a,D=[],B=(e,r)=>{o.then(a=>a.broadcast(e,r).catch(r=>{h.error("broadcast failed",{groupID:e,error:r})}),()=>{})},G=async(e,r)=>{let a=await o;await a.broadcast(e,r)},I=async e=>{let r=await o;return await r.rejoinGroup(e)};return(o=(async()=>{let[a,o]=await Promise.all([i.getStore("p2p"),i.getStore("graph")]),G=new e({p2pStore:a,graphStore:o,graph:s,selfDID:t.id,registry:p,hlc:v,storeUnreadable:f,defaultAccessLevel:y,forwarding:w,scheduleBroadcast:B,emitter:n,logger:h}),I=new r({p2pStore:a,broadcastService:G,deviceID:t.id,createHubClient:c,monitor:l,identity:t,getRandomID:d,logger:h,createGroupChannel:m,createHubConnection:g,onServerDIDObserved:b});D.push(G.on("broadcast",u)),D.push(n.on("groupJoined",e=>I.addGroup(e.id).catch(r=>{h.error("addGroup failed",{groupID:e.id,error:r})}))),D.push(n.on("groupLeft",e=>I.removeGroup(e.groupID).catch(r=>{h.error("removeGroup failed",{groupID:e.groupID,error:r})}))),D.push(n.on("hubBound",({groupID:e,hubURL:r})=>I.addBinding(e,r).catch(a=>{h.error("addBinding failed",{groupID:e,hubURL:r,error:a})}))),D.push(n.on("hubUnbound",({groupID:e,hubURL:r})=>I.removeBinding(e,r).catch(a=>{h.error("removeBinding failed",{groupID:e,hubURL:r,error:a})})));let S=await a.listGroups();return await I.start(S.map(e=>e.id)),I})()).catch(e=>{h.error("hub relay setup failed",{error:e})}),{ready:o.then(()=>void 0),scheduleBroadcast:B,broadcastNow:G,scheduleSendCommit:(e,r)=>{o.then(a=>a.sendCommit(e,r).catch(r=>{h.error("sendCommit failed",{groupID:e,error:r})}),()=>{})},rejoinGroup:I,dispose:async()=>{for(let e of D)try{e()}catch{}D.length=0;try{let e=await o;await e.stop()}catch{}}}}export const DISABLED_HUB={ready:Promise.resolve(),scheduleBroadcast:()=>{},broadcastNow:async()=>{},scheduleSendCommit:()=>{},rejoinGroup:async()=>({status:"recovery-failed"}),dispose:async()=>{}};
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;
12
+ export function setupHubRelay(params) {
13
+ const { identity, runtime, db, graph, emitter, createHubClient, registry, buildLedgerRedrive, logger, storeUnreadable, defaultAccessLevel, forwarding, hlc, hubReconnectBackoff, tunnelIdleTimeoutMs } = params;
14
+ const unsubscribes = [];
15
+ // Late-bound `ready` resolving to the live manager. The fire-and-forget
16
+ // schedulers chain off it so a caller can enqueue work before boot completes;
17
+ // the awaitable variants simply await it.
18
+ let ready;
19
+ const scheduleBroadcast = (groupID, message)=>{
20
+ void ready.then((manager)=>manager.broadcast(groupID, message).catch((error)=>{
21
+ logger.error('broadcast failed', {
22
+ groupID,
23
+ error
24
+ });
25
+ }), ()=>{
26
+ // ready rejected; setup failure already logged below
27
+ });
28
+ };
29
+ const broadcastNow = async (groupID, message)=>{
30
+ const manager = await ready;
31
+ await manager.broadcast(groupID, message);
32
+ };
33
+ const commitToGroup = async (groupID, build)=>{
34
+ const manager = await ready;
35
+ return await manager.commit(groupID, build);
36
+ };
37
+ const rejoinGroup = async (groupID)=>{
38
+ const manager = await ready;
39
+ return await manager.rejoinGroup(groupID);
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
+ };
71
+ ready = (async ()=>{
72
+ const [p2pStore, graphStore] = await Promise.all([
73
+ db.getStore('p2p'),
74
+ db.getStore('graph')
75
+ ]);
76
+ const manager = createGroupPeerManager({
77
+ createHubClient,
78
+ registry,
79
+ buildLedgerRedrive,
80
+ stores: db,
81
+ graphStore,
82
+ graph,
83
+ hlc,
84
+ localDID: identity.id,
85
+ identity,
86
+ emitter,
87
+ runtime,
88
+ logger,
89
+ storeUnreadable,
90
+ defaultAccessLevel,
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
105
+ });
106
+ unsubscribes.push(emitter.on('groupJoined', (group)=>manager.addGroup(group.id).catch((error)=>{
107
+ logger.error('addGroup failed', {
108
+ groupID: group.id,
109
+ error
110
+ });
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
+ }
126
+ unsubscribes.push(emitter.on('groupLeft', (group)=>manager.removeGroup(group.groupID).catch((error)=>{
127
+ logger.error('removeGroup failed', {
128
+ groupID: group.groupID,
129
+ error
130
+ });
131
+ })));
132
+ unsubscribes.push(emitter.on('hubBound', ({ groupID, hubURL })=>manager.addBinding(groupID, hubURL).catch((error)=>{
133
+ logger.error('addBinding failed', {
134
+ groupID,
135
+ hubURL,
136
+ error
137
+ });
138
+ })));
139
+ unsubscribes.push(emitter.on('hubUnbound', ({ groupID, hubURL })=>manager.removeBinding(groupID, hubURL).catch((error)=>{
140
+ logger.error('removeBinding failed', {
141
+ groupID,
142
+ hubURL,
143
+ error
144
+ });
145
+ })));
146
+ const groups = await p2pStore.listGroups();
147
+ await manager.start(groups.map((g)=>g.id));
148
+ return manager;
149
+ })();
150
+ // Prevent an unhandled rejection if nobody awaits `ready` directly.
151
+ ready.catch((error)=>{
152
+ logger.error('hub relay setup failed', {
153
+ error
154
+ });
155
+ });
156
+ return {
157
+ // Handle rejection here too: the base `.catch` covers the root `ready`, not
158
+ // this derived chain, and callers of `.ready` don't always await it.
159
+ ready: ready.then(()=>undefined, (error)=>{
160
+ logger.error('hub relay setup failed', {
161
+ error
162
+ });
163
+ }),
164
+ scheduleBroadcast,
165
+ broadcastNow,
166
+ commitToGroup,
167
+ rejoinGroup,
168
+ requestLedgerCatchup,
169
+ presence,
170
+ retryHubs,
171
+ syncTransportTo,
172
+ dispose: async ()=>{
173
+ for (const off of unsubscribes){
174
+ try {
175
+ off();
176
+ } catch {
177
+ // ignore
178
+ }
179
+ }
180
+ unsubscribes.length = 0;
181
+ try {
182
+ const manager = await ready;
183
+ await manager.stop();
184
+ } catch {
185
+ // setup failed; nothing to stop
186
+ }
187
+ }
188
+ };
189
+ }