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.4.2

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 on a local dev host; '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;
}

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. Whenever no LearnCard host is present and your app runs on a local dev host (localhost, 127.0.0.1, [::1], *.localhost, *.local) — plain local dev or a local Storybook — the SDK simulates the host locally:

  • 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 coins10.", launchFeature"Would open /wallet." So you get strong, visible feedback for every call, not just console logs.
  • requestConsent(...) auto-grants and shows a "mock consent" toast; incrementCounter / getCounter / getCounters persist to localStorage so values survive reloads.
  • Identical or polled calls coalesce into a single toast 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 hosts. A standalone page on a production or remote preview origin never auto-mocks — otherwise a real user opening your app's URL directly would receive a fabricated identity and auto-granted consent. For remote deploy previews (Netlify, Lovable, 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 | Standalone, local dev | Standalone, remote 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 on local dev hosts 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.');
}

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)

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);
}

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)

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');
}

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