@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.
- package/README.md +252 -0
- package/dist/arachnode-stub.d.ts +60 -0
- package/dist/arachnode-stub.js +120 -0
- package/dist/arachnode-stub.js.map +1 -0
- package/dist/cadre-node.d.ts +252 -0
- package/dist/cadre-node.js +753 -0
- package/dist/cadre-node.js.map +1 -0
- package/dist/control-database.d.ts +62 -0
- package/dist/control-database.js +295 -0
- package/dist/control-database.js.map +1 -0
- package/dist/enrollment.d.ts +76 -0
- package/dist/enrollment.js +126 -0
- package/dist/enrollment.js.map +1 -0
- package/dist/hibernation-manager.d.ts +68 -0
- package/dist/hibernation-manager.js +212 -0
- package/dist/hibernation-manager.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +164 -0
- package/dist/seed-bootstrap.js +554 -0
- package/dist/seed-bootstrap.js.map +1 -0
- package/dist/strand-database.d.ts +39 -0
- package/dist/strand-database.js +108 -0
- package/dist/strand-database.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -0
- package/dist/strand-formation-manager.js +168 -0
- package/dist/strand-formation-manager.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +60 -0
- package/dist/strand-instance-manager.js +209 -0
- package/dist/strand-instance-manager.js.map +1 -0
- package/dist/strand-solicitation.d.ts +145 -0
- package/dist/strand-solicitation.js +180 -0
- package/dist/strand-solicitation.js.map +1 -0
- package/dist/strand-watcher.d.ts +70 -0
- package/dist/strand-watcher.js +136 -0
- package/dist/strand-watcher.js.map +1 -0
- package/dist/types.d.ts +484 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/src/arachnode-stub.ts +160 -0
- package/src/cadre-node.ts +923 -0
- package/src/control-database.ts +376 -0
- package/src/enrollment.ts +199 -0
- package/src/hibernation-manager.ts +268 -0
- package/src/index.ts +71 -0
- package/src/seed-bootstrap.ts +753 -0
- package/src/strand-database.ts +168 -0
- package/src/strand-formation-manager.ts +277 -0
- package/src/strand-instance-manager.ts +281 -0
- package/src/strand-solicitation.ts +318 -0
- package/src/strand-watcher.ts +197 -0
- 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
|
+
|