@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,281 @@
1
+ import debug from 'debug';
2
+ import type { Libp2p } from '@libp2p/interface';
3
+ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
4
+ import type { IRepo } from '@optimystic/db-core';
5
+ import { StrandDatabase } from './strand-database.js';
6
+ import type {
7
+ StrandInstance,
8
+ StrandRow,
9
+ StorageConfig,
10
+ NetworkConfig,
11
+ LatencyHint,
12
+ NodeProfile,
13
+ SAppConfig,
14
+ SAppInfo,
15
+ RawStorageProvider
16
+ } from './types.js';
17
+
18
+ /**
19
+ * Extended Libp2p node with coordinatedRepo attached by createLibp2pNode.
20
+ * The db-p2p createLibp2pNode function attaches these properties after node creation.
21
+ */
22
+ interface Libp2pNodeWithRepo extends Libp2p {
23
+ coordinatedRepo: IRepo;
24
+ }
25
+
26
+ const log = debug('sereus:cadre:strand-manager');
27
+
28
+ /**
29
+ * Configuration for starting a strand instance
30
+ */
31
+ export interface StartStrandConfig {
32
+ strandRow: StrandRow;
33
+ /** sApp configuration provided by the hosting application */
34
+ sAppConfig: SAppConfig;
35
+ storage?: StorageConfig;
36
+ network?: NetworkConfig;
37
+ profile: NodeProfile;
38
+ defaultLatencyHint: LatencyHint;
39
+ }
40
+
41
+ /**
42
+ * Get the isolated storage path for a specific strand.
43
+ *
44
+ * @deprecated This function uses Node.js path module which is not available in React Native.
45
+ * Use a storage provider factory function instead, which receives the strandId and can
46
+ * create strand-specific storage paths using platform-appropriate methods.
47
+ *
48
+ * @example
49
+ * // Instead of using getStrandStoragePath, use a storage provider factory:
50
+ * const storage = {
51
+ * provider: (strandId: string) => new FileRawStorage(`./data/strands/${strandId}`)
52
+ * };
53
+ */
54
+ export function getStrandStoragePath(basePath: string, strandId: string): string {
55
+ // Check if we're in a Node.js environment
56
+ if (typeof process === 'undefined' || !process.versions?.node) {
57
+ throw new Error(
58
+ 'getStrandStoragePath is not available in React Native. ' +
59
+ 'Use a storage provider factory function instead.'
60
+ );
61
+ }
62
+
63
+ // Dynamically require path only in Node.js
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
65
+ const path = require('path');
66
+
67
+ // Sanitize strandId for filesystem safety (UUIDs should be safe, but just in case)
68
+ const safeId = strandId.replace(/[^a-zA-Z0-9-]/g, '_');
69
+ return path.join(basePath, 'strands', safeId);
70
+ }
71
+
72
+ /**
73
+ * Resolve a storage provider for a specific strand.
74
+ * If the provider is a factory function, call it with the strandId.
75
+ *
76
+ * @param provider - Storage provider (instance or factory)
77
+ * @param strandId - The strand ID to create storage for
78
+ * @returns The resolved IRawStorage instance, or undefined if no provider
79
+ */
80
+ function resolveStrandStorage(
81
+ provider: RawStorageProvider | undefined,
82
+ strandId: string
83
+ ): IRawStorage | undefined {
84
+ if (!provider) {
85
+ return undefined;
86
+ }
87
+ return typeof provider === 'function' ? provider(strandId) : provider;
88
+ }
89
+
90
+ /**
91
+ * Manages individual strand instances - creates and destroys isolated libp2p nodes
92
+ * for each strand the cadre participates in.
93
+ */
94
+ export class StrandInstanceManager {
95
+ private instances: Map<string, StrandInstance> = new Map();
96
+ private stopping = false;
97
+
98
+ constructor() {
99
+ log('StrandInstanceManager created');
100
+ }
101
+
102
+ /**
103
+ * Get all current strand instances
104
+ */
105
+ getInstances(): Map<string, StrandInstance> {
106
+ return new Map(this.instances);
107
+ }
108
+
109
+ /**
110
+ * Get a specific strand instance
111
+ */
112
+ getInstance(strandId: string): StrandInstance | undefined {
113
+ return this.instances.get(strandId);
114
+ }
115
+
116
+ /**
117
+ * Check if a strand is currently running
118
+ */
119
+ hasStrand(strandId: string): boolean {
120
+ return this.instances.has(strandId);
121
+ }
122
+
123
+ /**
124
+ * Start a new strand instance
125
+ */
126
+ async startStrand(config: StartStrandConfig): Promise<StrandInstance> {
127
+ const { strandRow, sAppConfig } = config;
128
+ const strandId = strandRow.Id;
129
+
130
+ if (this.stopping) {
131
+ throw new Error('StrandInstanceManager is stopping');
132
+ }
133
+
134
+ if (this.instances.has(strandId)) {
135
+ log('Strand %s already running', strandId);
136
+ return this.instances.get(strandId)!;
137
+ }
138
+
139
+ log('Starting strand instance: %s (sApp: %s v%s)', strandId, sAppConfig.id, sAppConfig.version);
140
+
141
+ // Convert SAppConfig to SAppInfo for the instance
142
+ const sAppInfo: SAppInfo = {
143
+ id: sAppConfig.id,
144
+ version: sAppConfig.version,
145
+ schema: sAppConfig.schema,
146
+ signature: sAppConfig.signature
147
+ };
148
+
149
+ // Determine latency hint: sApp config > default
150
+ const latencyHint = sAppConfig.latencyHint ?? config.defaultLatencyHint;
151
+
152
+ const instance: StrandInstance = {
153
+ strandId,
154
+ status: 'starting',
155
+ sAppInfo,
156
+ memberPrivateKey: strandRow.MemberPrivateKey ?? undefined,
157
+ connectedPeers: 0,
158
+ lastActivity: new Date(),
159
+ latencyHint
160
+ };
161
+
162
+ this.instances.set(strandId, instance);
163
+
164
+ try {
165
+ // Resolve storage for this strand
166
+ // If a factory function is provided, it will be called with the strandId
167
+ // to create strand-specific storage (e.g., strand-isolated directories)
168
+ const strandStorage = resolveStrandStorage(config.storage?.provider, strandId);
169
+ if (strandStorage) {
170
+ log('Strand %s using provided storage provider', strandId);
171
+ }
172
+
173
+ // Create isolated libp2p node for this strand
174
+ // Note: protocolPrefix would be used by libp2p services, but createLibp2pNode
175
+ // may not support it yet - tracked for future implementation
176
+ const _protocolPrefix = `/sereus/strand/${strandId}`;
177
+
178
+ // Determine relay mode: if explicitly set in config, use that;
179
+ // otherwise default to true for storage profile nodes
180
+ const enableRelay = config.network?.enableRelay ?? (config.profile === 'storage');
181
+
182
+ const node = await createLibp2pNode({
183
+ port: 0, // Random port
184
+ bootstrapNodes: [], // Will be populated from strand cohort
185
+ networkName: `strand-${strandId}`,
186
+ storage: strandStorage,
187
+ fretProfile: config.profile === 'storage' ? 'core' : 'edge',
188
+ relay: enableRelay,
189
+ clusterSize: 3,
190
+ clusterPolicy: {
191
+ allowDownsize: true,
192
+ sizeTolerance: 0.5
193
+ },
194
+ arachnode: {
195
+ enableRingZulu: true
196
+ // Storage ring participation stub - will be added when arachnode is built
197
+ // storageRing: config.profile === 'storage' ? { ring: 0 } : undefined
198
+ },
199
+ ...(config.network?.transports && { transports: config.network.transports }),
200
+ ...(config.network?.listenAddrs && { listenAddrs: config.network.listenAddrs })
201
+ }) as Libp2pNodeWithRepo;
202
+
203
+ instance.libp2pNode = node;
204
+
205
+ // Create and initialize the StrandDatabase
206
+ const strandDb = new StrandDatabase({
207
+ strandId,
208
+ sAppConfig,
209
+ libp2pNode: node,
210
+ coordinatedRepo: node.coordinatedRepo
211
+ });
212
+ await strandDb.initialize();
213
+ instance.database = strandDb;
214
+
215
+ instance.status = 'active';
216
+ instance.lastActivity = new Date();
217
+
218
+ log('Strand %s started successfully with sApp %s', strandId, sAppConfig.id);
219
+ return instance;
220
+
221
+ } catch (error) {
222
+ instance.status = 'error';
223
+ instance.error = error instanceof Error ? error.message : String(error);
224
+ log('Failed to start strand %s: %s', strandId, instance.error);
225
+ throw error;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Stop a strand instance
231
+ */
232
+ async stopStrand(strandId: string): Promise<void> {
233
+ const instance = this.instances.get(strandId);
234
+ if (!instance) {
235
+ log('Strand %s not found', strandId);
236
+ return;
237
+ }
238
+
239
+ log('Stopping strand instance: %s', strandId);
240
+ instance.status = 'stopping';
241
+
242
+ try {
243
+ // Close the database before stopping libp2p
244
+ if (instance.database) {
245
+ await instance.database.close();
246
+ instance.database = undefined;
247
+ }
248
+ if (instance.libp2pNode) {
249
+ await instance.libp2pNode.stop();
250
+ instance.libp2pNode = undefined;
251
+ }
252
+ instance.status = 'stopped';
253
+ this.instances.delete(strandId);
254
+ log('Strand %s stopped successfully', strandId);
255
+ } catch (error) {
256
+ instance.status = 'error';
257
+ instance.error = error instanceof Error ? error.message : String(error);
258
+ log('Error stopping strand %s: %s', strandId, instance.error);
259
+ throw error;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Stop all strand instances
265
+ */
266
+ async stopAll(): Promise<void> {
267
+ this.stopping = true;
268
+ log('Stopping all strand instances (%d)', this.instances.size);
269
+
270
+ const stopPromises = Array.from(this.instances.keys()).map(id =>
271
+ this.stopStrand(id).catch(err => {
272
+ log('Error stopping strand %s during shutdown: %s', id, err);
273
+ })
274
+ );
275
+
276
+ await Promise.all(stopPromises);
277
+ this.stopping = false;
278
+ log('All strand instances stopped');
279
+ }
280
+ }
281
+
@@ -0,0 +1,318 @@
1
+ import debug from 'debug';
2
+ import { toString as uint8ArrayToString } from 'uint8arrays';
3
+ import { generateKeyPair, privateKeyToProtobuf } from '@libp2p/crypto/keys';
4
+ import { peerIdFromPrivateKey } from '@libp2p/peer-id';
5
+ import type { Libp2p } from '@libp2p/interface';
6
+ import type {
7
+ OpenInvitation,
8
+ FormStrandResult,
9
+ ValidateFormationResult,
10
+ StrandFormationDisclosure
11
+ } from './types.js';
12
+ import {
13
+ StrandFormationManager,
14
+ type StrandFormationManagerConfig
15
+ } from './strand-formation-manager.js';
16
+
17
+ const log = debug('sereus:cadre:solicitation');
18
+
19
+ /**
20
+ * Interface for validating formation disclosures
21
+ */
22
+ export interface DisclosureValidator {
23
+ /**
24
+ * Validate the disclosure provided by an initiator
25
+ * @param token The invitation token
26
+ * @param disclosure The disclosure object from the initiator
27
+ * @returns Whether the disclosure is acceptable
28
+ */
29
+ validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
30
+ }
31
+
32
+ /**
33
+ * Interface for recording formation usage
34
+ */
35
+ export interface FormationUsageRecorder {
36
+ /**
37
+ * Record that a formation invite was used
38
+ */
39
+ recordUsage(token: string, initiatorKey: string, strandId: string): Promise<void>;
40
+
41
+ /**
42
+ * Check if a token has already been used (for single-use invites)
43
+ */
44
+ isTokenUsed(token: string): Promise<boolean>;
45
+
46
+ /**
47
+ * Check if a token is valid and not expired
48
+ */
49
+ isTokenValid(token: string): Promise<{ valid: boolean; invitation?: OpenInvitation }>;
50
+ }
51
+
52
+ /**
53
+ * Interface for strand provisioning during formation
54
+ */
55
+ export interface StrandProvisioner {
56
+ /**
57
+ * Provision a new strand after formation is validated
58
+ */
59
+ provisionStrand(
60
+ sAppId: string,
61
+ initiatorKey: string,
62
+ responderKey: string
63
+ ): Promise<{ strandId: string }>;
64
+ }
65
+
66
+ /**
67
+ * Interface for signing formation approvals
68
+ */
69
+ export interface FormationSigner {
70
+ /**
71
+ * Sign a formation approval
72
+ */
73
+ signFormation(token: string, disclosure: StrandFormationDisclosure): Promise<{
74
+ validationKey: string;
75
+ validationSignature: string;
76
+ }>;
77
+ }
78
+
79
+ export interface StrandSolicitationServiceOptions {
80
+ disclosureValidator?: DisclosureValidator;
81
+ formationUsageRecorder?: FormationUsageRecorder;
82
+ strandProvisioner?: StrandProvisioner;
83
+ formationSigner?: FormationSigner;
84
+ /** Party ID for this node (used in protocol messages) */
85
+ partyId?: string;
86
+ /** Cadre peer addresses for this node */
87
+ cadrePeerAddrs?: string[];
88
+ /** Configuration for the formation manager */
89
+ formationConfig?: StrandFormationManagerConfig;
90
+ }
91
+
92
+ /**
93
+ * Strand Solicitation API for forming strands via open invitations.
94
+ *
95
+ * This service handles the high-level API defined in api.md:
96
+ * - formStrand(invitation, disclosure, node) - called by initiator
97
+ * - validateStrandFormation(token, disclosure) - called by responder
98
+ *
99
+ * When a libp2p node is provided, the underlying protocol is handled by
100
+ * strand-proto's SessionManager via StrandFormationManager.
101
+ */
102
+ export class StrandSolicitationService {
103
+ private readonly disclosureValidator?: DisclosureValidator;
104
+ private readonly formationUsageRecorder?: FormationUsageRecorder;
105
+ private readonly strandProvisioner?: StrandProvisioner;
106
+ private readonly formationSigner?: FormationSigner;
107
+ private readonly partyId: string;
108
+ private readonly cadrePeerAddrs: string[];
109
+ private formationManager?: StrandFormationManager;
110
+ private readonly formationConfig?: StrandFormationManagerConfig;
111
+
112
+ constructor(options?: StrandSolicitationServiceOptions) {
113
+ this.disclosureValidator = options?.disclosureValidator;
114
+ this.formationUsageRecorder = options?.formationUsageRecorder;
115
+ this.strandProvisioner = options?.strandProvisioner;
116
+ this.formationSigner = options?.formationSigner;
117
+ this.partyId = options?.partyId ?? `party-${Date.now()}`;
118
+ this.cadrePeerAddrs = options?.cadrePeerAddrs ?? [];
119
+ this.formationConfig = options?.formationConfig;
120
+ log('StrandSolicitationService created for party: %s', this.partyId);
121
+ }
122
+
123
+ /**
124
+ * Get or create the StrandFormationManager.
125
+ * Lazily initialized to allow configuration after construction.
126
+ */
127
+ private getFormationManager(): StrandFormationManager {
128
+ if (!this.formationManager) {
129
+ this.formationManager = new StrandFormationManager({
130
+ disclosureValidator: this.disclosureValidator,
131
+ formationUsageRecorder: this.formationUsageRecorder,
132
+ strandProvisioner: this.strandProvisioner,
133
+ partyId: this.partyId,
134
+ cadrePeerAddrs: this.cadrePeerAddrs,
135
+ config: this.formationConfig
136
+ });
137
+ }
138
+ return this.formationManager;
139
+ }
140
+
141
+ /**
142
+ * Register as a responder on a libp2p node.
143
+ * This enables the node to handle incoming strand formation requests.
144
+ */
145
+ registerResponder(node: Libp2p): void {
146
+ this.getFormationManager().registerResponder(node);
147
+ log('Registered as responder');
148
+ }
149
+
150
+ /**
151
+ * Unregister as a responder from a libp2p node.
152
+ */
153
+ unregisterResponder(node: Libp2p): void {
154
+ this.getFormationManager().unregisterResponder(node);
155
+ log('Unregistered as responder');
156
+ }
157
+
158
+ /**
159
+ * Form a strand with a responder via an open invitation.
160
+ *
161
+ * Called by the initiator (the party who received an out-of-band invitation).
162
+ * This generates a member key, contacts the responder's cadre, and negotiates
163
+ * strand formation.
164
+ *
165
+ * @param invitation The open invitation (or just token for legacy API)
166
+ * @param disclosure Identity/context information to share with the responder
167
+ * @param node Optional libp2p node for real protocol handling
168
+ * @returns The member key and strand info if successful
169
+ */
170
+ async formStrand(
171
+ invitation: OpenInvitation | string,
172
+ disclosure: StrandFormationDisclosure,
173
+ node?: Libp2p
174
+ ): Promise<FormStrandResult> {
175
+ // Handle legacy API where just token was passed
176
+ const token = typeof invitation === 'string' ? invitation : invitation.token;
177
+ log('Forming strand with token: %s', token);
178
+
179
+ // Generate a new keypair for this strand membership
180
+ const privateKey = await generateKeyPair('Ed25519');
181
+ const peerId = peerIdFromPrivateKey(privateKey);
182
+ const privateKeyBytes = privateKeyToProtobuf(privateKey);
183
+
184
+ const memberKey = peerId.toString();
185
+ const invitePrivateKey = uint8ArrayToString(privateKeyBytes, 'base64');
186
+
187
+ log('Generated member key: %s', memberKey);
188
+
189
+ // If we have a full invitation and a node, use the real protocol
190
+ if (typeof invitation !== 'string' && node) {
191
+ log('Using strand-proto for real protocol handling');
192
+ const result = await this.getFormationManager().formStrand(
193
+ invitation,
194
+ { ...disclosure, partyId: memberKey },
195
+ node
196
+ );
197
+ return {
198
+ memberKey,
199
+ invitePrivateKey,
200
+ strandId: result.strandId
201
+ };
202
+ }
203
+
204
+ // Fallback: placeholder strandId (for testing without network)
205
+ log('No node provided, using placeholder strandId');
206
+ const strandId = `strand-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
207
+
208
+ return {
209
+ memberKey,
210
+ invitePrivateKey,
211
+ strandId
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Validate a strand formation request.
217
+ *
218
+ * Called by the responder (the party who created the open invitation) when
219
+ * an initiator contacts them to form a strand. Validates the disclosure
220
+ * and returns a signed approval.
221
+ *
222
+ * @param token The invitation token being used
223
+ * @param disclosure The disclosure from the initiator
224
+ * @returns Validation key and signature if approved
225
+ * @throws If validation fails or token is invalid
226
+ */
227
+ async validateStrandFormation(
228
+ token: string,
229
+ disclosure: StrandFormationDisclosure
230
+ ): Promise<ValidateFormationResult> {
231
+ log('Validating strand formation for token: %s', token);
232
+
233
+ // Check token validity
234
+ if (this.formationUsageRecorder) {
235
+ const tokenCheck = await this.formationUsageRecorder.isTokenValid(token);
236
+ if (!tokenCheck.valid) {
237
+ log('Token invalid or expired: %s', token);
238
+ throw new Error('Invalid or expired token');
239
+ }
240
+
241
+ // Check if already used (for single-use tokens)
242
+ const isUsed = await this.formationUsageRecorder.isTokenUsed(token);
243
+ if (isUsed) {
244
+ log('Token already used: %s', token);
245
+ throw new Error('Token has already been used');
246
+ }
247
+ }
248
+
249
+ // Validate the disclosure
250
+ if (this.disclosureValidator) {
251
+ const isValid = await this.disclosureValidator.validateDisclosure(token, disclosure);
252
+ if (!isValid) {
253
+ log('Disclosure validation failed for token: %s', token);
254
+ throw new Error('Disclosure validation failed');
255
+ }
256
+ }
257
+
258
+ // Sign the formation approval
259
+ if (!this.formationSigner) {
260
+ throw new Error('FormationSigner not configured');
261
+ }
262
+
263
+ const { validationKey, validationSignature } = await this.formationSigner.signFormation(
264
+ token,
265
+ disclosure
266
+ );
267
+
268
+ log('Formation validated, key: %s', validationKey);
269
+
270
+ return {
271
+ validationKey,
272
+ validationSignature
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Create an open invitation for others to form strands with this party.
278
+ *
279
+ * @param sAppId The sApp to use for formed strands
280
+ * @param expirationMs How long the invitation is valid (ms from now)
281
+ * @param bootstrap Bootstrap addresses for contacting this party's cadre
282
+ * @returns The open invitation to share out-of-band
283
+ */
284
+ async createOpenInvitation(
285
+ sAppId: string,
286
+ expirationMs: number,
287
+ bootstrap: string[]
288
+ ): Promise<OpenInvitation> {
289
+ // Generate a unique token
290
+ const token = `invite-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
291
+ const expiration = new Date(Date.now() + expirationMs);
292
+
293
+ log('Created open invitation: %s (expires %s)', token, expiration.toISOString());
294
+
295
+ return {
296
+ token,
297
+ sAppId,
298
+ expiration,
299
+ bootstrap
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Record that a formation was completed successfully.
305
+ * Called after strand provisioning to track usage.
306
+ */
307
+ async recordFormationComplete(
308
+ token: string,
309
+ initiatorKey: string,
310
+ strandId: string
311
+ ): Promise<void> {
312
+ if (this.formationUsageRecorder) {
313
+ await this.formationUsageRecorder.recordUsage(token, initiatorKey, strandId);
314
+ log('Recorded formation usage: token=%s strand=%s', token, strandId);
315
+ }
316
+ }
317
+ }
318
+