@learncard/partner-connect 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,6 +77,25 @@ interface PartnerConnectOptions {
77
77
  * Request timeout in milliseconds (default: 30000)
78
78
  */
79
79
  requestTimeout?: number;
80
+
81
+ /**
82
+ * Controls automatic standalone mock mode.
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
85
+ * present, on any origin; true always mocks; false never mocks.
86
+ */
87
+ mock?: boolean | 'auto' | 'standalone';
88
+
89
+ /**
90
+ * Fine-grained mock behavior (UI, logging, persistence, fake DID).
91
+ */
92
+ mockOptions?: MockHostOptions;
93
+
94
+ /**
95
+ * How long (ms) to wait for the host presence probe when embedded in a
96
+ * frame whose parent can't be confirmed as LearnCard (default: 1500).
97
+ */
98
+ hostProbeTimeout?: number;
80
99
  }
81
100
  ```
82
101
 
@@ -166,6 +185,169 @@ The SDK enforces an exact match between incoming message origins and the active
166
185
  // ❌ Rejects: messages from any other origin
167
186
  ```
168
187
 
188
+ ## Standalone / Mock Mode
189
+
190
+ The SDK only works when embedded inside a LearnCard host — that's the host that
191
+ answers its `postMessage` requests. When you run your app on its own (local dev,
192
+ Storybook, a preview deploy, CI), there is no host. Standalone calls that aren't
193
+ mocked reject immediately with `LC_NOT_EMBEDDED` (rather than hanging until the
194
+ request timeout), and the SDK logs a one-time hint pointing you to mock mode.
195
+
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:
200
+
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.
206
+
207
+ **No code changes, no environment flags in local dev.** Your app is fully
208
+ buildable and demo-able locally, and behaves identically against the real host
209
+ once embedded.
210
+
211
+ ```typescript
212
+ // Mocks in local dev when standalone; real host when embedded in LearnCard.
213
+ const learnCard = createPartnerConnect({ hostOrigin: 'https://learncard.app' });
214
+
215
+ const res = await learnCard.sendCredential({ templateAlias: 'course-completion' });
216
+ // Local dev, standalone: resolves with a mock URI + shows a toast.
217
+ // Embedded: goes to the real LearnCard host.
218
+ ```
219
+
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, …)
224
+ that should demo standalone anywhere but go real once embedded, opt in with
225
+ `mock: 'standalone'`; for CI and tests that should always mock, use
226
+ `mock: true`. Every mocked call shows a labeled toast and a
227
+ `[LearnCard SDK · MOCK]` console log, so it's clear the SDK is simulating
228
+ rather than talking to a real host.
229
+
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 |
236
+
237
+ **Unrelated iframes don't fool it.** If your app is embedded in something that
238
+ 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
240
+ `LC_NOT_EMBEDDED`. When the parent can't be identified (Firefox, or a
241
+ same-origin localhost wrapper), the SDK sends a one-time, side-effect-free
242
+ presence probe and only mocks if no host answers within `hostProbeTimeout`
243
+ (default 1500 ms).
244
+
245
+ For a **production build that's meant to run only inside LearnCard**, set
246
+ `mock: false`; standalone calls then reject immediately with `LC_NOT_EMBEDDED`
247
+ instead of showing simulated data.
248
+
249
+ Override the default behavior when needed:
250
+
251
+ ```typescript
252
+ // Mock wherever no host is present (remote previews), real host when embedded:
253
+ createPartnerConnect({ mock: 'standalone' });
254
+
255
+ // Always mock, even while embedded (CI, tests):
256
+ createPartnerConnect({ mock: true });
257
+
258
+ // Never mock (standalone calls reject fast with LC_NOT_EMBEDDED):
259
+ createPartnerConnect({ mock: false });
260
+
261
+ // Configure mock behavior:
262
+ createPartnerConnect({
263
+ mock: 'auto',
264
+ mockOptions: {
265
+ ui: true, // show toasts/banners (default true)
266
+ log: true, // console logging (default true)
267
+ persist: true, // localStorage-backed counters (default true)
268
+ did: 'did:web:example.com:me', // fake identity DID
269
+ namespace: 'my-app-mock', // localStorage namespace for mock state
270
+ },
271
+ });
272
+ ```
273
+
274
+ Check whether an instance is currently mocking:
275
+
276
+ ```typescript
277
+ if (learnCard.isMocked()) {
278
+ console.log('Running against the local mock host.');
279
+ }
280
+ ```
281
+
282
+ ### Coherent state: reads reflect writes
283
+
284
+ The mock keeps a small session store so it behaves like a real host, not a set of
285
+ disconnected stubs. Anything you do in a session shows up in later reads:
286
+
287
+ ```typescript
288
+ await learnCard.sendCredential({ templateAlias: 'course-completion' });
289
+
290
+ // Now reflects the credential you just issued:
291
+ await learnCard.checkUserHasCredential({ templateAlias: 'course-completion' }); // { hasCredential: true, ... }
292
+ await learnCard.requestLearnerContext(); // raw.credentials includes it
293
+ ```
294
+
295
+ This means happy-path UI (e.g. a "you already earned this" banner) actually
296
+ lights up standalone. Counters persist to `localStorage`; issued credentials and
297
+ identity live for the session (a reload re-applies your seeds — see below). Mock
298
+ credentials are clearly marked (`_mock: true`) and never cryptographically valid,
299
+ so they can't be mistaken for real ones.
300
+
301
+ ### Seeding data for demos
302
+
303
+ To demo a pre-populated state (a returning user who already has credentials, a
304
+ starting coin balance) without performing actions first, seed via `mockOptions`:
305
+
306
+ ```typescript
307
+ createPartnerConnect({
308
+ mockOptions: {
309
+ identity: { did: 'did:web:example.com:me', name: 'Ada' },
310
+ credentials: [
311
+ { templateAlias: 'course-completion', name: 'Algebra 101' },
312
+ // Model a credential you issued to someone else:
313
+ { boostUri: 'lc:boost:team-badge', recipient: 'alice', status: 'pending' },
314
+ ],
315
+ counters: { coins: 50 },
316
+ },
317
+ });
318
+ ```
319
+
320
+ Seeded credentials feed `checkUserHasCredential`, `getTemplateRecipients`,
321
+ `getTemplateIssuanceStatus`, `requestLearnerContext`, and `askCredentialSearch`.
322
+ Seeded counters are applied only when a counter has no persisted value yet, so
323
+ incremented values survive reloads.
324
+
325
+ ## Detecting the Embed Context
326
+
327
+ Use `isEmbedded()` to branch your own logic based on whether your app is running
328
+ inside a LearnCard iframe or as a standalone page — no need to write your own
329
+ frame detection.
330
+
331
+ ```typescript
332
+ import { isEmbedded, createPartnerConnect } from '@learncard/partner-connect';
333
+
334
+ if (isEmbedded()) {
335
+ // Inside LearnCard — hide your standalone header, enable SDK-backed features.
336
+ } else {
337
+ // Standalone — show an "Open in LearnCard" prompt, or lean on mock mode.
338
+ }
339
+ ```
340
+
341
+ It is also available as a static and instance method:
342
+
343
+ ```typescript
344
+ PartnerConnect.isEmbedded(); // static
345
+ createPartnerConnect().isEmbedded(); // instance
346
+ ```
347
+
348
+ `isEmbedded()` returns `false` during server-side rendering (no `window`) and is
349
+ safe to call anywhere.
350
+
169
351
  ## API Reference
170
352
 
171
353
  ### `requestIdentity()`
@@ -181,6 +363,7 @@ const identity = await learnCard.requestIdentity();
181
363
 
182
364
  - `LC_UNAUTHENTICATED`: User is not logged in to LearnCard
183
365
  - `LC_TIMEOUT`: Request timed out
366
+ - `LC_NOT_EMBEDDED`: The app is not embedded in a LearnCard host (standalone, not mocking)
184
367
 
185
368
  ---
186
369
 
@@ -601,6 +784,7 @@ interface LearnCardError {
601
784
  **Common Error Codes:**
602
785
 
603
786
  - `LC_TIMEOUT`: Request timed out
787
+ - `LC_NOT_EMBEDDED`: Not embedded in a LearnCard host (standalone, not mocking)
604
788
  - `LC_UNAUTHENTICATED`: User not logged in
605
789
  - `USER_REJECTED`: User declined the request
606
790
  - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
package/dist/index.d.ts CHANGED
@@ -91,6 +91,146 @@ interface PartnerConnectOptions {
91
91
  * Request timeout in milliseconds (default: 30000)
92
92
  */
93
93
  requestTimeout?: number;
94
+ /**
95
+ * Controls automatic **standalone mock mode**.
96
+ *
97
+ * When the SDK runs outside of a LearnCard host (i.e. as a top-level page,
98
+ * not embedded in an iframe), there is no host to answer `postMessage`
99
+ * requests, so every call would hang until it times out. Mock mode makes
100
+ * the SDK simulate the host locally so your app is fully buildable,
101
+ * demo-able, and testable without being embedded — and then behaves
102
+ * identically (real host) once embedded, with no code changes.
103
+ *
104
+ * - `'auto'` **(default)**: mock only when **no LearnCard host is present
105
+ * AND the app is running on a local dev host** (`localhost`,
106
+ * `127.0.0.1`, `[::1]`, `*.localhost`, `*.local`). This covers local
107
+ * dev and local Storybook, but deliberately never fabricates identity
108
+ * or consent on a production or preview origin. Each mocked call
109
+ * surfaces a labeled toast plus a console log so it's clear the host
110
+ * is simulated.
111
+ * - `'standalone'`: mock whenever **no LearnCard host is present**, on
112
+ * any origin — including remote deploy previews (Netlify, Lovable,
113
+ * Vercel, …) — and use the real host when embedded in LearnCard. The
114
+ * one-flag setting for apps that must demo standalone anywhere. Only
115
+ * choose it when a user opening your app's URL directly should see
116
+ * simulated data.
117
+ * - `true`: always mock, **even when embedded in a real LearnCard host**.
118
+ * Use this for CI and tests; for previews that should go real once
119
+ * embedded, prefer `'standalone'`.
120
+ * - `false`: never mock. Standalone calls reject immediately with
121
+ * `LC_NOT_EMBEDDED`. Set this in production builds meant to run only
122
+ * inside LearnCard.
123
+ *
124
+ * @default 'auto'
125
+ */
126
+ mock?: boolean | 'auto' | 'standalone';
127
+ /**
128
+ * Fine-grained configuration for standalone mock mode. Ignored when mock
129
+ * mode is not active. See {@link MockHostOptions}.
130
+ */
131
+ mockOptions?: MockHostOptions;
132
+ /**
133
+ * How long (ms) to wait for the host to answer the one-time presence
134
+ * probe used when the SDK is embedded in an iframe whose parent cannot
135
+ * be confirmed as LearnCard (e.g. a same-origin Storybook canvas on
136
+ * localhost). Only used with `mock: 'auto'` on local dev hosts.
137
+ *
138
+ * @default 1500
139
+ */
140
+ hostProbeTimeout?: number;
141
+ }
142
+ /**
143
+ * Options controlling the behavior of standalone mock mode.
144
+ *
145
+ * All fields are optional; mock mode works out-of-the-box with sensible
146
+ * defaults (visible UI, console logging, and localStorage-backed counters).
147
+ */
148
+ interface MockHostOptions {
149
+ /**
150
+ * Render lightweight visual feedback in the page: a fake credential-claim
151
+ * modal / toast for `sendCredential`, and a "mock consent" banner for
152
+ * `requestConsent`. Set to `false` for a headless mock (logs only).
153
+ *
154
+ * @default true
155
+ */
156
+ ui?: boolean;
157
+ /**
158
+ * Log every simulated host interaction to the console with a clear
159
+ * `[LearnCard SDK · MOCK]` prefix.
160
+ *
161
+ * @default true
162
+ */
163
+ log?: boolean;
164
+ /**
165
+ * Persist counters (`incrementCounter` / `getCounter` / `getCounters`) to
166
+ * `localStorage` so values survive page reloads, mirroring the real host's
167
+ * durable per-user counters. Falls back to in-memory storage when
168
+ * `localStorage` is unavailable.
169
+ *
170
+ * @default true
171
+ */
172
+ persist?: boolean;
173
+ /**
174
+ * The fake DID returned by `requestIdentity()` (and used as the mock
175
+ * user's identity) while in mock mode.
176
+ *
177
+ * @default 'did:web:mock.learncard.app:user'
178
+ */
179
+ did?: string;
180
+ /**
181
+ * Namespace used to scope persisted mock data (counters, claimed
182
+ * credentials) in `localStorage`. Change this if you run multiple mock
183
+ * apps on the same origin and want isolated state.
184
+ *
185
+ * @default 'lc-mock'
186
+ */
187
+ namespace?: string;
188
+ /**
189
+ * Seed the mock user's identity. Superseded per-field over the legacy
190
+ * `did` option. Extra fields are returned as-is from `requestIdentity()`.
191
+ */
192
+ identity?: MockIdentitySeed;
193
+ /**
194
+ * Pre-populate the mock with credentials the user already holds (or has
195
+ * issued to others). This lets you demo "happy path" states — e.g. a
196
+ * "you already earned this" banner — without performing an action first.
197
+ * Reads like `checkUserHasCredential`, `getTemplateRecipients`,
198
+ * `requestLearnerContext`, and `askCredentialSearch` reflect these.
199
+ */
200
+ credentials?: MockCredentialSeed[];
201
+ /**
202
+ * Initial counter values, applied only when a counter has no persisted
203
+ * value yet (so incremented values survive reloads).
204
+ */
205
+ counters?: Record<string, number>;
206
+ }
207
+ /**
208
+ * Seed shape for the mock user's identity (see {@link MockHostOptions.identity}).
209
+ */
210
+ interface MockIdentitySeed {
211
+ did?: string;
212
+ name?: string;
213
+ [key: string]: unknown;
214
+ }
215
+ /**
216
+ * Seed shape for a pre-populated mock credential (see
217
+ * {@link MockHostOptions.credentials}).
218
+ */
219
+ interface MockCredentialSeed {
220
+ /** Template alias this credential was issued from. */
221
+ templateAlias?: string;
222
+ /** Boost URI this credential was issued from. */
223
+ boostUri?: string;
224
+ /** Human-readable credential name (used in toasts and mock VC data). */
225
+ name?: string;
226
+ /**
227
+ * Recipient identifier (profileId or DID). Defaults to the mock user
228
+ * (i.e. a credential the user holds). Set this to model credentials the
229
+ * user has issued to other people.
230
+ */
231
+ recipient?: string;
232
+ /** Claim status. @default 'claimed' */
233
+ status?: 'pending' | 'claimed' | 'revoked';
94
234
  }
95
235
  /**
96
236
  * Identity information returned from REQUEST_IDENTITY
@@ -499,7 +639,7 @@ interface GetCountersResponse {
499
639
  /**
500
640
  * Error codes that can be returned by the LearnCard host
501
641
  */
502
- type ErrorCode = 'LC_TIMEOUT' | 'LC_UNAUTHENTICATED' | 'CREDENTIAL_NOT_FOUND' | 'USER_REJECTED' | 'UNAUTHORIZED' | 'TEMPLATE_NOT_FOUND' | 'BOOST_NOT_FOUND' | 'INSUFFICIENT_PERMISSIONS' | string;
642
+ type ErrorCode = 'LC_TIMEOUT' | 'LC_NOT_EMBEDDED' | 'LC_UNAUTHENTICATED' | 'CREDENTIAL_NOT_FOUND' | 'USER_REJECTED' | 'UNAUTHORIZED' | 'TEMPLATE_NOT_FOUND' | 'BOOST_NOT_FOUND' | 'INSUFFICIENT_PERMISSIONS' | string;
503
643
  /**
504
644
  * Error object returned when a request fails.
505
645
  *
@@ -598,6 +738,28 @@ interface PendingRequest {
598
738
  * ```
599
739
  */
600
740
 
741
+ /**
742
+ * Detect whether the current page is running inside an embedded iframe.
743
+ *
744
+ * Returns `true` when the SDK is embedded (e.g. inside the LearnCard host) and
745
+ * `false` when running as a standalone top-level page. Safe to call in any
746
+ * environment: returns `false` during server-side rendering (no `window`).
747
+ *
748
+ * Partner apps can use this to change behavior without writing their own frame
749
+ * detection — for example, showing a "Open in LearnCard" prompt when standalone.
750
+ *
751
+ * @example
752
+ * ```typescript
753
+ * import { isEmbedded } from '@learncard/partner-connect';
754
+ *
755
+ * if (isEmbedded()) {
756
+ * // Running inside LearnCard — use the SDK against the real host.
757
+ * } else {
758
+ * // Standalone — show a preview banner, or rely on automatic mock mode.
759
+ * }
760
+ * ```
761
+ */
762
+ declare function isEmbedded(): boolean;
601
763
  /**
602
764
  * LearnCard Partner Connect SDK class
603
765
  */
@@ -627,7 +789,47 @@ declare class PartnerConnect {
627
789
  private isInitialized;
628
790
  private syncCompleteCallbacks;
629
791
  private syncStatusPollId;
792
+ private mockHost;
793
+ private embedded;
794
+ private warnedNoHost;
795
+ private hostProbeTimeout;
796
+ /** Whether a real LearnCard host is believed to be listening. */
797
+ private hostReachable;
798
+ /** Pending probe decision; requests queue behind it when set. */
799
+ private activation;
630
800
  constructor(options?: PartnerConnectOptions);
801
+ /**
802
+ * Decide whether this instance talks to a real host, simulates one, or
803
+ * fails fast — the resolution of the `mock` option against the runtime
804
+ * embed context. See the `mock` option docs for the contract.
805
+ */
806
+ private configureMockActivation;
807
+ private isLocalDevContext;
808
+ private classifyParent;
809
+ private matchesConfiguredOrigin;
810
+ /**
811
+ * One-time host presence probe for the ambiguous-parent case. Sends a
812
+ * side-effect-free `GET_SYNC_STATUS`; an answer proves a live LearnCard
813
+ * host (responses are origin-checked), a timeout means nobody is
814
+ * listening and the mock takes over. Requests issued while the probe is
815
+ * in flight queue behind the decision instead of racing it.
816
+ */
817
+ private probeHost;
818
+ /**
819
+ * Whether this SDK instance is running inside an embedded iframe.
820
+ * Instance-level convenience wrapper around the standalone {@link isEmbedded}.
821
+ */
822
+ isEmbedded(): boolean;
823
+ /**
824
+ * Whether the current page is running inside an embedded iframe.
825
+ * Static convenience wrapper around the standalone {@link isEmbedded}.
826
+ */
827
+ static isEmbedded(): boolean;
828
+ /**
829
+ * Whether this instance is currently simulating the LearnCard host locally
830
+ * instead of talking to a real host over `postMessage`.
831
+ */
832
+ isMocked(): boolean;
631
833
  /**
632
834
  * Configure the active host origin using the following hierarchy:
633
835
  * 1. `window.location.ancestorOrigins[0]` (when supported) — the browser's
@@ -705,9 +907,13 @@ declare class PartnerConnect {
705
907
  */
706
908
  private generateRequestId;
707
909
  /**
708
- * Send a message to the parent window and return a Promise
910
+ * Send a message to the parent window and return a Promise. While a host
911
+ * presence probe is pending, requests queue behind its decision so they
912
+ * are answered by whichever side (real host or mock) actually exists.
709
913
  */
710
914
  private sendMessage;
915
+ private dispatchMessage;
916
+ private postToHost;
711
917
  /**
712
918
  * Request user identity (Single Sign-On)
713
919
  *
@@ -1037,4 +1243,4 @@ declare class PartnerConnect {
1037
1243
  */
1038
1244
  declare function createPartnerConnect(options?: PartnerConnectOptions): PartnerConnect;
1039
1245
 
1040
- export { AppNotificationInput, AppNotificationResponse, CheckCredentialInput, CheckCredentialResponse, CheckIssuanceStatusInput, ConsentResponse, CredentialSearchResponse, CredentialSpecificResponse, ErrorCode, GetCounterResponse, GetCountersResponse, GetTemplateRecipientsInput, IdentityResponse, IncrementCounterResponse, LearnCardError, LearnerContextCacheStatus, LearnerContextRawData, LearnerContextResponse, LearnerContextTimingBreakdown, PartnerConnect, PartnerConnectError, PartnerConnectOptions, PendingRequest, PostMessageRequest, PostMessageResponse, RequestConsentOptions, RequestConsentPayload, RequestLearnerContextOptions, SendAiSessionCredentialInput, SendAiSessionCredentialResponse, SendCredentialResponse, SummaryCredentialData, SummaryCredentialKeyword, SummaryCredentialNextStep, SummaryCredentialReflection, SummaryCredentialSkill, SyncProgress, SyncStatus, TemplateCredentialInput, TemplateCredentialResponse, TemplateIssuanceStatusResponse, TemplateIssueResponse, TemplateRecipientRecord, TemplateRecipientsResponse, VPRQuery, VerifiablePresentationRequest, createPartnerConnect, createPartnerConnect as default };
1246
+ export { AppNotificationInput, AppNotificationResponse, CheckCredentialInput, CheckCredentialResponse, CheckIssuanceStatusInput, ConsentResponse, CredentialSearchResponse, CredentialSpecificResponse, ErrorCode, GetCounterResponse, GetCountersResponse, GetTemplateRecipientsInput, IdentityResponse, IncrementCounterResponse, LearnCardError, LearnerContextCacheStatus, LearnerContextRawData, LearnerContextResponse, LearnerContextTimingBreakdown, MockCredentialSeed, MockHostOptions, MockIdentitySeed, PartnerConnect, PartnerConnectError, PartnerConnectOptions, PendingRequest, PostMessageRequest, PostMessageResponse, RequestConsentOptions, RequestConsentPayload, RequestLearnerContextOptions, SendAiSessionCredentialInput, SendAiSessionCredentialResponse, SendCredentialResponse, SummaryCredentialData, SummaryCredentialKeyword, SummaryCredentialNextStep, SummaryCredentialReflection, SummaryCredentialSkill, SyncProgress, SyncStatus, TemplateCredentialInput, TemplateCredentialResponse, TemplateIssuanceStatusResponse, TemplateIssueResponse, TemplateRecipientRecord, TemplateRecipientsResponse, VPRQuery, VerifiablePresentationRequest, createPartnerConnect, createPartnerConnect as default, isEmbedded };