@learncard/holder-continuity 0.2.4 → 0.2.6

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.
@@ -0,0 +1,652 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { isIP } from 'node:net';
4
+ import { dirname } from 'node:path';
5
+
6
+ import JSZip from 'jszip';
7
+ import { shareToRecoveryPhrase, splitPrivateKey } from '@learncard/sss-key-manager';
8
+
9
+ import type {
10
+ BundleContentType,
11
+ ExportLearnCardBundleOptions,
12
+ JsonValue,
13
+ LearnCardBundleEntryMetadata,
14
+ LearnCardBundleOptions,
15
+ LearnCardBundleResult,
16
+ LearnCardBundleWallet,
17
+ } from './types';
18
+ import { BUNDLE_README_MD, BUNDLE_SPEC_MD, finalizeManifest, SPEC_VERSION } from './manifest';
19
+ import { encodePayload, sha256Hex, stableStringify } from './crypto';
20
+
21
+ const ZIP_DATE = new Date('2024-01-01T00:00:00.000Z');
22
+
23
+ const DEFAULT_STATUS_LIST_FETCH_TIMEOUT_MS = 5000;
24
+ const DEFAULT_MAX_STATUS_LIST_BYTES = 5 * 1024 * 1024;
25
+
26
+ const stripIpv6Brackets = (hostname: string): string =>
27
+ hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
28
+
29
+ const redactMalformedUrl = (value: string): string => {
30
+ const queryIndex = value.indexOf('?');
31
+ const ampIndex = value.indexOf('&');
32
+
33
+ if (queryIndex === -1 && ampIndex === -1) return value;
34
+
35
+ const redactionIndex =
36
+ queryIndex === -1
37
+ ? ampIndex
38
+ : ampIndex === -1
39
+ ? queryIndex
40
+ : Math.min(queryIndex, ampIndex);
41
+
42
+ return `${value.slice(0, redactionIndex)}?[redacted]`;
43
+ };
44
+
45
+ const redactUrl = (value: string): string => {
46
+ try {
47
+ const url = new URL(value);
48
+ const hostname = stripIpv6Brackets(url.hostname.toLowerCase());
49
+ const shouldRedactHost =
50
+ !hostname.includes('.') ||
51
+ hostname === 'localhost' ||
52
+ hostname.endsWith('.localhost') ||
53
+ Boolean(isIP(hostname) && isPrivateAddress(hostname));
54
+ const host = shouldRedactHost ? '[redacted-host]' : url.host;
55
+
56
+ return `${url.protocol}//${host}${url.pathname}${url.search ? '?[redacted]' : ''}`;
57
+ } catch {
58
+ return redactMalformedUrl(value);
59
+ }
60
+ };
61
+
62
+ const urlEndDelimiters = new Set([' ', '\n', '\r', '\t', "'", '"', '<', '>']);
63
+
64
+ const findNextUrlStart = (value: string, fromIndex: number): number => {
65
+ const httpIndex = value.indexOf('http://', fromIndex);
66
+ const httpsIndex = value.indexOf('https://', fromIndex);
67
+
68
+ if (httpIndex === -1) return httpsIndex;
69
+ if (httpsIndex === -1) return httpIndex;
70
+
71
+ return Math.min(httpIndex, httpsIndex);
72
+ };
73
+
74
+ const findUrlEnd = (value: string, fromIndex: number): number => {
75
+ let index = fromIndex;
76
+
77
+ while (index < value.length && !urlEndDelimiters.has(value[index]!)) index += 1;
78
+
79
+ return index;
80
+ };
81
+
82
+ const redactText = (value: string): string => {
83
+ let redacted = '';
84
+ let index = 0;
85
+
86
+ while (index < value.length) {
87
+ const urlStart = findNextUrlStart(value, index);
88
+
89
+ if (urlStart === -1) {
90
+ redacted += value.slice(index);
91
+ break;
92
+ }
93
+
94
+ const urlEnd = findUrlEnd(value, urlStart);
95
+
96
+ redacted += value.slice(index, urlStart);
97
+ redacted += redactUrl(value.slice(urlStart, urlEnd));
98
+ index = urlEnd;
99
+ }
100
+
101
+ return redacted;
102
+ };
103
+
104
+ const safeMessage = (error: unknown): string =>
105
+ redactText(error instanceof Error ? error.message : String(error));
106
+
107
+ const isPrivateIPv4 = (address: string): boolean => {
108
+ const parts = address.split('.').map(part => Number(part));
109
+
110
+ if (parts.length !== 4 || parts.some(part => !Number.isInteger(part) || part < 0 || part > 255))
111
+ return true;
112
+
113
+ const [a, b] = parts;
114
+
115
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return true;
116
+ if (a === 100 && b >= 64 && b <= 127) return true;
117
+ if (a === 169 && b === 254) return true;
118
+ if (a === 172 && b >= 16 && b <= 31) return true;
119
+ if (a === 192 && b === 168) return true;
120
+ if (a === 198 && (b === 18 || b === 19)) return true;
121
+
122
+ return false;
123
+ };
124
+
125
+ const isPrivateIPv6 = (address: string): boolean => {
126
+ const normalized = address.toLowerCase();
127
+
128
+ if (normalized === '::' || normalized === '::1') return true;
129
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
130
+ if (
131
+ normalized.startsWith('fe8') ||
132
+ normalized.startsWith('fe9') ||
133
+ normalized.startsWith('fea') ||
134
+ normalized.startsWith('feb')
135
+ )
136
+ return true;
137
+
138
+ const ipv4Mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
139
+
140
+ return ipv4Mapped ? isPrivateIPv4(ipv4Mapped[1]!) : false;
141
+ };
142
+
143
+ const isPrivateAddress = (address: string): boolean => {
144
+ const version = isIP(address);
145
+
146
+ if (version === 4) return isPrivateIPv4(address);
147
+ if (version === 6) return isPrivateIPv6(address);
148
+
149
+ return true;
150
+ };
151
+
152
+ const assertPublicHttpsUrl = async (uri: string): Promise<URL> => {
153
+ const url = new URL(uri);
154
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
155
+
156
+ if (url.protocol !== 'https:') throw new Error('status-list URL must use https');
157
+ if (!hostname.includes('.') || hostname === 'localhost' || hostname.endsWith('.localhost')) {
158
+ throw new Error('status-list URL host must be public');
159
+ }
160
+
161
+ if (isIP(hostname)) {
162
+ if (isPrivateAddress(hostname)) throw new Error('status-list URL host must be public');
163
+
164
+ return url;
165
+ }
166
+
167
+ const addresses = await lookup(hostname, { all: true, verbatim: true });
168
+
169
+ if (addresses.length === 0 || addresses.some(address => isPrivateAddress(address.address))) {
170
+ throw new Error('status-list URL host must resolve to public addresses');
171
+ }
172
+
173
+ return url;
174
+ };
175
+
176
+ const readResponseText = async (response: Response, maxBytes: number): Promise<string> => {
177
+ const contentLength = Number(response.headers.get('content-length'));
178
+
179
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
180
+ throw new Error(`status-list response exceeds ${maxBytes} bytes`);
181
+ }
182
+
183
+ if (!response.body) {
184
+ const text = await response.text();
185
+
186
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
187
+ throw new Error(`status-list response exceeds ${maxBytes} bytes`);
188
+ }
189
+
190
+ return text;
191
+ }
192
+
193
+ const reader = response.body.getReader();
194
+ const chunks: Buffer[] = [];
195
+ let total = 0;
196
+
197
+ while (true) {
198
+ const { done, value } = await reader.read();
199
+
200
+ if (done) break;
201
+
202
+ total += value.byteLength;
203
+
204
+ if (total > maxBytes) {
205
+ await reader.cancel();
206
+
207
+ throw new Error(`status-list response exceeds ${maxBytes} bytes`);
208
+ }
209
+
210
+ chunks.push(Buffer.from(value));
211
+ }
212
+
213
+ return Buffer.concat(chunks).toString('utf8');
214
+ };
215
+
216
+ const fetchJsonWithTimeout = async (
217
+ url: URL,
218
+ timeoutMs: number,
219
+ maxBytes: number
220
+ ): Promise<JsonValue> => {
221
+ const controller = new AbortController();
222
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
223
+
224
+ try {
225
+ const response = await fetch(url, { signal: controller.signal });
226
+
227
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
228
+
229
+ return JSON.parse(await readResponseText(response, maxBytes)) as JsonValue;
230
+ } finally {
231
+ clearTimeout(timeout);
232
+ }
233
+ };
234
+
235
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
236
+ Boolean(value) && typeof value === 'object' && !Array.isArray(value);
237
+
238
+ const stringArrayIncludes = (value: unknown, expected: string): boolean =>
239
+ Array.isArray(value) && value.some(item => item === expected);
240
+
241
+ const json = (value: unknown): string => `${stableStringify(value)}\n`;
242
+
243
+ const classifyPayload = (payload: JsonValue): BundleContentType => {
244
+ if (!isRecord(payload)) return 'unknown-json';
245
+
246
+ if (
247
+ stringArrayIncludes(payload.type, 'VerifiablePresentation') ||
248
+ payload.type === 'VerifiablePresentation'
249
+ ) {
250
+ return 'presentation';
251
+ }
252
+
253
+ if (
254
+ stringArrayIncludes(payload.type, 'VerifiableCredential') ||
255
+ payload.type === 'VerifiableCredential'
256
+ ) {
257
+ return 'credential';
258
+ }
259
+
260
+ return 'unknown-json';
261
+ };
262
+
263
+ const pathForType = (type: BundleContentType, digest: string, encrypted: boolean): string => {
264
+ const suffix = encrypted ? '.enc' : '';
265
+
266
+ if (type === 'presentation') return `presentations/${digest}.json${suffix}`;
267
+
268
+ if (type === 'consent-record') return `consent-records/${digest}.json${suffix}`;
269
+
270
+ if (type === 'index-record') return `index-records/${digest}.json${suffix}`;
271
+ if (type === 'status-cache') return `status-cache/${digest}.json${suffix}`;
272
+
273
+ return `credentials/${digest}.json${suffix}`;
274
+ };
275
+
276
+ const getCredentialId = (payload: JsonValue): string | undefined =>
277
+ isRecord(payload) && typeof payload.id === 'string' ? payload.id : undefined;
278
+
279
+ const addZipText = (zip: JSZip, path: string, content: string): void => {
280
+ zip.file(path, content, { date: ZIP_DATE });
281
+ };
282
+
283
+ const collectDids = (wallet: LearnCardBundleWallet, warnings: string[]): Record<string, string> => {
284
+ const dids: Record<string, string> = {};
285
+
286
+ for (const method of [undefined, 'key', 'pkh:sol', 'tz', 'pkh:tz']) {
287
+ try {
288
+ dids[method ?? 'default'] = wallet.id.did(method);
289
+ } catch (error) {
290
+ warnings.push(
291
+ `Could not derive DID method ${method ?? 'default'}: ${safeMessage(error)}`
292
+ );
293
+ }
294
+ }
295
+
296
+ return dids;
297
+ };
298
+
299
+ const collectDidDocument = async (
300
+ wallet: LearnCardBundleWallet,
301
+ primaryDid: string,
302
+ dids: Record<string, string>,
303
+ warnings: string[]
304
+ ): Promise<JsonValue> => {
305
+ if (!wallet.invoke.resolveDid) return { primaryDid, dids };
306
+
307
+ try {
308
+ return { primaryDid, dids, primaryDidDocument: await wallet.invoke.resolveDid(primaryDid) };
309
+ } catch (error) {
310
+ warnings.push(`Could not resolve primary DID document: ${safeMessage(error)}`);
311
+
312
+ return { primaryDid, dids };
313
+ }
314
+ };
315
+
316
+ const collectKeyPayloads = async (
317
+ wallet: LearnCardBundleWallet,
318
+ warnings: string[]
319
+ ): Promise<
320
+ Array<{ path: string; type: BundleContentType; content: string; encrypted: boolean }>
321
+ > => {
322
+ const payloads: Array<{
323
+ path: string;
324
+ type: BundleContentType;
325
+ content: string;
326
+ encrypted: boolean;
327
+ }> = [];
328
+
329
+ if (wallet.invoke.getKey) {
330
+ try {
331
+ const seed = wallet.invoke.getKey();
332
+ const shares = await splitPrivateKey(seed);
333
+
334
+ payloads.push({
335
+ path: 'keys/private-key-seed.txt',
336
+ type: 'key-private-seed',
337
+ content: `${seed}\n`,
338
+ encrypted: true,
339
+ });
340
+
341
+ payloads.push({
342
+ path: 'keys/recovery-phrase.txt',
343
+ type: 'key-recovery-phrase',
344
+ content: `${await shareToRecoveryPhrase(shares.recoveryShare)}\n`,
345
+ encrypted: true,
346
+ });
347
+ } catch (error) {
348
+ warnings.push(`Could not export seed or recovery phrase: ${safeMessage(error)}`);
349
+ }
350
+ } else {
351
+ warnings.push(
352
+ 'Wallet does not expose invoke.getKey(); private seed and recovery phrase were not exported'
353
+ );
354
+ }
355
+
356
+ if (wallet.id.keypair) {
357
+ const jwks: Record<string, JsonValue> = {};
358
+
359
+ for (const algorithm of ['ed25519', 'secp256k1'] as const) {
360
+ try {
361
+ jwks[algorithm] = wallet.id.keypair(algorithm);
362
+ } catch (error) {
363
+ warnings.push(`Could not export ${algorithm} JWK: ${safeMessage(error)}`);
364
+ }
365
+ }
366
+
367
+ if (Object.keys(jwks).length > 0) {
368
+ payloads.push({
369
+ path: 'keys/jwks.json',
370
+ type: 'key-jwks',
371
+ content: json(jwks),
372
+ encrypted: true,
373
+ });
374
+ }
375
+ } else {
376
+ warnings.push('Wallet does not expose id.keypair(); JWK key export was skipped');
377
+ }
378
+
379
+ return payloads;
380
+ };
381
+
382
+ const extractStatusListUrls = (payload: JsonValue): string[] => {
383
+ if (!isRecord(payload)) return [];
384
+
385
+ const statuses = Array.isArray(payload.credentialStatus)
386
+ ? payload.credentialStatus
387
+ : payload.credentialStatus
388
+ ? [payload.credentialStatus]
389
+ : [];
390
+
391
+ return statuses.flatMap(status =>
392
+ isRecord(status) && typeof status.statusListCredential === 'string'
393
+ ? [status.statusListCredential]
394
+ : []
395
+ );
396
+ };
397
+
398
+ const collectStatusLists = async (
399
+ payloads: JsonValue[],
400
+ enabled: boolean,
401
+ warnings: string[],
402
+ timeoutMs = DEFAULT_STATUS_LIST_FETCH_TIMEOUT_MS,
403
+ maxBytes = DEFAULT_MAX_STATUS_LIST_BYTES
404
+ ): Promise<Array<{ uri: string; content: JsonValue }>> => {
405
+ if (!enabled) return [];
406
+
407
+ const urls = [...new Set(payloads.flatMap(extractStatusListUrls))];
408
+ const results: Array<{ uri: string; content: JsonValue }> = [];
409
+
410
+ for (const uri of urls) {
411
+ try {
412
+ const url = await assertPublicHttpsUrl(uri);
413
+
414
+ results.push({ uri, content: await fetchJsonWithTimeout(url, timeoutMs, maxBytes) });
415
+ } catch (error) {
416
+ warnings.push(
417
+ `Could not cache status-list credential ${redactUrl(uri)}: ${safeMessage(error)}`
418
+ );
419
+ }
420
+ }
421
+
422
+ return results;
423
+ };
424
+
425
+ const collectConsentRecords = async (
426
+ wallet: LearnCardBundleWallet,
427
+ warnings: string[]
428
+ ): Promise<JsonValue[]> => {
429
+ if (wallet.invoke.getHolderExportMetadata) {
430
+ try {
431
+ const metadata = await wallet.invoke.getHolderExportMetadata();
432
+
433
+ if (isRecord(metadata) && Array.isArray(metadata.warnings)) {
434
+ warnings.push(
435
+ ...metadata.warnings
436
+ .filter(warning => typeof warning === 'string')
437
+ .map(warning => redactText(warning))
438
+ );
439
+ }
440
+
441
+ return isRecord(metadata) && Array.isArray(metadata.consentRecords)
442
+ ? (metadata.consentRecords as JsonValue[])
443
+ : [];
444
+ } catch (error) {
445
+ warnings.push(`Could not fetch holder export metadata: ${safeMessage(error)}`);
446
+ }
447
+ }
448
+
449
+ if (!wallet.invoke.getConsentedContracts) return [];
450
+
451
+ try {
452
+ const response = await wallet.invoke.getConsentedContracts();
453
+
454
+ if (!isRecord(response) || !Array.isArray(response.records)) return [];
455
+
456
+ return response.records as JsonValue[];
457
+ } catch (error) {
458
+ warnings.push(`Could not fetch consented contracts fallback: ${safeMessage(error)}`);
459
+
460
+ return [];
461
+ }
462
+ };
463
+
464
+ export const createLearnCardBundle = async (
465
+ wallet: LearnCardBundleWallet,
466
+ options: LearnCardBundleOptions = {}
467
+ ): Promise<LearnCardBundleResult> => {
468
+ const encrypt = options.encrypt ?? true;
469
+
470
+ if (encrypt && !options.password)
471
+ throw new Error('A password is required for LearnCard export');
472
+
473
+ const warnings: string[] = [];
474
+ const zip = new JSZip();
475
+ const contents: LearnCardBundleEntryMetadata[] = [];
476
+ const primaryDid = wallet.id.did();
477
+ const dids = collectDids(wallet, warnings);
478
+ const didDocument = await collectDidDocument(wallet, primaryDid, dids, warnings);
479
+ const credentialPayloads: JsonValue[] = [];
480
+
481
+ const addStoredEntry = async (entry: {
482
+ id: string;
483
+ type: BundleContentType;
484
+ path: string;
485
+ content: string;
486
+ encrypted: boolean;
487
+ mediaType?: string;
488
+ sourceUri?: string;
489
+ credentialId?: string;
490
+ indexRecordRef?: string;
491
+ warnings?: string[];
492
+ }): Promise<void> => {
493
+ const encoded = await encodePayload(entry.content, {
494
+ encrypt: entry.encrypted && encrypt,
495
+ password: options.password,
496
+ });
497
+ const path =
498
+ encoded.encrypted && !entry.path.endsWith('.enc') ? `${entry.path}.enc` : entry.path;
499
+
500
+ addZipText(zip, path, encoded.stored);
501
+
502
+ contents.push({
503
+ id: entry.id,
504
+ type: entry.type,
505
+ path,
506
+ mediaType: entry.mediaType ?? 'application/json',
507
+ sha256: sha256Hex(encoded.stored),
508
+ encrypted: encoded.encrypted,
509
+ sourceUri: entry.sourceUri,
510
+ credentialId: entry.credentialId,
511
+ indexRecordRef: entry.indexRecordRef,
512
+ warnings: entry.warnings,
513
+ });
514
+ };
515
+
516
+ addZipText(zip, 'README.md', BUNDLE_README_MD);
517
+ addZipText(zip, 'BUNDLE_SPEC.md', BUNDLE_SPEC_MD);
518
+
519
+ await addStoredEntry({
520
+ id: 'did-document',
521
+ type: 'did-document',
522
+ path: 'keys/did-document.json',
523
+ content: json(didDocument),
524
+ encrypted: false,
525
+ });
526
+
527
+ for (const payload of await collectKeyPayloads(wallet, warnings)) {
528
+ await addStoredEntry({ ...payload, id: payload.path, mediaType: 'text/plain' });
529
+ }
530
+
531
+ const records = await wallet.index.LearnCloud.get();
532
+
533
+ for (const [recordIndex, record] of records.entries()) {
534
+ const entryWarnings: string[] = [];
535
+ const digest = sha256Hex(
536
+ stableStringify({ recordIndex, id: record.id ?? null, uri: record.uri ?? null })
537
+ );
538
+
539
+ try {
540
+ const resolved = await wallet.read.get(record.uri);
541
+
542
+ if (!resolved) {
543
+ warnings.push(`Could not resolve wallet index URI ${redactUrl(record.uri)}`);
544
+ continue;
545
+ }
546
+
547
+ credentialPayloads.push(resolved);
548
+ const type = classifyPayload(resolved);
549
+ const credentialId = getCredentialId(resolved);
550
+ if (type === 'unknown-json') entryWarnings.push('Payload is not a recognized VC or VP');
551
+
552
+ const indexRecordId = `urn:sha256:${digest}:index-record`;
553
+
554
+ await addStoredEntry({
555
+ id: indexRecordId,
556
+ type: 'index-record',
557
+ path: pathForType('index-record', digest, encrypt),
558
+ content: json(record),
559
+ encrypted: true,
560
+ sourceUri: record.uri,
561
+ credentialId,
562
+ });
563
+
564
+ await addStoredEntry({
565
+ id: `urn:sha256:${digest}`,
566
+ type,
567
+ path: pathForType(type, digest, encrypt),
568
+ content: json(resolved),
569
+ encrypted: true,
570
+ sourceUri: record.uri,
571
+ credentialId,
572
+ indexRecordRef: indexRecordId,
573
+ warnings: entryWarnings.length > 0 ? entryWarnings : undefined,
574
+ });
575
+ } catch (error) {
576
+ warnings.push(
577
+ `Could not export wallet index URI ${redactUrl(record.uri)}: ${safeMessage(error)}`
578
+ );
579
+ }
580
+ }
581
+
582
+ const consentRecords = await collectConsentRecords(wallet, warnings);
583
+
584
+ for (const consentRecord of consentRecords) {
585
+ const digest = sha256Hex(stableStringify(consentRecord));
586
+
587
+ await addStoredEntry({
588
+ id: `urn:sha256:${digest}`,
589
+ type: 'consent-record',
590
+ path: pathForType('consent-record', digest, encrypt),
591
+ content: json(consentRecord),
592
+ encrypted: true,
593
+ });
594
+ }
595
+
596
+ for (const statusList of await collectStatusLists(
597
+ credentialPayloads,
598
+ options.fetchStatusLists ?? true,
599
+ warnings,
600
+ options.statusListFetchTimeoutMs,
601
+ options.maxStatusListBytes
602
+ )) {
603
+ const digest = sha256Hex(statusList.uri);
604
+
605
+ await addStoredEntry({
606
+ id: `urn:sha256:${digest}`,
607
+ type: 'status-cache',
608
+ path: pathForType('status-cache', digest, encrypt),
609
+ content: json(statusList.content),
610
+ encrypted: true,
611
+ sourceUri: statusList.uri,
612
+ });
613
+ }
614
+
615
+ const manifest = finalizeManifest({
616
+ specVersion: SPEC_VERSION,
617
+ createdAt: options.createdAt ?? new Date().toISOString(),
618
+ primaryDid,
619
+ walletName: 'LearnCard',
620
+ encryption: encrypt
621
+ ? {
622
+ mode: 'argon2id-aes-256-gcm',
623
+ encryptedPayloads: true,
624
+ envelope: 'sss-key-manager-encryptWithPassword-v1',
625
+ kdf: 'argon2id',
626
+ cipher: 'AES-256-GCM',
627
+ }
628
+ : { mode: 'none', encryptedPayloads: false },
629
+ contents,
630
+ warnings,
631
+ });
632
+
633
+ addZipText(zip, 'manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
634
+
635
+ return {
636
+ data: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
637
+ manifest,
638
+ warnings,
639
+ };
640
+ };
641
+
642
+ export const exportLearnCardBundle = async (
643
+ wallet: LearnCardBundleWallet,
644
+ options: ExportLearnCardBundleOptions
645
+ ): Promise<LearnCardBundleResult> => {
646
+ const bundle = await createLearnCardBundle(wallet, options);
647
+
648
+ await mkdir(dirname(options.out), { recursive: true });
649
+ await writeFile(options.out, bundle.data);
650
+
651
+ return bundle;
652
+ };