@serfab/cadre-core 0.7.1 → 0.9.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/README.md +11 -10
- package/dist/cadre-node.d.ts +818 -19
- package/dist/cadre-node.js +1952 -99
- package/dist/cadre-node.js.map +1 -1
- package/dist/canonical-datetime.d.ts +33 -0
- package/dist/canonical-datetime.js +38 -0
- package/dist/canonical-datetime.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.js +33 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/control-cohort.d.ts +63 -0
- package/dist/control-cohort.js +80 -0
- package/dist/control-cohort.js.map +1 -0
- package/dist/control-database.d.ts +279 -8
- package/dist/control-database.js +563 -134
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -0
- package/dist/control-formation-recorder.js +139 -0
- package/dist/control-formation-recorder.js.map +1 -0
- package/dist/control-schema.d.ts +12 -0
- package/dist/control-schema.js +223 -0
- package/dist/control-schema.js.map +1 -0
- package/dist/control-stream.d.ts +54 -0
- package/dist/control-stream.js +104 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/device-token.d.ts +50 -0
- package/dist/device-token.js +66 -0
- package/dist/device-token.js.map +1 -0
- package/dist/diagnostics/connection-path.d.ts +118 -0
- package/dist/diagnostics/connection-path.js +204 -0
- package/dist/diagnostics/connection-path.js.map +1 -0
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
- package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
- package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
- package/dist/ed25519-key.d.ts +42 -0
- package/dist/ed25519-key.js +51 -0
- package/dist/ed25519-key.js.map +1 -0
- package/dist/hibernation-manager.d.ts +79 -0
- package/dist/hibernation-manager.js +173 -23
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +52 -0
- package/dist/key-store-file.js +194 -0
- package/dist/key-store-file.js.map +1 -0
- package/dist/key-store.d.ts +75 -0
- package/dist/key-store.js +66 -0
- package/dist/key-store.js.map +1 -0
- package/dist/peer-authorization.d.ts +48 -0
- package/dist/peer-authorization.js +66 -0
- package/dist/peer-authorization.js.map +1 -0
- package/dist/peer-record.d.ts +89 -0
- package/dist/peer-record.js +117 -0
- package/dist/peer-record.js.map +1 -0
- package/dist/push-credentials.d.ts +34 -0
- package/dist/push-credentials.js +81 -0
- package/dist/push-credentials.js.map +1 -0
- package/dist/push-fanout.d.ts +137 -0
- package/dist/push-fanout.js +197 -0
- package/dist/push-fanout.js.map +1 -0
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +61 -0
- package/dist/push-notifier-apns.js +199 -0
- package/dist/push-notifier-apns.js.map +1 -0
- package/dist/push-notifier-fcm.d.ts +44 -0
- package/dist/push-notifier-fcm.js +182 -0
- package/dist/push-notifier-fcm.js.map +1 -0
- package/dist/push-notifier-shared.d.ts +18 -0
- package/dist/push-notifier-shared.js +25 -0
- package/dist/push-notifier-shared.js.map +1 -0
- package/dist/push-notifier.d.ts +52 -0
- package/dist/push-notifier.js +22 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/schema-verification.d.ts +11 -2
- package/dist/schema-verification.js +15 -4
- package/dist/schema-verification.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +228 -21
- package/dist/seed-bootstrap.js +489 -168
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +63 -0
- package/dist/seed-trust-policy.js +56 -0
- package/dist/seed-trust-policy.js.map +1 -0
- package/dist/strand-addr-protocol.d.ts +132 -0
- package/dist/strand-addr-protocol.js +313 -0
- package/dist/strand-addr-protocol.js.map +1 -0
- package/dist/strand-cohort.d.ts +54 -0
- package/dist/strand-cohort.js +38 -0
- package/dist/strand-cohort.js.map +1 -0
- package/dist/strand-database.d.ts +54 -6
- package/dist/strand-database.js +88 -100
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +79 -19
- package/dist/strand-formation-manager.js +196 -104
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +162 -0
- package/dist/strand-formation-protocol.js +279 -0
- package/dist/strand-formation-protocol.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +73 -4
- package/dist/strand-instance-manager.js +167 -41
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +29 -0
- package/dist/strand-member-key.js +38 -0
- package/dist/strand-member-key.js.map +1 -0
- package/dist/strand-member-registry.d.ts +98 -0
- package/dist/strand-member-registry.js +129 -0
- package/dist/strand-member-registry.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +361 -0
- package/dist/strand-membership-writer.js +449 -0
- package/dist/strand-membership-writer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +77 -2
- package/dist/strand-solicitation.js +25 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-payload.d.ts +30 -0
- package/dist/strand-wake-payload.js +20 -0
- package/dist/strand-wake-payload.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +114 -0
- package/dist/strand-wake-protocol.js +257 -0
- package/dist/strand-wake-protocol.js.map +1 -0
- package/dist/strand-watcher.d.ts +6 -2
- package/dist/strand-watcher.js +57 -17
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +436 -33
- package/dist/types.js +22 -5
- package/dist/types.js.map +1 -1
- package/package.json +18 -8
- package/src/cadre-node.ts +2981 -948
- package/src/canonical-datetime.ts +39 -0
- package/src/canonical-json.ts +30 -0
- package/src/control-cohort.ts +103 -0
- package/src/control-database.ts +948 -383
- package/src/control-formation-recorder.ts +151 -0
- package/src/control-schema.ts +222 -0
- package/src/control-stream.ts +128 -0
- package/src/device-token.ts +89 -0
- package/src/diagnostics/connection-path.ts +277 -0
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
- package/src/ed25519-key.ts +69 -0
- package/src/hibernation-manager.ts +197 -28
- package/src/index.ts +275 -79
- package/src/key-store-file.ts +203 -0
- package/src/key-store.ts +99 -0
- package/src/peer-authorization.ts +81 -0
- package/src/peer-record.ts +146 -0
- package/src/push-credentials.ts +84 -0
- package/src/push-fanout.ts +272 -0
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +247 -0
- package/src/push-notifier-fcm.ts +220 -0
- package/src/push-notifier-shared.ts +28 -0
- package/src/push-notifier.ts +52 -0
- package/src/schema-verification.ts +18 -4
- package/src/seed-bootstrap.ts +563 -223
- package/src/seed-trust-policy.ts +107 -0
- package/src/strand-addr-protocol.ts +410 -0
- package/src/strand-cohort.ts +77 -0
- package/src/strand-database.ts +113 -141
- package/src/strand-formation-manager.ts +233 -142
- package/src/strand-formation-protocol.ts +391 -0
- package/src/strand-instance-manager.ts +207 -56
- package/src/strand-member-key.ts +39 -0
- package/src/strand-member-registry.ts +177 -0
- package/src/strand-membership-writer.ts +640 -0
- package/src/strand-solicitation.ts +93 -4
- package/src/strand-wake-payload.ts +32 -0
- package/src/strand-wake-protocol.ts +331 -0
- package/src/strand-watcher.ts +61 -17
- package/src/types.ts +1032 -582
package/dist/cadre-node.js
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
3
|
+
import { generateKeyPair, privateKeyToProtobuf, privateKeyFromProtobuf } from '@libp2p/crypto/keys';
|
|
4
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
3
5
|
import { createLibp2pNode } from '@optimystic/db-p2p';
|
|
6
|
+
import { multiaddr } from '@multiformats/multiaddr';
|
|
7
|
+
import { DEFAULT_CHECKIN_WINDOW_MS } from './types.js';
|
|
8
|
+
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
9
|
+
import { ed25519KeyPairFromLibp2p } from './ed25519-key.js';
|
|
10
|
+
import { DEFAULT_IDENTITY_KEY_ID } from './key-store.js';
|
|
11
|
+
import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
12
|
+
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
13
|
+
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
4
14
|
import { StrandWatcher } from './strand-watcher.js';
|
|
5
15
|
import { StrandInstanceManager } from './strand-instance-manager.js';
|
|
16
|
+
import { deriveCohortMembers, selectStrandMode } from './strand-cohort.js';
|
|
17
|
+
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
6
18
|
import { EnrollmentService } from './enrollment.js';
|
|
7
19
|
import { HibernationManager } from './hibernation-manager.js';
|
|
8
20
|
import { ControlDatabase } from './control-database.js';
|
|
9
21
|
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
10
22
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
23
|
+
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
24
|
+
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
25
|
+
import { PushFanoutService } from './push-fanout.js';
|
|
26
|
+
import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
|
|
27
|
+
import { TurnRelayTracker } from './diagnostics/webrtc-turn-tracker.js';
|
|
11
28
|
const log = debug('sereus:cadre:node');
|
|
12
29
|
const timing = debug('sereus:cadre:timing');
|
|
30
|
+
/**
|
|
31
|
+
* Window (ms) within which a queued TURN-relay settlement is correlated to a
|
|
32
|
+
* `connection:open`. Generous relative to the tight async gap between an
|
|
33
|
+
* `RTCPeerConnection` reaching `connected` and libp2p surfacing the connection.
|
|
34
|
+
*/
|
|
35
|
+
const TURN_CONSUME_WINDOW_MS = 1000;
|
|
13
36
|
/**
|
|
14
37
|
* CadreNode is the main entry point for a cadre member.
|
|
15
38
|
* It manages:
|
|
@@ -26,10 +49,125 @@ export class CadreNode {
|
|
|
26
49
|
this.strandWatcher = null;
|
|
27
50
|
this.seedBootstrapService = null;
|
|
28
51
|
this.strandSolicitationService = null;
|
|
29
|
-
this.
|
|
52
|
+
this.strandWakeService = null;
|
|
53
|
+
/**
|
|
54
|
+
* Control-network strand-address responder. Answers a co-cadre sibling's
|
|
55
|
+
* on-demand request for this node's live strand-network multiaddrs so the
|
|
56
|
+
* sibling can seed a strand mesh from us — the read side of the seed path that
|
|
57
|
+
* stops conflating control addresses with strand seeding.
|
|
58
|
+
*/
|
|
59
|
+
this.strandAddrService = null;
|
|
60
|
+
/**
|
|
61
|
+
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
62
|
+
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
63
|
+
* the node behaves exactly as before (no notifier, no fan-out). Owns who/when
|
|
64
|
+
* to wake hibernating mobile peers on strand activity.
|
|
65
|
+
*/
|
|
66
|
+
this.pushFanoutService = null;
|
|
67
|
+
/** Backing field for the {@link running} / {@link isRunning} getters. */
|
|
68
|
+
this._running = false;
|
|
69
|
+
/**
|
|
70
|
+
* In-flight {@link serviceWake} operations keyed by strandId. Coalesces
|
|
71
|
+
* concurrent on-demand wakes for the same strand into one runtime build + one
|
|
72
|
+
* window + one re-hibernate decision (a second caller joins the first's
|
|
73
|
+
* promise), complementing {@link HibernationManager}'s wake coalescing.
|
|
74
|
+
*/
|
|
75
|
+
this.serviceWakePromises = new Map();
|
|
76
|
+
/**
|
|
77
|
+
* Live wake-window waiters (see {@link holdWakeWindow}). Tracked so
|
|
78
|
+
* {@link cleanup} can clear the timer AND resolve the promise on teardown — a
|
|
79
|
+
* window must never fire (or hang an in-flight serviceWake) after stop().
|
|
80
|
+
*/
|
|
81
|
+
this.windowWaiters = new Set();
|
|
30
82
|
this.eventHandlers = new Map();
|
|
31
83
|
/** Map of strandId -> sAppConfig for sAppId filtering and management */
|
|
32
84
|
this.sAppConfigs = new Map();
|
|
85
|
+
/**
|
|
86
|
+
* Most-recently pushed invite addresses (see {@link setInviteAddresses}).
|
|
87
|
+
* When non-null these take priority over `libp2pNode.getMultiaddrs()` when
|
|
88
|
+
* minting invites — the host pushes NAT-resolved addresses here so the
|
|
89
|
+
* control-network node never needs to dial back to the manager.
|
|
90
|
+
*/
|
|
91
|
+
this.latestInviteAddresses = null;
|
|
92
|
+
/** Initial self-registration timer (see {@link scheduleSelfRegistration}). */
|
|
93
|
+
this.selfRegistrationTimer = null;
|
|
94
|
+
/** TTL heartbeat that re-publishes the self record before it goes stale. */
|
|
95
|
+
this.recordRefreshTimer = null;
|
|
96
|
+
/** Listener that re-publishes the self record when reachable addresses change. */
|
|
97
|
+
this.selfPeerUpdateHandler = null;
|
|
98
|
+
/**
|
|
99
|
+
* Recurring proactive control-cohort dial cadence (see
|
|
100
|
+
* {@link reconcileControlCohort}). Wired alongside {@link recordRefreshTimer}
|
|
101
|
+
* in {@link startRecordRefresh} and torn down symmetrically in
|
|
102
|
+
* {@link stopRecordRefresh}; `.unref()`'d so it never keeps the loop alive.
|
|
103
|
+
*/
|
|
104
|
+
this.controlCohortReconcileTimer = null;
|
|
105
|
+
/**
|
|
106
|
+
* Single-flight guard for {@link reconcileControlCohort}. The eager start pass,
|
|
107
|
+
* the recurring interval, and the `self:peer:update` trigger can fire close
|
|
108
|
+
* together; collapsing concurrent passes into one in-flight run prevents two
|
|
109
|
+
* passes from double-dialing the same siblings (mirrors {@link registerSelfInFlight}).
|
|
110
|
+
*/
|
|
111
|
+
this.reconcileControlCohortInFlight = null;
|
|
112
|
+
/**
|
|
113
|
+
* Single-flight guard for {@link registerSelf}. Concurrent callers (the explicit
|
|
114
|
+
* CLI `--owner` publish, the 1s startup timer, the TTL heartbeat, and the
|
|
115
|
+
* address-change listener) share one in-flight publish so two of them can never
|
|
116
|
+
* both read "no row yet" and race a duplicate INSERT (a `CadrePeer` PK conflict).
|
|
117
|
+
*/
|
|
118
|
+
this.registerSelfInFlight = null;
|
|
119
|
+
// ── Write-while-alone re-replication (control-write-ensure-replicated) ──────
|
|
120
|
+
/**
|
|
121
|
+
* Re-replication queue for owner control writes that committed while this
|
|
122
|
+
* node was alone — `controlNode.getConnections().length === 0` at write time
|
|
123
|
+
* means the Optimystic commit was local-only (the block's cluster was ≤1) and
|
|
124
|
+
* never broadcast. Maps the affected subject peerId → the write to re-issue once
|
|
125
|
+
* the cohort grows. A later op for the same subject overwrites an earlier one
|
|
126
|
+
* (authorize then remove → remove wins). Drained on the 0→≥1 control-connection
|
|
127
|
+
* transition by {@link drainPendingControlReplication}.
|
|
128
|
+
*/
|
|
129
|
+
this.pendingPeerWrites = new Map();
|
|
130
|
+
/** This node's own `CadrePeer` self-write committed local-only (re-touched on growth). */
|
|
131
|
+
this.pendingSelfPeerWrite = false;
|
|
132
|
+
/** This node's own `DeviceToken` self-write committed local-only (re-touched on growth). */
|
|
133
|
+
this.pendingSelfDeviceWrite = false;
|
|
134
|
+
/**
|
|
135
|
+
* Guards the one-shot, first-cohort-growth reconstruction: an owner re-touches
|
|
136
|
+
* every membership row it may have authored that could be unreplicated (covering
|
|
137
|
+
* writes made before this process started, which the in-memory queue cannot know).
|
|
138
|
+
* Set true after the first drain so later passes only drain the in-memory queue.
|
|
139
|
+
*/
|
|
140
|
+
this.reconstructedLocalOnlyWrites = false;
|
|
141
|
+
/** Single-flight guard for the re-replication drain (mirrors {@link registerSelfInFlight}). */
|
|
142
|
+
this.drainControlReplicationInFlight = null;
|
|
143
|
+
/**
|
|
144
|
+
* Tracks the control-connection presence edge so the drain fires only on the
|
|
145
|
+
* 0→≥1 transition (the earliest point a re-issue can broadcast), not on every
|
|
146
|
+
* subsequent `connection:open`. Re-armed to false once connections return to 0.
|
|
147
|
+
*/
|
|
148
|
+
this.hasControlConnection = false;
|
|
149
|
+
/** `connection:open` listener driving the growth-triggered drain (teardown in {@link stopRecordRefresh}). */
|
|
150
|
+
this.controlConnectionOpenHandler = null;
|
|
151
|
+
/** `connection:close` listener re-arming the growth edge (teardown in {@link stopRecordRefresh}). */
|
|
152
|
+
this.controlConnectionCloseHandler = null;
|
|
153
|
+
// ── WebRTC TURN-relay detection (turn-relayed-path-metrics) ─────────────────
|
|
154
|
+
/**
|
|
155
|
+
* Hooks `globalThis.RTCPeerConnection` to observe whether ICE selected a TURN
|
|
156
|
+
* relay candidate for each WebRTC session. Installed in {@link start}, disposed
|
|
157
|
+
* in {@link cleanup}; inert on Node.js (no `RTCPeerConnection`).
|
|
158
|
+
*/
|
|
159
|
+
this.turnTracker = new TurnRelayTracker();
|
|
160
|
+
/**
|
|
161
|
+
* Peer IDs whose current WebRTC connection was observed to be TURN-relayed.
|
|
162
|
+
* Populated by {@link handleTurnConnectionOpen} (drains the tracker queue on a
|
|
163
|
+
* `/webrtc` `connection:open`), cleared per-peer on `connection:close`. Read by
|
|
164
|
+
* {@link getConnectionPaths} to promote `webrtc` → `webrtc-turn` (relayed).
|
|
165
|
+
*/
|
|
166
|
+
this.turnRelayedPeers = new Set();
|
|
167
|
+
/** `connection:open` listener feeding TURN detection (teardown in {@link stopRecordRefresh}). */
|
|
168
|
+
this.turnConnectionOpenHandler = null;
|
|
169
|
+
/** `connection:close` listener clearing a peer's TURN flag (teardown in {@link stopRecordRefresh}). */
|
|
170
|
+
this.turnConnectionCloseHandler = null;
|
|
33
171
|
this.config = config;
|
|
34
172
|
this.strandManager = new StrandInstanceManager();
|
|
35
173
|
this.enrollmentService = new EnrollmentService();
|
|
@@ -37,7 +175,8 @@ export class CadreNode {
|
|
|
37
175
|
const hibernationCallbacks = {
|
|
38
176
|
onIdle: async (strandId) => this.handleStrandIdle(strandId),
|
|
39
177
|
onHibernate: async (strandId) => this.handleStrandHibernate(strandId),
|
|
40
|
-
onWake: async (strandId) => this.handleStrandWake(strandId)
|
|
178
|
+
onWake: async (strandId) => this.handleStrandWake(strandId),
|
|
179
|
+
onCheckIn: async (strandId) => this.handleStrandCheckIn(strandId)
|
|
41
180
|
};
|
|
42
181
|
this.hibernationManager = new HibernationManager(config.hibernation ?? { enabled: false }, hibernationCallbacks);
|
|
43
182
|
log('CadreNode created for party: %s', config.controlNetwork.partyId);
|
|
@@ -54,6 +193,12 @@ export class CadreNode {
|
|
|
54
193
|
get peerId() {
|
|
55
194
|
return this.controlNode?.peerId;
|
|
56
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* The party ID this node serves (control-network identity).
|
|
198
|
+
*/
|
|
199
|
+
get partyId() {
|
|
200
|
+
return this.config.controlNetwork.partyId;
|
|
201
|
+
}
|
|
57
202
|
/**
|
|
58
203
|
* Get the multiaddrs of this node (available after start)
|
|
59
204
|
*/
|
|
@@ -66,7 +211,49 @@ export class CadreNode {
|
|
|
66
211
|
* Check if the node is running
|
|
67
212
|
*/
|
|
68
213
|
get isRunning() {
|
|
69
|
-
return this.
|
|
214
|
+
return this._running;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Synchronous lifecycle snapshot for headless callers (a mobile
|
|
218
|
+
* `BackgroundRunner` that boots in a background task and must *query* state
|
|
219
|
+
* rather than subscribe to `control:connected`/`control:disconnected`).
|
|
220
|
+
* Equivalent to {@link isRunning}.
|
|
221
|
+
*/
|
|
222
|
+
get running() {
|
|
223
|
+
return this._running;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Synchronous readiness snapshot: whether the control network is currently
|
|
227
|
+
* connected (the node is running and its control-network libp2p node is up).
|
|
228
|
+
* Tracks the same edge the `control:connected`/`control:disconnected` events
|
|
229
|
+
* announce, but pollable.
|
|
230
|
+
*/
|
|
231
|
+
get controlConnected() {
|
|
232
|
+
return this._running && this.controlNode !== null;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Classify every open control-network connection as relayed
|
|
236
|
+
* (`/p2p-circuit`) vs direct, tag its transport, and summarise counts plus a
|
|
237
|
+
* stuck-on-relay condition. Pure, read-only snapshot over
|
|
238
|
+
* `controlNode.getConnections()`. Returns an empty (all-zero) summary when
|
|
239
|
+
* the node has not been started.
|
|
240
|
+
*
|
|
241
|
+
* @param settleWindowMs - grace period before a relayed connection with no
|
|
242
|
+
* direct sibling is considered stuck (default 10_000ms)
|
|
243
|
+
*/
|
|
244
|
+
getConnectionPaths(settleWindowMs) {
|
|
245
|
+
const conns = this.controlNode?.getConnections() ?? [];
|
|
246
|
+
// Annotate each connection with the TURN-relay hint so a WebRTC session whose
|
|
247
|
+
// ICE selected a TURN candidate classifies as relayed/webrtc-turn rather than
|
|
248
|
+
// direct/webrtc (the multiaddr alone cannot reveal it).
|
|
249
|
+
const annotated = conns.map((c) => ({
|
|
250
|
+
remotePeer: c.remotePeer,
|
|
251
|
+
remoteAddr: c.remoteAddr,
|
|
252
|
+
direction: c.direction,
|
|
253
|
+
timeline: c.timeline,
|
|
254
|
+
turnRelayed: this.turnRelayedPeers.has(c.remotePeer.toString()),
|
|
255
|
+
}));
|
|
256
|
+
return summarizeConnectionPaths(annotated, settleWindowMs);
|
|
70
257
|
}
|
|
71
258
|
/**
|
|
72
259
|
* Get all strand instances
|
|
@@ -90,13 +277,21 @@ export class CadreNode {
|
|
|
90
277
|
* Start the cadre node
|
|
91
278
|
*/
|
|
92
279
|
async start() {
|
|
93
|
-
if (this.
|
|
280
|
+
if (this._running) {
|
|
94
281
|
log('CadreNode already running');
|
|
95
282
|
return;
|
|
96
283
|
}
|
|
97
284
|
log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
|
|
98
285
|
try {
|
|
99
286
|
const tTotal = performance.now();
|
|
287
|
+
// Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
|
|
288
|
+
// wraps globalThis.RTCPeerConnection before the control node can create one.
|
|
289
|
+
// Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
|
|
290
|
+
this.turnTracker.install();
|
|
291
|
+
// Resolve the node identity (keyStore | privateKey | ephemeral) BEFORE any
|
|
292
|
+
// libp2p/network bring-up, so a misconfiguration or an access-denied secure
|
|
293
|
+
// store fails closed before a node is created.
|
|
294
|
+
await this.resolveIdentityKey();
|
|
100
295
|
// Create the control network libp2p node
|
|
101
296
|
let t0 = performance.now();
|
|
102
297
|
this.controlNode = await this.createControlNode();
|
|
@@ -131,10 +326,38 @@ export class CadreNode {
|
|
|
131
326
|
timing('[start] strandWatcher.start: %dms', Math.round(performance.now() - t0));
|
|
132
327
|
// Start hibernation manager
|
|
133
328
|
this.hibernationManager.start();
|
|
134
|
-
|
|
329
|
+
// Register the control-network push-wake receiver: a same-cadre peer can
|
|
330
|
+
// signal us to bring a hibernating strand online. Gated on AUTHORIZED
|
|
331
|
+
// membership (not the addressable surface); the wake routes through the same
|
|
332
|
+
// path as a local wake.
|
|
333
|
+
this.strandWakeService = new StrandWakeService({
|
|
334
|
+
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
335
|
+
getStrand: (strandId) => this.strandManager.getInstance(strandId),
|
|
336
|
+
wake: (strandId) => this.wakeStrand(strandId),
|
|
337
|
+
});
|
|
338
|
+
this.strandWakeService.initialize(this.controlNode);
|
|
339
|
+
// Register the control-network strand-address responder: a same-cadre peer
|
|
340
|
+
// resolving a strand's bootstrap seed asks us for our live strand-network
|
|
341
|
+
// multiaddrs (its CadrePeer row only knows our *control* address). Gated on
|
|
342
|
+
// AUTHORIZED membership; answers only for strands we are actively meshing.
|
|
343
|
+
this.strandAddrService = new StrandAddrService({
|
|
344
|
+
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
345
|
+
getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
|
|
346
|
+
});
|
|
347
|
+
this.strandAddrService.initialize(this.controlNode);
|
|
348
|
+
// Server-side push-wake fan-out: only when push is configured.
|
|
349
|
+
if (this.config.push) {
|
|
350
|
+
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
351
|
+
log('Push-wake fan-out enabled (injected notifier)');
|
|
352
|
+
}
|
|
353
|
+
this._running = true;
|
|
135
354
|
this.emit('control:connected', undefined);
|
|
136
355
|
timing('[start] total: %dms', Math.round(performance.now() - tTotal));
|
|
137
356
|
log('CadreNode started successfully');
|
|
357
|
+
// Wire the control-connection growth listeners immediately (not via the
|
|
358
|
+
// delayed refresh path) so no early `connection:open` is missed — the
|
|
359
|
+
// write-while-alone re-replication drain must fire on the first 0→≥1 edge.
|
|
360
|
+
this.wireControlConnectionListeners();
|
|
138
361
|
// Schedule self-registration in background
|
|
139
362
|
this.scheduleSelfRegistration();
|
|
140
363
|
}
|
|
@@ -144,17 +367,42 @@ export class CadreNode {
|
|
|
144
367
|
throw error;
|
|
145
368
|
}
|
|
146
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Build the server-side push-wake fan-out over an injected {@link PushNotifier}.
|
|
372
|
+
*
|
|
373
|
+
* The notifier reaches for `node:http2`/`node:crypto`, so the cross-platform
|
|
374
|
+
* core never constructs it — the Node host builds it from
|
|
375
|
+
* `@serfab/cadre-core/push-node` and passes the instance in
|
|
376
|
+
* `CadreNodeConfig.push`. This node owns that instance's lifecycle from here:
|
|
377
|
+
* {@link cleanup} closes the fan-out, which closes the notifier (freeing the
|
|
378
|
+
* APNs HTTP/2 session). Every other primitive the fan-out needs (member
|
|
379
|
+
* enumeration, participation, direct dial, token resolve/expire) is an
|
|
380
|
+
* import-clean closure over this node.
|
|
381
|
+
*/
|
|
382
|
+
buildPushFanout(push) {
|
|
383
|
+
return new PushFanoutService({
|
|
384
|
+
listMembers: () => this.listMembers(),
|
|
385
|
+
getStrand: (strandId) => this.strandManager.getInstance(strandId),
|
|
386
|
+
selfPeerId: () => this.controlNode?.peerId.toString(),
|
|
387
|
+
pushWake: (peerId, strandId, reason) => this.pushWake(peerId, strandId, reason),
|
|
388
|
+
resolveDeviceToken: (peerId) => this.resolveDeviceToken(peerId),
|
|
389
|
+
expireDeviceToken: (peerId) => this.expireDeviceToken(peerId),
|
|
390
|
+
notifier: push.notifier,
|
|
391
|
+
cooldownMs: push.cooldownMs,
|
|
392
|
+
debounceMs: push.debounceMs,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
147
395
|
/**
|
|
148
396
|
* Stop the cadre node
|
|
149
397
|
*/
|
|
150
398
|
async stop() {
|
|
151
|
-
if (!this.
|
|
399
|
+
if (!this._running) {
|
|
152
400
|
log('CadreNode not running');
|
|
153
401
|
return;
|
|
154
402
|
}
|
|
155
403
|
log('Stopping CadreNode');
|
|
156
404
|
await this.cleanup();
|
|
157
|
-
this.
|
|
405
|
+
this._running = false;
|
|
158
406
|
this.emit('control:disconnected', undefined);
|
|
159
407
|
log('CadreNode stopped');
|
|
160
408
|
}
|
|
@@ -183,8 +431,58 @@ export class CadreNode {
|
|
|
183
431
|
}
|
|
184
432
|
});
|
|
185
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Resolve the node identity into {@link identityKey} exactly once, fail-closed,
|
|
436
|
+
* before any network bring-up. Resolution order:
|
|
437
|
+
*
|
|
438
|
+
* 1. Both `keyStore` and `privateKey` set ⇒ configuration error (throws).
|
|
439
|
+
* 2. `keyStore` set ⇒ load protobuf bytes from `identityKeyId` (default
|
|
440
|
+
* {@link DEFAULT_IDENTITY_KEY_ID}). Found ⇒ deserialize; empty ⇒ generate a
|
|
441
|
+
* fresh Ed25519 key, persist it, and use it. A rejected `get` (access denied
|
|
442
|
+
* / backend failure) PROPAGATES — we never generate a new key on a read
|
|
443
|
+
* error, which would silently orphan the real identity.
|
|
444
|
+
* 3. `privateKey` set ⇒ use it directly.
|
|
445
|
+
* 4. Neither ⇒ leave undefined; libp2p generates an ephemeral key.
|
|
446
|
+
*
|
|
447
|
+
* Idempotent: a second call (or a stop()→start() cycle) reuses the already
|
|
448
|
+
* resolved key rather than regenerating or re-persisting.
|
|
449
|
+
*/
|
|
450
|
+
async resolveIdentityKey() {
|
|
451
|
+
if (this.identityKey) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const { keyStore, privateKey, identityKeyId } = this.config;
|
|
455
|
+
if (keyStore && privateKey) {
|
|
456
|
+
throw new Error('CadreNodeConfig: `keyStore` and `privateKey` are mutually exclusive — ' +
|
|
457
|
+
'configure at most one source for the node identity');
|
|
458
|
+
}
|
|
459
|
+
if (keyStore) {
|
|
460
|
+
const keyId = identityKeyId ?? DEFAULT_IDENTITY_KEY_ID;
|
|
461
|
+
// A rejection here (e.g. KeyStoreAccessError) must propagate — do NOT fall
|
|
462
|
+
// through to generation, which would orphan an existing but unreadable key.
|
|
463
|
+
const bytes = await keyStore.get(keyId);
|
|
464
|
+
if (bytes) {
|
|
465
|
+
// Corrupt/garbage bytes throw here; surface loudly rather than
|
|
466
|
+
// regenerating (which would orphan the real identity).
|
|
467
|
+
this.identityKey = privateKeyFromProtobuf(bytes);
|
|
468
|
+
log('Identity key loaded from key store (slot present)');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const generated = await generateKeyPair('Ed25519');
|
|
472
|
+
await keyStore.set(keyId, privateKeyToProtobuf(generated));
|
|
473
|
+
this.identityKey = generated;
|
|
474
|
+
log('Identity key generated and persisted to key store (first run)');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (privateKey) {
|
|
478
|
+
this.identityKey = privateKey;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Neither configured: libp2p generates an ephemeral key internally.
|
|
482
|
+
}
|
|
186
483
|
async createControlNode() {
|
|
187
|
-
const { controlNetwork, network, storage, profile
|
|
484
|
+
const { controlNetwork, network, storage, profile } = this.config;
|
|
485
|
+
const identityKey = this.identityKey;
|
|
188
486
|
// Determine relay mode: if explicitly set in config, use that;
|
|
189
487
|
// otherwise default to true for storage profile nodes (better connectivity/uptime)
|
|
190
488
|
const enableRelay = network?.enableRelay ?? (profile === 'storage');
|
|
@@ -205,9 +503,10 @@ export class CadreNode {
|
|
|
205
503
|
clusterSize: 3,
|
|
206
504
|
clusterPolicy: { allowDownsize: true, sizeTolerance: 0.5 },
|
|
207
505
|
arachnode: { enableRingZulu: this.config.profile === 'storage' },
|
|
208
|
-
...(
|
|
506
|
+
...(identityKey && { privateKey: identityKey }),
|
|
209
507
|
...(network?.transports && { transports: network.transports }),
|
|
210
|
-
...(network?.listenAddrs && { listenAddrs: network.listenAddrs })
|
|
508
|
+
...(network?.listenAddrs && { listenAddrs: network.listenAddrs }),
|
|
509
|
+
...(network?.connectionGater && { connectionGater: network.connectionGater })
|
|
211
510
|
};
|
|
212
511
|
return await createLibp2pNode(nodeOptions);
|
|
213
512
|
}
|
|
@@ -224,74 +523,1007 @@ export class CadreNode {
|
|
|
224
523
|
};
|
|
225
524
|
}
|
|
226
525
|
/**
|
|
227
|
-
* Schedule self-
|
|
228
|
-
*
|
|
526
|
+
* Schedule the node's initial self-record publish + ongoing refresh shortly
|
|
527
|
+
* after start (non-blocking). {@link registerSelf} is idempotent and safely
|
|
528
|
+
* no-ops when it cannot yet sign/insert (e.g. owner key not installed),
|
|
529
|
+
* so the timer is harmless even when registration only becomes possible later.
|
|
229
530
|
*/
|
|
230
531
|
scheduleSelfRegistration() {
|
|
231
|
-
|
|
232
|
-
|
|
532
|
+
this.selfRegistrationTimer = setTimeout(() => {
|
|
533
|
+
void (async () => {
|
|
534
|
+
try {
|
|
535
|
+
await this.registerSelf();
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
// Background task — a failed publish must not crash the node.
|
|
539
|
+
log('Self-registration failed: %o', error);
|
|
540
|
+
}
|
|
541
|
+
// Wire the ongoing refresh + control-cohort reconcile cadence, then run
|
|
542
|
+
// an eager reconcile pass once the node has settled (the recurring
|
|
543
|
+
// interval was just armed inside startRecordRefresh).
|
|
544
|
+
this.startRecordRefresh();
|
|
545
|
+
void this.reconcileControlCohort().catch((error) => log('Control-cohort reconcile (start) failed: %o', error));
|
|
546
|
+
})();
|
|
547
|
+
}, 1000); // Small delay to ensure node is fully started
|
|
548
|
+
// Node-only: don't keep the event loop alive solely for this timer.
|
|
549
|
+
this.selfRegistrationTimer?.unref?.();
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Publish (or refresh) this node's own signed `CadrePeer` address record so
|
|
553
|
+
* other members can resolve its current signaling/relay multiaddrs from its
|
|
554
|
+
* PeerId alone. Public, awaitable, and idempotent.
|
|
555
|
+
*
|
|
556
|
+
* - Builds a `PeerAddressRecord` from the node's current dialable addrs
|
|
557
|
+
* (signaling/`p2p-circuit` first), signed with the ed25519 key behind its
|
|
558
|
+
* PeerId (the resolved node identity from `keyStore`/`config.privateKey`).
|
|
559
|
+
* - If the row already exists: a self-signed UPDATE bumping `UpdatedAt`.
|
|
560
|
+
* - If not, and the node is its own owner: an owner-signed INSERT that
|
|
561
|
+
* also carries the self-signature.
|
|
562
|
+
* - Otherwise: logs and returns (a non-owner node with no row yet must
|
|
563
|
+
* wait for an owner to insert it; it can then self-refresh).
|
|
564
|
+
*
|
|
565
|
+
* Safe to call repeatedly (heartbeat / address-change driven); each successful
|
|
566
|
+
* publish strictly increases `UpdatedAt`. Concurrent calls are collapsed into a
|
|
567
|
+
* single in-flight publish (see {@link registerSelfInFlight}) so the explicit
|
|
568
|
+
* startup publish and the background timers can never race a duplicate INSERT.
|
|
569
|
+
*
|
|
570
|
+
* @returns what the publish did — `inserted`, `refreshed`, or `skipped`.
|
|
571
|
+
*/
|
|
572
|
+
async registerSelf() {
|
|
573
|
+
// Join an in-flight publish rather than starting a second one. Without this,
|
|
574
|
+
// the explicit CLI call and the 1s timer could both observe "no row yet" and
|
|
575
|
+
// both attempt the INSERT — the loser hits a CadrePeer PK conflict.
|
|
576
|
+
if (this.registerSelfInFlight) {
|
|
577
|
+
return this.registerSelfInFlight;
|
|
578
|
+
}
|
|
579
|
+
const op = this.publishSelfRecord();
|
|
580
|
+
this.registerSelfInFlight = op;
|
|
581
|
+
try {
|
|
582
|
+
return await op;
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
this.registerSelfInFlight = null;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** The body of {@link registerSelf}; serialised by its single-flight guard. */
|
|
589
|
+
async publishSelfRecord() {
|
|
590
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
591
|
+
log('Cannot register self - node or database not initialized');
|
|
592
|
+
return 'skipped';
|
|
593
|
+
}
|
|
594
|
+
const signingKey = this.getSelfSigningKey();
|
|
595
|
+
if (!signingKey) {
|
|
596
|
+
log('registerSelf: no self-signing key available (node identity unavailable or not matching peerId); skipping');
|
|
597
|
+
return 'skipped';
|
|
598
|
+
}
|
|
599
|
+
const peerId = this.controlNode.peerId.toString();
|
|
600
|
+
const addrs = await this.collectSelfAddrs();
|
|
601
|
+
const existing = await this.controlDatabase.queryPeerRecord(peerId);
|
|
602
|
+
// Strictly increase UpdatedAt even on a same-millisecond re-publish.
|
|
603
|
+
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
604
|
+
const record = signPeerRecord({ peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
|
|
605
|
+
if (existing) {
|
|
606
|
+
await this.controlDatabase.updateSelfPeerRecord(record);
|
|
607
|
+
if (this.committedAlone())
|
|
608
|
+
this.pendingSelfPeerWrite = true;
|
|
609
|
+
log('registerSelf: refreshed own CadrePeer record (updatedAt=%d, %d addrs)', updatedAt, addrs.length);
|
|
610
|
+
return 'refreshed';
|
|
611
|
+
}
|
|
612
|
+
if (this.seedBootstrapService) {
|
|
613
|
+
// First-time row: requires an owner signature (the node is its own
|
|
614
|
+
// owner). insertSelfPeerRecord throws if no owner key is present.
|
|
615
|
+
await this.seedBootstrapService.insertSelfPeerRecord(record);
|
|
616
|
+
if (this.committedAlone())
|
|
617
|
+
this.pendingSelfPeerWrite = true;
|
|
618
|
+
log('registerSelf: inserted own CadrePeer record (owner-signed, updatedAt=%d, %d addrs)', updatedAt, addrs.length);
|
|
619
|
+
return 'inserted';
|
|
620
|
+
}
|
|
621
|
+
log('registerSelf: not yet a CadrePeer member and no owner service to self-insert; skipping (an owner must add this peer first)');
|
|
622
|
+
return 'skipped';
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* The ed25519 keypair (base64url) the node signs its own record with — the key
|
|
626
|
+
* behind its libp2p PeerId. Sourced from the resolved {@link identityKey}
|
|
627
|
+
* (which a `keyStore` or `config.privateKey` supplies); returns null when
|
|
628
|
+
* absent (ephemeral identity) or (defensively) when it does not match the
|
|
629
|
+
* control node's PeerId, in which case self-publish is skipped rather than
|
|
630
|
+
* producing an unresolvable row.
|
|
631
|
+
*/
|
|
632
|
+
getSelfSigningKey() {
|
|
633
|
+
const peerId = this.controlNode?.peerId.toString();
|
|
634
|
+
if (!peerId || !this.identityKey) {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
const { privateKeyB64, publicKeyB64 } = ed25519KeyPairFromLibp2p(this.identityKey);
|
|
639
|
+
if (publicKeyB64 === ed25519PublicKeyB64FromPeerId(peerId)) {
|
|
640
|
+
return { privateKeyB64, publicKeyB64 };
|
|
641
|
+
}
|
|
642
|
+
log('getSelfSigningKey: resolved identity key does not match control node peerId; cannot self-sign');
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
// Logs the error shape only; ed25519KeyPairFromLibp2p never embeds key material.
|
|
646
|
+
log('getSelfSigningKey: failed to derive ed25519 key from identity key: %o', error);
|
|
647
|
+
}
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The owner keypair (base64url Ed25519) derived from this node's resolved
|
|
652
|
+
* identity key. In the single-key reference model the owner signing key is
|
|
653
|
+
* *derived from* the node identity (see {@link ed25519KeyPairFromLibp2p}), so the
|
|
654
|
+
* same key material protected in a secure enclave backs both.
|
|
655
|
+
*
|
|
656
|
+
* Exposed so the hosting app retains control of owner genesis: cadre-core
|
|
657
|
+
* resolves + protects the identity, then the app sources this pair to drive
|
|
658
|
+
* `ensureOwnerKey(pub)` + `initializeSeedBootstrap(priv)` itself — cadre-core
|
|
659
|
+
* never silently runs genesis. A future separate-owner slot would return a
|
|
660
|
+
* distinct key here instead of the identity-derived one.
|
|
661
|
+
*
|
|
662
|
+
* @returns The base64url seed/public-key owner pair.
|
|
663
|
+
* @throws If called before {@link start} has resolved the identity, or when the
|
|
664
|
+
* node runs on an ephemeral libp2p key (no `keyStore`/`privateKey` configured),
|
|
665
|
+
* since that key is internal to libp2p and not exposed.
|
|
666
|
+
*/
|
|
667
|
+
getIdentityOwnerKey() {
|
|
668
|
+
if (!this.identityKey) {
|
|
669
|
+
throw new Error('getIdentityOwnerKey: node identity not resolved — call start() first, and ' +
|
|
670
|
+
'configure `keyStore` or `privateKey` (an ephemeral libp2p identity exposes no owner key)');
|
|
671
|
+
}
|
|
672
|
+
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Collect this node's current dialable addresses for publication, signaling
|
|
676
|
+
* (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
|
|
677
|
+
* in the relay/signaling address (the WebRTC dial input) when not already
|
|
678
|
+
* present.
|
|
679
|
+
*/
|
|
680
|
+
async collectSelfAddrs() {
|
|
681
|
+
const resolved = await this.resolveInviteAddresses();
|
|
682
|
+
const relay = await this.getRelayAddress();
|
|
683
|
+
const merged = relay && !resolved.includes(relay) ? [...resolved, relay] : resolved;
|
|
684
|
+
return orderSignalingFirst([...new Set(merged)]);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Wire the ongoing self-record refresh: re-publish whenever libp2p reports an
|
|
688
|
+
* address change (relay reservation rotation, NAT change) and on a TTL
|
|
689
|
+
* heartbeat at half the freshness ceiling. Idempotent — repeated calls do not
|
|
690
|
+
* stack listeners/timers.
|
|
691
|
+
*/
|
|
692
|
+
startRecordRefresh() {
|
|
693
|
+
if (!this.controlNode || this.recordRefreshTimer) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const republish = (reason) => {
|
|
697
|
+
void this.registerSelf().catch((error) => log('Record refresh (%s) failed: %o', reason, error));
|
|
698
|
+
};
|
|
699
|
+
const reconcile = (reason) => {
|
|
700
|
+
void this.reconcileControlCohort().catch((error) => log('Control-cohort reconcile (%s) failed: %o', reason, error));
|
|
701
|
+
};
|
|
702
|
+
// Address churn re-publishes the self record AND re-checks the control cohort:
|
|
703
|
+
// a relay-reservation rotation / NAT change can drop a sibling connection, so
|
|
704
|
+
// the next pass should re-observe and re-dial it.
|
|
705
|
+
this.selfPeerUpdateHandler = () => {
|
|
706
|
+
republish('self:peer:update');
|
|
707
|
+
reconcile('self:peer:update');
|
|
708
|
+
};
|
|
709
|
+
this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
|
|
710
|
+
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
711
|
+
this.recordRefreshTimer?.unref?.();
|
|
712
|
+
const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
|
|
713
|
+
this.controlCohortReconcileTimer = setInterval(() => reconcile('interval'), reconcileMs);
|
|
714
|
+
this.controlCohortReconcileTimer?.unref?.();
|
|
715
|
+
log('Record refresh wired (heartbeat=%dms, cohortReconcile=%dms)', DEFAULT_PEER_RECORD_HEARTBEAT_MS, reconcileMs);
|
|
716
|
+
}
|
|
717
|
+
/** Tear down the self-record refresh timers + listener (see {@link cleanup}). */
|
|
718
|
+
stopRecordRefresh() {
|
|
719
|
+
if (this.selfRegistrationTimer) {
|
|
720
|
+
clearTimeout(this.selfRegistrationTimer);
|
|
721
|
+
this.selfRegistrationTimer = null;
|
|
722
|
+
}
|
|
723
|
+
if (this.recordRefreshTimer) {
|
|
724
|
+
clearInterval(this.recordRefreshTimer);
|
|
725
|
+
this.recordRefreshTimer = null;
|
|
726
|
+
}
|
|
727
|
+
if (this.controlCohortReconcileTimer) {
|
|
728
|
+
clearInterval(this.controlCohortReconcileTimer);
|
|
729
|
+
this.controlCohortReconcileTimer = null;
|
|
730
|
+
}
|
|
731
|
+
if (this.controlNode) {
|
|
732
|
+
if (this.selfPeerUpdateHandler) {
|
|
733
|
+
this.controlNode.removeEventListener('self:peer:update', this.selfPeerUpdateHandler);
|
|
734
|
+
}
|
|
735
|
+
if (this.controlConnectionOpenHandler) {
|
|
736
|
+
this.controlNode.removeEventListener('connection:open', this.controlConnectionOpenHandler);
|
|
737
|
+
}
|
|
738
|
+
if (this.controlConnectionCloseHandler) {
|
|
739
|
+
this.controlNode.removeEventListener('connection:close', this.controlConnectionCloseHandler);
|
|
740
|
+
}
|
|
741
|
+
if (this.turnConnectionOpenHandler) {
|
|
742
|
+
this.controlNode.removeEventListener('connection:open', this.turnConnectionOpenHandler);
|
|
743
|
+
}
|
|
744
|
+
if (this.turnConnectionCloseHandler) {
|
|
745
|
+
this.controlNode.removeEventListener('connection:close', this.turnConnectionCloseHandler);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
this.selfPeerUpdateHandler = null;
|
|
749
|
+
this.controlConnectionOpenHandler = null;
|
|
750
|
+
this.controlConnectionCloseHandler = null;
|
|
751
|
+
this.turnConnectionOpenHandler = null;
|
|
752
|
+
this.turnConnectionCloseHandler = null;
|
|
753
|
+
// Reset the write-while-alone re-replication state so a stop()→start() cycle
|
|
754
|
+
// re-arms the growth edge and re-runs the first-growth reconstruction. Any
|
|
755
|
+
// queued local-only writes are moot once the node is torn down; owner
|
|
756
|
+
// inserts are re-covered by reconstruction on the next start, and the
|
|
757
|
+
// (already-loudly-logged) delete gap is tracked for follow-up.
|
|
758
|
+
this.hasControlConnection = false;
|
|
759
|
+
this.reconstructedLocalOnlyWrites = false;
|
|
760
|
+
this.pendingPeerWrites.clear();
|
|
761
|
+
this.pendingSelfPeerWrite = false;
|
|
762
|
+
this.pendingSelfDeviceWrite = false;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Resolve a peer's current, signed, trust-checkable multiaddrs from only its
|
|
766
|
+
* PeerId — the transport-agnostic input a NAT-to-NAT WebRTC (or any) dial path
|
|
767
|
+
* consumes, with no copy/paste of a relayed dial string.
|
|
768
|
+
*
|
|
769
|
+
* Reads the peer's `CadrePeer` record and gates it through, in order:
|
|
770
|
+
* 1. record present (else `[]`),
|
|
771
|
+
* 2. `publicKey <-> peerId` binding (the stored key's libp2p identity must be
|
|
772
|
+
* the requested peerId),
|
|
773
|
+
* 3. self-signature verifies against `publicKey`,
|
|
774
|
+
* 4. freshness — rejected once older than `maxAgeMs` (never a dead relay
|
|
775
|
+
* reservation),
|
|
776
|
+
* 5. the pluggable trust gate (`opts.trustPolicy`).
|
|
777
|
+
* Survivors are returned signaling (`/p2p-circuit`) first, filtered to
|
|
778
|
+
* signaling-only when requested, as parsed `Multiaddr`s (unparsable addrs
|
|
779
|
+
* dropped). Any gate failure yields an empty array rather than throwing.
|
|
780
|
+
*/
|
|
781
|
+
async resolvePeerAddrs(peerId, opts = {}) {
|
|
782
|
+
if (!this.controlDatabase) {
|
|
783
|
+
throw new Error('CadreNode must be started before resolving peer addrs');
|
|
784
|
+
}
|
|
785
|
+
const record = await this.controlDatabase.queryPeerRecord(peerId);
|
|
786
|
+
if (!record) {
|
|
787
|
+
log('resolvePeerAddrs: no record for %s', peerId);
|
|
788
|
+
return [];
|
|
789
|
+
}
|
|
790
|
+
// publicKey <-> peerId binding: the stored key must be the one embedded in
|
|
791
|
+
// the requested Ed25519 peer id (also rejects a non-Ed25519 / missing key).
|
|
792
|
+
if (!record.publicKey || ed25519PublicKeyB64FromPeerId(peerId) !== record.publicKey) {
|
|
793
|
+
log('resolvePeerAddrs: publicKey does not match peerId for %s', peerId);
|
|
794
|
+
return [];
|
|
795
|
+
}
|
|
796
|
+
// Self-signature over (peerId, addrs, updatedAt).
|
|
797
|
+
if (!verifyPeerRecordSignature(record)) {
|
|
798
|
+
log('resolvePeerAddrs: signature verification failed for %s', peerId);
|
|
799
|
+
return [];
|
|
800
|
+
}
|
|
801
|
+
// Freshness: never hand back a dead relay reservation.
|
|
802
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_PEER_RECORD_MAX_AGE_MS;
|
|
803
|
+
if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
|
|
804
|
+
log('resolvePeerAddrs: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
|
|
805
|
+
return [];
|
|
806
|
+
}
|
|
807
|
+
// Pluggable trust gate (defaults to current-member).
|
|
808
|
+
const trustPolicy = opts.trustPolicy ?? currentMemberTrustPolicy();
|
|
809
|
+
const trusted = await trustPolicy.evaluate({
|
|
810
|
+
peerId,
|
|
811
|
+
publicKey: record.publicKey,
|
|
812
|
+
partyId: this.partyId,
|
|
813
|
+
record,
|
|
814
|
+
});
|
|
815
|
+
if (!trusted) {
|
|
816
|
+
log('resolvePeerAddrs: trust policy rejected %s', peerId);
|
|
817
|
+
return [];
|
|
818
|
+
}
|
|
819
|
+
// Order signaling-first (the on-record order was what we verified above),
|
|
820
|
+
// optionally restrict to signaling addrs, then parse — dropping any addr
|
|
821
|
+
// that does not parse as a multiaddr.
|
|
822
|
+
let addrs = orderSignalingFirst(record.addrs);
|
|
823
|
+
if (opts.signalingOnly) {
|
|
824
|
+
addrs = addrs.filter(isSignalingAddr);
|
|
825
|
+
}
|
|
826
|
+
return this.parseMultiaddrs(addrs);
|
|
827
|
+
}
|
|
828
|
+
/** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
|
|
829
|
+
parseMultiaddrs(addrs) {
|
|
830
|
+
const out = [];
|
|
831
|
+
for (const addr of addrs) {
|
|
233
832
|
try {
|
|
234
|
-
|
|
833
|
+
out.push(multiaddr(addr));
|
|
235
834
|
}
|
|
236
835
|
catch (error) {
|
|
237
|
-
log('
|
|
238
|
-
// Don't emit error event - this is a background task
|
|
239
|
-
// The node can still function without self-registration
|
|
836
|
+
log('resolvePeerAddrs: dropping unparsable multiaddr %s: %o', addr, error);
|
|
240
837
|
}
|
|
241
|
-
}
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
242
840
|
}
|
|
841
|
+
// ============================================================================
|
|
842
|
+
// Proactive control-cohort dial
|
|
843
|
+
//
|
|
844
|
+
// The control collections only replicate once a party's nodes are
|
|
845
|
+
// transport-connected (so FRET seats each peer in the others' keyspace cohort)
|
|
846
|
+
// AND a write happens while that cohort has ≥2 members. There is no production
|
|
847
|
+
// mechanism that makes a party's control nodes actively connect to each other;
|
|
848
|
+
// the convergence test does it by hand with a manual dial(). reconcileControlCohort
|
|
849
|
+
// productionizes that: each node resolves its known siblings' control addresses
|
|
850
|
+
// and proactively dials a bounded, backbone-preferential set, re-observing and
|
|
851
|
+
// re-dialing dropped connections on each pass. See docs/architecture.md (Control
|
|
852
|
+
// Network) and control-cohort.ts for the selection policy.
|
|
853
|
+
// ============================================================================
|
|
243
854
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
855
|
+
* Run one proactive control-cohort dial pass to keep this node connected to its
|
|
856
|
+
* cadre siblings (so the `CadreControl` collections form a replicating cohort).
|
|
857
|
+
* Public so the cohort-growth-driven re-replication path
|
|
858
|
+
* (`control-write-ensure-replicated`) and tests can trigger a pass on demand;
|
|
859
|
+
* normally driven by the eager start pass, the recurring interval, and
|
|
860
|
+
* `self:peer:update` (all wired in {@link startRecordRefresh}).
|
|
861
|
+
*
|
|
862
|
+
* Concurrent triggers collapse into a single in-flight pass
|
|
863
|
+
* (see {@link reconcileControlCohortInFlight}) so two passes never double-dial.
|
|
864
|
+
* Best-effort throughout: a failure to resolve/dial any one sibling is logged
|
|
865
|
+
* and the pass continues; the whole pass is a no-op when the node is alone.
|
|
246
866
|
*/
|
|
247
|
-
async
|
|
248
|
-
if (
|
|
249
|
-
|
|
867
|
+
async reconcileControlCohort() {
|
|
868
|
+
if (this.reconcileControlCohortInFlight) {
|
|
869
|
+
return this.reconcileControlCohortInFlight;
|
|
870
|
+
}
|
|
871
|
+
const op = this.runReconcileControlCohort();
|
|
872
|
+
this.reconcileControlCohortInFlight = op;
|
|
873
|
+
try {
|
|
874
|
+
await op;
|
|
875
|
+
}
|
|
876
|
+
finally {
|
|
877
|
+
this.reconcileControlCohortInFlight = null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/** Body of {@link reconcileControlCohort}; serialised by its single-flight guard. */
|
|
881
|
+
async runReconcileControlCohort() {
|
|
882
|
+
// Shutdown / not-yet-started guard (mirrors publishSelfRecord). A pass that
|
|
883
|
+
// fires after stop() began must early-return rather than touch a torn-down node.
|
|
884
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const selfPeerId = this.controlNode.peerId.toString();
|
|
888
|
+
// 1. Enumerate known siblings. This membership read is itself a pull-on-read
|
|
889
|
+
// that helps the CadrePeer table converge — a reader-only node converges
|
|
890
|
+
// purely by these reads.
|
|
891
|
+
const members = await this.listMembers();
|
|
892
|
+
const siblings = members.filter((m) => m.peerId !== selfPeerId);
|
|
893
|
+
if (siblings.length === 0) {
|
|
894
|
+
// Genuinely alone (cold start with no rows, or a solo cadre): nothing to
|
|
895
|
+
// dial. Must not throw or busy-loop.
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
// Re-guard after the await: a stop() may have raced the membership read.
|
|
899
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
// 2. Classify backbone (owner) members and select a bounded dial set.
|
|
903
|
+
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
904
|
+
if (!this._running || !this.controlNode) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const targetDegree = this.config.network?.controlCohort?.targetDegree
|
|
908
|
+
?? DEFAULT_CONTROL_COHORT_TARGET_DEGREE;
|
|
909
|
+
const { dials, cappedNonOwner } = selectControlCohortDials(siblings, ownerKeys, targetDegree);
|
|
910
|
+
if (cappedNonOwner > 0) {
|
|
911
|
+
// Don't silently bound coverage — surface what the out-degree cap dropped.
|
|
912
|
+
log('reconcileControlCohort: capped %d non-owner sibling(s) at targetDegree=%d', cappedNonOwner, targetDegree);
|
|
913
|
+
}
|
|
914
|
+
// 3. Skip already-connected peers (no re-dial / churn for live connections).
|
|
915
|
+
const connected = new Set(this.controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
916
|
+
// 4. Resolve + dial each selected, not-yet-connected sibling, best-effort.
|
|
917
|
+
let dialed = 0;
|
|
918
|
+
for (const sibling of dials) {
|
|
919
|
+
if (!this._running || !this.controlNode) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (connected.has(sibling.peerId)) {
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (await this.dialControlSibling(sibling)) {
|
|
926
|
+
dialed++;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
log('reconcileControlCohort: pass complete (siblings=%d, selected=%d, dialed=%d)', siblings.length, dials.length, dialed);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Resolve one sibling's control-network dial addresses and dial it, best-effort.
|
|
933
|
+
* Returns whether a dial was attempted (false when no address resolves).
|
|
934
|
+
*
|
|
935
|
+
* A per-peer failure (NAT, offline, relay down, connection-gater denial) is
|
|
936
|
+
* logged and swallowed so one unreachable sibling never aborts the pass —
|
|
937
|
+
* exactly like {@link SeedBootstrapService.applySeed}'s owner-dial loop. A
|
|
938
|
+
* failed dial is simply retried on the next pass.
|
|
939
|
+
*/
|
|
940
|
+
async dialControlSibling(sibling) {
|
|
941
|
+
const controlNode = this.controlNode;
|
|
942
|
+
if (!controlNode) {
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
const addrs = await this.resolveControlDialAddrs(sibling.peerId);
|
|
946
|
+
if (addrs.length === 0) {
|
|
947
|
+
log('reconcileControlCohort: no dialable control address for sibling %s; skipping', sibling.peerId);
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
log('reconcileControlCohort: dialing sibling %s (%d addr(s))', sibling.peerId, addrs.length);
|
|
952
|
+
await controlNode.dial(addrs);
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
catch (error) {
|
|
956
|
+
log('reconcileControlCohort: dial of sibling %s failed (continuing): %o', sibling.peerId, error);
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Resolve a sibling's control-network dial addresses for the reconcile pass.
|
|
962
|
+
*
|
|
963
|
+
* Primary (steady state): the signed, fresh, trust-gated control addresses from
|
|
964
|
+
* the converged `CadrePeer` record via {@link resolvePeerAddrs}. Fallback (cold
|
|
965
|
+
* start): the libp2p peerStore entries `applySeed` populated, used only while the
|
|
966
|
+
* record is not yet resolvable. Returns `[]` (never throws) when neither yields
|
|
967
|
+
* an address — that sibling is skipped this pass.
|
|
968
|
+
*/
|
|
969
|
+
async resolveControlDialAddrs(peerId) {
|
|
970
|
+
const resolved = await this.resolvePeerAddrs(peerId);
|
|
971
|
+
if (resolved.length > 0) {
|
|
972
|
+
return resolved;
|
|
973
|
+
}
|
|
974
|
+
return this.peerStoreAddrs(peerId);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
|
|
978
|
+
* {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
|
|
979
|
+
* parse/lookup failure — never throws.
|
|
980
|
+
*/
|
|
981
|
+
async peerStoreAddrs(peerId) {
|
|
982
|
+
if (!this.controlNode) {
|
|
983
|
+
return [];
|
|
984
|
+
}
|
|
985
|
+
try {
|
|
986
|
+
const peer = await this.controlNode.peerStore.get(peerIdFromString(peerId));
|
|
987
|
+
// Re-parse through the top-level multiaddr parser so the returned type matches
|
|
988
|
+
// resolvePeerAddrs (the peerStore bundles its own @multiformats/multiaddr copy).
|
|
989
|
+
return this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString()));
|
|
990
|
+
}
|
|
991
|
+
catch (error) {
|
|
992
|
+
log('reconcileControlCohort: peerStore lookup for %s failed: %o', peerId, error);
|
|
993
|
+
return [];
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// ============================================================================
|
|
997
|
+
// Write-while-alone re-replication (control-write-ensure-replicated)
|
|
998
|
+
//
|
|
999
|
+
// Optimystic's coordinator commits a control write WITHOUT broadcasting when the
|
|
1000
|
+
// block's cluster has ≤1 member. An owner that authorizes/removes a peer or
|
|
1001
|
+
// (re)publishes its own record while no sibling is connected therefore writes a
|
|
1002
|
+
// row that exists ONLY in its local control DB — a sibling that connects later
|
|
1003
|
+
// never observes it (pull-on-read routes to the block's cluster, which never
|
|
1004
|
+
// learned of the write). reconcileControlCohort shrinks the alone window but
|
|
1005
|
+
// cannot close it (a write in the instant before any sibling connects is still
|
|
1006
|
+
// local-only). The remedy: detect "wrote while alone" (0 connections is a sound
|
|
1007
|
+
// lower bound), queue the affected row, and RE-ISSUE the write once the cohort
|
|
1008
|
+
// grows (0→≥1 connection), as an idempotent monotonic update that now broadcasts.
|
|
1009
|
+
// See docs/architecture.md (Control Network → write-while-alone durability).
|
|
1010
|
+
// ============================================================================
|
|
1011
|
+
/**
|
|
1012
|
+
* Whether a control write happening now would commit local-only. A sound lower
|
|
1013
|
+
* bound is "no connected control peers": 0 connections ⇒ the block's cluster is
|
|
1014
|
+
* ≤1 ⇒ Optimystic commits without broadcasting. This is the pragmatic proxy for
|
|
1015
|
+
* the precise signal (the block's `getClusterSize`); it over-approximates safely
|
|
1016
|
+
* — a connected-but-not-in-this-block's-cluster write may still be local-only and
|
|
1017
|
+
* is caught by the cohort's periodic pull-on-read instead. Re-issuing an already-
|
|
1018
|
+
* replicated row is harmless (an idempotent monotonic bump), so the coarse proxy
|
|
1019
|
+
* is acceptable as the agreed first cut.
|
|
1020
|
+
*/
|
|
1021
|
+
committedAlone() {
|
|
1022
|
+
return (this.controlNode?.getConnections().length ?? 0) === 0;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Record (or clear) a just-committed owner membership write in the
|
|
1026
|
+
* write-while-alone re-replication queue. If the control node had no connections
|
|
1027
|
+
* at commit, the Optimystic write was local-only and must be re-issued once the
|
|
1028
|
+
* cohort grows; otherwise the write replicated and any stale queue entry for this
|
|
1029
|
+
* subject is dropped.
|
|
1030
|
+
*
|
|
1031
|
+
* A `remove` that commits alone is a security-relevant durability gap (a revoked
|
|
1032
|
+
* peer may persist as a member elsewhere), so it is logged loudly — and, because
|
|
1033
|
+
* a physical delete leaves no local trace to re-issue from, its on-growth
|
|
1034
|
+
* re-issue is best-effort only (see {@link drainPendingPeerWrites}).
|
|
1035
|
+
*/
|
|
1036
|
+
noteControlWrite(peerId, kind) {
|
|
1037
|
+
if (!this.committedAlone()) {
|
|
1038
|
+
this.pendingPeerWrites.delete(peerId);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
this.pendingPeerWrites.set(peerId, kind);
|
|
1042
|
+
if (kind === 'remove') {
|
|
1043
|
+
log('removePeer(%s) committed while ALONE (0 control connections): the deletion is ' +
|
|
1044
|
+
'local-only, so a revoked peer may persist in the cohort until re-replication. The ' +
|
|
1045
|
+
'on-growth re-issue is best-effort (a physical delete cannot be replayed without a ' +
|
|
1046
|
+
'schema tombstone — see control-delete-while-alone-tombstone).', peerId);
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
log('authorizePeer(%s) committed while alone (0 control connections); queued for ' +
|
|
1050
|
+
're-replication on cohort growth', peerId);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Drain the write-while-alone re-replication queue: re-issue the writes that
|
|
1055
|
+
* committed local-only now that the cohort can broadcast them. Public so the
|
|
1056
|
+
* 0→≥1 growth trigger ({@link handleControlConnectionChange}) and tests can drive
|
|
1057
|
+
* it; concurrent calls collapse into one in-flight drain
|
|
1058
|
+
* ({@link drainControlReplicationInFlight}) so two growth signals never
|
|
1059
|
+
* double-issue. Best-effort throughout — a per-row failure leaves that entry
|
|
1060
|
+
* queued for the next growth rather than aborting the drain.
|
|
1061
|
+
*/
|
|
1062
|
+
async drainPendingControlReplication(reason) {
|
|
1063
|
+
if (this.drainControlReplicationInFlight) {
|
|
1064
|
+
return this.drainControlReplicationInFlight;
|
|
1065
|
+
}
|
|
1066
|
+
const op = this.runDrainControlReplication(reason);
|
|
1067
|
+
this.drainControlReplicationInFlight = op;
|
|
1068
|
+
try {
|
|
1069
|
+
await op;
|
|
1070
|
+
}
|
|
1071
|
+
finally {
|
|
1072
|
+
this.drainControlReplicationInFlight = null;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
/** Body of {@link drainPendingControlReplication}; serialised by its single-flight guard. */
|
|
1076
|
+
async runDrainControlReplication(reason) {
|
|
1077
|
+
// Shutdown / not-yet-started guard (mirrors runReconcileControlCohort).
|
|
1078
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const firstGrowth = !this.reconstructedLocalOnlyWrites;
|
|
1082
|
+
log('Draining control re-replication (reason=%s, firstGrowth=%s, pendingPeers=%d, self=%s, deviceToken=%s)', reason, firstGrowth, this.pendingPeerWrites.size, this.pendingSelfPeerWrite, this.pendingSelfDeviceWrite);
|
|
1083
|
+
// 1. Self rows. On the first growth always re-touch (covers a self row carried
|
|
1084
|
+
// over from a prior process that committed local-only before this start);
|
|
1085
|
+
// afterwards only when a self-write-while-alone was recorded this session.
|
|
1086
|
+
// registerSelf is idempotent + single-flight, so this never races a
|
|
1087
|
+
// duplicate publish with the heartbeat republish.
|
|
1088
|
+
if (firstGrowth || this.pendingSelfPeerWrite) {
|
|
1089
|
+
await this.registerSelf().catch((error) => log('drain: registerSelf failed: %o', error));
|
|
1090
|
+
this.pendingSelfPeerWrite = false;
|
|
1091
|
+
}
|
|
1092
|
+
if (firstGrowth || this.pendingSelfDeviceWrite) {
|
|
1093
|
+
await this.retouchSelfDeviceToken();
|
|
1094
|
+
this.pendingSelfDeviceWrite = false;
|
|
1095
|
+
}
|
|
1096
|
+
// 2. First growth: an owner reconstructs membership rows it may have
|
|
1097
|
+
// authored that never replicated (covers writes from before this process
|
|
1098
|
+
// started). Rows already tracked in the in-memory queue are handled by
|
|
1099
|
+
// step 3 — skipped here to avoid a double re-touch.
|
|
1100
|
+
if (firstGrowth) {
|
|
1101
|
+
this.reconstructedLocalOnlyWrites = true;
|
|
1102
|
+
await this.reconstructAuthoredMembership();
|
|
1103
|
+
}
|
|
1104
|
+
// 3. In-session pending owner membership writes (authorize / remove).
|
|
1105
|
+
await this.drainPendingPeerWrites();
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Re-touch this node's own `DeviceToken` row (re-sign + bump `UpdatedAt` via
|
|
1109
|
+
* {@link registerDeviceToken}) so a self device-token that committed local-only
|
|
1110
|
+
* re-broadcasts on cohort growth. No-op when no row exists (nothing to
|
|
1111
|
+
* re-replicate) or the stored platform is unknown. Best-effort — never throws to
|
|
1112
|
+
* the drain.
|
|
1113
|
+
*/
|
|
1114
|
+
async retouchSelfDeviceToken() {
|
|
1115
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
250
1116
|
return;
|
|
251
1117
|
}
|
|
252
1118
|
const peerId = this.controlNode.peerId.toString();
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
1119
|
+
try {
|
|
1120
|
+
const existing = await this.controlDatabase.queryDeviceToken(peerId);
|
|
1121
|
+
if (!existing || !isPushPlatform(existing.platform)) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
await this.registerDeviceToken(existing.platform, existing.token);
|
|
1125
|
+
}
|
|
1126
|
+
catch (error) {
|
|
1127
|
+
log('drain: retouch self DeviceToken failed: %o', error);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* One-shot, first-cohort-growth reconstruction of the write-while-alone queue for
|
|
1132
|
+
* an OWNER node: re-touch every membership row that may be an unreplicated
|
|
1133
|
+
* owner insert. A row is a candidate iff it is not self (handled by
|
|
1134
|
+
* {@link registerSelf}), is not already tracked in the in-memory queue (handled by
|
|
1135
|
+
* {@link drainPendingPeerWrites}), and carries no self-`Sig` yet (an
|
|
1136
|
+
* owner-authored row the peer has not self-published — the only kind safe to
|
|
1137
|
+
* bump without invalidating a self-signature). O(rows) on the small control
|
|
1138
|
+
* tables; safe to over-apply (a monotonic owner bump on an already-replicated
|
|
1139
|
+
* row is a no-op-equivalent).
|
|
1140
|
+
*
|
|
1141
|
+
* A node with no owner private key skips this entirely — it cannot re-sign
|
|
1142
|
+
* rows for other peers, and rows it merely holds are not its to re-issue. DELETEs
|
|
1143
|
+
* cannot be reconstructed here (a removed row leaves no local trace and the schema
|
|
1144
|
+
* carries no tombstone) — see the delete-path note in {@link drainPendingPeerWrites}.
|
|
1145
|
+
*/
|
|
1146
|
+
async reconstructAuthoredMembership() {
|
|
1147
|
+
if (!this.controlDatabase || !this.controlNode) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const selfPeerId = this.controlNode.peerId.toString();
|
|
1154
|
+
const members = await this.controlDatabase.queryCadrePeers();
|
|
1155
|
+
let touched = 0;
|
|
1156
|
+
for (const member of members) {
|
|
1157
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
if (member.peerId === selfPeerId || this.pendingPeerWrites.has(member.peerId)) {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
const record = await this.controlDatabase.queryPeerRecord(member.peerId);
|
|
1165
|
+
// Missing (raced a delete) or peer-owned (self-Sig present) → not ours to bump.
|
|
1166
|
+
if (!record || record.sig) {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
await this.reissuePeerAuthorize(member.peerId, record.updatedAt);
|
|
1170
|
+
touched++;
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
log('drain: reconstruction re-touch of %s failed (continuing): %o', member.peerId, error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (touched > 0) {
|
|
1177
|
+
log('drain: reconstructed %d owner-authored membership row(s) on first cohort growth', touched);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Drain the in-session write-while-alone queue: re-issue each pending owner
|
|
1182
|
+
* membership write now that the cohort can broadcast it. Sequential (no fan-out)
|
|
1183
|
+
* to avoid a thundering re-touch; an entry is cleared only on success, so a
|
|
1184
|
+
* failure (or a still-alone re-commit) leaves it queued for the next growth.
|
|
1185
|
+
*
|
|
1186
|
+
* - `authorize`: re-issue as an idempotent monotonic owner UPDATE
|
|
1187
|
+
* ({@link reissuePeerAuthorize}). Skipped (and cleared) if the row vanished
|
|
1188
|
+
* (raced a delete) or now carries a self-`Sig` (the peer self-published and owns
|
|
1189
|
+
* its republish).
|
|
1190
|
+
* - `remove` (security-relevant): a removePeer-while-alone physically removed the
|
|
1191
|
+
* row locally, so a re-issued DELETE matches nothing and does NOT propagate —
|
|
1192
|
+
* full delete durability requires a schema tombstone (deferred:
|
|
1193
|
+
* `control-delete-while-alone-tombstone`). We still attempt the re-issue
|
|
1194
|
+
* (harmless, and it propagates in the row-still-present case); the commit-alone
|
|
1195
|
+
* was already logged loudly in {@link noteControlWrite}.
|
|
1196
|
+
*/
|
|
1197
|
+
async drainPendingPeerWrites() {
|
|
1198
|
+
if (this.pendingPeerWrites.size === 0) {
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
1202
|
+
// A non-owner node cannot have authored these writes; drop any stray entries.
|
|
1203
|
+
this.pendingPeerWrites.clear();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
for (const [peerId, kind] of [...this.pendingPeerWrites]) {
|
|
1207
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
try {
|
|
1211
|
+
if (kind === 'authorize') {
|
|
1212
|
+
const record = await this.controlDatabase.queryPeerRecord(peerId);
|
|
1213
|
+
if (!record || record.sig) {
|
|
1214
|
+
// Removed since, or peer self-published — nothing for the owner to re-issue.
|
|
1215
|
+
this.pendingPeerWrites.delete(peerId);
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
await this.reissuePeerAuthorize(peerId, record.updatedAt);
|
|
1219
|
+
}
|
|
1220
|
+
else {
|
|
1221
|
+
// Best-effort delete re-issue (see method doc): propagates only if the row
|
|
1222
|
+
// is somehow still present locally.
|
|
1223
|
+
await this.seedBootstrapService.removePeer(peerId);
|
|
1224
|
+
}
|
|
1225
|
+
this.pendingPeerWrites.delete(peerId);
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
log('drain: re-issue of %s (%s) failed; leaving queued for next growth: %o', peerId, kind, error);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Re-issue an owner membership row as a monotonic owner UPDATE: bump
|
|
1234
|
+
* `UpdatedAt` strictly above the stored value (and the wall clock) and re-sign via
|
|
1235
|
+
* the owner branch of `CadrePeer.AuthorizedUpdate`. The row already exists
|
|
1236
|
+
* locally (it committed there, just local-only), so this is an UPDATE, not the
|
|
1237
|
+
* original INSERT.
|
|
1238
|
+
*/
|
|
1239
|
+
async reissuePeerAuthorize(peerId, currentUpdatedAt) {
|
|
1240
|
+
if (!this.seedBootstrapService) {
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
const updatedAt = Math.max(Date.now(), (currentUpdatedAt ?? 0) + 1);
|
|
1244
|
+
await this.seedBootstrapService.reauthorizePeer(peerId, updatedAt);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* On a control connection opening, detect the 0→≥1 transition and drain the
|
|
1248
|
+
* write-while-alone re-replication queue (single-flight; fires only on the edge,
|
|
1249
|
+
* not on every subsequent `connection:open`). Best-effort — a drain failure is
|
|
1250
|
+
* logged, not thrown.
|
|
1251
|
+
*/
|
|
1252
|
+
handleControlConnectionChange() {
|
|
1253
|
+
if (!this._running || !this.controlNode) {
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
const connected = this.controlNode.getConnections().length > 0;
|
|
1257
|
+
if (!connected || this.hasControlConnection) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
this.hasControlConnection = true;
|
|
1261
|
+
log('Control cohort grew (0 → ≥1 connection); draining write-while-alone re-replication queue');
|
|
1262
|
+
void this.drainPendingControlReplication('connection:open')
|
|
1263
|
+
.catch((error) => log('Control re-replication drain (connection:open) failed: %o', error));
|
|
1264
|
+
}
|
|
1265
|
+
/** Re-arm the growth edge once all control connections drop, so a later reconnect re-drains. */
|
|
1266
|
+
handleControlConnectionClose() {
|
|
1267
|
+
if (!this.controlNode) {
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
if (this.controlNode.getConnections().length === 0) {
|
|
1271
|
+
this.hasControlConnection = false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Wire the control-connection growth listeners that drive write-while-alone
|
|
1276
|
+
* re-replication. Wired in {@link start} (not the delayed refresh path) so no
|
|
1277
|
+
* early `connection:open` is missed; torn down in {@link stopRecordRefresh}.
|
|
1278
|
+
* Idempotent.
|
|
1279
|
+
*/
|
|
1280
|
+
wireControlConnectionListeners() {
|
|
1281
|
+
if (!this.controlNode || this.controlConnectionOpenHandler) {
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
this.controlConnectionOpenHandler = () => this.handleControlConnectionChange();
|
|
1285
|
+
this.controlConnectionCloseHandler = () => this.handleControlConnectionClose();
|
|
1286
|
+
this.controlNode.addEventListener('connection:open', this.controlConnectionOpenHandler);
|
|
1287
|
+
this.controlNode.addEventListener('connection:close', this.controlConnectionCloseHandler);
|
|
1288
|
+
// TURN-relay detection: tag/untag peers whose WebRTC ICE used a TURN candidate.
|
|
1289
|
+
this.turnConnectionOpenHandler = (evt) => this.handleTurnConnectionOpen(evt.detail);
|
|
1290
|
+
this.turnConnectionCloseHandler = (evt) => this.handleTurnConnectionClose(evt.detail);
|
|
1291
|
+
this.controlNode.addEventListener('connection:open', this.turnConnectionOpenHandler);
|
|
1292
|
+
this.controlNode.addEventListener('connection:close', this.turnConnectionCloseHandler);
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* On a control connection opening: if it is a WebRTC connection, drain the TURN
|
|
1296
|
+
* tracker for the just-settled ICE verdict and, when it relayed, mark the peer
|
|
1297
|
+
* so {@link getConnectionPaths} classifies it `webrtc-turn` (relayed). The
|
|
1298
|
+
* settlement↔open correlation is timing-based and best-effort; an unknown
|
|
1299
|
+
* verdict degrades to not-relayed. Never throws to the event loop.
|
|
1300
|
+
*/
|
|
1301
|
+
handleTurnConnectionOpen(conn) {
|
|
1302
|
+
try {
|
|
1303
|
+
const addr = conn.remoteAddr?.toString() ?? '';
|
|
1304
|
+
// Only a genuine `/webrtc` (ICE) session can be TURN-relayed and promoted to
|
|
1305
|
+
// webrtc-turn. Use the classifier (which checks `/webrtc-direct` first) rather
|
|
1306
|
+
// than a bare substring — `'/webrtc-direct'.includes('/webrtc')` is true, and a
|
|
1307
|
+
// webrtc-direct open must not drain a settlement meant for a real webrtc dial.
|
|
1308
|
+
if (classifyTransport(addr).transport !== 'webrtc') {
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (this.turnTracker.consume(TURN_CONSUME_WINDOW_MS) === true) {
|
|
1312
|
+
const peerId = conn.remotePeer.toString();
|
|
1313
|
+
this.turnRelayedPeers.add(peerId);
|
|
1314
|
+
log('TURN-relayed WebRTC connection detected for peer %s', peerId);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
catch (error) {
|
|
1318
|
+
log('handleTurnConnectionOpen failed: %o', error);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/** On a control connection closing, drop any TURN-relayed flag for its peer. */
|
|
1322
|
+
handleTurnConnectionClose(conn) {
|
|
1323
|
+
try {
|
|
1324
|
+
this.turnRelayedPeers.delete(conn.remotePeer.toString());
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) {
|
|
1327
|
+
log('handleTurnConnectionClose failed: %o', error);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
// ============================================================================
|
|
1331
|
+
// Device-token registry (control-network push-token publish + resolve)
|
|
1332
|
+
//
|
|
1333
|
+
// The control network is the only network a hibernating peer keeps connected,
|
|
1334
|
+
// so it is where a mobile peer self-publishes its FCM/APNs push token and where
|
|
1335
|
+
// a server peer resolves it to deliver a push-wake to a suspended app. The write
|
|
1336
|
+
// + gate paths mirror registerSelf / resolvePeerAddrs (see DeviceToken in
|
|
1337
|
+
// control-schema.ts and device-token.ts).
|
|
1338
|
+
// ============================================================================
|
|
1339
|
+
/**
|
|
1340
|
+
* Publish (or refresh) this node's own self-signed `DeviceToken` row so a server
|
|
1341
|
+
* peer can resolve its FCM/APNs push token from its PeerId alone. Mirrors
|
|
1342
|
+
* {@link registerSelf}:
|
|
1343
|
+
*
|
|
1344
|
+
* - If the row already exists: a self-signed UPDATE bumping `UpdatedAt` (works
|
|
1345
|
+
* for any member — the `AuthorizedUpdate` self-branch verifies the new `Sig`
|
|
1346
|
+
* against the bound `CadrePeer.PublicKey`). Platform/Token may change here
|
|
1347
|
+
* (rotation / platform switch / reinstall are all normal self-updates).
|
|
1348
|
+
* - If not, and the node holds an owner service: an owner-signed INSERT
|
|
1349
|
+
* that also carries the self-signature.
|
|
1350
|
+
* - Otherwise: throws. Like `CadrePeer`, the first `DeviceToken` row requires an
|
|
1351
|
+
* owner signature; a non-owner peer (e.g. a phone) must have its row
|
|
1352
|
+
* seeded by an owner — typically the server it enrolled with — before it
|
|
1353
|
+
* can self-refresh. (Establishing that phone→server registration handshake is
|
|
1354
|
+
* the downstream "RN registration" ticket; this node only owns the cadre-core
|
|
1355
|
+
* write path.)
|
|
1356
|
+
*
|
|
1357
|
+
* `UpdatedAt` strictly increases on every publish (even a same-millisecond
|
|
1358
|
+
* re-publish), so a replayed older record is rejected by the schema.
|
|
1359
|
+
*
|
|
1360
|
+
* @param platform - `'fcm'` (Android/Firebase) or `'apns'` (Apple).
|
|
1361
|
+
* @param token - the opaque platform device/registration token.
|
|
1362
|
+
* @throws if the node is not started, exposes no self-signing key, or has no
|
|
1363
|
+
* existing row and no owner service to self-insert.
|
|
1364
|
+
*/
|
|
1365
|
+
async registerDeviceToken(platform, token) {
|
|
1366
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
1367
|
+
throw new Error('CadreNode must be started before registering a device token');
|
|
1368
|
+
}
|
|
1369
|
+
const signingKey = this.getSelfSigningKey();
|
|
1370
|
+
if (!signingKey) {
|
|
1371
|
+
throw new Error('Cannot register device token: no self-signing key available ' +
|
|
1372
|
+
'(node identity unavailable or not matching the node PeerId).');
|
|
1373
|
+
}
|
|
1374
|
+
const peerId = this.controlNode.peerId.toString();
|
|
1375
|
+
const existing = await this.controlDatabase.queryDeviceToken(peerId);
|
|
1376
|
+
// Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
|
|
1377
|
+
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
1378
|
+
const record = signDeviceTokenRecord({ peerId, platform, token, updatedAt }, signingKey.privateKeyB64);
|
|
1379
|
+
if (existing) {
|
|
1380
|
+
await this.controlDatabase.updateSelfDeviceToken(record);
|
|
1381
|
+
if (this.committedAlone())
|
|
1382
|
+
this.pendingSelfDeviceWrite = true;
|
|
1383
|
+
log('registerDeviceToken: refreshed own DeviceToken (platform=%s, updatedAt=%d)', platform, updatedAt);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (this.seedBootstrapService) {
|
|
1387
|
+
await this.seedBootstrapService.insertSelfDeviceToken(record);
|
|
1388
|
+
if (this.committedAlone())
|
|
1389
|
+
this.pendingSelfDeviceWrite = true;
|
|
1390
|
+
log('registerDeviceToken: inserted own DeviceToken (owner-signed, platform=%s, updatedAt=%d)', platform, updatedAt);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
throw new Error(`Cannot register device token for ${peerId}: no existing row to self-update and no ` +
|
|
1394
|
+
'owner service to self-insert. An owner must seed this peer\'s DeviceToken ' +
|
|
1395
|
+
'row first (mirrors CadrePeer enrollment).');
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Resolve a cadre peer's FCM/APNs push token from only its PeerId — the input a
|
|
1399
|
+
* server's push-wake fan-out consumes to deliver a platform push to a suspended
|
|
1400
|
+
* app. Applies the same gating shape as {@link resolvePeerAddrs}, returning
|
|
1401
|
+
* `null` (never throwing) on any failure:
|
|
1402
|
+
*
|
|
1403
|
+
* 1. membership — the peer has a `CadrePeer` row with a `PublicKey`,
|
|
1404
|
+
* 2. `publicKey <-> peerId` binding — the stored key's libp2p identity is the
|
|
1405
|
+
* requested peerId,
|
|
1406
|
+
* 3. a `DeviceToken` row exists with a known {@link PushPlatform},
|
|
1407
|
+
* 4. self-signature verifies against the bound `CadrePeer.PublicKey`,
|
|
1408
|
+
* 5. freshness — `updatedAt` is positive and within `opts.maxAgeMs` (default:
|
|
1409
|
+
* no ceiling, since a push token is valid until it rotates).
|
|
1410
|
+
*
|
|
1411
|
+
* A peer that is not a current member, or whose token has no backing `CadrePeer`
|
|
1412
|
+
* record, resolves to `null` — a server must not attempt to push to a non-cadre
|
|
1413
|
+
* peer.
|
|
1414
|
+
*/
|
|
1415
|
+
async resolveDeviceToken(peerId, opts = {}) {
|
|
1416
|
+
if (!this.controlDatabase) {
|
|
1417
|
+
throw new Error('CadreNode must be started before resolving a device token');
|
|
1418
|
+
}
|
|
1419
|
+
// Membership + publicKey<->peerId binding: read the peer's CadrePeer row and
|
|
1420
|
+
// confirm the stored key is the one embedded in the requested Ed25519 peer id.
|
|
1421
|
+
const peerRecord = await this.controlDatabase.queryPeerRecord(peerId);
|
|
1422
|
+
if (!peerRecord || !peerRecord.publicKey) {
|
|
1423
|
+
log('resolveDeviceToken: no CadrePeer/PublicKey for %s', peerId);
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
if (ed25519PublicKeyB64FromPeerId(peerId) !== peerRecord.publicKey) {
|
|
1427
|
+
log('resolveDeviceToken: publicKey does not match peerId for %s', peerId);
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
const record = await this.controlDatabase.queryDeviceToken(peerId);
|
|
1431
|
+
if (!record) {
|
|
1432
|
+
log('resolveDeviceToken: no device token for %s', peerId);
|
|
1433
|
+
return null;
|
|
1434
|
+
}
|
|
1435
|
+
if (!isPushPlatform(record.platform)) {
|
|
1436
|
+
log('resolveDeviceToken: unknown platform %s for %s', record.platform, peerId);
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
// Self-signature over (peerId, platform, token, updatedAt), verified against the
|
|
1440
|
+
// CadrePeer.PublicKey bound to this peerId.
|
|
1441
|
+
if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
|
|
1442
|
+
log('resolveDeviceToken: signature verification failed for %s', peerId);
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
// Freshness: a positive stamp, optionally bounded. No ceiling by default — a
|
|
1446
|
+
// suspended phone's token must stay resolvable for push-wake long after publish.
|
|
1447
|
+
const maxAgeMs = opts.maxAgeMs ?? Number.POSITIVE_INFINITY;
|
|
1448
|
+
if (!isPeerRecordFresh(record.updatedAt, maxAgeMs, Date.now())) {
|
|
1449
|
+
log('resolveDeviceToken: record for %s is stale (updatedAt=%d, maxAgeMs=%d)', peerId, record.updatedAt, maxAgeMs);
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
return record;
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Delete this node's own `DeviceToken` row (logout / token invalidation). No-op
|
|
1456
|
+
* when no row exists. Like {@link registerDeviceToken}'s first insert, the delete
|
|
1457
|
+
* is gated on an owner signature (`DeviceToken.AuthorizedInsert` covers insert
|
|
1458
|
+
* AND delete), so it requires this node's owner service; a non-owner peer
|
|
1459
|
+
* must route the clear through its owner (downstream RN registration path).
|
|
1460
|
+
*
|
|
1461
|
+
* @throws if the node is not started, or a row exists but no owner service is
|
|
1462
|
+
* available to sign the delete.
|
|
1463
|
+
*/
|
|
1464
|
+
async clearDeviceToken() {
|
|
1465
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
1466
|
+
throw new Error('CadreNode must be started before clearing a device token');
|
|
1467
|
+
}
|
|
1468
|
+
const peerId = this.controlNode.peerId.toString();
|
|
1469
|
+
const existing = await this.controlDatabase.queryDeviceToken(peerId);
|
|
1470
|
+
if (!existing) {
|
|
1471
|
+
log('clearDeviceToken: no DeviceToken row for %s; no-op', peerId);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
if (!this.seedBootstrapService) {
|
|
1475
|
+
throw new Error(`Cannot clear device token for ${peerId}: delete requires an owner signature ` +
|
|
1476
|
+
'and no owner service is initialized.');
|
|
1477
|
+
}
|
|
1478
|
+
await this.seedBootstrapService.deleteDeviceToken(peerId);
|
|
1479
|
+
log('clearDeviceToken: deleted DeviceToken for %s', peerId);
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Expire ANOTHER peer's stale `DeviceToken` after a platform reported it
|
|
1483
|
+
* unregistered during a push-wake fan-out. Unlike {@link clearDeviceToken}
|
|
1484
|
+
* (self-only — it hardcodes the local peerId), this takes an arbitrary peerId.
|
|
1485
|
+
*
|
|
1486
|
+
* - When this node holds an owner seed service, it deletes the row
|
|
1487
|
+
* (`deleteDeviceToken` is owner-gated and accepts any peerId), so the peer
|
|
1488
|
+
* is not retried until it re-registers.
|
|
1489
|
+
* - When this node is NOT an owner it cannot delete the row, so it only logs
|
|
1490
|
+
* that a re-registration is needed. The fan-out's own in-memory dead-token set
|
|
1491
|
+
* is what actually stops re-pushing to the dead token this process — see
|
|
1492
|
+
* {@link PushFanoutService}. That set is acceptably lossy across restarts (a
|
|
1493
|
+
* restart re-learns staleness on the next failed send).
|
|
1494
|
+
*
|
|
1495
|
+
* Best-effort: never throws to the (best-effort) fan-out caller.
|
|
1496
|
+
*/
|
|
1497
|
+
async expireDeviceToken(peerId) {
|
|
1498
|
+
if (this.seedBootstrapService) {
|
|
1499
|
+
try {
|
|
1500
|
+
await this.seedBootstrapService.deleteDeviceToken(peerId);
|
|
1501
|
+
log('expireDeviceToken: owner-deleted stale DeviceToken for %s', peerId);
|
|
1502
|
+
}
|
|
1503
|
+
catch (error) {
|
|
1504
|
+
log('expireDeviceToken: owner delete for %s failed: %o', peerId, error);
|
|
1505
|
+
}
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
log('expireDeviceToken: %s token is stale but this node is not an owner; re-registration required', peerId);
|
|
271
1509
|
}
|
|
272
1510
|
async handleStrandAdded(strand) {
|
|
273
1511
|
log('Handling strand added from control network: %s', strand.Id);
|
|
274
1512
|
// Check if we have sApp config for this strand
|
|
275
1513
|
const sAppConfig = this.sAppConfigs.get(strand.Id);
|
|
276
1514
|
if (!sAppConfig) {
|
|
277
|
-
log('No sAppConfig registered for strand %s -
|
|
278
|
-
// Strand
|
|
279
|
-
//
|
|
1515
|
+
log('No sAppConfig registered for strand %s - emitting strand:discovered', strand.Id);
|
|
1516
|
+
// Strand created by another member and not yet configured locally. Surface
|
|
1517
|
+
// it as a discovery event so the hosting app can decide whether to join
|
|
1518
|
+
// (register a config + addStrand); the strand-agnostic seam keeps this
|
|
1519
|
+
// class free of any app's join policy. A self-configured strand (config
|
|
1520
|
+
// already present) keeps auto-starting below, unchanged.
|
|
1521
|
+
this.emit('strand:discovered', { strandId: strand.Id, strand });
|
|
280
1522
|
return;
|
|
281
1523
|
}
|
|
282
1524
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
sAppConfig,
|
|
286
|
-
storage: this.config.storage,
|
|
287
|
-
network: this.config.network,
|
|
288
|
-
profile: this.config.profile,
|
|
289
|
-
defaultLatencyHint: this.config.hibernation?.defaultLatencyHint ?? 'interactive',
|
|
290
|
-
privateKey: this.config.privateKey
|
|
291
|
-
});
|
|
292
|
-
// Register with hibernation manager
|
|
293
|
-
this.hibernationManager.trackStrand(instance);
|
|
294
|
-
this.emit('strand:started', { strandId: strand.Id });
|
|
1525
|
+
// Discovery path: no explicit mode — infer from cohort membership.
|
|
1526
|
+
await this.launchStrand(strand, sAppConfig);
|
|
295
1527
|
}
|
|
296
1528
|
catch (error) {
|
|
297
1529
|
log('Error starting strand %s: %o', strand.Id, error);
|
|
@@ -320,6 +1552,16 @@ export class CadreNode {
|
|
|
320
1552
|
}
|
|
321
1553
|
}
|
|
322
1554
|
async cleanup() {
|
|
1555
|
+
// Resolve + clear any in-flight wake windows first, so an in-flight check-in
|
|
1556
|
+
// or serviceWake unblocks and tears down cleanly rather than firing a stale
|
|
1557
|
+
// window timer (or hanging) after the strand manager is stopped below.
|
|
1558
|
+
this.clearWindowWaiters();
|
|
1559
|
+
// Stop self-record refresh timers + address-change listener (before the
|
|
1560
|
+
// control node is torn down, so removeEventListener has a live target).
|
|
1561
|
+
this.stopRecordRefresh();
|
|
1562
|
+
// Restore the wrapped globalThis.RTCPeerConnection and drop TURN-relay state.
|
|
1563
|
+
this.turnTracker.dispose();
|
|
1564
|
+
this.turnRelayedPeers.clear();
|
|
323
1565
|
// Stop hibernation manager
|
|
324
1566
|
this.hibernationManager.stop();
|
|
325
1567
|
// Stop seed bootstrap service
|
|
@@ -327,6 +1569,22 @@ export class CadreNode {
|
|
|
327
1569
|
await this.seedBootstrapService.shutdown();
|
|
328
1570
|
this.seedBootstrapService = null;
|
|
329
1571
|
}
|
|
1572
|
+
// Stop strand wake service (unregister the WAKE_PROTOCOL handler)
|
|
1573
|
+
if (this.strandWakeService) {
|
|
1574
|
+
await this.strandWakeService.shutdown();
|
|
1575
|
+
this.strandWakeService = null;
|
|
1576
|
+
}
|
|
1577
|
+
// Stop strand-addr service (unregister the STRAND_ADDR_PROTOCOL handler so a
|
|
1578
|
+
// restart does not hit DuplicateProtocolHandlerError).
|
|
1579
|
+
if (this.strandAddrService) {
|
|
1580
|
+
await this.strandAddrService.shutdown();
|
|
1581
|
+
this.strandAddrService = null;
|
|
1582
|
+
}
|
|
1583
|
+
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
1584
|
+
if (this.pushFanoutService) {
|
|
1585
|
+
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
1586
|
+
this.pushFanoutService = null;
|
|
1587
|
+
}
|
|
330
1588
|
// Unregister strand solicitation service
|
|
331
1589
|
if (this.strandSolicitationService && this.controlNode) {
|
|
332
1590
|
this.strandSolicitationService.unregisterResponder(this.controlNode);
|
|
@@ -361,57 +1619,373 @@ export class CadreNode {
|
|
|
361
1619
|
this.emit('strand:idle', { strandId });
|
|
362
1620
|
}
|
|
363
1621
|
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Hibernate a strand: release its strand-network resources via the strand
|
|
1624
|
+
* manager (stop the libp2p node, close the StrandDatabase) and mark it
|
|
1625
|
+
* `hibernating`. A quiesced strand holds no open strand-network connections,
|
|
1626
|
+
* transports, or DB handles. No-ops if the strand is missing; if already
|
|
1627
|
+
* quiesced (defensive), just marks status and emits.
|
|
1628
|
+
*/
|
|
364
1629
|
async handleStrandHibernate(strandId) {
|
|
365
1630
|
const instance = this.strandManager.getInstance(strandId);
|
|
366
|
-
if (instance) {
|
|
1631
|
+
if (!instance) {
|
|
1632
|
+
log('handleStrandHibernate: strand %s not found', strandId);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if (!instance.libp2pNode && !instance.database) {
|
|
1636
|
+
// Already quiesced — only the status flag needs updating.
|
|
367
1637
|
instance.status = 'hibernating';
|
|
368
|
-
|
|
369
|
-
// For now we keep it connected but could stop it here
|
|
370
|
-
log('Strand %s transitioned to hibernating', strandId);
|
|
1638
|
+
log('Strand %s already quiesced; marked hibernating', strandId);
|
|
371
1639
|
this.emit('strand:hibernating', { strandId });
|
|
1640
|
+
return;
|
|
372
1641
|
}
|
|
1642
|
+
log('Hibernating strand %s — releasing strand-network resources', strandId);
|
|
1643
|
+
await this.strandManager.quiesceStrand(strandId);
|
|
1644
|
+
instance.status = 'hibernating';
|
|
1645
|
+
this.emit('strand:hibernating', { strandId });
|
|
1646
|
+
log('Strand %s hibernating (resources released)', strandId);
|
|
373
1647
|
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Wake a strand. If it was hibernating (quiesced — no libp2p node), re-resolve
|
|
1650
|
+
* the cohort discovery seed and mode exactly as `launchStrand` does and rebuild
|
|
1651
|
+
* its runtime via the strand manager. If it is still live (e.g. waking an idle
|
|
1652
|
+
* strand, which retains its resources), just flip the status. Overlapping wake
|
|
1653
|
+
* triggers are coalesced upstream by `HibernationManager`, so this runs once
|
|
1654
|
+
* per wake; `resumeStrand` is itself idempotent as a backstop.
|
|
1655
|
+
*/
|
|
374
1656
|
async handleStrandWake(strandId) {
|
|
375
1657
|
const instance = this.strandManager.getInstance(strandId);
|
|
376
|
-
if (instance) {
|
|
1658
|
+
if (!instance) {
|
|
1659
|
+
log('handleStrandWake: strand %s not found', strandId);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
// Still live (idle wake, or defensive double-wake): no rebuild needed.
|
|
1663
|
+
if (instance.libp2pNode || instance.database) {
|
|
377
1664
|
instance.status = 'active';
|
|
378
1665
|
instance.lastActivity = new Date();
|
|
379
|
-
log('Strand %s woke
|
|
1666
|
+
log('Strand %s woke (already live)', strandId);
|
|
380
1667
|
this.emit('strand:waking', { strandId });
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
// Quiesced: re-resolve volatile cohort inputs (the seed may have grown, the
|
|
1671
|
+
// mode may have shifted bootstrap → networked) and rebuild the runtime.
|
|
1672
|
+
log('Waking strand %s — rebuilding strand-network resources', strandId);
|
|
1673
|
+
await this.resumeStrandRuntime(strandId);
|
|
1674
|
+
instance.lastActivity = new Date();
|
|
1675
|
+
this.emit('strand:waking', { strandId });
|
|
1676
|
+
log('Strand %s awake (resources rebuilt)', strandId);
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Rebuild a quiesced strand's runtime, re-resolving the volatile cohort inputs
|
|
1680
|
+
* first: the discovery seed may have grown and cohort membership may have
|
|
1681
|
+
* shifted the mode `bootstrap → networked` since the strand last ran. Shared by
|
|
1682
|
+
* the wake (`handleStrandWake`) and check-in (`handleStrandCheckIn`) paths so
|
|
1683
|
+
* both apply the same fresh seed/mode resolution. `resumeStrand` is idempotent
|
|
1684
|
+
* (returns the live instance unchanged) as a backstop against double-resume.
|
|
1685
|
+
*/
|
|
1686
|
+
async resumeStrandRuntime(strandId) {
|
|
1687
|
+
const seed = await this.resolveCohortSeed(strandId);
|
|
1688
|
+
const mode = selectStrandMode(undefined, seed.hasOtherPeers);
|
|
1689
|
+
await this.strandManager.resumeStrand(strandId, {
|
|
1690
|
+
bootstrapNodes: seed.bootstrapNodes,
|
|
1691
|
+
mode
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Real cohort check-in for a hibernating strand (the `onCheckIn` callback).
|
|
1696
|
+
*
|
|
1697
|
+
* Optimystic syncs pull-on-read, not on connect, and exposes no cheap
|
|
1698
|
+
* repo-level "pull pending" hook (`IRepo` is get/pend/commit/cancel only —
|
|
1699
|
+
* see the review handoff). So "query the cohort for pending activity" is
|
|
1700
|
+
* realized as a resume → bounded window → re-hibernate-if-idle cycle that
|
|
1701
|
+
* reuses the existing quiesce/resume primitives rather than a bespoke probe:
|
|
1702
|
+
*
|
|
1703
|
+
* 1. Resume the strand (rebuild node + db, re-resolve cohort seed/mode) so
|
|
1704
|
+
* its strand network can reach cohort peers — exactly as a wake does.
|
|
1705
|
+
* 2. Hold it resumed for a bounded window, during which the app may drive
|
|
1706
|
+
* reads (pull-on-read) and record activity.
|
|
1707
|
+
* 3. If activity was recorded during the window, leave the strand `active`
|
|
1708
|
+
* (the idle/hibernate timers + backoff reset take over). Otherwise
|
|
1709
|
+
* quiesce again and leave it `hibernating`, so `HibernationManager`
|
|
1710
|
+
* schedules the next, longer-delayed check-in.
|
|
1711
|
+
*
|
|
1712
|
+
* No-ops unless the strand is currently `hibernating` — a concurrent wake may
|
|
1713
|
+
* have already resumed it.
|
|
1714
|
+
*/
|
|
1715
|
+
async handleStrandCheckIn(strandId) {
|
|
1716
|
+
const instance = this.strandManager.getInstance(strandId);
|
|
1717
|
+
if (!instance) {
|
|
1718
|
+
log('handleStrandCheckIn: strand %s not found', strandId);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
if (instance.status !== 'hibernating') {
|
|
1722
|
+
log('handleStrandCheckIn: strand %s not hibernating (status=%s); skipping', strandId, instance.status);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
try {
|
|
1726
|
+
// 1. Resume exactly as a wake does: re-resolve the (possibly grown) cohort
|
|
1727
|
+
// seed + mode, then rebuild the runtime.
|
|
1728
|
+
log('Check-in: resuming strand %s to probe the cohort for pending activity', strandId);
|
|
1729
|
+
await this.resumeStrandRuntime(strandId);
|
|
1730
|
+
// 2-3. Bounded window for the strand network to connect + the app to act,
|
|
1731
|
+
// then re-hibernate-if-idle. Shared with the on-demand serviceWake.
|
|
1732
|
+
const windowMs = this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
|
|
1733
|
+
await this.runWakeWindow(instance, windowMs);
|
|
1734
|
+
}
|
|
1735
|
+
catch (err) {
|
|
1736
|
+
// A check-in that throws part-way — resume failing on a flaky network (the
|
|
1737
|
+
// very scenario hibernation targets), the window rejecting, or quiesce
|
|
1738
|
+
// throwing — must leave the strand HIBERNATING, not in the `error` status
|
|
1739
|
+
// that `resumeStrand` sets on failure. `HibernationManager.runCheckIn`
|
|
1740
|
+
// decides wake-vs-escalate purely from `instance.status` after this
|
|
1741
|
+
// resolves: an `error` status reads as "woke", which STOPS the check-in
|
|
1742
|
+
// chain and strands the strand with no runtime and no future check-in.
|
|
1743
|
+
// Forcing it back to `hibernating` (after a best-effort quiesce to release
|
|
1744
|
+
// any partially-rebuilt runtime) makes the manager escalate the backoff and
|
|
1745
|
+
// retry on the next tick.
|
|
1746
|
+
log('Check-in failed for strand %s; re-hibernating to retry on backoff: %o', strandId, err);
|
|
1747
|
+
await this.strandManager.quiesceStrand(strandId).catch((cleanupErr) => {
|
|
1748
|
+
log('Check-in cleanup quiesce for strand %s failed: %o', strandId, cleanupErr);
|
|
1749
|
+
});
|
|
1750
|
+
instance.status = 'hibernating';
|
|
381
1751
|
}
|
|
382
1752
|
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Window-then-decide for a just-resumed strand, shared by the check-in timer
|
|
1755
|
+
* path ({@link handleStrandCheckIn}) and the on-demand {@link serviceWake}:
|
|
1756
|
+
*
|
|
1757
|
+
* 1. Capture the post-resume activity marker. `recordActivity` assigns a
|
|
1758
|
+
* FRESH `Date`, so a changed reference after the window means real
|
|
1759
|
+
* activity landed during it — not millisecond-resolution noise.
|
|
1760
|
+
* 2. Hold the strand live for `windowMs` so its strand network reaches the
|
|
1761
|
+
* cohort and the app can drive pull-on-read activity.
|
|
1762
|
+
* 3. If activity landed, leave the strand `active` (return `true`); otherwise
|
|
1763
|
+
* quiesce and mark it `hibernating` again (return `false`).
|
|
1764
|
+
*
|
|
1765
|
+
* @returns whether activity was observed during the window (strand left active).
|
|
1766
|
+
*/
|
|
1767
|
+
async runWakeWindow(instance, windowMs) {
|
|
1768
|
+
const strandId = instance.strandId;
|
|
1769
|
+
const activityMark = instance.lastActivity;
|
|
1770
|
+
await this.holdWakeWindow(instance, windowMs);
|
|
1771
|
+
const sawActivity = instance.lastActivity !== activityMark;
|
|
1772
|
+
if (sawActivity) {
|
|
1773
|
+
instance.status = 'active';
|
|
1774
|
+
this.emit('strand:waking', { strandId });
|
|
1775
|
+
log('Wake window: strand %s saw activity during the window; staying active', strandId);
|
|
1776
|
+
return true;
|
|
1777
|
+
}
|
|
1778
|
+
log('Wake window: no activity for strand %s; re-hibernating', strandId);
|
|
1779
|
+
await this.strandManager.quiesceStrand(strandId);
|
|
1780
|
+
instance.status = 'hibernating';
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* Hold a just-resumed strand live for `windowMs` (default
|
|
1785
|
+
* {@link DEFAULT_CHECKIN_WINDOW_MS} is applied by callers). A non-positive
|
|
1786
|
+
* window resolves immediately. The pending timer is tracked in
|
|
1787
|
+
* {@link windowWaiters} so {@link cleanup} can both clear it and resolve the
|
|
1788
|
+
* promise on teardown — a `stop()` during an in-flight window must neither fire
|
|
1789
|
+
* the timer afterward nor hang the awaiting check-in/serviceWake. Extracted as
|
|
1790
|
+
* its own method so tests can stub the wait (and inject activity during it).
|
|
1791
|
+
*/
|
|
1792
|
+
async holdWakeWindow(_instance, windowMs) {
|
|
1793
|
+
if (windowMs <= 0)
|
|
1794
|
+
return;
|
|
1795
|
+
await new Promise((resolve) => {
|
|
1796
|
+
const waiter = { timer: undefined, resolve };
|
|
1797
|
+
waiter.timer = setTimeout(() => {
|
|
1798
|
+
this.windowWaiters.delete(waiter);
|
|
1799
|
+
resolve();
|
|
1800
|
+
}, windowMs);
|
|
1801
|
+
this.windowWaiters.add(waiter);
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Clear every in-flight wake window: cancel its timer and resolve its promise
|
|
1806
|
+
* so any awaiting check-in/serviceWake completes promptly rather than hanging
|
|
1807
|
+
* past teardown. Called from {@link cleanup}.
|
|
1808
|
+
*/
|
|
1809
|
+
clearWindowWaiters() {
|
|
1810
|
+
for (const waiter of this.windowWaiters) {
|
|
1811
|
+
clearTimeout(waiter.timer);
|
|
1812
|
+
waiter.resolve();
|
|
1813
|
+
}
|
|
1814
|
+
this.windowWaiters.clear();
|
|
1815
|
+
}
|
|
383
1816
|
/**
|
|
384
1817
|
* Add a strand with its sApp configuration.
|
|
385
1818
|
* The hosting application must provide the sApp schema when creating a strand.
|
|
386
1819
|
*/
|
|
387
1820
|
async addStrand(config) {
|
|
388
|
-
if (!this.
|
|
1821
|
+
if (!this._running) {
|
|
389
1822
|
throw new Error('CadreNode not running');
|
|
390
1823
|
}
|
|
391
|
-
const { strandRow, sAppConfig, mode } = config;
|
|
1824
|
+
const { strandRow, sAppConfig, mode, founder } = config;
|
|
392
1825
|
// Store sApp config for this strand
|
|
393
1826
|
this.sAppConfigs.set(strandRow.Id, sAppConfig);
|
|
394
|
-
log('Registered sAppConfig for strand %s (sApp: %s, mode: %s)', strandRow.Id, sAppConfig.id, mode ?? '
|
|
1827
|
+
log('Registered sAppConfig for strand %s (sApp: %s, mode: %s, founder: %s)', strandRow.Id, sAppConfig.id, mode ?? 'inferred', founder ?? false);
|
|
1828
|
+
// Pass `mode` (possibly undefined) through: an explicit mode wins, while a
|
|
1829
|
+
// caller that omits it gets the same cohort-inferred mode as the discovery path.
|
|
1830
|
+
// `founder` only flows from the explicit addStrand path — the control-discovered
|
|
1831
|
+
// join path never founds, so its rows arrive via sync (see handleStrandAdded).
|
|
1832
|
+
return await this.launchStrand(strandRow, sAppConfig, mode, founder);
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Publish a strand row to the shared control database under this node's own
|
|
1836
|
+
* owner identity, so other cadre members discover it via control-network
|
|
1837
|
+
* sync (their {@link StrandWatcher} fires `strand:discovered`).
|
|
1838
|
+
*
|
|
1839
|
+
* This is the owner-signed `Strand` INSERT that {@link addStrand}
|
|
1840
|
+
* deliberately omits: `addStrand` only starts the LOCAL strand instance,
|
|
1841
|
+
* whereas publishing makes the strand visible cadre-wide. A typical creator
|
|
1842
|
+
* does both (start locally + publish); a discovering peer only does
|
|
1843
|
+
* `addStrand` (the row already exists).
|
|
1844
|
+
*
|
|
1845
|
+
* The insert is signed with the ed25519 key behind this node's PeerId — which
|
|
1846
|
+
* {@link ed25519KeyPairFromLibp2p} also exposes as the node's owner keypair,
|
|
1847
|
+
* so peer identity and owner key are one and the same. That key must be
|
|
1848
|
+
* enrolled in `OwnerKey` (e.g. via {@link ControlDatabase.ensureOwnerKey}
|
|
1849
|
+
* at genesis) or the schema's `Strand.Authorized` constraint rejects the write.
|
|
1850
|
+
* Failing loudly here is intentional: a silently-unpublished strand would run
|
|
1851
|
+
* as a local-only island that no peer could ever discover or join.
|
|
1852
|
+
*
|
|
1853
|
+
* @param strandId - Unique strand identifier (typically the same id passed to
|
|
1854
|
+
* {@link addStrand}).
|
|
1855
|
+
* @param type - `'o'` for open (default) or `'c'` for closed.
|
|
1856
|
+
* @param memberPrivateKey - Optional membership key for a closed strand.
|
|
1857
|
+
* @throws if the node is not started, exposes no owner signing key, or the
|
|
1858
|
+
* control DB rejects the (unauthorized) insert.
|
|
1859
|
+
*/
|
|
1860
|
+
async publishStrand(strandId, type = 'o', memberPrivateKey) {
|
|
1861
|
+
if (!this._running || !this.controlDatabase) {
|
|
1862
|
+
throw new Error('CadreNode must be started before publishing a strand');
|
|
1863
|
+
}
|
|
1864
|
+
const signingKey = this.getSelfSigningKey();
|
|
1865
|
+
if (!signingKey) {
|
|
1866
|
+
throw new Error(`Cannot publish strand ${strandId}: no owner signing key available ` +
|
|
1867
|
+
'(node identity is unavailable or does not match the node PeerId). Run owner ' +
|
|
1868
|
+
'genesis (ensureOwnerKey + initializeSeedBootstrap) before publishing.');
|
|
1869
|
+
}
|
|
1870
|
+
// insertStrand hands this callback the canonical row-bound message BYTES (see
|
|
1871
|
+
// buildAuthorizationMessage); ed25519-sign them directly (no pre-hash) with
|
|
1872
|
+
// the owner private key, returning a base64url signature.
|
|
1873
|
+
const signMessage = (message) => sign(message, signingKey.privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
|
|
1874
|
+
await this.controlDatabase.insertStrand(strandId, type, signingKey.publicKeyB64, signMessage, memberPrivateKey);
|
|
1875
|
+
log('Published strand %s (type %s) to control DB under owner %s', strandId, type, signingKey.publicKeyB64);
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Publish an owner-signed `FormationInvite` (open-invitation token) to the
|
|
1879
|
+
* shared control database, so a later {@link formStrand} redemption can be
|
|
1880
|
+
* validated against it (the consent branch of `Strand.Authorized`).
|
|
1881
|
+
*
|
|
1882
|
+
* Counterpart to {@link createOpenInvitation}, which only mints the
|
|
1883
|
+
* out-of-band {@link OpenInvitation} envelope: persisting the matching
|
|
1884
|
+
* `FormationInvite` row is what makes the token *redeemable* — the host's
|
|
1885
|
+
* {@link ControlFormationUsageRecorder} answers `isTokenValid`/`isTokenUsed`
|
|
1886
|
+
* from this row. A host minting a closed-strand invite does both (mint +
|
|
1887
|
+
* publish), exactly as the integration harness's `createInvitation` does.
|
|
1888
|
+
*
|
|
1889
|
+
* Signs with the same self-owner key as {@link publishStrand} (the ed25519
|
|
1890
|
+
* key behind this node's PeerId, which must be an enrolled `OwnerKey`).
|
|
1891
|
+
* Throws loudly if the node isn't started or exposes no signing key.
|
|
1892
|
+
*
|
|
1893
|
+
* @param token - Invitation token (the `FormationInvite` primary key); use the
|
|
1894
|
+
* `token` of the {@link OpenInvitation} from {@link createOpenInvitation}.
|
|
1895
|
+
* @param sAppId - The sApp a redeemed strand will use.
|
|
1896
|
+
* @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
|
|
1897
|
+
* `strandId` (bind a closed/pre-existing host strand for provision-then-record).
|
|
1898
|
+
*/
|
|
1899
|
+
async publishFormationInvite(token, sAppId, options = {}) {
|
|
1900
|
+
if (!this._running || !this.controlDatabase) {
|
|
1901
|
+
throw new Error('CadreNode must be started before publishing a formation invite');
|
|
1902
|
+
}
|
|
1903
|
+
const signingKey = this.getSelfSigningKey();
|
|
1904
|
+
if (!signingKey) {
|
|
1905
|
+
throw new Error(`Cannot publish formation invite ${token}: no owner signing key available ` +
|
|
1906
|
+
'(node identity is unavailable or does not match the node PeerId). Run owner ' +
|
|
1907
|
+
'genesis (ensureOwnerKey + initializeSeedBootstrap) before publishing.');
|
|
1908
|
+
}
|
|
1909
|
+
const signMessage = (message) => sign(message, signingKey.privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
|
|
1910
|
+
await this.controlDatabase.insertFormationInvite(token, sAppId, signingKey.publicKeyB64, signMessage, options);
|
|
1911
|
+
log('Published formation invite %s (sApp %s) under owner %s', token, sAppId, signingKey.publicKeyB64);
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Shared strand launch path for both the explicit (`addStrand`) and the
|
|
1915
|
+
* control-discovered (`handleStrandAdded`) entry points. Resolves the cohort
|
|
1916
|
+
* seed, selects the mode, starts the strand, and registers it with the
|
|
1917
|
+
* hibernation manager before emitting `strand:started`.
|
|
1918
|
+
*/
|
|
1919
|
+
async launchStrand(strand, sAppConfig, explicitMode, founder) {
|
|
1920
|
+
const seed = await this.resolveCohortSeed(strand.Id);
|
|
1921
|
+
const mode = selectStrandMode(explicitMode, seed.hasOtherPeers);
|
|
395
1922
|
const instance = await this.strandManager.startStrand({
|
|
396
|
-
strandRow,
|
|
1923
|
+
strandRow: strand,
|
|
397
1924
|
sAppConfig,
|
|
398
1925
|
storage: this.config.storage,
|
|
399
1926
|
network: this.config.network,
|
|
400
1927
|
profile: this.config.profile,
|
|
401
1928
|
defaultLatencyHint: this.config.hibernation?.defaultLatencyHint ?? 'interactive',
|
|
402
|
-
privateKey: this.
|
|
403
|
-
|
|
1929
|
+
privateKey: this.identityKey,
|
|
1930
|
+
bootstrapNodes: seed.bootstrapNodes,
|
|
1931
|
+
mode,
|
|
1932
|
+
requireSignedSchemas: this.config.requireSignedSchemas,
|
|
1933
|
+
founder
|
|
404
1934
|
});
|
|
405
|
-
// Register with hibernation manager
|
|
406
1935
|
this.hibernationManager.trackStrand(instance);
|
|
407
|
-
this.emit('strand:started', { strandId:
|
|
1936
|
+
this.emit('strand:started', { strandId: strand.Id });
|
|
408
1937
|
return instance;
|
|
409
1938
|
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Resolve a strand's discovery seed. Membership (and thus mode) comes from the
|
|
1941
|
+
* control network's CadrePeer rows; the **strand-network** bootstrap addresses
|
|
1942
|
+
* are resolved on demand over the control mesh via the strand-addr RPC —
|
|
1943
|
+
* deliberately NOT from `CadrePeer.Multiaddr`, which carries *control*
|
|
1944
|
+
* addresses that must not seed the strand mesh.
|
|
1945
|
+
*
|
|
1946
|
+
* Only siblings we already hold an open control connection to are RPC'd: they
|
|
1947
|
+
* are the ones that can answer right now, and dialing them by peerId reuses the
|
|
1948
|
+
* live connection. When no connected sibling yet runs the strand the seed is
|
|
1949
|
+
* empty (`[]`) — mode still follows membership, and the empty seed self-heals
|
|
1950
|
+
* on the next resume / check-in pass. Returns an empty seed when the control DB
|
|
1951
|
+
* or node is absent (not yet started / torn down).
|
|
1952
|
+
*/
|
|
1953
|
+
async resolveCohortSeed(strandId) {
|
|
1954
|
+
if (!this.controlDatabase || !this.controlNode) {
|
|
1955
|
+
return { bootstrapNodes: [], hasOtherPeers: false };
|
|
1956
|
+
}
|
|
1957
|
+
const peers = await this.controlDatabase.queryCadrePeers();
|
|
1958
|
+
const { otherPeerIds, hasOtherPeers } = deriveCohortMembers(peers, this.controlNode.peerId.toString());
|
|
1959
|
+
// RPC only siblings we already have a control connection to — they can answer
|
|
1960
|
+
// now, and `dialProtocol` by peerId reuses the open connection.
|
|
1961
|
+
const connected = new Set(this.controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
1962
|
+
const targets = otherPeerIds.filter((id) => connected.has(id));
|
|
1963
|
+
const bootstrapNodes = targets.length
|
|
1964
|
+
? await collectStrandAddrs(this.controlNode, targets.map((peerId) => ({ peerId })), strandId)
|
|
1965
|
+
: [];
|
|
1966
|
+
return { bootstrapNodes, hasOtherPeers };
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* The local strand instance's dialable strand-network multiaddrs for the
|
|
1970
|
+
* strand-addr RPC, ordered signaling-first (reusing the control node's
|
|
1971
|
+
* {@link orderSignalingFirst}). Returns `[]` when the strand is not running
|
|
1972
|
+
* locally or has no live libp2p node (hibernating / quiescing / never
|
|
1973
|
+
* participated) — a node only answers for a strand it is actively meshing,
|
|
1974
|
+
* regardless of the strand's mode. A `bootstrap`-mode first node still has a
|
|
1975
|
+
* live node, so it answers and a later sibling can dial in.
|
|
1976
|
+
*/
|
|
1977
|
+
getStrandMultiaddrs(strandId) {
|
|
1978
|
+
const node = this.strandManager.getInstance(strandId)?.libp2pNode;
|
|
1979
|
+
if (!node) {
|
|
1980
|
+
return [];
|
|
1981
|
+
}
|
|
1982
|
+
return orderSignalingFirst(node.getMultiaddrs().map((ma) => ma.toString()));
|
|
1983
|
+
}
|
|
410
1984
|
/**
|
|
411
1985
|
* Remove a strand
|
|
412
1986
|
*/
|
|
413
1987
|
async removeStrand(strandId) {
|
|
414
|
-
if (!this.
|
|
1988
|
+
if (!this._running) {
|
|
415
1989
|
throw new Error('CadreNode not running');
|
|
416
1990
|
}
|
|
417
1991
|
// Untrack from hibernation
|
|
@@ -422,13 +1996,34 @@ export class CadreNode {
|
|
|
422
1996
|
this.emit('strand:stopped', { strandId });
|
|
423
1997
|
}
|
|
424
1998
|
/**
|
|
425
|
-
* Record activity on a strand (resets hibernation timer)
|
|
1999
|
+
* Record activity on a strand (resets hibernation timer).
|
|
2000
|
+
*
|
|
2001
|
+
* Also drives the server push-wake fan-out: whatever already drives activity on
|
|
2002
|
+
* this node's strand (its relay/app layer doing pull-on-read) additionally wakes
|
|
2003
|
+
* hibernating mobile peers — the same imperative seam local-wake uses, with no
|
|
2004
|
+
* new contract. No-op for the fan-out when push is not configured.
|
|
426
2005
|
*/
|
|
427
2006
|
recordStrandActivity(strandId) {
|
|
428
2007
|
const instance = this.strandManager.getInstance(strandId);
|
|
429
2008
|
if (instance) {
|
|
430
2009
|
this.hibernationManager.recordActivity(instance);
|
|
431
2010
|
}
|
|
2011
|
+
this.notifyStrandActivity(strandId);
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Explicit fan-out trigger: an always-on host/relay/sApp calls this when it
|
|
2015
|
+
* observes activity for a strand this node participates in, to wake hibernating
|
|
2016
|
+
* mobile members over a direct control-network dial (falling back to FCM/APNs
|
|
2017
|
+
* for suspended phones). This is the supported, honest v1 trigger — Optimystic
|
|
2018
|
+
* exposes no passive repo-level "new transaction" hook to drive it automatically
|
|
2019
|
+
* (see the deferred passive-detector follow-up). No-op when push is not
|
|
2020
|
+
* configured; best-effort (never throws — the check-in wake is the backstop).
|
|
2021
|
+
*
|
|
2022
|
+
* @param strandId - the strand that saw activity.
|
|
2023
|
+
* @param reason - free-form cause hint carried in the wake (default `activity`).
|
|
2024
|
+
*/
|
|
2025
|
+
notifyStrandActivity(strandId, reason) {
|
|
2026
|
+
void this.pushFanoutService?.notify(strandId, reason);
|
|
432
2027
|
}
|
|
433
2028
|
/**
|
|
434
2029
|
* Force wake a hibernating strand
|
|
@@ -436,6 +2031,144 @@ export class CadreNode {
|
|
|
436
2031
|
async wakeStrand(strandId) {
|
|
437
2032
|
await this.hibernationManager.wakeStrand(strandId);
|
|
438
2033
|
}
|
|
2034
|
+
// ============================================================================
|
|
2035
|
+
// Mobile background lifecycle primitives
|
|
2036
|
+
//
|
|
2037
|
+
// Imperative control a mobile BackgroundRunner drives from OS app-state and
|
|
2038
|
+
// push events, rather than from the internal idle/hibernate/check-in timers.
|
|
2039
|
+
// ============================================================================
|
|
2040
|
+
/**
|
|
2041
|
+
* Force a single strand to hibernate immediately, bypassing the idle/hibernate
|
|
2042
|
+
* timers — the background-entry path. No-op if the strand is realtime
|
|
2043
|
+
* (never-hibernate latency hint), already hibernating, or unknown.
|
|
2044
|
+
*
|
|
2045
|
+
* Routes through {@link HibernationManager.forceHibernate}, which cancels the
|
|
2046
|
+
* strand's pending idle/hibernate (and check-in) timers — so a stale timer
|
|
2047
|
+
* can't re-fire on or resurrect the strand — then runs the same `onHibernate`
|
|
2048
|
+
* path as the timer (`quiesceStrand` + `status='hibernating'` +
|
|
2049
|
+
* `strand:hibernating`). Unlike the timer path it does NOT re-arm check-ins:
|
|
2050
|
+
* the strand stays down until the caller drives a wake (e.g. {@link serviceWake}).
|
|
2051
|
+
*/
|
|
2052
|
+
async hibernateStrand(strandId) {
|
|
2053
|
+
const instance = this.strandManager.getInstance(strandId);
|
|
2054
|
+
if (!instance) {
|
|
2055
|
+
log('hibernateStrand: strand %s unknown; no-op', strandId);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
if (!this.hibernationManager.hibernates(instance)) {
|
|
2059
|
+
log('hibernateStrand: strand %s is realtime; no-op', strandId);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (instance.status === 'hibernating') {
|
|
2063
|
+
log('hibernateStrand: strand %s already hibernating; no-op', strandId);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
await this.hibernationManager.forceHibernate(instance);
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Force-hibernate every tracked strand whose latency hint is not realtime,
|
|
2070
|
+
* tolerating per-strand failure (one strand failing to quiesce never aborts
|
|
2071
|
+
* the others). Realtime strands are left running — the caller keeps the control
|
|
2072
|
+
* connection and realtime strands alive for as long as the OS permits.
|
|
2073
|
+
*
|
|
2074
|
+
* @returns the strandIds actually hibernated (now in `hibernating` status);
|
|
2075
|
+
* realtime strands are excluded.
|
|
2076
|
+
*/
|
|
2077
|
+
async hibernateAll() {
|
|
2078
|
+
const hibernated = [];
|
|
2079
|
+
for (const [strandId, instance] of this.strandManager.getInstances()) {
|
|
2080
|
+
if (!this.hibernationManager.hibernates(instance)) {
|
|
2081
|
+
log('hibernateAll: skipping realtime strand %s', strandId);
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
try {
|
|
2085
|
+
await this.hibernateStrand(strandId);
|
|
2086
|
+
if (instance.status === 'hibernating') {
|
|
2087
|
+
hibernated.push(strandId);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
catch (error) {
|
|
2091
|
+
// Collect-and-continue: a single strand's quiesce failure must not strand
|
|
2092
|
+
// the rest of the background-entry sweep.
|
|
2093
|
+
log('hibernateAll: strand %s failed to hibernate (continuing): %o', strandId, error);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
log('hibernateAll: hibernated %d strand(s)', hibernated.length);
|
|
2097
|
+
return hibernated;
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* On-demand equivalent of a check-in cycle, for a push-delivered wake on
|
|
2101
|
+
* mobile: resume the strand, hold it live for `windowMs` so its strand network
|
|
2102
|
+
* reaches the cohort and the app can pull pending activity, then re-hibernate
|
|
2103
|
+
* if no activity was recorded (else leave it active).
|
|
2104
|
+
*
|
|
2105
|
+
* Idempotent / coalesced two ways: concurrent `serviceWake`s for the same
|
|
2106
|
+
* strand share one in-flight operation ({@link serviceWakePromises}), and the
|
|
2107
|
+
* underlying resume coalesces with a racing push-wake via
|
|
2108
|
+
* {@link HibernationManager}'s wake coalescing — one runtime build, one window,
|
|
2109
|
+
* one re-hibernate decision. Returns `{ serviced: false }` (never throws) when
|
|
2110
|
+
* the node is not running or the strand is unknown, and surfaces a resume
|
|
2111
|
+
* failure as `{ serviced: true, hadActivity: false }` after re-hibernating.
|
|
2112
|
+
*
|
|
2113
|
+
* @param strandId - the strand a push said has pending activity.
|
|
2114
|
+
* @param opts.windowMs - override the live-window duration (defaults to the
|
|
2115
|
+
* configured `checkInWindowMs` / {@link DEFAULT_CHECKIN_WINDOW_MS}).
|
|
2116
|
+
*/
|
|
2117
|
+
async serviceWake(strandId, opts) {
|
|
2118
|
+
const existing = this.serviceWakePromises.get(strandId);
|
|
2119
|
+
if (existing) {
|
|
2120
|
+
log('serviceWake: joining in-flight wake for strand %s', strandId);
|
|
2121
|
+
return existing;
|
|
2122
|
+
}
|
|
2123
|
+
const op = this.runServiceWake(strandId, opts);
|
|
2124
|
+
this.serviceWakePromises.set(strandId, op);
|
|
2125
|
+
try {
|
|
2126
|
+
return await op;
|
|
2127
|
+
}
|
|
2128
|
+
finally {
|
|
2129
|
+
this.serviceWakePromises.delete(strandId);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
/** Body of {@link serviceWake}; serialised per-strand by its coalescing guard. */
|
|
2133
|
+
async runServiceWake(strandId, opts) {
|
|
2134
|
+
// Not-running / control-absent guard (mirrors pushWake): a background task
|
|
2135
|
+
// must get a branchable result, never a throw.
|
|
2136
|
+
if (!this._running || !this.controlNode) {
|
|
2137
|
+
log('serviceWake: node not running; not serviced (strand %s)', strandId);
|
|
2138
|
+
return { strandId, serviced: false, hadActivity: false };
|
|
2139
|
+
}
|
|
2140
|
+
const instance = this.strandManager.getInstance(strandId);
|
|
2141
|
+
if (!instance) {
|
|
2142
|
+
log('serviceWake: strand %s unknown to this node; not serviced', strandId);
|
|
2143
|
+
return { strandId, serviced: false, hadActivity: false };
|
|
2144
|
+
}
|
|
2145
|
+
// Already live (active or idle — both retain their runtime): servicing is a
|
|
2146
|
+
// no-op success. Do NOT run a window that would re-hibernate a strand the app
|
|
2147
|
+
// may be actively using, and do NOT rebuild a second runtime.
|
|
2148
|
+
if (instance.libp2pNode || instance.database) {
|
|
2149
|
+
log('serviceWake: strand %s already live; no-op success', strandId);
|
|
2150
|
+
return { strandId, serviced: true, hadActivity: true };
|
|
2151
|
+
}
|
|
2152
|
+
try {
|
|
2153
|
+
// Coalesced resume: routes through wakeStrand → HibernationManager.beginWake
|
|
2154
|
+
// so a racing push-wake shares this single runtime build.
|
|
2155
|
+
await this.wakeStrand(strandId);
|
|
2156
|
+
const windowMs = opts?.windowMs ?? this.config.hibernation?.checkInWindowMs ?? DEFAULT_CHECKIN_WINDOW_MS;
|
|
2157
|
+
const hadActivity = await this.runWakeWindow(instance, windowMs);
|
|
2158
|
+
return { strandId, serviced: true, hadActivity };
|
|
2159
|
+
}
|
|
2160
|
+
catch (error) {
|
|
2161
|
+
// Resume failing mid-window (network unreachable inside a Doze grant, etc.)
|
|
2162
|
+
// must not throw out of a background task: re-hibernate and report no
|
|
2163
|
+
// activity, mirroring handleStrandCheckIn's re-hibernate-on-error.
|
|
2164
|
+
log('serviceWake: strand %s failed during wake window; re-hibernating: %o', strandId, error);
|
|
2165
|
+
await this.strandManager.quiesceStrand(strandId).catch((cleanupErr) => {
|
|
2166
|
+
log('serviceWake cleanup quiesce for strand %s failed: %o', strandId, cleanupErr);
|
|
2167
|
+
});
|
|
2168
|
+
instance.status = 'hibernating';
|
|
2169
|
+
return { strandId, serviced: true, hadActivity: false };
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
439
2172
|
/**
|
|
440
2173
|
* Get the control network node (for advanced use)
|
|
441
2174
|
*/
|
|
@@ -464,21 +2197,20 @@ export class CadreNode {
|
|
|
464
2197
|
// Seed Bootstrap API
|
|
465
2198
|
// ============================================================================
|
|
466
2199
|
/**
|
|
467
|
-
* Initialize the seed bootstrap service with an
|
|
2200
|
+
* Initialize the seed bootstrap service with an owner key.
|
|
468
2201
|
* Must be called before using seed-related methods that require signing.
|
|
469
2202
|
*
|
|
470
|
-
* @param
|
|
2203
|
+
* @param ownerPrivateKey - The owner's private key (base64url encoded)
|
|
471
2204
|
*/
|
|
472
|
-
initializeSeedBootstrap(
|
|
2205
|
+
initializeSeedBootstrap(ownerPrivateKey) {
|
|
473
2206
|
if (!this.controlNode || !this.controlDatabase) {
|
|
474
2207
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
475
2208
|
}
|
|
476
2209
|
this.seedBootstrapService = new SeedBootstrapService({
|
|
477
2210
|
partyId: this.config.controlNetwork.partyId,
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
: {}),
|
|
2211
|
+
ownerPrivateKey,
|
|
2212
|
+
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
2213
|
+
trustPolicy: this.config.seedTrustPolicy,
|
|
482
2214
|
});
|
|
483
2215
|
this.seedBootstrapService.setEventCallbacks({
|
|
484
2216
|
onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
|
|
@@ -488,10 +2220,105 @@ export class CadreNode {
|
|
|
488
2220
|
this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
|
|
489
2221
|
log('Seed bootstrap service initialized');
|
|
490
2222
|
}
|
|
2223
|
+
/**
|
|
2224
|
+
* Push the multiaddrs that future invites should advertise. Pass `null` to
|
|
2225
|
+
* revert to the libp2p-reported addresses (the default). The host calls this
|
|
2226
|
+
* at spawn and on every NAT change.
|
|
2227
|
+
*/
|
|
2228
|
+
setInviteAddresses(addresses) {
|
|
2229
|
+
this.latestInviteAddresses = addresses;
|
|
2230
|
+
log('Invite addresses updated: %s', addresses ? `${addresses.length} pushed` : 'cleared (libp2p fallback)');
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Resolve the addresses to embed in invites. Prefers pushed addresses, then
|
|
2234
|
+
* any config-supplied resolver, then the libp2p-observed multiaddrs.
|
|
2235
|
+
*/
|
|
2236
|
+
async resolveInviteAddresses() {
|
|
2237
|
+
if (this.latestInviteAddresses !== null) {
|
|
2238
|
+
return this.latestInviteAddresses;
|
|
2239
|
+
}
|
|
2240
|
+
if (this.config.network?.inviteAddressResolver) {
|
|
2241
|
+
return this.config.network.inviteAddressResolver();
|
|
2242
|
+
}
|
|
2243
|
+
return this.getMultiaddrs();
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Enumerate the cadre's `CadrePeer` membership.
|
|
2247
|
+
*/
|
|
2248
|
+
async listMembers() {
|
|
2249
|
+
if (!this.controlDatabase) {
|
|
2250
|
+
throw new Error('CadreNode must be started before listing members');
|
|
2251
|
+
}
|
|
2252
|
+
return this.controlDatabase.queryCadrePeers();
|
|
2253
|
+
}
|
|
2254
|
+
/**
|
|
2255
|
+
* Probe whether a given peer is a `CadrePeer` member.
|
|
2256
|
+
*
|
|
2257
|
+
* This is the ADDRESSABLE surface ("do I have a dialable address record for this
|
|
2258
|
+
* peer") — it includes this node's own self-published row. Address resolution and
|
|
2259
|
+
* push fan-out use this. The trust-facing gate is {@link isAuthorizedMember}.
|
|
2260
|
+
*/
|
|
2261
|
+
async isMember(peerId) {
|
|
2262
|
+
const members = await this.listMembers();
|
|
2263
|
+
return members.some(m => m.peerId === peerId);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Enumerate the party's AUTHORIZED members — the trust-facing set, distinct from
|
|
2267
|
+
* the addressable set ({@link listMembers}). Excludes this node itself: a node
|
|
2268
|
+
* publishes its own `CadrePeer` address row so its dialable address rides in seeds,
|
|
2269
|
+
* but "self" is not a peer this node authorized. The control-network wake and
|
|
2270
|
+
* strand-address gates consult this set, NOT the addressable one.
|
|
2271
|
+
*
|
|
2272
|
+
* NOTE: this is the addressable set minus self for now. The real trust predicate
|
|
2273
|
+
* (voucher recorded on the row ∈ node-local trusted-owner anchor, signature
|
|
2274
|
+
* verified) lands with the `membership-node-local-owner-anchor` /
|
|
2275
|
+
* `membership-authorized-predicate-and-gates` tickets; keeping the filter in this one
|
|
2276
|
+
* method means that change touches a single place.
|
|
2277
|
+
*/
|
|
2278
|
+
async listAuthorizedMembers() {
|
|
2279
|
+
const selfPeerId = this.peerId?.toString();
|
|
2280
|
+
const members = await this.listMembers();
|
|
2281
|
+
return members.filter(m => m.peerId !== selfPeerId);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Probe whether a given peer is an AUTHORIZED party member (see
|
|
2285
|
+
* {@link listAuthorizedMembers}) — the gate the control-network wake and
|
|
2286
|
+
* strand-address responders consult, NOT {@link isMember} (the addressable surface).
|
|
2287
|
+
*/
|
|
2288
|
+
async isAuthorizedMember(peerId) {
|
|
2289
|
+
const members = await this.listAuthorizedMembers();
|
|
2290
|
+
return members.some(m => m.peerId === peerId);
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Push-wake a hibernating cadre peer over the control network.
|
|
2294
|
+
*
|
|
2295
|
+
* Resolves the target's signed control-network address from its `CadrePeer`
|
|
2296
|
+
* record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
|
|
2297
|
+
* is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
|
|
2298
|
+
* the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
|
|
2299
|
+
* gates the request on cadre membership and only resumes a strand it already
|
|
2300
|
+
* participates in.
|
|
2301
|
+
*
|
|
2302
|
+
* @param targetPeerId - The hibernating cadre peer to wake.
|
|
2303
|
+
* @param strandId - The strand the caller knows has pending activity.
|
|
2304
|
+
* @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
|
|
2305
|
+
* @throws if the node is not started or the target has no dialable address.
|
|
2306
|
+
*/
|
|
2307
|
+
async pushWake(targetPeerId, strandId, reason) {
|
|
2308
|
+
if (!this.controlNode) {
|
|
2309
|
+
throw new Error('CadreNode must be started before pushing wakes');
|
|
2310
|
+
}
|
|
2311
|
+
const addrs = await this.resolvePeerAddrs(targetPeerId);
|
|
2312
|
+
if (addrs.length === 0) {
|
|
2313
|
+
throw new Error(`No dialable control-network address for peer ${targetPeerId}`);
|
|
2314
|
+
}
|
|
2315
|
+
const request = { strandId, reason };
|
|
2316
|
+
return await dialWake(this.controlNode, addrs, request);
|
|
2317
|
+
}
|
|
491
2318
|
/**
|
|
492
2319
|
* Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
|
|
493
|
-
* This is for drone nodes that need to receive seeds without being an
|
|
494
|
-
* Does not require an
|
|
2320
|
+
* This is for drone nodes that need to receive seeds without being an owner.
|
|
2321
|
+
* Does not require an owner key.
|
|
495
2322
|
*/
|
|
496
2323
|
enableSeedListener() {
|
|
497
2324
|
if (!this.controlNode || !this.controlDatabase) {
|
|
@@ -504,10 +2331,9 @@ export class CadreNode {
|
|
|
504
2331
|
}
|
|
505
2332
|
this.seedBootstrapService = new SeedBootstrapService({
|
|
506
2333
|
partyId: this.config.controlNetwork.partyId,
|
|
507
|
-
// No
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
: {}),
|
|
2334
|
+
// No owner key - this node only receives seeds
|
|
2335
|
+
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
2336
|
+
trustPolicy: this.config.seedTrustPolicy,
|
|
511
2337
|
});
|
|
512
2338
|
this.seedBootstrapService.setEventCallbacks({
|
|
513
2339
|
onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
|
|
@@ -525,7 +2351,7 @@ export class CadreNode {
|
|
|
525
2351
|
}
|
|
526
2352
|
/**
|
|
527
2353
|
* Authorize a new peer to join the cadre.
|
|
528
|
-
* Signs the peer ID with the
|
|
2354
|
+
* Signs the peer ID with the owner key and inserts into CadrePeer table.
|
|
529
2355
|
*
|
|
530
2356
|
* @param peerId - The peer ID to authorize
|
|
531
2357
|
* @param multiaddrs - Optional multiaddrs for the peer
|
|
@@ -535,10 +2361,12 @@ export class CadreNode {
|
|
|
535
2361
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
536
2362
|
}
|
|
537
2363
|
await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
|
|
2364
|
+
// Queue for re-replication if this committed local-only (no connected cohort).
|
|
2365
|
+
this.noteControlWrite(peerId, 'authorize');
|
|
538
2366
|
}
|
|
539
2367
|
/**
|
|
540
2368
|
* Remove a previously-authorized peer from the cadre.
|
|
541
|
-
* Signs the peer ID with the
|
|
2369
|
+
* Signs the peer ID with the owner key and deletes the CadrePeer row.
|
|
542
2370
|
*
|
|
543
2371
|
* @param peerId - The peer ID to remove
|
|
544
2372
|
*/
|
|
@@ -547,10 +2375,12 @@ export class CadreNode {
|
|
|
547
2375
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
548
2376
|
}
|
|
549
2377
|
await this.seedBootstrapService.removePeer(peerId);
|
|
2378
|
+
// Track + loudly flag a delete that committed local-only (security-relevant).
|
|
2379
|
+
this.noteControlWrite(peerId, 'remove');
|
|
550
2380
|
}
|
|
551
2381
|
/**
|
|
552
2382
|
* Create a seed from the current control network state.
|
|
553
|
-
* The seed contains peer information and is signed by an
|
|
2383
|
+
* The seed contains peer information and is signed by an owner.
|
|
554
2384
|
*/
|
|
555
2385
|
async createSeed() {
|
|
556
2386
|
if (!this.seedBootstrapService) {
|
|
@@ -560,20 +2390,36 @@ export class CadreNode {
|
|
|
560
2390
|
}
|
|
561
2391
|
/**
|
|
562
2392
|
* Apply a seed to populate the peer cache and enable connections.
|
|
563
|
-
*
|
|
2393
|
+
*
|
|
2394
|
+
* Validates the seed signature, then evaluates a trust anchor for the signer
|
|
2395
|
+
* key (see `SeedTrustPolicy`). An enrollment caller can pass a per-seed
|
|
2396
|
+
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
|
|
2397
|
+
* `CadreInvite.ownerKeys` — so a cold-start node can accept its first
|
|
2398
|
+
* seed without reconfiguring the service.
|
|
564
2399
|
*/
|
|
565
|
-
async applySeed(seed) {
|
|
2400
|
+
async applySeed(seed, options) {
|
|
566
2401
|
if (!this.seedBootstrapService) {
|
|
567
|
-
// Create a temporary service for applying seeds (doesn't need
|
|
2402
|
+
// Create a temporary service for applying seeds (doesn't need owner key).
|
|
2403
|
+
// partyId is the attacker-influenced seed.partyId — it only labels logs; the
|
|
2404
|
+
// trust decision rests solely on signerKey vs the anchor set (configured
|
|
2405
|
+
// default below, or the per-call options.trustPolicy override).
|
|
2406
|
+
//
|
|
2407
|
+
// This temp service is discarded after the call, so it must NOT own the shared
|
|
2408
|
+
// node's inbound seed handler: pass { registerHandler: false }. That keeps
|
|
2409
|
+
// repeated service-less applySeed/dialInvite idempotent (no handler leak, no
|
|
2410
|
+
// DuplicateProtocolHandlerError). The temp service still applies this seed; a
|
|
2411
|
+
// node that wants to RECEIVE inbound seeds needs a persistent service
|
|
2412
|
+
// (enableSeedListener / initializeSeedBootstrap) to own the handler.
|
|
568
2413
|
const tempService = new SeedBootstrapService({
|
|
569
2414
|
partyId: seed.partyId,
|
|
2415
|
+
trustPolicy: this.config.seedTrustPolicy,
|
|
570
2416
|
});
|
|
571
2417
|
if (this.controlNode && this.controlDatabase) {
|
|
572
|
-
tempService.initialize(this.controlNode, this.controlDatabase);
|
|
2418
|
+
tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
|
|
573
2419
|
}
|
|
574
|
-
return await tempService.applySeed(seed);
|
|
2420
|
+
return await tempService.applySeed(seed, options);
|
|
575
2421
|
}
|
|
576
|
-
return await this.seedBootstrapService.applySeed(seed);
|
|
2422
|
+
return await this.seedBootstrapService.applySeed(seed, options);
|
|
577
2423
|
}
|
|
578
2424
|
/**
|
|
579
2425
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
@@ -672,16 +2518,23 @@ export class CadreNode {
|
|
|
672
2518
|
return JSON.parse(json);
|
|
673
2519
|
}
|
|
674
2520
|
/**
|
|
675
|
-
* Dial an
|
|
2521
|
+
* Dial an owner from an invite (for phone joining via invite).
|
|
676
2522
|
*/
|
|
677
2523
|
async dialInvite(invite) {
|
|
678
2524
|
if (!this.seedBootstrapService) {
|
|
679
|
-
// Create a temporary service
|
|
2525
|
+
// Create a temporary service that only dials the invite. It is discarded after
|
|
2526
|
+
// the call, so it does NOT own the shared node's inbound seed handler:
|
|
2527
|
+
// initialize with { registerHandler: false }. This temp dialInvite never
|
|
2528
|
+
// applies a seed itself — a node that wants to RECEIVE an inbound seed back
|
|
2529
|
+
// must have a persistent service (enableSeedListener / initializeSeedBootstrap)
|
|
2530
|
+
// own the /sereus/seed/1.0.0 handler. trustPolicy is therefore effectively dead
|
|
2531
|
+
// on this dial-only path; it is kept for symmetry with the applySeed temp site.
|
|
680
2532
|
const tempService = new SeedBootstrapService({
|
|
681
2533
|
partyId: invite.partyId,
|
|
2534
|
+
trustPolicy: this.config.seedTrustPolicy,
|
|
682
2535
|
});
|
|
683
2536
|
if (this.controlNode && this.controlDatabase) {
|
|
684
|
-
tempService.initialize(this.controlNode, this.controlDatabase);
|
|
2537
|
+
tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
|
|
685
2538
|
}
|
|
686
2539
|
await tempService.dialInvite(invite);
|
|
687
2540
|
return;
|
|
@@ -689,7 +2542,7 @@ export class CadreNode {
|
|
|
689
2542
|
await this.seedBootstrapService.dialInvite(invite);
|
|
690
2543
|
}
|
|
691
2544
|
// ============================================================================
|
|
692
|
-
// Strand Solicitation API (
|
|
2545
|
+
// Strand Solicitation API (native cadre-core formation transport)
|
|
693
2546
|
// ============================================================================
|
|
694
2547
|
/**
|
|
695
2548
|
* Initialize the strand solicitation service.
|