@kubun/plugin-p2p 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -1 +1,200 @@
1
- import{createRevocationChecker as e}from"@enkaku/capability";import{getGraphStore as t}from"@kubun/store-graph";import{createP2PRevocationBackend as r,getP2PStore as o}from"@kubun/store-p2p";import{authorizeScope as s,checkSyncDelegation as n}from"./authorize.js";import{resolveCatalogSyncScopes as l}from"./catalog-scope.js";import{SyncAccessDeniedError as a}from"./errors.js";import{createMerkleSyncChannelHandler as i}from"./merkle-channel.js";import{negotiateDirection as c}from"./peer-registry.js";export function createSyncHandlers(n){let{db:u,graph:d,logger:f,peerRegistry:m}=n,p=n.storeUnreadable??"persist",g=null;async function w(){return null==g&&(g=e(r(await o(u)))),g}return{"sync/negotiate":async e=>{let r,{scopes:o,delegationTokens:n,catalogIDs:i,knownModelIDs:d,direction:p}=e.param,g=e.message.payload;if(null!=g.sub&&g.sub!==g.iss)throw new a("forged-subject","Sync access denied: signed payload subject does not match issuer");let D=g.iss;f.debug("sync/negotiate requested",{scopes:o,catalogIDs:i,viewerDID:D});let h=[],y=await t(u);if(null!=i&&i.length>0){let e=await l(u,i,d);if(e.modelIDs.length>0)if(null!=e.owners)for(let t of e.modelIDs)for(let r of e.owners)await s({viewerDID:D,ownerDID:r,delegationTokens:n,revocationChecker:await w()})&&h.push({modelID:t,ownerDID:r});else for(let t of e.modelIDs)for(let e of(await y.getDistinctOwnersForModel(t)))await s({viewerDID:D,ownerDID:e,delegationTokens:n,revocationChecker:await w()})&&h.push({modelID:t,ownerDID:e});e.missingClusterIDs.length>0&&(r=await y.getClusters(e.missingClusterIDs))}if(null!=o)for(let e of o)await s({viewerDID:D,ownerDID:e.ownerDID,delegationTokens:n,revocationChecker:await w()})?h.push(e):f.debug("sync/negotiate: scope rejected",{scope:e,viewerDID:D});let I=new Set,b=h.filter(e=>{let t=`${e.modelID}:${e.ownerDID}`;return!I.has(t)&&(I.add(t),!0)});f.info("sync/negotiate completed",{requested:(o?.length??0)+(i?.length??0),accepted:b.length,missingClusters:null!=r?Object.keys(r).length:0});let k=p??"pull",j=await m.getPeer({peerDID:D,stores:u}),C=j?.allowedDirection??"both",v=c(k,C);return v!==k&&f.info("sync/negotiate: direction downgraded",{requested:k,allowed:C,agreed:v}),{direction:v,acceptedScopes:b,excludedDocumentIDs:[],...null!=r&&{missingClusters:r}}},"sync/merkle-sync":i({db:u,graph:d,logger:f,peerRegistry:m,selfDID:n.selfDID,storeUnreadable:p,defaultAccessLevel:n.defaultAccessLevel,getRevocationChecker:w})}}export{n as checkSyncDelegation};
1
+ import { createDelegationRevocationChecker, getDelegationStore } from '@kubun/store-delegation';
2
+ import { getGraphStore } from '@kubun/store-graph';
3
+ import { getP2PStore } from '@kubun/store-p2p';
4
+ import { wrapHandlers } from '../util/handler-error.js';
5
+ import { authorizeScope, checkSyncDelegation } from './authorize.js';
6
+ import { resolveCatalogSyncScopes } from './catalog-scope.js';
7
+ import { SyncAccessDeniedError } from './errors.js';
8
+ import { createMerkleSyncChannelHandler } from './merkle-channel.js';
9
+ import { negotiateDirection } from './peer-registry.js';
10
+ export { checkSyncDelegation };
11
+ // ---- Handler factory ----
12
+ /**
13
+ * Create sync protocol handlers served by plugin-p2p's own Enkaku server.
14
+ */ export function createSyncHandlers(params) {
15
+ const { db, graph, logger, peerRegistry } = params;
16
+ const storeUnreadable = params.storeUnreadable ?? 'persist';
17
+ let cachedRevocationChecker = null;
18
+ async function getRevocationChecker() {
19
+ if (cachedRevocationChecker == null) {
20
+ const delegationStore = await getDelegationStore(db);
21
+ cachedRevocationChecker = createDelegationRevocationChecker(delegationStore);
22
+ }
23
+ return cachedRevocationChecker;
24
+ }
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({
48
+ 'sync/negotiate': async (ctx)=>{
49
+ const { scopes, delegationTokens, catalogIDs, knownModelIDs, direction } = ctx.param;
50
+ const payload = ctx.message.payload;
51
+ // The viewer is the cryptographically-verified signer (`iss`). A signed
52
+ // payload always carries `iss`; no legitimate kubun client sets `sub`.
53
+ // Reject any request whose `sub` is present and differs from `iss` to
54
+ // close the forged-subject path before any store read.
55
+ if (payload.sub != null && payload.sub !== payload.iss) {
56
+ throw new SyncAccessDeniedError('forged-subject', 'Sync access denied: signed payload subject does not match issuer');
57
+ }
58
+ const viewerDID = payload.iss;
59
+ logger.debug('sync/negotiate requested', {
60
+ scopes,
61
+ catalogIDs,
62
+ viewerDID
63
+ });
64
+ const acceptedScopes = [];
65
+ let missingClusters;
66
+ const graphStore = await getGraphStore(db);
67
+ // Catalog-based scope resolution
68
+ if (catalogIDs != null && catalogIDs.length > 0) {
69
+ const catalogScope = await resolveCatalogSyncScopes(db, catalogIDs);
70
+ // Generate scopes from resolved catalog criteria
71
+ if (catalogScope.modelIDs.length > 0) {
72
+ if (catalogScope.owners != null) {
73
+ // Explicit owners from catalog criteria
74
+ for (const modelID of catalogScope.modelIDs){
75
+ for (const ownerDID of catalogScope.owners){
76
+ const accepted = await authorizeScope({
77
+ viewerDID,
78
+ ownerDID,
79
+ modelID,
80
+ delegationTokens,
81
+ revocationChecker: await getRevocationChecker(),
82
+ circleReadGrant: await getCircleReadGrant()
83
+ });
84
+ if (accepted) {
85
+ acceptedScopes.push({
86
+ modelID,
87
+ ownerDID
88
+ });
89
+ }
90
+ }
91
+ }
92
+ } else {
93
+ // No owner filter — get all distinct owners for each model
94
+ for (const modelID of catalogScope.modelIDs){
95
+ const owners = await graphStore.getDistinctOwnersForModel(modelID);
96
+ for (const ownerDID of owners){
97
+ const accepted = await authorizeScope({
98
+ viewerDID,
99
+ ownerDID,
100
+ modelID,
101
+ delegationTokens,
102
+ revocationChecker: await getRevocationChecker(),
103
+ circleReadGrant: await getCircleReadGrant()
104
+ });
105
+ if (accepted) {
106
+ acceptedScopes.push({
107
+ modelID,
108
+ ownerDID
109
+ });
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ // Explicit scopes (existing behavior)
117
+ if (scopes != null) {
118
+ for (const scope of scopes){
119
+ const accepted = await authorizeScope({
120
+ viewerDID,
121
+ ownerDID: scope.ownerDID,
122
+ modelID: scope.modelID,
123
+ delegationTokens,
124
+ revocationChecker: await getRevocationChecker(),
125
+ circleReadGrant: await getCircleReadGrant()
126
+ });
127
+ if (accepted) {
128
+ acceptedScopes.push(scope);
129
+ } else {
130
+ logger.debug('sync/negotiate: scope rejected', {
131
+ scope,
132
+ viewerDID
133
+ });
134
+ }
135
+ }
136
+ }
137
+ // Deduplicate scopes
138
+ const seen = new Set();
139
+ const uniqueScopes = acceptedScopes.filter((s)=>{
140
+ const key = `${s.modelID}:${s.ownerDID}`;
141
+ if (seen.has(key)) return false;
142
+ seen.add(key);
143
+ return true;
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
+ }
159
+ logger.info('sync/negotiate completed', {
160
+ requested: (scopes?.length ?? 0) + (catalogIDs?.length ?? 0),
161
+ accepted: uniqueScopes.length,
162
+ missingClusters: missingClusters != null ? Object.keys(missingClusters).length : 0
163
+ });
164
+ // Resolve direction against per-peer policy
165
+ const requestedDirection = direction ?? 'pull';
166
+ const peer = await peerRegistry.getPeer({
167
+ peerDID: viewerDID,
168
+ stores: db
169
+ });
170
+ const peerAllowed = peer?.allowedDirection ?? 'both';
171
+ const agreedDirection = negotiateDirection(requestedDirection, peerAllowed);
172
+ if (agreedDirection !== requestedDirection) {
173
+ logger.info('sync/negotiate: direction downgraded', {
174
+ requested: requestedDirection,
175
+ allowed: peerAllowed,
176
+ agreed: agreedDirection
177
+ });
178
+ }
179
+ return {
180
+ direction: agreedDirection,
181
+ acceptedScopes: uniqueScopes,
182
+ excludedDocumentIDs: [],
183
+ ...missingClusters != null && {
184
+ missingClusters
185
+ }
186
+ };
187
+ },
188
+ 'sync/merkle-sync': createMerkleSyncChannelHandler({
189
+ db,
190
+ graph,
191
+ logger,
192
+ peerRegistry,
193
+ selfDID: params.selfDID,
194
+ storeUnreadable,
195
+ defaultAccessLevel: params.defaultAccessLevel,
196
+ getRevocationChecker,
197
+ getCircleReadGrant
198
+ })
199
+ });
200
+ }
@@ -1,22 +1,36 @@
1
- import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
1
  import { type ProcedureHandlers } from '@enkaku/server';
3
- import type { OwnIdentity } from '@enkaku/token';
2
+ import type { OwnIdentity } from '@kokuin/token';
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
- import type { HubConnection } from '../hub/hub-connection.js';
6
7
  import type { SyncProtocol } from '../protocol.js';
7
8
  export type HubTunnelSyncListenerParams = {
8
- hubConnection: HubConnection;
9
+ /**
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`.
15
+ */
16
+ hub: MailboxHub;
9
17
  registry: GroupHandleRegistry;
10
18
  groupID: string;
11
19
  localDID: string;
12
20
  peerDID: string;
13
21
  identity: OwnIdentity;
14
22
  syncHandlers: ProcedureHandlers<SyncProtocol>;
15
- 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;
16
29
  idleTimeoutMs?: number;
17
30
  reconnectTimeoutMs?: number;
18
31
  inboxCapacity?: number;
19
32
  onEvent?: ObservabilityEventListener;
33
+ logger?: Logger;
20
34
  };
21
35
  export declare class HubTunnelSyncListener {
22
36
  #private;
@@ -1 +1,261 @@
1
- import{createEncryptedHubTunnelTransport as t}from"@enkaku/hub-tunnel";import{Server as e}from"@enkaku/server";import{MLSEncryptor as s}from"../groups/mls-encryptor.js";export class HubTunnelSyncListener{#t;#e;#s;#n;#i;#r;#o;#c;#a;#h;#u;#p;#l=!1;#d=!1;#y;#D;#m;constructor(t){this.#t=t.hubConnection,this.#e=t.registry,this.#s=t.groupID,this.#n=t.localDID,this.#i=t.peerDID,this.#r=t.identity,this.#o=t.syncHandlers,this.#c=t.getRandomID,this.#a=t.idleTimeoutMs,this.#h=t.reconnectTimeoutMs,this.#u=t.inboxCapacity,this.#p=t.onEvent}start(){this.#l||this.#d||(this.#l=!0,this.#m=new s({registry:this.#e,groupID:this.#s}),this.#D=this.#t.tunnelMessageStream({peerDID:this.#i}),this.#I())}async stop(){if(this.#d)return;this.#d=!0;let t=this.#y;if(this.#y=void 0,null!=t){try{await t.transport.dispose()}catch{}try{await t.server.dispose()}catch{}}let e=this.#D;if(this.#D=void 0,null!=e)try{e.return()}catch{}this.#m=void 0}#I(){let s;if(this.#d)return;let n=this.#m;if(null==n)throw Error("HubTunnelSyncListener: encryptor not initialized; call start() first");let i=t({hub:this.#b(),encryptor:n,groupID:this.#s,sessionID:{auto:!0},localDID:this.#n,peerDID:this.#i,idleTimeoutMs:this.#a,reconnectTimeoutMs:this.#h,inboxCapacity:this.#u,onEvent:this.#p,onSessionEnd:()=>{s?.dispose().catch(()=>{})}});s=i;let r=new e({getRandomID:this.#c,handlers:this.#o,identity:this.#r,accessRules:{"sync/*":{allow:!0}},transports:[i]}),o={transport:i,server:r};this.#y=o,i.events.on("disposed",()=>{this.#y===o&&(this.#y=void 0),r.dispose().catch(()=>{}),this.#I()})}#b(){let t=this.#t,e=this.#D;if(null==e)throw Error("HubTunnelSyncListener: stream not initialized; call start() first");return{send:async e=>t.send({recipients:e.recipients,payload:e.payload}),receive:t=>e,events:{subscribe:e=>t.events.on("lifecycle",t=>{let s=function(t){switch(t.type){case"connected":return{type:"connected"};case"disconnected":return{type:"disconnected"};case"reconnecting":return{type:"reconnecting"};case"error":return}}(t);void 0!==s&&e(s)})}}}}
1
+ import { Server } from '@enkaku/server';
2
+ import { createEncryptedHubTunnelTransport } from '@kumiai/hub-tunnel';
3
+ import { APP_TOPIC_LABEL } from '@kumiai/rpc';
4
+ import { createGroupCrypto } from '../groups/group-crypto.js';
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';
9
+ export class HubTunnelSyncListener {
10
+ #hub;
11
+ #registry;
12
+ #groupID;
13
+ #localDID;
14
+ #peerDID;
15
+ #identity;
16
+ #syncHandlers;
17
+ #runtime;
18
+ #idleTimeoutMs;
19
+ #reconnectTimeoutMs;
20
+ #inboxCapacity;
21
+ #onEvent;
22
+ #logger;
23
+ #started = false;
24
+ #stopped = false;
25
+ /** Consecutive failed spawns, for the re-arm backoff. Reset by a success. */ #respawnAttempt = 0;
26
+ #respawnTimer;
27
+ #current;
28
+ // Single MLSEncryptor reused across spawns. Ordering across overlapping
29
+ // spawns (old transport's last decrypt vs. new spawn's first encrypt) is
30
+ // covered by the GroupHandleRegistry's per-group mutex.
31
+ #encryptor;
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;
36
+ constructor(params){
37
+ this.#hub = params.hub;
38
+ this.#registry = params.registry;
39
+ this.#groupID = params.groupID;
40
+ this.#localDID = params.localDID;
41
+ this.#peerDID = params.peerDID;
42
+ this.#identity = params.identity;
43
+ this.#syncHandlers = params.syncHandlers;
44
+ this.#runtime = params.runtime;
45
+ this.#idleTimeoutMs = params.idleTimeoutMs;
46
+ this.#reconnectTimeoutMs = params.reconnectTimeoutMs;
47
+ this.#inboxCapacity = params.inboxCapacity;
48
+ this.#onEvent = params.onEvent;
49
+ this.#logger = params.logger;
50
+ }
51
+ start() {
52
+ if (this.#started || this.#stopped) return;
53
+ this.#started = true;
54
+ this.#encryptor = new MLSEncryptor({
55
+ registry: this.#registry,
56
+ groupID: this.#groupID
57
+ });
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;
99
+ }
100
+ async stop() {
101
+ if (this.#stopped) return;
102
+ this.#stopped = true;
103
+ if (this.#respawnTimer != null) {
104
+ clearTimeout(this.#respawnTimer);
105
+ this.#respawnTimer = undefined;
106
+ }
107
+ const current = this.#current;
108
+ this.#current = undefined;
109
+ if (current != null) {
110
+ try {
111
+ await current.transport.dispose();
112
+ } catch {
113
+ // ignore — best-effort teardown
114
+ }
115
+ try {
116
+ await current.server.dispose();
117
+ } catch {
118
+ // ignore — best-effort teardown
119
+ }
120
+ }
121
+ this.#encryptor = undefined;
122
+ this.#peerHub = undefined;
123
+ }
124
+ async #spawn() {
125
+ if (this.#stopped) return;
126
+ const encryptor = this.#encryptor;
127
+ const peerHub = this.#peerHub;
128
+ if (encryptor == null || peerHub == null) {
129
+ throw new Error('HubTunnelSyncListener: not initialized; call start() first');
130
+ }
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
136
+ // spawn re-derives them against the live handle. MLS decrypt inside the
137
+ // registry lock remains the authoritative membership/forward-secrecy gate;
138
+ // stale-after-remove ciphertext surfaces as DecryptError.
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);
149
+ const epoch = crypto.epoch();
150
+ const sendTopicID = tunnelTopic(secret, epoch, 'dialer', this.#peerDID);
151
+ const receiveTopicID = tunnelTopic(secret, epoch, 'responder', this.#localDID);
152
+ // A stop() that landed while we awaited the handle must abort the spawn.
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
+ });
166
+ let transportRef;
167
+ const transport = createEncryptedHubTunnelTransport({
168
+ hub: peerHub,
169
+ encryptor,
170
+ groupID: this.#groupID,
171
+ sessionID: {
172
+ auto: true
173
+ },
174
+ localDID: this.#localDID,
175
+ sendTopicID,
176
+ receiveTopicID,
177
+ idleTimeoutMs: this.#idleTimeoutMs,
178
+ reconnectTimeoutMs: this.#reconnectTimeoutMs,
179
+ inboxCapacity: this.#inboxCapacity,
180
+ onEvent: sessionLogger == null ? this.#onEvent : createTunnelEventLogger(sessionLogger, this.#onEvent),
181
+ onSessionEnd: ()=>{
182
+ // Peer signaled end-of-session. Dispose the transport deterministically
183
+ // so the `disposed` event fires and the spawn loop re-arms for the
184
+ // next session arriving on the shared device drain.
185
+ void transportRef?.dispose().catch(()=>{
186
+ // ignore — best-effort
187
+ });
188
+ }
189
+ });
190
+ transportRef = transport;
191
+ // Setting identity forces requireAuth: the tunnel server verifies token
192
+ // signatures and that aud equals its own DID, matching the direct/HTTP sync
193
+ // servers. Per-scope authorization is handled uniformly by the merkle-sync
194
+ // scope-gate, not here.
195
+ const server = new Server({
196
+ runtime: this.#runtime,
197
+ handlers: this.#syncHandlers,
198
+ identity: this.#identity,
199
+ accessRules: {
200
+ 'sync/*': {
201
+ allow: true
202
+ }
203
+ },
204
+ transports: [
205
+ transport
206
+ ]
207
+ });
208
+ const session = {
209
+ transport,
210
+ server
211
+ };
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
+ }
235
+ // `disposed` may fire more than once for a single session (disposing the
236
+ // server can re-enter the transport's dispose). Collapse to exactly one
237
+ // teardown + respawn so one session leaves exactly one successor.
238
+ let handled = false;
239
+ transport.events.on('disposed', ()=>{
240
+ if (handled) return;
241
+ handled = true;
242
+ if (this.#current === session) {
243
+ this.#current = undefined;
244
+ }
245
+ // Server stays around until its handlers drain; explicit cleanup here so
246
+ // long-running sessions don't leak server instances.
247
+ void server.dispose().catch(()=>{
248
+ // ignore
249
+ });
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
+ //
256
+ // Respawn for the next session. The next spawn re-derives the topics
257
+ // against the (possibly advanced) epoch, and survives failing to.
258
+ this.#spawnGuarded();
259
+ });
260
+ }
261
+ }
@@ -1,19 +1,42 @@
1
- import { type ObservabilityEventListener } from '@enkaku/hub-tunnel';
2
1
  import type { ClientTransportOf } from '@enkaku/protocol';
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';
4
- import type { HubConnection } from '../hub/hub-connection.js';
6
+ import type { DeviceHub } from '../hub/hub-like.js';
5
7
  import type { SyncProtocol } from '../protocol.js';
6
8
  import type { SyncTransportProvider } from './sync-client.js';
7
9
  export type HubTunnelSyncProviderParams = {
8
- hubConnection: HubConnection;
10
+ /**
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`.
15
+ */
16
+ hub: DeviceHub;
9
17
  registry: GroupHandleRegistry;
10
18
  groupID: string;
11
19
  localDID: string;
12
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;
13
28
  idleTimeoutMs?: number;
14
29
  reconnectTimeoutMs?: number;
15
30
  inboxCapacity?: number;
16
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;
17
40
  };
18
41
  export declare class HubTunnelSyncProvider implements SyncTransportProvider {
19
42
  #private;
@@ -21,16 +44,13 @@ export declare class HubTunnelSyncProvider implements SyncTransportProvider {
21
44
  /**
22
45
  * Build a fresh client transport for one tunnel sync session.
23
46
  *
24
- * **Concurrent-call constraint:** the underlying `HubConnection` keys its
25
- * tunnel inbox by `peerDID`, and only one inbox per peer can be registered
26
- * at a time. Callers must therefore fully dispose the previous transport
27
- * (via `client.dispose()`) before invoking `createSyncTransport` again for
28
- * the same provider instance otherwise the second call throws
29
- * `Error('tunnelMessageStream: peerDID already registered')`.
30
- *
31
- * The default `SyncManager.merkleSyncWithPeer` flow already awaits
32
- * `client.dispose()` in its `finally` block, so sequential sync calls are
33
- * safe. Concurrent calls are not.
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
- createSyncTransport(signal?: AbortSignal): ClientTransportOf<SyncProtocol>;
55
+ createSyncTransport(signal?: AbortSignal): Promise<ClientTransportOf<SyncProtocol>>;
36
56
  }