@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
|
@@ -341,6 +341,235 @@ describe('incoming message validation', () => {
|
|
|
341
341
|
});
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Publish-origin resolution (lc_publish_override)
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
describe('publish-origin resolution hierarchy', () => {
|
|
349
|
+
const mocked = { mock: true as const, mockOptions: { ui: false, log: false } };
|
|
350
|
+
|
|
351
|
+
test('mockOptions.publishOrigin outranks lc_publish_override', () => {
|
|
352
|
+
installLocation({ search: '?lc_publish_override=https://staging.learncard.app' });
|
|
353
|
+
|
|
354
|
+
const sdk = new PartnerConnect({
|
|
355
|
+
mock: true,
|
|
356
|
+
mockOptions: { ui: false, log: false, publishOrigin: 'https://explicit.learncard.app' },
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
expect(sdk.getPublishOrigin()).toBe('https://explicit.learncard.app');
|
|
360
|
+
|
|
361
|
+
sdk.destroy();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('lc_publish_override outranks lc_host_override', () => {
|
|
365
|
+
installLocation({
|
|
366
|
+
search: '?lc_host_override=https://alpha.vetpass.app&lc_publish_override=http://localhost:3000',
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const sdk = new PartnerConnect(mocked);
|
|
370
|
+
|
|
371
|
+
expect(sdk.getPublishOrigin()).toBe('http://localhost:3000');
|
|
372
|
+
|
|
373
|
+
sdk.destroy();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('a stored lc_publish_override still outranks an lc_host_override param', () => {
|
|
377
|
+
window.sessionStorage.setItem('lc_publish_override', 'http://localhost:3000');
|
|
378
|
+
installLocation({ search: '?lc_host_override=https://alpha.vetpass.app' });
|
|
379
|
+
|
|
380
|
+
const sdk = new PartnerConnect(mocked);
|
|
381
|
+
|
|
382
|
+
expect(sdk.getPublishOrigin()).toBe('http://localhost:3000');
|
|
383
|
+
|
|
384
|
+
sdk.destroy();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('falls back to a concrete lc_host_override when no publish override exists', () => {
|
|
388
|
+
installLocation({ search: '?lc_host_override=https://alpha.vetpass.app' });
|
|
389
|
+
|
|
390
|
+
const sdk = new PartnerConnect(mocked);
|
|
391
|
+
|
|
392
|
+
expect(sdk.getPublishOrigin()).toBe('https://alpha.vetpass.app');
|
|
393
|
+
|
|
394
|
+
sdk.destroy();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('uses a stored lc_host_override when no query params are present', () => {
|
|
398
|
+
window.sessionStorage.setItem('lc_host_override', 'https://alpha.vetpass.app');
|
|
399
|
+
installLocation({ search: '' });
|
|
400
|
+
|
|
401
|
+
const sdk = new PartnerConnect(mocked);
|
|
402
|
+
|
|
403
|
+
expect(sdk.getPublishOrigin()).toBe('https://alpha.vetpass.app');
|
|
404
|
+
|
|
405
|
+
sdk.destroy();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('ignores a wildcard lc_host_override and infers from configured origins', () => {
|
|
409
|
+
installLocation({ search: '?lc_host_override=https://*.learncard.app' });
|
|
410
|
+
|
|
411
|
+
const sdk = new PartnerConnect({
|
|
412
|
+
...mocked,
|
|
413
|
+
hostOrigin: 'https://staging.learncard.app',
|
|
414
|
+
disableDefaultTenants: true,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(sdk.getPublishOrigin()).toBe('https://staging.learncard.app');
|
|
418
|
+
|
|
419
|
+
sdk.destroy();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('infers the first concrete configured origin, skipping wildcards', () => {
|
|
423
|
+
installLocation({ search: '' });
|
|
424
|
+
|
|
425
|
+
const sdk = new PartnerConnect({
|
|
426
|
+
...mocked,
|
|
427
|
+
hostOrigin: ['https://*.learncard.app', 'https://staging.learncard.app'],
|
|
428
|
+
disableDefaultTenants: true,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
expect(sdk.getPublishOrigin()).toBe('https://staging.learncard.app');
|
|
432
|
+
|
|
433
|
+
sdk.destroy();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('falls back to the default publish origin when nothing else applies', () => {
|
|
437
|
+
installLocation({ search: '' });
|
|
438
|
+
|
|
439
|
+
const sdk = new PartnerConnect(mocked);
|
|
440
|
+
|
|
441
|
+
expect(sdk.getPublishOrigin()).toBe('https://learncard.app');
|
|
442
|
+
|
|
443
|
+
sdk.destroy();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('applies to non-mock instances too', () => {
|
|
447
|
+
installLocation({ search: '?lc_publish_override=http://localhost:3000' });
|
|
448
|
+
|
|
449
|
+
const sdk = new PartnerConnect({ mock: false });
|
|
450
|
+
|
|
451
|
+
expect(sdk.getPublishOrigin()).toBe('http://localhost:3000');
|
|
452
|
+
|
|
453
|
+
sdk.destroy();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('normalizes a full URL down to its origin', () => {
|
|
457
|
+
installLocation({
|
|
458
|
+
search: `?lc_publish_override=${encodeURIComponent(
|
|
459
|
+
'http://localhost:3000/app-store/developer?foo=bar'
|
|
460
|
+
)}`,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const sdk = new PartnerConnect(mocked);
|
|
464
|
+
|
|
465
|
+
expect(sdk.getPublishOrigin()).toBe('http://localhost:3000');
|
|
466
|
+
|
|
467
|
+
sdk.destroy();
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
describe('invalid lc_publish_override values', () => {
|
|
472
|
+
const mocked = { mock: true as const, mockOptions: { ui: false, log: false } };
|
|
473
|
+
|
|
474
|
+
test.each([['not-a-url'], ['ftp://example.com'], ['//learncard.app'], ['javascript:alert(1)']])(
|
|
475
|
+
'ignores %s with a warning and falls through',
|
|
476
|
+
value => {
|
|
477
|
+
installLocation({ search: `?lc_publish_override=${encodeURIComponent(value)}` });
|
|
478
|
+
|
|
479
|
+
const sdk = new PartnerConnect({
|
|
480
|
+
...mocked,
|
|
481
|
+
hostOrigin: 'https://staging.learncard.app',
|
|
482
|
+
disableDefaultTenants: true,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
expect(sdk.getPublishOrigin()).toBe('https://staging.learncard.app');
|
|
486
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
487
|
+
expect.stringContaining('lc_publish_override is not a valid http(s) origin'),
|
|
488
|
+
value
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
sdk.destroy();
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
test('an invalid value still lets a concrete lc_host_override through', () => {
|
|
496
|
+
installLocation({
|
|
497
|
+
search: '?lc_publish_override=nope&lc_host_override=https://alpha.vetpass.app',
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const sdk = new PartnerConnect(mocked);
|
|
501
|
+
|
|
502
|
+
expect(sdk.getPublishOrigin()).toBe('https://alpha.vetpass.app');
|
|
503
|
+
|
|
504
|
+
sdk.destroy();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('a corrupt stored value is ignored rather than thrown on', () => {
|
|
508
|
+
window.sessionStorage.setItem('lc_publish_override', 'not-a-url');
|
|
509
|
+
installLocation({ search: '' });
|
|
510
|
+
|
|
511
|
+
const sdk = new PartnerConnect(mocked);
|
|
512
|
+
|
|
513
|
+
expect(sdk.getPublishOrigin()).toBe('https://learncard.app');
|
|
514
|
+
|
|
515
|
+
sdk.destroy();
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
describe('lc_publish_override sessionStorage persistence', () => {
|
|
520
|
+
const mocked = { mock: true as const, mockOptions: { ui: false, log: false } };
|
|
521
|
+
|
|
522
|
+
test('persists a valid override so later navigations keep it', () => {
|
|
523
|
+
installLocation({ search: '?lc_publish_override=http://localhost:3000' });
|
|
524
|
+
|
|
525
|
+
const first = new PartnerConnect(mocked);
|
|
526
|
+
expect(window.sessionStorage.getItem('lc_publish_override')).toBe('http://localhost:3000');
|
|
527
|
+
first.destroy();
|
|
528
|
+
|
|
529
|
+
// A later page load in the same tab, without the query parameter.
|
|
530
|
+
installLocation({ search: '' });
|
|
531
|
+
|
|
532
|
+
const second = new PartnerConnect(mocked);
|
|
533
|
+
expect(second.getPublishOrigin()).toBe('http://localhost:3000');
|
|
534
|
+
second.destroy();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('persists the normalized origin, not the raw value', () => {
|
|
538
|
+
installLocation({
|
|
539
|
+
search: `?lc_publish_override=${encodeURIComponent('http://localhost:3000/submit')}`,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const sdk = new PartnerConnect(mocked);
|
|
543
|
+
|
|
544
|
+
expect(window.sessionStorage.getItem('lc_publish_override')).toBe('http://localhost:3000');
|
|
545
|
+
|
|
546
|
+
sdk.destroy();
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test('does not persist an invalid value', () => {
|
|
550
|
+
installLocation({ search: '?lc_publish_override=not-a-url' });
|
|
551
|
+
|
|
552
|
+
const sdk = new PartnerConnect(mocked);
|
|
553
|
+
|
|
554
|
+
expect(window.sessionStorage.getItem('lc_publish_override')).toBeNull();
|
|
555
|
+
|
|
556
|
+
sdk.destroy();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('is stored separately from lc_host_override', () => {
|
|
560
|
+
installLocation({
|
|
561
|
+
search: '?lc_host_override=https://alpha.vetpass.app&lc_publish_override=http://localhost:3000',
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const sdk = new PartnerConnect(mocked);
|
|
565
|
+
|
|
566
|
+
expect(window.sessionStorage.getItem('lc_host_override')).toBe('https://alpha.vetpass.app');
|
|
567
|
+
expect(window.sessionStorage.getItem('lc_publish_override')).toBe('http://localhost:3000');
|
|
568
|
+
|
|
569
|
+
sdk.destroy();
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
344
573
|
// ---------------------------------------------------------------------------
|
|
345
574
|
// Native-app origins
|
|
346
575
|
// ---------------------------------------------------------------------------
|
package/src/types.ts
CHANGED
|
@@ -2,13 +2,42 @@
|
|
|
2
2
|
* LearnCard Partner Connect SDK - Type Definitions
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
SendCredentialEvent,
|
|
5
|
+
import type {
|
|
6
|
+
AppEvent as BaseAppEvent,
|
|
7
|
+
AppEventResponse as BaseAppEventResponse,
|
|
9
8
|
CheckCredentialEvent,
|
|
10
|
-
|
|
9
|
+
SendCredentialEvent as BaseSendCredentialEvent,
|
|
11
10
|
} from '@learncard/types';
|
|
11
|
+
import type {
|
|
12
|
+
CapturedAppManifest,
|
|
13
|
+
ConsentRequest,
|
|
14
|
+
InlineCredentialTemplate,
|
|
15
|
+
InlineTemplateValidationError,
|
|
16
|
+
} from '@learncard/partner-connect-core';
|
|
17
|
+
|
|
18
|
+
// Re-export AppEvent types from shared types package
|
|
19
|
+
export type { CheckCredentialEvent };
|
|
20
|
+
export type {
|
|
21
|
+
CapturedAppManifest,
|
|
22
|
+
CapturedConsentRecord,
|
|
23
|
+
CapturedTemplateRecord,
|
|
24
|
+
CompiledInlineTemplate,
|
|
25
|
+
ConsentRequest,
|
|
26
|
+
InlineCredentialTemplate,
|
|
27
|
+
InlineTemplateValidationError,
|
|
28
|
+
PersonalField,
|
|
29
|
+
RawInlineCredentialTemplate,
|
|
30
|
+
SimpleInlineCredentialTemplate,
|
|
31
|
+
VariableManifest,
|
|
32
|
+
WalletCategory,
|
|
33
|
+
} from '@learncard/partner-connect-core';
|
|
34
|
+
|
|
35
|
+
export interface PreviewCompiledTemplateResult {
|
|
36
|
+
valid: boolean;
|
|
37
|
+
errors: InlineTemplateValidationError[];
|
|
38
|
+
compiled?: Record<string, unknown>;
|
|
39
|
+
rendered?: Record<string, unknown>;
|
|
40
|
+
}
|
|
12
41
|
|
|
13
42
|
/**
|
|
14
43
|
* Configuration options for initializing the SDK
|
|
@@ -112,15 +141,17 @@ export interface PartnerConnectOptions {
|
|
|
112
141
|
* identically (real host) once embedded, with no code changes.
|
|
113
142
|
*
|
|
114
143
|
* - `'auto'` **(default)**: mock only when **no LearnCard host is present
|
|
115
|
-
* AND the app is running
|
|
116
|
-
* `
|
|
117
|
-
*
|
|
118
|
-
*
|
|
144
|
+
* AND the app is running in local dev** (`localhost`, `127.0.0.1`,
|
|
145
|
+
* `[::1]`, `*.localhost`, `*.local`) **or an AI app builder's editor
|
|
146
|
+
* preview** (Lovable, Bolt, v0, Replit). This covers local dev, local
|
|
147
|
+
* Storybook, and building in those tools, but deliberately never
|
|
148
|
+
* fabricates identity or consent on a published or deploy-preview
|
|
149
|
+
* origin. Each mocked call
|
|
119
150
|
* surfaces a labeled toast plus a console log so it's clear the host
|
|
120
151
|
* is simulated.
|
|
121
152
|
* - `'standalone'`: mock whenever **no LearnCard host is present**, on
|
|
122
|
-
* any origin — including remote deploy previews (Netlify,
|
|
123
|
-
*
|
|
153
|
+
* any origin — including remote deploy previews (Netlify, Vercel, …)
|
|
154
|
+
* — and use the real host when embedded in LearnCard. The
|
|
124
155
|
* one-flag setting for apps that must demo standalone anywhere. Only
|
|
125
156
|
* choose it when a user opening your app's URL directly should see
|
|
126
157
|
* simulated data.
|
|
@@ -186,6 +217,21 @@ export interface MockHostOptions {
|
|
|
186
217
|
*/
|
|
187
218
|
persist?: boolean;
|
|
188
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Show the mock-mode "Publish to LearnCard" nudge once the captured app
|
|
222
|
+
* manifest becomes publishable.
|
|
223
|
+
*
|
|
224
|
+
* @default true
|
|
225
|
+
*/
|
|
226
|
+
publishPrompt?: boolean;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* LearnCard origin used to build the App Store submission URL.
|
|
230
|
+
*
|
|
231
|
+
* @default 'https://learncard.app'
|
|
232
|
+
*/
|
|
233
|
+
publishOrigin?: string;
|
|
234
|
+
|
|
189
235
|
/**
|
|
190
236
|
* The fake DID returned by `requestIdentity()` (and used as the mock
|
|
191
237
|
* user's identity) while in mock mode.
|
|
@@ -203,6 +249,13 @@ export interface MockHostOptions {
|
|
|
203
249
|
*/
|
|
204
250
|
namespace?: string;
|
|
205
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Distinguishes your app from other apps served on the same origin
|
|
254
|
+
* (e.g. multiple projects on localhost:4321). Defaults to a fingerprint
|
|
255
|
+
* of the initial document.title.
|
|
256
|
+
*/
|
|
257
|
+
appId?: string;
|
|
258
|
+
|
|
206
259
|
/**
|
|
207
260
|
* Seed the mock user's identity. Superseded per-field over the legacy
|
|
208
261
|
* `did` option. Extra fields are returned as-is from `requestIdentity()`.
|
|
@@ -259,6 +312,16 @@ export interface MockCredentialSeed {
|
|
|
259
312
|
status?: 'pending' | 'claimed' | 'revoked';
|
|
260
313
|
}
|
|
261
314
|
|
|
315
|
+
/** Public manifest snapshot getter, available only while mock mode is active. */
|
|
316
|
+
export interface CapturedManifestReadable {
|
|
317
|
+
getCapturedManifest(): CapturedAppManifest | undefined;
|
|
318
|
+
getPublishUrl(): string | undefined;
|
|
319
|
+
/** Start fresh for the current practice app; no-op outside mock mode. */
|
|
320
|
+
resetPracticeMode(): void;
|
|
321
|
+
getActiveHostOrigin(): string | null;
|
|
322
|
+
getPublishOrigin(): string | null;
|
|
323
|
+
}
|
|
324
|
+
|
|
262
325
|
/**
|
|
263
326
|
* Identity information returned from REQUEST_IDENTITY
|
|
264
327
|
*/
|
|
@@ -295,6 +358,56 @@ export interface TemplateCredentialInput {
|
|
|
295
358
|
preventDuplicateClaim?: boolean;
|
|
296
359
|
}
|
|
297
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Inline credential template input for sendCredential.
|
|
363
|
+
*
|
|
364
|
+
* This sends the full template definition to LearnCard at runtime, with no
|
|
365
|
+
* pre-configured host template required.
|
|
366
|
+
*
|
|
367
|
+
* @example
|
|
368
|
+
* ```typescript
|
|
369
|
+
* await learnCard.sendCredential({
|
|
370
|
+
* alias: 'course-complete',
|
|
371
|
+
* template: {
|
|
372
|
+
* name: 'Completed {{courseName}}',
|
|
373
|
+
* description: 'Awarded for finishing {{courseName}}.',
|
|
374
|
+
* achievementType: 'Course',
|
|
375
|
+
* criteria: { narrative: 'Finished all modules' }
|
|
376
|
+
* },
|
|
377
|
+
* templateData: { courseName: 'Intro to Baking' }
|
|
378
|
+
* });
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
export interface InlineTemplateCredentialInput {
|
|
382
|
+
/** App-scoped alias used for duplicate checks and inline template versioning. */
|
|
383
|
+
alias: string;
|
|
384
|
+
|
|
385
|
+
/** Inline credential template definition compiled by LearnCard at issuance time. */
|
|
386
|
+
template: InlineCredentialTemplate;
|
|
387
|
+
|
|
388
|
+
/** Runtime values for the template variables. */
|
|
389
|
+
templateData?: Record<string, unknown>;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* If true, the host will return an existing credential (if present) instead of issuing a duplicate.
|
|
393
|
+
*/
|
|
394
|
+
preventDuplicateClaim?: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Inline `send-credential` app event for zero-config issuance. */
|
|
398
|
+
export interface InlineTemplateSendCredentialEvent {
|
|
399
|
+
type: 'send-credential';
|
|
400
|
+
alias: string;
|
|
401
|
+
template: InlineCredentialTemplate;
|
|
402
|
+
templateData?: Record<string, unknown>;
|
|
403
|
+
preventDuplicateClaim?: boolean;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export type SendCredentialEvent = BaseSendCredentialEvent | InlineTemplateSendCredentialEvent;
|
|
407
|
+
export type AppEvent = BaseAppEvent | InlineTemplateSendCredentialEvent;
|
|
408
|
+
export type AppEventResponse = BaseAppEventResponse;
|
|
409
|
+
export type SendCredentialInput = unknown | TemplateCredentialInput | InlineTemplateCredentialInput;
|
|
410
|
+
|
|
298
411
|
/**
|
|
299
412
|
* Response from template-based credential issuance
|
|
300
413
|
*/
|
|
@@ -317,6 +430,9 @@ export interface TemplateCredentialResponse {
|
|
|
317
430
|
/** The date the credential was received (when preventDuplicateClaim is true) */
|
|
318
431
|
receivedDate?: string;
|
|
319
432
|
|
|
433
|
+
/** Version number for inline templates sharing the same alias. */
|
|
434
|
+
templateVersion?: number;
|
|
435
|
+
|
|
320
436
|
[key: string]: unknown;
|
|
321
437
|
}
|
|
322
438
|
|
|
@@ -382,6 +498,7 @@ export interface RequestConsentPayload {
|
|
|
382
498
|
* to use the contract configured for the current app listing.
|
|
383
499
|
*/
|
|
384
500
|
contractUri?: string;
|
|
501
|
+
scopes?: ConsentRequest;
|
|
385
502
|
redirect?: boolean;
|
|
386
503
|
}
|
|
387
504
|
|
|
@@ -724,6 +841,8 @@ export type ErrorCode =
|
|
|
724
841
|
| 'CREDENTIAL_NOT_FOUND'
|
|
725
842
|
| 'USER_REJECTED'
|
|
726
843
|
| 'UNAUTHORIZED'
|
|
844
|
+
| 'TEMPLATE_INVALID'
|
|
845
|
+
| 'TEMPLATE_DATA_INVALID'
|
|
727
846
|
| 'TEMPLATE_NOT_FOUND'
|
|
728
847
|
| 'BOOST_NOT_FOUND'
|
|
729
848
|
| 'INSUFFICIENT_PERMISSIONS'
|