@learncard/partner-connect
v0.6.3
Published
LearnCard Partner Connect SDK - Promise-based cross-origin messaging for partner apps
Readme
@learncard/partner-connect
Promise-based JavaScript SDK for managing cross-origin messaging between partner apps and LearnCard
The LearnCard Partner Connect SDK transforms complex postMessage communication into clean, modern Promise-based functions. It handles the entire cross-origin message lifecycle, including request queuing, message validation, and timeout management.
Features
- 🔒 Secure: Origin validation for all messages
- 🎯 Type-safe: Full TypeScript support with comprehensive types
- ⚡ Promise-based: Modern async/await API
- 🧹 Clean: Abstracts away all postMessage complexity
- 📦 Lightweight: Zero runtime dependencies
- 🛡️ Robust: Built-in timeout handling and error management
Installation
npm install @learncard/partner-connectpnpm add @learncard/partner-connectyarn add @learncard/partner-connectQuick Start
import { createPartnerConnect } from '@learncard/partner-connect';
// Initialize the SDK
const learnCard = createPartnerConnect({
hostOrigin: 'https://learncard.app',
});
// Request user identity (SSO)
try {
const identity = await learnCard.requestIdentity();
console.log('User DID:', identity.user.did);
console.log('JWT Token:', identity.token);
} catch (error) {
if (error.code === 'LC_UNAUTHENTICATED') {
console.log('User is not logged in');
}
}Configuration
Options
interface PartnerConnectOptions {
/**
* The origin(s) of the LearnCard host
* Single string or array for query parameter whitelist
* @default 'https://learncard.app'
*/
hostOrigin?: string | string[];
/**
* Whether to allow native app origins (Capacitor/Ionic)
* @default true
*/
allowNativeAppOrigins?: boolean;
/**
* Protocol identifier (default: 'LEARNCARD_V1')
*/
protocol?: string;
/**
* Request timeout in milliseconds (default: 30000)
*/
requestTimeout?: number;
/**
* Controls automatic standalone mock mode.
* 'auto' (default) mocks only when no LearnCard host is present AND the
* page runs in local dev or an AI app builder's editor preview
* (Lovable, Bolt, v0, Replit); 'standalone' mocks whenever no host is
* present, on any origin; true always mocks; false never mocks.
*/
mock?: boolean | 'auto' | 'standalone';
/**
* Fine-grained mock behavior (UI, logging, persistence, fake DID).
*/
mockOptions?: MockHostOptions;
/**
* How long (ms) to wait for the host presence probe when embedded in a
* frame whose parent can't be confirmed as LearnCard (default: 1500).
*/
hostProbeTimeout?: number;
}From mock to App Store
While mock mode is active, the SDK silently captures an app manifest in local storage
({namespace}:manifests) with the LearnCard surface your app actually uses:
- inline credential templates
- consent scopes
- permissions inferred from SDK calls
- launched feature paths, counter keys, learner-context usage, notifications
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.
When mockOptions.ui !== false, mock mode also shows a Practice mode panel in the
bottom-left corner. It starts collapsed as a pill (Practice mode plus a feature count) and
expands into a frosted-glass card with the app name and address, a plain-language list of the
LearnCard features the app uses (templates, consent, screens, counters as details), a
Publish app button, and a copy-link button. It follows the system light/dark setting and
respects reduced motion.
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:
https://learncard.app/app-store/developer/submit?manifest=<base64url(JSON)>You can also read the same data yourself:
const manifest = learnCard.getCapturedManifest();
const publishUrl = learnCard.getPublishUrl();getPublishUrl() is always available once any manifest has been captured. The publishability
threshold above is now used only for the automatic prompt/nudge.
Configure the nudge with mockOptions:
createPartnerConnect({
mock: 'auto',
mockOptions: {
publishPrompt: true, // default
publishOrigin: 'https://learncard.app', // default
},
});If the card is dismissed, that dismissal is persisted for 24 hours under
{namespace}:publish-dismissed-at:{fingerprint}.
Multiple apps on one origin
If you run multiple apps on the same origin (for example, two local projects on
http://localhost:4321), mock mode keeps a separate manifest for each app.
By default it fingerprints the app from the initial document.title, so a
single SPA stays in one slot even if route changes later update the title.
If you want an explicit identity, pass mockOptions.appId:
createPartnerConnect({
mock: 'auto',
mockOptions: {
appId: 'student-quest-dev',
},
});That fingerprint scopes both the captured manifest and the publish-nudge dismissal.
If mock mode warns that two apps may be sharing state, set mockOptions.appId immediately.
The warning fires when the same fingerprint already has stored manifest data from a different
captured appUrl.
You can also override the LearnCard origin used for generated publish links:
createPartnerConnect({
mock: true,
mockOptions: {
appId: 'student-quest-dev',
publishOrigin: 'https://staging.learncard.app',
},
});Testing against a local or staging LearnCard
By default, publish links point at production LearnCard (https://learncard.app).
If you're running LearnCard yourself — a local checkout on http://localhost:3000,
or a staging tenant — you want those links to open your instance instead.
There are three ways to do that, from most convenient to most explicit.
1. ?lc_publish_override= (no code changes)
Add the parameter to your app's URL:
http://localhost:4321/?lc_publish_override=http://localhost:3000getPublishUrl(), getPublishOrigin(), and the practice panel's Publish app and copy-link buttons
all immediately target http://localhost:3000.
The value is validated as a parseable http: / https: URL and reduced to its
origin, so http://localhost:3000/anything?x=1 is stored as http://localhost:3000.
Anything that isn't a valid http(s) origin is ignored with a console warning,
and the SDK falls through to the next source — a typo can't silently break your
publish links.
A valid value is saved to sessionStorage under lc_publish_override, so you only
type it once per tab. Later navigations in that tab keep the override; opening a
fresh tab drops it.
Unlike
lc_host_override, this is not a security boundary — it only decides which LearnCard origin publish links point at, and never affects which origins the SDK will acceptpostMessagetraffic from. That's why it isn't whitelist-checked.
2. PUBLIC_LEARNCARD_ORIGIN (per-developer, via .env)
For a setting that should persist across tabs, drive it from your app's environment and pass it to both options at once:
const learnCardOrigin = import.meta.env.PUBLIC_LEARNCARD_ORIGIN;
const learnCard = createPartnerConnect({
...(learnCardOrigin
? { hostOrigin: learnCardOrigin, mockOptions: { publishOrigin: learnCardOrigin } }
: {}),
});# .env
PUBLIC_LEARNCARD_ORIGIN=http://localhost:30003. mockOptions.publishOrigin (hard-coded)
createPartnerConnect({ mockOptions: { publishOrigin: 'https://staging.learncard.app' } });Resolution order
The publish origin is resolved from the first source that yields a usable origin:
| # | Source |
| --- | --------------------------------------------------------------------- |
| 1 | mockOptions.publishOrigin |
| 2 | ?lc_publish_override= query param, then its sessionStorage value |
| 3 | ?lc_host_override= (param, then stored) if it's one concrete origin |
| 4 | The first non-wildcard, non-native-app hostOrigin you configured |
| 5 | https://learncard.app |
Step 3 means that if you're already testing against a local host with
?lc_host_override=http://localhost:3000, publish links follow it automatically —
you don't need both parameters. Wildcard patterns like https://*.learncard.app
are skipped here, since they name a family of hosts rather than one publishable origin.
Inspect the result at any time:
learnCard.getPublishOrigin(); // 'http://localhost:3000'
learnCard.getPublishUrl(); // 'http://localhost:3000/app-store/developer/submit?manifest=…'When mock mode is running on localhost and publish links would still go to
production, the expanded practice panel shows a one-line reminder:
Local LearnCard? Add ?lc_publish_override=http://localhost:3000. It disappears as
soon as an override or an explicit publishOrigin is in effect.
Dynamic Origin Configuration
The SDK uses a hierarchical approach to determine the active host origin:
1. Hardcoded Default (Security Anchor)
PartnerConnect.DEFAULT_HOST_ORIGIN; // 'https://learncard.app'2. Query Parameter Override (Staging/Testing)
// Your app URL: https://partner-app.com/?lc_host_override=https://staging.learncard.app
const learnCard = createPartnerConnect({
hostOrigin: ['https://learncard.app', 'https://staging.learncard.app'],
});
// Active origin: https://staging.learncard.app (from query param)
// ✅ Only accepts messages from: https://staging.learncard.app
// ✅ Sends messages to: https://staging.learncard.appHow the LearnCard Host Uses This:
- Production: Iframe URL has no
lc_host_overrideparameter - Staging: Iframe URL includes
?lc_host_override=https://staging.learncard.app - This allows testing against non-production environments without recompiling partner code
3. Configured Origin (Fallback)
const learnCard = createPartnerConnect({
hostOrigin: 'https://learncard.app',
});
// Active origin: https://learncard.app (configured)Origin Whitelist (Security Gate)
When providing multiple origins, they serve as a whitelist for the lc_host_override parameter:
const learnCard = createPartnerConnect({
hostOrigin: [
'https://learncard.app',
'https://staging.learncard.app',
'https://preview.learncard.app',
],
});
// Scenario 1: No query param
// → Uses: https://learncard.app (first in array)
// Scenario 2: Valid override
// URL: ?lc_host_override=https://staging.learncard.app
// → Uses: https://staging.learncard.app ✅
// Scenario 3: Invalid override (not in whitelist)
// URL: ?lc_host_override=https://evil.com
// → Uses: https://learncard.app (falls back to first) ⚠️
// → Logs warning about unauthorized overrideSecurity Model
STRICT Origin Validation:
Incoming Message Origin ≡ Configured Host OriginThe SDK enforces an exact match between incoming message origins and the active host origin:
- ✅ Secure: Even if a malicious actor adds
?lc_host_override=https://evil.com, messages fromevil.comwill be rejected - ✅ Cannot be spoofed: Browser security prevents malicious sites from faking their
event.origin - ✅ No wildcards: Only exact matches are accepted
// Active origin: https://staging.learncard.app
// ✅ Accepts: messages from https://staging.learncard.app
// ❌ Rejects: messages from https://learncard.app
// ❌ Rejects: messages from https://evil.com
// ❌ Rejects: messages from any other originStandalone / Mock Mode
The SDK only works when embedded inside a LearnCard host — that's the host that
answers its postMessage requests. When you run your app on its own (local dev,
Storybook, a preview deploy, CI), there is no host. Standalone calls that aren't
mocked reject immediately with LC_NOT_EMBEDDED (rather than hanging until the
request timeout), and the SDK logs a one-time hint pointing you to mock mode.
Mock mode fixes this automatically in local development and AI app
builders. Whenever no LearnCard host is present and your app runs on a local
dev host (localhost, 127.0.0.1, [::1], *.localhost, *.local) or in
the editor preview of Lovable, Bolt, v0, or Replit, the SDK simulates the host:
- 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. requestConsent(...)auto-grants and shows a notice describing what the learner would be asked to share;incrementCounter/getCounter/getCounterspersist tolocalStorageso values survive reloads.- Identical or polled calls coalesce into a single notice with a ×N counter, so nothing spams the screen.
requestIdentity, notifications, learner context, sync status, etc. all resolve with sensible fake data.- Every simulated interaction is also logged to the console with a
[LearnCard SDK · MOCK]prefix.
No code changes, no environment flags in local dev. Your app is fully buildable and demo-able locally, and behaves identically against the real host once embedded.
// Mocks in local dev when standalone; real host when embedded in LearnCard.
const learnCard = createPartnerConnect({ hostOrigin: 'https://learncard.app' });
const res = await learnCard.sendCredential({ templateAlias: 'course-completion' });
// Local dev, standalone: resolves with a mock URI + shows a toast.
// Embedded: goes to the real LearnCard host.'auto' is deliberately scoped to local dev and app-builder previews. A
builder preview is recognized by the editor framing the app (e.g. lovable.dev)
or by a preview-only host (*.lovableproject.com, id-preview--*.lovable.app,
*.webcontainer-api.io, *.replit.dev, *.vusercontent.net). A published app
(*.lovable.app, *.vercel.app, *.replit.app, your own domain) never
auto-mocks — otherwise a real user opening your app's URL directly would receive
a fabricated identity and auto-granted consent. For other deploy previews (Netlify, Vercel, …)
that should demo standalone anywhere but go real once embedded, opt in with
mock: 'standalone'; for CI and tests that should always mock, use
mock: true. Every mocked call shows a labeled toast and a
[LearnCard SDK · MOCK] console log, so it's clear the SDK is simulating
rather than talking to a real host.
| mock | Local dev or app-builder preview | Standalone, other origin | Embedded in LearnCard |
| ------------------ | -------------------------------- | ----------------------------- | --------------------- |
| 'auto' (default) | mock | fail fast (LC_NOT_EMBEDDED) | real host |
| 'standalone' | mock | mock | real host |
| true | mock | mock | mock |
| false | fail fast | fail fast | real host |
Unrelated iframes don't fool it. If your app is embedded in something that
isn't LearnCard (a cross-origin Storybook canvas, a preview shell), calls no
longer hang: the SDK mocks in local dev and app-builder previews and otherwise rejects fast with
LC_NOT_EMBEDDED. When the parent can't be identified (Firefox, or a
same-origin localhost wrapper), the SDK sends a one-time, side-effect-free
presence probe and only mocks if no host answers within hostProbeTimeout
(default 1500 ms).
For a production build that's meant to run only inside LearnCard, set
mock: false; standalone calls then reject immediately with LC_NOT_EMBEDDED
instead of showing simulated data.
Override the default behavior when needed:
// Mock wherever no host is present (remote previews), real host when embedded:
createPartnerConnect({ mock: 'standalone' });
// Always mock, even while embedded (CI, tests):
createPartnerConnect({ mock: true });
// Never mock (standalone calls reject fast with LC_NOT_EMBEDDED):
createPartnerConnect({ mock: false });
// Configure mock behavior:
createPartnerConnect({
mock: 'auto',
mockOptions: {
ui: true, // show toasts/banners (default true)
log: true, // console logging (default true)
persist: true, // localStorage-backed counters (default true)
did: 'did:web:example.com:me', // fake identity DID
namespace: 'my-app-mock', // localStorage namespace for mock state
},
});Check whether an instance is currently mocking:
if (learnCard.isMocked()) {
console.log('Running against the local mock host.');
}Inspect the resolved origins directly:
learnCard.getActiveHostOrigin(); // real host origin, or null while mocking
learnCard.getPublishOrigin(); // LearnCard origin used for publish URLsCoherent state: reads reflect writes
The mock keeps a small session store so it behaves like a real host, not a set of disconnected stubs. Anything you do in a session shows up in later reads:
await learnCard.sendCredential({ templateAlias: 'course-completion' });
// Now reflects the credential you just issued:
await learnCard.checkUserHasCredential({ templateAlias: 'course-completion' }); // { hasCredential: true, ... }
await learnCard.requestLearnerContext(); // raw.credentials includes itThis means happy-path UI (e.g. a "you already earned this" banner) actually
lights up standalone. Counters persist to localStorage; issued credentials and
identity live for the session (a reload re-applies your seeds — see below). Mock
credentials are clearly marked (_mock: true) and never cryptographically valid,
so they can't be mistaken for real ones.
Seeding data for demos
To demo a pre-populated state (a returning user who already has credentials, a
starting coin balance) without performing actions first, seed via mockOptions:
createPartnerConnect({
mockOptions: {
identity: { did: 'did:web:example.com:me', name: 'Ada' },
credentials: [
{ templateAlias: 'course-completion', name: 'Algebra 101' },
// Model a credential you issued to someone else:
{ boostUri: 'lc:boost:team-badge', recipient: 'alice', status: 'pending' },
],
counters: { coins: 50 },
},
});Seeded credentials feed checkUserHasCredential, getTemplateRecipients,
getTemplateIssuanceStatus, requestLearnerContext, and askCredentialSearch.
Seeded counters are applied only when a counter has no persisted value yet, so
incremented values survive reloads.
Detecting the Embed Context
Use isEmbedded() to branch your own logic based on whether your app is running
inside a LearnCard iframe or as a standalone page — no need to write your own
frame detection.
import { isEmbedded, createPartnerConnect } from '@learncard/partner-connect';
if (isEmbedded()) {
// Inside LearnCard — hide your standalone header, enable SDK-backed features.
} else {
// Standalone — show an "Open in LearnCard" prompt, or lean on mock mode.
}It is also available as a static and instance method:
PartnerConnect.isEmbedded(); // static
createPartnerConnect().isEmbedded(); // instanceisEmbedded() returns false during server-side rendering (no window) and is
safe to call anywhere.
API Reference
requestIdentity()
Request user identity information (Single Sign-On).
const identity = await learnCard.requestIdentity();
// Returns: { token: string, user: { did: string, ... } }Error Codes:
LC_UNAUTHENTICATED: User is not logged in to LearnCardLC_TIMEOUT: Request timed outLC_NOT_EMBEDDED: The app is not embedded in a LearnCard host (standalone, not mocking)
getActiveHostOrigin() / getPublishOrigin()
Inspect which LearnCard origin the SDK is using.
const hostOrigin = learnCard.getActiveHostOrigin();
const publishOrigin = learnCard.getPublishOrigin();getActiveHostOrigin()returns the active real-host origin, ornullwhile mock mode is active.getPublishOrigin()returns the LearnCard origin used to build App Store publish URLs.
getCapturedManifest() / getPublishUrl()
Read the manifest captured by mock mode and generate an App Store publish URL.
const manifest = learnCard.getCapturedManifest();
const publishUrl = learnCard.getPublishUrl();getCapturedManifest()returnsundefinedwhen you're not in mock mode or nothing has been captured yet.getPublishUrl()returnsundefineduntil the first manifest exists, then always returns a URL.- The auto-shown publish prompt still waits for at least 1 inline template or 2 distinct permissions.
sendCredential(credential)
Send a verifiable credential to the user's LearnCard wallet.
const response = await learnCard.sendCredential({
'@context': ['https://www.w3.org/2018/credentials/v1'],
type: ['VerifiableCredential', 'AchievementCredential'],
credentialSubject: {
id: identity.user.did,
achievement: {
name: 'JavaScript Expert',
description: 'Mastered advanced JavaScript concepts',
},
},
});
console.log('Credential ID:', response.credentialId);Returns: { credentialId: string } (raw credential mode) or { credentialUri: string, boostUri: string } (template mode).
Template mode example with duplicate prevention:
const response = await learnCard.sendCredential({
templateAlias: 'achievement',
templateData: { score: 95 },
preventDuplicateClaim: true,
});
if (response.alreadyClaimed) {
console.log('User already has this credential:', response.credentialUri);
}Inline credential templates (zero-config)
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.
const response = await learnCard.sendCredential({
alias: 'course-complete',
template: {
name: 'Completed {{courseName}}',
description: 'Awarded for finishing {{courseName}}.',
achievementType: 'Course',
criteria: { narrative: 'Finished all modules' },
},
templateData: { courseName: 'Intro to Baking' },
});
console.log(response.credentialUri, response.templateVersion);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, ...).
If you want to validate offline before issuing, use validateCredentialTemplate:
const validation = learnCard.validateCredentialTemplate(
{
name: 'Completed {{courseName}}',
description: 'Awarded for finishing {{courseName}}.',
achievementType: 'Course',
criteria: { narrative: 'Finished all modules' },
},
{ courseName: 'Intro to Baking' }
);
if (!validation.valid) {
console.error(validation.errors);
}If you want a full offline preview of the compiled OBv3 object, use previewCompiledTemplate:
const preview = learnCard.previewCompiledTemplate(
{
name: 'Completed {{courseName}}',
description: 'Awarded for finishing {{courseName}}.',
credits: { earned: '{{earnedCredits}}' },
},
{ courseName: 'Intro to Baking', earnedCredits: 3 }
);
console.log(preview.compiled); // OBv3 object with {{variables}} intact
console.log(preview.rendered); // same object with templateData substitutedYou can also import it directly, along with decodeManifestFromUrl, from the SDK package:
import { previewCompiledTemplate, decodeManifestFromUrl } from '@learncard/partner-connect';previewCompiledTemplate(template, templateData?)
Compile and optionally render an inline credential template entirely offline.
import { previewCompiledTemplate } from '@learncard/partner-connect';
const preview = previewCompiledTemplate(
{
name: 'Completed {{courseName}}',
description: 'Awarded for finishing {{courseName}}.',
},
{ courseName: 'Intro to Baking' }
);
if (preview.valid) {
console.log(preview.compiled);
console.log(preview.rendered);
}Returns { valid, errors, compiled?, rendered? }.
checkUserHasCredential(input)
Silently check whether the current user already has a credential for a given app boost template.
const result = await learnCard.checkUserHasCredential({
templateAlias: 'achievement',
});
if (result.hasCredential) {
console.log('Already earned:', result.credentialUri, result.receivedDate);
} else {
console.log('Not earned yet');
}You can also query directly by boost URI:
await learnCard.checkUserHasCredential({
boostUri: 'lc:network:network.learncard.com/trpc:boost:abc123',
});Returns: { hasCredential: boolean, credentialUri?: string, receivedDate?: string, status?: 'pending' | 'claimed' | 'revoked' }
getTemplateIssuanceStatus(input)
Check if the current user has issued/sent a specific template to someone. Returns issuance status including sent date and claim status.
const status = await learnCard.getTemplateIssuanceStatus({
templateAlias: 'achievement-badge',
recipient: 'user123', // Can be a profileId or DID (did:web:...)
});
if (status.sent) {
console.log('Issued on:', status.sentDate);
console.log('Status:', status.status); // 'pending', 'claimed', or 'revoked'
if (status.claimedDate) {
console.log('Claimed on:', status.claimedDate);
}
}You can also use a DID as the recipient:
await learnCard.getTemplateIssuanceStatus({
boostUri: 'lc:network:network.learncard.com/trpc:boost:abc123',
recipient: 'did:web:network.learncard.com:users:user456',
});Returns: { sent: boolean, credentialUri?: string, sentDate?: string, claimedDate?: string, status?: 'pending' | 'claimed' | 'revoked' }
getTemplateRecipients(input)
Get the list of all recipients for a specific template/boost. Useful for dashboards showing who has received a credential.
const recipients = await learnCard.getTemplateRecipients({
templateAlias: 'achievement-badge',
limit: 10,
});
console.log(`Found ${recipients.records.length} recipients`);
recipients.records.forEach(r => {
console.log(`${r.recipientDisplayName}: ${r.status}`);
});
// Paginate if more results available
if (recipients.hasMore) {
const nextPage = await learnCard.getTemplateRecipients({
templateAlias: 'achievement-badge',
limit: 10,
cursor: recipients.cursor,
});
}Returns: { records: TemplateRecipientRecord[], hasMore: boolean, cursor?: string, total?: number }
sendNotification(input)
Send a notification to the current user from this app. The notification appears in the user's LearnCard notification inbox, even after they leave the app.
await learnCard.sendNotification({
title: 'Sprint Bonus!',
body: '+10 coins from Sprint 42',
actionPath: '/',
category: 'reward',
});Parameters:
title(optional): Notification titlebody(optional): Notification body textactionPath(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 ishttps://myapp.comandactionPathis'/challenges/42', the app will open athttps://myapp.com/challenges/42. Hash routes (e.g.'/#/page') are not supported — use pathname-based routing.category(optional): Grouping category (e.g.'reward','announcement','status')priority(optional):'normal'(default) or'high'. Affects visual styling of the notification card and toast. Does not change delivery priority or ordering.
At least one of title or body is required.
Returns: { sent: boolean }
Note: This method sends a notification to the current user (self-notification) via the
send-notificationapp event. For server-to-server notifications to arbitrary users, use thePOST /app-store/listing/{listingId}/notifybrain-service route directly from your app backend.
incrementCounter(key, amount)
Increment or decrement an app-scoped counter for the current user. Counters are scoped to (user, app, key). If the counter does not exist, it is created with the given amount as its initial value.
// Add 10 coins
const result = await learnCard.incrementCounter('coins', 10);
console.log(result.newValue); // 10
// Spend 5 coins
const spent = await learnCard.incrementCounter('coins', -5);
console.log(spent.newValue); // 5Parameters:
key(required): Counter name. Must match[a-zA-Z0-9_-]+, max 64 characters.amount(required): Integer value to add. Use a negative integer to decrement.
Returns: { key: string, previousValue: number, newValue: number }
Limits:
- Max 50 distinct counter keys per user per app
- Max 100 writes per user per app per minute
- Amount must be a finite integer
getCounter(key)
Read the current value of an app-scoped counter. Returns { value: 0 } if the counter does not exist.
const { value } = await learnCard.getCounter('coins');
console.log('Balance:', value);Parameters:
key(required): Counter name (same format asincrementCounter)
Returns: { key: string, value: number, updatedAt: string | null }
getCounters(keys?)
Read multiple app-scoped counters at once. If keys is omitted, returns all counters for this app.
// Specific keys
const { counters } = await learnCard.getCounters(['coins', 'spins', 'streak']);
counters.forEach(c => console.log(c.key, c.value));
// All counters
const all = await learnCard.getCounters();Parameters:
keys(optional): Array of counter names to fetch (max 50). Omit to return all.
Returns: { counters: Array<{ key: string, value: number, updatedAt: string | null }> }
launchFeature(featurePath, initialPrompt?)
Launch a feature in the LearnCard host application.
await learnCard.launchFeature(
'/ai/topics?shortCircuitStep=newTopic&selectedAppId=null',
'Explain the postMessage security model'
);Parameters:
featurePath: Path to the featureinitialPrompt: Optional initial data or prompt
askCredentialSearch(verifiablePresentationRequest)
Request credentials from the user's wallet using a Verifiable Presentation Request.
const response = await learnCard.askCredentialSearch({
query: [
{
type: 'QueryByTitle',
credentialQuery: {
reason: 'We need to verify your teamwork skills',
title: 'Capstone',
},
},
],
challenge: `challenge-${Date.now()}`,
domain: window.location.hostname,
});
if (response.verifiablePresentation) {
const credentials = response.verifiablePresentation.verifiableCredential;
console.log(`Received ${credentials.length} credential(s)`);
}Returns: { verifiablePresentation?: { verifiableCredential: unknown[], ... } }
askCredentialSpecific(credentialId)
Request a specific credential by ID.
const response = await learnCard.askCredentialSpecific('credential-id-123');
if (response.credential) {
console.log('Received credential:', response.credential);
}Error Codes:
CREDENTIAL_NOT_FOUND: Credential doesn't existUSER_REJECTED: User declined to share
requestConsent(contractUri) / requestConsent(scopes)
Request user consent for permissions.
const response = await learnCard.requestConsent(
'lc:network:network.learncard.com/trpc:contract:abc123'
);
if (response.granted) {
console.log('User granted consent');
} else {
console.log('User denied consent');
}Declarative scopes can be requested without a pre-created contract URI:
const { granted } = await learnCard.requestConsent({
read: { credentialCategories: ['Achievement', 'Skill'], personalFields: ['name'] },
write: { credentialCategories: ['Achievement'] },
reason: 'Personalize your training plan',
});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.
Returns: { granted: boolean }
initiateTemplateIssue(templateId, draftRecipients?)
Initiate a template-based credential issuance flow (e.g., Send Boost).
const response = await learnCard.initiateTemplateIssue(
'lc:network:network.learncard.com/trpc:boost:xyz789',
['did:key:z6Mkr...', 'did:key:z6Mks...']
);
if (response.issued) {
console.log('Template issued successfully');
}Error Codes:
UNAUTHORIZED: Not an admin of this templateTEMPLATE_NOT_FOUND: Template doesn't exist
destroy()
Clean up the SDK and remove event listeners. Call this when unmounting your component or closing your app.
learnCard.destroy();Complete Example
Here's a complete example showing how to refactor a manual postMessage implementation to use the SDK:
Before (Manual postMessage)
// Manual setup - verbose and error-prone
const LEARNCARD_HOST_ORIGIN = 'https://learncard.app';
const PROTOCOL = 'LEARNCARD_V1';
const pendingRequests = new Map();
function sendPostMessage(action, payload = {}) {
return new Promise((resolve, reject) => {
const requestId = `${action}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
pendingRequests.set(requestId, { resolve, reject });
window.parent.postMessage(
{
protocol: PROTOCOL,
action,
requestId,
payload,
},
LEARNCARD_HOST_ORIGIN
);
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
reject({ code: 'LC_TIMEOUT', message: 'Request timed out' });
}
}, 30000);
});
}
window.addEventListener('message', event => {
if (event.origin !== LEARNCARD_HOST_ORIGIN) return;
const { protocol, requestId, type, data, error } = event.data;
if (protocol !== PROTOCOL || !requestId) return;
const pending = pendingRequests.get(requestId);
if (!pending) return;
pendingRequests.delete(requestId);
if (type === 'SUCCESS') {
pending.resolve(data);
} else if (type === 'ERROR') {
pending.reject(error);
}
});
// Usage
const identity = await sendPostMessage('REQUEST_IDENTITY');After (With SDK)
import { createPartnerConnect } from '@learncard/partner-connect';
// Clean, one-line setup
const learnCard = createPartnerConnect({
hostOrigin: 'https://learncard.app',
});
// Usage - same result, much cleaner
const identity = await learnCard.requestIdentity();Error Handling
All methods return Promises that reject with a LearnCardError object:
interface LearnCardError {
code: string;
message: string;
}Common Error Codes:
LC_TIMEOUT: Request timed outLC_NOT_EMBEDDED: Not embedded in a LearnCard host (standalone, not mocking)LC_UNAUTHENTICATED: User not logged inUSER_REJECTED: User declined the requestCREDENTIAL_NOT_FOUND: Credential doesn't existUNAUTHORIZED: User lacks permissionTEMPLATE_NOT_FOUND: Template doesn't existSDK_NOT_INITIALIZED: SDK initialization failedSDK_DESTROYED: SDK was destroyed before completion
Example:
try {
const identity = await learnCard.requestIdentity();
// Success
} catch (error) {
switch (error.code) {
case 'LC_UNAUTHENTICATED':
console.log('Please log in to your LearnCard account');
break;
case 'LC_TIMEOUT':
console.log('Request timed out. Please try again.');
break;
default:
console.error('An error occurred:', error.message);
}
}Integration with Astro
---
// src/pages/index.astro
const config = {
learnCardHostOrigin: import.meta.env.PUBLIC_LEARNCARD_HOST || 'https://learncard.app',
};
---
<script>
import { createPartnerConnect } from '@learncard/partner-connect';
const config = window.__LC_CONFIG;
const learnCard = createPartnerConnect({
hostOrigin: config.learnCardHostOrigin,
});
async function init() {
try {
const identity = await learnCard.requestIdentity();
console.log('Logged in as:', identity.user.did);
} catch (error) {
console.error('Not authenticated:', error);
}
}
init();
</script>Browser Support
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
Requires postMessage API and Promise support.
Security
The SDK implements multiple security layers:
1. Strict Origin Validation
- Messages must come from the exact active host origin
- No wildcards, no pattern matching, no exceptions
- Mathematical equivalence:
event.origin === activeHostOrigin
2. Query Parameter Whitelist
lc_host_overridevalues are validated against configuredhostOriginarray- Invalid overrides are rejected and logged
- Falls back to first configured origin on validation failure
3. Anti-Spoofing Protection
Even if a malicious actor injects ?lc_host_override=https://evil.com:
- The SDK may adopt
evil.comas the active origin (if not whitelisted) - BUT messages from
evil.comwill only be accepted ifevent.origin === 'evil.com' - Browser security prevents
evil.comfrom spoofing another domain's origin - Malicious messages are silently rejected
4. Additional Security Layers
- Protocol Validation: Messages must match the expected protocol identifier
- Request ID Tracking: Only tracked requests with valid IDs are processed
- Timeout Protection: Requests automatically timeout to prevent hanging
- Explicit targetOrigin: Never uses
'*'in postMessage calls
Example Attack Scenario (Prevented)
// Attacker adds malicious query param
// URL: https://partner-app.com/?lc_host_override=https://evil.com
// SDK configuration
const learnCard = createPartnerConnect({
hostOrigin: ['https://learncard.app', 'https://staging.learncard.app'],
});
// What happens:
// 1. SDK detects lc_host_override=https://evil.com
// 2. Validates against whitelist: NOT FOUND
// 3. Falls back to: https://learncard.app
// 4. Sends messages to: https://learncard.app
// 5. Only accepts messages from: https://learncard.app
// 6. Attacker's messages from evil.com: REJECTED ❌TypeScript
The SDK is written in TypeScript and includes comprehensive type definitions:
import type {
PartnerConnectOptions,
IdentityResponse,
SendCredentialResponse,
VerifiablePresentationRequest,
CredentialSearchResponse,
ConsentResponse,
LearnCardError,
} from '@learncard/partner-connect';Learner Context Sync Readiness
Apps that need a complete learner snapshot can ask LearnCard to wait for background data sync:
const context = await learnCard.requestLearnerContext({
includeCredentials: true,
waitForSync: true,
format: 'structured',
});
if (context.status === 'syncing') {
const unsubscribe = learnCard.onSyncComplete(async () => {
const readyContext = await learnCard.requestLearnerContext({
includeCredentials: true,
waitForSync: true,
format: 'structured',
});
unsubscribe();
console.log(readyContext.raw?.credentials);
});
}Use learnCard.getSyncStatus() to render your own progress UI:
const syncStatus = await learnCard.getSyncStatus();
console.log(syncStatus.status, syncStatus.progress);License
MIT
Contributing
Contributions are welcome! Please see the main LearnCard repository for contribution guidelines.
Support
For issues and questions:
- GitHub Issues: https://github.com/learningeconomy/LearnCard/issues
- Documentation: https://docs.learncard.com
