@serfab/cadre-core 0.0.2

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 (54) hide show
  1. package/README.md +252 -0
  2. package/dist/arachnode-stub.d.ts +60 -0
  3. package/dist/arachnode-stub.js +120 -0
  4. package/dist/arachnode-stub.js.map +1 -0
  5. package/dist/cadre-node.d.ts +252 -0
  6. package/dist/cadre-node.js +753 -0
  7. package/dist/cadre-node.js.map +1 -0
  8. package/dist/control-database.d.ts +62 -0
  9. package/dist/control-database.js +295 -0
  10. package/dist/control-database.js.map +1 -0
  11. package/dist/enrollment.d.ts +76 -0
  12. package/dist/enrollment.js +126 -0
  13. package/dist/enrollment.js.map +1 -0
  14. package/dist/hibernation-manager.d.ts +68 -0
  15. package/dist/hibernation-manager.js +212 -0
  16. package/dist/hibernation-manager.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.js +24 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/seed-bootstrap.d.ts +164 -0
  21. package/dist/seed-bootstrap.js +554 -0
  22. package/dist/seed-bootstrap.js.map +1 -0
  23. package/dist/strand-database.d.ts +39 -0
  24. package/dist/strand-database.js +108 -0
  25. package/dist/strand-database.js.map +1 -0
  26. package/dist/strand-formation-manager.d.ts +87 -0
  27. package/dist/strand-formation-manager.js +168 -0
  28. package/dist/strand-formation-manager.js.map +1 -0
  29. package/dist/strand-instance-manager.d.ts +60 -0
  30. package/dist/strand-instance-manager.js +209 -0
  31. package/dist/strand-instance-manager.js.map +1 -0
  32. package/dist/strand-solicitation.d.ts +145 -0
  33. package/dist/strand-solicitation.js +180 -0
  34. package/dist/strand-solicitation.js.map +1 -0
  35. package/dist/strand-watcher.d.ts +70 -0
  36. package/dist/strand-watcher.js +136 -0
  37. package/dist/strand-watcher.js.map +1 -0
  38. package/dist/types.d.ts +484 -0
  39. package/dist/types.js +26 -0
  40. package/dist/types.js.map +1 -0
  41. package/package.json +67 -0
  42. package/src/arachnode-stub.ts +160 -0
  43. package/src/cadre-node.ts +923 -0
  44. package/src/control-database.ts +376 -0
  45. package/src/enrollment.ts +199 -0
  46. package/src/hibernation-manager.ts +268 -0
  47. package/src/index.ts +71 -0
  48. package/src/seed-bootstrap.ts +753 -0
  49. package/src/strand-database.ts +168 -0
  50. package/src/strand-formation-manager.ts +277 -0
  51. package/src/strand-instance-manager.ts +281 -0
  52. package/src/strand-solicitation.ts +318 -0
  53. package/src/strand-watcher.ts +197 -0
  54. package/src/types.ts +553 -0
@@ -0,0 +1,923 @@
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
+
37
+ type EventHandler<T> = (data: T) => void;
38
+
39
+ /**
40
+ * CadreNode is the main entry point for a cadre member.
41
+ * It manages:
42
+ * - Connection to the control network
43
+ * - Watching for strand changes
44
+ * - Starting/stopping strand instances
45
+ * - Strand hibernation lifecycle
46
+ * - Peer enrollment
47
+ */
48
+ export class CadreNode implements SAppIdLookup {
49
+ private readonly config: CadreNodeConfig;
50
+ private controlNode: Libp2p | null = null;
51
+ private controlDatabase: ControlDatabase | null = null;
52
+ private strandWatcher: StrandWatcher | null = null;
53
+ private strandManager: StrandInstanceManager;
54
+ private hibernationManager: HibernationManager;
55
+ private enrollmentService: EnrollmentService;
56
+ private seedBootstrapService: SeedBootstrapService | null = null;
57
+ private strandSolicitationService: StrandSolicitationService | null = null;
58
+ private running = false;
59
+ private eventHandlers: Map<keyof CadreNodeEvents, Set<EventHandler<any>>> = new Map();
60
+
61
+ /** Map of strandId -> sAppConfig for sAppId filtering and management */
62
+ private sAppConfigs: Map<string, SAppConfig> = new Map();
63
+
64
+ constructor(config: CadreNodeConfig) {
65
+ this.config = config;
66
+ this.strandManager = new StrandInstanceManager();
67
+ this.enrollmentService = new EnrollmentService();
68
+
69
+ // Create hibernation manager with callbacks
70
+ const hibernationCallbacks: HibernationCallbacks = {
71
+ onIdle: async (strandId) => this.handleStrandIdle(strandId),
72
+ onHibernate: async (strandId) => this.handleStrandHibernate(strandId),
73
+ onWake: async (strandId) => this.handleStrandWake(strandId)
74
+ };
75
+ this.hibernationManager = new HibernationManager(
76
+ config.hibernation ?? { enabled: false },
77
+ hibernationCallbacks
78
+ );
79
+
80
+ log('CadreNode created for party: %s', config.controlNetwork.partyId);
81
+ }
82
+
83
+ /**
84
+ * SAppIdLookup implementation - get sAppId for a strand
85
+ */
86
+ getSAppId(strandId: string): string | undefined {
87
+ return this.sAppConfigs.get(strandId)?.id;
88
+ }
89
+
90
+ /**
91
+ * Get the peer ID of this node (available after start)
92
+ */
93
+ get peerId(): PeerId | undefined {
94
+ return this.controlNode?.peerId;
95
+ }
96
+
97
+ /**
98
+ * Get the multiaddrs of this node (available after start)
99
+ */
100
+ getMultiaddrs(): string[] {
101
+ if (!this.controlNode) return [];
102
+ return this.controlNode.getMultiaddrs().map(ma => ma.toString());
103
+ }
104
+
105
+ /**
106
+ * Check if the node is running
107
+ */
108
+ get isRunning(): boolean {
109
+ return this.running;
110
+ }
111
+
112
+ /**
113
+ * Get all strand instances
114
+ */
115
+ getStrands(): Map<string, StrandInstance> {
116
+ return this.strandManager.getInstances();
117
+ }
118
+
119
+ /**
120
+ * Get a specific strand instance
121
+ */
122
+ getStrand(strandId: string): StrandInstance | undefined {
123
+ return this.strandManager.getInstance(strandId);
124
+ }
125
+
126
+ /**
127
+ * Get the enrollment service for adding new peers
128
+ */
129
+ getEnrollmentService(): EnrollmentService {
130
+ return this.enrollmentService;
131
+ }
132
+
133
+ /**
134
+ * Start the cadre node
135
+ */
136
+ async start(): Promise<void> {
137
+ if (this.running) {
138
+ log('CadreNode already running');
139
+ return;
140
+ }
141
+
142
+ log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
143
+
144
+ try {
145
+ // Create the control network libp2p node
146
+ this.controlNode = await this.createControlNode();
147
+ log('Control node started with ID: %s', this.controlNode.peerId.toString());
148
+
149
+ // Extract coordinatedRepo from the node (attached by createLibp2pNode)
150
+ const coordinatedRepo = (this.controlNode as any).coordinatedRepo as IRepo;
151
+ if (!coordinatedRepo) {
152
+ throw new Error('coordinatedRepo not available on control node');
153
+ }
154
+
155
+ // Initialize the control database with the libp2p node
156
+ this.controlDatabase = new ControlDatabase({
157
+ partyId: this.config.controlNetwork.partyId,
158
+ libp2pNode: this.controlNode,
159
+ coordinatedRepo,
160
+ schemaPath: this.config.controlNetwork.schemaPath,
161
+ });
162
+ await this.controlDatabase.initialize();
163
+ log('Control database initialized');
164
+
165
+ // Create strand queryable using the control database
166
+ const queryable = this.createStrandQueryable();
167
+
168
+ // Create and start the strand watcher with sAppId lookup
169
+ this.strandWatcher = new StrandWatcher(
170
+ queryable,
171
+ {
172
+ onStrandAdded: async (strand) => this.handleStrandAdded(strand),
173
+ onStrandRemoved: async (strandId) => this.handleStrandRemoved(strandId)
174
+ },
175
+ this.config.strandFilter ?? { mode: 'all' },
176
+ this.config.strandWatchInterval ?? 5000,
177
+ this // CadreNode implements SAppIdLookup
178
+ );
179
+
180
+ await this.strandWatcher.start();
181
+
182
+ // Start hibernation manager
183
+ this.hibernationManager.start();
184
+
185
+ this.running = true;
186
+ this.emit('control:connected', undefined);
187
+ log('CadreNode started successfully');
188
+
189
+ // Schedule self-registration in background
190
+ this.scheduleSelfRegistration();
191
+
192
+ } catch (error) {
193
+ log('Failed to start CadreNode: %o', error);
194
+ await this.cleanup();
195
+ throw error;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Stop the cadre node
201
+ */
202
+ async stop(): Promise<void> {
203
+ if (!this.running) {
204
+ log('CadreNode not running');
205
+ return;
206
+ }
207
+
208
+ log('Stopping CadreNode');
209
+ await this.cleanup();
210
+ this.running = false;
211
+ this.emit('control:disconnected', undefined);
212
+ log('CadreNode stopped');
213
+ }
214
+
215
+ /**
216
+ * Subscribe to events
217
+ */
218
+ on<K extends keyof CadreNodeEvents>(
219
+ event: K,
220
+ handler: EventHandler<CadreNodeEvents[K]>
221
+ ): void {
222
+ if (!this.eventHandlers.has(event)) {
223
+ this.eventHandlers.set(event, new Set());
224
+ }
225
+ this.eventHandlers.get(event)!.add(handler);
226
+ }
227
+
228
+ /**
229
+ * Unsubscribe from events
230
+ */
231
+ off<K extends keyof CadreNodeEvents>(
232
+ event: K,
233
+ handler: EventHandler<CadreNodeEvents[K]>
234
+ ): void {
235
+ this.eventHandlers.get(event)?.delete(handler);
236
+ }
237
+
238
+ private emit<K extends keyof CadreNodeEvents>(
239
+ event: K,
240
+ data: CadreNodeEvents[K]
241
+ ): void {
242
+ this.eventHandlers.get(event)?.forEach(handler => {
243
+ try { handler(data); } catch (e) { log('Event handler error: %o', e); }
244
+ });
245
+ }
246
+
247
+ private async createControlNode(): Promise<Libp2p> {
248
+ const { controlNetwork, network, storage, profile, privateKey } = this.config;
249
+
250
+ // Determine relay mode: if explicitly set in config, use that;
251
+ // otherwise default to true for storage profile nodes (better connectivity/uptime)
252
+ const enableRelay = network?.enableRelay ?? (profile === 'storage');
253
+
254
+ // Create storage provider for control network
255
+ // Uses the factory function pattern if provided to create control-network-specific storage
256
+ const controlStorageProvider = storage?.provider
257
+ ? (typeof storage.provider === 'function'
258
+ ? storage.provider('control')
259
+ : storage.provider)
260
+ : undefined;
261
+
262
+ const nodeOptions: Parameters<typeof createLibp2pNode>[0] = {
263
+ port: 0,
264
+ bootstrapNodes: controlNetwork.bootstrapNodes,
265
+ networkName: `control-${controlNetwork.partyId}`,
266
+ storage: controlStorageProvider,
267
+ fretProfile: profile === 'storage' ? 'core' : 'edge',
268
+ relay: enableRelay,
269
+ clusterSize: 3,
270
+ clusterPolicy: { allowDownsize: true, sizeTolerance: 0.5 },
271
+ arachnode: { enableRingZulu: true },
272
+ ...(network?.transports && { transports: network.transports }),
273
+ ...(network?.listenAddrs && { listenAddrs: network.listenAddrs })
274
+ };
275
+
276
+ // If private key provided, we need to load it
277
+ // Note: createLibp2pNode doesn't support privateKey directly yet
278
+ // For now we create the node without it
279
+ if (privateKey) {
280
+ log('Private key provided - loading identity');
281
+ // This would need to be integrated into createLibp2pNode
282
+ }
283
+
284
+ return await createLibp2pNode(nodeOptions);
285
+ }
286
+
287
+ private createStrandQueryable(): StrandQueryable {
288
+ return {
289
+ queryStrands: async (): Promise<StrandRow[]> => {
290
+ if (!this.controlDatabase) {
291
+ log('Control database not initialized, returning empty strands');
292
+ return [];
293
+ }
294
+ log('Querying strands from control database');
295
+ return await this.controlDatabase.queryStrands();
296
+ }
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Schedule self-registration in the CadrePeer table.
302
+ * This runs as a background task after the node is started.
303
+ */
304
+ private scheduleSelfRegistration(): void {
305
+ // Run in background - don't block start()
306
+ setTimeout(async () => {
307
+ try {
308
+ await this.registerSelf();
309
+ } catch (error) {
310
+ log('Self-registration failed: %o', error);
311
+ // Don't emit error event - this is a background task
312
+ // The node can still function without self-registration
313
+ }
314
+ }, 1000); // Small delay to ensure node is fully started
315
+ }
316
+
317
+ /**
318
+ * Register this peer in the CadrePeer table.
319
+ * This allows other cadre members to discover this node.
320
+ */
321
+ private async registerSelf(): Promise<void> {
322
+ if (!this.controlNode || !this.controlDatabase) {
323
+ log('Cannot register self - node or database not initialized');
324
+ return;
325
+ }
326
+
327
+ const peerId = this.controlNode.peerId.toString();
328
+ const multiaddrs = this.controlNode.getMultiaddrs();
329
+
330
+ if (multiaddrs.length === 0) {
331
+ log('No multiaddrs available for self-registration');
332
+ return;
333
+ }
334
+
335
+ // Use the first available multiaddr (typically the one with the peer ID)
336
+ const multiaddr = multiaddrs[0]!.toString();
337
+
338
+ log('Registering self: peerId=%s, multiaddr=%s', peerId, multiaddr);
339
+
340
+ // TODO: This requires signing the update to satisfy the AuthorizedUpdate constraint
341
+ // For now, we log what would happen. Full implementation requires:
342
+ // 1. Either an authority key to authorize the insert
343
+ // 2. Or the peer's private key to sign the update
344
+ log('Self-registration requires authorization - skipping for now');
345
+ // const db = this.controlDatabase.getDatabase();
346
+ // await db.exec(
347
+ // 'insert or replace into CadrePeer (PeerId, Multiaddr) values (?, ?)',
348
+ // [peerId, multiaddr]
349
+ // );
350
+ }
351
+
352
+ private async handleStrandAdded(strand: StrandRow): Promise<void> {
353
+ log('Handling strand added from control network: %s', strand.Id);
354
+
355
+ // Check if we have sApp config for this strand
356
+ const sAppConfig = this.sAppConfigs.get(strand.Id);
357
+ if (!sAppConfig) {
358
+ log('No sAppConfig registered for strand %s - skipping auto-start', strand.Id);
359
+ // Strand detected in control network but not yet configured via addStrand
360
+ // This is normal - the app will call addStrand with the config
361
+ return;
362
+ }
363
+
364
+ try {
365
+ const instance = await this.strandManager.startStrand({
366
+ strandRow: strand,
367
+ sAppConfig,
368
+ storage: this.config.storage,
369
+ network: this.config.network,
370
+ profile: this.config.profile,
371
+ defaultLatencyHint: this.config.hibernation?.defaultLatencyHint ?? 'interactive'
372
+ });
373
+
374
+ // Register with hibernation manager
375
+ this.hibernationManager.trackStrand(instance);
376
+
377
+ this.emit('strand:started', { strandId: strand.Id });
378
+ } catch (error) {
379
+ log('Error starting strand %s: %o', strand.Id, error);
380
+ this.emit('strand:error', {
381
+ strandId: strand.Id,
382
+ error: error instanceof Error ? error : new Error(String(error))
383
+ });
384
+ }
385
+ }
386
+
387
+ private async handleStrandRemoved(strandId: string): Promise<void> {
388
+ log('Handling strand removed: %s', strandId);
389
+
390
+ try {
391
+ // Untrack from hibernation
392
+ this.hibernationManager.untrackStrand(strandId);
393
+
394
+ // Remove sApp config
395
+ this.sAppConfigs.delete(strandId);
396
+
397
+ await this.strandManager.stopStrand(strandId);
398
+ this.emit('strand:stopped', { strandId });
399
+ } catch (error) {
400
+ log('Error stopping strand %s: %o', strandId, error);
401
+ this.emit('strand:error', {
402
+ strandId,
403
+ error: error instanceof Error ? error : new Error(String(error))
404
+ });
405
+ }
406
+ }
407
+
408
+ private async cleanup(): Promise<void> {
409
+ // Stop hibernation manager
410
+ this.hibernationManager.stop();
411
+
412
+ // Stop seed bootstrap service
413
+ if (this.seedBootstrapService) {
414
+ await this.seedBootstrapService.shutdown();
415
+ this.seedBootstrapService = null;
416
+ }
417
+
418
+ // Unregister strand solicitation service
419
+ if (this.strandSolicitationService && this.controlNode) {
420
+ this.strandSolicitationService.unregisterResponder(this.controlNode);
421
+ this.strandSolicitationService = null;
422
+ }
423
+
424
+ // Stop strand watcher
425
+ if (this.strandWatcher) {
426
+ await this.strandWatcher.stop();
427
+ this.strandWatcher = null;
428
+ }
429
+
430
+ // Stop all strand instances
431
+ await this.strandManager.stopAll();
432
+
433
+ // Clear sApp configs
434
+ this.sAppConfigs.clear();
435
+
436
+ // Close control database (this also shuts down the collection factory)
437
+ if (this.controlDatabase) {
438
+ await this.controlDatabase.close();
439
+ this.controlDatabase = null;
440
+ }
441
+
442
+ // Stop control node
443
+ if (this.controlNode) {
444
+ await this.controlNode.stop();
445
+ this.controlNode = null;
446
+ }
447
+ }
448
+
449
+ // Hibernation callbacks
450
+ private async handleStrandIdle(strandId: string): Promise<void> {
451
+ const instance = this.strandManager.getInstance(strandId);
452
+ if (instance) {
453
+ instance.status = 'idle';
454
+ log('Strand %s transitioned to idle', strandId);
455
+ this.emit('strand:idle', { strandId });
456
+ }
457
+ }
458
+
459
+ private async handleStrandHibernate(strandId: string): Promise<void> {
460
+ const instance = this.strandManager.getInstance(strandId);
461
+ if (instance) {
462
+ instance.status = 'hibernating';
463
+
464
+ // Optionally disconnect libp2p to save resources
465
+ // For now we keep it connected but could stop it here
466
+ log('Strand %s transitioned to hibernating', strandId);
467
+ this.emit('strand:hibernating', { strandId });
468
+ }
469
+ }
470
+
471
+ private async handleStrandWake(strandId: string): Promise<void> {
472
+ const instance = this.strandManager.getInstance(strandId);
473
+ if (instance) {
474
+ instance.status = 'active';
475
+ instance.lastActivity = new Date();
476
+ log('Strand %s woke up', strandId);
477
+ this.emit('strand:waking', { strandId });
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Add a strand with its sApp configuration.
483
+ * The hosting application must provide the sApp schema when creating a strand.
484
+ */
485
+ async addStrand(config: StrandConfig): Promise<StrandInstance> {
486
+ if (!this.running) {
487
+ throw new Error('CadreNode not running');
488
+ }
489
+
490
+ const { strandRow, sAppConfig } = config;
491
+
492
+ // Store sApp config for this strand
493
+ this.sAppConfigs.set(strandRow.Id, sAppConfig);
494
+ log('Registered sAppConfig for strand %s (sApp: %s)', strandRow.Id, sAppConfig.id);
495
+
496
+ const instance = await this.strandManager.startStrand({
497
+ strandRow,
498
+ sAppConfig,
499
+ storage: this.config.storage,
500
+ network: this.config.network,
501
+ profile: this.config.profile,
502
+ defaultLatencyHint: this.config.hibernation?.defaultLatencyHint ?? 'interactive'
503
+ });
504
+
505
+ // Register with hibernation manager
506
+ this.hibernationManager.trackStrand(instance);
507
+
508
+ this.emit('strand:started', { strandId: strandRow.Id });
509
+ return instance;
510
+ }
511
+
512
+ /**
513
+ * Remove a strand
514
+ */
515
+ async removeStrand(strandId: string): Promise<void> {
516
+ if (!this.running) {
517
+ throw new Error('CadreNode not running');
518
+ }
519
+
520
+ // Untrack from hibernation
521
+ this.hibernationManager.untrackStrand(strandId);
522
+
523
+ // Remove sApp config
524
+ this.sAppConfigs.delete(strandId);
525
+
526
+ await this.strandManager.stopStrand(strandId);
527
+ this.emit('strand:stopped', { strandId });
528
+ }
529
+
530
+ /**
531
+ * Record activity on a strand (resets hibernation timer)
532
+ */
533
+ recordStrandActivity(strandId: string): void {
534
+ const instance = this.strandManager.getInstance(strandId);
535
+ if (instance) {
536
+ this.hibernationManager.recordActivity(instance);
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Force wake a hibernating strand
542
+ */
543
+ async wakeStrand(strandId: string): Promise<void> {
544
+ await this.hibernationManager.wakeStrand(strandId);
545
+ }
546
+
547
+ /**
548
+ * Get the control network node (for advanced use)
549
+ */
550
+ getControlNode(): Libp2p | null {
551
+ return this.controlNode;
552
+ }
553
+
554
+ /**
555
+ * Get the control database (for advanced queries)
556
+ */
557
+ getControlDatabase(): ControlDatabase | null {
558
+ return this.controlDatabase;
559
+ }
560
+
561
+ /**
562
+ * Force a poll of the strand watcher (for testing)
563
+ */
564
+ async forceStrandPoll(): Promise<void> {
565
+ await this.strandWatcher?.forcePoll();
566
+ }
567
+
568
+ /**
569
+ * Get the sApp configuration for a strand
570
+ */
571
+ getSAppConfig(strandId: string): SAppConfig | undefined {
572
+ return this.sAppConfigs.get(strandId);
573
+ }
574
+
575
+ // ============================================================================
576
+ // Seed Bootstrap API
577
+ // ============================================================================
578
+
579
+ /**
580
+ * Initialize the seed bootstrap service with an authority key.
581
+ * Must be called before using seed-related methods that require signing.
582
+ *
583
+ * @param authorityPrivateKey - The authority's private key (base64url encoded)
584
+ */
585
+ initializeSeedBootstrap(authorityPrivateKey: string): void {
586
+ if (!this.controlNode || !this.controlDatabase) {
587
+ throw new Error('CadreNode must be started before initializing seed bootstrap');
588
+ }
589
+
590
+ this.seedBootstrapService = new SeedBootstrapService({
591
+ partyId: this.config.controlNetwork.partyId,
592
+ authorityPrivateKey,
593
+ });
594
+
595
+ this.seedBootstrapService.setEventCallbacks({
596
+ onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
597
+ onSeedApplied: (partyId, peersAdded) => this.emit('seed:applied', { partyId, peersAdded }),
598
+ onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
599
+ });
600
+
601
+ this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
602
+ log('Seed bootstrap service initialized');
603
+ }
604
+
605
+ /**
606
+ * Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
607
+ * This is for drone nodes that need to receive seeds without being an authority.
608
+ * Does not require an authority key.
609
+ */
610
+ enableSeedListener(): void {
611
+ if (!this.controlNode || !this.controlDatabase) {
612
+ throw new Error('CadreNode must be started before enabling seed listener');
613
+ }
614
+
615
+ // Don't re-initialize if already has a service
616
+ if (this.seedBootstrapService) {
617
+ log('Seed bootstrap service already initialized');
618
+ return;
619
+ }
620
+
621
+ this.seedBootstrapService = new SeedBootstrapService({
622
+ partyId: this.config.controlNetwork.partyId,
623
+ // No authority key - this node only receives seeds
624
+ });
625
+
626
+ this.seedBootstrapService.setEventCallbacks({
627
+ onSeedReceived: (partyId, peerId) => this.emit('seed:received', { partyId, peerId }),
628
+ onSeedApplied: (partyId, peersAdded) => this.emit('seed:applied', { partyId, peersAdded }),
629
+ onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
630
+ });
631
+
632
+ this.seedBootstrapService.initialize(this.controlNode, this.controlDatabase);
633
+ log('Seed listener enabled');
634
+ }
635
+
636
+ /**
637
+ * Get the seed bootstrap service (for advanced use)
638
+ */
639
+ getSeedBootstrapService(): SeedBootstrapService | null {
640
+ return this.seedBootstrapService;
641
+ }
642
+
643
+ /**
644
+ * Authorize a new peer to join the cadre.
645
+ * Signs the peer ID with the authority key and inserts into CadrePeer table.
646
+ *
647
+ * @param peerId - The peer ID to authorize
648
+ * @param multiaddrs - Optional multiaddrs for the peer
649
+ */
650
+ async authorizePeer(peerId: string, multiaddrs?: string[]): Promise<void> {
651
+ if (!this.seedBootstrapService) {
652
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
653
+ }
654
+ await this.seedBootstrapService.authorizePeer({ peerId, multiaddrs });
655
+ }
656
+
657
+ /**
658
+ * Create a seed from the current control network state.
659
+ * The seed contains peer information and is signed by an authority.
660
+ */
661
+ async createSeed(): Promise<ControlNetworkSeed> {
662
+ if (!this.seedBootstrapService) {
663
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
664
+ }
665
+ return await this.seedBootstrapService.createSeed();
666
+ }
667
+
668
+ /**
669
+ * Apply a seed to populate the peer cache and enable connections.
670
+ * Validates the seed signature before applying.
671
+ */
672
+ async applySeed(seed: ControlNetworkSeed): Promise<ApplySeedResult> {
673
+ if (!this.seedBootstrapService) {
674
+ // Create a temporary service for applying seeds (doesn't need authority key)
675
+ const tempService = new SeedBootstrapService({
676
+ partyId: seed.partyId,
677
+ });
678
+ if (this.controlNode && this.controlDatabase) {
679
+ tempService.initialize(this.controlNode, this.controlDatabase);
680
+ }
681
+ return await tempService.applySeed(seed);
682
+ }
683
+ return await this.seedBootstrapService.applySeed(seed);
684
+ }
685
+
686
+ /**
687
+ * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
688
+ */
689
+ async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<{ accepted: boolean; reason?: string }> {
690
+ if (!this.seedBootstrapService) {
691
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
692
+ }
693
+ return await this.seedBootstrapService.deliverSeed(targetMultiaddr, seed);
694
+ }
695
+
696
+ /**
697
+ * Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
698
+ */
699
+ encodeSeed(seed: ControlNetworkSeed): string {
700
+ // Static method - doesn't need service initialization
701
+ const json = JSON.stringify(seed);
702
+ return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
703
+ }
704
+
705
+ /**
706
+ * Decode a seed from base64url encoding.
707
+ */
708
+ decodeSeed(encoded: string): ControlNetworkSeed {
709
+ // Static method - doesn't need service initialization
710
+ const bytes = uint8ArrayFromString(encoded, 'base64url');
711
+ const json = new TextDecoder().decode(bytes);
712
+ return JSON.parse(json) as ControlNetworkSeed;
713
+ }
714
+
715
+ /**
716
+ * Get this node's circuit relay address for inclusion in seeds.
717
+ * Returns null if no relay address is available.
718
+ */
719
+ async getRelayAddress(): Promise<string | null> {
720
+ if (!this.controlNode) {
721
+ return null;
722
+ }
723
+
724
+ const addrs = this.controlNode.getMultiaddrs();
725
+ const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
726
+ return relayAddr?.toString() ?? null;
727
+ }
728
+
729
+ // ============================================================================
730
+ // Seed Bootstrap Helper Methods
731
+ // ============================================================================
732
+
733
+ /**
734
+ * Add a drone to the cadre (for phone/server adding provider-hosted node).
735
+ * Creates authorization and seed for drone initialization.
736
+ */
737
+ async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
738
+ if (!this.seedBootstrapService) {
739
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
740
+ }
741
+ return await this.seedBootstrapService.addDrone(options);
742
+ }
743
+
744
+ /**
745
+ * Create an invite for a phone to join the cadre.
746
+ * Use when a server wants to invite a NAT'd phone.
747
+ */
748
+ async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
749
+ if (!this.seedBootstrapService) {
750
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
751
+ }
752
+ return await this.seedBootstrapService.createInvite(token, expiresIn);
753
+ }
754
+
755
+ /**
756
+ * Accept a phone connection using an invite.
757
+ * Call this when a phone dials in with an invite token.
758
+ */
759
+ async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
760
+ if (!this.seedBootstrapService) {
761
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
762
+ }
763
+ await this.seedBootstrapService.acceptPhone(options, issuedInvite);
764
+ }
765
+
766
+ /**
767
+ * Add a phone to the cadre with relay support.
768
+ * Use when both nodes are NAT'd (phone-to-phone).
769
+ */
770
+ async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
771
+ if (!this.seedBootstrapService) {
772
+ throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
773
+ }
774
+ return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
775
+ }
776
+
777
+ /**
778
+ * Encode an invite for out-of-band delivery (QR, link, etc.).
779
+ */
780
+ encodeInvite(invite: CadreInvite): string {
781
+ const json = JSON.stringify(invite);
782
+ return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
783
+ }
784
+
785
+ /**
786
+ * Decode an invite from base64url encoding.
787
+ */
788
+ decodeInvite(encoded: string): CadreInvite {
789
+ const bytes = uint8ArrayFromString(encoded, 'base64url');
790
+ const json = new TextDecoder().decode(bytes);
791
+ return JSON.parse(json) as CadreInvite;
792
+ }
793
+
794
+ /**
795
+ * Dial an authority from an invite (for phone joining via invite).
796
+ */
797
+ async dialInvite(invite: CadreInvite): Promise<void> {
798
+ if (!this.seedBootstrapService) {
799
+ // Create a temporary service for dialing
800
+ const tempService = new SeedBootstrapService({
801
+ partyId: invite.partyId,
802
+ });
803
+ if (this.controlNode && this.controlDatabase) {
804
+ tempService.initialize(this.controlNode, this.controlDatabase);
805
+ }
806
+ await tempService.dialInvite(invite);
807
+ return;
808
+ }
809
+ await this.seedBootstrapService.dialInvite(invite);
810
+ }
811
+
812
+ // ============================================================================
813
+ // Strand Solicitation API (strand-proto integration)
814
+ // ============================================================================
815
+
816
+ /**
817
+ * Initialize the strand solicitation service.
818
+ * This enables forming strands with other parties via open invitations.
819
+ *
820
+ * @param options - Configuration for the solicitation service
821
+ */
822
+ initializeStrandSolicitation(options?: StrandSolicitationServiceOptions): void {
823
+ if (!this.controlNode) {
824
+ throw new Error('CadreNode must be started before initializing strand solicitation');
825
+ }
826
+
827
+ this.strandSolicitationService = new StrandSolicitationService({
828
+ ...options,
829
+ partyId: this.config.controlNetwork.partyId,
830
+ cadrePeerAddrs: this.getMultiaddrs()
831
+ });
832
+
833
+ // Register as responder on the control node
834
+ this.strandSolicitationService.registerResponder(this.controlNode);
835
+ log('Strand solicitation service initialized');
836
+ }
837
+
838
+ /**
839
+ * Get the strand solicitation service (for advanced use)
840
+ */
841
+ getStrandSolicitationService(): StrandSolicitationService | null {
842
+ return this.strandSolicitationService;
843
+ }
844
+
845
+ /**
846
+ * Create an open invitation for others to form strands with this party.
847
+ *
848
+ * @param sAppId - The sApp to use for formed strands
849
+ * @param expirationMs - How long the invitation is valid (ms from now)
850
+ * @returns The open invitation to share out-of-band
851
+ */
852
+ async createOpenInvitation(
853
+ sAppId: string,
854
+ expirationMs: number = 24 * 60 * 60 * 1000 // 24 hours default
855
+ ): Promise<OpenInvitation> {
856
+ if (!this.strandSolicitationService) {
857
+ // Create a temporary service for creating invitations
858
+ this.initializeStrandSolicitation();
859
+ }
860
+
861
+ const bootstrap = this.getMultiaddrs();
862
+ if (bootstrap.length === 0) {
863
+ throw new Error('No multiaddrs available for invitation');
864
+ }
865
+
866
+ return await this.strandSolicitationService!.createOpenInvitation(
867
+ sAppId,
868
+ expirationMs,
869
+ bootstrap
870
+ );
871
+ }
872
+
873
+ /**
874
+ * Form a strand with a responder via an open invitation.
875
+ *
876
+ * @param invitation - The open invitation received out-of-band
877
+ * @param disclosure - Identity/context information to share with the responder
878
+ * @returns The member key and strand info if successful
879
+ */
880
+ async formStrand(
881
+ invitation: OpenInvitation,
882
+ disclosure: StrandFormationDisclosure = {}
883
+ ): Promise<FormStrandResult> {
884
+ if (!this.controlNode) {
885
+ throw new Error('CadreNode must be started before forming strands');
886
+ }
887
+
888
+ if (!this.strandSolicitationService) {
889
+ this.initializeStrandSolicitation();
890
+ }
891
+
892
+ return await this.strandSolicitationService!.formStrand(
893
+ invitation,
894
+ disclosure,
895
+ this.controlNode
896
+ );
897
+ }
898
+
899
+ /**
900
+ * Encode an open invitation for out-of-band delivery (QR, link, etc.).
901
+ */
902
+ encodeInvitation(invitation: OpenInvitation): string {
903
+ const json = JSON.stringify({
904
+ ...invitation,
905
+ expiration: invitation.expiration.toISOString()
906
+ });
907
+ return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
908
+ }
909
+
910
+ /**
911
+ * Decode an open invitation from base64url encoding.
912
+ */
913
+ decodeInvitation(encoded: string): OpenInvitation {
914
+ const bytes = uint8ArrayFromString(encoded, 'base64url');
915
+ const json = new TextDecoder().decode(bytes);
916
+ const parsed = JSON.parse(json);
917
+ return {
918
+ ...parsed,
919
+ expiration: new Date(parsed.expiration)
920
+ };
921
+ }
922
+ }
923
+