@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.
package/README.md CHANGED
@@ -6,12 +6,12 @@ The LearnCard Partner Connect SDK transforms complex `postMessage` communication
6
6
 
7
7
  ## Features
8
8
 
9
- - ๐Ÿ”’ **Secure**: Origin validation for all messages
10
- - ๐ŸŽฏ **Type-safe**: Full TypeScript support with comprehensive types
11
- - โšก **Promise-based**: Modern async/await API
12
- - ๐Ÿงน **Clean**: Abstracts away all postMessage complexity
13
- - ๐Ÿ“ฆ **Lightweight**: Zero runtime dependencies
14
- - ๐Ÿ›ก๏ธ **Robust**: Built-in timeout handling and error management
9
+ - ๐Ÿ”’ **Secure**: Origin validation for all messages
10
+ - ๐ŸŽฏ **Type-safe**: Full TypeScript support with comprehensive types
11
+ - โšก **Promise-based**: Modern async/await API
12
+ - ๐Ÿงน **Clean**: Abstracts away all postMessage complexity
13
+ - ๐Ÿ“ฆ **Lightweight**: Zero runtime dependencies
14
+ - ๐Ÿ›ก๏ธ **Robust**: Built-in timeout handling and error management
15
15
 
16
16
  ## Installation
17
17
 
@@ -81,7 +81,8 @@ interface PartnerConnectOptions {
81
81
  /**
82
82
  * Controls automatic standalone mock mode.
83
83
  * 'auto' (default) mocks only when no LearnCard host is present AND the
84
- * page runs on a local dev host; 'standalone' mocks whenever no host is
84
+ * page runs in local dev or an AI app builder's editor preview
85
+ * (Lovable, Bolt, v0, Replit); 'standalone' mocks whenever no host is
85
86
  * present, on any origin; true always mocks; false never mocks.
86
87
  */
87
88
  mock?: boolean | 'auto' | 'standalone';
@@ -99,6 +100,180 @@ interface PartnerConnectOptions {
99
100
  }
100
101
  ```
101
102
 
103
+ ### From mock to App Store
104
+
105
+ While mock mode is active, the SDK silently captures an app manifest in local storage
106
+ (`{namespace}:manifests`) with the LearnCard surface your app actually uses:
107
+
108
+ - inline credential templates
109
+ - consent scopes
110
+ - permissions inferred from SDK calls
111
+ - launched feature paths, counter keys, learner-context usage, notifications
112
+
113
+ Practice captures include a stable `appKey`, saved per app title in this browser (or set exactly by `mockOptions.appId`). Give apps distinct titles or explicit IDs when they share an address. **Start over** in the expanded panel asks for confirmation; `learnCard.resetPracticeMode()` does the same reset directly, clearing only this app's captures, practice credentials, counters, and generated identity. The next capture gets a new key unless you supplied `appId`. Outside mock mode the method is a no-op. Icon capture prefers Apple touch icons, then large favicons, then same-origin web app manifest icons, with `/favicon.ico` as fallback; it also tries to include a small rasterized image for publishing. Oversized publish links omit that image.
114
+
115
+ When `mockOptions.ui !== false`, mock mode also shows a **Practice mode** panel in the
116
+ bottom-left corner. It starts collapsed as a pill (`Practice mode` plus a feature count) and
117
+ expands into a frosted-glass card with the app name and address, a plain-language list of the
118
+ LearnCard features the app uses (templates, consent, screens, counters as details), a
119
+ **Publish app** button, and a copy-link button. It follows the system light/dark setting and
120
+ respects reduced motion.
121
+
122
+ Once the manifest becomes publishable (at least **1 inline template** or **2 distinct permissions**), mock mode shows a persistent, dismissible **Publish to LearnCard** card. The link opens:
123
+
124
+ ```text
125
+ https://learncard.app/app-store/developer/submit?manifest=<base64url(JSON)>
126
+ ```
127
+
128
+ You can also read the same data yourself:
129
+
130
+ ```typescript
131
+ const manifest = learnCard.getCapturedManifest();
132
+ const publishUrl = learnCard.getPublishUrl();
133
+ ```
134
+
135
+ `getPublishUrl()` is **always available once any manifest has been captured**. The publishability
136
+ threshold above is now used only for the automatic prompt/nudge.
137
+
138
+ Configure the nudge with `mockOptions`:
139
+
140
+ ```typescript
141
+ createPartnerConnect({
142
+ mock: 'auto',
143
+ mockOptions: {
144
+ publishPrompt: true, // default
145
+ publishOrigin: 'https://learncard.app', // default
146
+ },
147
+ });
148
+ ```
149
+
150
+ If the card is dismissed, that dismissal is persisted for 24 hours under
151
+ `{namespace}:publish-dismissed-at:{fingerprint}`.
152
+
153
+ #### Multiple apps on one origin
154
+
155
+ If you run multiple apps on the same origin (for example, two local projects on
156
+ `http://localhost:4321`), mock mode keeps a separate manifest for each app.
157
+ By default it fingerprints the app from the **initial** `document.title`, so a
158
+ single SPA stays in one slot even if route changes later update the title.
159
+
160
+ If you want an explicit identity, pass `mockOptions.appId`:
161
+
162
+ ```typescript
163
+ createPartnerConnect({
164
+ mock: 'auto',
165
+ mockOptions: {
166
+ appId: 'student-quest-dev',
167
+ },
168
+ });
169
+ ```
170
+
171
+ That fingerprint scopes both the captured manifest and the publish-nudge dismissal.
172
+
173
+ If mock mode warns that two apps may be sharing state, set `mockOptions.appId` immediately.
174
+ The warning fires when the same fingerprint already has stored manifest data from a different
175
+ captured `appUrl`.
176
+
177
+ You can also override the LearnCard origin used for generated publish links:
178
+
179
+ ```typescript
180
+ createPartnerConnect({
181
+ mock: true,
182
+ mockOptions: {
183
+ appId: 'student-quest-dev',
184
+ publishOrigin: 'https://staging.learncard.app',
185
+ },
186
+ });
187
+ ```
188
+
189
+ ### Testing against a local or staging LearnCard
190
+
191
+ By default, publish links point at production LearnCard (`https://learncard.app`).
192
+ If you're running LearnCard yourself โ€” a local checkout on `http://localhost:3000`,
193
+ or a staging tenant โ€” you want those links to open _your_ instance instead.
194
+
195
+ There are three ways to do that, from most convenient to most explicit.
196
+
197
+ #### 1. `?lc_publish_override=` (no code changes)
198
+
199
+ Add the parameter to your app's URL:
200
+
201
+ ```text
202
+ http://localhost:4321/?lc_publish_override=http://localhost:3000
203
+ ```
204
+
205
+ `getPublishUrl()`, `getPublishOrigin()`, and the practice panel's **Publish app** and copy-link buttons
206
+ all immediately target `http://localhost:3000`.
207
+
208
+ The value is validated as a parseable `http:` / `https:` URL and reduced to its
209
+ origin, so `http://localhost:3000/anything?x=1` is stored as `http://localhost:3000`.
210
+ Anything that isn't a valid http(s) origin is **ignored with a console warning**,
211
+ and the SDK falls through to the next source โ€” a typo can't silently break your
212
+ publish links.
213
+
214
+ A valid value is saved to `sessionStorage` under `lc_publish_override`, so you only
215
+ type it once per tab. Later navigations in that tab keep the override; opening a
216
+ fresh tab drops it.
217
+
218
+ > Unlike `lc_host_override`, this is **not** a security boundary โ€” it only decides
219
+ > which LearnCard origin publish links point at, and never affects which origins the
220
+ > SDK will accept `postMessage` traffic from. That's why it isn't whitelist-checked.
221
+
222
+ #### 2. `PUBLIC_LEARNCARD_ORIGIN` (per-developer, via `.env`)
223
+
224
+ For a setting that should persist across tabs, drive it from your app's environment
225
+ and pass it to both options at once:
226
+
227
+ ```typescript
228
+ const learnCardOrigin = import.meta.env.PUBLIC_LEARNCARD_ORIGIN;
229
+
230
+ const learnCard = createPartnerConnect({
231
+ ...(learnCardOrigin
232
+ ? { hostOrigin: learnCardOrigin, mockOptions: { publishOrigin: learnCardOrigin } }
233
+ : {}),
234
+ });
235
+ ```
236
+
237
+ ```bash
238
+ # .env
239
+ PUBLIC_LEARNCARD_ORIGIN=http://localhost:3000
240
+ ```
241
+
242
+ #### 3. `mockOptions.publishOrigin` (hard-coded)
243
+
244
+ ```typescript
245
+ createPartnerConnect({ mockOptions: { publishOrigin: 'https://staging.learncard.app' } });
246
+ ```
247
+
248
+ #### Resolution order
249
+
250
+ The publish origin is resolved from the first source that yields a usable origin:
251
+
252
+ | # | Source |
253
+ | --- | --------------------------------------------------------------------- |
254
+ | 1 | `mockOptions.publishOrigin` |
255
+ | 2 | `?lc_publish_override=` query param, then its `sessionStorage` value |
256
+ | 3 | `?lc_host_override=` (param, then stored) if it's one concrete origin |
257
+ | 4 | The first non-wildcard, non-native-app `hostOrigin` you configured |
258
+ | 5 | `https://learncard.app` |
259
+
260
+ Step 3 means that if you're already testing against a local host with
261
+ `?lc_host_override=http://localhost:3000`, publish links follow it automatically โ€”
262
+ you don't need both parameters. Wildcard patterns like `https://*.learncard.app`
263
+ are skipped here, since they name a family of hosts rather than one publishable origin.
264
+
265
+ Inspect the result at any time:
266
+
267
+ ```typescript
268
+ learnCard.getPublishOrigin(); // 'http://localhost:3000'
269
+ learnCard.getPublishUrl(); // 'http://localhost:3000/app-store/developer/submit?manifest=โ€ฆ'
270
+ ```
271
+
272
+ When mock mode is running on `localhost` and publish links would still go to
273
+ production, the expanded practice panel shows a one-line reminder:
274
+ `Local LearnCard? Add ?lc_publish_override=http://localhost:3000`. It disappears as
275
+ soon as an override or an explicit `publishOrigin` is in effect.
276
+
102
277
  ### Dynamic Origin Configuration
103
278
 
104
279
  The SDK uses a hierarchical approach to determine the active host origin:
@@ -124,9 +299,9 @@ const learnCard = createPartnerConnect({
124
299
 
125
300
  **How the LearnCard Host Uses This:**
126
301
 
127
- - Production: Iframe URL has no `lc_host_override` parameter
128
- - Staging: Iframe URL includes `?lc_host_override=https://staging.learncard.app`
129
- - This allows testing against non-production environments without recompiling partner code
302
+ - Production: Iframe URL has no `lc_host_override` parameter
303
+ - Staging: Iframe URL includes `?lc_host_override=https://staging.learncard.app`
304
+ - This allows testing against non-production environments without recompiling partner code
130
305
 
131
306
  #### 3. **Configured Origin** (Fallback)
132
307
 
@@ -173,9 +348,9 @@ Incoming Message Origin โ‰ก Configured Host Origin
173
348
 
174
349
  The SDK enforces an exact match between incoming message origins and the active host origin:
175
350
 
176
- - โœ… **Secure**: Even if a malicious actor adds `?lc_host_override=https://evil.com`, messages from `evil.com` will be rejected
177
- - โœ… **Cannot be spoofed**: Browser security prevents malicious sites from faking their `event.origin`
178
- - โœ… **No wildcards**: Only exact matches are accepted
351
+ - โœ… **Secure**: Even if a malicious actor adds `?lc_host_override=https://evil.com`, messages from `evil.com` will be rejected
352
+ - โœ… **Cannot be spoofed**: Browser security prevents malicious sites from faking their `event.origin`
353
+ - โœ… **No wildcards**: Only exact matches are accepted
179
354
 
180
355
  ```typescript
181
356
  // Active origin: https://staging.learncard.app
@@ -193,16 +368,16 @@ Storybook, a preview deploy, CI), there is no host. Standalone calls that aren't
193
368
  mocked reject immediately with `LC_NOT_EMBEDDED` (rather than hanging until the
194
369
  request timeout), and the SDK logs a one-time hint pointing you to mock mode.
195
370
 
196
- **Mock mode fixes this automatically in local development.** Whenever no
197
- LearnCard host is present and your app runs on a local dev host (`localhost`,
198
- `127.0.0.1`, `[::1]`, `*.localhost`, `*.local`) โ€” plain local dev or a local
199
- Storybook โ€” the SDK simulates the host locally:
371
+ **Mock mode fixes this automatically in local development and AI app
372
+ builders.** Whenever no LearnCard host is present and your app runs on a local
373
+ dev host (`localhost`, `127.0.0.1`, `[::1]`, `*.localhost`, `*.local`) or in
374
+ the editor preview of Lovable, Bolt, v0, or Replit, the SDK simulates the host:
200
375
 
201
- - **Every method shows a branded toast** describing what would happen once embedded โ€” e.g. `sendCredential` โ†’ _"โœ… In LearnCard, the user would receive **[name]** here."_, `incrementCounter` โ†’ _"Counter **coins** โ†’ **10**."_, `launchFeature` โ†’ _"Would open **/wallet**."_ So you get strong, visible feedback for every call, not just console logs.
202
- - `requestConsent(...)` auto-grants and shows a "mock consent" toast; `incrementCounter` / `getCounter` / `getCounters` persist to `localStorage` so values survive reloads.
203
- - Identical or polled calls **coalesce** into a single toast with a ร—N counter, so nothing spams the screen.
204
- - `requestIdentity`, notifications, learner context, sync status, etc. all resolve with sensible fake data.
205
- - Every simulated interaction is also logged to the console with a `[LearnCard SDK ยท MOCK]` prefix.
376
+ - **Every method shows a short notice** describing what would happen once embedded โ€” e.g. `sendCredential` โ†’ _"In LearnCard, the learner would receive **[name]**."_, `incrementCounter` โ†’ _"Counter **coins** โ†’ **10**."_, `launchFeature` โ†’ _"In LearnCard, this would open **/wallet**."_ So you get strong, visible feedback for every call, not just console logs. At most three notices show at once (one on phones); hovering a notice keeps it on screen.
377
+ - `requestConsent(...)` auto-grants and shows a notice describing what the learner would be asked to share; `incrementCounter` / `getCounter` / `getCounters` persist to `localStorage` so values survive reloads.
378
+ - Identical or polled calls **coalesce** into a single notice with a ร—N counter, so nothing spams the screen.
379
+ - `requestIdentity`, notifications, learner context, sync status, etc. all resolve with sensible fake data.
380
+ - Every simulated interaction is also logged to the console with a `[LearnCard SDK ยท MOCK]` prefix.
206
381
 
207
382
  **No code changes, no environment flags in local dev.** Your app is fully
208
383
  buildable and demo-able locally, and behaves identically against the real host
@@ -217,26 +392,29 @@ const res = await learnCard.sendCredential({ templateAlias: 'course-completion'
217
392
  // Embedded: goes to the real LearnCard host.
218
393
  ```
219
394
 
220
- **`'auto'` is deliberately scoped to local dev hosts.** A standalone page on a
221
- production or remote preview origin never auto-mocks โ€” otherwise a real user
222
- opening your app's URL directly would receive a fabricated identity and
223
- auto-granted consent. For remote deploy previews (Netlify, Lovable, Vercel, โ€ฆ)
395
+ **`'auto'` is deliberately scoped to local dev and app-builder previews.** A
396
+ builder preview is recognized by the editor framing the app (e.g. `lovable.dev`)
397
+ or by a preview-only host (`*.lovableproject.com`, `id-preview--*.lovable.app`,
398
+ `*.webcontainer-api.io`, `*.replit.dev`, `*.vusercontent.net`). A published app
399
+ (`*.lovable.app`, `*.vercel.app`, `*.replit.app`, your own domain) never
400
+ auto-mocks โ€” otherwise a real user opening your app's URL directly would receive
401
+ a fabricated identity and auto-granted consent. For other deploy previews (Netlify, Vercel, โ€ฆ)
224
402
  that should demo standalone anywhere but go real once embedded, opt in with
225
403
  `mock: 'standalone'`; for CI and tests that should always mock, use
226
404
  `mock: true`. Every mocked call shows a labeled toast and a
227
405
  `[LearnCard SDK ยท MOCK]` console log, so it's clear the SDK is simulating
228
406
  rather than talking to a real host.
229
407
 
230
- | `mock` | Standalone, local dev | Standalone, remote origin | Embedded in LearnCard |
231
- | ------------------ | --------------------- | ----------------------------- | --------------------- |
232
- | `'auto'` (default) | mock | fail fast (`LC_NOT_EMBEDDED`) | real host |
233
- | `'standalone'` | mock | mock | real host |
234
- | `true` | mock | mock | mock |
235
- | `false` | fail fast | fail fast | real host |
408
+ | `mock` | Local dev or app-builder preview | Standalone, other origin | Embedded in LearnCard |
409
+ | ------------------ | -------------------------------- | ----------------------------- | --------------------- |
410
+ | `'auto'` (default) | mock | fail fast (`LC_NOT_EMBEDDED`) | real host |
411
+ | `'standalone'` | mock | mock | real host |
412
+ | `true` | mock | mock | mock |
413
+ | `false` | fail fast | fail fast | real host |
236
414
 
237
415
  **Unrelated iframes don't fool it.** If your app is embedded in something that
238
416
  isn't LearnCard (a cross-origin Storybook canvas, a preview shell), calls no
239
- longer hang: the SDK mocks on local dev hosts and otherwise rejects fast with
417
+ longer hang: the SDK mocks in local dev and app-builder previews and otherwise rejects fast with
240
418
  `LC_NOT_EMBEDDED`. When the parent can't be identified (Firefox, or a
241
419
  same-origin localhost wrapper), the SDK sends a one-time, side-effect-free
242
420
  presence probe and only mocks if no host answers within `hostProbeTimeout`
@@ -279,6 +457,13 @@ if (learnCard.isMocked()) {
279
457
  }
280
458
  ```
281
459
 
460
+ Inspect the resolved origins directly:
461
+
462
+ ```typescript
463
+ learnCard.getActiveHostOrigin(); // real host origin, or null while mocking
464
+ learnCard.getPublishOrigin(); // LearnCard origin used for publish URLs
465
+ ```
466
+
282
467
  ### Coherent state: reads reflect writes
283
468
 
284
469
  The mock keeps a small session store so it behaves like a real host, not a set of
@@ -361,9 +546,38 @@ const identity = await learnCard.requestIdentity();
361
546
 
362
547
  **Error Codes:**
363
548
 
364
- - `LC_UNAUTHENTICATED`: User is not logged in to LearnCard
365
- - `LC_TIMEOUT`: Request timed out
366
- - `LC_NOT_EMBEDDED`: The app is not embedded in a LearnCard host (standalone, not mocking)
549
+ - `LC_UNAUTHENTICATED`: User is not logged in to LearnCard
550
+ - `LC_TIMEOUT`: Request timed out
551
+ - `LC_NOT_EMBEDDED`: The app is not embedded in a LearnCard host (standalone, not mocking)
552
+
553
+ ---
554
+
555
+ ### `getActiveHostOrigin()` / `getPublishOrigin()`
556
+
557
+ Inspect which LearnCard origin the SDK is using.
558
+
559
+ ```typescript
560
+ const hostOrigin = learnCard.getActiveHostOrigin();
561
+ const publishOrigin = learnCard.getPublishOrigin();
562
+ ```
563
+
564
+ - `getActiveHostOrigin()` returns the active real-host origin, or `null` while mock mode is active.
565
+ - `getPublishOrigin()` returns the LearnCard origin used to build App Store publish URLs.
566
+
567
+ ---
568
+
569
+ ### `getCapturedManifest()` / `getPublishUrl()`
570
+
571
+ Read the manifest captured by mock mode and generate an App Store publish URL.
572
+
573
+ ```typescript
574
+ const manifest = learnCard.getCapturedManifest();
575
+ const publishUrl = learnCard.getPublishUrl();
576
+ ```
577
+
578
+ - `getCapturedManifest()` returns `undefined` when you're not in mock mode or nothing has been captured yet.
579
+ - `getPublishUrl()` returns `undefined` until the first manifest exists, then always returns a URL.
580
+ - The auto-shown publish prompt still waits for at least 1 inline template or 2 distinct permissions.
367
581
 
368
582
  ---
369
583
 
@@ -403,6 +617,92 @@ if (response.alreadyClaimed) {
403
617
  }
404
618
  ```
405
619
 
620
+ #### Inline credential templates (zero-config)
621
+
622
+ You can also issue a credential from an inline template with no pre-configured host boost. The SDK validates the template and `templateData` locally before it posts anything to LearnCard, so invalid inputs fail fast with `TEMPLATE_INVALID` or `TEMPLATE_DATA_INVALID`.
623
+
624
+ ```typescript
625
+ const response = await learnCard.sendCredential({
626
+ alias: 'course-complete',
627
+ template: {
628
+ name: 'Completed {{courseName}}',
629
+ description: 'Awarded for finishing {{courseName}}.',
630
+ achievementType: 'Course',
631
+ criteria: { narrative: 'Finished all modules' },
632
+ },
633
+ templateData: { courseName: 'Intro to Baking' },
634
+ });
635
+
636
+ console.log(response.credentialUri, response.templateVersion);
637
+ ```
638
+
639
+ Inline templates are versioned by `alias`. Re-sending the same alias with the same canonical template keeps the same `templateVersion`; changing the template body under that alias bumps the version (`1`, `2`, `3`, ...).
640
+
641
+ If you want to validate offline before issuing, use `validateCredentialTemplate`:
642
+
643
+ ```typescript
644
+ const validation = learnCard.validateCredentialTemplate(
645
+ {
646
+ name: 'Completed {{courseName}}',
647
+ description: 'Awarded for finishing {{courseName}}.',
648
+ achievementType: 'Course',
649
+ criteria: { narrative: 'Finished all modules' },
650
+ },
651
+ { courseName: 'Intro to Baking' }
652
+ );
653
+
654
+ if (!validation.valid) {
655
+ console.error(validation.errors);
656
+ }
657
+ ```
658
+
659
+ If you want a full offline preview of the compiled OBv3 object, use `previewCompiledTemplate`:
660
+
661
+ ```typescript
662
+ const preview = learnCard.previewCompiledTemplate(
663
+ {
664
+ name: 'Completed {{courseName}}',
665
+ description: 'Awarded for finishing {{courseName}}.',
666
+ credits: { earned: '{{earnedCredits}}' },
667
+ },
668
+ { courseName: 'Intro to Baking', earnedCredits: 3 }
669
+ );
670
+
671
+ console.log(preview.compiled); // OBv3 object with {{variables}} intact
672
+ console.log(preview.rendered); // same object with templateData substituted
673
+ ```
674
+
675
+ You can also import it directly, along with `decodeManifestFromUrl`, from the SDK package:
676
+
677
+ ```typescript
678
+ import { previewCompiledTemplate, decodeManifestFromUrl } from '@learncard/partner-connect';
679
+ ```
680
+
681
+ ---
682
+
683
+ ### `previewCompiledTemplate(template, templateData?)`
684
+
685
+ Compile and optionally render an inline credential template entirely offline.
686
+
687
+ ```typescript
688
+ import { previewCompiledTemplate } from '@learncard/partner-connect';
689
+
690
+ const preview = previewCompiledTemplate(
691
+ {
692
+ name: 'Completed {{courseName}}',
693
+ description: 'Awarded for finishing {{courseName}}.',
694
+ },
695
+ { courseName: 'Intro to Baking' }
696
+ );
697
+
698
+ if (preview.valid) {
699
+ console.log(preview.compiled);
700
+ console.log(preview.rendered);
701
+ }
702
+ ```
703
+
704
+ Returns `{ valid, errors, compiled?, rendered? }`.
705
+
406
706
  ---
407
707
 
408
708
  ### `checkUserHasCredential(input)`
@@ -503,11 +803,11 @@ await learnCard.sendNotification({
503
803
 
504
804
  **Parameters:**
505
805
 
506
- - `title` _(optional)_: Notification title
507
- - `body` _(optional)_: Notification body text
508
- - `actionPath` _(optional)_: Deep link path within the app (e.g. `'/prizes'`). Must be an absolute pathname starting with `/`. This path is appended to the app's configured embed URL when the user taps the notification. For example, if your embed URL is `https://myapp.com` and `actionPath` is `'/challenges/42'`, the app will open at `https://myapp.com/challenges/42`. Hash routes (e.g. `'/#/page'`) are **not** supported โ€” use pathname-based routing.
509
- - `category` _(optional)_: Grouping category (e.g. `'reward'`, `'announcement'`, `'status'`)
510
- - `priority` _(optional)_: `'normal'` (default) or `'high'`. Affects visual styling of the notification card and toast. Does not change delivery priority or ordering.
806
+ - `title` _(optional)_: Notification title
807
+ - `body` _(optional)_: Notification body text
808
+ - `actionPath` _(optional)_: Deep link path within the app (e.g. `'/prizes'`). Must be an absolute pathname starting with `/`. This path is appended to the app's configured embed URL when the user taps the notification. For example, if your embed URL is `https://myapp.com` and `actionPath` is `'/challenges/42'`, the app will open at `https://myapp.com/challenges/42`. Hash routes (e.g. `'/#/page'`) are **not** supported โ€” use pathname-based routing.
809
+ - `category` _(optional)_: Grouping category (e.g. `'reward'`, `'announcement'`, `'status'`)
810
+ - `priority` _(optional)_: `'normal'` (default) or `'high'`. Affects visual styling of the notification card and toast. Does not change delivery priority or ordering.
511
811
 
512
812
  At least one of `title` or `body` is required.
513
813
 
@@ -533,16 +833,16 @@ console.log(spent.newValue); // 5
533
833
 
534
834
  **Parameters:**
535
835
 
536
- - `key` _(required)_: Counter name. Must match `[a-zA-Z0-9_-]+`, max 64 characters.
537
- - `amount` _(required)_: Integer value to add. Use a negative integer to decrement.
836
+ - `key` _(required)_: Counter name. Must match `[a-zA-Z0-9_-]+`, max 64 characters.
837
+ - `amount` _(required)_: Integer value to add. Use a negative integer to decrement.
538
838
 
539
839
  **Returns:** `{ key: string, previousValue: number, newValue: number }`
540
840
 
541
841
  **Limits:**
542
842
 
543
- - Max 50 distinct counter keys per user per app
544
- - Max 100 writes per user per app per minute
545
- - Amount must be a finite integer
843
+ - Max 50 distinct counter keys per user per app
844
+ - Max 100 writes per user per app per minute
845
+ - Amount must be a finite integer
546
846
 
547
847
  ---
548
848
 
@@ -557,7 +857,7 @@ console.log('Balance:', value);
557
857
 
558
858
  **Parameters:**
559
859
 
560
- - `key` _(required)_: Counter name (same format as `incrementCounter`)
860
+ - `key` _(required)_: Counter name (same format as `incrementCounter`)
561
861
 
562
862
  **Returns:** `{ key: string, value: number, updatedAt: string | null }`
563
863
 
@@ -578,7 +878,7 @@ const all = await learnCard.getCounters();
578
878
 
579
879
  **Parameters:**
580
880
 
581
- - `keys` _(optional)_: Array of counter names to fetch (max 50). Omit to return all.
881
+ - `keys` _(optional)_: Array of counter names to fetch (max 50). Omit to return all.
582
882
 
583
883
  **Returns:** `{ counters: Array<{ key: string, value: number, updatedAt: string | null }> }`
584
884
 
@@ -597,8 +897,8 @@ await learnCard.launchFeature(
597
897
 
598
898
  **Parameters:**
599
899
 
600
- - `featurePath`: Path to the feature
601
- - `initialPrompt`: Optional initial data or prompt
900
+ - `featurePath`: Path to the feature
901
+ - `initialPrompt`: Optional initial data or prompt
602
902
 
603
903
  ---
604
904
 
@@ -645,12 +945,12 @@ if (response.credential) {
645
945
 
646
946
  **Error Codes:**
647
947
 
648
- - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
649
- - `USER_REJECTED`: User declined to share
948
+ - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
949
+ - `USER_REJECTED`: User declined to share
650
950
 
651
951
  ---
652
952
 
653
- ### `requestConsent(contractUri)`
953
+ ### `requestConsent(contractUri)` / `requestConsent(scopes)`
654
954
 
655
955
  Request user consent for permissions.
656
956
 
@@ -666,6 +966,18 @@ if (response.granted) {
666
966
  }
667
967
  ```
668
968
 
969
+ Declarative scopes can be requested without a pre-created contract URI:
970
+
971
+ ```typescript
972
+ const { granted } = await learnCard.requestConsent({
973
+ read: { credentialCategories: ['Achievement', 'Skill'], personalFields: ['name'] },
974
+ write: { credentialCategories: ['Achievement'] },
975
+ reason: 'Personalize your training plan',
976
+ });
977
+ ```
978
+
979
+ In standalone mock mode, calling `requestConsent()` without scopes or a contract URI will auto-grant but show a toast nudge suggesting you pass scopes for zero-setup production use.
980
+
669
981
  **Returns:** `{ granted: boolean }`
670
982
 
671
983
  ---
@@ -687,8 +999,8 @@ if (response.issued) {
687
999
 
688
1000
  **Error Codes:**
689
1001
 
690
- - `UNAUTHORIZED`: Not an admin of this template
691
- - `TEMPLATE_NOT_FOUND`: Template doesn't exist
1002
+ - `UNAUTHORIZED`: Not an admin of this template
1003
+ - `TEMPLATE_NOT_FOUND`: Template doesn't exist
692
1004
 
693
1005
  ---
694
1006
 
@@ -783,15 +1095,15 @@ interface LearnCardError {
783
1095
 
784
1096
  **Common Error Codes:**
785
1097
 
786
- - `LC_TIMEOUT`: Request timed out
787
- - `LC_NOT_EMBEDDED`: Not embedded in a LearnCard host (standalone, not mocking)
788
- - `LC_UNAUTHENTICATED`: User not logged in
789
- - `USER_REJECTED`: User declined the request
790
- - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
791
- - `UNAUTHORIZED`: User lacks permission
792
- - `TEMPLATE_NOT_FOUND`: Template doesn't exist
793
- - `SDK_NOT_INITIALIZED`: SDK initialization failed
794
- - `SDK_DESTROYED`: SDK was destroyed before completion
1098
+ - `LC_TIMEOUT`: Request timed out
1099
+ - `LC_NOT_EMBEDDED`: Not embedded in a LearnCard host (standalone, not mocking)
1100
+ - `LC_UNAUTHENTICATED`: User not logged in
1101
+ - `USER_REJECTED`: User declined the request
1102
+ - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
1103
+ - `UNAUTHORIZED`: User lacks permission
1104
+ - `TEMPLATE_NOT_FOUND`: Template doesn't exist
1105
+ - `SDK_NOT_INITIALIZED`: SDK initialization failed
1106
+ - `SDK_DESTROYED`: SDK was destroyed before completion
795
1107
 
796
1108
  **Example:**
797
1109
 
@@ -846,9 +1158,9 @@ const config = {
846
1158
 
847
1159
  ## Browser Support
848
1160
 
849
- - Chrome/Edge 90+
850
- - Firefox 88+
851
- - Safari 14+
1161
+ - Chrome/Edge 90+
1162
+ - Firefox 88+
1163
+ - Safari 14+
852
1164
 
853
1165
  Requires `postMessage` API and `Promise` support.
854
1166
 
@@ -858,31 +1170,31 @@ The SDK implements multiple security layers:
858
1170
 
859
1171
  ### 1. **Strict Origin Validation**
860
1172
 
861
- - Messages must come from the **exact** active host origin
862
- - No wildcards, no pattern matching, no exceptions
863
- - Mathematical equivalence: `event.origin === activeHostOrigin`
1173
+ - Messages must come from the **exact** active host origin
1174
+ - No wildcards, no pattern matching, no exceptions
1175
+ - Mathematical equivalence: `event.origin === activeHostOrigin`
864
1176
 
865
1177
  ### 2. **Query Parameter Whitelist**
866
1178
 
867
- - `lc_host_override` values are validated against configured `hostOrigin` array
868
- - Invalid overrides are rejected and logged
869
- - Falls back to first configured origin on validation failure
1179
+ - `lc_host_override` values are validated against configured `hostOrigin` array
1180
+ - Invalid overrides are rejected and logged
1181
+ - Falls back to first configured origin on validation failure
870
1182
 
871
1183
  ### 3. **Anti-Spoofing Protection**
872
1184
 
873
1185
  Even if a malicious actor injects `?lc_host_override=https://evil.com`:
874
1186
 
875
- - The SDK may adopt `evil.com` as the active origin (if not whitelisted)
876
- - **BUT** messages from `evil.com` will only be accepted if `event.origin === 'evil.com'`
877
- - Browser security prevents `evil.com` from spoofing another domain's origin
878
- - Malicious messages are silently rejected
1187
+ - The SDK may adopt `evil.com` as the active origin (if not whitelisted)
1188
+ - **BUT** messages from `evil.com` will only be accepted if `event.origin === 'evil.com'`
1189
+ - Browser security prevents `evil.com` from spoofing another domain's origin
1190
+ - Malicious messages are silently rejected
879
1191
 
880
1192
  ### 4. **Additional Security Layers**
881
1193
 
882
- - **Protocol Validation**: Messages must match the expected protocol identifier
883
- - **Request ID Tracking**: Only tracked requests with valid IDs are processed
884
- - **Timeout Protection**: Requests automatically timeout to prevent hanging
885
- - **Explicit targetOrigin**: Never uses `'*'` in postMessage calls
1194
+ - **Protocol Validation**: Messages must match the expected protocol identifier
1195
+ - **Request ID Tracking**: Only tracked requests with valid IDs are processed
1196
+ - **Timeout Protection**: Requests automatically timeout to prevent hanging
1197
+ - **Explicit targetOrigin**: Never uses `'*'` in postMessage calls
886
1198
 
887
1199
  ### Example Attack Scenario (Prevented)
888
1200
 
@@ -964,5 +1276,5 @@ Contributions are welcome! Please see the [main LearnCard repository](https://gi
964
1276
 
965
1277
  For issues and questions:
966
1278
 
967
- - GitHub Issues: https://github.com/learningeconomy/LearnCard/issues
968
- - Documentation: https://docs.learncard.com
1279
+ - GitHub Issues: https://github.com/learningeconomy/LearnCard/issues
1280
+ - Documentation: https://docs.learncard.com