@learncard/partner-connect 0.3.10 โ†’ 0.4.1

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,887 @@
1
+ /**
2
+ * Standalone mock host for the Partner Connect SDK.
3
+ *
4
+ * When the SDK is not embedded in a real LearnCard host there is nothing to
5
+ * answer its `postMessage` requests. `MockHost` locally simulates the host so
6
+ * partner apps are fully buildable, demo-able, and testable in isolation. It
7
+ * intercepts the SDK's single `sendMessage(action, payload)` chokepoint and
8
+ * returns responses that match the shapes the real host produces.
9
+ *
10
+ * This module is browser-oriented but SSR-safe: it never touches `document` at
11
+ * import time and degrades to log-only behavior when the DOM is unavailable.
12
+ */
13
+
14
+ import type { MockHostOptions } from './types';
15
+
16
+ const DEFAULT_DID = 'did:web:mock.learncard.app:user';
17
+ const DEFAULT_NAMESPACE = 'lc-mock';
18
+ const MOCK_PREFIX = '[LearnCard SDK ยท MOCK]';
19
+
20
+ interface ResolvedMockOptions {
21
+ ui: boolean;
22
+ log: boolean;
23
+ persist: boolean;
24
+ namespace: string;
25
+ }
26
+
27
+ interface StoredCounter {
28
+ value: number;
29
+ updatedAt: string;
30
+ }
31
+
32
+ type MockStatus = 'pending' | 'claimed' | 'revoked';
33
+
34
+ interface MockCredential {
35
+ credentialUri: string;
36
+ boostUri?: string;
37
+ templateAlias?: string;
38
+ name: string;
39
+ recipient: string;
40
+ status: MockStatus;
41
+ sentDate: string;
42
+ claimedDate?: string;
43
+ receivedDate?: string;
44
+ credential: unknown;
45
+ }
46
+
47
+ interface TemplateQuery {
48
+ templateAlias?: unknown;
49
+ boostUri?: unknown;
50
+ }
51
+
52
+ type ToastTone = 'default' | 'positive';
53
+
54
+ /** A toast body is a list of plain strings and bold (`{ b }`) segments. */
55
+ type ToastSegment = string | { b: string };
56
+
57
+ interface ToastSpec {
58
+ icon: string;
59
+ segments: ToastSegment[];
60
+ tone?: ToastTone;
61
+ ttl?: number;
62
+ }
63
+
64
+ interface ActiveToast {
65
+ node: HTMLElement;
66
+ timeoutId: ReturnType<typeof setTimeout>;
67
+ count: number;
68
+ countEl: HTMLElement;
69
+ }
70
+
71
+ const hasDocument = (): boolean =>
72
+ typeof document !== 'undefined' && typeof document.createElement === 'function';
73
+
74
+ const readAchievementName = (payload: unknown): string => {
75
+ if (!payload || typeof payload !== 'object') return 'a credential';
76
+
77
+ const record = payload as Record<string, unknown>;
78
+
79
+ // Template-based issuance (APP_EVENT / send-credential).
80
+ if (typeof record.templateAlias === 'string' && record.templateAlias) {
81
+ const data = record.templateData;
82
+ if (data && typeof data === 'object') {
83
+ const fields = data as Record<string, unknown>;
84
+ const named =
85
+ fields.name ?? fields.achievementName ?? fields.courseName ?? fields.title;
86
+ if (typeof named === 'string' && named) return named;
87
+ }
88
+ return record.templateAlias;
89
+ }
90
+
91
+ // Raw verifiable credential.
92
+ const credential = (record.credential ?? record) as Record<string, unknown>;
93
+ if (credential && typeof credential === 'object') {
94
+ const subject = credential.credentialSubject as Record<string, unknown> | undefined;
95
+ const achievement = subject?.achievement as Record<string, unknown> | undefined;
96
+ if (achievement && typeof achievement.name === 'string' && achievement.name) {
97
+ return achievement.name;
98
+ }
99
+ if (typeof credential.name === 'string' && credential.name) return credential.name;
100
+ }
101
+
102
+ return 'a credential';
103
+ };
104
+
105
+ /**
106
+ * Simulates the LearnCard host for a single {@link PartnerConnect} instance.
107
+ * All public methods route through `handle`, keeping the mock behind the same
108
+ * `(action, payload)` contract the real host answers over `postMessage`.
109
+ */
110
+ export class MockHost {
111
+ private readonly options: ResolvedMockOptions;
112
+
113
+ /** In-memory counter fallback used when persistence is off/unavailable. */
114
+ private readonly memoryCounters = new Map<string, StoredCounter>();
115
+
116
+ /** Session-scoped credential store; reads reflect writes and seeds. */
117
+ private readonly credentials: MockCredential[] = [];
118
+
119
+ private readonly identity: { did: string; [key: string]: unknown };
120
+
121
+ /** DOM nodes injected for visual feedback, tracked for cleanup. */
122
+ private readonly domNodes = new Set<HTMLElement>();
123
+
124
+ /** The app's single AI Topic, created lazily by the first AI session. */
125
+ private aiTopic: { topicUri: string; topicCredentialUri: string } | null = null;
126
+
127
+ private styleEl: HTMLStyleElement | null = null;
128
+ private stackEl: HTMLElement | null = null;
129
+ private readonly activeToasts = new Map<string, ActiveToast>();
130
+ /** Pending exit-animation timers, tracked so `destroy()` can cancel them. */
131
+ private readonly exitTimers = new Set<ReturnType<typeof setTimeout>>();
132
+ private idSeq = 0;
133
+ private destroyed = false;
134
+
135
+ constructor(options?: MockHostOptions) {
136
+ this.options = {
137
+ ui: options?.ui ?? true,
138
+ log: options?.log ?? true,
139
+ persist: options?.persist ?? true,
140
+ namespace: options?.namespace || DEFAULT_NAMESPACE,
141
+ };
142
+
143
+ this.identity = {
144
+ ...(options?.identity ?? {}),
145
+ did: options?.identity?.did || options?.did || DEFAULT_DID,
146
+ };
147
+
148
+ for (const seed of options?.credentials ?? []) {
149
+ this.addCredential({
150
+ name: seed.name || seed.templateAlias || 'Mock Credential',
151
+ templateAlias: seed.templateAlias,
152
+ boostUri: seed.boostUri,
153
+ recipient: seed.recipient,
154
+ status: seed.status,
155
+ });
156
+ }
157
+
158
+ for (const [key, value] of Object.entries(options?.counters ?? {})) {
159
+ if (this.readCounter(key) === undefined) this.writeCounter(key, value);
160
+ }
161
+
162
+ this.announce();
163
+ }
164
+
165
+ /**
166
+ * Resolve a simulated response for one SDK request. Mirrors the real host:
167
+ * direct actions map 1:1, and `APP_EVENT` is dispatched by its `type`.
168
+ */
169
+ public handle(action: string, payload?: unknown): Promise<unknown> {
170
+ if (this.destroyed) {
171
+ return Promise.reject({
172
+ code: 'SDK_DESTROYED',
173
+ message: 'Mock host was destroyed before the request completed',
174
+ });
175
+ }
176
+
177
+ this.log(action, payload);
178
+
179
+ if (action === 'APP_EVENT') {
180
+ return this.handleAppEvent(payload as Record<string, unknown>);
181
+ }
182
+
183
+ switch (action) {
184
+ case 'REQUEST_IDENTITY':
185
+ this.toast({
186
+ icon: '๐Ÿ‘ค',
187
+ segments: ['In LearnCard, the user would sign in. Returning a mock identity.'],
188
+ });
189
+ return Promise.resolve({
190
+ token: `mock-token-${Date.now()}`,
191
+ user: { ...this.identity },
192
+ });
193
+
194
+ case 'SEND_CREDENTIAL': {
195
+ const name = readAchievementName(payload);
196
+ const credentialId = `mock-credential-${Date.now()}-${(this.idSeq += 1)}`;
197
+ this.addCredential({
198
+ name,
199
+ credentialUri: credentialId,
200
+ credential: (payload as { credential?: unknown } | undefined)?.credential,
201
+ });
202
+ this.showClaimToast(name);
203
+ return Promise.resolve({ credentialId, stored: true });
204
+ }
205
+
206
+ case 'REQUEST_CONSENT': {
207
+ const redirect = Boolean((payload as { redirect?: boolean } | undefined)?.redirect);
208
+ this.showConsentBanner(redirect);
209
+ return Promise.resolve({ granted: true });
210
+ }
211
+
212
+ case 'LAUNCH_FEATURE': {
213
+ const featurePath =
214
+ (payload as { featurePath?: string } | undefined)?.featurePath ?? '';
215
+ this.toast({
216
+ icon: '๐Ÿš€',
217
+ segments: featurePath
218
+ ? ['In LearnCard, this would open ', { b: featurePath }, '.']
219
+ : ['In LearnCard, this would open a feature screen.'],
220
+ });
221
+ return Promise.resolve({ launched: true, featurePath });
222
+ }
223
+
224
+ case 'ASK_CREDENTIAL_SEARCH': {
225
+ const held = this.selfCredentials();
226
+ this.toast({
227
+ icon: '๐Ÿ”',
228
+ segments: held.length
229
+ ? ['The user could share ', { b: String(held.length) }, ' credential(s).']
230
+ : [
231
+ 'In LearnCard, the user would be asked to share matching credentials. None in mock.',
232
+ ],
233
+ });
234
+ return Promise.resolve({
235
+ verifiablePresentation: {
236
+ verifiableCredential: held.map(c => c.credential),
237
+ },
238
+ });
239
+ }
240
+
241
+ case 'ASK_CREDENTIAL_SPECIFIC': {
242
+ const credentialId = (payload as { credentialId?: string } | undefined)
243
+ ?.credentialId;
244
+ const found = this.credentials.find(c => c.credentialUri === credentialId);
245
+ this.toast({
246
+ icon: '๐Ÿ”',
247
+ segments: found
248
+ ? ['Sharing ', { b: found.name }, '.']
249
+ : [
250
+ 'In LearnCard, the user would be asked to share a credential. Not found in mock.',
251
+ ],
252
+ });
253
+ return Promise.resolve({ credential: found?.credential });
254
+ }
255
+
256
+ case 'INITIATE_TEMPLATE_ISSUE': {
257
+ const input = payload as
258
+ | { templateId?: string; draftRecipients?: string[] }
259
+ | undefined;
260
+ const templateId = input?.templateId ?? '';
261
+ const recipients = Array.isArray(input?.draftRecipients)
262
+ ? (input?.draftRecipients as string[])
263
+ : [];
264
+ for (const recipient of recipients) {
265
+ this.addCredential({
266
+ name: templateId || 'Boost',
267
+ boostUri: templateId,
268
+ recipient,
269
+ status: 'pending',
270
+ });
271
+ }
272
+ this.toast({
273
+ icon: '๐Ÿ“ค',
274
+ segments: recipients.length
275
+ ? [
276
+ 'This would issue to ',
277
+ { b: String(recipients.length) },
278
+ ' recipient(s).',
279
+ ]
280
+ : ['In LearnCard, this would open the Send Boost flow.'],
281
+ });
282
+ return Promise.resolve({ issued: true });
283
+ }
284
+
285
+ case 'REQUEST_LEARNER_CONTEXT': {
286
+ const opts = (payload ?? {}) as {
287
+ includeCredentials?: boolean;
288
+ format?: string;
289
+ };
290
+ const includeCredentials = opts.includeCredentials !== false;
291
+ const structured = opts.format === 'structured';
292
+ const held = includeCredentials ? this.selfCredentials() : [];
293
+ this.toast({
294
+ icon: '๐Ÿง ',
295
+ segments: !includeCredentials
296
+ ? ['Learner profile requested with credentials excluded.']
297
+ : held.length
298
+ ? ['Learner profile: ', { b: String(held.length) }, ' credential(s).']
299
+ : ["In LearnCard, the user's learner profile would load. Empty in mock."],
300
+ });
301
+ const prompt = !includeCredentials
302
+ ? 'Mock learner context: credentials were not requested.'
303
+ : held.length
304
+ ? `Mock learner context. Credentials held: ${held.map(c => c.name).join(', ')}.`
305
+ : 'Mock learner context: this user has no credentials in standalone mode.';
306
+ return Promise.resolve({
307
+ status: 'ready',
308
+ prompt,
309
+ did: this.identity.did,
310
+ // Matches the real host: `raw` only ships for format: 'structured'.
311
+ ...(structured ? { raw: { credentials: held.map(c => c.credential) } } : {}),
312
+ });
313
+ }
314
+
315
+ case 'GET_SYNC_STATUS':
316
+ this.toast({
317
+ icon: '๐Ÿ”„',
318
+ segments: [
319
+ 'In LearnCard, this reports data sync progress. Mock reports ready.',
320
+ ],
321
+ });
322
+ return Promise.resolve({
323
+ status: 'ready',
324
+ progress: {
325
+ totalCredentials: 0,
326
+ completedCredentials: 0,
327
+ failedCredentials: 0,
328
+ retryCount: 0,
329
+ },
330
+ });
331
+
332
+ default:
333
+ this.toast({
334
+ icon: 'โœจ',
335
+ segments: ['In LearnCard, this would run ', { b: action }, '.'],
336
+ });
337
+ return Promise.resolve({});
338
+ }
339
+ }
340
+
341
+ /** Tear down injected UI and clear in-memory state. */
342
+ public destroy(): void {
343
+ this.destroyed = true;
344
+
345
+ for (const entry of this.activeToasts.values()) clearTimeout(entry.timeoutId);
346
+ this.activeToasts.clear();
347
+
348
+ for (const timer of this.exitTimers) clearTimeout(timer);
349
+ this.exitTimers.clear();
350
+
351
+ for (const node of this.domNodes) node.remove();
352
+ this.domNodes.clear();
353
+
354
+ if (this.stackEl) {
355
+ this.stackEl.remove();
356
+ this.stackEl = null;
357
+ }
358
+
359
+ if (this.styleEl) {
360
+ this.styleEl.remove();
361
+ this.styleEl = null;
362
+ }
363
+
364
+ this.memoryCounters.clear();
365
+ this.credentials.length = 0;
366
+ }
367
+
368
+ private handleAppEvent(event: Record<string, unknown>): Promise<unknown> {
369
+ const type = typeof event?.type === 'string' ? event.type : '';
370
+
371
+ switch (type) {
372
+ case 'send-credential': {
373
+ const name = readAchievementName(event);
374
+ const templateAlias =
375
+ typeof event.templateAlias === 'string' ? event.templateAlias : undefined;
376
+ const boostUri = `lc:mock:boost:${String(event.templateAlias ?? 'template')}`;
377
+
378
+ if (event.preventDuplicateClaim) {
379
+ const existing = this.selfCredentials().find(c =>
380
+ this.matchesTemplate(c, { templateAlias, boostUri })
381
+ );
382
+ if (existing) {
383
+ this.toast({
384
+ icon: 'โœ…',
385
+ segments: ['The user already has ', { b: existing.name }, '.'],
386
+ });
387
+ return Promise.resolve({
388
+ credentialUri: existing.credentialUri,
389
+ boostUri: existing.boostUri ?? boostUri,
390
+ alreadyClaimed: true,
391
+ hasCredential: true,
392
+ status: existing.status,
393
+ receivedDate: existing.receivedDate,
394
+ });
395
+ }
396
+ }
397
+
398
+ const record = this.addCredential({
399
+ name,
400
+ templateAlias,
401
+ boostUri,
402
+ status: 'claimed',
403
+ });
404
+ this.showClaimToast(name);
405
+ return Promise.resolve({
406
+ credentialUri: record.credentialUri,
407
+ boostUri: record.boostUri ?? boostUri,
408
+ alreadyClaimed: false,
409
+ hasCredential: true,
410
+ status: 'claimed',
411
+ receivedDate: record.receivedDate,
412
+ });
413
+ }
414
+
415
+ case 'check-credential': {
416
+ const held = this.selfCredentials().find(c => this.matchesTemplate(c, event));
417
+ this.toast({
418
+ icon: '๐Ÿ”Ž',
419
+ segments: held
420
+ ? ['The user already has ', { b: held.name }, '.']
421
+ : ["Mock: the user doesn't have this credential yet."],
422
+ });
423
+ return Promise.resolve(
424
+ held
425
+ ? {
426
+ hasCredential: true,
427
+ credentialUri: held.credentialUri,
428
+ receivedDate: held.receivedDate,
429
+ status: held.status,
430
+ }
431
+ : { hasCredential: false }
432
+ );
433
+ }
434
+
435
+ case 'check-issuance-status': {
436
+ const recipient = typeof event.recipient === 'string' ? event.recipient : '';
437
+ const match = this.credentials.find(
438
+ c => this.matchesTemplate(c, event) && c.recipient === recipient
439
+ );
440
+ this.toast({
441
+ icon: '๐Ÿ”Ž',
442
+ segments: match
443
+ ? ['Issued to ', { b: recipient }, ' โ€” ', { b: match.status }, '.']
444
+ : ['Mock: not sent to this recipient yet.'],
445
+ });
446
+ return Promise.resolve(
447
+ match
448
+ ? {
449
+ sent: true,
450
+ credentialUri: match.credentialUri,
451
+ sentDate: match.sentDate,
452
+ claimedDate: match.claimedDate,
453
+ status: match.status,
454
+ }
455
+ : { sent: false }
456
+ );
457
+ }
458
+
459
+ case 'get-template-recipients': {
460
+ const matched = this.credentials.filter(c => this.matchesTemplate(c, event));
461
+ const limit =
462
+ typeof event.limit === 'number' && event.limit > 0 ? event.limit : undefined;
463
+ // Cursor pagination: the cursor is the offset of the next record,
464
+ // issued by the previous page so callers can walk the full list.
465
+ const parsedCursor =
466
+ typeof event.cursor === 'string' ? Number.parseInt(event.cursor, 10) : 0;
467
+ const offset = Number.isFinite(parsedCursor) && parsedCursor > 0 ? parsedCursor : 0;
468
+ const page = limit ? matched.slice(offset, offset + limit) : matched.slice(offset);
469
+ const nextOffset = offset + page.length;
470
+ const hasMore = nextOffset < matched.length;
471
+ this.toast({
472
+ icon: '๐Ÿ‘ฅ',
473
+ segments: [
474
+ 'In LearnCard, this lists recipients. ',
475
+ { b: String(matched.length) },
476
+ ' in mock.',
477
+ ],
478
+ });
479
+ return Promise.resolve({
480
+ records: page.map(c => this.toRecipientRecord(c)),
481
+ hasMore,
482
+ ...(hasMore ? { cursor: String(nextOffset) } : {}),
483
+ total: matched.length,
484
+ });
485
+ }
486
+
487
+ case 'send-notification': {
488
+ const title = typeof event.title === 'string' ? event.title : '';
489
+ const body = typeof event.body === 'string' ? event.body : '';
490
+ const text = [title, body].filter(Boolean).join(' โ€” ');
491
+ this.toast({
492
+ icon: '๐Ÿ””',
493
+ segments: text
494
+ ? ['The user would be notified: ', { b: text }]
495
+ : ['In LearnCard, the user would receive a notification.'],
496
+ });
497
+ return Promise.resolve({ sent: true });
498
+ }
499
+
500
+ case 'increment-counter': {
501
+ const key = String(event.key ?? '');
502
+ const amount = typeof event.amount === 'number' ? event.amount : 0;
503
+ const previous = this.readCounter(key)?.value ?? 0;
504
+ const next = previous + amount;
505
+ this.writeCounter(key, next);
506
+ this.toast({
507
+ icon: '๐Ÿ”ข',
508
+ segments: ['Counter ', { b: key }, ' โ†’ ', { b: String(next) }, '.'],
509
+ });
510
+ return Promise.resolve({ key, previousValue: previous, newValue: next });
511
+ }
512
+
513
+ case 'get-counter': {
514
+ const key = String(event.key ?? '');
515
+ const stored = this.readCounter(key);
516
+ const value = stored?.value ?? 0;
517
+ this.toast({
518
+ icon: '๐Ÿ”ข',
519
+ segments: ['Counter ', { b: key }, ' is ', { b: String(value) }, '.'],
520
+ });
521
+ return Promise.resolve({
522
+ key,
523
+ value,
524
+ updatedAt: stored?.updatedAt ?? null,
525
+ });
526
+ }
527
+
528
+ case 'get-counters': {
529
+ const requested = Array.isArray(event.keys)
530
+ ? (event.keys as unknown[]).map(String)
531
+ : this.allCounterKeys();
532
+ const counters = requested.map(key => {
533
+ const stored = this.readCounter(key);
534
+ return { key, value: stored?.value ?? 0, updatedAt: stored?.updatedAt ?? null };
535
+ });
536
+ this.toast({
537
+ icon: '๐Ÿ”ข',
538
+ segments: ['Read ', { b: String(counters.length) }, ' counter(s).'],
539
+ });
540
+ return Promise.resolve({ counters });
541
+ }
542
+
543
+ case 'send-ai-session-credential': {
544
+ const sessionTitle =
545
+ typeof event.sessionTitle === 'string' && event.sessionTitle
546
+ ? event.sessionTitle
547
+ : 'AI session';
548
+ const sessionBoostUri = this.nextUri('lc:mock:session-boost');
549
+ const record = this.addCredential({
550
+ name: sessionTitle,
551
+ boostUri: sessionBoostUri,
552
+ status: 'claimed',
553
+ });
554
+ this.toast({
555
+ icon: 'โœ…',
556
+ segments: [
557
+ 'In LearnCard, an AI session credential would be saved: ',
558
+ { b: sessionTitle },
559
+ ],
560
+ });
561
+
562
+ // Mirrors the real host's topic hierarchy: the first session
563
+ // creates the app's AI Topic (returning its credential URI);
564
+ // later sessions reuse it and report isNewTopic: false.
565
+ const isNewTopic = this.aiTopic === null;
566
+ if (!this.aiTopic) {
567
+ this.aiTopic = {
568
+ topicUri: this.nextUri('lc:mock:topic'),
569
+ topicCredentialUri: this.nextUri('lc:mock:topic-credential'),
570
+ };
571
+ }
572
+
573
+ return Promise.resolve({
574
+ topicUri: this.aiTopic.topicUri,
575
+ ...(isNewTopic ? { topicCredentialUri: this.aiTopic.topicCredentialUri } : {}),
576
+ sessionCredentialUri: record.credentialUri,
577
+ sessionBoostUri,
578
+ isNewTopic,
579
+ });
580
+ }
581
+
582
+ default:
583
+ this.toast({
584
+ icon: 'โœจ',
585
+ segments: ['In LearnCard, this would run ', { b: type }, '.'],
586
+ });
587
+ return Promise.resolve({});
588
+ }
589
+ }
590
+
591
+ private nextUri(prefix: string): string {
592
+ this.idSeq += 1;
593
+ return `${prefix}:${Date.now()}-${this.idSeq}`;
594
+ }
595
+
596
+ private buildMockVc(name: string, id: string): Record<string, unknown> {
597
+ return {
598
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
599
+ id,
600
+ type: ['VerifiableCredential'],
601
+ issuer: this.identity.did,
602
+ credentialSubject: { id: this.identity.did, achievement: { name } },
603
+ _mock: true,
604
+ };
605
+ }
606
+
607
+ private addCredential(input: {
608
+ name: string;
609
+ templateAlias?: string;
610
+ boostUri?: string;
611
+ recipient?: string;
612
+ status?: MockStatus;
613
+ credentialUri?: string;
614
+ credential?: unknown;
615
+ }): MockCredential {
616
+ const now = new Date().toISOString();
617
+ const status: MockStatus = input.status ?? 'claimed';
618
+ const credentialUri = input.credentialUri ?? this.nextUri('lc:mock:credential');
619
+ const record: MockCredential = {
620
+ credentialUri,
621
+ boostUri: input.boostUri,
622
+ templateAlias: input.templateAlias,
623
+ name: input.name,
624
+ recipient: input.recipient || this.identity.did,
625
+ status,
626
+ sentDate: now,
627
+ claimedDate: status === 'claimed' ? now : undefined,
628
+ receivedDate: status === 'claimed' ? now : undefined,
629
+ credential: input.credential ?? this.buildMockVc(input.name, credentialUri),
630
+ };
631
+ this.credentials.push(record);
632
+ return record;
633
+ }
634
+
635
+ private selfCredentials(): MockCredential[] {
636
+ return this.credentials.filter(c => c.recipient === this.identity.did);
637
+ }
638
+
639
+ private matchesTemplate(c: MockCredential, q: TemplateQuery): boolean {
640
+ if (typeof q.templateAlias === 'string') return c.templateAlias === q.templateAlias;
641
+ if (typeof q.boostUri === 'string') return c.boostUri === q.boostUri;
642
+ return false;
643
+ }
644
+
645
+ private toRecipientRecord(c: MockCredential): {
646
+ recipientProfileId: string;
647
+ recipientDisplayName: string;
648
+ sentDate: string;
649
+ claimedDate?: string;
650
+ credentialUri: string;
651
+ status: MockStatus;
652
+ } {
653
+ return {
654
+ recipientProfileId: c.recipient,
655
+ recipientDisplayName: c.recipient,
656
+ sentDate: c.sentDate,
657
+ claimedDate: c.claimedDate,
658
+ credentialUri: c.credentialUri,
659
+ status: c.status,
660
+ };
661
+ }
662
+
663
+ private counterStorageKey(): string {
664
+ return `${this.options.namespace}:counters`;
665
+ }
666
+
667
+ private loadCounters(): Record<string, StoredCounter> {
668
+ if (this.options.persist && typeof localStorage !== 'undefined') {
669
+ try {
670
+ const raw = localStorage.getItem(this.counterStorageKey());
671
+ if (raw) return JSON.parse(raw) as Record<string, StoredCounter>;
672
+ } catch {
673
+ // Corrupt or unavailable storage: fall through to in-memory.
674
+ }
675
+ }
676
+
677
+ const fromMemory: Record<string, StoredCounter> = {};
678
+ for (const [key, value] of this.memoryCounters) fromMemory[key] = value;
679
+ return fromMemory;
680
+ }
681
+
682
+ private readCounter(key: string): StoredCounter | undefined {
683
+ return this.loadCounters()[key];
684
+ }
685
+
686
+ private allCounterKeys(): string[] {
687
+ return Object.keys(this.loadCounters());
688
+ }
689
+
690
+ /**
691
+ * The load โ†’ patch โ†’ save cycle below runs synchronously within one task,
692
+ * so increments in the same tab can never interleave. Concurrent writes
693
+ * from *other tabs* sharing the namespace can still be lost โ€” acceptable
694
+ * for a dev-only mock, and inherent to `localStorage` without web locks.
695
+ */
696
+ private writeCounter(key: string, value: number): void {
697
+ const entry: StoredCounter = { value, updatedAt: new Date().toISOString() };
698
+
699
+ if (this.options.persist && typeof localStorage !== 'undefined') {
700
+ try {
701
+ const all = this.loadCounters();
702
+ all[key] = entry;
703
+ localStorage.setItem(this.counterStorageKey(), JSON.stringify(all));
704
+ return;
705
+ } catch {
706
+ // Fall back to in-memory storage below.
707
+ }
708
+ }
709
+
710
+ this.memoryCounters.set(key, entry);
711
+ }
712
+
713
+ private log(action: string, payload?: unknown): void {
714
+ if (!this.options.log) return;
715
+ // eslint-disable-next-line no-console
716
+ console.log(`${MOCK_PREFIX} ${action}`, payload ?? '');
717
+ }
718
+
719
+ private announce(): void {
720
+ if (!this.options.log) return;
721
+ // eslint-disable-next-line no-console
722
+ console.log(
723
+ `${MOCK_PREFIX} Standalone mock mode is active. The SDK is simulating the ` +
724
+ 'LearnCard host locally. When embedded in a real host, these calls run ' +
725
+ 'against it unchanged.'
726
+ );
727
+ }
728
+
729
+ private showClaimToast(credentialName: string): void {
730
+ this.toast({
731
+ icon: 'โœ…',
732
+ ttl: 5200,
733
+ segments: ['In LearnCard, the user would receive ', { b: credentialName }, ' here.'],
734
+ });
735
+ }
736
+
737
+ private showConsentBanner(redirectIgnored = false): void {
738
+ this.toast({
739
+ icon: '๐Ÿ”“',
740
+ tone: 'positive',
741
+ segments: redirectIgnored
742
+ ? ['Consent auto-granted. Redirect ignored in mock.']
743
+ : ['The user would review and grant consent. Auto-granted in mock.'],
744
+ });
745
+
746
+ if (redirectIgnored) {
747
+ this.note(
748
+ "requestConsent: 'redirect' is ignored in mock mode โ€” the real host would " +
749
+ "navigate to the contract's redirectUrl with the VP in the URL."
750
+ );
751
+ }
752
+ }
753
+
754
+ private note(message: string): void {
755
+ if (!this.options.log) return;
756
+ // eslint-disable-next-line no-console
757
+ console.log(`${MOCK_PREFIX} ${message}`);
758
+ }
759
+
760
+ /**
761
+ * Show a branded toast describing what the real host would do. Identical
762
+ * messages coalesce into one toast with a ร—N counter so repeated or polled
763
+ * calls never spam the screen.
764
+ */
765
+ private toast(spec: ToastSpec): void {
766
+ if (!this.options.ui || !hasDocument()) return;
767
+
768
+ const tone = spec.tone ?? 'default';
769
+ const ttl = spec.ttl ?? 4200;
770
+ const text = spec.segments.map(s => (typeof s === 'string' ? s : s.b)).join('');
771
+ const key = `${tone}|${spec.icon}|${text}`;
772
+
773
+ const existing = this.activeToasts.get(key);
774
+ if (existing) {
775
+ existing.count += 1;
776
+ existing.countEl.textContent = `ร—${existing.count}`;
777
+ existing.countEl.style.display = '';
778
+ clearTimeout(existing.timeoutId);
779
+ existing.timeoutId = setTimeout(() => this.dismissToast(key), ttl);
780
+ return;
781
+ }
782
+
783
+ const stack = this.ensureStack();
784
+ if (!stack) return;
785
+ this.ensureStyles();
786
+
787
+ const toast = document.createElement('div');
788
+ toast.className = `lc-mock-toast lc-mock-toast--${tone}`;
789
+
790
+ const badge = document.createElement('div');
791
+ badge.className = 'lc-mock-badge';
792
+ const name = document.createElement('span');
793
+ name.className = 'lc-mock-badge-name';
794
+ name.textContent = `${spec.icon} LearnCard`;
795
+ const pill = document.createElement('span');
796
+ pill.className = 'lc-mock-pill';
797
+ pill.textContent = 'MOCK';
798
+ badge.append(name, pill);
799
+
800
+ const body = document.createElement('div');
801
+ body.className = 'lc-mock-body';
802
+ for (const seg of spec.segments) {
803
+ if (typeof seg === 'string') {
804
+ body.append(seg);
805
+ } else {
806
+ const strong = document.createElement('strong');
807
+ strong.textContent = seg.b;
808
+ body.appendChild(strong);
809
+ }
810
+ }
811
+
812
+ const countEl = document.createElement('span');
813
+ countEl.className = 'lc-mock-count';
814
+ countEl.style.display = 'none';
815
+ body.appendChild(countEl);
816
+
817
+ toast.append(badge, body);
818
+ stack.appendChild(toast);
819
+ this.domNodes.add(toast);
820
+
821
+ const timeoutId = setTimeout(() => this.dismissToast(key), ttl);
822
+ this.activeToasts.set(key, { node: toast, timeoutId, count: 1, countEl });
823
+ }
824
+
825
+ private dismissToast(key: string): void {
826
+ const entry = this.activeToasts.get(key);
827
+ if (!entry) return;
828
+
829
+ this.activeToasts.delete(key);
830
+ clearTimeout(entry.timeoutId);
831
+
832
+ const { node } = entry;
833
+ node.classList.add('lc-mock-out');
834
+ const exitTimer = setTimeout(() => {
835
+ this.exitTimers.delete(exitTimer);
836
+ node.remove();
837
+ this.domNodes.delete(node);
838
+ }, 200);
839
+ this.exitTimers.add(exitTimer);
840
+ }
841
+
842
+ private ensureStack(): HTMLElement | null {
843
+ if (!document.body) return null;
844
+ if (this.stackEl && document.body.contains(this.stackEl)) return this.stackEl;
845
+
846
+ const stack = document.createElement('div');
847
+ stack.className = 'lc-mock-stack';
848
+ document.body.appendChild(stack);
849
+ this.stackEl = stack;
850
+ return stack;
851
+ }
852
+
853
+ private ensureStyles(): void {
854
+ if (!this.options.ui || !hasDocument() || this.styleEl || !document.head) return;
855
+
856
+ const style = document.createElement('style');
857
+ style.textContent = `
858
+ @keyframes lc-mock-in { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }
859
+ @keyframes lc-mock-out { to { opacity: 0; transform: translateY(6px); } }
860
+ .lc-mock-stack {
861
+ position: fixed; bottom: 20px; right: 20px; z-index: 2147483647;
862
+ display: flex; flex-direction: column; gap: 10px; align-items: flex-end;
863
+ pointer-events: none; max-width: min(360px, calc(100vw - 40px));
864
+ }
865
+ .lc-mock-toast {
866
+ pointer-events: auto; width: 100%; box-sizing: border-box; padding: 11px 14px; border-radius: 14px;
867
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13.5px;
868
+ line-height: 1.45; box-shadow: 0 10px 30px rgba(24,34,78,0.22); animation: lc-mock-in 180ms cubic-bezier(0.2,0.8,0.2,1);
869
+ }
870
+ .lc-mock-toast.lc-mock-out { animation: lc-mock-out 180ms ease-in forwards; }
871
+ .lc-mock-toast--default { background: #18224E; color: #fff; }
872
+ .lc-mock-toast--positive { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; }
873
+ .lc-mock-badge {
874
+ display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 5px;
875
+ font-size: 11px; letter-spacing: 0.02em; text-transform: uppercase; opacity: 0.72;
876
+ }
877
+ .lc-mock-badge-name { font-weight: 600; }
878
+ .lc-mock-pill { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 999px; background: rgba(255,255,255,0.16); }
879
+ .lc-mock-toast--positive .lc-mock-pill { background: rgba(6,95,70,0.12); }
880
+ .lc-mock-body strong { font-weight: 700; }
881
+ .lc-mock-count { margin-left: 6px; font-weight: 700; opacity: 0.75; }
882
+ `.trim();
883
+
884
+ document.head.appendChild(style);
885
+ this.styleEl = style;
886
+ }
887
+ }