npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-connect
pnpm add @learncard/partner-connect
yarn add @learncard/partner-connect

Quick 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:3000

getPublishUrl(), 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 accept postMessage traffic 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:3000

3. 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.app

How the LearnCard Host Uses This:

  • Production: Iframe URL has no lc_host_override parameter
  • 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 override

Security Model

STRICT Origin Validation:

Incoming Message Origin ≡ Configured Host Origin

The 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 from evil.com will 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 origin

Standalone / 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 / getCounters persist to localStorage so 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 URLs

Coherent 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 it

This 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(); // instance

isEmbedded() 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 LearnCard
  • LC_TIMEOUT: Request timed out
  • LC_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, or null while 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() returns undefined when you're not in mock mode or nothing has been captured yet.
  • getPublishUrl() returns undefined until 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 substituted

You 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 title
  • body (optional): Notification body text
  • 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.
  • 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-notification app event. For server-to-server notifications to arbitrary users, use the POST /app-store/listing/{listingId}/notify brain-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); // 5

Parameters:

  • 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 as incrementCounter)

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 feature
  • initialPrompt: 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 exist
  • USER_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 template
  • TEMPLATE_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 out
  • LC_NOT_EMBEDDED: Not embedded in a LearnCard host (standalone, not mocking)
  • LC_UNAUTHENTICATED: User not logged in
  • USER_REJECTED: User declined the request
  • CREDENTIAL_NOT_FOUND: Credential doesn't exist
  • UNAUTHORIZED: User lacks permission
  • TEMPLATE_NOT_FOUND: Template doesn't exist
  • SDK_NOT_INITIALIZED: SDK initialization failed
  • SDK_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_override values are validated against configured hostOrigin array
  • 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.com as the active origin (if not whitelisted)
  • BUT messages from evil.com will only be accepted if event.origin === 'evil.com'
  • Browser security prevents evil.com from 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