@learncard/network-plugin 2.14.1 → 3.0.1

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.
@@ -0,0 +1,66 @@
1
+ import type { InboxBatchStatus } from '@learncard/types';
2
+ import type { WaitForInboxCredentialBatchOptions } from './types';
3
+
4
+ /** Poll without resubmitting. Timeout/abort stops waiting; the durable job keeps running. */
5
+ export const waitForInboxCredentialBatch = async (
6
+ read: (signal: AbortSignal) => Promise<InboxBatchStatus>,
7
+ options: WaitForInboxCredentialBatchOptions = {}
8
+ ): Promise<InboxBatchStatus> => {
9
+ const { timeoutMs = 600_000, intervalMs = 2_000, signal, onProgress } = options;
10
+ if (
11
+ !Number.isFinite(timeoutMs) ||
12
+ timeoutMs <= 0 ||
13
+ timeoutMs > 2_147_483_647 ||
14
+ !Number.isFinite(intervalMs) ||
15
+ intervalMs <= 0 ||
16
+ intervalMs > 2_147_483_647
17
+ ) {
18
+ throw new RangeError('timeoutMs and intervalMs must be positive finite timer durations.');
19
+ }
20
+ const controller = new AbortController();
21
+ const abort = (): void => controller.abort(signal?.reason);
22
+ signal?.addEventListener('abort', abort, { once: true });
23
+ if (signal?.aborted) abort();
24
+ const timeout = setTimeout(() => {
25
+ const error = new Error(
26
+ 'Timed out waiting for inbox batch. Keep the batch ID to check again.'
27
+ );
28
+ error.name = 'TimeoutError';
29
+ controller.abort(error);
30
+ }, timeoutMs);
31
+
32
+ // Race each operation so an unresponsive transport cannot defeat the deadline.
33
+ const cancellable = <T>(operation: () => Promise<T>): Promise<T> =>
34
+ new Promise((resolve, reject) => {
35
+ const interrupted = (): void => reject(controller.signal.reason);
36
+ if (controller.signal.aborted) return interrupted();
37
+ controller.signal.addEventListener('abort', interrupted, { once: true });
38
+ Promise.resolve()
39
+ .then(() => {
40
+ if (controller.signal.aborted) throw controller.signal.reason;
41
+ return operation();
42
+ })
43
+ .then(resolve, reject)
44
+ .finally(() => controller.signal.removeEventListener('abort', interrupted));
45
+ });
46
+ let delay = intervalMs;
47
+ let sleepTimer: ReturnType<typeof setTimeout> | undefined;
48
+ try {
49
+ while (true) {
50
+ const batch = await cancellable(() => read(controller.signal));
51
+ onProgress?.(batch);
52
+ if (batch.done) return batch;
53
+ await cancellable(
54
+ () =>
55
+ new Promise<void>(resolve => {
56
+ sleepTimer = setTimeout(resolve, delay);
57
+ })
58
+ );
59
+ delay = Math.min(delay * 1.5, Math.max(intervalMs, 10_000));
60
+ }
61
+ } finally {
62
+ clearTimeout(timeout);
63
+ clearTimeout(sleepTimer);
64
+ signal?.removeEventListener('abort', abort);
65
+ }
66
+ };
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({});
@@ -1055,6 +1147,63 @@ export async function getLearnCardNetworkPlugin(
1055
1147
  return client.profile.invalidateInvite.mutate({ challenge });
1056
1148
  },
1057
1149
 
1150
+ createShareLink: async (_learnCard, input) => {
1151
+ await ensureUser();
1152
+
1153
+ return client.shareLinks.create.mutate(input);
1154
+ },
1155
+ updateShareLink: async (_learnCard, input) => {
1156
+ await ensureUser();
1157
+
1158
+ return client.shareLinks.update.mutate(input);
1159
+ },
1160
+ revokeShareLink: async (_learnCard, input) => {
1161
+ await ensureUser();
1162
+
1163
+ return client.shareLinks.revoke.mutate(input);
1164
+ },
1165
+ getShareLink: async (_learnCard, id) => {
1166
+ await ensureUser();
1167
+
1168
+ return client.shareLinks.get.query({ id });
1169
+ },
1170
+ getShareLinkOperationStatus: async (_learnCard, input) => {
1171
+ await ensureUser();
1172
+
1173
+ return client.shareLinks.getOperationStatus.query(input);
1174
+ },
1175
+ retryShareLinkOperation: async (_learnCard, input) => {
1176
+ await ensureUser();
1177
+
1178
+ return client.shareLinks.retry.mutate(input);
1179
+ },
1180
+ getShareLinkRecovery: async (_learnCard, id) => {
1181
+ await ensureUser();
1182
+
1183
+ return client.shareLinks.getRecovery.query({ id });
1184
+ },
1185
+ getShareLinkOwnerContent: async (_learnCard, id) => {
1186
+ await ensureUser();
1187
+
1188
+ return client.shareLinks.getContent.query({ id });
1189
+ },
1190
+ listShareLinks: async (_learnCard, input) => {
1191
+ await ensureUser();
1192
+
1193
+ return client.shareLinks.list.query(input);
1194
+ },
1195
+
1196
+ // Anonymous public methods: deliberately NO `ensureUser()`. A viewer
1197
+ // has no account and must not need one.
1198
+ // POST keeps an optional passcode in the request body and out of URLs,
1199
+ // access logs, browser history and referrers.
1200
+ resolveShareLink: async (_learnCard, id, passcode) =>
1201
+ client.publicShareLinks.resolve.mutate({ id, passcode }),
1202
+ getShareLinkContent: async (_learnCard, id, passcode) =>
1203
+ client.publicShareLinks.content.mutate({ id, passcode }),
1204
+ acknowledgeShareLinkView: async (_learnCard, receipt) =>
1205
+ client.publicShareLinks.acknowledgeView.mutate({ receipt }),
1206
+
1058
1207
  blockProfile: async (_learnCard, profileId) => {
1059
1208
  await ensureUser();
1060
1209
 
@@ -1199,13 +1348,19 @@ export async function getLearnCardNetworkPlugin(
1199
1348
  return client.credential.deleteCredential.mutate({ uri });
1200
1349
  },
1201
1350
 
1202
- sendPresentation: async (_learnCard, profileId, vp, encrypt = true) => {
1351
+ sendPresentation: async (_learnCard, profileId, vp, metadataOrEncrypt, encrypt) => {
1203
1352
  await ensureUser();
1204
1353
 
1205
- if (!encrypt) {
1354
+ const metadata =
1355
+ typeof metadataOrEncrypt === 'object' ? metadataOrEncrypt : undefined;
1356
+ const shouldEncrypt =
1357
+ typeof metadataOrEncrypt === 'boolean' ? metadataOrEncrypt : (encrypt ?? true);
1358
+
1359
+ if (!shouldEncrypt) {
1206
1360
  return client.presentation.sendPresentation.mutate({
1207
1361
  profileId,
1208
1362
  presentation: vp,
1363
+ metadata,
1209
1364
  });
1210
1365
  }
1211
1366
 
@@ -1219,7 +1374,11 @@ export async function getLearnCardNetworkPlugin(
1219
1374
  target.did,
1220
1375
  ]);
1221
1376
 
1222
- return client.presentation.sendPresentation.mutate({ profileId, presentation });
1377
+ return client.presentation.sendPresentation.mutate({
1378
+ profileId,
1379
+ presentation,
1380
+ metadata,
1381
+ });
1223
1382
  },
1224
1383
  acceptPresentation: async (_learnCard, uri) => {
1225
1384
  await ensureUser();
@@ -1703,7 +1862,7 @@ export async function getLearnCardNetworkPlugin(
1703
1862
 
1704
1863
  const enableRefresh = typeof options === 'object' && options.enableRefresh === true;
1705
1864
 
1706
- let managedRefreshId: string | undefined;
1865
+ let refreshAllocation: AllocateCredentialRefreshResult | undefined;
1707
1866
 
1708
1867
  if (enableRefresh) {
1709
1868
  // Generate a stable UUID credential ID when the template has none;
@@ -1711,15 +1870,16 @@ export async function getLearnCardNetworkPlugin(
1711
1870
  if (!boost.id) boost.id = `urn:uuid:${crypto.randomUUID()}`;
1712
1871
 
1713
1872
  // Allocate BEFORE signing: the refresh service must be part of the
1714
- // signed payload, so it is injected before proof creation.
1715
- const allocation =
1873
+ // signed payload, so it is injected before proof creation. The
1874
+ // allocation descriptor is retained through signing so the receipt
1875
+ // can be returned alongside the issued credential URI.
1876
+ refreshAllocation =
1716
1877
  await client.credentialRefresh.allocateCredentialRefresh.mutate({
1717
1878
  holder: { profileId, did: targetProfile.did },
1718
1879
  credentialId: boost.id,
1719
1880
  });
1720
1881
 
1721
- managedRefreshId = allocation.refreshId;
1722
- boost = injectManagedRefreshService(boost, allocation.refreshService);
1882
+ boost = injectManagedRefreshService(boost, refreshAllocation.refreshService);
1723
1883
  }
1724
1884
 
1725
1885
  const statusPurposes =
@@ -1731,20 +1891,37 @@ export async function getLearnCardNetworkPlugin(
1731
1891
  statusPurposes
1732
1892
  );
1733
1893
 
1734
- if (managedRefreshId) {
1894
+ if (refreshAllocation) {
1735
1895
  // Dedicated managed send: brain-service verifies the proof and
1736
1896
  // persists ONLY a holder-encrypted JWE. Legacy credential storage
1737
- // (issuer/LCN-readable JWE or plaintext) is intentionally bypassed.
1897
+ // (issuer/LCN-readable JWE or plaintext) is intentionally bypassed —
1898
+ // managed encryption is mandatory even when `encrypt: false`.
1738
1899
  // The boost URI is forwarded so the credential stays linked
1739
1900
  // 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
- });
1901
+ const credentialUri =
1902
+ await client.credentialRefresh.sendRefreshableCredential.mutate({
1903
+ refreshId: refreshAllocation.refreshId,
1904
+ credential: vc,
1905
+ boostUri,
1906
+ ...(typeof options === 'object' && options.skipNotification
1907
+ ? { skipNotification: true }
1908
+ : {}),
1909
+ });
1910
+
1911
+ // Refresh-enabled callers get the issuance receipt needed to publish
1912
+ // future versions, populated from the actual signed version 1
1913
+ // (LC-2198 decision 4). Non-opt-in callers still receive a plain URI
1914
+ // string.
1915
+ return {
1916
+ credentialUri,
1917
+ refresh: buildManagedRefreshReceipt(
1918
+ vc,
1919
+ refreshAllocation,
1920
+ boost.id ?? '',
1921
+ targetProfile.did,
1922
+ _learnCard.id.did()
1923
+ ),
1924
+ };
1748
1925
  }
1749
1926
 
1750
1927
  // options is allowed to be a boolean to maintain backwards compatibility
@@ -1828,6 +2005,145 @@ export async function getLearnCardNetworkPlugin(
1828
2005
  send: async (_learnCard, input) => {
1829
2006
  await ensureUser();
1830
2007
 
2008
+ if (input.type === 'boost' && input.refresh === true) {
2009
+ // LC-2198: dedicated managed-refresh branch — evaluated BEFORE the
2010
+ // ordinary local-signing / remote-DID / federation shortcuts so a
2011
+ // refresh request can never fall back to a non-refresh delivery.
2012
+ // Inbox credentials stay unsigned until a verified claimant binds their DID.
2013
+ const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.recipient);
2014
+ const isPhone = /^\+?[\d\s-]{10,}$/.test(input.recipient.replace(/[\s-]/g, ''));
2015
+ if (isEmail || isPhone) return client.boost.send.mutate(input);
2016
+
2017
+ const canIssueLocally = 'issueCredential' in _learnCard.invoke;
2018
+
2019
+ if (
2020
+ canIssueLocally &&
2021
+ !input.signedCredential &&
2022
+ (input.templateUri || input.template)
2023
+ ) {
2024
+ // Local signing. Load the template first (read-only) so a template-
2025
+ // supplied credential ID is honored, then let the server run every
2026
+ // managed-send guard, create/reuse the boost and allocate the refresh
2027
+ // in ONE step. With an idempotencyKey that step also makes the whole
2028
+ // call retryable; `completed` means this key already delivered.
2029
+ let boost: UnsignedVC;
2030
+
2031
+ if (input.templateUri) {
2032
+ const result = await getBoostTemplateForIssuance(
2033
+ _learnCard,
2034
+ input.templateUri
2035
+ );
2036
+ const data = await UnsignedVCValidator.spa(result);
2037
+
2038
+ if (!data.success)
2039
+ throw new Error('Did not get a valid boost from URI');
2040
+
2041
+ boost = data.data;
2042
+ } else {
2043
+ // Clone before preparation so template rendering never
2044
+ // mutates the caller's claims.
2045
+ boost = JSON.parse(
2046
+ JSON.stringify(input.template!.credential)
2047
+ ) as UnsignedVC;
2048
+ }
2049
+
2050
+ const prepared = await client.boost.prepareRefreshableSend.mutate({
2051
+ recipient: input.recipient,
2052
+ ...(input.templateUri
2053
+ ? { templateUri: input.templateUri }
2054
+ : { template: input.template! }),
2055
+ ...(input.contractUri ? { contractUri: input.contractUri } : {}),
2056
+ ...(input.templateData ? { templateData: input.templateData } : {}),
2057
+ ...(input.integrationId ? { integrationId: input.integrationId } : {}),
2058
+ ...(boost.id ? { credentialId: boost.id } : {}),
2059
+ ...(input.idempotencyKey
2060
+ ? { idempotencyKey: input.idempotencyKey }
2061
+ : {}),
2062
+ });
2063
+
2064
+ if (prepared.completed) return prepared.completed;
2065
+
2066
+ const boostString = JSON.stringify(boost);
2067
+ const allowAutoAppendEvidence = !hasDynamicEvidenceTemplate(boostString);
2068
+
2069
+ if (input.templateData && Object.keys(input.templateData).length > 0) {
2070
+ try {
2071
+ const rendered = renderTemplateJson(
2072
+ boostString,
2073
+ input.templateData
2074
+ );
2075
+ boost = JSON.parse(rendered);
2076
+ boost = appendTemplateEvidence(
2077
+ boost,
2078
+ input.templateData,
2079
+ allowAutoAppendEvidence
2080
+ );
2081
+ } catch (error) {
2082
+ throw new Error(
2083
+ `Failed to apply template data: ${
2084
+ error instanceof Error ? error.message : 'Unknown error'
2085
+ }`,
2086
+ { cause: error }
2087
+ );
2088
+ }
2089
+ }
2090
+
2091
+ if (isVC2Format(boost)) {
2092
+ boost.validFrom = new Date().toISOString();
2093
+ } else {
2094
+ boost.issuanceDate = new Date().toISOString();
2095
+ }
2096
+
2097
+ boost.issuer = _learnCard.id.did();
2098
+
2099
+ if (Array.isArray(boost.credentialSubject)) {
2100
+ boost.credentialSubject = boost.credentialSubject.map(subject => ({
2101
+ ...subject,
2102
+ id: prepared.holderDid,
2103
+ }));
2104
+ } else {
2105
+ boost.credentialSubject = {
2106
+ ...boost.credentialSubject,
2107
+ id: prepared.holderDid,
2108
+ };
2109
+ }
2110
+
2111
+ // The refresh aggregate is permanently bound to this ID.
2112
+ boost.id = prepared.credentialId;
2113
+
2114
+ if (boost.type?.includes('BoostCredential'))
2115
+ boost.boostId = prepared.boostUri;
2116
+
2117
+ boost = injectManagedRefreshService(boost, prepared.refreshService);
2118
+
2119
+ // Signing goes through the vc-plugin boundary, which prepares the
2120
+ // managed inline JSON-LD context — the service and its terms are
2121
+ // signed in one proof, never mutated afterwards.
2122
+ const signedCredential = await issueCredentialWithNetworkStatus(
2123
+ _learnCard,
2124
+ client,
2125
+ boost
2126
+ );
2127
+
2128
+ // Validated handoff: the server re-derives the refresh ID from the
2129
+ // signed service, re-validates ownership/holder/proof, records the
2130
+ // idempotency intent, and returns the canonical receipt.
2131
+ return client.boost.send.mutate({
2132
+ ...input,
2133
+ template: undefined,
2134
+ templateUri: prepared.boostUri,
2135
+ signedCredential,
2136
+ refresh: true,
2137
+ });
2138
+ }
2139
+
2140
+ // No local signing capability (or caller-supplied signedCredential,
2141
+ // which is forwarded untouched — its proof is never mutated):
2142
+ // delegate the unchanged refresh request to the server signing
2143
+ // authority path.
2144
+ return client.boost.send.mutate(input);
2145
+ }
2146
+
1831
2147
  if (input.type === 'boost') {
1832
2148
  const recipient = input.recipient;
1833
2149
  const isDid = recipient.startsWith('did:');
@@ -2393,6 +2709,37 @@ export async function getLearnCardNetworkPlugin(
2393
2709
 
2394
2710
  return client.inbox.issue.mutate(issueInboxCredential);
2395
2711
  },
2712
+ sendCredentialsViaInbox: async (_learnCard, batch) => {
2713
+ await ensureUser();
2714
+ return client.inbox.issueBatch.mutate(batch);
2715
+ },
2716
+ sendCredentialBatchViaInbox: async (_learnCard, batch) => {
2717
+ await ensureUser();
2718
+ return client.inbox.issueBatch.mutate(batch);
2719
+ },
2720
+ waitForInboxCredentialBatch: async (_learnCard, batchId, options) => {
2721
+ await ensureUser();
2722
+ return waitForInboxCredentialBatch(
2723
+ signal => client.inbox.getBatch.query({ batchId }, { signal }),
2724
+ options
2725
+ );
2726
+ },
2727
+ sendCredentialsViaInboxAndWait: async (_learnCard, batch, options) => {
2728
+ await ensureUser();
2729
+ if (options?.signal?.aborted) throw options.signal.reason;
2730
+ const receipt = await client.inbox.issueBatch.mutate(batch, {
2731
+ signal: options?.signal,
2732
+ });
2733
+ await options?.onSubmitted?.(receipt);
2734
+ return waitForInboxCredentialBatch(
2735
+ signal => client.inbox.getBatch.query({ batchId: receipt.batchId }, { signal }),
2736
+ options
2737
+ );
2738
+ },
2739
+ getInboxCredentialBatch: async (_learnCard, batchId) => {
2740
+ await ensureUser();
2741
+ return client.inbox.getBatch.query({ batchId });
2742
+ },
2396
2743
  getMySentInboxCredentials: async (_learnCard, options) => {
2397
2744
  await ensureUser();
2398
2745