@learncard/network-plugin 3.2.2 → 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/README.md +18 -123
- package/dist/lcn-plugin.cjs.development.cjs +986 -79
- package/dist/lcn-plugin.cjs.development.cjs.map +3 -3
- package/dist/lcn-plugin.cjs.production.min.cjs +29 -29
- package/dist/lcn-plugin.cjs.production.min.cjs.map +3 -3
- package/dist/lcn-plugin.esm.js +992 -81
- package/dist/lcn-plugin.esm.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/test/holder-send.test.d.ts +2 -0
- package/dist/test/holder-send.test.d.ts.map +1 -0
- package/dist/types.d.ts +95 -12
- package/dist/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/plugin.ts +604 -38
- package/src/test/campaigns.test.ts +87 -1
- package/src/test/holder-send.test.ts +735 -0
- package/src/test/index.test.ts +71 -0
- package/src/test/refresh-send.test.ts +149 -4
- package/src/types.ts +122 -10
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:
|
|
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
|
-
|
|
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
|
-
|
|
2094
|
-
|
|
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 =
|
|
2164
|
-
_learnCard
|
|
2165
|
-
|
|
2166
|
-
|
|
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
|
-
//
|
|
2170
|
-
//
|
|
2171
|
-
//
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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(
|
|
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
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
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
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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();
|