@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
@@ -1,6 +1,7 @@
1
- import { createRevocationChecker } from '@kokuin/capability';
2
- import { createRevocationBackend, getDelegationStore } from '@kubun/store-delegation';
1
+ import { createDelegationRevocationChecker, getDelegationStore } from '@kubun/store-delegation';
3
2
  import { getGraphStore } from '@kubun/store-graph';
3
+ import { getP2PStore } from '@kubun/store-p2p';
4
+ import { wrapHandlers } from '../util/handler-error.js';
4
5
  import { authorizeScope, checkSyncDelegation } from './authorize.js';
5
6
  import { resolveCatalogSyncScopes } from './catalog-scope.js';
6
7
  import { SyncAccessDeniedError } from './errors.js';
@@ -17,11 +18,33 @@ export { checkSyncDelegation };
17
18
  async function getRevocationChecker() {
18
19
  if (cachedRevocationChecker == null) {
19
20
  const delegationStore = await getDelegationStore(db);
20
- cachedRevocationChecker = createRevocationChecker(createRevocationBackend(delegationStore));
21
+ cachedRevocationChecker = createDelegationRevocationChecker(delegationStore);
21
22
  }
22
23
  return cachedRevocationChecker;
23
24
  }
24
- return {
25
+ // Circle-granted read resolution, shared by negotiate and merkle-sync so the
26
+ // two gates authorize identical scopes. Reads run against this device's own
27
+ // projected state (the owner's tier-2 default + the requester's membership).
28
+ let cachedCircleReadGrant = null;
29
+ async function getCircleReadGrant() {
30
+ if (cachedCircleReadGrant == null) {
31
+ const [graphStore, p2pStore] = await Promise.all([
32
+ getGraphStore(db),
33
+ getP2PStore(db)
34
+ ]);
35
+ cachedCircleReadGrant = {
36
+ getUserModelReadDefault: (ownerDID, modelID)=>graphStore.getUserModelAccessDefault(ownerDID, modelID, 'read'),
37
+ // `listAccessibleCircles` excludes tombstoned circles, so a removed
38
+ // circle is simply absent and the gate fails closed on it.
39
+ listAccessibleCircles: (viewerDID, circleIDs)=>p2pStore.listAccessibleCircles(viewerDID, circleIDs),
40
+ listMemberGroupIDs: (viewerDID, groupIDs)=>p2pStore.listMemberGroupIDs(viewerDID, groupIDs)
41
+ };
42
+ }
43
+ return cachedCircleReadGrant;
44
+ }
45
+ // Wrapped so a refusal carrying a registered KubunErrorCode reaches the
46
+ // calling device with its code and reason instead of enkaku's generic EK01.
47
+ return wrapHandlers({
25
48
  'sync/negotiate': async (ctx)=>{
26
49
  const { scopes, delegationTokens, catalogIDs, knownModelIDs, direction } = ctx.param;
27
50
  const payload = ctx.message.payload;
@@ -43,7 +66,7 @@ export { checkSyncDelegation };
43
66
  const graphStore = await getGraphStore(db);
44
67
  // Catalog-based scope resolution
45
68
  if (catalogIDs != null && catalogIDs.length > 0) {
46
- const catalogScope = await resolveCatalogSyncScopes(db, catalogIDs, knownModelIDs);
69
+ const catalogScope = await resolveCatalogSyncScopes(db, catalogIDs);
47
70
  // Generate scopes from resolved catalog criteria
48
71
  if (catalogScope.modelIDs.length > 0) {
49
72
  if (catalogScope.owners != null) {
@@ -53,8 +76,10 @@ export { checkSyncDelegation };
53
76
  const accepted = await authorizeScope({
54
77
  viewerDID,
55
78
  ownerDID,
79
+ modelID,
56
80
  delegationTokens,
57
- revocationChecker: await getRevocationChecker()
81
+ revocationChecker: await getRevocationChecker(),
82
+ circleReadGrant: await getCircleReadGrant()
58
83
  });
59
84
  if (accepted) {
60
85
  acceptedScopes.push({
@@ -72,8 +97,10 @@ export { checkSyncDelegation };
72
97
  const accepted = await authorizeScope({
73
98
  viewerDID,
74
99
  ownerDID,
100
+ modelID,
75
101
  delegationTokens,
76
- revocationChecker: await getRevocationChecker()
102
+ revocationChecker: await getRevocationChecker(),
103
+ circleReadGrant: await getCircleReadGrant()
77
104
  });
78
105
  if (accepted) {
79
106
  acceptedScopes.push({
@@ -85,10 +112,6 @@ export { checkSyncDelegation };
85
112
  }
86
113
  }
87
114
  }
88
- // Fetch missing cluster definitions
89
- if (catalogScope.missingClusterIDs.length > 0) {
90
- missingClusters = await graphStore.getClusters(catalogScope.missingClusterIDs);
91
- }
92
115
  }
93
116
  // Explicit scopes (existing behavior)
94
117
  if (scopes != null) {
@@ -96,8 +119,10 @@ export { checkSyncDelegation };
96
119
  const accepted = await authorizeScope({
97
120
  viewerDID,
98
121
  ownerDID: scope.ownerDID,
122
+ modelID: scope.modelID,
99
123
  delegationTokens,
100
- revocationChecker: await getRevocationChecker()
124
+ revocationChecker: await getRevocationChecker(),
125
+ circleReadGrant: await getCircleReadGrant()
101
126
  });
102
127
  if (accepted) {
103
128
  acceptedScopes.push(scope);
@@ -117,6 +142,20 @@ export { checkSyncDelegation };
117
142
  seen.add(key);
118
143
  return true;
119
144
  });
145
+ // A model the requester does not have is one it cannot store documents
146
+ // for, so ship the cluster it was deployed in. Computed over the ACCEPTED
147
+ // scopes rather than over a catalog: production negotiates with explicit
148
+ // scopes and sends no `catalogIDs`, so a catalog-only computation is
149
+ // unreachable there — and an unauthorized scope must not leak a model
150
+ // definition either way.
151
+ const knownSet = new Set(knownModelIDs ?? []);
152
+ // Concurrently: one read per unknown model, none depending on another, on
153
+ // the negotiate that a peer waits out before any document moves.
154
+ const resolved = await Promise.all(uniqueScopes.filter((scope)=>!knownSet.has(scope.modelID)).map((scope)=>graphStore.getClusterForModel(scope.modelID)));
155
+ const missingClusterIDs = new Set(resolved.filter((id)=>id != null));
156
+ if (missingClusterIDs.size > 0) {
157
+ missingClusters = await graphStore.getClusters(Array.from(missingClusterIDs));
158
+ }
120
159
  logger.info('sync/negotiate completed', {
121
160
  requested: (scopes?.length ?? 0) + (catalogIDs?.length ?? 0),
122
161
  accepted: uniqueScopes.length,
@@ -154,7 +193,8 @@ export { checkSyncDelegation };
154
193
  selfDID: params.selfDID,
155
194
  storeUnreadable,
156
195
  defaultAccessLevel: params.defaultAccessLevel,
157
- getRevocationChecker
196
+ getRevocationChecker,
197
+ getCircleReadGrant
158
198
  })
159
- };
199
+ });
160
200
  }
@@ -1,26 +1,36 @@
1
1
  import { type ProcedureHandlers } from '@enkaku/server';
2
2
  import type { OwnIdentity } from '@kokuin/token';
3
- import { type HubLike, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
3
+ import type { Logger } from '@kubun/logger';
4
+ import { type MailboxHub, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
5
+ import type { Runtime } from '@sozai/runtime';
4
6
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
5
7
  import type { SyncProtocol } from '../protocol.js';
6
8
  export type HubTunnelSyncListenerParams = {
7
9
  /**
8
- * Shared multi-subscriber device hub (from `createHubLike`). Each spawned
9
- * server transport attaches its own sink to this device-wide drain and filters
10
- * to its `receiveTopicID`; lifecycle events ride `hub.events`.
10
+ * Shared multi-subscriber device hub (from `createHubLike`). Every listener on
11
+ * a device receives on the SAME topic (this device's inbox for the epoch), so
12
+ * the hub is not handed to the transport directly it is fronted by a
13
+ * per-peer view that drops other peers' frames before they reach the cipher.
14
+ * Lifecycle events ride `hub.events`.
11
15
  */
12
- hub: HubLike;
16
+ hub: MailboxHub;
13
17
  registry: GroupHandleRegistry;
14
18
  groupID: string;
15
19
  localDID: string;
16
20
  peerDID: string;
17
21
  identity: OwnIdentity;
18
22
  syncHandlers: ProcedureHandlers<SyncProtocol>;
19
- getRandomID?: () => string;
23
+ /**
24
+ * Threaded through to every spawned session's server rather than defaulted
25
+ * here, so a device whose platform primitives differ from the Node defaults
26
+ * keeps one generator across the whole plugin.
27
+ */
28
+ runtime: Runtime;
20
29
  idleTimeoutMs?: number;
21
30
  reconnectTimeoutMs?: number;
22
31
  inboxCapacity?: number;
23
32
  onEvent?: ObservabilityEventListener;
33
+ logger?: Logger;
24
34
  };
25
35
  export declare class HubTunnelSyncListener {
26
36
  #private;
@@ -1,8 +1,11 @@
1
1
  import { Server } from '@enkaku/server';
2
2
  import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
3
- import { inboxTopic } from '@kumiai/rpc';
3
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
4
4
  import { createGroupCrypto } from '../groups/group-crypto.js';
5
5
  import { MLSEncryptor } from '../groups/mls-encryptor.js';
6
+ import { createPeerScopedHubView } from '../hub/peer-scoped-hub-view.js';
7
+ import { createTunnelEventLogger } from './tunnel-observability.js';
8
+ import { tunnelTopic } from './tunnel-topics.js';
6
9
  export class HubTunnelSyncListener {
7
10
  #hub;
8
11
  #registry;
@@ -11,23 +14,25 @@ export class HubTunnelSyncListener {
11
14
  #peerDID;
12
15
  #identity;
13
16
  #syncHandlers;
14
- #getRandomID;
17
+ #runtime;
15
18
  #idleTimeoutMs;
16
19
  #reconnectTimeoutMs;
17
20
  #inboxCapacity;
18
21
  #onEvent;
22
+ #logger;
19
23
  #started = false;
20
24
  #stopped = false;
25
+ /** Consecutive failed spawns, for the re-arm backoff. Reset by a success. */ #respawnAttempt = 0;
26
+ #respawnTimer;
21
27
  #current;
22
28
  // Single MLSEncryptor reused across spawns. Ordering across overlapping
23
29
  // spawns (old transport's last decrypt vs. new spawn's first encrypt) is
24
30
  // covered by the GroupHandleRegistry's per-group mutex.
25
31
  #encryptor;
26
- // This device's inbox topic for the live epoch, cached from the first spawn.
27
- // Within an epoch it is stable (same secret + localDID), so it lets the
28
- // disposed handler re-subscribe synchronously and close the gap left by the
29
- // departing transport's teardown-unsubscribe before the async respawn lands.
30
- #receiveTopicID;
32
+ // Per-peer view over the device hub, built once and shared by every spawn: it
33
+ // holds the inbox subscription for the listener's whole life, so respawns
34
+ // neither re-arm nor release a topic other listeners are draining.
35
+ #peerHub;
31
36
  constructor(params){
32
37
  this.#hub = params.hub;
33
38
  this.#registry = params.registry;
@@ -36,11 +41,12 @@ export class HubTunnelSyncListener {
36
41
  this.#peerDID = params.peerDID;
37
42
  this.#identity = params.identity;
38
43
  this.#syncHandlers = params.syncHandlers;
39
- this.#getRandomID = params.getRandomID;
44
+ this.#runtime = params.runtime;
40
45
  this.#idleTimeoutMs = params.idleTimeoutMs;
41
46
  this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
42
47
  this.#inboxCapacity = params.inboxCapacity;
43
48
  this.#onEvent = params.onEvent;
49
+ this.#logger = params.logger;
44
50
  }
45
51
  start() {
46
52
  if (this.#started || this.#stopped) return;
@@ -49,11 +55,55 @@ export class HubTunnelSyncListener {
49
55
  registry: this.#registry,
50
56
  groupID: this.#groupID
51
57
  });
52
- void this.#spawn();
58
+ this.#peerHub = createPeerScopedHubView({
59
+ hub: this.#hub,
60
+ peerDID: this.#peerDID
61
+ });
62
+ this.#spawnGuarded();
63
+ }
64
+ /**
65
+ * Spawn, and survive a spawn that throws.
66
+ *
67
+ * `#spawn` can reject — `exportSecret(APP_TOPIC_LABEL)` throws whenever the
68
+ * group handle is unavailable (mid-epoch-change, group left, registry lock
69
+ * contention). Left bare, that was an unhandled rejection AND a listener that
70
+ * stopped answering forever: the only other respawn trigger is the
71
+ * transport's `disposed` event, which cannot fire for a session that was
72
+ * never built. From the dialer's side that is exactly the silence this
73
+ * listener exists to eliminate.
74
+ */ #spawnGuarded() {
75
+ void this.#spawn().then(()=>{
76
+ this.#respawnAttempt = 0;
77
+ }).catch((error)=>{
78
+ this.#logger?.warn('hub tunnel listener spawn failed', {
79
+ groupID: this.#groupID,
80
+ peerDID: this.#peerDID,
81
+ attempt: this.#respawnAttempt,
82
+ error
83
+ });
84
+ this.#scheduleRespawn();
85
+ });
86
+ }
87
+ #scheduleRespawn() {
88
+ if (this.#stopped || this.#respawnTimer != null) return;
89
+ // Bounded: a handle that is unavailable because the group was left never
90
+ // becomes available, and this must not spin on it.
91
+ const delay = Math.min(30_000, 250 * 2 ** Math.min(this.#respawnAttempt++, 7));
92
+ const timer = setTimeout(()=>{
93
+ this.#respawnTimer = undefined;
94
+ if (this.#stopped) return;
95
+ this.#spawnGuarded();
96
+ }, delay);
97
+ timer.unref?.();
98
+ this.#respawnTimer = timer;
53
99
  }
54
100
  async stop() {
55
101
  if (this.#stopped) return;
56
102
  this.#stopped = true;
103
+ if (this.#respawnTimer != null) {
104
+ clearTimeout(this.#respawnTimer);
105
+ this.#respawnTimer = undefined;
106
+ }
57
107
  const current = this.#current;
58
108
  this.#current = undefined;
59
109
  if (current != null) {
@@ -69,30 +119,53 @@ export class HubTunnelSyncListener {
69
119
  }
70
120
  }
71
121
  this.#encryptor = undefined;
122
+ this.#peerHub = undefined;
72
123
  }
73
124
  async #spawn() {
74
125
  if (this.#stopped) return;
75
126
  const encryptor = this.#encryptor;
76
- if (encryptor == null) {
77
- throw new Error('HubTunnelSyncListener: encryptor not initialized; call start() first');
127
+ const peerHub = this.#peerHub;
128
+ if (encryptor == null || peerHub == null) {
129
+ throw new Error('HubTunnelSyncListener: not initialized; call start() first');
78
130
  }
79
- // Derive the epoch-bound inbox topics for this spawn. The server accepts
80
- // inbound frames on its own inbox (`receiveTopicID`) and replies to the
81
- // peer's inbox (`sendTopicID`). Topics rotate with the MLS epoch, so each
131
+ // Derive the epoch-bound, role-scoped topics for this spawn. The server
132
+ // accepts inbound frames on this device's RESPONDER topic and replies to the
133
+ // peer's DIALER topic the mirror of what the dialing side derives, and
134
+ // disjoint from it, so this device's own dial to the same peer never lands
135
+ // here. Topics rotate with the MLS epoch, so each
82
136
  // spawn re-derives them against the live handle. MLS decrypt inside the
83
137
  // registry lock remains the authoritative membership/forward-secrecy gate;
84
138
  // stale-after-remove ciphertext surfaces as DecryptError.
85
- const crypto = createGroupCrypto(this.#registry, this.#groupID);
86
- const secret = await crypto.exportSecret();
139
+ // The seed epoch is unread here: this port never classifies commit frames,
140
+ // and `exportSecret()` below records the live epoch before `epoch()` is
141
+ // asked for it. Only a peer that opens a receive drain needs a real seed.
142
+ const crypto = createGroupCrypto({
143
+ registry: this.#registry,
144
+ groupID: this.#groupID,
145
+ initialEpoch: 0,
146
+ runtime: this.#runtime
147
+ });
148
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
87
149
  const epoch = crypto.epoch();
88
- const sendTopicID = inboxTopic(secret, epoch, this.#peerDID);
89
- const receiveTopicID = inboxTopic(secret, epoch, this.#localDID);
90
- this.#receiveTopicID = receiveTopicID;
150
+ const sendTopicID = tunnelTopic(secret, epoch, 'dialer', this.#peerDID);
151
+ const receiveTopicID = tunnelTopic(secret, epoch, 'responder', this.#localDID);
91
152
  // A stop() that landed while we awaited the handle must abort the spawn.
92
153
  if (this.#stopped) return;
154
+ // No sessionID: a responder locks to whatever session dials it, so the id
155
+ // is not known until the first frame. Everything else is, and `role` is what
156
+ // separates this listener's lines from the dialer's on the same device. The
157
+ // two now read disjoint topics, so `receiveTopicID` says the same thing —
158
+ // but it says it in a hash, and only the role says it at a glance.
159
+ const sessionLogger = this.#logger?.with({
160
+ role: 'listener',
161
+ groupID: this.#groupID,
162
+ peerDID: this.#peerDID,
163
+ epoch,
164
+ receiveTopicID
165
+ });
93
166
  let transportRef;
94
167
  const transport = createEncryptedHubTunnelTransport({
95
- hub: this.#hub,
168
+ hub: peerHub,
96
169
  encryptor,
97
170
  groupID: this.#groupID,
98
171
  sessionID: {
@@ -104,7 +177,7 @@ export class HubTunnelSyncListener {
104
177
  idleTimeoutMs: this.#idleTimeoutMs,
105
178
  reconnectTimeoutMs: this.#reconnectTimeoutMs,
106
179
  inboxCapacity: this.#inboxCapacity,
107
- onEvent: this.#onEvent,
180
+ onEvent: sessionLogger == null ? this.#onEvent : createTunnelEventLogger(sessionLogger, this.#onEvent),
108
181
  onSessionEnd: ()=>{
109
182
  // Peer signaled end-of-session. Dispose the transport deterministically
110
183
  // so the `disposed` event fires and the spawn loop re-arms for the
@@ -120,7 +193,7 @@ export class HubTunnelSyncListener {
120
193
  // servers. Per-scope authorization is handled uniformly by the merkle-sync
121
194
  // scope-gate, not here.
122
195
  const server = new Server({
123
- getRandomID: this.#getRandomID,
196
+ runtime: this.#runtime,
124
197
  handlers: this.#syncHandlers,
125
198
  identity: this.#identity,
126
199
  accessRules: {
@@ -137,9 +210,31 @@ export class HubTunnelSyncListener {
137
210
  server
138
211
  };
139
212
  this.#current = session;
213
+ // Defence in depth, and today only that: there is no `await` between the
214
+ // `#stopped` check above and this assignment, so a `stop()` cannot
215
+ // currently interleave into that window. The moment one is introduced —
216
+ // anything awaited while building the transport or the server — a `stop()`
217
+ // landing there would find `#current === undefined`, dispose nothing and
218
+ // return, while this spawn stood up a subscribed, draining transport that
219
+ // nothing holds a reference to. Re-checking after the assignment is what
220
+ // makes that a non-event: whichever side runs second does the teardown.
221
+ if (this.#stopped) {
222
+ this.#current = undefined;
223
+ try {
224
+ await transport.dispose();
225
+ } catch {
226
+ // ignore — best-effort teardown
227
+ }
228
+ try {
229
+ await server.dispose();
230
+ } catch {
231
+ // ignore — best-effort teardown
232
+ }
233
+ return;
234
+ }
140
235
  // `disposed` may fire more than once for a single session (disposing the
141
236
  // server can re-enter the transport's dispose). Collapse to exactly one
142
- // teardown + respawn so the inbox is re-armed once, not N times.
237
+ // teardown + respawn so one session leaves exactly one successor.
143
238
  let handled = false;
144
239
  transport.events.on('disposed', ()=>{
145
240
  if (handled) return;
@@ -152,21 +247,15 @@ export class HubTunnelSyncListener {
152
247
  void server.dispose().catch(()=>{
153
248
  // ignore
154
249
  });
155
- // The departing transport's teardown unsubscribes this device's inbox
156
- // topic. Re-subscribe it eagerly (idempotent server-side) so a peer that
157
- // reconnects immediately before the async respawn re-derives and
158
- // re-subscribes does not lose its first frame to a momentarily empty
159
- // topic. Within an epoch the topic is stable, so this targets the same
160
- // inbox the next spawn will use.
161
- const receiveTopicID = this.#receiveTopicID;
162
- if (receiveTopicID != null) {
163
- void Promise.resolve(this.#hub.subscribe(this.#localDID, receiveTopicID)).catch(()=>{
164
- // best-effort; the respawn re-subscribes authoritatively
165
- });
166
- }
250
+ // Nothing re-arms the inbox here. This fires even after stop(), so a
251
+ // departing listener would be re-subscribing a topic it no longer uses on
252
+ // behalf of listeners it knows nothing about. The gap it was covering is
253
+ // closed at the source instead: the per-peer view never releases the
254
+ // topic, so the departing transport's teardown cannot empty it.
255
+ //
167
256
  // Respawn for the next session. The next spawn re-derives the topics
168
- // against the (possibly advanced) epoch and re-subscribes.
169
- void this.#spawn();
257
+ // against the (possibly advanced) epoch, and survives failing to.
258
+ this.#spawnGuarded();
170
259
  });
171
260
  }
172
261
  }
@@ -1,23 +1,42 @@
1
1
  import type { ClientTransportOf } from '@enkaku/protocol';
2
- import { type HubLike, type ObservabilityEventListener } from '@kumiai/hub-tunnel';
2
+ import type { Logger } from '@kubun/logger';
3
+ import { type ObservabilityEventListener } from '@kumiai/hub-tunnel';
4
+ import type { Runtime } from '@sozai/runtime';
3
5
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
6
+ import type { DeviceHub } from '../hub/hub-like.js';
4
7
  import type { SyncProtocol } from '../protocol.js';
5
8
  import type { SyncTransportProvider } from './sync-client.js';
6
9
  export type HubTunnelSyncProviderParams = {
7
10
  /**
8
- * Shared multi-subscriber device hub (from `createHubLike`). The transport's
9
- * `receive()` attaches its own sink to this device-wide drain and filters to
10
- * `receiveTopicID`; lifecycle events ride `hub.events`.
11
+ * Shared multi-subscriber device hub (from `createHubLike`). Each session
12
+ * attaches its own sink to this device-wide drain, narrowed to the peer it
13
+ * dialled, and filters to `receiveTopicID`; lifecycle events ride
14
+ * `hub.events`.
11
15
  */
12
- hub: HubLike;
16
+ hub: DeviceHub;
13
17
  registry: GroupHandleRegistry;
14
18
  groupID: string;
15
19
  localDID: string;
16
20
  peerDID: string;
21
+ /**
22
+ * Supplies the platform primitives, including the per-session id generator.
23
+ * Threaded in from the caller rather than defaulted here: this package runs on
24
+ * React Native and in the browser as well as on node, so the one runtime the
25
+ * host built has to reach every seam that mints an id.
26
+ */
27
+ runtime: Runtime;
17
28
  idleTimeoutMs?: number;
18
29
  reconnectTimeoutMs?: number;
19
30
  inboxCapacity?: number;
20
31
  onEvent?: ObservabilityEventListener;
32
+ /**
33
+ * Where this session's frame-level events and its open/close are recorded. A
34
+ * session that is handed nothing raises nothing until its idle timeout fires
35
+ * — half a minute later, from inside the dependency, naming neither the group
36
+ * nor the peer — so without this the only account of a starved dial is a
37
+ * stack trace with no message.
38
+ */
39
+ logger?: Logger;
21
40
  };
22
41
  export declare class HubTunnelSyncProvider implements SyncTransportProvider {
23
42
  #private;
@@ -25,12 +44,13 @@ export declare class HubTunnelSyncProvider implements SyncTransportProvider {
25
44
  /**
26
45
  * Build a fresh client transport for one tunnel sync session.
27
46
  *
28
- * The tunnel rides group-scoped inbox topics derived from the group's current
29
- * MLS epoch secret: outbound frames publish to the peer's inbox
30
- * (`inboxTopic(secret, epoch, peerDID)`) and the transport subscribes to this
31
- * device's own inbox (`inboxTopic(secret, epoch, localDID)`). Topics rotate
32
- * with the epoch, so a transport built in epoch N stops matching once a commit
33
- * advances the group — callers create a new transport per session.
47
+ * The tunnel rides group-scoped, ROLE-scoped topics derived from the group's
48
+ * current MLS epoch secret: outbound frames publish to the peer's responder
49
+ * topic, and the transport subscribes to this device's own dialer topic —
50
+ * which the peer's listener writes its answers to and nothing else does.
51
+ * Topics rotate with the epoch, so a transport built in epoch N stops matching
52
+ * once a commit advances the group — callers create a new transport per
53
+ * session.
34
54
  */
35
55
  createSyncTransport(signal?: AbortSignal): Promise<ClientTransportOf<SyncProtocol>>;
36
56
  }
@@ -1,50 +1,94 @@
1
1
  import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
2
- import { inboxTopic } from '@kumiai/rpc';
2
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
3
3
  import { createGroupCrypto } from '../groups/group-crypto.js';
4
4
  import { MLSEncryptor } from '../groups/mls-encryptor.js';
5
+ import { createSenderScopedHubView } from '../hub/sender-scoped-hub-view.js';
6
+ import { createTunnelEventLogger } from './tunnel-observability.js';
7
+ import { tunnelTopic } from './tunnel-topics.js';
5
8
  export class HubTunnelSyncProvider {
6
9
  #hub;
7
10
  #registry;
8
11
  #groupID;
9
12
  #localDID;
10
13
  #peerDID;
14
+ #runtime;
11
15
  #idleTimeoutMs;
12
16
  #reconnectTimeoutMs;
13
17
  #inboxCapacity;
14
18
  #onEvent;
19
+ #logger;
15
20
  constructor(params){
16
21
  this.#hub = params.hub;
17
22
  this.#registry = params.registry;
18
23
  this.#groupID = params.groupID;
19
24
  this.#localDID = params.localDID;
20
25
  this.#peerDID = params.peerDID;
26
+ this.#runtime = params.runtime;
21
27
  this.#idleTimeoutMs = params.idleTimeoutMs;
22
28
  this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
23
29
  this.#inboxCapacity = params.inboxCapacity;
24
30
  this.#onEvent = params.onEvent;
31
+ this.#logger = params.logger;
25
32
  }
26
33
  /**
27
34
  * Build a fresh client transport for one tunnel sync session.
28
35
  *
29
- * The tunnel rides group-scoped inbox topics derived from the group's current
30
- * MLS epoch secret: outbound frames publish to the peer's inbox
31
- * (`inboxTopic(secret, epoch, peerDID)`) and the transport subscribes to this
32
- * device's own inbox (`inboxTopic(secret, epoch, localDID)`). Topics rotate
33
- * with the epoch, so a transport built in epoch N stops matching once a commit
34
- * advances the group — callers create a new transport per session.
36
+ * The tunnel rides group-scoped, ROLE-scoped topics derived from the group's
37
+ * current MLS epoch secret: outbound frames publish to the peer's responder
38
+ * topic, and the transport subscribes to this device's own dialer topic —
39
+ * which the peer's listener writes its answers to and nothing else does.
40
+ * Topics rotate with the epoch, so a transport built in epoch N stops matching
41
+ * once a commit advances the group — callers create a new transport per
42
+ * session.
35
43
  */ async createSyncTransport(signal) {
36
- const sessionID = generateSessionID();
44
+ const sessionID = this.#runtime.getRandomID();
37
45
  const encryptor = new MLSEncryptor({
38
46
  registry: this.#registry,
39
47
  groupID: this.#groupID
40
48
  });
41
- const crypto1 = createGroupCrypto(this.#registry, this.#groupID);
42
- const secret = await crypto1.exportSecret();
43
- const epoch = crypto1.epoch();
44
- const sendTopicID = inboxTopic(secret, epoch, this.#peerDID);
45
- const receiveTopicID = inboxTopic(secret, epoch, this.#localDID);
46
- return createEncryptedHubTunnelTransport({
47
- hub: this.#hub,
49
+ // The seed epoch is unread here: this port never classifies commit frames,
50
+ // and `exportSecret()` below records the live epoch before `epoch()` is
51
+ // asked for it. Only a peer that opens a receive drain needs a real seed.
52
+ const crypto = createGroupCrypto({
53
+ registry: this.#registry,
54
+ groupID: this.#groupID,
55
+ initialEpoch: 0,
56
+ runtime: this.#runtime
57
+ });
58
+ const secret = await crypto.exportSecret(APP_TOPIC_LABEL);
59
+ const epoch = crypto.epoch();
60
+ const sendTopicID = tunnelTopic(secret, epoch, 'responder', this.#peerDID);
61
+ const receiveTopicID = tunnelTopic(secret, epoch, 'dialer', this.#localDID);
62
+ // Bound once and carried by every line this session logs, including the
63
+ // frame drops below. The transport's own idle timeout names none of this —
64
+ // it raises a bare `TimeoutInterruption` from inside the dependency — so
65
+ // attributing a starved session to a group, a peer and an epoch is only
66
+ // possible from here.
67
+ const sessionLogger = this.#logger?.with({
68
+ role: 'dialer',
69
+ groupID: this.#groupID,
70
+ peerDID: this.#peerDID,
71
+ sessionID,
72
+ epoch,
73
+ receiveTopicID
74
+ });
75
+ sessionLogger?.debug('tunnel session opening');
76
+ const transport = createEncryptedHubTunnelTransport({
77
+ // The peer-scoped VIEW, never the device hub: two sessions open at once
78
+ // receive on this same inbox topic, and a frame one of them opens is
79
+ // destroyed for the other — MLS consumes a ratchet generation per open.
80
+ // The loser starves to its idle timeout with nothing raised.
81
+ // The idle timeout is reused as the publish bound on purpose: "a session
82
+ // that gets nothing for N gives up" and "a session that cannot even send
83
+ // for N gives up" are one promise to whoever is waiting, and a second knob
84
+ // would only let the two drift apart.
85
+ hub: createSenderScopedHubView({
86
+ hub: this.#hub,
87
+ peerDID: this.#peerDID,
88
+ ...this.#idleTimeoutMs == null ? {} : {
89
+ publishTimeoutMs: this.#idleTimeoutMs
90
+ }
91
+ }),
48
92
  encryptor,
49
93
  groupID: this.#groupID,
50
94
  sessionID,
@@ -55,10 +99,15 @@ export class HubTunnelSyncProvider {
55
99
  idleTimeoutMs: this.#idleTimeoutMs,
56
100
  reconnectTimeoutMs: this.#reconnectTimeoutMs,
57
101
  inboxCapacity: this.#inboxCapacity,
58
- onEvent: this.#onEvent
102
+ onEvent: sessionLogger == null ? this.#onEvent : createTunnelEventLogger(sessionLogger, this.#onEvent)
59
103
  });
104
+ // The close is as load-bearing as the open: a session that ends without
105
+ // having received anything is what an unreachable peer looks like, and the
106
+ // pair of lines is what says whether it ran for milliseconds or waited out
107
+ // the full idle window.
108
+ transport.events.on('disposed', ()=>{
109
+ sessionLogger?.debug('tunnel session closed');
110
+ });
111
+ return transport;
60
112
  }
61
113
  }
62
- function generateSessionID() {
63
- return crypto.randomUUID();
64
- }