@learncard/partner-connect 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +395 -83
- package/dist/index.d.ts +257 -25
- package/dist/partner-connect.esm.js +3294 -431
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +3295 -430
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +3294 -431
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +3 -2
- package/src/docs-snippets.test.ts +79 -0
- package/src/index.ts +496 -33
- package/src/mock-host.ts +2447 -523
- package/src/mock-mode.test.ts +1337 -7
- package/src/origin-resolution.test.ts +229 -0
- package/src/types.ts +130 -11
package/src/index.ts
CHANGED
|
@@ -18,11 +18,28 @@
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import {
|
|
22
|
+
compileInlineTemplate,
|
|
23
|
+
isAppBuilderEditorOrigin,
|
|
24
|
+
isAppBuilderPreviewHost,
|
|
25
|
+
decodeManifestFromUrl,
|
|
26
|
+
normalizeConsentRequest,
|
|
27
|
+
renderCompiledTemplate,
|
|
28
|
+
validateInlineTemplate,
|
|
29
|
+
validateTemplateData,
|
|
30
|
+
} from '@learncard/partner-connect-core';
|
|
21
31
|
import { PartnerConnectError } from './types';
|
|
22
32
|
import { MockHost } from './mock-host';
|
|
33
|
+
import type { MockHostContext } from './mock-host';
|
|
23
34
|
import type {
|
|
35
|
+
InlineCredentialTemplate,
|
|
36
|
+
InlineTemplateCredentialInput,
|
|
37
|
+
InlineTemplateValidationError,
|
|
24
38
|
PartnerConnectOptions,
|
|
39
|
+
CapturedAppManifest,
|
|
40
|
+
MockHostOptions,
|
|
25
41
|
IdentityResponse,
|
|
42
|
+
SendCredentialInput,
|
|
26
43
|
SendCredentialResponse,
|
|
27
44
|
TemplateCredentialInput,
|
|
28
45
|
TemplateCredentialResponse,
|
|
@@ -30,6 +47,7 @@ import type {
|
|
|
30
47
|
CredentialSearchResponse,
|
|
31
48
|
CredentialSpecificResponse,
|
|
32
49
|
ConsentResponse,
|
|
50
|
+
ConsentRequest,
|
|
33
51
|
RequestConsentOptions,
|
|
34
52
|
TemplateIssueResponse,
|
|
35
53
|
CheckCredentialInput,
|
|
@@ -51,15 +69,19 @@ import type {
|
|
|
51
69
|
AppEvent,
|
|
52
70
|
AppEventResponse,
|
|
53
71
|
LearnCardError,
|
|
72
|
+
PreviewCompiledTemplateResult,
|
|
54
73
|
PostMessageRequest,
|
|
55
74
|
PostMessageResponse,
|
|
56
75
|
PendingRequest,
|
|
57
76
|
} from './types';
|
|
58
77
|
|
|
78
|
+
const DEFAULT_PUBLISH_ORIGIN = 'https://learncard.app';
|
|
79
|
+
|
|
59
80
|
// Re-export the class as a value plus all type exports.
|
|
60
81
|
// `MockHost` is an internal implementation detail (constructed via
|
|
61
82
|
// `createPartnerConnect({ mock, mockOptions })`); only its options type is public.
|
|
62
83
|
export { PartnerConnectError } from './types';
|
|
84
|
+
export { decodeManifestFromUrl };
|
|
63
85
|
export type * from './types';
|
|
64
86
|
|
|
65
87
|
/** Maximum time to poll for sync completion before giving up (10 minutes) */
|
|
@@ -68,6 +90,74 @@ const SYNC_STATUS_POLL_MAX_DURATION_MS = 10 * 60 * 1000;
|
|
|
68
90
|
/** Default wait for the host presence probe (see `hostProbeTimeout`). */
|
|
69
91
|
const DEFAULT_HOST_PROBE_TIMEOUT_MS = 1500;
|
|
70
92
|
|
|
93
|
+
const joinInlineTemplateErrors = (errors: InlineTemplateValidationError[]): string =>
|
|
94
|
+
errors.map(error => `${error.path}: ${error.message}`).join('\n');
|
|
95
|
+
|
|
96
|
+
const parseCompiledTemplateObject = (credentialTemplateJson: string): Record<string, unknown> => {
|
|
97
|
+
return JSON.parse(
|
|
98
|
+
credentialTemplateJson.replace(/:(\s*)(\{\{\w+\}\})(?=\s*[,}\]])/g, ':$1"$2"')
|
|
99
|
+
) as Record<string, unknown>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Compile and optionally render an inline credential template entirely offline.
|
|
104
|
+
*
|
|
105
|
+
* `compiled` contains the canonical OBv3 credential object with `{{variables}}`
|
|
106
|
+
* preserved. If `templateData` is provided and valid, `rendered` contains the
|
|
107
|
+
* same object with those variables substituted.
|
|
108
|
+
*/
|
|
109
|
+
export const previewCompiledTemplate = (
|
|
110
|
+
template: InlineCredentialTemplate,
|
|
111
|
+
templateData?: Record<string, unknown>
|
|
112
|
+
): PreviewCompiledTemplateResult => {
|
|
113
|
+
const templateErrors = validateInlineTemplate(template);
|
|
114
|
+
|
|
115
|
+
if (templateErrors.length > 0) {
|
|
116
|
+
return { valid: false, errors: templateErrors };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const compiledTemplate = compileInlineTemplate(template);
|
|
120
|
+
const compiled = parseCompiledTemplateObject(compiledTemplate.credentialTemplateJson);
|
|
121
|
+
|
|
122
|
+
if (templateData === undefined) {
|
|
123
|
+
return { valid: true, errors: [], compiled };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const dataErrors = validateTemplateData(compiledTemplate.variableManifest, templateData);
|
|
127
|
+
|
|
128
|
+
if (dataErrors.length > 0) {
|
|
129
|
+
return { valid: false, errors: dataErrors, compiled };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
valid: true,
|
|
134
|
+
errors: [],
|
|
135
|
+
compiled,
|
|
136
|
+
rendered: renderCompiledTemplate(compiledTemplate.credentialTemplateJson, templateData),
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const isTemplateAliasInput = (input: unknown): input is TemplateCredentialInput =>
|
|
141
|
+
Boolean(
|
|
142
|
+
input &&
|
|
143
|
+
typeof input === 'object' &&
|
|
144
|
+
'templateAlias' in input &&
|
|
145
|
+
typeof (input as TemplateCredentialInput).templateAlias === 'string'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const isInlineTemplateInput = (input: unknown): input is InlineTemplateCredentialInput =>
|
|
149
|
+
Boolean(
|
|
150
|
+
input &&
|
|
151
|
+
typeof input === 'object' &&
|
|
152
|
+
'alias' in input &&
|
|
153
|
+
typeof (input as InlineTemplateCredentialInput).alias === 'string' &&
|
|
154
|
+
'template' in input
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const isConsentRequestInput = (input: unknown): input is ConsentRequest => {
|
|
158
|
+
return Boolean(input && typeof input === 'object' && !Array.isArray(input));
|
|
159
|
+
};
|
|
160
|
+
|
|
71
161
|
/**
|
|
72
162
|
* Whether a hostname is a local development host. `mock: 'auto'` only ever
|
|
73
163
|
* activates on these, so a standalone page on a production or preview origin
|
|
@@ -164,6 +254,14 @@ export class PartnerConnect {
|
|
|
164
254
|
private syncCompleteCallbacks: Set<(status: SyncStatus) => void> = new Set();
|
|
165
255
|
private syncStatusPollId: ReturnType<typeof setInterval> | null = null;
|
|
166
256
|
private mockHost: MockHost | null = null;
|
|
257
|
+
/** Internal, non-public context handed to every {@link MockHost} we build. */
|
|
258
|
+
private mockHostContext: MockHostContext = {};
|
|
259
|
+
/**
|
|
260
|
+
* Memoized override-derived publish origin. `undefined` means "not looked
|
|
261
|
+
* up yet"; `null` means "looked up, no usable override". Caching keeps the
|
|
262
|
+
* warning for an invalid value to a single log per instance.
|
|
263
|
+
*/
|
|
264
|
+
private pinnedPublishOrigin: string | null | undefined;
|
|
167
265
|
private embedded = false;
|
|
168
266
|
private warnedNoHost = false;
|
|
169
267
|
private hostProbeTimeout: number = DEFAULT_HOST_PROBE_TIMEOUT_MS;
|
|
@@ -198,6 +296,146 @@ export class PartnerConnect {
|
|
|
198
296
|
this.configureMockActivation(options);
|
|
199
297
|
}
|
|
200
298
|
|
|
299
|
+
private resolveMockOptions(options?: PartnerConnectOptions): MockHostOptions | undefined {
|
|
300
|
+
if (options?.mockOptions?.publishOrigin) {
|
|
301
|
+
this.mockHostContext = { publishOriginPinned: true };
|
|
302
|
+
return options.mockOptions;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const publishOrigin = this.resolvePublishOrigin(options?.hostOrigin);
|
|
306
|
+
|
|
307
|
+
this.mockHostContext = { publishOriginPinned: this.readPinnedPublishOrigin() !== null };
|
|
308
|
+
|
|
309
|
+
return { ...(options?.mockOptions ?? {}), publishOrigin };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Resolve the LearnCard origin that App Store publish links point at:
|
|
314
|
+
* 1. `?lc_publish_override=` (query param, then this tab's stored value).
|
|
315
|
+
* 2. `?lc_host_override=` (query param, then stored) when it names one
|
|
316
|
+
* concrete http(s) origin — testing against a host implies publishing
|
|
317
|
+
* to it.
|
|
318
|
+
* 3. The first concrete configured `hostOrigin`.
|
|
319
|
+
* 4. `DEFAULT_PUBLISH_ORIGIN`.
|
|
320
|
+
*
|
|
321
|
+
* `mockOptions.publishOrigin` outranks all of these and is applied by
|
|
322
|
+
* {@link resolveMockOptions} before this runs.
|
|
323
|
+
*/
|
|
324
|
+
private resolvePublishOrigin(hostOrigin?: string | string[]): string {
|
|
325
|
+
return this.readPinnedPublishOrigin() ?? this.inferPublishOrigin(hostOrigin);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private readPinnedPublishOrigin(): string | null {
|
|
329
|
+
if (this.pinnedPublishOrigin !== undefined) return this.pinnedPublishOrigin;
|
|
330
|
+
|
|
331
|
+
this.pinnedPublishOrigin = this.readPublishOverride() ?? this.readHostOverrideForPublish();
|
|
332
|
+
|
|
333
|
+
return this.pinnedPublishOrigin;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Unlike `lc_host_override`, this is not a security boundary — it only
|
|
338
|
+
* decides which LearnCard origin publish links target — so any parseable
|
|
339
|
+
* http(s) origin is accepted rather than being whitelist-checked.
|
|
340
|
+
*/
|
|
341
|
+
private readPublishOverride(): string | null {
|
|
342
|
+
if (typeof window === 'undefined') return null;
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const param = new URLSearchParams(window.location.search).get('lc_publish_override');
|
|
346
|
+
|
|
347
|
+
if (param) {
|
|
348
|
+
const normalized = PartnerConnect.normalizePublishOrigin(param);
|
|
349
|
+
|
|
350
|
+
if (normalized) {
|
|
351
|
+
this.persistPublishOverride(normalized);
|
|
352
|
+
console.log('[LearnCard SDK] Using lc_publish_override:', normalized);
|
|
353
|
+
return normalized;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.warn(
|
|
357
|
+
'[LearnCard SDK] lc_publish_override is not a valid http(s) origin; ignoring:',
|
|
358
|
+
param
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const stored = PartnerConnect.readSessionValue(
|
|
363
|
+
PartnerConnect.PUBLISH_SESSION_STORAGE_KEY
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
if (stored) return PartnerConnect.normalizePublishOrigin(stored);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
console.error('[LearnCard SDK] Error reading lc_publish_override:', error);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private readHostOverrideForPublish(): string | null {
|
|
375
|
+
if (typeof window === 'undefined') return null;
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const candidates = [
|
|
379
|
+
new URLSearchParams(window.location.search).get('lc_host_override'),
|
|
380
|
+
PartnerConnect.readSessionValue(PartnerConnect.SESSION_STORAGE_KEY),
|
|
381
|
+
];
|
|
382
|
+
|
|
383
|
+
for (const candidate of candidates) {
|
|
384
|
+
// Wildcard patterns name a family of hosts, not one publishable origin.
|
|
385
|
+
if (!candidate || candidate.includes('*')) continue;
|
|
386
|
+
|
|
387
|
+
const normalized = PartnerConnect.normalizePublishOrigin(candidate);
|
|
388
|
+
if (normalized) return normalized;
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// A malformed location must never break initialization.
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private static normalizePublishOrigin(value: string): string | null {
|
|
398
|
+
try {
|
|
399
|
+
const url = new URL(value);
|
|
400
|
+
|
|
401
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
402
|
+
|
|
403
|
+
return url.origin;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private static readSessionValue(key: string): string | null {
|
|
410
|
+
try {
|
|
411
|
+
return sessionStorage.getItem(key);
|
|
412
|
+
} catch {
|
|
413
|
+
// sessionStorage may be unavailable (e.g. sandboxed iframes)
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private inferPublishOrigin(hostOrigin?: string | string[]): string {
|
|
419
|
+
const configuredOrigins = Array.isArray(hostOrigin)
|
|
420
|
+
? hostOrigin
|
|
421
|
+
: hostOrigin
|
|
422
|
+
? [hostOrigin]
|
|
423
|
+
: [];
|
|
424
|
+
|
|
425
|
+
for (const origin of configuredOrigins) {
|
|
426
|
+
if (origin.includes('*')) continue;
|
|
427
|
+
if (this.isOriginNativeApp(origin)) continue;
|
|
428
|
+
|
|
429
|
+
try {
|
|
430
|
+
return new URL(origin).origin;
|
|
431
|
+
} catch {
|
|
432
|
+
// Ignore malformed configured origins and continue.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return DEFAULT_PUBLISH_ORIGIN;
|
|
437
|
+
}
|
|
438
|
+
|
|
201
439
|
/**
|
|
202
440
|
* Decide whether this instance talks to a real host, simulates one, or
|
|
203
441
|
* fails fast — the resolution of the `mock` option against the runtime
|
|
@@ -205,24 +443,26 @@ export class PartnerConnect {
|
|
|
205
443
|
*/
|
|
206
444
|
private configureMockActivation(options?: PartnerConnectOptions): void {
|
|
207
445
|
const mockSetting = options?.mock ?? 'auto';
|
|
446
|
+
const mockOptions = this.resolveMockOptions(options);
|
|
208
447
|
|
|
209
448
|
if (mockSetting === true) {
|
|
210
|
-
this.mockHost = new MockHost(
|
|
449
|
+
this.mockHost = new MockHost(mockOptions, this.mockHostContext);
|
|
211
450
|
return;
|
|
212
451
|
}
|
|
213
452
|
|
|
214
453
|
// Whether this origin may fall back to mocking when no host answers:
|
|
215
|
-
// 'standalone' anywhere, 'auto' only
|
|
216
|
-
// production page must never fabricate
|
|
217
|
-
// nowhere.
|
|
454
|
+
// 'standalone' anywhere, 'auto' only in local dev or an app builder's
|
|
455
|
+
// editor preview (a standalone production page must never fabricate
|
|
456
|
+
// identity or consent), false nowhere.
|
|
218
457
|
const canAutoMock =
|
|
219
|
-
mockSetting === 'standalone' ||
|
|
458
|
+
mockSetting === 'standalone' ||
|
|
459
|
+
(mockSetting === 'auto' && (this.isLocalDevContext() || this.isAppBuilderContext()));
|
|
220
460
|
|
|
221
461
|
if (!this.embedded) {
|
|
222
462
|
// Standalone page: no host can answer. hostReachable stays false,
|
|
223
463
|
// so un-mocked calls fail fast with LC_NOT_EMBEDDED.
|
|
224
464
|
if (canAutoMock) {
|
|
225
|
-
this.mockHost = new MockHost(
|
|
465
|
+
this.mockHost = new MockHost(mockOptions, this.mockHostContext);
|
|
226
466
|
}
|
|
227
467
|
return;
|
|
228
468
|
}
|
|
@@ -237,7 +477,7 @@ export class PartnerConnect {
|
|
|
237
477
|
// never postMessage into the 30s timeout. Mock where allowed,
|
|
238
478
|
// fail fast everywhere else.
|
|
239
479
|
if (canAutoMock) {
|
|
240
|
-
this.mockHost = new MockHost(
|
|
480
|
+
this.mockHost = new MockHost(mockOptions, this.mockHostContext);
|
|
241
481
|
}
|
|
242
482
|
return;
|
|
243
483
|
|
|
@@ -247,7 +487,7 @@ export class PartnerConnect {
|
|
|
247
487
|
// unrelated wrapper. Ask: if the host answers a cheap
|
|
248
488
|
// side-effect-free probe, stay real; otherwise mock.
|
|
249
489
|
this.hostReachable = true;
|
|
250
|
-
this.activation = this.probeHost(
|
|
490
|
+
this.activation = this.probeHost(mockOptions);
|
|
251
491
|
} else {
|
|
252
492
|
// Mocking is off the table here anyway — preserve the
|
|
253
493
|
// long-standing assumption that an unverifiable parent
|
|
@@ -263,6 +503,31 @@ export class PartnerConnect {
|
|
|
263
503
|
return isLocalDevHost(window.location.hostname);
|
|
264
504
|
}
|
|
265
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Whether the app is running inside an AI app builder's preview (Lovable,
|
|
508
|
+
* Bolt, v0, Replit): either on a preview-only host, or framed by the
|
|
509
|
+
* builder's editor. Firefox hides ancestor origins, so the referrer of an
|
|
510
|
+
* embedded document stands in for the parent origin there.
|
|
511
|
+
*/
|
|
512
|
+
private isAppBuilderContext(): boolean {
|
|
513
|
+
if (typeof window === 'undefined') return false;
|
|
514
|
+
if (isAppBuilderPreviewHost(window.location.hostname)) return true;
|
|
515
|
+
if (!this.embedded) return false;
|
|
516
|
+
|
|
517
|
+
const parentOrigin = this.readAncestorOrigin() ?? this.readReferrerOrigin();
|
|
518
|
+
return parentOrigin !== null && isAppBuilderEditorOrigin(parentOrigin);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private readReferrerOrigin(): string | null {
|
|
522
|
+
if (typeof document === 'undefined' || !document.referrer) return null;
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
return new URL(document.referrer).origin;
|
|
526
|
+
} catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
266
531
|
private classifyParent(): ParentKind {
|
|
267
532
|
const ancestorOrigin = this.readAncestorOrigin();
|
|
268
533
|
|
|
@@ -290,7 +555,7 @@ export class PartnerConnect {
|
|
|
290
555
|
* listening and the mock takes over. Requests issued while the probe is
|
|
291
556
|
* in flight queue behind the decision instead of racing it.
|
|
292
557
|
*/
|
|
293
|
-
private probeHost(
|
|
558
|
+
private probeHost(mockOptions?: MockHostOptions): Promise<void> {
|
|
294
559
|
return this.postToHost('GET_SYNC_STATUS', undefined, this.hostProbeTimeout)
|
|
295
560
|
.then(() => {
|
|
296
561
|
this.activation = null;
|
|
@@ -299,7 +564,7 @@ export class PartnerConnect {
|
|
|
299
564
|
this.activation = null;
|
|
300
565
|
if (this.isInitialized) {
|
|
301
566
|
this.hostReachable = false;
|
|
302
|
-
this.mockHost = new MockHost(
|
|
567
|
+
this.mockHost = new MockHost(mockOptions, this.mockHostContext);
|
|
303
568
|
console.warn(
|
|
304
569
|
'[LearnCard SDK] Embedded in a frame, but no LearnCard host answered ' +
|
|
305
570
|
`within ${this.hostProbeTimeout}ms — activating standalone mock mode. ` +
|
|
@@ -333,6 +598,70 @@ export class PartnerConnect {
|
|
|
333
598
|
return this.mockHost !== null;
|
|
334
599
|
}
|
|
335
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Read the app manifest captured by standalone mock mode.
|
|
603
|
+
*
|
|
604
|
+
* Returns `undefined` when this instance is talking to a real host, or when
|
|
605
|
+
* mock mode has not captured anything yet.
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* ```typescript
|
|
609
|
+
* const manifest = learnCard.getCapturedManifest();
|
|
610
|
+
* if (manifest) {
|
|
611
|
+
* console.log(manifest.permissions, manifest.templates);
|
|
612
|
+
* }
|
|
613
|
+
* ```
|
|
614
|
+
*/
|
|
615
|
+
public getCapturedManifest(): CapturedAppManifest | undefined {
|
|
616
|
+
return this.mockHost?.getCapturedManifest();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Build the LearnCard App Store submission URL for the current captured manifest.
|
|
621
|
+
*
|
|
622
|
+
* Returns `undefined` unless mock mode is active and the SDK has captured at
|
|
623
|
+
* least one manifest interaction. The publish prompt still uses a higher
|
|
624
|
+
* threshold (at least 1 inline template or at least 2 distinct permissions)
|
|
625
|
+
* before it auto-nudges the developer.
|
|
626
|
+
*
|
|
627
|
+
* @example
|
|
628
|
+
* ```typescript
|
|
629
|
+
* const publishUrl = learnCard.getPublishUrl();
|
|
630
|
+
* if (publishUrl) {
|
|
631
|
+
* window.open(publishUrl, '_blank', 'noopener');
|
|
632
|
+
* }
|
|
633
|
+
* ```
|
|
634
|
+
*/
|
|
635
|
+
public getPublishUrl(): string | undefined {
|
|
636
|
+
return this.mockHost?.getPublishUrl();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Clears this app's practice captures, credentials and counters. No-op outside mock mode. */
|
|
640
|
+
public resetPracticeMode(): void {
|
|
641
|
+
this.mockHost?.resetPracticeMode();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Returns the currently active real-host origin, or `null` while standalone
|
|
646
|
+
* mock mode is active.
|
|
647
|
+
*/
|
|
648
|
+
public getActiveHostOrigin(): string | null {
|
|
649
|
+
if (this.mockHost) return null;
|
|
650
|
+
return this.activeHostOrigin || null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Returns the LearnCard origin used to build App Store publish URLs.
|
|
655
|
+
*
|
|
656
|
+
* Point this at a local or staging LearnCard with
|
|
657
|
+
* `?lc_publish_override=<origin>`; see {@link resolvePublishOrigin} for
|
|
658
|
+
* the full precedence order.
|
|
659
|
+
*/
|
|
660
|
+
public getPublishOrigin(): string | null {
|
|
661
|
+
if (this.mockHost) return this.mockHost.getPublishOrigin();
|
|
662
|
+
return this.resolvePublishOrigin(this.hostOrigins);
|
|
663
|
+
}
|
|
664
|
+
|
|
336
665
|
/**
|
|
337
666
|
* Configure the active host origin using the following hierarchy:
|
|
338
667
|
* 1. `window.location.ancestorOrigins[0]` (when supported) — the browser's
|
|
@@ -349,6 +678,9 @@ export class PartnerConnect {
|
|
|
349
678
|
*/
|
|
350
679
|
private static readonly SESSION_STORAGE_KEY = 'lc_host_override';
|
|
351
680
|
|
|
681
|
+
/** Persisted so the parameter only has to be typed once per tab. */
|
|
682
|
+
private static readonly PUBLISH_SESSION_STORAGE_KEY = 'lc_publish_override';
|
|
683
|
+
|
|
352
684
|
/**
|
|
353
685
|
* Read `window.location.ancestorOrigins[0]` without throwing if the
|
|
354
686
|
* property is unavailable (Firefox) or the list is empty (top-level
|
|
@@ -453,6 +785,14 @@ export class PartnerConnect {
|
|
|
453
785
|
}
|
|
454
786
|
}
|
|
455
787
|
|
|
788
|
+
private persistPublishOverride(origin: string): void {
|
|
789
|
+
try {
|
|
790
|
+
sessionStorage.setItem(PartnerConnect.PUBLISH_SESSION_STORAGE_KEY, origin);
|
|
791
|
+
} catch {
|
|
792
|
+
// sessionStorage may be unavailable (e.g. sandboxed iframes)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
456
796
|
private isOriginNativeApp(origin: string): boolean {
|
|
457
797
|
return (
|
|
458
798
|
origin.startsWith('capacitor://') ||
|
|
@@ -752,12 +1092,70 @@ export class PartnerConnect {
|
|
|
752
1092
|
});
|
|
753
1093
|
}
|
|
754
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* Validate an inline credential template entirely offline.
|
|
1097
|
+
*
|
|
1098
|
+
* This performs the same local checks that {@link sendCredential} runs for
|
|
1099
|
+
* inline templates before posting any message to LearnCard.
|
|
1100
|
+
*
|
|
1101
|
+
* @param template - Inline credential template to validate
|
|
1102
|
+
* @param templateData - Optional runtime variable values to validate against the compiled manifest
|
|
1103
|
+
* @returns Validation status plus path-based errors suitable for UI display
|
|
1104
|
+
*
|
|
1105
|
+
* @example
|
|
1106
|
+
* ```typescript
|
|
1107
|
+
* const result = learnCard.validateCredentialTemplate(
|
|
1108
|
+
* {
|
|
1109
|
+
* name: 'Completed {{courseName}}',
|
|
1110
|
+
* description: 'Awarded for finishing {{courseName}}.',
|
|
1111
|
+
* achievementType: 'Course',
|
|
1112
|
+
* criteria: { narrative: 'Finished all modules' }
|
|
1113
|
+
* },
|
|
1114
|
+
* { courseName: 'Intro to Baking' }
|
|
1115
|
+
* );
|
|
1116
|
+
*
|
|
1117
|
+
* if (!result.valid) {
|
|
1118
|
+
* console.error(result.errors.map(error => `${error.path}: ${error.message}`).join('\n'));
|
|
1119
|
+
* }
|
|
1120
|
+
* ```
|
|
1121
|
+
*/
|
|
1122
|
+
public validateCredentialTemplate(
|
|
1123
|
+
template: InlineCredentialTemplate,
|
|
1124
|
+
templateData?: Record<string, unknown>
|
|
1125
|
+
): { valid: boolean; errors: InlineTemplateValidationError[] } {
|
|
1126
|
+
const templateErrors = validateInlineTemplate(template);
|
|
1127
|
+
|
|
1128
|
+
if (templateErrors.length > 0) {
|
|
1129
|
+
return { valid: false, errors: templateErrors };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const compiled = compileInlineTemplate(template);
|
|
1133
|
+
const dataErrors = validateTemplateData(compiled.variableManifest, templateData);
|
|
1134
|
+
|
|
1135
|
+
if (dataErrors.length > 0) {
|
|
1136
|
+
return { valid: false, errors: dataErrors };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
return { valid: true, errors: [] };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Compile and optionally render an inline credential template entirely offline.
|
|
1144
|
+
*/
|
|
1145
|
+
public previewCompiledTemplate(
|
|
1146
|
+
template: InlineCredentialTemplate,
|
|
1147
|
+
templateData?: Record<string, unknown>
|
|
1148
|
+
): PreviewCompiledTemplateResult {
|
|
1149
|
+
return previewCompiledTemplate(template, templateData);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
755
1152
|
/**
|
|
756
1153
|
* Send a credential to the user's LearnCard wallet
|
|
757
1154
|
*
|
|
758
1155
|
* Supports two modes:
|
|
759
1156
|
* 1. **Raw credential**: Pass a full verifiable credential object
|
|
760
1157
|
* 2. **Template-based**: Pass `{ templateAlias, templateData }` to issue from a pre-configured boost template
|
|
1158
|
+
* 3. **Inline template**: Pass `{ alias, template, templateData }` to issue from a zero-config inline template
|
|
761
1159
|
*
|
|
762
1160
|
* @param input - Either a verifiable credential or a template-based input
|
|
763
1161
|
* @returns Promise resolving to credential response
|
|
@@ -780,17 +1178,59 @@ export class PartnerConnect {
|
|
|
780
1178
|
* });
|
|
781
1179
|
* console.log('Credential URI:', response.credentialUri);
|
|
782
1180
|
* ```
|
|
1181
|
+
*
|
|
1182
|
+
* @example Inline template (zero-config)
|
|
1183
|
+
* ```typescript
|
|
1184
|
+
* await learnCard.sendCredential({
|
|
1185
|
+
* alias: 'course-complete',
|
|
1186
|
+
* template: {
|
|
1187
|
+
* name: 'Completed {{courseName}}',
|
|
1188
|
+
* description: 'Awarded for finishing {{courseName}}.',
|
|
1189
|
+
* achievementType: 'Course',
|
|
1190
|
+
* criteria: { narrative: 'Finished all modules' }
|
|
1191
|
+
* },
|
|
1192
|
+
* templateData: { courseName: 'Intro to Baking' }
|
|
1193
|
+
* });
|
|
1194
|
+
* ```
|
|
783
1195
|
*/
|
|
784
1196
|
public sendCredential(
|
|
785
|
-
input:
|
|
1197
|
+
input: SendCredentialInput
|
|
786
1198
|
): Promise<SendCredentialResponse | TemplateCredentialResponse> {
|
|
787
|
-
if (
|
|
788
|
-
input
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
1199
|
+
if (isInlineTemplateInput(input)) {
|
|
1200
|
+
const templateErrors = validateInlineTemplate(input.template);
|
|
1201
|
+
|
|
1202
|
+
if (templateErrors.length > 0) {
|
|
1203
|
+
return Promise.reject(
|
|
1204
|
+
new PartnerConnectError(
|
|
1205
|
+
'TEMPLATE_INVALID',
|
|
1206
|
+
joinInlineTemplateErrors(templateErrors)
|
|
1207
|
+
)
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const compiled = compileInlineTemplate(input.template);
|
|
1212
|
+
const dataErrors = validateTemplateData(compiled.variableManifest, input.templateData);
|
|
1213
|
+
|
|
1214
|
+
if (dataErrors.length > 0) {
|
|
1215
|
+
return Promise.reject(
|
|
1216
|
+
new PartnerConnectError(
|
|
1217
|
+
'TEMPLATE_DATA_INVALID',
|
|
1218
|
+
joinInlineTemplateErrors(dataErrors)
|
|
1219
|
+
)
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
return this.sendAppEvent<TemplateCredentialResponse>({
|
|
1224
|
+
type: 'send-credential',
|
|
1225
|
+
alias: input.alias,
|
|
1226
|
+
template: input.template,
|
|
1227
|
+
templateData: input.templateData,
|
|
1228
|
+
preventDuplicateClaim: input.preventDuplicateClaim,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
if (isTemplateAliasInput(input)) {
|
|
1233
|
+
const templateInput = input;
|
|
794
1234
|
|
|
795
1235
|
return this.sendAppEvent<TemplateCredentialResponse>({
|
|
796
1236
|
type: 'send-credential',
|
|
@@ -955,9 +1395,12 @@ export class PartnerConnect {
|
|
|
955
1395
|
}
|
|
956
1396
|
|
|
957
1397
|
/**
|
|
958
|
-
* Request user consent for permissions
|
|
1398
|
+
* Request user consent for permissions.
|
|
959
1399
|
*
|
|
960
|
-
*
|
|
1400
|
+
* Pass either a pre-created consent contract URI, or a declarative consent
|
|
1401
|
+
* request and let LearnCard resolve or create the backing contract.
|
|
1402
|
+
*
|
|
1403
|
+
* @param requestOrContractUri - Consent scope request or explicit contract URI
|
|
961
1404
|
* @param options - Additional options including redirect behavior
|
|
962
1405
|
* @returns Promise resolving to consent response
|
|
963
1406
|
*
|
|
@@ -965,29 +1408,49 @@ export class PartnerConnect {
|
|
|
965
1408
|
* ```typescript
|
|
966
1409
|
* // With explicit contract URI (for external/non-app store integrations)
|
|
967
1410
|
* const response = await learnCard.requestConsent('lc:network:network.learncard.com/trpc:contract:abc123');
|
|
968
|
-
* if (response.granted) {
|
|
969
|
-
* console.log('User granted consent');
|
|
970
|
-
* }
|
|
971
|
-
*
|
|
972
|
-
* // Without contract URI (uses app's configured contract from integration)
|
|
973
|
-
* // This works for App Store apps that have configured a contract in their integration
|
|
974
|
-
* const response = await learnCard.requestConsent();
|
|
975
|
-
* if (response.granted) {
|
|
976
|
-
* console.log('User granted consent using listing contract');
|
|
977
|
-
* }
|
|
978
1411
|
*
|
|
979
|
-
* //
|
|
980
|
-
* const
|
|
1412
|
+
* // Declarative scopes (LearnCard upserts the contract automatically)
|
|
1413
|
+
* const { granted } = await learnCard.requestConsent({
|
|
1414
|
+
* read: { credentialCategories: ['Achievement', 'Skill'], personalFields: ['name'] },
|
|
1415
|
+
* write: { credentialCategories: ['Achievement'] },
|
|
1416
|
+
* reason: 'Personalize your training plan',
|
|
1417
|
+
* });
|
|
981
1418
|
* ```
|
|
982
1419
|
*/
|
|
1420
|
+
public requestConsent(
|
|
1421
|
+
request: ConsentRequest,
|
|
1422
|
+
options?: RequestConsentOptions
|
|
1423
|
+
): Promise<ConsentResponse>;
|
|
983
1424
|
public requestConsent(
|
|
984
1425
|
contractUri?: string,
|
|
1426
|
+
options?: RequestConsentOptions
|
|
1427
|
+
): Promise<ConsentResponse>;
|
|
1428
|
+
public requestConsent(
|
|
1429
|
+
requestOrContractUri?: string | ConsentRequest,
|
|
985
1430
|
options: RequestConsentOptions = {}
|
|
986
1431
|
): Promise<ConsentResponse> {
|
|
987
1432
|
const { redirect = false } = options;
|
|
988
1433
|
|
|
1434
|
+
if (isConsentRequestInput(requestOrContractUri)) {
|
|
1435
|
+
try {
|
|
1436
|
+
normalizeConsentRequest(requestOrContractUri);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
return Promise.reject(
|
|
1439
|
+
new PartnerConnectError(
|
|
1440
|
+
'CONSENT_SCOPES_INVALID',
|
|
1441
|
+
error instanceof Error ? error.message : 'Invalid consent scopes'
|
|
1442
|
+
)
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return this.sendMessage<ConsentResponse>('REQUEST_CONSENT', {
|
|
1447
|
+
scopes: requestOrContractUri,
|
|
1448
|
+
redirect,
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
|
|
989
1452
|
return this.sendMessage<ConsentResponse>('REQUEST_CONSENT', {
|
|
990
|
-
contractUri,
|
|
1453
|
+
contractUri: requestOrContractUri,
|
|
991
1454
|
redirect,
|
|
992
1455
|
});
|
|
993
1456
|
}
|