@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,753 @@
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
+ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
+ import type { Libp2p, PeerId, Connection } from '@libp2p/interface';
5
+ import { multiaddr } from '@multiformats/multiaddr';
6
+ import type { Multiaddr } from '@multiformats/multiaddr';
7
+ import { peerIdFromString } from '@libp2p/peer-id';
8
+
9
+ /**
10
+ * Minimal libp2p stream interface for cross-platform compatibility.
11
+ * This abstraction works across different libp2p versions and environments.
12
+ */
13
+ interface LibP2PStream {
14
+ source: AsyncIterable<Uint8Array>;
15
+ sink(source: AsyncIterable<Uint8Array>): Promise<void>;
16
+ close(): Promise<void>;
17
+ }
18
+
19
+ /**
20
+ * Incoming stream data from libp2p handle callback.
21
+ * Defined locally for cross-version compatibility.
22
+ */
23
+ interface IncomingStreamData {
24
+ stream: LibP2PStream;
25
+ connection: Connection;
26
+ }
27
+ import type {
28
+ ControlNetworkSeed,
29
+ SeedPeer,
30
+ SeedMessage,
31
+ SeedAckMessage,
32
+ AuthorizePeerOptions,
33
+ ApplySeedResult,
34
+ SignedTransaction,
35
+ AddDroneOptions,
36
+ AddPhoneOptions,
37
+ DroneInitResult,
38
+ InviteResult,
39
+ CadreInvite
40
+ } from './types.js';
41
+ import type { ControlDatabase } from './control-database.js';
42
+
43
+ const log = debug('sereus:cadre:seed-bootstrap');
44
+
45
+ /** Protocol ID for seed delivery */
46
+ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
47
+
48
+ /** Maximum seed message size (1MB) */
49
+ const MAX_SEED_SIZE = 1024 * 1024;
50
+
51
+ /**
52
+ * Configuration for the SeedBootstrapService
53
+ */
54
+ export interface SeedBootstrapConfig {
55
+ /** Party ID for this cadre */
56
+ partyId: string;
57
+ /** Authority private key for signing seeds and peer authorizations (base64url) */
58
+ authorityPrivateKey?: string;
59
+ /** Authority public key (base64url) - derived from private key if not provided */
60
+ authorityPublicKey?: string;
61
+ }
62
+
63
+ /**
64
+ * Event callbacks for seed-related events
65
+ */
66
+ export interface SeedEventCallbacks {
67
+ /** Called when a seed is received via the protocol */
68
+ onSeedReceived?: (partyId: string, peerId: string) => void;
69
+ /** Called when a seed is successfully applied */
70
+ onSeedApplied?: (partyId: string, peersAdded: number) => void;
71
+ /** Called when seed application fails */
72
+ onSeedError?: (partyId: string, error: string) => void;
73
+ }
74
+
75
+ /**
76
+ * SeedBootstrapService handles control network seed generation and delivery.
77
+ *
78
+ * Seeds solve the cold-start problem: new nodes need control data to validate
79
+ * connections, but can't get data without connecting first. Seeds pre-populate
80
+ * the new node's cache with peer information and optionally transactions.
81
+ */
82
+ export class SeedBootstrapService {
83
+ private readonly config: SeedBootstrapConfig;
84
+ private libp2pNode: Libp2p | null = null;
85
+ private controlDatabase: ControlDatabase | null = null;
86
+ private readonly authorityPublicKey: string | null;
87
+ private eventCallbacks: SeedEventCallbacks = {};
88
+
89
+ constructor(config: SeedBootstrapConfig) {
90
+ this.config = config;
91
+
92
+ // Derive public key from private key if not provided
93
+ if (config.authorityPrivateKey && !config.authorityPublicKey) {
94
+ this.authorityPublicKey = getPublicKey(
95
+ config.authorityPrivateKey,
96
+ 'ed25519',
97
+ 'base64url',
98
+ 'base64url'
99
+ ) as string;
100
+ } else {
101
+ this.authorityPublicKey = config.authorityPublicKey ?? null;
102
+ }
103
+
104
+ log('SeedBootstrapService created for party: %s', config.partyId);
105
+ }
106
+
107
+ /**
108
+ * Set event callbacks for seed-related events.
109
+ * Used by CadreNode to emit events.
110
+ */
111
+ setEventCallbacks(callbacks: SeedEventCallbacks): void {
112
+ this.eventCallbacks = callbacks;
113
+ }
114
+
115
+ /**
116
+ * Initialize the service with libp2p node and control database
117
+ */
118
+ initialize(libp2pNode: Libp2p, controlDatabase: ControlDatabase): void {
119
+ this.libp2pNode = libp2pNode;
120
+ this.controlDatabase = controlDatabase;
121
+
122
+ // Register the seed protocol handler
123
+ this.registerProtocolHandler();
124
+
125
+ log('SeedBootstrapService initialized');
126
+ }
127
+
128
+ /**
129
+ * Authorize a new peer to join the cadre.
130
+ * Signs the peer ID with the authority key and inserts into CadrePeer table.
131
+ */
132
+ async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
133
+ const { peerId, multiaddrs } = options;
134
+
135
+ if (!this.config.authorityPrivateKey) {
136
+ throw new Error('Authority private key required to authorize peers');
137
+ }
138
+
139
+ if (!this.controlDatabase) {
140
+ throw new Error('Control database not initialized');
141
+ }
142
+
143
+ log('Authorizing peer: %s', peerId);
144
+
145
+ // Sign the peer ID with the authority key
146
+ const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url') as string;
147
+ const signature = sign(
148
+ peerIdDigest,
149
+ this.config.authorityPrivateKey,
150
+ 'ed25519',
151
+ 'base64url',
152
+ 'base64url',
153
+ 'base64url'
154
+ ) as string;
155
+
156
+ // Insert into CadrePeer table with authority context
157
+ const db = this.controlDatabase.getDatabase();
158
+ const multiaddrStr = multiaddrs?.join(',') ?? null;
159
+ const multiaddrValue = multiaddrStr ? `'${multiaddrStr}'` : 'null';
160
+
161
+ await db.exec(`
162
+ insert into CadreControl.CadrePeer (PeerId, Multiaddr)
163
+ with context AuthorityKey = '${this.authorityPublicKey}', Signature = '${signature}'
164
+ values ('${peerId}', ${multiaddrValue})
165
+ `);
166
+
167
+ log('Peer %s authorized successfully', peerId);
168
+ }
169
+
170
+ /**
171
+ * Create a seed from the current control network state.
172
+ * The seed contains peer information and is signed by an authority.
173
+ */
174
+ async createSeed(): Promise<ControlNetworkSeed> {
175
+ if (!this.config.authorityPrivateKey || !this.authorityPublicKey) {
176
+ throw new Error('Authority key required to create seeds');
177
+ }
178
+
179
+ if (!this.controlDatabase || !this.libp2pNode) {
180
+ throw new Error('Service not initialized');
181
+ }
182
+
183
+ log('Creating seed for party: %s', this.config.partyId);
184
+
185
+ // Query all peers from the control database
186
+ const peers = await this.queryPeers();
187
+
188
+ // Create the seed data (without signature)
189
+ const seedData = {
190
+ partyId: this.config.partyId,
191
+ peers,
192
+ };
193
+
194
+ // Sign the seed
195
+ const seedJson = JSON.stringify(seedData);
196
+ const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
197
+ const signature = sign(
198
+ seedDigest,
199
+ this.config.authorityPrivateKey,
200
+ 'ed25519',
201
+ 'base64url',
202
+ 'base64url',
203
+ 'base64url'
204
+ ) as string;
205
+
206
+ const seed: ControlNetworkSeed = {
207
+ ...seedData,
208
+ signature,
209
+ signerKey: this.authorityPublicKey,
210
+ };
211
+
212
+ log('Created seed with %d peers', peers.length);
213
+ return seed;
214
+ }
215
+
216
+ /**
217
+ * Apply a seed to populate the peer cache and enable connections.
218
+ * Validates the seed signature before applying.
219
+ */
220
+ async applySeed(seed: ControlNetworkSeed): Promise<ApplySeedResult> {
221
+ if (!this.libp2pNode) {
222
+ return { success: false, peersAdded: 0, error: 'Service not initialized' };
223
+ }
224
+
225
+ log('Applying seed for party: %s', seed.partyId);
226
+
227
+ // Validate the seed signature
228
+ if (!this.validateSeedSignature(seed)) {
229
+ return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
230
+ }
231
+
232
+ // Verify the signer is an authority in the seed's peer list
233
+ const signerPeer = seed.peers.find(p => p.isAuthority);
234
+ if (!signerPeer) {
235
+ return { success: false, peersAdded: 0, error: 'No authority peer in seed' };
236
+ }
237
+
238
+ let peersAdded = 0;
239
+
240
+ // Add peers to the peer store
241
+ for (const peer of seed.peers) {
242
+ try {
243
+ // Import peer multiaddrs into the peer store
244
+ if (peer.multiaddrs.length > 0) {
245
+ const peerId = peerIdFromString(peer.peerId);
246
+ const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
247
+
248
+ await this.libp2pNode.peerStore.merge(peerId, {
249
+ multiaddrs: addrs
250
+ });
251
+
252
+ peersAdded++;
253
+ log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
254
+ }
255
+ } catch (error) {
256
+ log('Failed to add peer %s: %o', peer.peerId, error);
257
+ }
258
+ }
259
+
260
+ // Dial authority peers to establish connections
261
+ for (const peer of seed.peers.filter(p => p.isAuthority)) {
262
+ try {
263
+ if (peer.multiaddrs.length > 0) {
264
+ const addr = multiaddr(peer.multiaddrs[0]);
265
+
266
+ log('Dialing authority peer: %s', peer.peerId);
267
+ await this.libp2pNode.dial(addr);
268
+ }
269
+ } catch (error) {
270
+ log('Failed to dial peer %s: %o', peer.peerId, error);
271
+ // Continue - not all peers need to be reachable
272
+ }
273
+ }
274
+
275
+ log('Applied seed: %d peers added', peersAdded);
276
+ return { success: true, peersAdded };
277
+ }
278
+
279
+ /**
280
+ * Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
281
+ */
282
+ encodeSeed(seed: ControlNetworkSeed): string {
283
+ const json = JSON.stringify(seed);
284
+ return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
285
+ }
286
+
287
+ /**
288
+ * Decode a seed from base64url encoding.
289
+ */
290
+ decodeSeed(encoded: string): ControlNetworkSeed {
291
+ const bytes = uint8ArrayFromString(encoded, 'base64url');
292
+ const json = new TextDecoder().decode(bytes);
293
+ return JSON.parse(json) as ControlNetworkSeed;
294
+ }
295
+
296
+ /**
297
+ * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
298
+ */
299
+ async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
300
+ if (!this.libp2pNode) {
301
+ throw new Error('Service not initialized');
302
+ }
303
+
304
+ const addr = multiaddr(targetMultiaddr);
305
+
306
+ log('Delivering seed to: %s', targetMultiaddr);
307
+
308
+ // Dial the target and open a stream
309
+ const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
310
+ const stream = rawStream as unknown as LibP2PStream;
311
+
312
+ try {
313
+ // Send the seed message
314
+ const message: SeedMessage = {
315
+ partyId: seed.partyId,
316
+ peers: seed.peers,
317
+ transactions: seed.transactions,
318
+ signature: seed.signature,
319
+ signerKey: seed.signerKey,
320
+ };
321
+
322
+ const messageBytes = new TextEncoder().encode(JSON.stringify(message));
323
+
324
+ // Write length-prefixed message
325
+ const lengthBytes = new Uint8Array(4);
326
+ new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
327
+
328
+ async function* sinkData() { yield lengthBytes; yield messageBytes; }
329
+ await stream.sink(sinkData());
330
+
331
+ // Read the acknowledgment
332
+ const chunks: Uint8Array[] = [];
333
+ for await (const chunk of stream.source) {
334
+ chunks.push((chunk as Uint8Array).subarray());
335
+ // Read until we have the length prefix
336
+ if (chunks.reduce((sum, c) => sum + c.length, 0) >= 4) break;
337
+ }
338
+
339
+ const responseData = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0));
340
+ let offset = 0;
341
+ for (const chunk of chunks) {
342
+ responseData.set(chunk, offset);
343
+ offset += chunk.length;
344
+ }
345
+
346
+ // Parse length and read remaining data if needed
347
+ const responseLength = new DataView(responseData.buffer).getUint32(0, false);
348
+ const responseJson = new TextDecoder().decode(responseData.slice(4, 4 + responseLength));
349
+ const ack = JSON.parse(responseJson) as SeedAckMessage;
350
+
351
+ log('Seed delivery response: accepted=%s', ack.accepted);
352
+ return ack;
353
+
354
+ } finally {
355
+ await stream.close();
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Get this node's circuit relay address for inclusion in seeds.
361
+ * Returns null if no relay address is available.
362
+ */
363
+ async getRelayAddress(): Promise<string | null> {
364
+ if (!this.libp2pNode) {
365
+ return null;
366
+ }
367
+
368
+ const addrs = this.libp2pNode.getMultiaddrs();
369
+
370
+ // Find a circuit relay address
371
+ const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
372
+
373
+ return relayAddr?.toString() ?? null;
374
+ }
375
+
376
+ /**
377
+ * Validate a seed's signature.
378
+ */
379
+ validateSeedSignature(seed: ControlNetworkSeed): boolean {
380
+ try {
381
+ // Reconstruct the signed data (seed without signature fields)
382
+ const seedData = {
383
+ partyId: seed.partyId,
384
+ peers: seed.peers,
385
+ ...(seed.transactions ? { transactions: seed.transactions } : {}),
386
+ };
387
+
388
+ const seedJson = JSON.stringify(seedData);
389
+ const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
390
+
391
+ return verify(
392
+ seedDigest,
393
+ seed.signature,
394
+ seed.signerKey,
395
+ 'ed25519',
396
+ 'base64url',
397
+ 'base64url',
398
+ 'base64url'
399
+ );
400
+ } catch (error) {
401
+ log('Seed signature validation failed: %o', error);
402
+ return false;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Query peers from the control database.
408
+ */
409
+ private async queryPeers(): Promise<SeedPeer[]> {
410
+ if (!this.controlDatabase) {
411
+ return [];
412
+ }
413
+
414
+ const db = this.controlDatabase.getDatabase();
415
+ const peers: SeedPeer[] = [];
416
+
417
+ // First, get all authority keys
418
+ const authorityKeys = new Set<string>();
419
+ for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
420
+ authorityKeys.add(row.Key as string);
421
+ }
422
+
423
+ // Query CadrePeer table
424
+ for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
425
+ const peerId = row.PeerId as string;
426
+ const multiaddr = row.Multiaddr as string | null;
427
+
428
+ // Check if this peer's public key is an authority
429
+ // Note: Authority keys are base64url public keys, not peer IDs
430
+ // For now, we mark the peer as authority if it matches the service's authority key
431
+ const isAuthority = peerId === this.libp2pNode?.peerId.toString();
432
+
433
+ peers.push({
434
+ peerId,
435
+ multiaddrs: multiaddr ? multiaddr.split(',') : [],
436
+ isAuthority,
437
+ });
438
+ }
439
+
440
+ return peers;
441
+ }
442
+
443
+ /**
444
+ * Register the seed protocol handler.
445
+ */
446
+ private registerProtocolHandler(): void {
447
+ if (!this.libp2pNode) return;
448
+
449
+ this.libp2pNode.handle(SEED_PROTOCOL, async (data: unknown) => {
450
+ const { stream, connection } = data as IncomingStreamData;
451
+ const remotePeerId = connection.remotePeer.toString();
452
+ log('Incoming seed delivery from: %s', remotePeerId);
453
+
454
+ try {
455
+ // Read the seed message
456
+ const chunks: Uint8Array[] = [];
457
+ let totalLength = 0;
458
+
459
+ for await (const chunk of stream.source) {
460
+ chunks.push((chunk as Uint8Array).subarray());
461
+ totalLength += (chunk as Uint8Array).length;
462
+ if (totalLength > MAX_SEED_SIZE) {
463
+ throw new Error('Seed message too large');
464
+ }
465
+ }
466
+
467
+ const data = new Uint8Array(totalLength);
468
+ let offset = 0;
469
+ for (const chunk of chunks) {
470
+ data.set(chunk, offset);
471
+ offset += chunk.length;
472
+ }
473
+
474
+ // Parse length-prefixed message
475
+ const messageLength = new DataView(data.buffer).getUint32(0, false);
476
+ const messageJson = new TextDecoder().decode(data.slice(4, 4 + messageLength));
477
+ const message = JSON.parse(messageJson) as SeedMessage;
478
+
479
+ // Emit seed received event
480
+ this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
481
+
482
+ // Convert to seed and apply
483
+ const seed: ControlNetworkSeed = {
484
+ partyId: message.partyId,
485
+ peers: message.peers,
486
+ transactions: message.transactions,
487
+ signature: message.signature,
488
+ signerKey: message.signerKey,
489
+ };
490
+
491
+ const result = await this.applySeed(seed);
492
+
493
+ // Emit appropriate event based on result
494
+ if (result.success) {
495
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
496
+ } else {
497
+ this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
498
+ }
499
+
500
+ // Send acknowledgment
501
+ const ack: SeedAckMessage = {
502
+ accepted: result.success,
503
+ reason: result.error,
504
+ };
505
+
506
+ const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
507
+ const lengthBytes = new Uint8Array(4);
508
+ new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
509
+
510
+ async function* sinkAck() { yield lengthBytes; yield ackBytes; }
511
+ await stream.sink(sinkAck());
512
+
513
+ } catch (error) {
514
+ log('Error handling seed delivery: %o', error);
515
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
516
+
517
+ // Emit error event
518
+ this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
519
+
520
+ // Send error acknowledgment
521
+ const ack: SeedAckMessage = {
522
+ accepted: false,
523
+ reason: errorMessage,
524
+ };
525
+
526
+ const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
527
+ const lengthBytes = new Uint8Array(4);
528
+ new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
529
+
530
+ try {
531
+ async function* sinkError() { yield lengthBytes; yield ackBytes; }
532
+ await stream.sink(sinkError());
533
+ } catch {
534
+ // Ignore sink errors
535
+ }
536
+ } finally {
537
+ await stream.close();
538
+ }
539
+ });
540
+
541
+ log('Registered seed protocol handler: %s', SEED_PROTOCOL);
542
+ }
543
+
544
+ /**
545
+ * Shutdown the service.
546
+ */
547
+ async shutdown(): Promise<void> {
548
+ if (this.libp2pNode) {
549
+ await this.libp2pNode.unhandle(SEED_PROTOCOL);
550
+ }
551
+ this.libp2pNode = null;
552
+ this.controlDatabase = null;
553
+ log('SeedBootstrapService shutdown');
554
+ }
555
+
556
+ // ============================================================================
557
+ // Helper Functions for Common Scenarios
558
+ // ============================================================================
559
+
560
+ /**
561
+ * Add a drone to the cadre (phone/server adds provider-hosted node).
562
+ *
563
+ * Use this when you've spawned a drone via provider API and received its
564
+ * peer ID and multiaddrs. This method:
565
+ * 1. Authorizes the drone peer
566
+ * 2. Creates a seed including all current peers
567
+ * 3. Returns the seed for sending to provider API
568
+ *
569
+ * @param options - Drone peer info from provider API
570
+ * @returns Seed and encoded seed for drone initialization
571
+ */
572
+ async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
573
+ const { dronePeerId, droneMultiaddrs } = options;
574
+
575
+ log('Adding drone: %s', dronePeerId);
576
+
577
+ // 1. Authorize the new drone peer
578
+ await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
579
+
580
+ // 2. Create seed with current state
581
+ const seed = await this.createSeed();
582
+
583
+ // 3. Encode for transport
584
+ const encodedSeed = this.encodeSeed(seed);
585
+
586
+ log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
587
+
588
+ return { seed, encodedSeed };
589
+ }
590
+
591
+ /**
592
+ * Create an invite for a phone to join the cadre.
593
+ *
594
+ * Use this when a server (public IP) wants to invite a phone (NAT'd).
595
+ * The invite is shared out-of-band (QR code, link, etc.) and contains
596
+ * the server's address so the phone can dial in.
597
+ *
598
+ * @param token - Optional invite token for validation
599
+ * @param expiresIn - Optional expiration time in milliseconds
600
+ * @returns Invite and encoded invite for sharing
601
+ */
602
+ async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
603
+ if (!this.libp2pNode) {
604
+ throw new Error('Service not initialized');
605
+ }
606
+
607
+ log('Creating invite for phone');
608
+
609
+ // Get this node's dialable addresses
610
+ const addrs = this.libp2pNode.getMultiaddrs();
611
+ const authorityAddrs = addrs.map(a => a.toString());
612
+
613
+ const now = Date.now();
614
+ const invite: CadreInvite = {
615
+ partyId: this.config.partyId,
616
+ authorityAddrs,
617
+ token,
618
+ createdAt: now,
619
+ expiresAt: expiresIn ? now + expiresIn : undefined,
620
+ };
621
+
622
+ const encodedInvite = this.encodeInvite(invite);
623
+
624
+ log('Invite created with %d authority addresses', authorityAddrs.length);
625
+
626
+ return { invite, encodedInvite };
627
+ }
628
+
629
+ /**
630
+ * Accept a phone connection using an invite.
631
+ *
632
+ * Use this when a phone dials in with an invite token. This method:
633
+ * 1. Validates the token if provided
634
+ * 2. Authorizes the phone peer
635
+ *
636
+ * After this, the phone can sync the control database normally.
637
+ *
638
+ * @param options - Phone peer info and invite token
639
+ * @param issuedInvite - The original invite for validation
640
+ */
641
+ async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
642
+ const { phonePeerId, token } = options;
643
+
644
+ log('Accepting phone: %s', phonePeerId);
645
+
646
+ // Validate token if invite provided
647
+ if (issuedInvite) {
648
+ if (issuedInvite.token && issuedInvite.token !== token) {
649
+ throw new Error('Invalid invite token');
650
+ }
651
+ if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
652
+ throw new Error('Invite has expired');
653
+ }
654
+ }
655
+
656
+ // Authorize the phone peer (no multiaddrs - phone is NAT'd)
657
+ await this.authorizePeer({ peerId: phonePeerId });
658
+
659
+ log('Phone %s accepted and authorized', phonePeerId);
660
+ }
661
+
662
+ /**
663
+ * Add a phone to the cadre with relay support.
664
+ *
665
+ * Use this when both nodes are NAT'd (phone-to-phone). This method:
666
+ * 1. Authorizes the new phone peer
667
+ * 2. Creates a seed with relay addresses for dialing
668
+ *
669
+ * @param phonePeerId - Peer ID of the new phone
670
+ * @returns Seed with relay addresses for out-of-band delivery
671
+ */
672
+ async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
673
+ log('Adding phone with relay: %s', phonePeerId);
674
+
675
+ // 1. Authorize the new phone peer (no multiaddrs - NAT'd)
676
+ await this.authorizePeer({ peerId: phonePeerId });
677
+
678
+ // 2. Get relay address for this node
679
+ const relayAddr = await this.getRelayAddress();
680
+
681
+ // 3. Create seed - will include our relay address if available
682
+ const seed = await this.createSeed();
683
+
684
+ // If we have a relay address, make sure it's in our peer entry
685
+ if (relayAddr && this.libp2pNode) {
686
+ const ourPeerId = this.libp2pNode.peerId.toString();
687
+ const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
688
+ if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
689
+ ourPeer.multiaddrs.push(relayAddr);
690
+ }
691
+ }
692
+
693
+ const encodedSeed = this.encodeSeed(seed);
694
+
695
+ log('Phone %s added with relay, seed created', phonePeerId);
696
+
697
+ return { seed, encodedSeed };
698
+ }
699
+
700
+ /**
701
+ * Encode an invite for out-of-band delivery.
702
+ */
703
+ encodeInvite(invite: CadreInvite): string {
704
+ const json = JSON.stringify(invite);
705
+ return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
706
+ }
707
+
708
+ /**
709
+ * Decode an invite from base64url encoding.
710
+ */
711
+ decodeInvite(encoded: string): CadreInvite {
712
+ const bytes = uint8ArrayFromString(encoded, 'base64url');
713
+ const json = new TextDecoder().decode(bytes);
714
+ return JSON.parse(json) as CadreInvite;
715
+ }
716
+
717
+ /**
718
+ * Dial an authority from an invite.
719
+ * Use this on a phone after receiving an invite to connect to the authority.
720
+ *
721
+ * @param invite - The invite received out-of-band
722
+ * @returns Connection to the authority
723
+ */
724
+ async dialInvite(invite: CadreInvite): Promise<void> {
725
+ if (!this.libp2pNode) {
726
+ throw new Error('Service not initialized');
727
+ }
728
+
729
+ // Check expiration
730
+ if (invite.expiresAt && Date.now() > invite.expiresAt) {
731
+ throw new Error('Invite has expired');
732
+ }
733
+
734
+ log('Dialing invite authority with %d addresses', invite.authorityAddrs.length);
735
+
736
+ // Try each authority address until one succeeds
737
+ let lastError: Error | null = null;
738
+ for (const addrStr of invite.authorityAddrs) {
739
+ try {
740
+ const addr = multiaddr(addrStr);
741
+ await this.libp2pNode.dial(addr);
742
+ log('Connected to authority at: %s', addrStr);
743
+ return;
744
+ } catch (error) {
745
+ lastError = error instanceof Error ? error : new Error(String(error));
746
+ log('Failed to dial %s: %o', addrStr, error);
747
+ }
748
+ }
749
+
750
+ throw lastError ?? new Error('No authority addresses available');
751
+ }
752
+ }
753
+