@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
@@ -0,0 +1,175 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { getP2PStore } from '@kubun/store-p2p';
3
+ import { HubTunnelSyncListener } from './hub-tunnel-sync-listener.js';
4
+ /**
5
+ * The answering half of the tunnel: one listener per co-member per group, so a
6
+ * device that dials `tunnel://<groupID>/<ourDID>` finds something draining the
7
+ * mailbox.
8
+ *
9
+ * Without this a route resolves, opens, publishes its first frame and idles out:
10
+ * "nobody is home" and "nobody is listening" are the same silence to the dialer.
11
+ *
12
+ * ONE PER PEER, not one per group: every listener on a device receives on the
13
+ * same inbox topic, and two sharing a raw drain destroy each other's frames.
14
+ * `HubTunnelSyncListener` fronts the hub with a per-peer view for exactly this,
15
+ * so peers are separated here rather than multiplexed inside a session.
16
+ *
17
+ * Gated on this device having declared a profile — the same invariant presence
18
+ * runs on. A device that never opted in is in nobody's projection, so nothing
19
+ * can dial it and a listener would hold a subscription open for a session that
20
+ * cannot arrive.
21
+ */ export function createTunnelListeners(params) {
22
+ const selfDID = normalizeDID(params.localDID);
23
+ const { logger } = params;
24
+ // groupID → peerDID → its listener.
25
+ const listeners = new Map();
26
+ // Which drain each group's listeners were built on. A listener holds its hub
27
+ // for life, so a group that switches hubs needs new ones — and the peer set
28
+ // alone cannot show that, since the same members are still wanted.
29
+ const attachedHubs = new Map();
30
+ // And which epoch they derived their topics at. A live listener's subscription
31
+ // is pinned to the epoch its spawn ran at, and only a disposed transport
32
+ // re-derives — so a group whose epoch moved keeps draining a topic nobody
33
+ // publishes to any more, while every dialer idles out against the silence. The
34
+ // peer set cannot show this either: the same members are still wanted.
35
+ const attachedEpochs = new Map();
36
+ // Reconciles of one group run in sequence: an epoch burst fires several, and
37
+ // two interleaved passes over the same map would each start a listener the
38
+ // other has not recorded yet.
39
+ const queues = new Map();
40
+ let disposed = false;
41
+ const stopGroup = async (groupID)=>{
42
+ attachedHubs.delete(groupID);
43
+ attachedEpochs.delete(groupID);
44
+ const group = listeners.get(groupID);
45
+ if (group == null) {
46
+ return;
47
+ }
48
+ listeners.delete(groupID);
49
+ await Promise.allSettled([
50
+ ...group.values()
51
+ ].map((listener)=>listener.stop()));
52
+ };
53
+ const runReconcile = async (groupID)=>{
54
+ if (disposed) {
55
+ return;
56
+ }
57
+ const store = await getP2PStore(params.stores);
58
+ const profile = await store.getLocalPeerProfile(selfDID);
59
+ if (profile == null) {
60
+ await stopGroup(groupID);
61
+ return;
62
+ }
63
+ const hub = params.tunnelHub(groupID);
64
+ if (hub == null) {
65
+ // No hub bound: there is no mailbox to drain. A binding arriving later
66
+ // reconciles again.
67
+ await stopGroup(groupID);
68
+ return;
69
+ }
70
+ const epoch = params.registry.groupEpoch(groupID);
71
+ if (attachedHubs.get(groupID) !== hub || attachedEpochs.get(groupID) !== epoch) {
72
+ await stopGroup(groupID);
73
+ }
74
+ attachedHubs.set(groupID, hub);
75
+ attachedEpochs.set(groupID, epoch);
76
+ const members = await store.listGroupMembers(groupID);
77
+ const wanted = new Set(members.map((member)=>normalizeDID(member.member_did)).filter((did)=>did !== selfDID));
78
+ let group = listeners.get(groupID);
79
+ if (group == null) {
80
+ group = new Map();
81
+ listeners.set(groupID, group);
82
+ }
83
+ for (const [peerDID, listener] of group){
84
+ if (!wanted.has(peerDID)) {
85
+ group.delete(peerDID);
86
+ try {
87
+ await listener.stop();
88
+ } catch (error) {
89
+ logger?.warn('tunnel listener stop failed', {
90
+ groupID,
91
+ peerDID,
92
+ error
93
+ });
94
+ }
95
+ }
96
+ }
97
+ // Every await above is a point where the group could have been left or the
98
+ // whole thing disposed. A pass that continued past one would repopulate
99
+ // `listeners` with fresh, STARTED listeners for a group this device is no
100
+ // longer in, and they would drain that group's inbox topic indefinitely —
101
+ // nothing later removes what `removeGroup` already walked past.
102
+ if (disposed || listeners.get(groupID) !== group) {
103
+ return;
104
+ }
105
+ for (const peerDID of wanted){
106
+ if (group.has(peerDID)) {
107
+ continue;
108
+ }
109
+ const listener = new HubTunnelSyncListener({
110
+ hub,
111
+ registry: params.registry,
112
+ groupID,
113
+ localDID: selfDID,
114
+ peerDID,
115
+ identity: params.identity,
116
+ syncHandlers: params.syncHandlers,
117
+ runtime: params.runtime,
118
+ ...params.idleTimeoutMs != null ? {
119
+ idleTimeoutMs: params.idleTimeoutMs
120
+ } : {},
121
+ ...logger != null ? {
122
+ logger
123
+ } : {}
124
+ });
125
+ group.set(peerDID, listener);
126
+ listener.start();
127
+ }
128
+ };
129
+ const reconcile = (groupID)=>{
130
+ const queued = (queues.get(groupID) ?? Promise.resolve()).then(()=>runReconcile(groupID), ()=>runReconcile(groupID));
131
+ // Never let one group's failure reject a caller that only asked for a
132
+ // reconcile — the next signal retries, and a throw here would surface on
133
+ // whichever unrelated lane happened to trigger it.
134
+ const settled = queued.catch((error)=>{
135
+ logger?.warn('tunnel listener reconcile failed', {
136
+ groupID,
137
+ error
138
+ });
139
+ });
140
+ queues.set(groupID, settled);
141
+ return settled;
142
+ };
143
+ return {
144
+ reconcile,
145
+ reconcileAll: async (groupIDs)=>{
146
+ await Promise.all(groupIDs.map((groupID)=>reconcile(groupID)));
147
+ },
148
+ removeGroup: async (groupID)=>{
149
+ // Await the in-flight reconcile BEFORE tearing down. Dropping the queue
150
+ // entry does not stop the pass already running: it would finish after the
151
+ // stop and leave started listeners behind for a group the device has
152
+ // left. `runReconcile` also re-checks, so this is the pair.
153
+ const queued = queues.get(groupID);
154
+ queues.delete(groupID);
155
+ if (queued != null) {
156
+ await queued.catch(()=>{
157
+ // A failed reconcile is already logged; the teardown proceeds.
158
+ });
159
+ }
160
+ await stopGroup(groupID);
161
+ },
162
+ dispose: async ()=>{
163
+ disposed = true;
164
+ const inFlight = [
165
+ ...queues.values()
166
+ ];
167
+ queues.clear();
168
+ await Promise.allSettled(inFlight);
169
+ const groupIDs = [
170
+ ...listeners.keys()
171
+ ];
172
+ await Promise.allSettled(groupIDs.map((groupID)=>stopGroup(groupID)));
173
+ }
174
+ };
175
+ }
@@ -0,0 +1,25 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { ObservabilityEventListener } from '@kumiai/hub-tunnel';
3
+ /**
4
+ * Which side of a tunnel session a transport is. Carried on every line a
5
+ * session logs, because the two are indistinguishable otherwise: both receive
6
+ * on this device's inbox topic for the epoch, and a drop only means something
7
+ * once you know whether the reader was answering a peer or dialling one.
8
+ */
9
+ export type TunnelSessionRole = 'dialer' | 'listener';
10
+ /**
11
+ * Turn a tunnel transport's observability events into log lines, and pass them
12
+ * on to a listener the caller supplied.
13
+ *
14
+ * Until this existed the transports' `onEvent` port had no producer anywhere in
15
+ * the repo — declared, forwarded to the transport, and set by nobody. Every
16
+ * destroyed frame was therefore silent, in tests and on devices alike: a
17
+ * starved session showed up as an idle timeout half a minute later with nothing
18
+ * saying why. That is the entire reason a two-device sync failure had to be
19
+ * diagnosed by reading code rather than by reading a log.
20
+ *
21
+ * `logger` is expected to carry the session's context already (`groupID`,
22
+ * `peerDID`, `role`, `epoch`, topics), so a line needs no arguments beyond what
23
+ * the event itself adds.
24
+ */
25
+ export declare function createTunnelEventLogger(logger: Logger, delegate?: ObservabilityEventListener): ObservabilityEventListener;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Drops a healthy lane never produces, so each one is a frame that reached this
3
+ * device and was thrown away.
4
+ *
5
+ * - `decrypt` — the ciphertext did not open. On a lane whose key is right, that
6
+ * means something else opened it first: MLS consumes a ratchet generation per
7
+ * open, so a second reader of the same frame is handed a key that is gone.
8
+ * - `session-mismatch` — it opened, and belonged to a session this reader is not
9
+ * serving. The frame is destroyed for the session it WAS for, which is now
10
+ * waiting out its idle timeout with nothing raised.
11
+ * - `group-mismatch` — a frame for another group arrived on this topic, which
12
+ * the envelope catches before the cipher. Same-key misroute, never routine.
13
+ *
14
+ * Everything else is routine: `dedup` is the hub redelivering, `topic-mismatch`
15
+ * is the shared device drain doing its job, `envelope-decode` is a foreign
16
+ * publisher on the topic.
17
+ */ const UNEXPECTED_REASONS = new Set([
18
+ 'decrypt',
19
+ 'session-mismatch',
20
+ 'group-mismatch'
21
+ ]);
22
+ /**
23
+ * Turn a tunnel transport's observability events into log lines, and pass them
24
+ * on to a listener the caller supplied.
25
+ *
26
+ * Until this existed the transports' `onEvent` port had no producer anywhere in
27
+ * the repo — declared, forwarded to the transport, and set by nobody. Every
28
+ * destroyed frame was therefore silent, in tests and on devices alike: a
29
+ * starved session showed up as an idle timeout half a minute later with nothing
30
+ * saying why. That is the entire reason a two-device sync failure had to be
31
+ * diagnosed by reading code rather than by reading a log.
32
+ *
33
+ * `logger` is expected to carry the session's context already (`groupID`,
34
+ * `peerDID`, `role`, `epoch`, topics), so a line needs no arguments beyond what
35
+ * the event itself adds.
36
+ */ export function createTunnelEventLogger(logger, delegate) {
37
+ return (event)=>{
38
+ switch(event.type){
39
+ case 'decrypt-failed':
40
+ logger.warn('tunnel frame failed to decrypt', {
41
+ error: event.error
42
+ });
43
+ break;
44
+ case 'envelope-decode-failed':
45
+ logger.debug('tunnel envelope failed to decode', {
46
+ error: event.error
47
+ });
48
+ break;
49
+ case 'frame-dropped':
50
+ if (UNEXPECTED_REASONS.has(event.reason)) {
51
+ logger.warn('tunnel frame dropped', {
52
+ reason: event.reason
53
+ });
54
+ } else {
55
+ logger.debug('tunnel frame dropped', {
56
+ reason: event.reason
57
+ });
58
+ }
59
+ break;
60
+ }
61
+ delegate?.(event);
62
+ };
63
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Which side of a directed session a topic belongs to. A `dialer` topic carries
3
+ * the responses flowing back to whoever opened the session; a `responder` topic
4
+ * carries the requests arriving at whoever answers.
5
+ */
6
+ export type TunnelRole = 'dialer' | 'responder';
7
+ /**
8
+ * The topic on which `ownerDID` receives the frames addressed to it in `role`.
9
+ *
10
+ * Both sides derive both topics from the same (secret, epoch), so a dialer knows
11
+ * where to write and a responder knows where to answer without negotiating
12
+ * anything. Topics rotate with the MLS epoch exactly as the inbox ones did.
13
+ *
14
+ * Scoped by role and owner but NOT by session: a device holds one drain per
15
+ * (role, peer) and separates concurrent sessions to different peers by sender.
16
+ * Two sessions in the same role to the SAME peer at once would still contend —
17
+ * nothing in the sync client opens those, and a session id in the scope would
18
+ * cost a round trip to agree on before either side could subscribe.
19
+ */
20
+ export declare function tunnelTopic(secret: Uint8Array, epoch: number, role: TunnelRole, ownerDID: string): string;
@@ -0,0 +1,28 @@
1
+ import { protocolTopic } from '@kumiai/rpc';
2
+ /**
3
+ * The lane every directed sync tunnel rides, owned by this package.
4
+ *
5
+ * Deliberately not `inboxTopic`: that derives ONE topic per member, so a device
6
+ * dialing a peer and answering that same peer both read and both write the two
7
+ * members' inboxes. The two roles then share a topic, and sender scope cannot
8
+ * separate them — a peer's reply and that peer's own dial request are equally
9
+ * "from the peer". Whichever of the two opens a frame first consumes its MLS
10
+ * ratchet generation and DESTROYS it for the other, which waits out its idle
11
+ * timeout instead. `inboxTopic` is also `@kumiai/rpc`'s reserved lane, and a
12
+ * host protocol has no business publishing into it.
13
+ */ const SYNC_TUNNEL_PROTOCOL = 'kubun/sync-tunnel/v1';
14
+ /**
15
+ * The topic on which `ownerDID` receives the frames addressed to it in `role`.
16
+ *
17
+ * Both sides derive both topics from the same (secret, epoch), so a dialer knows
18
+ * where to write and a responder knows where to answer without negotiating
19
+ * anything. Topics rotate with the MLS epoch exactly as the inbox ones did.
20
+ *
21
+ * Scoped by role and owner but NOT by session: a device holds one drain per
22
+ * (role, peer) and separates concurrent sessions to different peers by sender.
23
+ * Two sessions in the same role to the SAME peer at once would still contend —
24
+ * nothing in the sync client opens those, and a session id in the scope would
25
+ * cost a round trip to agree on before either side could subscribe.
26
+ */ export function tunnelTopic(secret, epoch, role, ownerDID) {
27
+ return protocolTopic(secret, epoch, SYNC_TUNNEL_PROTOCOL, `${role}:${ownerDID}`);
28
+ }