@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 +395 -83
- package/dist/index.d.ts +257 -25
- package/dist/partner-connect.esm.js +3294 -431
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +3295 -430
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +3294 -431
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +3 -2
- package/src/docs-snippets.test.ts +79 -0
- package/src/index.ts +496 -33
- package/src/mock-host.ts +2447 -523
- package/src/mock-mode.test.ts +1337 -7
- package/src/origin-resolution.test.ts +229 -0
- package/src/types.ts +130 -11
package/README.md
CHANGED
|
@@ -6,12 +6,12 @@ The LearnCard Partner Connect SDK transforms complex `postMessage` communication
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
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
|
|
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
|
-
-
|
|
128
|
-
-
|
|
129
|
-
-
|
|
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
|
-
-
|
|
177
|
-
-
|
|
178
|
-
-
|
|
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
|
|
197
|
-
LearnCard host is present and your app runs on a local
|
|
198
|
-
`127.0.0.1`, `[::1]`, `*.localhost`, `*.local`)
|
|
199
|
-
|
|
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
|
-
-
|
|
202
|
-
-
|
|
203
|
-
-
|
|
204
|
-
-
|
|
205
|
-
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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` |
|
|
231
|
-
| ------------------ |
|
|
232
|
-
| `'auto'` (default) | mock
|
|
233
|
-
| `'standalone'` | mock
|
|
234
|
-
| `true` | mock
|
|
235
|
-
| `false` | fail fast
|
|
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
|
|
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
|
-
-
|
|
365
|
-
-
|
|
366
|
-
-
|
|
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
|
-
-
|
|
507
|
-
-
|
|
508
|
-
-
|
|
509
|
-
-
|
|
510
|
-
-
|
|
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
|
-
-
|
|
537
|
-
-
|
|
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
|
-
-
|
|
544
|
-
-
|
|
545
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
601
|
-
-
|
|
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
|
-
-
|
|
649
|
-
-
|
|
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
|
-
-
|
|
691
|
-
-
|
|
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
|
-
-
|
|
787
|
-
-
|
|
788
|
-
-
|
|
789
|
-
-
|
|
790
|
-
-
|
|
791
|
-
-
|
|
792
|
-
-
|
|
793
|
-
-
|
|
794
|
-
-
|
|
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
|
-
-
|
|
850
|
-
-
|
|
851
|
-
-
|
|
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
|
-
-
|
|
862
|
-
-
|
|
863
|
-
-
|
|
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
|
-
-
|
|
868
|
-
-
|
|
869
|
-
-
|
|
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
|
-
-
|
|
876
|
-
-
|
|
877
|
-
-
|
|
878
|
-
-
|
|
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
|
-
-
|
|
883
|
-
-
|
|
884
|
-
-
|
|
885
|
-
-
|
|
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
|
-
-
|
|
968
|
-
-
|
|
1279
|
+
- GitHub Issues: https://github.com/learningeconomy/LearnCard/issues
|
|
1280
|
+
- Documentation: https://docs.learncard.com
|