@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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/errors.d.ts +0 -30
- package/lib/hub/errors.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
package/lib/sync/handlers.js
CHANGED
|
@@ -1 +1,200 @@
|
|
|
1
|
-
import
|
|
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 '@
|
|
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
|
-
|
|
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
|
-
|
|
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{
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* the
|
|
29
|
-
*
|
|
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
|
}
|