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