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