@kubun/plugin-p2p 0.11.0 → 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 (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 +56 -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
package/lib/index.js CHANGED
@@ -3,41 +3,51 @@ import { Server } from '@enkaku/server';
3
3
  import { DirectTransports } from '@enkaku/transport';
4
4
  import { isFullIdentity } from '@kokuin/token';
5
5
  import { resolveAllowedOrigin } from '@kubun/http-util';
6
+ import { getGraphStore } from '@kubun/store-graph';
6
7
  import { p2pStoreDefinition } from '@kubun/store-p2p';
7
8
  import { createDelegationContext } from './context/delegation.js';
8
9
  import { createGroupContext } from './context/group.js';
9
10
  import { createHubContext } from './context/hub.js';
10
11
  import { createJoinContext } from './context/join.js';
12
+ import { createPeerContext, serveGroupInvite, servePushControl } from './context/peer.js';
11
13
  import { createSyncContext } from './context/sync.js';
12
14
  import { createP2PEventEmitter } from './groups/events.js';
13
15
  import { GroupHandleRegistry } from './groups/group-handle-registry.js';
14
16
  import { GroupHealthMonitor } from './groups/group-health-monitor.js';
15
17
  import { GroupManager } from './groups/manager.js';
16
18
  import { createHTTPHubClient } from './hub/http-client.js';
17
- import { captureServerDID } from './hub/manager.js';
18
- import { DISABLED_HUB, setupHubRelay } from './hub/wiring.js';
19
+ import { setupHubRelay } from './hub/wiring.js';
20
+ import { PeerConnectionRegistry } from './peer/connection-registry.js';
21
+ import { createPeerHandlers } from './peer/handlers.js';
19
22
  import { createP2PSchemaExtension } from './schema.js';
23
+ import { wireAccessDefaultSender } from './sync/access-default-sender.js';
20
24
  import { createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG } from './sync/broadcast-queue.js';
21
25
  import { DEFAULT_PUSH_SYNC_CONFIG, wireBroadcastSender } from './sync/broadcast-sender.js';
22
26
  import { createSyncHandlers } from './sync/handlers.js';
23
27
  import { SyncManager } from './sync/sync-manager.js';
24
- // ---- Re-exports: groups ----
25
- export { ADMIN_ROLE_ENTRY_TYPE, ADMIN_ROSTER_PROJECTION, adminRosterReducer, applyAdminEntryIncremental, foldAdminRoster, projectAdminRoster, projectAdminRosterFromStore, reprojectAdminRoster } from './groups/admin-roster.js';
28
+ export { GROUP_CONTROL_DENIED, LAST_GROUP_ADMIN, NOT_GROUP_ADMIN, requireGroupAdmin } from './context/require-admin.js';
29
+ export { ADMIN_ROLE_ENTRY_TYPE, foldAdminRoster } from './groups/admin-roster.js';
26
30
  export { processBroadcast } from './groups/broadcast.js';
27
31
  export { deserializeBroadcast, serializeBroadcast } from './groups/broadcast-codec.js';
28
32
  export { createFilteredGenerator, createP2PEventEmitter } from './groups/events.js';
29
- export { groupAnchorCapabilities, readGroupAnchor } from './groups/group-anchor.js';
33
+ export { buildKubunGroupAnchorExtension, readRecoverySecret } from './groups/group-anchor.js';
30
34
  export { GroupHandleRegistry } from './groups/group-handle-registry.js';
31
35
  export { decodeFullJoinRequest, decodeInvitePayload, decodeJoinRequest, encodeFullJoinRequest, encodeInvitePayload, encodeJoinRequest } from './groups/invite-payload.js';
32
- export { ledgerEntryDigest, signLedgerEntry, verifyLedgerEntry } from './groups/ledger-entry.js';
33
- export { foldLedger } from './groups/ledger-fold.js';
34
- export { GroupManager } from './groups/manager.js';
36
+ export { resolveJoinRequestDID } from './groups/join-request-identity.js';
37
+ export { entryOrd, foldLedgerEntries, ledgerEntryDigest, signLedgerEntry, verifyLedgerEntry } from './groups/ledger.js';
35
38
  export { MLSEncryptor } from './groups/mls-encryptor.js';
36
39
  export { restoreMLSGroupHandle } from './groups/mls-group-handle.js';
37
40
  export { replacer as mlsJSONReplacer, reviver as mlsJSONReviver } from './groups/mls-json.js';
38
41
  export { deserializeMLSGroupState, serializeMLSGroupState } from './groups/mls-state.js';
42
+ export { MERKLE_SYNC_PROTOCOL } from './groups/peer-selection.js';
43
+ export { createHTTPHubClient } from './hub/http-client.js';
44
+ export { bindHubToGroup, createHub, deleteHub, getHub, listGroupsByHub, listHubs, listHubsByGroup, unbindHubFromGroup, updateHub, upsertHub } from './hub/manager.js';
45
+ export { createHubServerDIDResolver, fetchHubInfo } from './hub/server-did.js';
46
+ export { setupHubRelay } from './hub/wiring.js';
47
+ export { PeerConnectionRegistry } from './peer/connection-registry.js';
48
+ export { createPeerHandlers } from './peer/handlers.js';
49
+ export { peerProtocol } from './protocol.js';
39
50
  export { createP2PSchemaExtension } from './schema.js';
40
- // ---- Re-exports: sync ----
41
51
  export { createBroadcastQueue, DEFAULT_BROADCAST_BATCH_CONFIG } from './sync/broadcast-queue.js';
42
52
  export { DEFAULT_PUSH_SYNC_CONFIG, wireBroadcastSender } from './sync/broadcast-sender.js';
43
53
  export { resolveCatalogSyncScopes } from './sync/catalog-scope.js';
@@ -49,18 +59,12 @@ export { HubTunnelSyncProvider } from './sync/hub-tunnel-sync-provider.js';
49
59
  export { negotiateDirection, PeerRegistry } from './sync/peer-registry.js';
50
60
  export { SyncClient } from './sync/sync-client.js';
51
61
  export { SyncManager } from './sync/sync-manager.js';
52
- // ---- Re-exports: hub ----
53
- export { DIDObservingTransport } from './hub/did-observing-transport.js';
54
- export { createHTTPHubClient } from './hub/http-client.js';
55
- export { bindHubToGroup, captureServerDID, createHub, deleteHub, getHub, HubServerDIDConflictError, listGroupsByHub, listHubs, listHubsByGroup, unbindHubFromGroup, updateHub, upsertHub } from './hub/manager.js';
56
- export { DISABLED_HUB, setupHubRelay } from './hub/wiring.js';
57
62
  /**
58
63
  * Default receive config. Under `'persist'` no gate is built and no extra
59
64
  * DB lookups occur during receive.
60
65
  */ export const DEFAULT_RECEIVE_CONFIG = {
61
66
  storeUnreadable: 'persist'
62
67
  };
63
- // ---- Plugin factory ----
64
68
  function isOwnIdentity(identity) {
65
69
  return isFullIdentity(identity) && 'privateKey' in identity;
66
70
  }
@@ -79,7 +83,6 @@ export function createP2PPlugin(options) {
79
83
  // than identical ones (the second of which would silently lose under
80
84
  // last-writer-wins).
81
85
  const hlc = params.hlc;
82
- // ---- Receive config ----
83
86
  // Computed up front so SyncManager + sync handlers + the hub-relay
84
87
  // BroadcastService all see the same receive policy. Hot-path invariant:
85
88
  // when `storeUnreadable === 'persist'` (default), no gate is built and
@@ -92,9 +95,8 @@ export function createP2PPlugin(options) {
92
95
  read: 'only_owner',
93
96
  write: 'only_owner'
94
97
  };
95
- // ---- Group handle registry ----
96
98
  // Single canonical access point for the device's MLS GroupHandle instances.
97
- // Threaded through GroupManager + BroadcastService + (Phase 4) tunnel
99
+ // Threaded through GroupManager + BroadcastService + the tunnel
98
100
  // listener/provider so all five mutation paths serialize through one
99
101
  // per-group mutex with write-through persist.
100
102
  const registry = new GroupHandleRegistry({
@@ -102,27 +104,47 @@ export function createP2PPlugin(options) {
102
104
  deviceID: identity.id,
103
105
  logger: params.getLogger('group-handle-registry')
104
106
  });
105
- // ---- Group manager ----
107
+ const emitter = createP2PEventEmitter();
108
+ // `stores` + `emitter` are the lane-side wiring: a commit's `onAccepted`
109
+ // fires decoupled from the resolver that started it, so it folds this
110
+ // device's own committed entries on the device-wide connection and emits the
111
+ // resulting domain events itself.
106
112
  const groupManager = new GroupManager({
107
113
  identity,
108
- getRandomID: params.runtime.getRandomID,
114
+ runtime: params.runtime,
109
115
  registry,
110
- hlc
116
+ hlc,
117
+ stores: params.db,
118
+ emitter,
119
+ logger: params.getLogger('group-manager')
111
120
  });
112
- const emitter = createP2PEventEmitter();
113
121
  const groupHealthMonitor = new GroupHealthMonitor({
114
122
  emitter,
115
123
  logger: params.getLogger('group-health-monitor')
116
124
  });
117
- // ---- Sync manager ----
118
125
  const syncManager = new SyncManager({
119
126
  deployClusters: async (clusters)=>{
120
127
  // Values are ClusterModel objects serialized through the protocol —
121
128
  // cast through unknown since SyncManager doesn't depend on protocol types.
122
129
  const clusterModels = Object.values(clusters);
123
- await params.engine.deployGraph({
124
- clusters: clusterModels
125
- });
130
+ // Into the graphs this device already syncs on, never a fresh one. A
131
+ // received model deployed under a new id gets its tables and nothing
132
+ // else: the document lands and the device's own graph — the one its app
133
+ // queries — has no schema for it, so the model reads as absent while
134
+ // being present. Deployed WITHOUT `plugins`, which leaves each graph's
135
+ // existing extension SDL and plugin config untouched.
136
+ const graphStore = await getGraphStore(params.db);
137
+ const targets = (await graphStore.listGraphs()).filter((graph)=>graph.plugin_config != null && 'p2p' in graph.plugin_config);
138
+ if (targets.length === 0) {
139
+ params.getLogger('sync').warn('Received clusters but no p2p-enabled graph to deploy them into');
140
+ return;
141
+ }
142
+ for (const graph of targets){
143
+ await params.engine.deployGraph({
144
+ id: graph.id,
145
+ clusters: clusterModels
146
+ });
147
+ }
126
148
  },
127
149
  graph: params.graph,
128
150
  runtime: params.runtime,
@@ -140,9 +162,12 @@ export function createP2PPlugin(options) {
140
162
  storeUnreadable: receiveConfig.storeUnreadable,
141
163
  defaultAccessLevel
142
164
  });
143
- // ---- Pending join requests (for CLI peering flow) ----
144
- const pendingJoinRequests = new Map();
145
- // ---- Sync server ----
165
+ // In-memory registry of live peer connections, keyed by remote DID. Written
166
+ // by `connectPeer`; nothing persisted. Plugin-scoped so a connection
167
+ // outlives a single GraphQL request.
168
+ const peerConnections = new PeerConnectionRegistry({
169
+ logger: params.getLogger('peer-connections')
170
+ });
146
171
  // The p2p plugin manages its own Enkaku server for sync handlers,
147
172
  // separate from plugin-rpc's graph server.
148
173
  const syncServers = [];
@@ -151,7 +176,7 @@ export function createP2PPlugin(options) {
151
176
  signal
152
177
  });
153
178
  const server = new Server({
154
- getRandomID: params.runtime.getRandomID,
179
+ runtime: params.runtime,
155
180
  handlers: syncHandlers,
156
181
  identity,
157
182
  // Open at the procedure layer by design: the sync protocol's
@@ -171,48 +196,95 @@ export function createP2PPlugin(options) {
171
196
  syncServers.push(server);
172
197
  return directTransports.client;
173
198
  }
174
- // ---- Hub relay ----
175
199
  const hubRelayLogger = params.getLogger('hub-relay');
176
- let hub = DISABLED_HUB;
177
200
  let unwireBroadcastSender;
201
+ let unwireAccessDefaultSender;
178
202
  let broadcastQueue;
179
203
  const pushSyncConfig = {
180
204
  ...DEFAULT_PUSH_SYNC_CONFIG,
181
205
  ...options?.pushSync
182
206
  };
207
+ // The relay always runs: it is what gives every joined group a commit lane,
208
+ // including a group with no hub bound (which commits against an in-process
209
+ // loopback log). Hub clients are only ever spawned for a group that has a
210
+ // hub row, so a device configured without hubs opens no connection.
211
+ const createHubClient = typeof options?.hub === 'object' && options.hub.createHubClient != null ? options.hub.createHubClient : createHTTPHubClient({
212
+ identity,
213
+ fetch: params.runtime.fetch
214
+ });
215
+ const hubReconnectBackoff = typeof options?.hub === 'object' ? options.hub.reconnectBackoff : undefined;
216
+ const hub = setupHubRelay({
217
+ identity,
218
+ runtime: params.runtime,
219
+ db: params.db,
220
+ hlc,
221
+ graph: params.graph,
222
+ emitter,
223
+ createHubClient,
224
+ registry,
225
+ // Re-drive a lost `ledger` commit's surviving tokens through the group
226
+ // manager's own ledger write path, so the seam stays a consumer of it.
227
+ buildLedgerRedrive: (groupID, tokens, requestID)=>groupManager.buildEnactLedgerCommit({
228
+ groupID,
229
+ tokens,
230
+ requestID
231
+ }),
232
+ logger: hubRelayLogger,
233
+ storeUnreadable: receiveConfig.storeUnreadable,
234
+ defaultAccessLevel,
235
+ forwarding: options?.forwarding,
236
+ ...hubReconnectBackoff != null ? {
237
+ hubReconnectBackoff
238
+ } : {},
239
+ ...options?.tunnelIdleTimeoutMs != null ? {
240
+ tunnelIdleTimeoutMs: options.tunnelIdleTimeoutMs
241
+ } : {},
242
+ // The same handlers the direct and HTTP transports serve — a tunnel is a
243
+ // route, so it must not reach a different sync implementation.
244
+ syncHandlers: syncHandlers
245
+ });
246
+ // Turn a `tunnel://<groupID>/<peerDID>` route into a live session over the
247
+ // group's hub. Until this existed the scheme parsed and then failed at
248
+ // "Server resolver not configured", so every device could only be reached by
249
+ // URL — the topology this design retires.
250
+ //
251
+ // Route resolution only. A resolver hands back a transport; what the far end
252
+ // serves over it is decided per document there, so nothing here can widen
253
+ // what a peer may read.
254
+ syncManager.setServerResolver((serverID, route)=>{
255
+ if (route == null) {
256
+ // `direct://` — in-process peers, which only a test wires by hand.
257
+ return undefined;
258
+ }
259
+ // Built lazily: the hub relay binds asynchronously, and a resolver that
260
+ // waited for it here would have to be async, which this seam is not.
261
+ return {
262
+ createSyncTransport: async (signal)=>{
263
+ const provider = await hub.syncTransportTo(route.groupID, serverID);
264
+ if (provider == null) {
265
+ throw new Error(`No hub bound for group ${route.groupID}: cannot open a tunnel`);
266
+ }
267
+ return await provider.createSyncTransport(signal);
268
+ }
269
+ };
270
+ });
183
271
  if (options?.hub) {
184
- const createHubClient = typeof options.hub === 'object' && options.hub.createHubClient != null ? options.hub.createHubClient : createHTTPHubClient({
185
- identity,
186
- fetch: params.runtime.fetch
187
- });
188
- hub = setupHubRelay({
189
- identity,
190
- getRandomID: params.runtime.getRandomID,
272
+ // Replicate this peer's own model access-defaults to co-members.
273
+ // Control-plane, not document push-sync: always wired when a hub exists so
274
+ // an owner's sharing policy reaches members that must evaluate its docs.
275
+ unwireAccessDefaultSender = wireAccessDefaultSender({
191
276
  db: params.db,
192
- hlc,
193
- graph: params.graph,
194
- emitter,
195
- createHubClient,
196
- registry,
197
- logger: hubRelayLogger,
198
- storeUnreadable: receiveConfig.storeUnreadable,
199
- defaultAccessLevel,
200
- // TOFU capture. The pool fires this once per fresh client when the
201
- // first signed response arrives. Manager-level conflicts are caught
202
- // and logged inside the pool; the pinned-DID mismatch path surfaces
203
- // a typed error to callers via the transport wrapper.
204
- onServerDIDObserved: ({ hubURL, serverDID })=>captureServerDID({
205
- stores: params.db,
206
- hubURL,
207
- serverDID
208
- }).then(()=>undefined)
277
+ eventBus: params.eventBus,
278
+ identity,
279
+ scheduleBroadcast: hub.scheduleBroadcast,
280
+ logger: params.getLogger('access-default-sender')
209
281
  });
210
282
  // Subscribe to locally-authored mutations and fan out per-scope
211
283
  // `mutation:apply` broadcasts through the hub. Gated independently from
212
284
  // the hub itself: the hub is needed for receive + pull regardless, but
213
- // push-sync is opt-in via `pushSync.enabled`. With `DISABLED_HUB` we'd
214
- // also skip wiring (scheduleBroadcast no-ops + spurious broadcast_log
215
- // rows), but the explicit gate here makes intent observable.
285
+ // push-sync is opt-in via `pushSync.enabled`. Without the gate an
286
+ // unconfigured device would still accrue broadcast_log rows for sends that
287
+ // reach no hub-backed peer.
216
288
  if (pushSyncConfig.enabled) {
217
289
  const batchConfig = {
218
290
  ...DEFAULT_BROADCAST_BATCH_CONFIG,
@@ -232,7 +304,60 @@ export function createP2PPlugin(options) {
232
304
  });
233
305
  }
234
306
  }
235
- // ---- Plugin API ----
307
+ // Single builder for the per-request ContextDeps, shared by the GraphQL
308
+ // context factory and the peer-procedure runners. Reads `hub` at call time
309
+ // so the hub wiring assigned above is always current.
310
+ function buildContextDeps(stores) {
311
+ return {
312
+ identity,
313
+ groupManager,
314
+ registry,
315
+ syncManager,
316
+ peerConnections,
317
+ stores,
318
+ deviceStores: params.db,
319
+ adapter,
320
+ hlc,
321
+ emitter,
322
+ runtime: params.runtime,
323
+ autoAcceptPeers: options?.autoAcceptPeers,
324
+ ...options?.inviteCommitTimeoutMs != null ? {
325
+ inviteCommitTimeoutMs: options.inviteCommitTimeoutMs
326
+ } : {},
327
+ scheduleBroadcast: hub.scheduleBroadcast,
328
+ broadcastNow: hub.broadcastNow,
329
+ commitToGroup: hub.commitToGroup,
330
+ monitor: groupHealthMonitor,
331
+ rejoinGroup: hub.rejoinGroup,
332
+ requestLedgerCatchup: hub.requestLedgerCatchup,
333
+ logger: params.getLogger('context')
334
+ };
335
+ }
336
+ // The dance runners reuse the SAME join context ops the GraphQL mutations
337
+ // use, executed on behalf of the verified caller against the plugin's own
338
+ // DB (peer procedures run outside an engine transaction; `joinGroup` manages
339
+ // its own inner transaction). No MLS logic is reimplemented here.
340
+ const peerHandlers = createPeerHandlers({
341
+ db: params.db,
342
+ identity,
343
+ logger: params.getLogger('peer-handlers'),
344
+ autoAcceptPeers: options?.autoAcceptPeers,
345
+ runPrepareJoin: (callerDID)=>createJoinContext({
346
+ viewerDID: callerDID
347
+ }, buildContextDeps(params.db)).prepareRequest(),
348
+ runCompleteJoin: (callerDID, invitePayload)=>createJoinContext({
349
+ viewerDID: callerDID
350
+ }, buildContextDeps(params.db)).complete(invitePayload),
351
+ runInvite: (callerDID, groupID, joinRequest)=>serveGroupInvite(buildContextDeps(params.db), {
352
+ callerDID,
353
+ groupID,
354
+ joinRequest
355
+ }),
356
+ runPushControl: (callerDID, param)=>servePushControl(buildContextDeps(params.db), {
357
+ callerDID,
358
+ ...param
359
+ })
360
+ });
236
361
  const pluginAPI = {
237
362
  hubReady: hub.ready,
238
363
  addPeer: (config)=>syncManager.addPeer({
@@ -254,12 +379,28 @@ export function createP2PPlugin(options) {
254
379
  peerDID,
255
380
  stores: params.db
256
381
  }),
382
+ // Through the same context the GraphQL surface uses, as the peer-procedure
383
+ // runners do: the scopes an automatic catch-up asks for cannot drift from
384
+ // the ones an explicit one asks for if there is only one implementation.
385
+ catchUpWithBestPeer: (groupID)=>createSyncContext(null, buildContextDeps(params.db)).catchUpWithBestPeer(groupID),
386
+ listPeerDevices: (groupID)=>createSyncContext(null, buildContextDeps(params.db)).listPeerDevices(groupID),
257
387
  onSyncEvent: (callback)=>syncManager.onSyncEvent(callback),
258
- createSyncTransport
388
+ onHubServerDIDChanged: (listener)=>emitter.on('hubServerDIDChanged', listener),
389
+ createSyncTransport,
390
+ requestLedgerCatchup: (groupID, options)=>hub.requestLedgerCatchup(groupID, options),
391
+ setLocalPeerProfile: async (profile)=>{
392
+ await (await hub.presence()).setProfile(profile);
393
+ },
394
+ getLocalPeerProfile: async ()=>await (await hub.presence()).getProfile(),
395
+ gatherPeers: async (groupID, options)=>await (await hub.presence()).gather(groupID, options),
396
+ refreshPeerPresence: async (groupID, options)=>await (await hub.presence()).refresh(groupID, options),
397
+ retryHubConnection: async ()=>await hub.retryHubs(),
398
+ getPeerConnection: (peerDID)=>peerConnections.get(peerDID)
259
399
  };
260
- // ---- HTTP sync transport (optional) ----
261
400
  let httpSyncTransport;
262
401
  let httpSyncServer;
402
+ let httpPeerTransport;
403
+ let httpPeerServer;
263
404
  if (options?.http) {
264
405
  // Capture in the outer scope so the union is narrowed before the async
265
406
  // closure below (re-reading options.http inside the IIFE would widen it).
@@ -283,7 +424,7 @@ export function createP2PPlugin(options) {
283
424
  maxRequestBodySize
284
425
  });
285
426
  httpSyncServer = new Server({
286
- getRandomID: params.runtime.getRandomID,
427
+ runtime: params.runtime,
287
428
  handlers: syncHandlers,
288
429
  identity,
289
430
  // Open at the procedure layer by design: the sync protocol's
@@ -301,39 +442,52 @@ export function createP2PPlugin(options) {
301
442
  });
302
443
  syncServers.push(httpSyncServer);
303
444
  httpAPI.registerProtocol(protocolName, httpSyncTransport.fetch.bind(httpSyncTransport));
445
+ // Mount the peer protocol alongside sync, advertised under its own
446
+ // well-known name so a connecting device discovers it in `/info`. Same
447
+ // CORS + body-limit policy as the sync surface. Its procedure layer is
448
+ // open (`allow: true`); `peer/groups` gates the verified caller in the
449
+ // handler via `checkPeerAccess`, mirroring the dance mutations.
450
+ httpPeerTransport = new ServerTransport({
451
+ allowedOrigin,
452
+ maxRequestBodySize
453
+ });
454
+ httpPeerServer = new Server({
455
+ runtime: params.runtime,
456
+ handlers: peerHandlers,
457
+ identity,
458
+ // Open at the procedure layer, like the sync server: each peer handler
459
+ // gates the verified caller itself (forged-`sub` rejection +
460
+ // `checkPeerAccess`). Covers `peer/*` and the dance `group/*`.
461
+ accessRules: {
462
+ 'peer/*': {
463
+ allow: true
464
+ },
465
+ 'group/*': {
466
+ allow: true
467
+ }
468
+ },
469
+ logger: params.getLogger('peer-http-server'),
470
+ transports: [
471
+ httpPeerTransport
472
+ ]
473
+ });
474
+ httpAPI.registerProtocol('peer', httpPeerTransport.fetch.bind(httpPeerTransport));
304
475
  })();
305
476
  }
306
477
  return {
307
478
  name: 'p2p',
308
- schemaExtension: (_config)=>createP2PSchemaExtension(emitter, params.getLogger('p2p-schema')),
479
+ schemaExtension: (_config)=>createP2PSchemaExtension(emitter, syncManager, params.getLogger('p2p-schema')),
309
480
  api: pluginAPI,
310
481
  createContextFactory: ()=>{
311
482
  return (ctx, stores)=>{
312
- const deps = {
313
- identity,
314
- groupManager,
315
- registry,
316
- syncManager,
317
- stores,
318
- adapter,
319
- hlc,
320
- emitter,
321
- pendingJoinRequests,
322
- runtime: params.runtime,
323
- autoAcceptPeers: options?.autoAcceptPeers,
324
- scheduleBroadcast: hub.scheduleBroadcast,
325
- broadcastNow: hub.broadcastNow,
326
- scheduleSendCommit: hub.scheduleSendCommit,
327
- monitor: groupHealthMonitor,
328
- rejoinGroup: hub.rejoinGroup,
329
- logger: params.getLogger('context')
330
- };
483
+ const deps = buildContextDeps(stores);
331
484
  return {
332
485
  group: createGroupContext(ctx, deps),
333
486
  sync: createSyncContext(ctx, deps),
334
487
  join: createJoinContext(ctx, deps),
335
488
  hub: createHubContext(ctx, deps),
336
- delegation: createDelegationContext(ctx, deps)
489
+ delegation: createDelegationContext(ctx, deps),
490
+ peer: createPeerContext(ctx, deps)
337
491
  };
338
492
  };
339
493
  },
@@ -347,6 +501,13 @@ export function createP2PPlugin(options) {
347
501
  // ignore
348
502
  }
349
503
  }
504
+ if (unwireAccessDefaultSender != null) {
505
+ try {
506
+ unwireAccessDefaultSender();
507
+ } catch {
508
+ // ignore
509
+ }
510
+ }
350
511
  // Synchronously drain the batch queue BEFORE other teardown so any
351
512
  // pending broadcasts ship through the still-live hub transport.
352
513
  if (broadcastQueue != null) {
@@ -356,25 +517,35 @@ export function createP2PPlugin(options) {
356
517
  // ignore
357
518
  }
358
519
  }
359
- // Sync HTTP server depends on httpSyncTransport dispose servers
360
- // first, then the transport. Runs in parallel with syncManager and
520
+ // Tear down every live outbound peer connection (client + transport).
521
+ const peerConnectionsPath = peerConnections.disposeAll();
522
+ // Sync + peer HTTP servers depend on their transports — dispose servers
523
+ // first, then the transports. Runs in parallel with syncManager and
361
524
  // hub teardown, which have no such dependency.
362
525
  const httpPath = (async ()=>{
363
526
  for (const server of syncServers){
364
527
  await server.dispose();
365
528
  }
529
+ if (httpPeerServer != null) {
530
+ await httpPeerServer.dispose();
531
+ }
366
532
  if (httpSyncTransport != null) {
367
533
  await httpSyncTransport.dispose();
368
534
  }
535
+ if (httpPeerTransport != null) {
536
+ await httpPeerTransport.dispose();
537
+ }
369
538
  })();
370
539
  const disposeLogger = params.getLogger('dispose');
371
540
  const results = await Promise.allSettled([
372
541
  httpPath,
542
+ peerConnectionsPath,
373
543
  syncManager.dispose(),
374
544
  hub.dispose()
375
545
  ]);
376
546
  const labels = [
377
547
  'sync-http',
548
+ 'peer-connections',
378
549
  'sync-manager',
379
550
  'hub'
380
551
  ];
@@ -0,0 +1,45 @@
1
+ import type { Client } from '@enkaku/client';
2
+ import type { ClientTransport } from '@enkaku/http-fetch';
3
+ import type { Logger } from '@kubun/logger';
4
+ import type { PeerProtocol } from '../protocol.js';
5
+ /**
6
+ * A live, in-memory connection to a remote peer. Holds the Enkaku client used
7
+ * to dispatch peer procedures plus the transport it rides, so the connection
8
+ * can be torn down cleanly on replacement or plugin dispose. Nothing here is
9
+ * persisted — a connection lives only for the process, keyed by `peerDID`.
10
+ */
11
+ export type PeerConnection = {
12
+ peerDID: string;
13
+ /** Base URL the peer was reached at (the `connectPeer` argument). */
14
+ endpoint: string;
15
+ /**
16
+ * Fully-resolved sync-protocol endpoint discovered from the peer's `/info`.
17
+ * Recorded at connect so `sharePeerGroup` can register the sync peer without
18
+ * re-fetching `/info` or assuming the default `/sync` path.
19
+ */
20
+ syncEndpoint: string;
21
+ client: Client<PeerProtocol>;
22
+ transport: ClientTransport<PeerProtocol>;
23
+ };
24
+ /**
25
+ * In-memory registry of live peer connections, keyed by `peerDID`. A second
26
+ * `connectPeer` to the same DID replaces the prior entry and disposes the old
27
+ * client + transport, so a stale connection is never leaked. Purely ephemeral:
28
+ * durable peer state (endpoint, DID) lives in the peer registry, written by the
29
+ * later dance ops — not here.
30
+ */
31
+ export declare class PeerConnectionRegistry {
32
+ #private;
33
+ constructor(params: {
34
+ logger: Logger;
35
+ });
36
+ /**
37
+ * Register a connection, replacing (and disposing) any existing connection
38
+ * for the same `peerDID`.
39
+ */
40
+ set(connection: PeerConnection): Promise<void>;
41
+ get(peerDID: string): PeerConnection | undefined;
42
+ list(): Array<PeerConnection>;
43
+ /** Dispose every held connection and clear the registry. */
44
+ disposeAll(): Promise<void>;
45
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * In-memory registry of live peer connections, keyed by `peerDID`. A second
3
+ * `connectPeer` to the same DID replaces the prior entry and disposes the old
4
+ * client + transport, so a stale connection is never leaked. Purely ephemeral:
5
+ * durable peer state (endpoint, DID) lives in the peer registry, written by the
6
+ * later dance ops — not here.
7
+ */ export class PeerConnectionRegistry {
8
+ #connections = new Map();
9
+ #logger;
10
+ constructor(params){
11
+ this.#logger = params.logger;
12
+ }
13
+ /**
14
+ * Register a connection, replacing (and disposing) any existing connection
15
+ * for the same `peerDID`.
16
+ */ async set(connection) {
17
+ const existing = this.#connections.get(connection.peerDID);
18
+ if (existing != null) {
19
+ await this.#dispose(existing);
20
+ }
21
+ this.#connections.set(connection.peerDID, connection);
22
+ }
23
+ get(peerDID) {
24
+ return this.#connections.get(peerDID);
25
+ }
26
+ list() {
27
+ return Array.from(this.#connections.values());
28
+ }
29
+ /** Dispose every held connection and clear the registry. */ async disposeAll() {
30
+ const all = Array.from(this.#connections.values());
31
+ this.#connections.clear();
32
+ await Promise.all(all.map((connection)=>this.#dispose(connection)));
33
+ }
34
+ async #dispose(connection) {
35
+ try {
36
+ await connection.client.dispose();
37
+ } catch (error) {
38
+ this.#logger.debug('peer client dispose failed', {
39
+ peerDID: connection.peerDID,
40
+ error
41
+ });
42
+ }
43
+ try {
44
+ await connection.transport.dispose();
45
+ } catch (error) {
46
+ this.#logger.debug('peer transport dispose failed', {
47
+ peerDID: connection.peerDID,
48
+ error
49
+ });
50
+ }
51
+ }
52
+ }