@learncard/network-plugin 3.2.3 → 4.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,7 +1,8 @@
1
1
  import { waitForInboxCredentialBatch } from './inbox-batch';
2
- import { getClient, getApiTokenClient } from '@learncard/network-brain-client';
2
+ import { getClient, getApiTokenClient, getAnonymousClient } from '@learncard/network-brain-client';
3
3
  import {
4
4
  JWEValidator,
5
+ MyCampaignValidator,
5
6
  LCNProfile,
6
7
  LCNVisibleProfile,
7
8
  UnsignedVCValidator,
@@ -14,14 +15,24 @@ import {
14
15
  UnsignedVC,
15
16
  VC,
16
17
  BitstringCredentialStatusEntry,
18
+ type BitstringStatusListEntry,
19
+ BitstringStatusListEntryValidator,
17
20
  BitstringCredentialStatusPurpose,
18
21
  AllocateCredentialRefreshResult,
19
22
  ManagedCredentialRefreshReceipt,
20
23
  ManagedCredentialRefreshService,
24
+ ManagedCredentialRefreshBindingValidator,
21
25
  StoredCredentialEnvelope,
22
26
  StoredCredentialEnvelopeValidator,
23
27
  isStoredCredentialEnvelope,
24
28
  ACT_AS_HEADER,
29
+ HolderEncryptedHandoffValidator,
30
+ type HolderEncryptedHandoff,
31
+ type SendHolderEncryptedRefreshableInput,
32
+ type PublishHolderEncryptedRefreshInput,
33
+ type GetEncryptionRecipientsInput,
34
+ type EncryptionRecipientsResult,
35
+ type PublishCredentialRefreshResult,
25
36
  } from '@learncard/types';
26
37
  import { LearnCard } from '@learncard/core';
27
38
  import { VerifyExtension } from '@learncard/vc-plugin';
@@ -29,10 +40,14 @@ import {
29
40
  getCredentialIssuerId,
30
41
  getCredentialStatusArray,
31
42
  injectManagedRefreshService as injectSharedManagedRefreshService,
43
+ prepareManagedRefreshContext,
44
+ isDisplayOnlyAnchorTemplate,
32
45
  isVC2Format,
33
46
  materializeCredentialFields,
34
47
  isEncrypted,
35
48
  resolveStorageReadResult,
49
+ assertExactJweRecipients,
50
+ assertManagedCredentialBinding,
36
51
  } from '@learncard/helpers';
37
52
  import Mustache from 'mustache';
38
53
 
@@ -443,8 +458,19 @@ const issueCredentialWithNetworkStatus = async (
443
458
  learnCard: LearnCard<any, any, LearnCardNetworkPluginDependentMethods>,
444
459
  client: any,
445
460
  credential: UnsignedVC,
446
- statusPurposes?: BitstringCredentialStatusPurpose[]
461
+ statusPurposes?: BitstringCredentialStatusPurpose[],
462
+ statusEntries?: BitstringStatusListEntry[]
447
463
  ): Promise<VC> => {
464
+ if (statusEntries) {
465
+ const prepared = UnsignedVCValidator.parse({
466
+ ...materializeCredentialFields(credential),
467
+ credentialStatus:
468
+ statusEntries.length === 1 && !Array.isArray(credential.credentialStatus)
469
+ ? statusEntries[0]
470
+ : statusEntries,
471
+ });
472
+ return learnCard.invoke.issueCredential(prepareManagedRefreshContext(prepared));
473
+ }
448
474
  return learnCard.invoke.issueCredential(
449
475
  await appendNetworkCredentialStatus(
450
476
  client,
@@ -510,7 +536,7 @@ const buildManagedRefreshReceipt = (
510
536
 
511
537
  return {
512
538
  refreshId: allocation.refreshId,
513
- refreshService: allocation.refreshService,
539
+ refreshService: ManagedCredentialRefreshBindingValidator.parse(vc.refreshService),
514
540
  credentialId: typeof vc.id === 'string' && vc.id.length > 0 ? vc.id : credentialId,
515
541
  issuerDid,
516
542
  holderDid,
@@ -523,6 +549,119 @@ const buildManagedRefreshReceipt = (
523
549
  };
524
550
  };
525
551
 
552
+ /**
553
+ * Narrow local view of the LC-2345 holder-mode credential-refresh routes, typed
554
+ * directly from the committed `@learncard/types` wire contracts. Kept separate from
555
+ * the generated `LCNClient` type so this plugin compiles against the contract
556
+ * regardless of router rollout order.
557
+ */
558
+ type HolderEncryptionClient = {
559
+ sendHolderEncryptedRefreshable: {
560
+ mutate: (input: SendHolderEncryptedRefreshableInput) => Promise<{
561
+ uri: string;
562
+ activityId: string;
563
+ statusManagement?: 'network' | 'external' | 'mixed' | 'none';
564
+ }>;
565
+ };
566
+ publishHolderEncryptedRefreshable: {
567
+ mutate: (
568
+ input: PublishHolderEncryptedRefreshInput
569
+ ) => Promise<PublishCredentialRefreshResult>;
570
+ };
571
+ getEncryptionRecipients: {
572
+ query: (input: GetEncryptionRecipientsInput) => Promise<EncryptionRecipientsResult>;
573
+ };
574
+ };
575
+
576
+ /** Thrown when `encryption: 'holder'` was requested but the server prepare response has no `recipientKeyIds`. */
577
+ const HOLDER_MODE_PREPARE_UNSUPPORTED =
578
+ 'Server does not support holder-mode encryption (missing recipientKeyIds from prepareRefreshableSend); refusing to fall back to network-mode plaintext delivery.';
579
+
580
+ /**
581
+ * Reads `recipientKeyIds` off a `prepareRefreshableSend` result through a local type
582
+ * widening: the wire contract (`@learncard/types`) already carries this field (A3
583
+ * step 6), but a given resolved build of the generated brain client may not yet
584
+ * reflect it.
585
+ */
586
+ const getPreparedRecipientKeyIds = (prepared: unknown): string[] | undefined =>
587
+ (prepared as { recipientKeyIds?: string[] }).recipientKeyIds;
588
+
589
+ /**
590
+ * True only for the server's holder-mode manager-set conflict (`CONFLICT` paired
591
+ * with the literal `RECIPIENTS_CHANGED` message the send/publish/recipients routes
592
+ * throw when the manager set changed since prepare).
593
+ */
594
+ const isRecipientsChangedError = (error: unknown): boolean => {
595
+ const code = (error as { data?: { code?: string } } | undefined)?.data?.code;
596
+ const message = (error as { message?: unknown } | undefined)?.message;
597
+
598
+ return code === 'CONFLICT' && message === 'RECIPIENTS_CHANGED';
599
+ };
600
+
601
+ /** Extracts the DID from a key-agreement verification-method id (`did:...#fragment`). */
602
+ const didFromRecipientKeyId = (keyId: string): string => {
603
+ const [did] = keyId.split('#');
604
+
605
+ if (!did) throw new Error(`Invalid recipient key id: ${keyId}`);
606
+
607
+ return did;
608
+ };
609
+
610
+ /**
611
+ * Encrypts an already-signed managed-refresh credential exactly to `recipientKeyIds`
612
+ * and returns the ciphertext-only handoff. Carries no credential content,
613
+ * templateData, or display-derived claim value outside the JWE itself — only the
614
+ * public bindings `HolderEncryptedHandoffValidator` allows.
615
+ */
616
+ /** The public bindings of a holder handoff; validated with the JWE by HolderEncryptedHandoffValidator. */
617
+ type HolderEncryptedHandoffBindings = Omit<HolderEncryptedHandoff, 'jwe'>;
618
+
619
+ const buildHolderEncryptedHandoff = async (
620
+ learnCard: { invoke: Pick<LearnCardNetworkPluginDependentMethods, 'createDagJweExact'> },
621
+ signedCredential: VC,
622
+ bindings: HolderEncryptedHandoffBindings,
623
+ recipientKeyIds: string[]
624
+ ): Promise<HolderEncryptedHandoff> => {
625
+ const recipientDids = recipientKeyIds.map(didFromRecipientKeyId);
626
+ const jwe = await learnCard.invoke.createDagJweExact?.(signedCredential, recipientDids);
627
+
628
+ if (!jwe) {
629
+ throw new Error(
630
+ 'encryption: "holder" requires the Encryption plugin (createDagJweExact), which this wallet does not have.'
631
+ );
632
+ }
633
+
634
+ assertExactJweRecipients(jwe, recipientKeyIds);
635
+
636
+ return HolderEncryptedHandoffValidator.parse({ ...bindings, jwe });
637
+ };
638
+
639
+ /**
640
+ * Delivers a holder-encrypted handoff, retrying exactly once with a freshly fetched
641
+ * recipient set on `RECIPIENTS_CHANGED`. Any other failure, or a second
642
+ * `RECIPIENTS_CHANGED`, propagates without a further retry.
643
+ */
644
+ const deliverHolderEncryptedHandoff = async <T>(
645
+ holderClient: HolderEncryptionClient,
646
+ refreshId: string,
647
+ recipientKeyIds: string[],
648
+ buildHandoff: (recipientKeyIds: string[]) => Promise<HolderEncryptedHandoff>,
649
+ deliver: (handoff: HolderEncryptedHandoff) => Promise<T>
650
+ ): Promise<T> => {
651
+ const handoff = await buildHandoff(recipientKeyIds);
652
+
653
+ try {
654
+ return await deliver(handoff);
655
+ } catch (error) {
656
+ if (!isRecipientsChangedError(error)) throw error;
657
+
658
+ const fresh = await holderClient.getEncryptionRecipients.query({ refreshId });
659
+ const retryHandoff = await buildHandoff(fresh.recipientKeyIds);
660
+
661
+ return deliver(retryHandoff);
662
+ }
663
+ };
664
+
526
665
  export * from './types';
527
666
 
528
667
  export type GuardianApprovalGetter = () => string | undefined | Promise<string | undefined>;
@@ -571,6 +710,23 @@ export interface LearnCardNetworkPluginOptions extends FederationConfig {
571
710
  actAs?: string;
572
711
  }
573
712
 
713
+ /**
714
+ * Mirrors the server's `normalizePublicDisplay` (accesslayer/boost/create.ts) so wire
715
+ * payloads already carry the category explicitly; the server still normalizes
716
+ * independently since direct API/REST callers bypass the SDK entirely. Only
717
+ * `category` may fall back to the inline template — consent-flow authorization keys
718
+ * off it, and a dropped category reads as unrestricted. `name`/`image` are never
719
+ * inferred from the template.
720
+ */
721
+ const normalizePublicDisplay = (input: {
722
+ display?: { name?: string; category?: string; image?: string };
723
+ template?: { category?: string | null };
724
+ }): { name?: string; category?: string; image?: string } | undefined => {
725
+ const category = input.display?.category ?? input.template?.category?.slice(0, 280);
726
+ if (!input.display && !category) return undefined;
727
+ return { ...input.display, ...(category ? { category } : {}) };
728
+ };
729
+
574
730
  export async function getLearnCardNetworkPlugin(
575
731
  learnCard: LearnCard<any, 'id', LearnCardNetworkPluginDependentMethods>,
576
732
  url: string,
@@ -715,6 +871,24 @@ export async function getLearnCardNetworkPlugin(
715
871
  }
716
872
  };
717
873
 
874
+ /**
875
+ * Display-only anchors (created by a prior managed/signed-only send that had no
876
+ * explicit credential) store a minimal placeholder VC — valid-looking, but with
877
+ * no real claims — marked via `isDisplayOnlyAnchorTemplate` (the same marker the
878
+ * server's `prepareCredentialFromBoost` guard checks for; a plain top-level
879
+ * `display` key is NOT a safe marker, since ordinary Boosts commonly have their
880
+ * own `display` object too). Reusing such an anchor's uri as a template would
881
+ * sign and deliver that empty placeholder instead of real content, bypassing the
882
+ * equivalent server-side guard, since a locally-signed credential never reaches it.
883
+ */
884
+ const assertIssuableBoostTemplate = (boost: unknown): void => {
885
+ if (isDisplayOnlyAnchorTemplate(boost)) {
886
+ throw new Error(
887
+ 'This Boost is a display-only anchor and has no issuance credential. Pass `template` or `signedCredential` instead of reusing its uri as a templateUri.'
888
+ );
889
+ }
890
+ };
891
+
718
892
  const getInboxEndpointForDid = async (
719
893
  _learnCard: any,
720
894
  recipientDid: string
@@ -1787,6 +1961,163 @@ export async function getLearnCardNetworkPlugin(
1787
1961
 
1788
1962
  return client.credentialRefresh.getCredentialRefreshHistory.query(input);
1789
1963
  },
1964
+ getCredentialRefreshReceipt: async (_learnCard, input) => {
1965
+ await ensureUser();
1966
+ return client.credentialRefresh.getReceipt.query(input);
1967
+ },
1968
+ publishHolderEncryptedRefreshable: async (_learnCard, receipt, credential, options) => {
1969
+ await ensureUser();
1970
+
1971
+ if (!('issueCredential' in _learnCard.invoke)) {
1972
+ throw new Error(
1973
+ 'Holder-mode publishing requires local signing capability, which this wallet does not have.'
1974
+ );
1975
+ }
1976
+
1977
+ if (!('createDagJweExact' in _learnCard.invoke)) {
1978
+ throw new Error(
1979
+ 'Holder-mode publishing requires the Encryption plugin (createDagJweExact), which this wallet does not have.'
1980
+ );
1981
+ }
1982
+
1983
+ if (receipt.credentialStatus === undefined) {
1984
+ throw new Error(
1985
+ "Holder-mode publishing requires the original send receipt's credentialStatus binding."
1986
+ );
1987
+ }
1988
+
1989
+ // Publishing reuses version 1's exact status entries (A1): no new
1990
+ // allocation. Narrowed via the validator so the signing step below gets a
1991
+ // real `BitstringStatusListEntry[]`, not the looser generic `CredentialStatus`.
1992
+ const boundCredentialStatus = BitstringStatusListEntryValidator.array().parse(
1993
+ Array.isArray(receipt.credentialStatus)
1994
+ ? receipt.credentialStatus
1995
+ : [receipt.credentialStatus]
1996
+ );
1997
+
1998
+ // Clone before preparation so template rendering never mutates the
1999
+ // caller's claims.
2000
+ let boost: UnsignedVC = JSON.parse(JSON.stringify(credential));
2001
+ const boostString = JSON.stringify(boost);
2002
+ const allowAutoAppendEvidence = !hasDynamicEvidenceTemplate(boostString);
2003
+
2004
+ if (options.templateData && Object.keys(options.templateData).length > 0) {
2005
+ try {
2006
+ const rendered = renderTemplateJson(boostString, options.templateData);
2007
+ boost = JSON.parse(rendered);
2008
+ boost = appendTemplateEvidence(
2009
+ boost,
2010
+ options.templateData,
2011
+ allowAutoAppendEvidence
2012
+ );
2013
+ } catch (error) {
2014
+ throw new Error(
2015
+ `Failed to apply template data: ${
2016
+ error instanceof Error ? error.message : 'Unknown error'
2017
+ }`,
2018
+ { cause: error }
2019
+ );
2020
+ }
2021
+ }
2022
+
2023
+ if (isVC2Format(boost)) {
2024
+ boost.validFrom = new Date().toISOString();
2025
+ } else {
2026
+ boost.issuanceDate = new Date().toISOString();
2027
+ }
2028
+
2029
+ boost.issuer = receipt.issuerDid;
2030
+ boost.id = receipt.credentialId;
2031
+
2032
+ if (Array.isArray(boost.credentialSubject)) {
2033
+ boost.credentialSubject = boost.credentialSubject.map(subject => ({
2034
+ ...subject,
2035
+ id: receipt.holderDid,
2036
+ }));
2037
+ } else {
2038
+ boost.credentialSubject = { ...boost.credentialSubject, id: receipt.holderDid };
2039
+ }
2040
+
2041
+ if (
2042
+ receipt.refreshServiceBinding === 'managed-entry' &&
2043
+ !credential.refreshService
2044
+ ) {
2045
+ throw new Error(
2046
+ 'This legacy receipt does not include the original refresh service descriptor. Repair the receipt or reissue the credential before publishing.'
2047
+ );
2048
+ }
2049
+
2050
+ boost = prepareManagedRefreshContext({
2051
+ ...boost,
2052
+ refreshService:
2053
+ receipt.refreshServiceBinding === 'managed-entry'
2054
+ ? credential.refreshService
2055
+ : receipt.refreshService,
2056
+ });
2057
+ boost.credentialStatus = receipt.credentialStatus;
2058
+ assertManagedCredentialBinding(boost, { ...receipt, encryptionMode: 'holder' });
2059
+
2060
+ const signedCredential = await issueCredentialWithNetworkStatus(
2061
+ _learnCard,
2062
+ client,
2063
+ boost,
2064
+ undefined,
2065
+ boundCredentialStatus
2066
+ );
2067
+
2068
+ const holderBindings: HolderEncryptedHandoffBindings = {
2069
+ refreshId: receipt.refreshId,
2070
+ credentialId: receipt.credentialId,
2071
+ holderDid: receipt.holderDid,
2072
+ issuerDid: receipt.issuerDid,
2073
+ refreshService: ManagedCredentialRefreshBindingValidator.parse(
2074
+ boost.refreshService
2075
+ ),
2076
+ credentialStatus: BitstringStatusListEntryValidator.or(
2077
+ BitstringStatusListEntryValidator.array()
2078
+ ).parse(receipt.credentialStatus),
2079
+ };
2080
+
2081
+ assertManagedCredentialBinding(signedCredential, {
2082
+ ...holderBindings,
2083
+ refreshService: ManagedCredentialRefreshBindingValidator.parse(
2084
+ boost.refreshService
2085
+ ),
2086
+ encryptionMode: 'holder',
2087
+ });
2088
+
2089
+ const holderClient = client.credentialRefresh as unknown as HolderEncryptionClient;
2090
+ const { recipientKeyIds } = await holderClient.getEncryptionRecipients.query({
2091
+ refreshId: receipt.refreshId,
2092
+ });
2093
+
2094
+ return deliverHolderEncryptedHandoff(
2095
+ holderClient,
2096
+ receipt.refreshId,
2097
+ recipientKeyIds,
2098
+ keyIds =>
2099
+ buildHolderEncryptedHandoff(
2100
+ _learnCard,
2101
+ signedCredential,
2102
+ holderBindings,
2103
+ keyIds
2104
+ ),
2105
+ handoff =>
2106
+ holderClient.publishHolderEncryptedRefreshable.mutate({
2107
+ handoff,
2108
+ notify: options.notify,
2109
+ ...(options.updateSummary !== undefined
2110
+ ? { updateSummary: options.updateSummary }
2111
+ : {}),
2112
+ ...(options.updateReason !== undefined
2113
+ ? { updateReason: options.updateReason }
2114
+ : {}),
2115
+ ...(options.idempotencyKey !== undefined
2116
+ ? { idempotencyKey: options.idempotencyKey }
2117
+ : {}),
2118
+ })
2119
+ );
2120
+ },
1790
2121
  revokeBoostRecipient: async (
1791
2122
  _learnCard,
1792
2123
  boostUri,
@@ -1840,6 +2171,7 @@ export async function getLearnCardNetworkPlugin(
1840
2171
  await ensureUser();
1841
2172
 
1842
2173
  const result = await getBoostTemplateForIssuance(_learnCard, boostUri);
2174
+ assertIssuableBoostTemplate(result);
1843
2175
  const data = await UnsignedVCValidator.spa(result);
1844
2176
 
1845
2177
  if (!data.success) throw new Error('Did not get a valid boost from URI');
@@ -2046,6 +2378,16 @@ export async function getLearnCardNetworkPlugin(
2046
2378
  send: async (_learnCard, input) => {
2047
2379
  await ensureUser();
2048
2380
 
2381
+ if (
2382
+ input.type === 'boost' &&
2383
+ input.encryption === 'holder' &&
2384
+ input.refresh !== true
2385
+ ) {
2386
+ throw new Error(
2387
+ 'encryption: "holder" requires refresh: true; holder-mode encryption is only defined for managed refresh sends.'
2388
+ );
2389
+ }
2390
+
2049
2391
  if (input.type === 'boost' && input.refresh === true) {
2050
2392
  // LC-2198: dedicated managed-refresh branch — evaluated BEFORE the
2051
2393
  // ordinary local-signing / remote-DID / federation shortcuts so a
@@ -2053,10 +2395,56 @@ export async function getLearnCardNetworkPlugin(
2053
2395
  // Inbox credentials stay unsigned until a verified claimant binds their DID.
2054
2396
  const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.recipient);
2055
2397
  const isPhone = /^\+?[\d\s-]{10,}$/.test(input.recipient.replace(/[\s-]/g, ''));
2056
- if (isEmail || isPhone) return client.boost.send.mutate(input);
2398
+ // LC-2345: holder (student-only) encryption is rejected client-side for
2399
+ // the two cases the server also rejects, so neither case ever reaches
2400
+ // the network — an inbox claimant isn't known yet to encrypt to, and a
2401
+ // caller-supplied signedCredential bypasses the local signing this mode
2402
+ // requires.
2403
+ const isHolderEncryption = input.encryption === 'holder';
2404
+
2405
+ if (isHolderEncryption && (isEmail || isPhone)) {
2406
+ throw new Error(
2407
+ 'encryption: "holder" does not support inbox (email or phone) recipients; use a profile ID or DID recipient instead.'
2408
+ );
2409
+ }
2410
+
2411
+ if (isHolderEncryption && input.signedCredential) {
2412
+ throw new Error(
2413
+ 'encryption: "holder" does not support signedCredential; the SDK must sign locally so the server never receives plaintext.'
2414
+ );
2415
+ }
2416
+
2417
+ const request = {
2418
+ type: 'boost' as const,
2419
+ recipient: input.recipient,
2420
+ templateUri: input.templateUri,
2421
+ template: input.template,
2422
+ signedCredential: input.signedCredential,
2423
+ display: normalizePublicDisplay(input),
2424
+ statusPurposes: input.statusPurposes,
2425
+ contractUri: input.contractUri,
2426
+ templateData: input.templateData,
2427
+ integrationId: input.integrationId,
2428
+ options: input.options,
2429
+ idempotencyKey: input.idempotencyKey,
2430
+ refresh: true,
2431
+ };
2432
+ if (isEmail || isPhone) return client.boost.send.mutate(request);
2057
2433
 
2058
2434
  const canIssueLocally = 'issueCredential' in _learnCard.invoke;
2059
2435
 
2436
+ if (isHolderEncryption && !canIssueLocally) {
2437
+ throw new Error(
2438
+ 'encryption: "holder" requires local signing capability, which this wallet does not have.'
2439
+ );
2440
+ }
2441
+
2442
+ if (isHolderEncryption && !('createDagJweExact' in _learnCard.invoke)) {
2443
+ throw new Error(
2444
+ 'encryption: "holder" requires the Encryption plugin (createDagJweExact), which this wallet does not have.'
2445
+ );
2446
+ }
2447
+
2060
2448
  if (
2061
2449
  canIssueLocally &&
2062
2450
  !input.signedCredential &&
@@ -2069,11 +2457,18 @@ export async function getLearnCardNetworkPlugin(
2069
2457
  // call retryable; `completed` means this key already delivered.
2070
2458
  let boost: UnsignedVC;
2071
2459
 
2460
+ if (input.template?.status === 'DRAFT') {
2461
+ throw new Error(
2462
+ 'Draft Boosts cannot be sent. Publish the template first.'
2463
+ );
2464
+ }
2465
+
2072
2466
  if (input.templateUri) {
2073
2467
  const result = await getBoostTemplateForIssuance(
2074
2468
  _learnCard,
2075
2469
  input.templateUri
2076
2470
  );
2471
+ assertIssuableBoostTemplate(result);
2077
2472
  const data = await UnsignedVCValidator.spa(result);
2078
2473
 
2079
2474
  if (!data.success)
@@ -2089,21 +2484,28 @@ export async function getLearnCardNetworkPlugin(
2089
2484
  }
2090
2485
 
2091
2486
  const prepared = await client.boost.prepareRefreshableSend.mutate({
2487
+ ...(boost.credentialStatus ? {} : { statusBinding: 'server' as const }),
2488
+ ...(input.statusPurposes
2489
+ ? { statusPurposes: input.statusPurposes }
2490
+ : {}),
2092
2491
  recipient: input.recipient,
2093
- ...(input.templateUri
2094
- ? { templateUri: input.templateUri }
2095
- : { template: input.template! }),
2492
+ templateUri: input.templateUri,
2493
+ display: normalizePublicDisplay(input),
2096
2494
  ...(input.contractUri ? { contractUri: input.contractUri } : {}),
2097
- ...(input.templateData ? { templateData: input.templateData } : {}),
2098
2495
  ...(input.integrationId ? { integrationId: input.integrationId } : {}),
2099
2496
  ...(boost.id ? { credentialId: boost.id } : {}),
2100
2497
  ...(input.idempotencyKey
2101
2498
  ? { idempotencyKey: input.idempotencyKey }
2102
2499
  : {}),
2500
+ ...(isHolderEncryption ? { encryption: 'holder' as const } : {}),
2103
2501
  });
2104
2502
 
2105
2503
  if (prepared.completed) return prepared.completed;
2106
2504
 
2505
+ if (isHolderEncryption && !getPreparedRecipientKeyIds(prepared)) {
2506
+ throw new Error(HOLDER_MODE_PREPARE_UNSUPPORTED);
2507
+ }
2508
+
2107
2509
  const boostString = JSON.stringify(boost);
2108
2510
  const allowAutoAppendEvidence = !hasDynamicEvidenceTemplate(boostString);
2109
2511
 
@@ -2157,32 +2559,128 @@ export async function getLearnCardNetworkPlugin(
2157
2559
 
2158
2560
  boost = injectManagedRefreshService(boost, prepared.refreshService);
2159
2561
 
2562
+ if (
2563
+ isHolderEncryption &&
2564
+ !prepared.statusEntries?.length &&
2565
+ !boost.credentialStatus
2566
+ ) {
2567
+ throw new Error(
2568
+ 'Server did not bind status entries for this holder-mode send; refusing to send a managed credential without a public status binding.'
2569
+ );
2570
+ }
2571
+
2160
2572
  // Signing goes through the vc-plugin boundary, which prepares the
2161
2573
  // managed inline JSON-LD context — the service and its terms are
2162
2574
  // signed in one proof, never mutated afterwards.
2163
- const signedCredential = await issueCredentialWithNetworkStatus(
2164
- _learnCard,
2165
- client,
2166
- boost
2167
- );
2575
+ const signedCredential = boost.credentialStatus
2576
+ ? await _learnCard.invoke.issueCredential(
2577
+ prepareManagedRefreshContext(
2578
+ UnsignedVCValidator.parse(materializeCredentialFields(boost))
2579
+ )
2580
+ )
2581
+ : await issueCredentialWithNetworkStatus(
2582
+ _learnCard,
2583
+ client,
2584
+ boost,
2585
+ input.statusPurposes
2586
+ ? [
2587
+ ...new Set<BitstringCredentialStatusPurpose>([
2588
+ 'revocation',
2589
+ ...input.statusPurposes,
2590
+ ]),
2591
+ ]
2592
+ : undefined,
2593
+ prepared.statusEntries
2594
+ );
2595
+
2596
+ if (!isHolderEncryption) {
2597
+ // Validated handoff: the server re-derives the refresh ID from
2598
+ // the signed service, re-validates ownership/holder/proof,
2599
+ // records the idempotency intent, and returns the canonical
2600
+ // receipt.
2601
+ return client.boost.send.mutate({
2602
+ type: 'boost',
2603
+ recipient: input.recipient,
2604
+ display: normalizePublicDisplay(input),
2605
+ statusPurposes: input.statusPurposes,
2606
+ contractUri: input.contractUri,
2607
+ integrationId: input.integrationId,
2608
+ idempotencyKey: input.idempotencyKey,
2609
+ templateUri: prepared.boostUri,
2610
+ signedCredential,
2611
+ refresh: true,
2612
+ });
2613
+ }
2168
2614
 
2169
- // Validated handoff: the server re-derives the refresh ID from the
2170
- // signed service, re-validates ownership/holder/proof, records the
2171
- // idempotency intent, and returns the canonical receipt.
2172
- return client.boost.send.mutate({
2173
- ...input,
2174
- template: undefined,
2175
- templateUri: prepared.boostUri,
2176
- signedCredential,
2177
- refresh: true,
2615
+ // LC-2345 holder mode: the signed credential never leaves this
2616
+ // process in plaintext from here on. Only its ciphertext (exactly
2617
+ // to the current holder + managers) and the public bindings already
2618
+ // disclosed by `prepared` cross the wire.
2619
+ //
2620
+ // `recipientKeyIds` is captured into a local immediately after the
2621
+ // guard (rather than re-reading `prepared.recipientKeyIds` below) so
2622
+ // the non-optional type holds across the later `await` calls.
2623
+ const initialRecipientKeyIds = getPreparedRecipientKeyIds(prepared);
2624
+
2625
+ if (!initialRecipientKeyIds) {
2626
+ throw new Error(HOLDER_MODE_PREPARE_UNSUPPORTED);
2627
+ }
2628
+
2629
+ const boundCredentialStatus = BitstringStatusListEntryValidator.or(
2630
+ BitstringStatusListEntryValidator.array()
2631
+ ).parse(signedCredential.credentialStatus);
2632
+ const holderBindings = {
2633
+ refreshId: prepared.refreshId,
2634
+ credentialId: prepared.credentialId,
2635
+ holderDid: prepared.holderDid,
2636
+ issuerDid: _learnCard.id.did(),
2637
+ credentialStatus: boundCredentialStatus,
2638
+ refreshService: ManagedCredentialRefreshBindingValidator.parse(
2639
+ signedCredential.refreshService
2640
+ ),
2641
+ };
2642
+
2643
+ assertManagedCredentialBinding(signedCredential, {
2644
+ ...holderBindings,
2645
+ encryptionMode: 'holder',
2178
2646
  });
2647
+
2648
+ const holderClient =
2649
+ client.credentialRefresh as unknown as HolderEncryptionClient;
2650
+ const delivered = await deliverHolderEncryptedHandoff(
2651
+ holderClient,
2652
+ prepared.refreshId,
2653
+ initialRecipientKeyIds,
2654
+ recipientKeyIds =>
2655
+ buildHolderEncryptedHandoff(
2656
+ _learnCard,
2657
+ signedCredential,
2658
+ holderBindings,
2659
+ recipientKeyIds
2660
+ ),
2661
+ handoff =>
2662
+ holderClient.sendHolderEncryptedRefreshable.mutate({
2663
+ handoff,
2664
+ boostUri: prepared.boostUri,
2665
+ idempotencyKey: input.idempotencyKey,
2666
+ })
2667
+ );
2668
+
2669
+ return {
2670
+ type: 'boost' as const,
2671
+ uri: prepared.boostUri,
2672
+ credentialUri: delivered.uri,
2673
+ activityId: delivered.activityId,
2674
+ statusManagement: delivered.statusManagement,
2675
+ refresh: holderBindings,
2676
+ };
2179
2677
  }
2180
2678
 
2181
2679
  // No local signing capability (or caller-supplied signedCredential,
2182
2680
  // which is forwarded untouched — its proof is never mutated):
2183
2681
  // delegate the unchanged refresh request to the server signing
2184
2682
  // authority path.
2185
- return client.boost.send.mutate(input);
2683
+ return client.boost.send.mutate(request);
2186
2684
  }
2187
2685
 
2188
2686
  if (input.type === 'boost') {
@@ -2202,6 +2700,7 @@ export async function getLearnCardNetworkPlugin(
2202
2700
 
2203
2701
  if (isRemoteDidWebRecipient && input.templateUri) {
2204
2702
  const boostRecord = await _learnCard.invoke.getBoost(input.templateUri);
2703
+ assertIssuableBoostTemplate(boostRecord.boost);
2205
2704
  let boost = boostRecord.boost;
2206
2705
  const boostString = JSON.stringify(boost);
2207
2706
  const allowAutoAppendEvidence = !hasDynamicEvidenceTemplate(boostString);
@@ -2271,6 +2770,7 @@ export async function getLearnCardNetworkPlugin(
2271
2770
  _learnCard,
2272
2771
  input.templateUri
2273
2772
  );
2773
+ assertIssuableBoostTemplate(result);
2274
2774
  const data = await UnsignedVCValidator.spa(result);
2275
2775
 
2276
2776
  if (data.success) {
@@ -2379,18 +2879,27 @@ export async function getLearnCardNetworkPlugin(
2379
2879
  return client.contracts.createConsentFlowContract.mutate(contract);
2380
2880
  },
2381
2881
 
2382
- addAutoBoostsToContract: async (_learnCard, contractUri, autoboosts) => {
2383
- await ensureUser();
2384
-
2385
- return client.contracts.addAutoBoostsToContract.mutate({ contractUri, autoboosts });
2386
- },
2387
-
2388
- removeAutoBoostsFromContract: async (_learnCard, contractUri, boostUris) => {
2882
+ updateContract: async (_learnCard, uri, changes, options = {}) => {
2883
+ const { read, write, contract, ...details } = changes;
2884
+ if ((read && contract?.read) || (write && contract?.write)) {
2885
+ throw new Error(
2886
+ 'Contract read/write updates must use either the top-level or nested contract shape, not both.'
2887
+ );
2888
+ }
2389
2889
  await ensureUser();
2390
-
2391
- return client.contracts.removeAutoBoostsFromContract.mutate({
2392
- contractUri,
2393
- boostUris,
2890
+ return client.contracts.updateConsentFlowContract.mutate({
2891
+ uri,
2892
+ changes: {
2893
+ ...details,
2894
+ ...((contract || read || write) && {
2895
+ contract: {
2896
+ ...contract,
2897
+ ...(read && { read }),
2898
+ ...(write && { write }),
2899
+ },
2900
+ }),
2901
+ },
2902
+ ...options,
2394
2903
  });
2395
2904
  },
2396
2905
 
@@ -2405,6 +2914,15 @@ export async function getLearnCardNetworkPlugin(
2405
2914
  getContract: async (_learnCard, uri) => {
2406
2915
  return client.contracts.getConsentFlowContract.query({ uri });
2407
2916
  },
2917
+ getContractVersions: async (_learnCard, uri) => {
2918
+ await ensureUser();
2919
+ return client.contracts.getContractVersions.query({ uri });
2920
+ },
2921
+
2922
+ getConsentStatus: async (_learnCard, uri, profileId) => {
2923
+ await ensureUser();
2924
+ return client.contracts.getConsentStatus.query({ uri, profileId });
2925
+ },
2408
2926
 
2409
2927
  getContracts: async (_learnCard, options) => {
2410
2928
  await ensureUser();
@@ -2493,6 +3011,15 @@ export async function getLearnCardNetworkPlugin(
2493
3011
  audienceVersion,
2494
3012
  });
2495
3013
  },
3014
+ acceptContractUpdate: async (_learnCard, uri, { version, terms, audienceVersion }) => {
3015
+ await ensureUser();
3016
+ return client.contracts.updateConsentedContractTerms.mutate({
3017
+ uri,
3018
+ terms,
3019
+ acceptVersion: version,
3020
+ audienceVersion,
3021
+ });
3022
+ },
2496
3023
 
2497
3024
  withdrawConsent: async (_learnCard, uri) => {
2498
3025
  await ensureUser();
@@ -2868,7 +3395,13 @@ export async function getLearnCardNetworkPlugin(
2868
3395
  );
2869
3396
  return [];
2870
3397
  });
2871
- return { ...result, records, failed: results.length - records.length };
3398
+ const { cursor, ...page } = result;
3399
+ return {
3400
+ ...page,
3401
+ ...(cursor === undefined ? {} : { cursor }),
3402
+ records,
3403
+ failed: results.length - records.length,
3404
+ };
2872
3405
  },
2873
3406
  sendGuardianApprovalEmail: async (_learnCard, options) => {
2874
3407
  await ensureUser();
@@ -3306,10 +3839,33 @@ export async function getLearnCardNetworkPlugin(
3306
3839
  return client.appStore.isAdmin.query();
3307
3840
  },
3308
3841
 
3842
+ getAnnouncementPreferences: async _learnCard => {
3843
+ await ensureUser();
3844
+ return client.campaigns.getAnnouncementPreferences.query();
3845
+ },
3846
+ updateAnnouncementPreferences: async (_learnCard, updates) => {
3847
+ await ensureUser();
3848
+ return client.campaigns.updateAnnouncementPreferences.mutate(updates);
3849
+ },
3850
+ unsubscribeFromAnnouncements: async (_learnCard, token) => {
3851
+ return getAnonymousClient(url).campaigns.unsubscribe.mutate({ token });
3852
+ },
3309
3853
  campaignsIsAdmin: async _learnCard => {
3310
3854
  await ensureUser();
3311
3855
  return client.campaigns.isAdmin.query();
3312
3856
  },
3857
+ campaignsSendTestToMe: async (_learnCard, campaignId) => {
3858
+ await ensureUser();
3859
+ return client.campaigns.sendTestToMe.mutate({ campaignId });
3860
+ },
3861
+ campaignsPreviewDispatch: async (_learnCard, campaignId) => {
3862
+ await ensureUser();
3863
+ return client.campaigns.previewDispatch.query({ campaignId });
3864
+ },
3865
+ campaignsDispatch: async (_learnCard, campaignId, options = {}) => {
3866
+ await ensureUser();
3867
+ return client.campaigns.dispatch.mutate({ campaignId, ...options });
3868
+ },
3313
3869
  campaignsCreate: async (_learnCard, input) => {
3314
3870
  await ensureUser();
3315
3871
  return client.campaigns.create.mutate(input);
@@ -3352,11 +3908,21 @@ export async function getLearnCardNetworkPlugin(
3352
3908
  },
3353
3909
  getMyCampaigns: async _learnCard => {
3354
3910
  await ensureUser();
3355
- return client.campaigns.getMyCampaigns.query();
3911
+ const response = await client.campaigns.getMyCampaigns.query();
3912
+ if (!Array.isArray(response))
3913
+ throw new Error('Invalid campaigns response: expected an array');
3914
+ return response.flatMap(item => {
3915
+ const result = MyCampaignValidator.safeParse(item);
3916
+ return result.success ? [result.data] : [];
3917
+ });
3356
3918
  },
3357
- recordCampaignEvent: async (_learnCard, campaignId, event) => {
3919
+ recordCampaignEvent: async (_learnCard, campaignId, event, testId) => {
3358
3920
  await ensureUser();
3359
- return client.campaigns.recordEvent.mutate({ campaignId, event });
3921
+ return client.campaigns.recordEvent.mutate({
3922
+ campaignId,
3923
+ event,
3924
+ ...(testId ? { testId } : {}),
3925
+ });
3360
3926
  },
3361
3927
  enrollInCampaign: async (_learnCard, campaignId) => {
3362
3928
  await ensureUser();