@learncard/partner-connect 0.3.6 → 0.3.8

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/src/index.ts ADDED
@@ -0,0 +1,1013 @@
1
+ /**
2
+ * LearnCard Partner Connect SDK
3
+ *
4
+ * A Promise-based JavaScript utility for managing cross-origin message communication
5
+ * between partner apps and the LearnCard host application.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { createPartnerConnect } from '@learncard/partner-connect';
10
+ *
11
+ * const learnCard = createPartnerConnect({
12
+ * hostOrigin: 'https://learncard.app'
13
+ * });
14
+ *
15
+ * // Request user identity (SSO)
16
+ * const identity = await learnCard.requestIdentity();
17
+ * console.log('User DID:', identity.user.did);
18
+ * ```
19
+ */
20
+
21
+ import { PartnerConnectError } from './types';
22
+ import type {
23
+ PartnerConnectOptions,
24
+ IdentityResponse,
25
+ SendCredentialResponse,
26
+ TemplateCredentialInput,
27
+ TemplateCredentialResponse,
28
+ VerifiablePresentationRequest,
29
+ CredentialSearchResponse,
30
+ CredentialSpecificResponse,
31
+ ConsentResponse,
32
+ RequestConsentOptions,
33
+ TemplateIssueResponse,
34
+ CheckCredentialInput,
35
+ CheckCredentialResponse,
36
+ CheckIssuanceStatusInput,
37
+ TemplateIssuanceStatusResponse,
38
+ GetTemplateRecipientsInput,
39
+ TemplateRecipientsResponse,
40
+ RequestLearnerContextOptions,
41
+ LearnerContextResponse,
42
+ SyncStatus,
43
+ SendAiSessionCredentialInput,
44
+ SendAiSessionCredentialResponse,
45
+ AppNotificationInput,
46
+ AppNotificationResponse,
47
+ IncrementCounterResponse,
48
+ GetCounterResponse,
49
+ GetCountersResponse,
50
+ AppEvent,
51
+ AppEventResponse,
52
+ LearnCardError,
53
+ PostMessageRequest,
54
+ PostMessageResponse,
55
+ PendingRequest,
56
+ } from './types';
57
+
58
+ // Re-export the class as a value plus all type exports.
59
+ export { PartnerConnectError } from './types';
60
+ export type * from './types';
61
+
62
+ /** Maximum time to poll for sync completion before giving up (10 minutes) */
63
+ const SYNC_STATUS_POLL_MAX_DURATION_MS = 10 * 60 * 1000;
64
+
65
+ /**
66
+ * LearnCard Partner Connect SDK class
67
+ */
68
+ export class PartnerConnect {
69
+ /** Default host origin (security anchor) */
70
+ public static readonly DEFAULT_HOST_ORIGIN = 'https://learncard.app';
71
+
72
+ /**
73
+ * Built-in list of LearnCard-managed tenant origins.
74
+ *
75
+ * These are merged with the partner app's configured `hostOrigin` whitelist
76
+ * unless `disableDefaultTenants: true` is passed. This lets a partner app
77
+ * run inside any current or future LearnCard tenant (staging, preview,
78
+ * VetPass, etc.) without needing a re-deploy each time a new tenant is
79
+ * onboarded.
80
+ *
81
+ * Patterns follow the same rules as user-supplied `hostOrigin` entries:
82
+ * `*` is a wildcard for one or more DNS labels in the host portion.
83
+ */
84
+ public static readonly DEFAULT_TRUSTED_TENANTS: readonly string[] = [
85
+ 'https://learncard.app',
86
+ 'https://*.learncard.app',
87
+ 'https://*.learncard.ai',
88
+ 'https://vetpass.app',
89
+ 'https://*.vetpass.app',
90
+ ];
91
+
92
+ private hostOrigins: string[] = ['https://learncard.app'];
93
+ private activeHostOrigin: string = 'https://learncard.app';
94
+ private allowNativeAppOrigins: boolean = true;
95
+ private protocol: string = 'LEARNCARD_V1';
96
+ private requestTimeout: number = 30000;
97
+ private pendingRequests: Map<string, PendingRequest>;
98
+ private messageListener: ((event: MessageEvent) => void) | null = null;
99
+ private isInitialized = false;
100
+ private syncCompleteCallbacks: Set<(status: SyncStatus) => void> = new Set();
101
+ private syncStatusPollId: ReturnType<typeof setInterval> | null = null;
102
+
103
+ constructor(options?: PartnerConnectOptions) {
104
+ // Normalize hostOrigin to an array for whitelist validation
105
+ const hostOrigin = options?.hostOrigin ?? PartnerConnect.DEFAULT_HOST_ORIGIN;
106
+ const configured = Array.isArray(hostOrigin) ? hostOrigin : [hostOrigin];
107
+
108
+ // Merge with the built-in tenant list unless the caller explicitly
109
+ // opted out. De-duplicate while preserving order so the caller's
110
+ // first entry remains the default active origin.
111
+ const disableDefaults = options?.disableDefaultTenants === true;
112
+ const merged = disableDefaults
113
+ ? [...configured]
114
+ : [...configured, ...PartnerConnect.DEFAULT_TRUSTED_TENANTS];
115
+ this.hostOrigins = Array.from(new Set(merged));
116
+
117
+ this.protocol = options?.protocol || 'LEARNCARD_V1';
118
+ this.requestTimeout = options?.requestTimeout || 30000;
119
+ this.allowNativeAppOrigins = options?.allowNativeAppOrigins ?? true;
120
+ this.pendingRequests = new Map();
121
+ this.configureActiveOrigin();
122
+ this.setupMessageListener();
123
+ }
124
+
125
+ /**
126
+ * Configure the active host origin using the following hierarchy:
127
+ * 1. `window.location.ancestorOrigins[0]` (when supported) — the browser's
128
+ * view of who our parent frame is. Cannot be forged by a malicious
129
+ * `lc_host_override` query param and therefore takes precedence.
130
+ * 2. `?lc_host_override=<origin>` query param (for staging / cross-tenant).
131
+ * 3. `sessionStorage` value saved from a previously-validated override.
132
+ * 4. First configured origin.
133
+ * 5. `DEFAULT_HOST_ORIGIN`.
134
+ *
135
+ * When a valid override is found in the query parameter, it is persisted
136
+ * to sessionStorage so subsequent in-iframe navigations in the same tab
137
+ * continue to use the same active origin.
138
+ */
139
+ private static readonly SESSION_STORAGE_KEY = 'lc_host_override';
140
+
141
+ /**
142
+ * Read `window.location.ancestorOrigins[0]` without throwing if the
143
+ * property is unavailable (Firefox) or the list is empty (top-level
144
+ * context, e.g. running outside of an iframe).
145
+ */
146
+ private readAncestorOrigin(): string | null {
147
+ if (typeof window === 'undefined') return null;
148
+
149
+ try {
150
+ const ancestors = window.location.ancestorOrigins;
151
+
152
+ if (ancestors && ancestors.length > 0) {
153
+ const parent = ancestors[0];
154
+
155
+ if (typeof parent === 'string' && parent.length > 0) {
156
+ return parent;
157
+ }
158
+ }
159
+ } catch {
160
+ // `ancestorOrigins` is a WebKit/Blink extension; accessing it
161
+ // under unusual conditions can throw. Treat as unavailable.
162
+ }
163
+
164
+ return null;
165
+ }
166
+
167
+ private configureActiveOrigin(): void {
168
+ if (typeof window === 'undefined') {
169
+ this.activeHostOrigin = this.hostOrigins[0] || PartnerConnect.DEFAULT_HOST_ORIGIN;
170
+ return;
171
+ }
172
+
173
+ try {
174
+ const ancestorOrigin = this.readAncestorOrigin();
175
+ const urlParams = new URLSearchParams(window.location.search);
176
+ const hostOverride = urlParams.get('lc_host_override');
177
+
178
+ // Priority 1: the real parent origin as reported by the browser.
179
+ // This is unspoofable by query-param manipulation, so if it is
180
+ // trusted we use it and ignore any override. If both are present
181
+ // and disagree, we log and prefer the ancestor.
182
+ if (ancestorOrigin && this.isOriginInWhitelist(ancestorOrigin)) {
183
+ if (hostOverride && hostOverride !== ancestorOrigin) {
184
+ console.warn(
185
+ '[LearnCard SDK] lc_host_override does not match the real parent origin; preferring parent.',
186
+ { override: hostOverride, parent: ancestorOrigin }
187
+ );
188
+ }
189
+
190
+ this.activeHostOrigin = ancestorOrigin;
191
+ this.persistOverride(ancestorOrigin);
192
+ console.log('[LearnCard SDK] Using parent origin:', ancestorOrigin);
193
+ return;
194
+ }
195
+
196
+ // Priority 2: lc_host_override query parameter.
197
+ if (hostOverride) {
198
+ if (this.isOriginInWhitelist(hostOverride)) {
199
+ this.activeHostOrigin = hostOverride;
200
+ this.persistOverride(hostOverride);
201
+ console.log('[LearnCard SDK] Using lc_host_override:', hostOverride);
202
+ return;
203
+ }
204
+
205
+ console.warn(
206
+ '[LearnCard SDK] lc_host_override value is not in the configured whitelist:',
207
+ hostOverride,
208
+ 'Allowed:',
209
+ this.hostOrigins
210
+ );
211
+ }
212
+
213
+ // Priority 3: a previously-validated override from this tab session.
214
+ let storedOverride: string | null = null;
215
+
216
+ try {
217
+ storedOverride = sessionStorage.getItem(PartnerConnect.SESSION_STORAGE_KEY);
218
+ } catch {
219
+ // sessionStorage may be unavailable (e.g. sandboxed iframes)
220
+ }
221
+
222
+ if (storedOverride && this.isOriginInWhitelist(storedOverride)) {
223
+ this.activeHostOrigin = storedOverride;
224
+ console.log('[LearnCard SDK] Using stored lc_host_override:', storedOverride);
225
+ return;
226
+ }
227
+
228
+ // Priority 4/5: fall back to the first configured origin or default.
229
+ this.activeHostOrigin = this.hostOrigins[0] || PartnerConnect.DEFAULT_HOST_ORIGIN;
230
+ console.log('[LearnCard SDK] Using configured origin:', this.activeHostOrigin);
231
+ } catch (error) {
232
+ console.error('[LearnCard SDK] Error configuring active origin:', error);
233
+ this.activeHostOrigin = this.hostOrigins[0] || PartnerConnect.DEFAULT_HOST_ORIGIN;
234
+ }
235
+ }
236
+
237
+ private persistOverride(origin: string): void {
238
+ try {
239
+ sessionStorage.setItem(PartnerConnect.SESSION_STORAGE_KEY, origin);
240
+ } catch {
241
+ // sessionStorage may be unavailable (e.g. sandboxed iframes)
242
+ }
243
+ }
244
+
245
+ private isOriginNativeApp(origin: string): boolean {
246
+ return (
247
+ origin.startsWith('capacitor://') ||
248
+ origin.startsWith('ionic://') ||
249
+ origin.startsWith('https://localhost') ||
250
+ origin.startsWith('http://localhost') ||
251
+ origin.startsWith('http://127.0.0.1')
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Internal placeholder substituted in for `*` so that `new URL(...)` can
257
+ * parse a wildcard pattern. Chosen to be a syntactically-valid DNS label
258
+ * that cannot collide with a real hostname.
259
+ */
260
+ private static readonly WILDCARD_PLACEHOLDER = '__lc_wildcard__';
261
+
262
+ /** `*` (any number of occurrences) for replacement in the pattern. */
263
+ private static readonly WILDCARD_REGEX = /\*/g;
264
+
265
+ /** The required leading-label form a wildcard pattern must take. */
266
+ private static readonly WILDCARD_LEADING_PREFIX = `${PartnerConnect.WILDCARD_PLACEHOLDER}.`;
267
+
268
+ /**
269
+ * Check whether a candidate origin matches a configured whitelist entry.
270
+ *
271
+ * Supports exact matches and wildcard patterns. A wildcard entry has the
272
+ * form `<protocol>://*.<domain>` and matches any origin with the same
273
+ * protocol, same port, and a host ending in `.<domain>` with at least
274
+ * one non-empty DNS label in place of the `*`.
275
+ *
276
+ * Examples with pattern `https://*.learncard.app`:
277
+ * - `https://staging.learncard.app` → match
278
+ * - `https://pr-1.preview.learncard.app` → match
279
+ * - `https://learncard.app` → no match (no subdomain)
280
+ * - `http://staging.learncard.app` → no match (protocol mismatch)
281
+ * - `https://learncard.app.attacker.com` → no match (suffix mismatch)
282
+ *
283
+ * Exposed as a public static so it can be unit-tested directly without
284
+ * standing up a full SDK instance.
285
+ */
286
+ public static matchesOriginPattern(candidate: string, pattern: string): boolean {
287
+ if (candidate === pattern) return true;
288
+ if (!pattern.includes('*')) return false;
289
+
290
+ let patternUrl: URL;
291
+ let candidateUrl: URL;
292
+
293
+ try {
294
+ // Replace the wildcard labels with a syntactically-valid host so
295
+ // URL() can parse it; we validate the real shape ourselves below.
296
+ patternUrl = new URL(
297
+ pattern.replace(PartnerConnect.WILDCARD_REGEX, PartnerConnect.WILDCARD_PLACEHOLDER)
298
+ );
299
+ candidateUrl = new URL(candidate);
300
+ } catch {
301
+ return false;
302
+ }
303
+
304
+ // Protocol, port, and (empty) path-origin must match exactly.
305
+ if (patternUrl.protocol !== candidateUrl.protocol) return false;
306
+ if (patternUrl.port !== candidateUrl.port) return false;
307
+
308
+ const patternHost = patternUrl.hostname;
309
+ const candidateHost = candidateUrl.hostname;
310
+
311
+ // Only allow wildcards as leading label(s): `*.foo.bar`, not `a*.b` or
312
+ // `foo.*.bar`. This keeps the matching rule predictable and safe.
313
+ if (!patternHost.startsWith(PartnerConnect.WILDCARD_LEADING_PREFIX)) return false;
314
+
315
+ const patternSuffix = patternHost.slice(PartnerConnect.WILDCARD_LEADING_PREFIX.length);
316
+
317
+ if (patternSuffix.length === 0) return false;
318
+ // No further wildcards anywhere else in the pattern.
319
+ if (patternSuffix.includes(PartnerConnect.WILDCARD_PLACEHOLDER)) return false;
320
+
321
+ // Candidate must end with `.<suffix>` and have at least one label
322
+ // before the suffix (the portion that the `*` stands in for).
323
+ const required = '.' + patternSuffix;
324
+
325
+ if (!candidateHost.endsWith(required)) return false;
326
+
327
+ const prefix = candidateHost.slice(0, candidateHost.length - required.length);
328
+
329
+ if (prefix.length === 0) return false;
330
+ // Labels in the prefix must themselves be non-empty (no `..`).
331
+ if (prefix.startsWith('.') || prefix.endsWith('.')) return false;
332
+ if (prefix.split('.').some(label => label.length === 0)) return false;
333
+
334
+ return true;
335
+ }
336
+
337
+ /**
338
+ * Check if an origin is in the effective whitelist (exact origins +
339
+ * wildcard patterns + optional native-app origins).
340
+ */
341
+ private isOriginInWhitelist(origin: string): boolean {
342
+ if (!origin) return false;
343
+
344
+ for (const entry of this.hostOrigins) {
345
+ if (PartnerConnect.matchesOriginPattern(origin, entry)) return true;
346
+ }
347
+
348
+ if (this.allowNativeAppOrigins && this.isOriginNativeApp(origin)) return true;
349
+
350
+ return false;
351
+ }
352
+
353
+ /**
354
+ * Check if an event origin is valid against the active host origin
355
+ *
356
+ * Security Rule: Incoming messages must exactly match the active host origin.
357
+ * This prevents malicious actors from spoofing origins via query parameters.
358
+ *
359
+ * @param eventOrigin - The origin from the MessageEvent
360
+ * @returns true if the origin is valid
361
+ */
362
+ private isValidOrigin(eventOrigin: string): boolean {
363
+ // STRICT: Exact match with active host origin only
364
+ return eventOrigin === this.activeHostOrigin;
365
+ }
366
+
367
+ /**
368
+ * Set up the central message listener to handle responses from the LearnCard host
369
+ */
370
+ private setupMessageListener(): void {
371
+ if (typeof window === 'undefined') {
372
+ throw new Error('PartnerConnect SDK can only be used in a browser environment');
373
+ }
374
+
375
+ this.messageListener = (event: MessageEvent) => {
376
+ // SECURITY CHECK 1: Strict origin validation - must exactly match active host origin
377
+ if (!this.isValidOrigin(event.origin)) {
378
+ // Silently ignore messages from unauthorized origins
379
+ return;
380
+ }
381
+
382
+ const data = event.data as PostMessageResponse;
383
+
384
+ // SECURITY CHECK 2: Validate protocol and requestId
385
+ if (data.protocol !== this.protocol || !data.requestId) {
386
+ return;
387
+ }
388
+
389
+ // Look up the pending request
390
+ const pending = this.pendingRequests.get(data.requestId);
391
+ if (!pending) {
392
+ return; // Ignore stale or unrecognized responses
393
+ }
394
+
395
+ // Clean up
396
+ clearTimeout(pending.timeoutId);
397
+ this.pendingRequests.delete(data.requestId);
398
+
399
+ // Resolve or reject the promise
400
+ if (data.type === 'SUCCESS') {
401
+ pending.resolve(data.data);
402
+ } else if (data.type === 'ERROR') {
403
+ pending.reject(
404
+ PartnerConnectError.from(
405
+ data.error || {
406
+ code: 'UNKNOWN_ERROR',
407
+ message: 'An unknown error occurred',
408
+ }
409
+ )
410
+ );
411
+ }
412
+ };
413
+
414
+ window.addEventListener('message', this.messageListener);
415
+ this.isInitialized = true;
416
+ }
417
+
418
+ /**
419
+ * Generate a unique request ID
420
+ */
421
+ private generateRequestId(action: string): string {
422
+ return `${action}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
423
+ }
424
+
425
+ /**
426
+ * Send a message to the parent window and return a Promise
427
+ */
428
+ private sendMessage<T = unknown>(action: string, payload?: unknown): Promise<T> {
429
+ if (!this.isInitialized) {
430
+ return Promise.reject(
431
+ new PartnerConnectError('SDK_NOT_INITIALIZED', 'SDK is not initialized')
432
+ );
433
+ }
434
+
435
+ return new Promise<T>((resolve, reject) => {
436
+ const requestId = this.generateRequestId(action);
437
+
438
+ // Set up timeout
439
+ const timeoutId = setTimeout(() => {
440
+ if (this.pendingRequests.has(requestId)) {
441
+ this.pendingRequests.delete(requestId);
442
+ reject(
443
+ new PartnerConnectError(
444
+ 'LC_TIMEOUT',
445
+ `Request ${action} timed out after ${this.requestTimeout}ms`
446
+ )
447
+ );
448
+ }
449
+ }, this.requestTimeout);
450
+
451
+ // Store the pending request
452
+ this.pendingRequests.set(requestId, {
453
+ resolve: resolve as (value: unknown) => void,
454
+ reject,
455
+ timeoutId,
456
+ });
457
+
458
+ // Construct the message
459
+ const message: PostMessageRequest = {
460
+ protocol: this.protocol,
461
+ action,
462
+ requestId,
463
+ payload,
464
+ };
465
+
466
+ // Send to parent window with the active host origin (configured or overridden)
467
+ window.parent.postMessage(message, this.activeHostOrigin);
468
+ });
469
+ }
470
+
471
+ /**
472
+ * Request user identity (Single Sign-On)
473
+ *
474
+ * @returns Promise resolving to user identity including DID and JWT token
475
+ * @throws {LearnCardError} When user is not authenticated or request fails
476
+ *
477
+ * @example
478
+ * ```typescript
479
+ * const identity = await learnCard.requestIdentity();
480
+ * console.log('User DID:', identity.user.did);
481
+ * console.log('JWT Token:', identity.token);
482
+ * ```
483
+ */
484
+ public requestIdentity(): Promise<IdentityResponse> {
485
+ return this.sendMessage<IdentityResponse>('REQUEST_IDENTITY', {
486
+ challenge: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
487
+ });
488
+ }
489
+
490
+ /**
491
+ * Send a credential to the user's LearnCard wallet
492
+ *
493
+ * Supports two modes:
494
+ * 1. **Raw credential**: Pass a full verifiable credential object
495
+ * 2. **Template-based**: Pass `{ templateAlias, templateData }` to issue from a pre-configured boost template
496
+ *
497
+ * @param input - Either a verifiable credential or a template-based input
498
+ * @returns Promise resolving to credential response
499
+ *
500
+ * @example Raw credential
501
+ * ```typescript
502
+ * const response = await learnCard.sendCredential({
503
+ * '@context': ['https://www.w3.org/2018/credentials/v1'],
504
+ * type: ['VerifiableCredential'],
505
+ * credentialSubject: { id: 'did:example:123' }
506
+ * });
507
+ * console.log('Credential ID:', response.credentialId);
508
+ * ```
509
+ *
510
+ * @example Template-based (for App Store apps)
511
+ * ```typescript
512
+ * const response = await learnCard.sendCredential({
513
+ * templateAlias: 'achievement-badge',
514
+ * templateData: { score: '95', courseName: 'Web Dev 101' }
515
+ * });
516
+ * console.log('Credential URI:', response.credentialUri);
517
+ * ```
518
+ */
519
+ public sendCredential(
520
+ input: unknown | TemplateCredentialInput
521
+ ): Promise<SendCredentialResponse | TemplateCredentialResponse> {
522
+ if (
523
+ input &&
524
+ typeof input === 'object' &&
525
+ 'templateAlias' in input &&
526
+ typeof (input as TemplateCredentialInput).templateAlias === 'string'
527
+ ) {
528
+ const templateInput = input as TemplateCredentialInput;
529
+
530
+ return this.sendAppEvent<TemplateCredentialResponse>({
531
+ type: 'send-credential',
532
+ templateAlias: templateInput.templateAlias,
533
+ templateData: templateInput.templateData,
534
+ preventDuplicateClaim: templateInput.preventDuplicateClaim,
535
+ });
536
+ }
537
+
538
+ return this.sendMessage<SendCredentialResponse>('SEND_CREDENTIAL', { credential: input });
539
+ }
540
+
541
+ /**
542
+ * Check whether the current user already has a credential from a given boost template.
543
+ * This is a silent, non-interactive status check for installed app integrations.
544
+ */
545
+ public checkUserHasCredential(input: CheckCredentialInput): Promise<CheckCredentialResponse> {
546
+ return this.sendAppEvent<CheckCredentialResponse>({
547
+ type: 'check-credential',
548
+ ...input,
549
+ });
550
+ }
551
+
552
+ /**
553
+ * Check if the current user has issued/sent a specific template to someone.
554
+ * Returns issuance status including sent date and claim status.
555
+ *
556
+ * @param input - Template identifier (templateAlias or boostUri) and recipient identifier (recipientDid or recipientProfileId)
557
+ * @returns Promise resolving to issuance status response
558
+ *
559
+ * @example
560
+ * ```typescript
561
+ * // Check if user already issued 'achievement-badge' to a specific person
562
+ * const status = await learnCard.getTemplateIssuanceStatus({
563
+ * templateAlias: 'achievement-badge',
564
+ * recipientProfileId: 'user123'
565
+ * });
566
+ *
567
+ * if (status.sent) {
568
+ * console.log('Already issued on:', status.sentDate);
569
+ * console.log('Status:', status.status); // 'pending', 'claimed', or 'revoked'
570
+ * }
571
+ * ```
572
+ */
573
+ public getTemplateIssuanceStatus(
574
+ input: CheckIssuanceStatusInput
575
+ ): Promise<TemplateIssuanceStatusResponse> {
576
+ return this.sendAppEvent<TemplateIssuanceStatusResponse>({
577
+ type: 'check-issuance-status',
578
+ ...input,
579
+ });
580
+ }
581
+
582
+ /**
583
+ * Get the list of all recipients for a specific template/boost.
584
+ * Useful for dashboards showing who has received a credential.
585
+ *
586
+ * @param input - Template identifier (templateAlias or boostUri) and optional pagination params
587
+ * @returns Promise resolving to paginated list of recipients
588
+ *
589
+ * @example
590
+ * ```typescript
591
+ * // Get first 10 recipients of 'achievement-badge'
592
+ * const recipients = await learnCard.getTemplateRecipients({
593
+ * templateAlias: 'achievement-badge',
594
+ * limit: 10
595
+ * });
596
+ *
597
+ * console.log(`Found ${recipients.records.length} recipients`);
598
+ * recipients.records.forEach(r => {
599
+ * console.log(`${r.recipientDisplayName}: ${r.status}`);
600
+ * });
601
+ *
602
+ * // Get next page if available
603
+ * if (recipients.hasMore) {
604
+ * const nextPage = await learnCard.getTemplateRecipients({
605
+ * templateAlias: 'achievement-badge',
606
+ * limit: 10,
607
+ * cursor: recipients.cursor
608
+ * });
609
+ * }
610
+ * ```
611
+ */
612
+ public getTemplateRecipients(
613
+ input: GetTemplateRecipientsInput
614
+ ): Promise<TemplateRecipientsResponse> {
615
+ return this.sendAppEvent<TemplateRecipientsResponse>({
616
+ type: 'get-template-recipients',
617
+ ...input,
618
+ });
619
+ }
620
+
621
+ /**
622
+ * Launch a feature in the LearnCard host application
623
+ *
624
+ * @param featurePath - Path to the feature (e.g., '/ai/topics')
625
+ * @param initialPrompt - Optional initial prompt or data
626
+ *
627
+ * @example
628
+ * ```typescript
629
+ * await learnCard.launchFeature(
630
+ * '/ai/topics?shortCircuitStep=newTopic',
631
+ * 'Help me understand cryptography'
632
+ * );
633
+ * ```
634
+ */
635
+ public launchFeature(featurePath: string, initialPrompt?: string): Promise<void> {
636
+ return this.sendMessage<void>('LAUNCH_FEATURE', { featurePath, initialPrompt });
637
+ }
638
+
639
+ /**
640
+ * Request credentials from the user's wallet using a query
641
+ *
642
+ * @param verifiablePresentationRequest - VPR with query criteria
643
+ * @returns Promise resolving to verifiable presentation
644
+ *
645
+ * @example
646
+ * ```typescript
647
+ * const response = await learnCard.askCredentialSearch({
648
+ * query: [{
649
+ * type: 'QueryByTitle',
650
+ * credentialQuery: {
651
+ * reason: 'We need to verify your skills',
652
+ * title: 'JavaScript Expert'
653
+ * }
654
+ * }],
655
+ * challenge: 'challenge-123',
656
+ * domain: window.location.hostname
657
+ * });
658
+ *
659
+ * if (response.verifiablePresentation) {
660
+ * console.log('Received credentials:', response.verifiablePresentation.verifiableCredential);
661
+ * }
662
+ * ```
663
+ */
664
+ public askCredentialSearch(
665
+ verifiablePresentationRequest: VerifiablePresentationRequest
666
+ ): Promise<CredentialSearchResponse> {
667
+ return this.sendMessage<CredentialSearchResponse>('ASK_CREDENTIAL_SEARCH', {
668
+ verifiablePresentationRequest,
669
+ });
670
+ }
671
+
672
+ /**
673
+ * Request a specific credential by ID
674
+ *
675
+ * @param credentialId - The ID of the credential to request
676
+ * @returns Promise resolving to the credential
677
+ *
678
+ * @example
679
+ * ```typescript
680
+ * const response = await learnCard.askCredentialSpecific('credential-id-123');
681
+ * if (response.credential) {
682
+ * console.log('Received credential:', response.credential);
683
+ * }
684
+ * ```
685
+ */
686
+ public askCredentialSpecific(credentialId: string): Promise<CredentialSpecificResponse> {
687
+ return this.sendMessage<CredentialSpecificResponse>('ASK_CREDENTIAL_SPECIFIC', {
688
+ credentialId,
689
+ });
690
+ }
691
+
692
+ /**
693
+ * Request user consent for permissions
694
+ *
695
+ * @param contractUri - URI of the consent contract (optional for App Store apps with configured contracts)
696
+ * @param options - Additional options including redirect behavior
697
+ * @returns Promise resolving to consent response
698
+ *
699
+ * @example
700
+ * ```typescript
701
+ * // With explicit contract URI (for external/non-app store integrations)
702
+ * const response = await learnCard.requestConsent('lc:network:network.learncard.com/trpc:contract:abc123');
703
+ * if (response.granted) {
704
+ * console.log('User granted consent');
705
+ * }
706
+ *
707
+ * // Without contract URI (uses app's configured contract from integration)
708
+ * // This works for App Store apps that have configured a contract in their integration
709
+ * const response = await learnCard.requestConsent();
710
+ * if (response.granted) {
711
+ * console.log('User granted consent using listing contract');
712
+ * }
713
+ *
714
+ * // With redirect - redirects to contract's redirectUrl with VP in URL params
715
+ * const response = await learnCard.requestConsent(undefined, { redirect: true });
716
+ * ```
717
+ */
718
+ public requestConsent(
719
+ contractUri?: string,
720
+ options: RequestConsentOptions = {}
721
+ ): Promise<ConsentResponse> {
722
+ const { redirect = false } = options;
723
+
724
+ return this.sendMessage<ConsentResponse>('REQUEST_CONSENT', {
725
+ contractUri,
726
+ redirect,
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Initiate a template-based credential issuance flow
732
+ *
733
+ * @param templateId - ID of the template/boost to issue
734
+ * @param draftRecipients - Optional array of recipient DIDs
735
+ * @returns Promise resolving to template issue response
736
+ *
737
+ * @example
738
+ * ```typescript
739
+ * const response = await learnCard.initiateTemplateIssue(
740
+ * 'lc:network:network.learncard.com/trpc:boost:xyz789',
741
+ * ['did:key:abc', 'did:key:def']
742
+ * );
743
+ *
744
+ * if (response.issued) {
745
+ * console.log('Template issued successfully');
746
+ * }
747
+ * ```
748
+ */
749
+ public initiateTemplateIssue(
750
+ templateId: string,
751
+ draftRecipients?: string[]
752
+ ): Promise<TemplateIssueResponse> {
753
+ return this.sendMessage<TemplateIssueResponse>('INITIATE_TEMPLATE_ISSUE', {
754
+ templateId,
755
+ draftRecipients: draftRecipients || [],
756
+ });
757
+ }
758
+
759
+ /**
760
+ * Request comprehensive learner context for AI tutoring systems.
761
+ *
762
+ * This method retrieves the user's credentials and personal data,
763
+ * then formats them into an LLM-ready prompt that can be injected directly into
764
+ * an AI system prompt.
765
+ *
766
+ * @param options - Configuration options for what data to include and how to format it
767
+ * @returns Promise resolving to learner context with prompt and optional raw data
768
+ *
769
+ * @example
770
+ * ```typescript
771
+ * // Get LLM-ready prompt with credentials and personal data
772
+ * const context = await learnCard.requestLearnerContext({
773
+ * includeCredentials: true,
774
+ * includePersonalData: true,
775
+ * waitForSync: true,
776
+ * format: 'prompt',
777
+ * instructions: 'Focus on technical skills and certifications',
778
+ * detailLevel: 'expanded'
779
+ * });
780
+ *
781
+ * if (context.status === 'syncing') {
782
+ * const unsubscribe = learnCard.onSyncComplete(async () => {
783
+ * const readyContext = await learnCard.requestLearnerContext({ waitForSync: true });
784
+ * unsubscribe();
785
+ * });
786
+ * }
787
+ *
788
+ * // Use in AI system prompt
789
+ * const systemPrompt = `You are a helpful tutor. ${context.prompt}`;
790
+ *
791
+ * // Access structured data if needed
792
+ * console.log('User DID:', context.did);
793
+ * console.log('Credentials count:', context.raw?.credentials.length);
794
+ * ```
795
+ */
796
+ public requestLearnerContext(
797
+ options?: RequestLearnerContextOptions
798
+ ): Promise<LearnerContextResponse> {
799
+ return this.sendMessage<LearnerContextResponse>('REQUEST_LEARNER_CONTEXT', {
800
+ includeCredentials: options?.includeCredentials ?? true,
801
+ includePersonalData: options?.includePersonalData ?? false,
802
+ format: options?.format ?? 'prompt',
803
+ instructions: options?.instructions,
804
+ detailLevel: options?.detailLevel ?? 'compact',
805
+ waitForSync: options?.waitForSync ?? false,
806
+ });
807
+ }
808
+
809
+ /**
810
+ * Get the current LearnCard background data sync status.
811
+ */
812
+ public getSyncStatus(): Promise<SyncStatus> {
813
+ return this.sendMessage<SyncStatus>('GET_SYNC_STATUS');
814
+ }
815
+
816
+ /**
817
+ * Register a callback that fires when LearnCard reports background sync has reached a
818
+ * terminal state ('ready' or 'error'). Check `status.status` to distinguish the two.
819
+ * Polling stops once a terminal state is reached, all callbacks unsubscribe, or the
820
+ * poll exceeds its maximum duration (reported to callbacks as an 'error' status).
821
+ * Returns an unsubscribe function.
822
+ */
823
+ public onSyncComplete(callback: (status: SyncStatus) => void): () => void {
824
+ this.syncCompleteCallbacks.add(callback);
825
+
826
+ if (!this.syncStatusPollId) {
827
+ const pollStartedAt = Date.now();
828
+
829
+ const stopPolling = () => {
830
+ if (this.syncStatusPollId) {
831
+ clearInterval(this.syncStatusPollId);
832
+ this.syncStatusPollId = null;
833
+ }
834
+ };
835
+
836
+ this.syncStatusPollId = setInterval(() => {
837
+ if (Date.now() - pollStartedAt > SYNC_STATUS_POLL_MAX_DURATION_MS) {
838
+ stopPolling();
839
+ const timeoutStatus: SyncStatus = {
840
+ status: 'error',
841
+ progress: {
842
+ totalCredentials: 0,
843
+ completedCredentials: 0,
844
+ failedCredentials: 0,
845
+ retryCount: 0,
846
+ },
847
+ lastError: 'Timed out waiting for sync to complete',
848
+ };
849
+ this.syncCompleteCallbacks.forEach(cb => cb(timeoutStatus));
850
+ return;
851
+ }
852
+
853
+ this.getSyncStatus()
854
+ .then(status => {
855
+ if (status.status !== 'ready' && status.status !== 'error') return;
856
+
857
+ stopPolling();
858
+ this.syncCompleteCallbacks.forEach(cb => cb(status));
859
+ })
860
+ .catch(() => undefined);
861
+ }, 1000);
862
+ }
863
+
864
+ return () => {
865
+ this.syncCompleteCallbacks.delete(callback);
866
+ if (this.syncCompleteCallbacks.size === 0 && this.syncStatusPollId) {
867
+ clearInterval(this.syncStatusPollId);
868
+ this.syncStatusPollId = null;
869
+ }
870
+ };
871
+ }
872
+
873
+ /**
874
+ * Send a generic event to be processed by the brain service on behalf of this app.
875
+ * This is used for backend-like operations such as issuing credentials.
876
+ *
877
+ * @param event - The event payload to send
878
+ * @returns Promise resolving to the event response
879
+ *
880
+ * @example
881
+ * ```typescript
882
+ * // Issue a credential to the current user
883
+ * const response = await learnCard.sendAppEvent({
884
+ * type: 'send-credential',
885
+ * templateAlias: 'achievement-badge',
886
+ * templateData: { score: '95' }
887
+ * });
888
+ *
889
+ * if (response.credentialUri) {
890
+ * console.log('Credential issued:', response.credentialUri);
891
+ * }
892
+ * ```
893
+ */
894
+ public sendAppEvent<T = AppEventResponse>(event: AppEvent): Promise<T> {
895
+ return this.sendMessage<T>('APP_EVENT', event);
896
+ }
897
+
898
+ /**
899
+ * Create and send an AI Session credential to the user.
900
+ *
901
+ * This method manages the AI Topic → AI Session hierarchy:
902
+ * - Ensures an AI Topic exists for this app (creates one if needed)
903
+ * - Creates a new AI Session as a child of the topic
904
+ * - The topic appears in the user's AI Sessions page with the app's name
905
+ * - All sessions from this app are organized under that topic
906
+ *
907
+ * @param input - Session details including title and optional metadata
908
+ * @returns Promise resolving to topic and session URIs
909
+ */
910
+ public sendAiSessionCredential(
911
+ input: SendAiSessionCredentialInput
912
+ ): Promise<SendAiSessionCredentialResponse> {
913
+ return this.sendAppEvent<SendAiSessionCredentialResponse>({
914
+ type: 'send-ai-session-credential',
915
+ ...input,
916
+ });
917
+ }
918
+
919
+ /**
920
+ * Send a notification to the current user from this app.
921
+ * The notification appears in the user's LearnCard notification inbox.
922
+ */
923
+ public sendNotification(input: AppNotificationInput): Promise<AppNotificationResponse> {
924
+ return this.sendAppEvent<AppNotificationResponse>({
925
+ type: 'send-notification',
926
+ ...input,
927
+ });
928
+ }
929
+
930
+ /**
931
+ * Increment or decrement an app-scoped counter for the current user.
932
+ */
933
+ public incrementCounter(key: string, amount: number): Promise<IncrementCounterResponse> {
934
+ return this.sendAppEvent<IncrementCounterResponse>({
935
+ type: 'increment-counter',
936
+ key,
937
+ amount,
938
+ });
939
+ }
940
+
941
+ /**
942
+ * Read the current value of an app-scoped counter for the current user.
943
+ */
944
+ public getCounter(key: string): Promise<GetCounterResponse> {
945
+ return this.sendAppEvent<GetCounterResponse>({
946
+ type: 'get-counter',
947
+ key,
948
+ });
949
+ }
950
+
951
+ /**
952
+ * Read multiple app-scoped counters at once for the current user.
953
+ */
954
+ public getCounters(keys?: string[]): Promise<GetCountersResponse> {
955
+ return this.sendAppEvent<GetCountersResponse>({
956
+ type: 'get-counters',
957
+ ...(keys ? { keys } : {}),
958
+ });
959
+ }
960
+
961
+ /**
962
+ * Clean up the SDK and remove event listeners
963
+ */
964
+ public destroy(): void {
965
+ if (this.messageListener) {
966
+ window.removeEventListener('message', this.messageListener);
967
+ this.messageListener = null;
968
+ }
969
+
970
+ // Reject all pending requests
971
+ for (const [requestId, pending] of this.pendingRequests.entries()) {
972
+ clearTimeout(pending.timeoutId);
973
+ pending.reject(
974
+ new PartnerConnectError(
975
+ 'SDK_DESTROYED',
976
+ 'SDK was destroyed before request completed'
977
+ )
978
+ );
979
+ }
980
+
981
+ this.pendingRequests.clear();
982
+
983
+ if (this.syncStatusPollId) {
984
+ clearInterval(this.syncStatusPollId);
985
+ this.syncStatusPollId = null;
986
+ }
987
+ this.syncCompleteCallbacks.clear();
988
+
989
+ this.isInitialized = false;
990
+ }
991
+ }
992
+
993
+ /**
994
+ * Factory function to create a PartnerConnect instance
995
+ *
996
+ * @param options - Configuration options
997
+ * @returns PartnerConnect instance
998
+ *
999
+ * @example
1000
+ * ```typescript
1001
+ * const learnCard = createPartnerConnect({
1002
+ * hostOrigin: 'https://learncard.app',
1003
+ * protocol: 'LEARNCARD_V1',
1004
+ * requestTimeout: 30000
1005
+ * });
1006
+ * ```
1007
+ */
1008
+ export function createPartnerConnect(options?: PartnerConnectOptions): PartnerConnect {
1009
+ return new PartnerConnect(options);
1010
+ }
1011
+
1012
+ // Default export for convenience
1013
+ export default createPartnerConnect;