@learncard/network-plugin 2.14.1 → 3.0.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
@@ -1,3 +1,4 @@
1
+ import { waitForInboxCredentialBatch } from './inbox-batch';
1
2
  import { getClient, getApiTokenClient } from '@learncard/network-brain-client';
2
3
  import {
3
4
  JWEValidator,
@@ -14,15 +15,20 @@ import {
14
15
  VC,
15
16
  BitstringCredentialStatusEntry,
16
17
  BitstringCredentialStatusPurpose,
18
+ AllocateCredentialRefreshResult,
19
+ ManagedCredentialRefreshReceipt,
17
20
  ManagedCredentialRefreshService,
18
21
  StoredCredentialEnvelope,
19
22
  StoredCredentialEnvelopeValidator,
20
23
  isStoredCredentialEnvelope,
24
+ ACT_AS_HEADER,
21
25
  } from '@learncard/types';
22
26
  import { LearnCard } from '@learncard/core';
23
27
  import { VerifyExtension } from '@learncard/vc-plugin';
24
28
  import {
29
+ getCredentialIssuerId,
25
30
  getCredentialStatusArray,
31
+ injectManagedRefreshService as injectSharedManagedRefreshService,
26
32
  isVC2Format,
27
33
  resolveStorageReadResult,
28
34
  } from '@learncard/helpers';
@@ -443,55 +449,102 @@ const issueCredentialWithNetworkStatus = async (
443
449
  };
444
450
 
445
451
  /**
446
- * Inline JSON-LD context fragment required to sign credentials carrying a
447
- * LearnCard-managed `LearnCardCredentialRefresh2026` refresh service.
452
+ * Injects an allocated managed refresh service into an unsigned credential so the
453
+ * service (and its required inline JSON-LD context) becomes part of the signed
454
+ * payload.
448
455
  *
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.
456
+ * Thin re-export of the shared {@link injectSharedManagedRefreshService} helper
457
+ * (LC-2198 Task 1): validates the service, rejects a second/different managed
458
+ * service, and prepares the inline context idempotently — replacing this plugin's
459
+ * former private copy that could silently overwrite an existing service and only
460
+ * checked context presence by key existence.
453
461
  */
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
- },
462
+ const injectManagedRefreshService = (
463
+ credential: UnsignedVC,
464
+ refreshService: ManagedCredentialRefreshService
465
+ ): UnsignedVC => injectSharedManagedRefreshService(credential, refreshService);
466
+
467
+ const getCredentialSubjectIds = (vc: VC): string[] => {
468
+ const subject = (vc as Record<string, unknown>).credentialSubject;
469
+ const subjects = Array.isArray(subject) ? subject : subject ? [subject] : [];
470
+
471
+ return subjects.flatMap(entry => {
472
+ const id = (entry as Record<string, unknown> | undefined)?.id;
473
+
474
+ return typeof id === 'string' && id.length > 0 ? [id] : [];
475
+ });
463
476
  };
464
477
 
465
478
  /**
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.
479
+ * Builds the metadata-only managed-refresh issuance receipt (LC-2198 decision 2)
480
+ * from the actual signed version-1 credential plus the allocation used to send it.
481
+ *
482
+ * Deliberately excludes all credential claims, subject bodies, plaintext VC
483
+ * content, and JWE payloads: the issuer keeps its own template/claims and retains
484
+ * this receipt to publish future versions (including the exact `credentialStatus`
485
+ * descriptor, so publishing never allocates another one).
469
486
  */
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
- );
487
+ const buildManagedRefreshReceipt = (
488
+ vc: VC,
489
+ allocation: AllocateCredentialRefreshResult,
490
+ credentialId: string,
491
+ fallbackHolderDid: string,
492
+ fallbackIssuerDid: string
493
+ ): ManagedCredentialRefreshReceipt => {
494
+ const issuerDid = getCredentialIssuerId(vc) ?? fallbackIssuerDid;
495
+ const holderDid = getCredentialSubjectIds(vc)[0] ?? fallbackHolderDid;
496
+ const rawStatus = (vc as Record<string, unknown>).credentialStatus;
497
+
498
+ // Skip empty status collections (e.g. a VC2 credential whose status allocation
499
+ // returned no entries) — only a real descriptor round-trips for publication.
500
+ const hasStatus =
501
+ rawStatus !== undefined &&
502
+ rawStatus !== null &&
503
+ !(Array.isArray(rawStatus) && rawStatus.length === 0);
483
504
 
484
505
  return {
485
- ...credential,
486
- '@context': hasMapping ? contextList : [...contextList, MANAGED_REFRESH_SERVICE_CONTEXT],
487
- refreshService,
488
- } as UnsignedVC;
506
+ refreshId: allocation.refreshId,
507
+ refreshService: allocation.refreshService,
508
+ credentialId: typeof vc.id === 'string' && vc.id.length > 0 ? vc.id : credentialId,
509
+ issuerDid,
510
+ holderDid,
511
+ ...(hasStatus
512
+ ? {
513
+ credentialStatus:
514
+ rawStatus as ManagedCredentialRefreshReceipt['credentialStatus'],
515
+ }
516
+ : {}),
517
+ };
489
518
  };
490
519
 
491
520
  export * from './types';
492
521
 
493
522
  export type GuardianApprovalGetter = () => string | undefined | Promise<string | undefined>;
494
523
 
524
+ export { ACT_AS_HEADER };
525
+
526
+ // Must match the message resolveActAs throws for a missing target
527
+ // (services/learn-card-network/brain-service/src/routes/index.ts).
528
+ const ACT_AS_TARGET_NOT_FOUND_MESSAGE = 'Act-as target profile not found';
529
+
530
+ /**
531
+ * True only for errors the server's act-as resolver throws when denying the delegation
532
+ * itself (FORBIDDEN: not a manager / policy denies; BAD_REQUEST: malformed header;
533
+ * NOT_FOUND: the target profile). Any other error — including a NOT_FOUND from elsewhere —
534
+ * is unrelated and must surface unchanged.
535
+ */
536
+ const isActAsDenial = (error: unknown): boolean => {
537
+ const code = (error as { data?: { code?: string } } | undefined)?.data?.code;
538
+ if (code === 'FORBIDDEN' || code === 'BAD_REQUEST') return true;
539
+ if (code === 'NOT_FOUND') {
540
+ return (
541
+ (error as { message?: unknown } | undefined)?.message ===
542
+ ACT_AS_TARGET_NOT_FOUND_MESSAGE
543
+ );
544
+ }
545
+ return false;
546
+ };
547
+
495
548
  /**
496
549
  * @group Plugins
497
550
  */
@@ -499,6 +552,15 @@ export type GuardianApprovalGetter = () => string | undefined | Promise<string |
499
552
  export interface LearnCardNetworkPluginOptions extends FederationConfig {
500
553
  guardianApprovalGetter?: GuardianApprovalGetter;
501
554
  extraHeaders?: Record<string, string>;
555
+ /**
556
+ * Profile ID (or managed `did:web`) to act as for every request made by this plugin
557
+ * instance. Sent as the `X-LearnCard-Act-As` header ({@link ACT_AS_HEADER}). The server
558
+ * swaps the acting profile for the duration of the request without changing token scope;
559
+ * it responds `403` if the caller doesn't manage the target profile, or if an API token's
560
+ * grant doesn't allow acting as it. Works for both API-token and seed/DID-auth clients,
561
+ * since seed-based users may also manage — and act as — other profiles.
562
+ */
563
+ actAs?: string;
502
564
  }
503
565
 
504
566
  export async function getLearnCardNetworkPlugin(
@@ -527,8 +589,13 @@ export async function getLearnCardNetworkPlugin(
527
589
  extraHeaders,
528
590
  trustedFederationHosts,
529
591
  allowLocalhostFederation,
592
+ actAs,
530
593
  } = resolvedOptions;
531
594
 
595
+ // Only allocate a new headers object when actAs is actually set, so behavior/identity
596
+ // of `extraHeaders` is unchanged for existing callers that don't use actAs.
597
+ const mergedExtraHeaders = actAs ? { ...extraHeaders, [ACT_AS_HEADER]: actAs } : extraHeaders;
598
+
532
599
  // Parse service URL to determine origin and auto-detect localhost
533
600
  const serviceUrl = new URL(url);
534
601
  const serviceIsLocalhost = isLocalhostHost(serviceUrl.hostname);
@@ -554,7 +621,7 @@ export async function getLearnCardNetworkPlugin(
554
621
 
555
622
  learnCard?.debug?.('Adding LearnCardNetwork Plugin');
556
623
  const client = apiToken
557
- ? await getApiTokenClient(url, apiToken, guardianApprovalGetter, extraHeaders)
624
+ ? await getApiTokenClient(url, apiToken, guardianApprovalGetter, mergedExtraHeaders)
558
625
  : await getClient(
559
626
  url,
560
627
  async challenge => {
@@ -568,7 +635,7 @@ export async function getLearnCardNetworkPlugin(
568
635
  return jwt;
569
636
  },
570
637
  guardianApprovalGetter,
571
- extraHeaders
638
+ mergedExtraHeaders
572
639
  );
573
640
 
574
641
  let userData: LCNProfile | undefined;
@@ -857,6 +924,31 @@ export async function getLearnCardNetworkPlugin(
857
924
  getManagedProfiles: async (_learnCard, options = {}) => {
858
925
  return client.profileManager.getManagedProfiles.query(options);
859
926
  },
927
+ actAs: async (_learnCard, profileId) => {
928
+ const actingOptions: LearnCardNetworkPluginOptions = {
929
+ ...resolvedOptions,
930
+ actAs: profileId,
931
+ };
932
+
933
+ const actingPlugin = apiToken
934
+ ? await getLearnCardNetworkPlugin(_learnCard, url, apiToken, actingOptions)
935
+ : await getLearnCardNetworkPlugin(_learnCard, url, actingOptions);
936
+
937
+ // The plugin's own initial getProfile is non-fatal by design, so an act-as
938
+ // denial would otherwise only surface later as "Please make an account first!".
939
+ try {
940
+ await actingPlugin.methods.getLCNClient(_learnCard).profile.getProfile.query();
941
+ } catch (error) {
942
+ if (isActAsDenial(error)) {
943
+ const message =
944
+ error instanceof Error ? error.message : 'Act-as request was denied.';
945
+ throw new Error(message, { cause: error });
946
+ }
947
+ _learnCard.debug?.('LCN actAs: getProfile failed (non-fatal)', error);
948
+ }
949
+
950
+ return _learnCard.addPlugin(actingPlugin);
951
+ },
860
952
  claimPendingGuardianLinks: async () => {
861
953
  await ensureUser();
862
954
  return client.inbox.claimPendingGuardianLinks.mutate({});
@@ -1703,7 +1795,7 @@ export async function getLearnCardNetworkPlugin(
1703
1795
 
1704
1796
  const enableRefresh = typeof options === 'object' && options.enableRefresh === true;
1705
1797
 
1706
- let managedRefreshId: string | undefined;
1798
+ let refreshAllocation: AllocateCredentialRefreshResult | undefined;
1707
1799
 
1708
1800
  if (enableRefresh) {
1709
1801
  // Generate a stable UUID credential ID when the template has none;
@@ -1711,15 +1803,16 @@ export async function getLearnCardNetworkPlugin(
1711
1803
  if (!boost.id) boost.id = `urn:uuid:${crypto.randomUUID()}`;
1712
1804
 
1713
1805
  // Allocate BEFORE signing: the refresh service must be part of the
1714
- // signed payload, so it is injected before proof creation.
1715
- const allocation =
1806
+ // signed payload, so it is injected before proof creation. The
1807
+ // allocation descriptor is retained through signing so the receipt
1808
+ // can be returned alongside the issued credential URI.
1809
+ refreshAllocation =
1716
1810
  await client.credentialRefresh.allocateCredentialRefresh.mutate({
1717
1811
  holder: { profileId, did: targetProfile.did },
1718
1812
  credentialId: boost.id,
1719
1813
  });
1720
1814
 
1721
- managedRefreshId = allocation.refreshId;
1722
- boost = injectManagedRefreshService(boost, allocation.refreshService);
1815
+ boost = injectManagedRefreshService(boost, refreshAllocation.refreshService);
1723
1816
  }
1724
1817
 
1725
1818
  const statusPurposes =
@@ -1731,20 +1824,37 @@ export async function getLearnCardNetworkPlugin(
1731
1824
  statusPurposes
1732
1825
  );
1733
1826
 
1734
- if (managedRefreshId) {
1827
+ if (refreshAllocation) {
1735
1828
  // Dedicated managed send: brain-service verifies the proof and
1736
1829
  // persists ONLY a holder-encrypted JWE. Legacy credential storage
1737
- // (issuer/LCN-readable JWE or plaintext) is intentionally bypassed.
1830
+ // (issuer/LCN-readable JWE or plaintext) is intentionally bypassed —
1831
+ // managed encryption is mandatory even when `encrypt: false`.
1738
1832
  // The boost URI is forwarded so the credential stays linked
1739
1833
  // 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
- });
1834
+ const credentialUri =
1835
+ await client.credentialRefresh.sendRefreshableCredential.mutate({
1836
+ refreshId: refreshAllocation.refreshId,
1837
+ credential: vc,
1838
+ boostUri,
1839
+ ...(typeof options === 'object' && options.skipNotification
1840
+ ? { skipNotification: true }
1841
+ : {}),
1842
+ });
1843
+
1844
+ // Refresh-enabled callers get the issuance receipt needed to publish
1845
+ // future versions, populated from the actual signed version 1
1846
+ // (LC-2198 decision 4). Non-opt-in callers still receive a plain URI
1847
+ // string.
1848
+ return {
1849
+ credentialUri,
1850
+ refresh: buildManagedRefreshReceipt(
1851
+ vc,
1852
+ refreshAllocation,
1853
+ boost.id ?? '',
1854
+ targetProfile.did,
1855
+ _learnCard.id.did()
1856
+ ),
1857
+ };
1748
1858
  }
1749
1859
 
1750
1860
  // options is allowed to be a boolean to maintain backwards compatibility
@@ -1828,6 +1938,145 @@ export async function getLearnCardNetworkPlugin(
1828
1938
  send: async (_learnCard, input) => {
1829
1939
  await ensureUser();
1830
1940
 
1941
+ if (input.type === 'boost' && input.refresh === true) {
1942
+ // LC-2198: dedicated managed-refresh branch — evaluated BEFORE the
1943
+ // ordinary local-signing / remote-DID / federation shortcuts so a
1944
+ // refresh request can never fall back to a non-refresh delivery.
1945
+ // Inbox credentials stay unsigned until a verified claimant binds their DID.
1946
+ const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.recipient);
1947
+ const isPhone = /^\+?[\d\s-]{10,}$/.test(input.recipient.replace(/[\s-]/g, ''));
1948
+ if (isEmail || isPhone) return client.boost.send.mutate(input);
1949
+
1950
+ const canIssueLocally = 'issueCredential' in _learnCard.invoke;
1951
+
1952
+ if (
1953
+ canIssueLocally &&
1954
+ !input.signedCredential &&
1955
+ (input.templateUri || input.template)
1956
+ ) {
1957
+ // Local signing. Load the template first (read-only) so a template-
1958
+ // supplied credential ID is honored, then let the server run every
1959
+ // managed-send guard, create/reuse the boost and allocate the refresh
1960
+ // in ONE step. With an idempotencyKey that step also makes the whole
1961
+ // call retryable; `completed` means this key already delivered.
1962
+ let boost: UnsignedVC;
1963
+
1964
+ if (input.templateUri) {
1965
+ const result = await getBoostTemplateForIssuance(
1966
+ _learnCard,
1967
+ input.templateUri
1968
+ );
1969
+ const data = await UnsignedVCValidator.spa(result);
1970
+
1971
+ if (!data.success)
1972
+ throw new Error('Did not get a valid boost from URI');
1973
+
1974
+ boost = data.data;
1975
+ } else {
1976
+ // Clone before preparation so template rendering never
1977
+ // mutates the caller's claims.
1978
+ boost = JSON.parse(
1979
+ JSON.stringify(input.template!.credential)
1980
+ ) as UnsignedVC;
1981
+ }
1982
+
1983
+ const prepared = await client.boost.prepareRefreshableSend.mutate({
1984
+ recipient: input.recipient,
1985
+ ...(input.templateUri
1986
+ ? { templateUri: input.templateUri }
1987
+ : { template: input.template! }),
1988
+ ...(input.contractUri ? { contractUri: input.contractUri } : {}),
1989
+ ...(input.templateData ? { templateData: input.templateData } : {}),
1990
+ ...(input.integrationId ? { integrationId: input.integrationId } : {}),
1991
+ ...(boost.id ? { credentialId: boost.id } : {}),
1992
+ ...(input.idempotencyKey
1993
+ ? { idempotencyKey: input.idempotencyKey }
1994
+ : {}),
1995
+ });
1996
+
1997
+ if (prepared.completed) return prepared.completed;
1998
+
1999
+ const boostString = JSON.stringify(boost);
2000
+ const allowAutoAppendEvidence = !hasDynamicEvidenceTemplate(boostString);
2001
+
2002
+ if (input.templateData && Object.keys(input.templateData).length > 0) {
2003
+ try {
2004
+ const rendered = renderTemplateJson(
2005
+ boostString,
2006
+ input.templateData
2007
+ );
2008
+ boost = JSON.parse(rendered);
2009
+ boost = appendTemplateEvidence(
2010
+ boost,
2011
+ input.templateData,
2012
+ allowAutoAppendEvidence
2013
+ );
2014
+ } catch (error) {
2015
+ throw new Error(
2016
+ `Failed to apply template data: ${
2017
+ error instanceof Error ? error.message : 'Unknown error'
2018
+ }`,
2019
+ { cause: error }
2020
+ );
2021
+ }
2022
+ }
2023
+
2024
+ if (isVC2Format(boost)) {
2025
+ boost.validFrom = new Date().toISOString();
2026
+ } else {
2027
+ boost.issuanceDate = new Date().toISOString();
2028
+ }
2029
+
2030
+ boost.issuer = _learnCard.id.did();
2031
+
2032
+ if (Array.isArray(boost.credentialSubject)) {
2033
+ boost.credentialSubject = boost.credentialSubject.map(subject => ({
2034
+ ...subject,
2035
+ id: prepared.holderDid,
2036
+ }));
2037
+ } else {
2038
+ boost.credentialSubject = {
2039
+ ...boost.credentialSubject,
2040
+ id: prepared.holderDid,
2041
+ };
2042
+ }
2043
+
2044
+ // The refresh aggregate is permanently bound to this ID.
2045
+ boost.id = prepared.credentialId;
2046
+
2047
+ if (boost.type?.includes('BoostCredential'))
2048
+ boost.boostId = prepared.boostUri;
2049
+
2050
+ boost = injectManagedRefreshService(boost, prepared.refreshService);
2051
+
2052
+ // Signing goes through the vc-plugin boundary, which prepares the
2053
+ // managed inline JSON-LD context — the service and its terms are
2054
+ // signed in one proof, never mutated afterwards.
2055
+ const signedCredential = await issueCredentialWithNetworkStatus(
2056
+ _learnCard,
2057
+ client,
2058
+ boost
2059
+ );
2060
+
2061
+ // Validated handoff: the server re-derives the refresh ID from the
2062
+ // signed service, re-validates ownership/holder/proof, records the
2063
+ // idempotency intent, and returns the canonical receipt.
2064
+ return client.boost.send.mutate({
2065
+ ...input,
2066
+ template: undefined,
2067
+ templateUri: prepared.boostUri,
2068
+ signedCredential,
2069
+ refresh: true,
2070
+ });
2071
+ }
2072
+
2073
+ // No local signing capability (or caller-supplied signedCredential,
2074
+ // which is forwarded untouched — its proof is never mutated):
2075
+ // delegate the unchanged refresh request to the server signing
2076
+ // authority path.
2077
+ return client.boost.send.mutate(input);
2078
+ }
2079
+
1831
2080
  if (input.type === 'boost') {
1832
2081
  const recipient = input.recipient;
1833
2082
  const isDid = recipient.startsWith('did:');
@@ -2393,6 +2642,37 @@ export async function getLearnCardNetworkPlugin(
2393
2642
 
2394
2643
  return client.inbox.issue.mutate(issueInboxCredential);
2395
2644
  },
2645
+ sendCredentialsViaInbox: async (_learnCard, batch) => {
2646
+ await ensureUser();
2647
+ return client.inbox.issueBatch.mutate(batch);
2648
+ },
2649
+ sendCredentialBatchViaInbox: async (_learnCard, batch) => {
2650
+ await ensureUser();
2651
+ return client.inbox.issueBatch.mutate(batch);
2652
+ },
2653
+ waitForInboxCredentialBatch: async (_learnCard, batchId, options) => {
2654
+ await ensureUser();
2655
+ return waitForInboxCredentialBatch(
2656
+ signal => client.inbox.getBatch.query({ batchId }, { signal }),
2657
+ options
2658
+ );
2659
+ },
2660
+ sendCredentialsViaInboxAndWait: async (_learnCard, batch, options) => {
2661
+ await ensureUser();
2662
+ if (options?.signal?.aborted) throw options.signal.reason;
2663
+ const receipt = await client.inbox.issueBatch.mutate(batch, {
2664
+ signal: options?.signal,
2665
+ });
2666
+ await options?.onSubmitted?.(receipt);
2667
+ return waitForInboxCredentialBatch(
2668
+ signal => client.inbox.getBatch.query({ batchId: receipt.batchId }, { signal }),
2669
+ options
2670
+ );
2671
+ },
2672
+ getInboxCredentialBatch: async (_learnCard, batchId) => {
2673
+ await ensureUser();
2674
+ return client.inbox.getBatch.query({ batchId });
2675
+ },
2396
2676
  getMySentInboxCredentials: async (_learnCard, options) => {
2397
2677
  await ensureUser();
2398
2678