@learncard/network-plugin 2.14.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/inbox-batch.d.ts +5 -0
- package/dist/inbox-batch.d.ts.map +1 -0
- package/dist/inbox-batch.test.d.ts +2 -0
- package/dist/inbox-batch.test.d.ts.map +1 -0
- package/dist/lcn-plugin.cjs.development.cjs +670 -246
- package/dist/lcn-plugin.cjs.development.cjs.map +4 -4
- package/dist/lcn-plugin.cjs.production.min.cjs +36 -36
- package/dist/lcn-plugin.cjs.production.min.cjs.map +4 -4
- package/dist/lcn-plugin.esm.js +672 -246
- package/dist/lcn-plugin.esm.js.map +4 -4
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/test/act-as.test.d.ts +2 -0
- package/dist/test/act-as.test.d.ts.map +1 -0
- package/dist/test/refresh-send.test-d.d.ts +2 -0
- package/dist/test/refresh-send.test-d.d.ts.map +1 -0
- package/dist/test/refresh-send.test.d.ts +2 -0
- package/dist/test/refresh-send.test.d.ts.map +1 -0
- package/dist/test/verify-boost-regression.test.d.ts +2 -0
- package/dist/test/verify-boost-regression.test.d.ts.map +1 -0
- package/dist/types.d.ts +93 -19
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/inbox-batch.test.ts +105 -0
- package/src/inbox-batch.ts +66 -0
- package/src/plugin.ts +377 -63
- package/src/test/act-as.test.ts +323 -0
- package/src/test/index.test.ts +161 -10
- package/src/test/refresh-send.test-d.ts +152 -0
- package/src/test/refresh-send.test.ts +646 -0
- package/src/test/verify-boost-regression.test.ts +167 -0
- package/src/types.ts +118 -22
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
|
-
*
|
|
447
|
-
*
|
|
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
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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,
|
|
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
|
-
|
|
638
|
+
mergedExtraHeaders
|
|
572
639
|
);
|
|
573
640
|
|
|
574
641
|
let userData: LCNProfile | undefined;
|
|
@@ -857,6 +924,31 @@ export async function getLearnCardNetworkPlugin(
|
|
|
857
924
|
getManagedProfiles: async (_learnCard, options = {}) => {
|
|
858
925
|
return client.profileManager.getManagedProfiles.query(options);
|
|
859
926
|
},
|
|
927
|
+
actAs: async (_learnCard, profileId) => {
|
|
928
|
+
const actingOptions: LearnCardNetworkPluginOptions = {
|
|
929
|
+
...resolvedOptions,
|
|
930
|
+
actAs: profileId,
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const actingPlugin = apiToken
|
|
934
|
+
? await getLearnCardNetworkPlugin(_learnCard, url, apiToken, actingOptions)
|
|
935
|
+
: await getLearnCardNetworkPlugin(_learnCard, url, actingOptions);
|
|
936
|
+
|
|
937
|
+
// The plugin's own initial getProfile is non-fatal by design, so an act-as
|
|
938
|
+
// denial would otherwise only surface later as "Please make an account first!".
|
|
939
|
+
try {
|
|
940
|
+
await actingPlugin.methods.getLCNClient(_learnCard).profile.getProfile.query();
|
|
941
|
+
} catch (error) {
|
|
942
|
+
if (isActAsDenial(error)) {
|
|
943
|
+
const message =
|
|
944
|
+
error instanceof Error ? error.message : 'Act-as request was denied.';
|
|
945
|
+
throw new Error(message, { cause: error });
|
|
946
|
+
}
|
|
947
|
+
_learnCard.debug?.('LCN actAs: getProfile failed (non-fatal)', error);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return _learnCard.addPlugin(actingPlugin);
|
|
951
|
+
},
|
|
860
952
|
claimPendingGuardianLinks: async () => {
|
|
861
953
|
await ensureUser();
|
|
862
954
|
return client.inbox.claimPendingGuardianLinks.mutate({});
|
|
@@ -1703,7 +1795,7 @@ export async function getLearnCardNetworkPlugin(
|
|
|
1703
1795
|
|
|
1704
1796
|
const enableRefresh = typeof options === 'object' && options.enableRefresh === true;
|
|
1705
1797
|
|
|
1706
|
-
let
|
|
1798
|
+
let refreshAllocation: AllocateCredentialRefreshResult | undefined;
|
|
1707
1799
|
|
|
1708
1800
|
if (enableRefresh) {
|
|
1709
1801
|
// Generate a stable UUID credential ID when the template has none;
|
|
@@ -1711,15 +1803,16 @@ export async function getLearnCardNetworkPlugin(
|
|
|
1711
1803
|
if (!boost.id) boost.id = `urn:uuid:${crypto.randomUUID()}`;
|
|
1712
1804
|
|
|
1713
1805
|
// Allocate BEFORE signing: the refresh service must be part of the
|
|
1714
|
-
// signed payload, so it is injected before proof creation.
|
|
1715
|
-
|
|
1806
|
+
// signed payload, so it is injected before proof creation. The
|
|
1807
|
+
// allocation descriptor is retained through signing so the receipt
|
|
1808
|
+
// can be returned alongside the issued credential URI.
|
|
1809
|
+
refreshAllocation =
|
|
1716
1810
|
await client.credentialRefresh.allocateCredentialRefresh.mutate({
|
|
1717
1811
|
holder: { profileId, did: targetProfile.did },
|
|
1718
1812
|
credentialId: boost.id,
|
|
1719
1813
|
});
|
|
1720
1814
|
|
|
1721
|
-
|
|
1722
|
-
boost = injectManagedRefreshService(boost, allocation.refreshService);
|
|
1815
|
+
boost = injectManagedRefreshService(boost, refreshAllocation.refreshService);
|
|
1723
1816
|
}
|
|
1724
1817
|
|
|
1725
1818
|
const statusPurposes =
|
|
@@ -1731,20 +1824,37 @@ export async function getLearnCardNetworkPlugin(
|
|
|
1731
1824
|
statusPurposes
|
|
1732
1825
|
);
|
|
1733
1826
|
|
|
1734
|
-
if (
|
|
1827
|
+
if (refreshAllocation) {
|
|
1735
1828
|
// Dedicated managed send: brain-service verifies the proof and
|
|
1736
1829
|
// persists ONLY a holder-encrypted JWE. Legacy credential storage
|
|
1737
|
-
// (issuer/LCN-readable JWE or plaintext) is intentionally bypassed
|
|
1830
|
+
// (issuer/LCN-readable JWE or plaintext) is intentionally bypassed —
|
|
1831
|
+
// managed encryption is mandatory even when `encrypt: false`.
|
|
1738
1832
|
// The boost URI is forwarded so the credential stays linked
|
|
1739
1833
|
// INSTANCE_OF the boost for canonical recipient management.
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1834
|
+
const credentialUri =
|
|
1835
|
+
await client.credentialRefresh.sendRefreshableCredential.mutate({
|
|
1836
|
+
refreshId: refreshAllocation.refreshId,
|
|
1837
|
+
credential: vc,
|
|
1838
|
+
boostUri,
|
|
1839
|
+
...(typeof options === 'object' && options.skipNotification
|
|
1840
|
+
? { skipNotification: true }
|
|
1841
|
+
: {}),
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// Refresh-enabled callers get the issuance receipt needed to publish
|
|
1845
|
+
// future versions, populated from the actual signed version 1
|
|
1846
|
+
// (LC-2198 decision 4). Non-opt-in callers still receive a plain URI
|
|
1847
|
+
// string.
|
|
1848
|
+
return {
|
|
1849
|
+
credentialUri,
|
|
1850
|
+
refresh: buildManagedRefreshReceipt(
|
|
1851
|
+
vc,
|
|
1852
|
+
refreshAllocation,
|
|
1853
|
+
boost.id ?? '',
|
|
1854
|
+
targetProfile.did,
|
|
1855
|
+
_learnCard.id.did()
|
|
1856
|
+
),
|
|
1857
|
+
};
|
|
1748
1858
|
}
|
|
1749
1859
|
|
|
1750
1860
|
// options is allowed to be a boolean to maintain backwards compatibility
|
|
@@ -1828,6 +1938,145 @@ export async function getLearnCardNetworkPlugin(
|
|
|
1828
1938
|
send: async (_learnCard, input) => {
|
|
1829
1939
|
await ensureUser();
|
|
1830
1940
|
|
|
1941
|
+
if (input.type === 'boost' && input.refresh === true) {
|
|
1942
|
+
// LC-2198: dedicated managed-refresh branch — evaluated BEFORE the
|
|
1943
|
+
// ordinary local-signing / remote-DID / federation shortcuts so a
|
|
1944
|
+
// refresh request can never fall back to a non-refresh delivery.
|
|
1945
|
+
// Inbox credentials stay unsigned until a verified claimant binds their DID.
|
|
1946
|
+
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.recipient);
|
|
1947
|
+
const isPhone = /^\+?[\d\s-]{10,}$/.test(input.recipient.replace(/[\s-]/g, ''));
|
|
1948
|
+
if (isEmail || isPhone) return client.boost.send.mutate(input);
|
|
1949
|
+
|
|
1950
|
+
const canIssueLocally = 'issueCredential' in _learnCard.invoke;
|
|
1951
|
+
|
|
1952
|
+
if (
|
|
1953
|
+
canIssueLocally &&
|
|
1954
|
+
!input.signedCredential &&
|
|
1955
|
+
(input.templateUri || input.template)
|
|
1956
|
+
) {
|
|
1957
|
+
// Local signing. Load the template first (read-only) so a template-
|
|
1958
|
+
// supplied credential ID is honored, then let the server run every
|
|
1959
|
+
// managed-send guard, create/reuse the boost and allocate the refresh
|
|
1960
|
+
// in ONE step. With an idempotencyKey that step also makes the whole
|
|
1961
|
+
// call retryable; `completed` means this key already delivered.
|
|
1962
|
+
let boost: UnsignedVC;
|
|
1963
|
+
|
|
1964
|
+
if (input.templateUri) {
|
|
1965
|
+
const result = await getBoostTemplateForIssuance(
|
|
1966
|
+
_learnCard,
|
|
1967
|
+
input.templateUri
|
|
1968
|
+
);
|
|
1969
|
+
const data = await UnsignedVCValidator.spa(result);
|
|
1970
|
+
|
|
1971
|
+
if (!data.success)
|
|
1972
|
+
throw new Error('Did not get a valid boost from URI');
|
|
1973
|
+
|
|
1974
|
+
boost = data.data;
|
|
1975
|
+
} else {
|
|
1976
|
+
// Clone before preparation so template rendering never
|
|
1977
|
+
// mutates the caller's claims.
|
|
1978
|
+
boost = JSON.parse(
|
|
1979
|
+
JSON.stringify(input.template!.credential)
|
|
1980
|
+
) as UnsignedVC;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
const prepared = await client.boost.prepareRefreshableSend.mutate({
|
|
1984
|
+
recipient: input.recipient,
|
|
1985
|
+
...(input.templateUri
|
|
1986
|
+
? { templateUri: input.templateUri }
|
|
1987
|
+
: { template: input.template! }),
|
|
1988
|
+
...(input.contractUri ? { contractUri: input.contractUri } : {}),
|
|
1989
|
+
...(input.templateData ? { templateData: input.templateData } : {}),
|
|
1990
|
+
...(input.integrationId ? { integrationId: input.integrationId } : {}),
|
|
1991
|
+
...(boost.id ? { credentialId: boost.id } : {}),
|
|
1992
|
+
...(input.idempotencyKey
|
|
1993
|
+
? { idempotencyKey: input.idempotencyKey }
|
|
1994
|
+
: {}),
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
if (prepared.completed) return prepared.completed;
|
|
1998
|
+
|
|
1999
|
+
const boostString = JSON.stringify(boost);
|
|
2000
|
+
const allowAutoAppendEvidence = !hasDynamicEvidenceTemplate(boostString);
|
|
2001
|
+
|
|
2002
|
+
if (input.templateData && Object.keys(input.templateData).length > 0) {
|
|
2003
|
+
try {
|
|
2004
|
+
const rendered = renderTemplateJson(
|
|
2005
|
+
boostString,
|
|
2006
|
+
input.templateData
|
|
2007
|
+
);
|
|
2008
|
+
boost = JSON.parse(rendered);
|
|
2009
|
+
boost = appendTemplateEvidence(
|
|
2010
|
+
boost,
|
|
2011
|
+
input.templateData,
|
|
2012
|
+
allowAutoAppendEvidence
|
|
2013
|
+
);
|
|
2014
|
+
} catch (error) {
|
|
2015
|
+
throw new Error(
|
|
2016
|
+
`Failed to apply template data: ${
|
|
2017
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
2018
|
+
}`,
|
|
2019
|
+
{ cause: error }
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
if (isVC2Format(boost)) {
|
|
2025
|
+
boost.validFrom = new Date().toISOString();
|
|
2026
|
+
} else {
|
|
2027
|
+
boost.issuanceDate = new Date().toISOString();
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
boost.issuer = _learnCard.id.did();
|
|
2031
|
+
|
|
2032
|
+
if (Array.isArray(boost.credentialSubject)) {
|
|
2033
|
+
boost.credentialSubject = boost.credentialSubject.map(subject => ({
|
|
2034
|
+
...subject,
|
|
2035
|
+
id: prepared.holderDid,
|
|
2036
|
+
}));
|
|
2037
|
+
} else {
|
|
2038
|
+
boost.credentialSubject = {
|
|
2039
|
+
...boost.credentialSubject,
|
|
2040
|
+
id: prepared.holderDid,
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// The refresh aggregate is permanently bound to this ID.
|
|
2045
|
+
boost.id = prepared.credentialId;
|
|
2046
|
+
|
|
2047
|
+
if (boost.type?.includes('BoostCredential'))
|
|
2048
|
+
boost.boostId = prepared.boostUri;
|
|
2049
|
+
|
|
2050
|
+
boost = injectManagedRefreshService(boost, prepared.refreshService);
|
|
2051
|
+
|
|
2052
|
+
// Signing goes through the vc-plugin boundary, which prepares the
|
|
2053
|
+
// managed inline JSON-LD context — the service and its terms are
|
|
2054
|
+
// signed in one proof, never mutated afterwards.
|
|
2055
|
+
const signedCredential = await issueCredentialWithNetworkStatus(
|
|
2056
|
+
_learnCard,
|
|
2057
|
+
client,
|
|
2058
|
+
boost
|
|
2059
|
+
);
|
|
2060
|
+
|
|
2061
|
+
// Validated handoff: the server re-derives the refresh ID from the
|
|
2062
|
+
// signed service, re-validates ownership/holder/proof, records the
|
|
2063
|
+
// idempotency intent, and returns the canonical receipt.
|
|
2064
|
+
return client.boost.send.mutate({
|
|
2065
|
+
...input,
|
|
2066
|
+
template: undefined,
|
|
2067
|
+
templateUri: prepared.boostUri,
|
|
2068
|
+
signedCredential,
|
|
2069
|
+
refresh: true,
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// No local signing capability (or caller-supplied signedCredential,
|
|
2074
|
+
// which is forwarded untouched — its proof is never mutated):
|
|
2075
|
+
// delegate the unchanged refresh request to the server signing
|
|
2076
|
+
// authority path.
|
|
2077
|
+
return client.boost.send.mutate(input);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1831
2080
|
if (input.type === 'boost') {
|
|
1832
2081
|
const recipient = input.recipient;
|
|
1833
2082
|
const isDid = recipient.startsWith('did:');
|
|
@@ -2393,6 +2642,37 @@ export async function getLearnCardNetworkPlugin(
|
|
|
2393
2642
|
|
|
2394
2643
|
return client.inbox.issue.mutate(issueInboxCredential);
|
|
2395
2644
|
},
|
|
2645
|
+
sendCredentialsViaInbox: async (_learnCard, batch) => {
|
|
2646
|
+
await ensureUser();
|
|
2647
|
+
return client.inbox.issueBatch.mutate(batch);
|
|
2648
|
+
},
|
|
2649
|
+
sendCredentialBatchViaInbox: async (_learnCard, batch) => {
|
|
2650
|
+
await ensureUser();
|
|
2651
|
+
return client.inbox.issueBatch.mutate(batch);
|
|
2652
|
+
},
|
|
2653
|
+
waitForInboxCredentialBatch: async (_learnCard, batchId, options) => {
|
|
2654
|
+
await ensureUser();
|
|
2655
|
+
return waitForInboxCredentialBatch(
|
|
2656
|
+
signal => client.inbox.getBatch.query({ batchId }, { signal }),
|
|
2657
|
+
options
|
|
2658
|
+
);
|
|
2659
|
+
},
|
|
2660
|
+
sendCredentialsViaInboxAndWait: async (_learnCard, batch, options) => {
|
|
2661
|
+
await ensureUser();
|
|
2662
|
+
if (options?.signal?.aborted) throw options.signal.reason;
|
|
2663
|
+
const receipt = await client.inbox.issueBatch.mutate(batch, {
|
|
2664
|
+
signal: options?.signal,
|
|
2665
|
+
});
|
|
2666
|
+
await options?.onSubmitted?.(receipt);
|
|
2667
|
+
return waitForInboxCredentialBatch(
|
|
2668
|
+
signal => client.inbox.getBatch.query({ batchId: receipt.batchId }, { signal }),
|
|
2669
|
+
options
|
|
2670
|
+
);
|
|
2671
|
+
},
|
|
2672
|
+
getInboxCredentialBatch: async (_learnCard, batchId) => {
|
|
2673
|
+
await ensureUser();
|
|
2674
|
+
return client.inbox.getBatch.query({ batchId });
|
|
2675
|
+
},
|
|
2396
2676
|
getMySentInboxCredentials: async (_learnCard, options) => {
|
|
2397
2677
|
await ensureUser();
|
|
2398
2678
|
|
|
@@ -2974,6 +3254,13 @@ export const getVerifyBoostPlugin = async (
|
|
|
2974
3254
|
if (!issuerDID) return;
|
|
2975
3255
|
return boostRegistry.find(o => o.did === issuerDID);
|
|
2976
3256
|
};
|
|
3257
|
+
const getTrustedBoostNetwork = (boostId: unknown): TrustedBoostRegistryEntry | undefined => {
|
|
3258
|
+
if (typeof boostId !== 'string') return;
|
|
3259
|
+
const match = /^lc:network:([^?#\s]+)\/(?:trpc:)?boost:([^/?#\s]+)$/.exec(boostId);
|
|
3260
|
+
if (!match) return;
|
|
3261
|
+
const networkDid = `did:web:${match[1]!.replace(/\//g, ':')}`;
|
|
3262
|
+
return boostRegistry.find(entry => entry.did === networkDid);
|
|
3263
|
+
};
|
|
2977
3264
|
return {
|
|
2978
3265
|
name: 'VerifyBoost',
|
|
2979
3266
|
displayName: 'Verify Boost Extension',
|
|
@@ -2984,20 +3271,31 @@ export const getVerifyBoostPlugin = async (
|
|
|
2984
3271
|
credential,
|
|
2985
3272
|
options
|
|
2986
3273
|
);
|
|
3274
|
+
const hasOuterVerificationErrors = Boolean(verificationCheck.errors?.length);
|
|
2987
3275
|
const boostCredential = credential?.boostCredential;
|
|
2988
3276
|
try {
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3277
|
+
// Legacy credentials contain a separately signed inner VC. New
|
|
3278
|
+
// credentials are the issuer-signed VC itself with boostId metadata.
|
|
3279
|
+
const boostId = boostCredential?.boostId ?? credential?.boostId;
|
|
3280
|
+
if (
|
|
3281
|
+
boostCredential ||
|
|
3282
|
+
credential?.boostId ||
|
|
3283
|
+
credential?.type?.includes('BoostCredential')
|
|
3284
|
+
) {
|
|
3285
|
+
const verifyBoostCredential = boostCredential
|
|
3286
|
+
? await learnCard.invoke.verifyCredential(boostCredential)
|
|
3287
|
+
: verificationCheck;
|
|
3288
|
+
const boostCredentialErrors = boostCredential
|
|
3289
|
+
? (verifyBoostCredential.errors ?? [])
|
|
3290
|
+
: [];
|
|
3291
|
+
if (boostCredential && verifyBoostCredential.status?.length) {
|
|
2994
3292
|
verificationCheck.status = [
|
|
2995
3293
|
...(verificationCheck.status ?? []),
|
|
2996
3294
|
...verifyBoostCredential.status,
|
|
2997
3295
|
];
|
|
2998
3296
|
}
|
|
2999
3297
|
|
|
3000
|
-
if (!
|
|
3298
|
+
if (!boostId) {
|
|
3001
3299
|
verificationCheck.warnings.push(
|
|
3002
3300
|
'Boost Authenticity could not be verified: Boost ID metadata is missing.'
|
|
3003
3301
|
);
|
|
@@ -3019,21 +3317,37 @@ export const getVerifyBoostPlugin = async (
|
|
|
3019
3317
|
...(verificationCheck.errors || []),
|
|
3020
3318
|
'Boost Credential could not be verified.',
|
|
3021
3319
|
];
|
|
3022
|
-
} else if (
|
|
3320
|
+
} else if (hasOuterVerificationErrors) {
|
|
3321
|
+
verificationCheck.warnings.push(
|
|
3322
|
+
'Boost Authenticity could not be verified: Credential verification failed.'
|
|
3323
|
+
);
|
|
3324
|
+
} else if (!boostId) {
|
|
3325
|
+
// The missing metadata warning above explains why trust is unknown.
|
|
3326
|
+
} else if (
|
|
3327
|
+
boostCredential &&
|
|
3328
|
+
boostCredential.boostId !== credential.boostId
|
|
3329
|
+
) {
|
|
3023
3330
|
verificationCheck.errors.push(
|
|
3024
3331
|
'Boost Authenticity could not be verified: Boost ID metadata is mismatched.'
|
|
3025
3332
|
);
|
|
3026
3333
|
} else {
|
|
3027
|
-
|
|
3334
|
+
// A direct VC is signed by the issuing authority, not by the
|
|
3335
|
+
// network. Its signed boostId identifies the associated network.
|
|
3336
|
+
// Legacy wrappers still establish trust through the outer issuer.
|
|
3337
|
+
const trustedBoostIssuer = boostCredential
|
|
3338
|
+
? getTrustedBoostVerifier(credential?.issuer)
|
|
3339
|
+
: getTrustedBoostNetwork(boostId);
|
|
3028
3340
|
if (trustedBoostIssuer) {
|
|
3029
3341
|
verificationCheck.checks.push(
|
|
3030
3342
|
`Boost is Authentic. Verified by ${trustedBoostIssuer.id}.`
|
|
3031
3343
|
);
|
|
3032
3344
|
} else {
|
|
3033
3345
|
verificationCheck.warnings.push(
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3346
|
+
boostCredential
|
|
3347
|
+
? `Boost Authenticity could not be verified. Issuer is outside of trust network: ${getIssuerDID(
|
|
3348
|
+
credential?.issuer
|
|
3349
|
+
)}`
|
|
3350
|
+
: `Boost Authenticity could not be verified. Boost ID does not identify a trusted network: ${boostId}`
|
|
3037
3351
|
);
|
|
3038
3352
|
}
|
|
3039
3353
|
}
|