@learncard/partner-connect 0.5.2 → 0.6.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.
@@ -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
- // Re-export AppEvent types from shared types package
6
- export type {
7
- AppEvent,
8
- SendCredentialEvent,
5
+ import type {
6
+ AppEvent as BaseAppEvent,
7
+ AppEventResponse as BaseAppEventResponse,
9
8
  CheckCredentialEvent,
10
- AppEventResponse,
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 on a local dev host** (`localhost`,
116
- * `127.0.0.1`, `[::1]`, `*.localhost`, `*.local`). This covers local
117
- * dev and local Storybook, but deliberately never fabricates identity
118
- * or consent on a production or preview origin. Each mocked call
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, Lovable,
123
- * Vercel, …) — and use the real host when embedded in LearnCard. The
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'