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