@learncard/partner-connect 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Learning Economy Foundation <sdk@learningeconomy.io>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,534 @@
1
+ # @learncard/partner-connect
2
+
3
+ > Promise-based JavaScript SDK for managing cross-origin messaging between partner apps and LearnCard
4
+
5
+ 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.
6
+
7
+ ## Features
8
+
9
+ - ๐Ÿ”’ **Secure**: Origin validation for all messages
10
+ - ๐ŸŽฏ **Type-safe**: Full TypeScript support with comprehensive types
11
+ - โšก **Promise-based**: Modern async/await API
12
+ - ๐Ÿงน **Clean**: Abstracts away all postMessage complexity
13
+ - ๐Ÿ“ฆ **Lightweight**: Zero dependencies
14
+ - ๐Ÿ›ก๏ธ **Robust**: Built-in timeout handling and error management
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @learncard/partner-connect
20
+ ```
21
+
22
+ ```bash
23
+ pnpm add @learncard/partner-connect
24
+ ```
25
+
26
+ ```bash
27
+ yarn add @learncard/partner-connect
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```typescript
33
+ import { createPartnerConnect } from '@learncard/partner-connect';
34
+
35
+ // Initialize the SDK
36
+ const learnCard = createPartnerConnect({
37
+ hostOrigin: 'https://learncard.app'
38
+ });
39
+
40
+ // Request user identity (SSO)
41
+ try {
42
+ const identity = await learnCard.requestIdentity();
43
+ console.log('User DID:', identity.user.did);
44
+ console.log('JWT Token:', identity.token);
45
+ } catch (error) {
46
+ if (error.code === 'LC_UNAUTHENTICATED') {
47
+ console.log('User is not logged in');
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ ### Options
55
+
56
+ ```typescript
57
+ interface PartnerConnectOptions {
58
+ /**
59
+ * The origin of the LearnCard host (e.g., 'https://learncard.app')
60
+ * All messages will be validated against this origin for security
61
+ * (default: 'https://learncard.app')
62
+ */
63
+ hostOrigin?: string;
64
+
65
+ /**
66
+ * Protocol identifier (default: 'LEARNCARD_V1')
67
+ */
68
+ protocol?: string;
69
+
70
+ /**
71
+ * Request timeout in milliseconds (default: 30000)
72
+ */
73
+ requestTimeout?: number;
74
+ }
75
+ ```
76
+
77
+ ### Dynamic Origin Configuration
78
+
79
+ The SDK uses a hierarchical approach to determine the active host origin:
80
+
81
+ #### 1. **Hardcoded Default** (Security Anchor)
82
+ ```typescript
83
+ PartnerConnect.DEFAULT_HOST_ORIGIN // 'https://learncard.app'
84
+ ```
85
+
86
+ #### 2. **Query Parameter Override** (Staging/Testing)
87
+ ```typescript
88
+ // Your app URL: https://partner-app.com/?lc_host_override=https://staging.learncard.app
89
+
90
+ const learnCard = createPartnerConnect({
91
+ hostOrigin: ['https://learncard.app', 'https://staging.learncard.app']
92
+ });
93
+ // Active origin: https://staging.learncard.app (from query param)
94
+ // โœ… Only accepts messages from: https://staging.learncard.app
95
+ // โœ… Sends messages to: https://staging.learncard.app
96
+ ```
97
+
98
+ **How the LearnCard Host Uses This:**
99
+ - Production: Iframe URL has no `lc_host_override` parameter
100
+ - Staging: Iframe URL includes `?lc_host_override=https://staging.learncard.app`
101
+ - This allows testing against non-production environments without recompiling partner code
102
+
103
+ #### 3. **Configured Origin** (Fallback)
104
+ ```typescript
105
+ const learnCard = createPartnerConnect({
106
+ hostOrigin: 'https://learncard.app'
107
+ });
108
+ // Active origin: https://learncard.app (configured)
109
+ ```
110
+
111
+ ### Origin Whitelist (Security Gate)
112
+
113
+ When providing multiple origins, they serve as a **whitelist** for the `lc_host_override` parameter:
114
+
115
+ ```typescript
116
+ const learnCard = createPartnerConnect({
117
+ hostOrigin: [
118
+ 'https://learncard.app',
119
+ 'https://staging.learncard.app',
120
+ 'https://preview.learncard.app'
121
+ ]
122
+ });
123
+
124
+ // Scenario 1: No query param
125
+ // โ†’ Uses: https://learncard.app (first in array)
126
+
127
+ // Scenario 2: Valid override
128
+ // URL: ?lc_host_override=https://staging.learncard.app
129
+ // โ†’ Uses: https://staging.learncard.app โœ…
130
+
131
+ // Scenario 3: Invalid override (not in whitelist)
132
+ // URL: ?lc_host_override=https://evil.com
133
+ // โ†’ Uses: https://learncard.app (falls back to first) โš ๏ธ
134
+ // โ†’ Logs warning about unauthorized override
135
+ ```
136
+
137
+ ### Security Model
138
+
139
+ **STRICT Origin Validation:**
140
+ ```
141
+ Incoming Message Origin โ‰ก Configured Host Origin
142
+ ```
143
+
144
+ The SDK enforces an exact match between incoming message origins and the active host origin:
145
+
146
+ - โœ… **Secure**: Even if a malicious actor adds `?lc_host_override=https://evil.com`, messages from `evil.com` will be rejected
147
+ - โœ… **Cannot be spoofed**: Browser security prevents malicious sites from faking their `event.origin`
148
+ - โœ… **No wildcards**: Only exact matches are accepted
149
+
150
+ ```typescript
151
+ // Active origin: https://staging.learncard.app
152
+ // โœ… Accepts: messages from https://staging.learncard.app
153
+ // โŒ Rejects: messages from https://learncard.app
154
+ // โŒ Rejects: messages from https://evil.com
155
+ // โŒ Rejects: messages from any other origin
156
+ ```
157
+
158
+ ## API Reference
159
+
160
+ ### `requestIdentity()`
161
+
162
+ Request user identity information (Single Sign-On).
163
+
164
+ ```typescript
165
+ const identity = await learnCard.requestIdentity();
166
+ // Returns: { token: string, user: { did: string, ... } }
167
+ ```
168
+
169
+ **Error Codes:**
170
+ - `LC_UNAUTHENTICATED`: User is not logged in to LearnCard
171
+ - `LC_TIMEOUT`: Request timed out
172
+
173
+ ---
174
+
175
+ ### `sendCredential(credential)`
176
+
177
+ Send a verifiable credential to the user's LearnCard wallet.
178
+
179
+ ```typescript
180
+ const response = await learnCard.sendCredential({
181
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
182
+ type: ['VerifiableCredential', 'AchievementCredential'],
183
+ credentialSubject: {
184
+ id: identity.user.did,
185
+ achievement: {
186
+ name: 'JavaScript Expert',
187
+ description: 'Mastered advanced JavaScript concepts'
188
+ }
189
+ }
190
+ });
191
+
192
+ console.log('Credential ID:', response.credentialId);
193
+ ```
194
+
195
+ **Returns:** `{ credentialId: string }`
196
+
197
+ ---
198
+
199
+ ### `launchFeature(featurePath, initialPrompt?)`
200
+
201
+ Launch a feature in the LearnCard host application.
202
+
203
+ ```typescript
204
+ await learnCard.launchFeature(
205
+ '/ai/topics?shortCircuitStep=newTopic&selectedAppId=null',
206
+ 'Explain the postMessage security model'
207
+ );
208
+ ```
209
+
210
+ **Parameters:**
211
+ - `featurePath`: Path to the feature
212
+ - `initialPrompt`: Optional initial data or prompt
213
+
214
+ ---
215
+
216
+ ### `askCredentialSearch(verifiablePresentationRequest)`
217
+
218
+ Request credentials from the user's wallet using a Verifiable Presentation Request.
219
+
220
+ ```typescript
221
+ const response = await learnCard.askCredentialSearch({
222
+ query: [
223
+ {
224
+ type: 'QueryByTitle',
225
+ credentialQuery: {
226
+ reason: 'We need to verify your teamwork skills',
227
+ title: 'Capstone'
228
+ }
229
+ }
230
+ ],
231
+ challenge: `challenge-${Date.now()}`,
232
+ domain: window.location.hostname
233
+ });
234
+
235
+ if (response.verifiablePresentation) {
236
+ const credentials = response.verifiablePresentation.verifiableCredential;
237
+ console.log(`Received ${credentials.length} credential(s)`);
238
+ }
239
+ ```
240
+
241
+ **Returns:** `{ verifiablePresentation?: { verifiableCredential: unknown[], ... } }`
242
+
243
+ ---
244
+
245
+ ### `askCredentialSpecific(credentialId)`
246
+
247
+ Request a specific credential by ID.
248
+
249
+ ```typescript
250
+ const response = await learnCard.askCredentialSpecific('credential-id-123');
251
+
252
+ if (response.credential) {
253
+ console.log('Received credential:', response.credential);
254
+ }
255
+ ```
256
+
257
+ **Error Codes:**
258
+ - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
259
+ - `USER_REJECTED`: User declined to share
260
+
261
+ ---
262
+
263
+ ### `requestConsent(contractUri)`
264
+
265
+ Request user consent for permissions.
266
+
267
+ ```typescript
268
+ const response = await learnCard.requestConsent(
269
+ 'lc:network:network.learncard.com/trpc:contract:abc123'
270
+ );
271
+
272
+ if (response.granted) {
273
+ console.log('User granted consent');
274
+ } else {
275
+ console.log('User denied consent');
276
+ }
277
+ ```
278
+
279
+ **Returns:** `{ granted: boolean }`
280
+
281
+ ---
282
+
283
+ ### `initiateTemplateIssue(templateId, draftRecipients?)`
284
+
285
+ Initiate a template-based credential issuance flow (e.g., Send Boost).
286
+
287
+ ```typescript
288
+ const response = await learnCard.initiateTemplateIssue(
289
+ 'lc:network:network.learncard.com/trpc:boost:xyz789',
290
+ ['did:key:z6Mkr...', 'did:key:z6Mks...']
291
+ );
292
+
293
+ if (response.issued) {
294
+ console.log('Template issued successfully');
295
+ }
296
+ ```
297
+
298
+ **Error Codes:**
299
+ - `UNAUTHORIZED`: Not an admin of this template
300
+ - `TEMPLATE_NOT_FOUND`: Template doesn't exist
301
+
302
+ ---
303
+
304
+ ### `destroy()`
305
+
306
+ Clean up the SDK and remove event listeners. Call this when unmounting your component or closing your app.
307
+
308
+ ```typescript
309
+ learnCard.destroy();
310
+ ```
311
+
312
+ ## Complete Example
313
+
314
+ Here's a complete example showing how to refactor a manual postMessage implementation to use the SDK:
315
+
316
+ ### Before (Manual postMessage)
317
+
318
+ ```typescript
319
+ // Manual setup - verbose and error-prone
320
+ const LEARNCARD_HOST_ORIGIN = 'https://learncard.app';
321
+ const PROTOCOL = 'LEARNCARD_V1';
322
+ const pendingRequests = new Map();
323
+
324
+ function sendPostMessage(action, payload = {}) {
325
+ return new Promise((resolve, reject) => {
326
+ const requestId = `${action}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
327
+ pendingRequests.set(requestId, { resolve, reject });
328
+
329
+ window.parent.postMessage({
330
+ protocol: PROTOCOL,
331
+ action,
332
+ requestId,
333
+ payload,
334
+ }, LEARNCARD_HOST_ORIGIN);
335
+
336
+ setTimeout(() => {
337
+ if (pendingRequests.has(requestId)) {
338
+ pendingRequests.delete(requestId);
339
+ reject({ code: 'LC_TIMEOUT', message: 'Request timed out' });
340
+ }
341
+ }, 30000);
342
+ });
343
+ }
344
+
345
+ window.addEventListener('message', (event) => {
346
+ if (event.origin !== LEARNCARD_HOST_ORIGIN) return;
347
+ const { protocol, requestId, type, data, error } = event.data;
348
+ if (protocol !== PROTOCOL || !requestId) return;
349
+
350
+ const pending = pendingRequests.get(requestId);
351
+ if (!pending) return;
352
+
353
+ pendingRequests.delete(requestId);
354
+ if (type === 'SUCCESS') {
355
+ pending.resolve(data);
356
+ } else if (type === 'ERROR') {
357
+ pending.reject(error);
358
+ }
359
+ });
360
+
361
+ // Usage
362
+ const identity = await sendPostMessage('REQUEST_IDENTITY');
363
+ ```
364
+
365
+ ### After (With SDK)
366
+
367
+ ```typescript
368
+ import { createPartnerConnect } from '@learncard/partner-connect';
369
+
370
+ // Clean, one-line setup
371
+ const learnCard = createPartnerConnect({
372
+ hostOrigin: 'https://learncard.app'
373
+ });
374
+
375
+ // Usage - same result, much cleaner
376
+ const identity = await learnCard.requestIdentity();
377
+ ```
378
+
379
+ ## Error Handling
380
+
381
+ All methods return Promises that reject with a `LearnCardError` object:
382
+
383
+ ```typescript
384
+ interface LearnCardError {
385
+ code: string;
386
+ message: string;
387
+ }
388
+ ```
389
+
390
+ **Common Error Codes:**
391
+ - `LC_TIMEOUT`: Request timed out
392
+ - `LC_UNAUTHENTICATED`: User not logged in
393
+ - `USER_REJECTED`: User declined the request
394
+ - `CREDENTIAL_NOT_FOUND`: Credential doesn't exist
395
+ - `UNAUTHORIZED`: User lacks permission
396
+ - `TEMPLATE_NOT_FOUND`: Template doesn't exist
397
+ - `SDK_NOT_INITIALIZED`: SDK initialization failed
398
+ - `SDK_DESTROYED`: SDK was destroyed before completion
399
+
400
+ **Example:**
401
+
402
+ ```typescript
403
+ try {
404
+ const identity = await learnCard.requestIdentity();
405
+ // Success
406
+ } catch (error) {
407
+ switch (error.code) {
408
+ case 'LC_UNAUTHENTICATED':
409
+ console.log('Please log in to your LearnCard account');
410
+ break;
411
+ case 'LC_TIMEOUT':
412
+ console.log('Request timed out. Please try again.');
413
+ break;
414
+ default:
415
+ console.error('An error occurred:', error.message);
416
+ }
417
+ }
418
+ ```
419
+
420
+ ## Integration with Astro
421
+
422
+ ```astro
423
+ ---
424
+ // src/pages/index.astro
425
+ const config = {
426
+ learnCardHostOrigin: import.meta.env.PUBLIC_LEARNCARD_HOST || 'https://learncard.app'
427
+ };
428
+ ---
429
+
430
+ <script>
431
+ import { createPartnerConnect } from '@learncard/partner-connect';
432
+
433
+ const config = window.__LC_CONFIG;
434
+ const learnCard = createPartnerConnect({
435
+ hostOrigin: config.learnCardHostOrigin
436
+ });
437
+
438
+ async function init() {
439
+ try {
440
+ const identity = await learnCard.requestIdentity();
441
+ console.log('Logged in as:', identity.user.did);
442
+ } catch (error) {
443
+ console.error('Not authenticated:', error);
444
+ }
445
+ }
446
+
447
+ init();
448
+ </script>
449
+ ```
450
+
451
+ ## Browser Support
452
+
453
+ - Chrome/Edge 90+
454
+ - Firefox 88+
455
+ - Safari 14+
456
+
457
+ Requires `postMessage` API and `Promise` support.
458
+
459
+ ## Security
460
+
461
+ The SDK implements multiple security layers:
462
+
463
+ ### 1. **Strict Origin Validation**
464
+ - Messages must come from the **exact** active host origin
465
+ - No wildcards, no pattern matching, no exceptions
466
+ - Mathematical equivalence: `event.origin === activeHostOrigin`
467
+
468
+ ### 2. **Query Parameter Whitelist**
469
+ - `lc_host_override` values are validated against configured `hostOrigin` array
470
+ - Invalid overrides are rejected and logged
471
+ - Falls back to first configured origin on validation failure
472
+
473
+ ### 3. **Anti-Spoofing Protection**
474
+ Even if a malicious actor injects `?lc_host_override=https://evil.com`:
475
+ - The SDK may adopt `evil.com` as the active origin (if not whitelisted)
476
+ - **BUT** messages from `evil.com` will only be accepted if `event.origin === 'evil.com'`
477
+ - Browser security prevents `evil.com` from spoofing another domain's origin
478
+ - Malicious messages are silently rejected
479
+
480
+ ### 4. **Additional Security Layers**
481
+ - **Protocol Validation**: Messages must match the expected protocol identifier
482
+ - **Request ID Tracking**: Only tracked requests with valid IDs are processed
483
+ - **Timeout Protection**: Requests automatically timeout to prevent hanging
484
+ - **Explicit targetOrigin**: Never uses `'*'` in postMessage calls
485
+
486
+ ### Example Attack Scenario (Prevented)
487
+
488
+ ```typescript
489
+ // Attacker adds malicious query param
490
+ // URL: https://partner-app.com/?lc_host_override=https://evil.com
491
+
492
+ // SDK configuration
493
+ const learnCard = createPartnerConnect({
494
+ hostOrigin: ['https://learncard.app', 'https://staging.learncard.app']
495
+ });
496
+
497
+ // What happens:
498
+ // 1. SDK detects lc_host_override=https://evil.com
499
+ // 2. Validates against whitelist: NOT FOUND
500
+ // 3. Falls back to: https://learncard.app
501
+ // 4. Sends messages to: https://learncard.app
502
+ // 5. Only accepts messages from: https://learncard.app
503
+ // 6. Attacker's messages from evil.com: REJECTED โŒ
504
+ ```
505
+
506
+ ## TypeScript
507
+
508
+ The SDK is written in TypeScript and includes comprehensive type definitions:
509
+
510
+ ```typescript
511
+ import type {
512
+ PartnerConnectOptions,
513
+ IdentityResponse,
514
+ SendCredentialResponse,
515
+ VerifiablePresentationRequest,
516
+ CredentialSearchResponse,
517
+ ConsentResponse,
518
+ LearnCardError,
519
+ } from '@learncard/partner-connect';
520
+ ```
521
+
522
+ ## License
523
+
524
+ MIT
525
+
526
+ ## Contributing
527
+
528
+ Contributions are welcome! Please see the [main LearnCard repository](https://github.com/learningeconomy/LearnCard) for contribution guidelines.
529
+
530
+ ## Support
531
+
532
+ For issues and questions:
533
+ - GitHub Issues: https://github.com/learningeconomy/LearnCard/issues
534
+ - Documentation: https://docs.learncard.com