@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/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(options?.mockOptions);
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 on local dev hosts (a standalone
216
- // production page must never fabricate identity or consent), false
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' || (mockSetting === 'auto' && this.isLocalDevContext());
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(options?.mockOptions);
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(options?.mockOptions);
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(options);
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(options?: PartnerConnectOptions): Promise<void> {
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(options?.mockOptions);
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: unknown | TemplateCredentialInput
1197
+ input: SendCredentialInput
786
1198
  ): Promise<SendCredentialResponse | TemplateCredentialResponse> {
787
- if (
788
- input &&
789
- typeof input === 'object' &&
790
- 'templateAlias' in input &&
791
- typeof (input as TemplateCredentialInput).templateAlias === 'string'
792
- ) {
793
- const templateInput = input as TemplateCredentialInput;
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
- * @param contractUri - URI of the consent contract (optional for App Store apps with configured contracts)
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
- * // With redirect - redirects to contract's redirectUrl with VP in URL params
980
- * const response = await learnCard.requestConsent(undefined, { redirect: true });
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
  }