@learncard/network-plugin 2.13.16 → 2.14.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/src/plugin.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  VC,
15
15
  BitstringCredentialStatusEntry,
16
16
  BitstringCredentialStatusPurpose,
17
+ ManagedCredentialRefreshService,
17
18
  StoredCredentialEnvelope,
18
19
  StoredCredentialEnvelopeValidator,
19
20
  isStoredCredentialEnvelope,
@@ -34,6 +35,135 @@ import {
34
35
  TrustedBoostRegistryEntry,
35
36
  } from './types';
36
37
 
38
+ /**
39
+ * Trusted LearnCard domain suffixes for federation.
40
+ * Only dotted suffixes - the isLearnCardDomain check handles apex domains via
41
+ * `host === suffix.replace(/^\./, '')`. Bare entries would allow bypass
42
+ * (e.g., 'evillearncard.com'.endsWith('learncard.com') === true).
43
+ */
44
+ const LEARNCARD_DOMAIN_SUFFIXES = ['.learncard.com', '.learncard.app', '.learncard.ai'];
45
+
46
+ /**
47
+ * Configuration for federation URL validation.
48
+ */
49
+ export interface FederationConfig {
50
+ /**
51
+ * Explicit list of trusted hosts. If not provided, federation is open (any HTTPS host allowed).
52
+ * This is safe for client-side code where SSRF doesn't apply.
53
+ * Server-side code should provide an explicit allowlist.
54
+ */
55
+ trustedFederationHosts?: string[];
56
+ /**
57
+ * Allow localhost/127.0.0.1 for development.
58
+ * Auto-detected: defaults to true when serviceUrl is localhost, false otherwise.
59
+ */
60
+ allowLocalhostFederation?: boolean;
61
+ /**
62
+ * The origin of this plugin's service URL. When set, endpoints matching this origin
63
+ * skip validation (they're self-generated, not from external DID documents).
64
+ */
65
+ serviceOrigin?: string;
66
+ }
67
+
68
+ /**
69
+ * Checks if a hostname matches any LearnCard domain suffix.
70
+ */
71
+ const isLearnCardDomain = (host: string): boolean => {
72
+ return LEARNCARD_DOMAIN_SUFFIXES.some(
73
+ suffix => host === suffix.replace(/^\./, '') || host.endsWith(suffix)
74
+ );
75
+ };
76
+
77
+ /**
78
+ * Checks if a hostname is localhost or loopback.
79
+ */
80
+ const isLocalhostHost = (host: string): boolean => {
81
+ const lower = host.toLowerCase();
82
+ return lower === 'localhost' || lower === '127.0.0.1';
83
+ };
84
+
85
+ /**
86
+ * Validates and normalizes a federation URL.
87
+ *
88
+ * This runs in the CLIENT plugin where SSRF risk is lower (the browser's same-origin
89
+ * policy provides protection). By default, federation is open to any HTTPS host.
90
+ * Server-side code should provide an explicit trustedFederationHosts list.
91
+ *
92
+ * Note: For local-service recipients, getInboxEndpointForDid returns a fixed
93
+ * `/api/inbox/receive` path. Only external DID doc serviceEndpoints preserve
94
+ * their original path.
95
+ *
96
+ * @param userProvidedUrl - The inbox endpoint URL (from DID doc or local service)
97
+ * @param config - Federation configuration with trusted hosts
98
+ * @returns The validated URL
99
+ * @throws Error if the URL is invalid or host is not trusted
100
+ */
101
+ const validateFederationUrl = (userProvidedUrl: string, config: FederationConfig): string => {
102
+ let parsed: URL;
103
+ try {
104
+ parsed = new URL(userProvidedUrl);
105
+ } catch {
106
+ throw new Error(`Invalid federation endpoint URL: ${userProvidedUrl}`);
107
+ }
108
+
109
+ // If this endpoint matches our own service origin, skip validation entirely.
110
+ // These URLs are self-generated (e.g., from getInboxEndpointForDid when the recipient
111
+ // resolves to our local service), not from external DID documents.
112
+ if (config.serviceOrigin) {
113
+ const endpointOrigin = parsed.origin;
114
+ if (endpointOrigin === config.serviceOrigin) {
115
+ // Self-referential URL - return parsed.href so CodeQL sees URL sanitization
116
+ return parsed.href;
117
+ }
118
+ }
119
+
120
+ const host = parsed.hostname.toLowerCase();
121
+ const isLocalhost = isLocalhostHost(host);
122
+
123
+ // Handle localhost: check allowLocalhostFederation and convert https->http
124
+ if (isLocalhost) {
125
+ if (!config.allowLocalhostFederation) {
126
+ throw new Error(
127
+ `Localhost federation is disabled. Set allowLocalhostFederation: true for development.`
128
+ );
129
+ }
130
+ // Local dev typically doesn't have TLS - convert https to http
131
+ if (parsed.protocol === 'https:') {
132
+ parsed.protocol = 'http:';
133
+ }
134
+ // Return the validated/normalized URL
135
+ return parsed.href;
136
+ }
137
+
138
+ // Validate protocol - require HTTPS for non-localhost
139
+ if (parsed.protocol !== 'https:') {
140
+ throw new Error(`Federation requires HTTPS for non-localhost hosts: ${userProvidedUrl}`);
141
+ }
142
+
143
+ // If trustedFederationHosts is provided, use explicit allowlist mode
144
+ if (config.trustedFederationHosts !== undefined) {
145
+ const trustedHosts = new Set(config.trustedFederationHosts);
146
+ const allowAnyHost = trustedHosts.has('*');
147
+
148
+ // Check explicit list, LearnCard domains, or wildcard.
149
+ // Note: LearnCard domains are always trusted even with an explicit allowlist -
150
+ // this is intentional to ensure core federation always works.
151
+ if (!allowAnyHost && !isLearnCardDomain(host) && !trustedHosts.has(host)) {
152
+ throw new Error(
153
+ `Federation host '${host}' is not trusted. ` +
154
+ `Add it to trustedFederationHosts or use '*' to allow any host.`
155
+ );
156
+ }
157
+ }
158
+ // If trustedFederationHosts is not provided, allow any HTTPS host (client-side default)
159
+ // This enables third-party did:web federation without requiring explicit opt-in
160
+
161
+ // Return the validated URL - CodeQL: at this point the URL has been validated
162
+ // as either matching our service origin, being localhost with permission, or
163
+ // being HTTPS with a trusted/allowed host
164
+ return parsed.href;
165
+ };
166
+
37
167
  const uint8ArrayToBase64Url = (bytes: Uint8Array): string => {
38
168
  let binary = '';
39
169
  for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]!);
@@ -312,6 +442,52 @@ const issueCredentialWithNetworkStatus = async (
312
442
  );
313
443
  };
314
444
 
445
+ /**
446
+ * Inline JSON-LD context fragment required to sign credentials carrying a
447
+ * LearnCard-managed `LearnCardCredentialRefresh2026` refresh service.
448
+ *
449
+ * Neither VCDM 1.1 (which defines only `ManualRefreshService2018`), VCDM 2.0, nor the
450
+ * live OBv3 contexts define the term `LearnCardCredentialRefresh2026` (nor the LearnCard
451
+ * `authorization` / `LearnCardDIDAuth` extension terms), so DIDKit's data-loss
452
+ * detection refuses to sign unless issuers define these terms inline.
453
+ */
454
+ const MANAGED_REFRESH_SERVICE_CONTEXT = {
455
+ 'LearnCardCredentialRefresh2026':
456
+ 'https://learncard.com/refresh#LearnCardCredentialRefresh2026',
457
+ authorization: {
458
+ '@id': 'https://purl.imsglobal.org/spec/ob/v3p0#authorization',
459
+ '@context': {
460
+ LearnCardDIDAuth: 'https://docs.learncard.com/definitions#LearnCardDIDAuth',
461
+ },
462
+ },
463
+ };
464
+
465
+ /**
466
+ * Injects an allocated managed refresh service into an unsigned credential so the
467
+ * service becomes part of the signed payload. Appends the inline context fragment
468
+ * unless an equivalent mapping is already present.
469
+ */
470
+ const injectManagedRefreshService = (
471
+ credential: UnsignedVC,
472
+ refreshService: ManagedCredentialRefreshService
473
+ ): UnsignedVC => {
474
+ const existingContext = (credential as Record<string, unknown>)['@context'];
475
+ const contextList = Array.isArray(existingContext) ? existingContext : [existingContext];
476
+
477
+ const hasMapping = contextList.some(
478
+ entry =>
479
+ !!entry &&
480
+ typeof entry === 'object' &&
481
+ 'LearnCardCredentialRefresh2026' in (entry as Record<string, unknown>)
482
+ );
483
+
484
+ return {
485
+ ...credential,
486
+ '@context': hasMapping ? contextList : [...contextList, MANAGED_REFRESH_SERVICE_CONTEXT],
487
+ refreshService,
488
+ } as UnsignedVC;
489
+ };
490
+
315
491
  export * from './types';
316
492
 
317
493
  export type GuardianApprovalGetter = () => string | undefined | Promise<string | undefined>;
@@ -319,46 +495,52 @@ export type GuardianApprovalGetter = () => string | undefined | Promise<string |
319
495
  /**
320
496
  * @group Plugins
321
497
  */
498
+ /** Options for the LearnCard Network Plugin */
499
+ export interface LearnCardNetworkPluginOptions extends FederationConfig {
500
+ guardianApprovalGetter?: GuardianApprovalGetter;
501
+ extraHeaders?: Record<string, string>;
502
+ }
503
+
322
504
  export async function getLearnCardNetworkPlugin(
323
505
  learnCard: LearnCard<any, 'id', LearnCardNetworkPluginDependentMethods>,
324
506
  url: string,
325
- apiTokenOrOptions?: {
326
- guardianApprovalGetter?: GuardianApprovalGetter;
327
- extraHeaders?: Record<string, string>;
328
- }
507
+ apiTokenOrOptions?: LearnCardNetworkPluginOptions
329
508
  ): Promise<LearnCardNetworkPlugin>;
330
509
  export async function getLearnCardNetworkPlugin(
331
510
  learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>,
332
511
  url: string,
333
512
  apiToken: string,
334
- options?: {
335
- guardianApprovalGetter?: GuardianApprovalGetter;
336
- extraHeaders?: Record<string, string>;
337
- }
513
+ options?: LearnCardNetworkPluginOptions
338
514
  ): Promise<LearnCardNetworkPlugin>;
339
515
  export async function getLearnCardNetworkPlugin(
340
516
  learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>,
341
517
  url: string,
342
- apiTokenOrOptions?:
343
- | string
344
- | {
345
- guardianApprovalGetter?: GuardianApprovalGetter;
346
- extraHeaders?: Record<string, string>;
347
- },
348
- options?: {
349
- guardianApprovalGetter?: GuardianApprovalGetter;
350
- extraHeaders?: Record<string, string>;
351
- }
518
+ apiTokenOrOptions?: string | LearnCardNetworkPluginOptions,
519
+ options?: LearnCardNetworkPluginOptions
352
520
  ): Promise<LearnCardNetworkPlugin> {
353
521
  const apiToken = typeof apiTokenOrOptions === 'string' ? apiTokenOrOptions : undefined;
354
- const guardianApprovalGetter =
355
- (typeof apiTokenOrOptions === 'object'
356
- ? apiTokenOrOptions?.guardianApprovalGetter
357
- : undefined) ?? options?.guardianApprovalGetter;
358
-
359
- const extraHeaders =
360
- (typeof apiTokenOrOptions === 'object' ? apiTokenOrOptions?.extraHeaders : undefined) ??
361
- options?.extraHeaders;
522
+ const resolvedOptions: LearnCardNetworkPluginOptions =
523
+ (typeof apiTokenOrOptions === 'object' ? apiTokenOrOptions : options) ?? {};
524
+
525
+ const {
526
+ guardianApprovalGetter,
527
+ extraHeaders,
528
+ trustedFederationHosts,
529
+ allowLocalhostFederation,
530
+ } = resolvedOptions;
531
+
532
+ // Parse service URL to determine origin and auto-detect localhost
533
+ const serviceUrl = new URL(url);
534
+ const serviceIsLocalhost = isLocalhostHost(serviceUrl.hostname);
535
+
536
+ // Federation config for URL validation
537
+ // - serviceOrigin: skip validation for self-referential URLs
538
+ // - allowLocalhostFederation: auto-detect from service URL if not explicitly set
539
+ const federationConfig: FederationConfig = {
540
+ trustedFederationHosts,
541
+ allowLocalhostFederation: allowLocalhostFederation ?? serviceIsLocalhost,
542
+ serviceOrigin: serviceUrl.origin,
543
+ };
362
544
  // Initialize DID safely: in API-key mode there may be no local ID plane provider
363
545
  let did = '';
364
546
  try {
@@ -713,7 +895,11 @@ export async function getLearnCardNetworkPlugin(
713
895
  return client.profileManager.updateProfileManager.mutate(manager);
714
896
  },
715
897
  deleteProfile: async () => {
716
- if (!userData) throw new Error('Account does not exist!');
898
+ // Settle initialization before deletion so it cannot repopulate userData later.
899
+ await initialQuery;
900
+ // API tokens may grant profiles:delete without profiles:read. The server
901
+ // validates the profile and scope; a local read must not gate deletion.
902
+ if (!apiToken) await ensureUser();
717
903
 
718
904
  const result = await client.profile.deleteProfile.mutate();
719
905
 
@@ -910,13 +1096,14 @@ export async function getLearnCardNetworkPlugin(
910
1096
  challenge: `inbox-federation-${crypto.randomUUID()}`,
911
1097
  });
912
1098
 
913
- let receiveUrl = inboxEndpoint;
914
-
915
- if (receiveUrl.includes('localhost')) {
916
- receiveUrl = receiveUrl.replace('https://', 'http://');
917
- }
1099
+ // Validate the federation URL. For external DIDs, inboxEndpoint comes
1100
+ // from the DID doc's serviceEndpoint; for local-service DIDs, it's
1101
+ // a fixed /api/inbox/receive path from getInboxEndpointForDid.
1102
+ const receiveUrl = validateFederationUrl(inboxEndpoint, federationConfig);
918
1103
 
919
1104
  const response = await fetch(receiveUrl, {
1105
+ // A redirect must not bypass endpoint validation or forward credentials.
1106
+ redirect: 'error',
920
1107
  method: 'POST',
921
1108
  headers: {
922
1109
  'Content-Type': 'application/json',
@@ -1369,6 +1556,37 @@ export async function getLearnCardNetworkPlugin(
1369
1556
 
1370
1557
  return client.boost.allocateCredentialStatus.mutate(options);
1371
1558
  },
1559
+ allocateCredentialRefresh: async (_learnCard, input) => {
1560
+ await ensureUser();
1561
+
1562
+ return client.credentialRefresh.allocateCredentialRefresh.mutate(input);
1563
+ },
1564
+ sendRefreshableCredential: async (
1565
+ _learnCard,
1566
+ refreshId,
1567
+ credential,
1568
+ boostUri,
1569
+ skipNotification
1570
+ ) => {
1571
+ await ensureUser();
1572
+
1573
+ return client.credentialRefresh.sendRefreshableCredential.mutate({
1574
+ refreshId,
1575
+ credential,
1576
+ boostUri,
1577
+ ...(skipNotification ? { skipNotification: true } : {}),
1578
+ });
1579
+ },
1580
+ publishCredentialRefresh: async (_learnCard, input) => {
1581
+ await ensureUser();
1582
+
1583
+ return client.credentialRefresh.publishCredentialRefresh.mutate(input);
1584
+ },
1585
+ getCredentialRefreshHistory: async (_learnCard, input) => {
1586
+ await ensureUser();
1587
+
1588
+ return client.credentialRefresh.getCredentialRefreshHistory.query(input);
1589
+ },
1372
1590
  revokeBoostRecipient: async (
1373
1591
  _learnCard,
1374
1592
  boostUri,
@@ -1483,6 +1701,27 @@ export async function getLearnCardNetworkPlugin(
1483
1701
  boost = options.overideFn(boost);
1484
1702
  }
1485
1703
 
1704
+ const enableRefresh = typeof options === 'object' && options.enableRefresh === true;
1705
+
1706
+ let managedRefreshId: string | undefined;
1707
+
1708
+ if (enableRefresh) {
1709
+ // Generate a stable UUID credential ID when the template has none;
1710
+ // the allocation is permanently bound to this ID.
1711
+ if (!boost.id) boost.id = `urn:uuid:${crypto.randomUUID()}`;
1712
+
1713
+ // Allocate BEFORE signing: the refresh service must be part of the
1714
+ // signed payload, so it is injected before proof creation.
1715
+ const allocation =
1716
+ await client.credentialRefresh.allocateCredentialRefresh.mutate({
1717
+ holder: { profileId, did: targetProfile.did },
1718
+ credentialId: boost.id,
1719
+ });
1720
+
1721
+ managedRefreshId = allocation.refreshId;
1722
+ boost = injectManagedRefreshService(boost, allocation.refreshService);
1723
+ }
1724
+
1486
1725
  const statusPurposes =
1487
1726
  typeof options === 'object' ? options.statusPurposes : undefined;
1488
1727
  const vc = await issueCredentialWithNetworkStatus(
@@ -1492,6 +1731,22 @@ export async function getLearnCardNetworkPlugin(
1492
1731
  statusPurposes
1493
1732
  );
1494
1733
 
1734
+ if (managedRefreshId) {
1735
+ // Dedicated managed send: brain-service verifies the proof and
1736
+ // persists ONLY a holder-encrypted JWE. Legacy credential storage
1737
+ // (issuer/LCN-readable JWE or plaintext) is intentionally bypassed.
1738
+ // The boost URI is forwarded so the credential stays linked
1739
+ // INSTANCE_OF the boost for canonical recipient management.
1740
+ return client.credentialRefresh.sendRefreshableCredential.mutate({
1741
+ refreshId: managedRefreshId,
1742
+ credential: vc,
1743
+ boostUri,
1744
+ ...(typeof options === 'object' && options.skipNotification
1745
+ ? { skipNotification: true }
1746
+ : {}),
1747
+ });
1748
+ }
1749
+
1495
1750
  // options is allowed to be a boolean to maintain backwards compatibility
1496
1751
  if ((typeof options === 'object' && !options.encrypt) || !options) {
1497
1752
  return client.boost.sendBoost.mutate({
@@ -2153,6 +2408,23 @@ export async function getLearnCardNetworkPlugin(
2153
2408
 
2154
2409
  return client.inbox.finalize.mutate();
2155
2410
  },
2411
+ recoverInboxCredentials: async (learnCard, options = {}) => {
2412
+ const result = await client.inbox.getMyInboxDeliveries.query(options);
2413
+ const results = await Promise.allSettled(
2414
+ result.records.map(async record => ({
2415
+ ...record,
2416
+ credential: VCValidator.parse(
2417
+ await learnCard.invoke.decryptDagJwe(record.credential, [
2418
+ learnCard.id.keypair(),
2419
+ ])
2420
+ ),
2421
+ }))
2422
+ );
2423
+ const records = results.flatMap(record =>
2424
+ record.status === 'fulfilled' ? [record.value] : []
2425
+ );
2426
+ return { ...result, records, failed: results.length - records.length };
2427
+ },
2156
2428
  sendGuardianApprovalEmail: async (_learnCard, options) => {
2157
2429
  await ensureUser();
2158
2430