@learncard/network-brain-service 3.17.0 → 3.19.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/.env.example +4 -0
- package/AGENTS.md +177 -159
- package/CHANGELOG.md +148 -0
- package/README.md +26 -8
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
- package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
- package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
- package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.d.ts +2 -1
- package/dist/accesslayer/credential/create.d.ts.map +1 -1
- package/dist/accesslayer/credential/create.test.d.ts +2 -0
- package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
- package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
- package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
- package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
- package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
- package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
- package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
- package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
- package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
- package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
- package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
- package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
- package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
- package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
- package/dist/cache/atomic-counter.test.d.ts +2 -0
- package/dist/cache/atomic-counter.test.d.ts.map +1 -0
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/config/environment.d.ts +46 -0
- package/dist/config/environment.d.ts.map +1 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.helpers.d.ts.map +1 -1
- package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
- package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
- package/dist/helpers/boost.helpers.d.ts +3 -7
- package/dist/helpers/boost.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
- package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
- package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
- package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
- package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
- package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
- package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
- package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
- package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
- package/dist/helpers/credential.helpers.d.ts +4 -2
- package/dist/helpers/credential.helpers.d.ts.map +1 -1
- package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
- package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
- package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
- package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
- package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
- package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
- package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
- package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
- package/dist/helpers/inbox.helpers.d.ts +9 -1
- package/dist/helpers/inbox.helpers.d.ts.map +1 -1
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
- package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
- package/dist/helpers/notifications.helpers.d.ts.map +1 -1
- package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
- package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
- package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
- package/dist/helpers/shim.d.ts +1 -1
- package/dist/helpers/shim.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
- package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
- package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
- package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
- package/dist/helpers/status-list.helpers.d.ts.map +1 -1
- package/dist/helpers/status-list.helpers.test.d.ts +2 -0
- package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
- package/dist/inbox-queue-local.d.ts +2 -0
- package/dist/inbox-queue-local.d.ts.map +1 -0
- package/dist/index.js +2 -2
- package/dist/models/AuthGrant.d.ts.map +1 -1
- package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
- package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
- package/dist/models/Credential.d.ts +1 -0
- package/dist/models/Credential.d.ts.map +1 -1
- package/dist/models/CredentialActivity.d.ts +1 -0
- package/dist/models/CredentialActivity.d.ts.map +1 -1
- package/dist/models/CredentialRefresh.d.ts +5 -2
- package/dist/models/CredentialRefresh.d.ts.map +1 -1
- package/dist/models/InboxCredential.d.ts +1 -0
- package/dist/models/InboxCredential.d.ts.map +1 -1
- package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
- package/dist/openapi.d.ts.map +1 -1
- package/dist/routes/act-as.test.d.ts +2 -0
- package/dist/routes/act-as.test.d.ts.map +1 -0
- package/dist/routes/activity.d.ts +3 -0
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/app-store.d.ts.map +1 -1
- package/dist/routes/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +506 -12
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/contracts.d.ts +48 -2
- package/dist/routes/contracts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts +4 -4
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts +8 -2
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/did-metadata.d.ts +8 -8
- package/dist/routes/federation.d.ts +2 -2
- package/dist/routes/inbox.d.ts +973 -31
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +47 -127
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/presentations.d.ts +6 -6
- package/dist/routes/storage.d.ts +63 -63
- package/dist/routes/test.d.ts +6 -0
- package/dist/routes/test.d.ts.map +1 -1
- package/dist/routes/workflows.d.ts +14 -14
- package/dist/routes/workflows.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
- package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
- package/dist/services/registry/registry.factory.d.ts.map +1 -1
- package/dist/types/activity.d.ts +4 -0
- package/dist/types/activity.d.ts.map +1 -1
- package/dist/types/consentflowcontract.d.ts +22 -8
- package/dist/types/consentflowcontract.d.ts.map +1 -1
- package/dist/types/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- package/dist/types/credential.d.ts +7 -0
- package/dist/types/credential.d.ts.map +1 -1
- package/dist/types/did-metadata.d.ts +2 -2
- package/dist/types/inbox-batch.d.ts +38 -0
- package/dist/types/inbox-batch.d.ts.map +1 -0
- package/inbox-e2e-setup.ts +53 -1
- package/inbox-elasticmq.conf +10 -0
- package/lambda.ts +61 -16
- package/package.json +18 -16
- package/scripts/setup-inbox-queues.ts +60 -0
- package/serverless-local.yml +2 -0
- package/serverless.yml +75 -0
- package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
- package/src/accesslayer/auth-grant/read.ts +2 -1
- package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
- package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
- package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
- package/src/accesslayer/credential/create.test.ts +141 -0
- package/src/accesslayer/credential/create.ts +27 -3
- package/src/accesslayer/credential-activity/create.ts +1 -0
- package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
- package/src/accesslayer/credential-refresh/index.ts +1 -0
- package/src/accesslayer/credential-refresh/read.ts +8 -2
- package/src/accesslayer/credential-refresh/update.ts +4 -0
- package/src/accesslayer/inbox-batch/store.ts +469 -0
- package/src/accesslayer/inbox-credential/create.ts +51 -19
- package/src/accesslayer/inbox-credential/refresh.ts +221 -0
- package/src/accesslayer/inbox-credential/update.ts +1 -1
- package/src/cache/atomic-counter.test-helpers.ts +27 -0
- package/src/cache/atomic-counter.test.ts +6 -0
- package/src/cache/index.ts +25 -13
- package/src/config/environment.ts +26 -0
- package/src/credential-refresh.ts +5 -1
- package/src/docker-entry.ts +8 -0
- package/src/helpers/activity.helpers.ts +4 -0
- package/src/helpers/app-counter.helpers.ts +1 -1
- package/src/helpers/boost-storage.helpers.test.ts +242 -0
- package/src/helpers/boost.helpers.ts +58 -437
- package/src/helpers/contact-method.helpers.test.ts +43 -0
- package/src/helpers/contact-method.helpers.ts +3 -4
- package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
- package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
- package/src/helpers/credential-refresh-email.helpers.ts +201 -0
- package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
- package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
- package/src/helpers/credential-refresh.helpers.ts +385 -107
- package/src/helpers/credential.helpers.ts +6 -2
- package/src/helpers/finalize-inbox.helpers.ts +39 -22
- package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
- package/src/helpers/inbox-batch-http.helpers.ts +150 -0
- package/src/helpers/inbox-batch-status.helpers.ts +11 -0
- package/src/helpers/inbox-batch.helpers.test.ts +668 -0
- package/src/helpers/inbox-batch.helpers.ts +374 -0
- package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
- package/src/helpers/inbox-queue.helpers.test.ts +253 -0
- package/src/helpers/inbox-queue.helpers.ts +372 -0
- package/src/helpers/inbox-refresh.helpers.ts +432 -0
- package/src/helpers/inbox.helpers.ts +301 -50
- package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
- package/src/helpers/notifications.helpers.ts +4 -2
- package/src/helpers/rateLimit.helpers.ts +2 -5
- package/src/helpers/refresh-send-intent.helpers.ts +273 -0
- package/src/helpers/shim.ts +32 -31
- package/src/helpers/signingAuthority.helpers.test.ts +198 -0
- package/src/helpers/signingAuthority.helpers.ts +41 -18
- package/src/helpers/status-list.helpers.test.ts +77 -0
- package/src/helpers/status-list.helpers.ts +31 -8
- package/src/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.ts +1 -0
- package/src/models/ConsentFlowTerms.ts +4 -0
- package/src/models/ConsentFlowTransaction.ts +4 -0
- package/src/models/Credential.ts +1 -0
- package/src/models/CredentialActivity.ts +3 -5
- package/src/models/CredentialRefresh.ts +4 -2
- package/src/models/InboxCredential.ts +2 -0
- package/src/models/credential-refresh-constraints.test.ts +8 -3
- package/src/models/credential-refresh-constraints.ts +5 -0
- package/src/openapi.ts +8 -0
- package/src/routes/act-as.test.ts +239 -0
- package/src/routes/app-store.ts +69 -52
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +943 -120
- package/src/routes/contracts.ts +55 -7
- package/src/routes/credential-refreshes.test.ts +8 -2
- package/src/routes/credential-refreshes.ts +4 -1
- package/src/routes/credentials.ts +2 -1
- package/src/routes/inbox.ts +95 -49
- package/src/routes/index.ts +164 -88
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +80 -31
- package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
- package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
- package/src/services/registry/registry.factory.ts +2 -1
- package/src/types/activity.ts +2 -0
- package/src/types/consentflowcontract.ts +21 -3
- package/src/types/credential-refresh.ts +21 -2
- package/src/types/credential.ts +13 -1
- package/src/types/inbox-batch.ts +46 -0
- package/test/app-store-listing.spec.ts +66 -30
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/bitstring-status-list.spec.ts +12 -9
- package/test/consentflow.spec.ts +453 -26
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +13 -5
- package/test/guardian-gated-routes.spec.ts +337 -180
- package/test/helpers/inbox-e2e-environment.ts +6 -0
- package/test/inbox-batch.spec.ts +1338 -0
- package/test/inbox-refresh.spec.ts +534 -0
- package/test/inbox-security.e2e.ts +48 -3
- package/test/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/test/revoke-suspend-per-instance.spec.ts +9 -1
- package/vitest.config.ts +13 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -6,18 +6,27 @@ import {
|
|
|
6
6
|
VC,
|
|
7
7
|
type AllocateCredentialRefreshResult,
|
|
8
8
|
type CredentialRefreshSigningMode,
|
|
9
|
+
type ManagedCredentialRefreshReceipt,
|
|
10
|
+
type ManagedCredentialRefreshService,
|
|
9
11
|
type PublishCredentialRefreshInput,
|
|
10
12
|
type PublishCredentialRefreshNotification,
|
|
11
13
|
type PublishCredentialRefreshResult,
|
|
12
14
|
} from '@learncard/types';
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
getCredentialEffectiveTime,
|
|
17
|
+
getCredentialIssuerId,
|
|
18
|
+
getManagedRefreshServices,
|
|
19
|
+
prepareManagedRefreshContext,
|
|
20
|
+
} from '@learncard/helpers';
|
|
14
21
|
|
|
15
22
|
import { neogma } from '@instance';
|
|
16
23
|
|
|
17
24
|
import { storeCredential } from '@accesslayer/credential/create';
|
|
18
25
|
import { deleteCredential } from '@accesslayer/credential/delete';
|
|
19
26
|
import { getBoostByUri } from '@accesslayer/boost/read';
|
|
27
|
+
import { canProfileIssueBoost } from '@accesslayer/boost/relationships/read';
|
|
20
28
|
import { getProfileByProfileId } from '@accesslayer/profile/read';
|
|
29
|
+
import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
|
|
21
30
|
import {
|
|
22
31
|
advanceCredentialRefreshHead,
|
|
23
32
|
generateRefreshId,
|
|
@@ -34,7 +43,12 @@ import type {
|
|
|
34
43
|
CredentialRefreshVersionNode,
|
|
35
44
|
} from 'types/credential-refresh';
|
|
36
45
|
|
|
46
|
+
import { createCredentialIssuedViaContractRelationship } from '@accesslayer/credential/relationships/create';
|
|
47
|
+
import type { CredentialInstance } from '@models';
|
|
48
|
+
import type { DbTermsType } from 'types/consentflowcontract';
|
|
49
|
+
|
|
37
50
|
import { createDagJweForRecipients, getLearnCard } from './learnCard.helpers';
|
|
51
|
+
import { verifyManagedRefreshProof } from './credential-refresh-proof.helpers';
|
|
38
52
|
import { issueCredentialWithSigningAuthority } from './signingAuthority.helpers';
|
|
39
53
|
import {
|
|
40
54
|
computeCredentialMaterialDigest,
|
|
@@ -51,6 +65,9 @@ import {
|
|
|
51
65
|
buildInitialCredentialReceivedNotification,
|
|
52
66
|
buildCredentialRefreshedNotification,
|
|
53
67
|
} from './notifications.helpers';
|
|
68
|
+
import { deliverCredentialRefreshEmailNotification } from './credential-refresh-email.helpers';
|
|
69
|
+
import { extractBoundedCredentialDisplayTitle } from './credential-refresh-email-content.helpers';
|
|
70
|
+
import type { TenantBranding } from '@learncard/email-templates';
|
|
54
71
|
import { getDidWeb } from './did.helpers';
|
|
55
72
|
import { isRelationshipBlocked } from './connection.helpers';
|
|
56
73
|
import { ProfileType } from 'types/profile';
|
|
@@ -69,7 +86,7 @@ import { ProfileType } from 'types/profile';
|
|
|
69
86
|
export const getCredentialRefreshServiceUrl = (refreshId: string, domain: string): string =>
|
|
70
87
|
`${getStatusListBaseUrl(domain)}/refresh/${refreshId}`;
|
|
71
88
|
|
|
72
|
-
const getManagedCredentialUri = (id: string, domain: string): string =>
|
|
89
|
+
export const getManagedCredentialUri = (id: string, domain: string): string =>
|
|
73
90
|
constructUri('credential', id, domain);
|
|
74
91
|
|
|
75
92
|
export type AllocateCredentialRefreshParams = {
|
|
@@ -136,6 +153,134 @@ export const allocateCredentialRefresh = async (
|
|
|
136
153
|
};
|
|
137
154
|
};
|
|
138
155
|
|
|
156
|
+
export type ManagedRefreshHandoff = {
|
|
157
|
+
refreshId: string;
|
|
158
|
+
refreshService: ManagedCredentialRefreshService;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extracts and validates the local managed refresh service embedded in a signed
|
|
163
|
+
* credential (unified-send handoff, LC-2198 decision 6).
|
|
164
|
+
*
|
|
165
|
+
* Only THIS deployment's managed refresh endpoint origin/path is accepted: the
|
|
166
|
+
* service ID must be exactly the canonical URL for the refreshId it carries, and
|
|
167
|
+
* the refreshId is always derived from the service URL — never from caller-supplied
|
|
168
|
+
* receipt fields. Returns null when the credential carries no managed service;
|
|
169
|
+
* throws when it carries conflicting managed services or one pointing elsewhere
|
|
170
|
+
* (a third-party `1EdTechCredentialRefresh` service is not a handoff).
|
|
171
|
+
*/
|
|
172
|
+
export const extractManagedRefreshHandoff = (
|
|
173
|
+
credential: VC,
|
|
174
|
+
domain: string
|
|
175
|
+
): ManagedRefreshHandoff | null => {
|
|
176
|
+
const services = getManagedRefreshServices(credential);
|
|
177
|
+
|
|
178
|
+
if (services.length === 0) return null;
|
|
179
|
+
|
|
180
|
+
const distinctIds = new Set(services.map(service => service.id));
|
|
181
|
+
|
|
182
|
+
if (distinctIds.size > 1) {
|
|
183
|
+
throw new TRPCError({
|
|
184
|
+
code: 'BAD_REQUEST',
|
|
185
|
+
message:
|
|
186
|
+
'Credential carries conflicting managed refresh services; a credential can only reference one managed refresh service.',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const service = services[0]!;
|
|
191
|
+
const prefix = `${getStatusListBaseUrl(domain)}/refresh/`;
|
|
192
|
+
|
|
193
|
+
if (!service.id.startsWith(prefix) || service.id.slice(prefix.length).length === 0) {
|
|
194
|
+
throw new TRPCError({
|
|
195
|
+
code: 'BAD_REQUEST',
|
|
196
|
+
message:
|
|
197
|
+
"Credential refreshService does not reference this deployment's managed refresh endpoint.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const refreshId = service.id.slice(prefix.length);
|
|
202
|
+
|
|
203
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(refreshId)) {
|
|
204
|
+
throw new TRPCError({
|
|
205
|
+
code: 'BAD_REQUEST',
|
|
206
|
+
message:
|
|
207
|
+
"Credential refreshService does not reference this deployment's managed refresh endpoint.",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { refreshId, refreshService: service };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export type ManagedRefreshInitialBindingInfo = {
|
|
215
|
+
/** True when the refresh already has a bound root matching this exact credential. */
|
|
216
|
+
bound: boolean;
|
|
217
|
+
activityId?: string;
|
|
218
|
+
integrationId?: string;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reports whether the refresh already has a bound root for this exact credential so
|
|
223
|
+
* unified-send retries can reuse the original delivery activity instead of creating
|
|
224
|
+
* a duplicate. Throws CONFLICT when the refresh is bound to a DIFFERENT credential
|
|
225
|
+
* or boost — rejecting the retry before any write or activity logging.
|
|
226
|
+
*/
|
|
227
|
+
export const peekCredentialRefreshInitialBinding = async (params: {
|
|
228
|
+
refreshId: string;
|
|
229
|
+
credential: VC;
|
|
230
|
+
boostId?: string;
|
|
231
|
+
}): Promise<ManagedRefreshInitialBindingInfo> => {
|
|
232
|
+
const { refreshId, credential, boostId } = params;
|
|
233
|
+
|
|
234
|
+
const root = await getInitialRefreshRoot(refreshId);
|
|
235
|
+
|
|
236
|
+
if (!root) return { bound: false };
|
|
237
|
+
|
|
238
|
+
assertInitialCredentialMatches(
|
|
239
|
+
root,
|
|
240
|
+
computeCredentialMaterialDigest(credential as unknown as Record<string, unknown>),
|
|
241
|
+
computeCredentialStatusDigest(
|
|
242
|
+
(credential as unknown as Record<string, unknown>).credentialStatus
|
|
243
|
+
),
|
|
244
|
+
boostId
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const result = await neogma.queryRunner.run(
|
|
248
|
+
`MATCH (:Profile)-[sent:CREDENTIAL_SENT]->(root:Credential {id: $rootId})
|
|
249
|
+
RETURN sent.activityId AS activityId, sent.integrationId AS integrationId
|
|
250
|
+
LIMIT 1`,
|
|
251
|
+
{ rootId: root.rootId }
|
|
252
|
+
);
|
|
253
|
+
const row = result.records[0];
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
bound: true,
|
|
257
|
+
activityId: row?.get('activityId') ?? undefined,
|
|
258
|
+
integrationId: row?.get('integrationId') ?? undefined,
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Idempotently links a managed root credential to the approved consent-flow contract
|
|
264
|
+
* terms it was issued through, on both fresh binds and resume paths.
|
|
265
|
+
*/
|
|
266
|
+
const ensureCredentialIssuedViaContractRelationship = async (
|
|
267
|
+
credentialNodeId: string,
|
|
268
|
+
terms: DbTermsType
|
|
269
|
+
): Promise<void> => {
|
|
270
|
+
const existing = await neogma.queryRunner.run(
|
|
271
|
+
`MATCH (root:Credential {id: $nodeId})-[:ISSUED_VIA_TRANSACTION]->()
|
|
272
|
+
RETURN 1 AS one LIMIT 1`,
|
|
273
|
+
{ nodeId: credentialNodeId }
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
if (existing.records.length > 0) return;
|
|
277
|
+
|
|
278
|
+
await createCredentialIssuedViaContractRelationship(
|
|
279
|
+
{ id: credentialNodeId } as unknown as CredentialInstance,
|
|
280
|
+
terms
|
|
281
|
+
);
|
|
282
|
+
};
|
|
283
|
+
|
|
139
284
|
/** Collects the subject identifiers of a credential (single object or array form). */
|
|
140
285
|
const getCredentialSubjectIds = (credential: VC): string[] => {
|
|
141
286
|
const subjects = Array.isArray(credential.credentialSubject)
|
|
@@ -177,9 +322,21 @@ export type SendRefreshableCredentialParams = {
|
|
|
177
322
|
boostUri?: string;
|
|
178
323
|
/** Suppress the initial CREDENTIAL_RECEIVED notification for this delivery. */
|
|
179
324
|
skipNotification?: boolean;
|
|
325
|
+
/** Issuance activity to correlate (unified send, LC-2198). Stored on CREDENTIAL_SENT. */
|
|
326
|
+
activityId?: string;
|
|
327
|
+
/** Integration tracking for the unified send, stored on CREDENTIAL_SENT. */
|
|
328
|
+
integrationId?: string;
|
|
329
|
+
/** Approved consent-flow terms the credential was issued through (LC-2198). */
|
|
330
|
+
contractTerms?: DbTermsType;
|
|
180
331
|
domain: string;
|
|
181
332
|
};
|
|
182
333
|
|
|
334
|
+
/** Metadata-only issuance receipt derived from the signed version 1 and the validated allocation. */
|
|
335
|
+
export type SendRefreshableCredentialResult = {
|
|
336
|
+
uri: string;
|
|
337
|
+
receipt: ManagedCredentialRefreshReceipt;
|
|
338
|
+
};
|
|
339
|
+
|
|
183
340
|
type InitialRefreshRoot = {
|
|
184
341
|
rootId: string;
|
|
185
342
|
materialDigest?: string;
|
|
@@ -187,7 +344,9 @@ type InitialRefreshRoot = {
|
|
|
187
344
|
boostId?: string;
|
|
188
345
|
};
|
|
189
346
|
|
|
190
|
-
const getInitialRefreshRoot = async (
|
|
347
|
+
export const getInitialRefreshRoot = async (
|
|
348
|
+
refreshId: string
|
|
349
|
+
): Promise<InitialRefreshRoot | null> => {
|
|
191
350
|
const result = await neogma.queryRunner.run(
|
|
192
351
|
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
|
|
193
352
|
OPTIONAL MATCH (root)-[:INSTANCE_OF]->(boost:Boost)
|
|
@@ -211,6 +370,13 @@ const getInitialRefreshRoot = async (refreshId: string): Promise<InitialRefreshR
|
|
|
211
370
|
};
|
|
212
371
|
};
|
|
213
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Returns the boost a managed refresh's version 1 is already bound to, if any. Lets a
|
|
375
|
+
* replayed pre-signed send reuse its original boost instead of auto-creating another.
|
|
376
|
+
*/
|
|
377
|
+
export const getBoundRefreshBoostId = async (refreshId: string): Promise<string | undefined> =>
|
|
378
|
+
(await getInitialRefreshRoot(refreshId))?.boostId;
|
|
379
|
+
|
|
214
380
|
const assertInitialCredentialMatches = (
|
|
215
381
|
root: InitialRefreshRoot,
|
|
216
382
|
materialDigest: string,
|
|
@@ -242,8 +408,11 @@ const ensureInitialRefreshRelationships = async (params: {
|
|
|
242
408
|
issuerProfileId: string;
|
|
243
409
|
holderProfileId: string;
|
|
244
410
|
boostId?: string;
|
|
411
|
+
activityId?: string;
|
|
412
|
+
integrationId?: string;
|
|
245
413
|
}): Promise<string | undefined> => {
|
|
246
|
-
const { refreshId, issuerProfileId, holderProfileId, boostId } =
|
|
414
|
+
const { refreshId, issuerProfileId, holderProfileId, boostId, activityId, integrationId } =
|
|
415
|
+
params;
|
|
247
416
|
const now = new Date().toISOString();
|
|
248
417
|
const result = await neogma.queryRunner.run(
|
|
249
418
|
`MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
|
|
@@ -252,6 +421,8 @@ const ensureInitialRefreshRelationships = async (params: {
|
|
|
252
421
|
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
253
422
|
MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
|
|
254
423
|
ON CREATE SET sent.date = $now
|
|
424
|
+
SET sent.activityId = coalesce(sent.activityId, $activityId),
|
|
425
|
+
sent.integrationId = coalesce(sent.integrationId, $integrationId)
|
|
255
426
|
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
256
427
|
MERGE (root)-[:INSTANCE_OF]->(boost)
|
|
257
428
|
)
|
|
@@ -261,6 +432,8 @@ const ensureInitialRefreshRelationships = async (params: {
|
|
|
261
432
|
issuerProfileId,
|
|
262
433
|
holderProfileId,
|
|
263
434
|
boostId: boostId ?? null,
|
|
435
|
+
activityId: activityId ?? null,
|
|
436
|
+
integrationId: integrationId ?? null,
|
|
264
437
|
now,
|
|
265
438
|
}
|
|
266
439
|
);
|
|
@@ -268,7 +441,7 @@ const ensureInitialRefreshRelationships = async (params: {
|
|
|
268
441
|
return result.records[0]?.get('rootId') ?? undefined;
|
|
269
442
|
};
|
|
270
443
|
|
|
271
|
-
const sendInitialCredentialNotificationOnce = async (params: {
|
|
444
|
+
export const sendInitialCredentialNotificationOnce = async (params: {
|
|
272
445
|
refreshId: string;
|
|
273
446
|
uri: string;
|
|
274
447
|
issuerProfile: ProfileType;
|
|
@@ -301,6 +474,24 @@ const sendInitialCredentialNotificationOnce = async (params: {
|
|
|
301
474
|
);
|
|
302
475
|
};
|
|
303
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Holder-side JWE recipients for managed refresh storage.
|
|
479
|
+
*
|
|
480
|
+
* Mirrors the human-controlled keyAgreement keys of the holder's did:web document
|
|
481
|
+
* (see src/dids.ts): the profile's own controller did:key plus every profile that
|
|
482
|
+
* manages it. Managed profiles are created with a did:key whose seed is discarded,
|
|
483
|
+
* so their managers are the only parties able to decrypt. All recipients are
|
|
484
|
+
* did:keys, so encryption never needs a remote did:web fetch. Signing authorities
|
|
485
|
+
* and the brain DID are deliberately excluded.
|
|
486
|
+
* Do not add the aggregate's holder did:web here: its keyAgreement document can
|
|
487
|
+
* also include registered signing-authority keys, outside the holder-only boundary.
|
|
488
|
+
*/
|
|
489
|
+
const getHolderEncryptionRecipients = async (holderProfile: ProfileType): Promise<string[]> => {
|
|
490
|
+
const managers = await getProfilesThatManageAProfile(holderProfile.profileId);
|
|
491
|
+
|
|
492
|
+
return [...new Set([holderProfile.did, ...managers.map(manager => manager.did)])];
|
|
493
|
+
};
|
|
494
|
+
|
|
304
495
|
/**
|
|
305
496
|
* Binds the signed original credential to a previously allocated refresh aggregate.
|
|
306
497
|
*
|
|
@@ -311,13 +502,16 @@ const sendInitialCredentialNotificationOnce = async (params: {
|
|
|
311
502
|
*/
|
|
312
503
|
export const sendRefreshableCredential = async (
|
|
313
504
|
params: SendRefreshableCredentialParams
|
|
314
|
-
): Promise<
|
|
505
|
+
): Promise<SendRefreshableCredentialResult> => {
|
|
315
506
|
const {
|
|
316
507
|
issuerProfile,
|
|
317
508
|
refreshId,
|
|
318
509
|
credential,
|
|
319
510
|
boostUri,
|
|
320
511
|
skipNotification = false,
|
|
512
|
+
activityId,
|
|
513
|
+
integrationId,
|
|
514
|
+
contractTerms,
|
|
321
515
|
domain,
|
|
322
516
|
} = params;
|
|
323
517
|
|
|
@@ -334,6 +528,17 @@ export const sendRefreshableCredential = async (
|
|
|
334
528
|
});
|
|
335
529
|
}
|
|
336
530
|
|
|
531
|
+
if (
|
|
532
|
+
!aggregate.holderDid ||
|
|
533
|
+
aggregate.state === 'pending_holder' ||
|
|
534
|
+
aggregate.inboxCredentialId
|
|
535
|
+
) {
|
|
536
|
+
throw new TRPCError({
|
|
537
|
+
code: 'CONFLICT',
|
|
538
|
+
message: 'Inbox refresh must be bound through its verified claim flow',
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
337
542
|
if (credential.id !== aggregate.credentialId) {
|
|
338
543
|
throw new TRPCError({
|
|
339
544
|
code: 'BAD_REQUEST',
|
|
@@ -359,7 +564,10 @@ export const sendRefreshableCredential = async (
|
|
|
359
564
|
});
|
|
360
565
|
}
|
|
361
566
|
|
|
362
|
-
if (
|
|
567
|
+
if (
|
|
568
|
+
!aggregate.holderDid ||
|
|
569
|
+
!getCredentialSubjectIds(credential).includes(aggregate.holderDid)
|
|
570
|
+
) {
|
|
363
571
|
throw new TRPCError({
|
|
364
572
|
code: 'BAD_REQUEST',
|
|
365
573
|
message: 'Credential subject does not match the intended holder',
|
|
@@ -373,6 +581,24 @@ export const sendRefreshableCredential = async (
|
|
|
373
581
|
});
|
|
374
582
|
}
|
|
375
583
|
|
|
584
|
+
// Metadata-only issuance receipt (LC-2198 decision 2): populated from the actual
|
|
585
|
+
// signed credential and the validated allocation. It deliberately excludes all
|
|
586
|
+
// credential claims, subject bodies, plaintext VC content, and JWE payloads.
|
|
587
|
+
const buildReceipt = (): ManagedCredentialRefreshReceipt => ({
|
|
588
|
+
refreshId,
|
|
589
|
+
refreshService: {
|
|
590
|
+
id: getCredentialRefreshServiceUrl(refreshId, domain),
|
|
591
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
592
|
+
authorization: { type: 'LearnCardDIDAuth' },
|
|
593
|
+
},
|
|
594
|
+
credentialId: aggregate.credentialId,
|
|
595
|
+
issuerDid: credentialIssuerDid,
|
|
596
|
+
holderDid: aggregate.holderDid!,
|
|
597
|
+
...(credential.credentialStatus !== undefined && credential.credentialStatus !== null
|
|
598
|
+
? { credentialStatus: credential.credentialStatus }
|
|
599
|
+
: {}),
|
|
600
|
+
});
|
|
601
|
+
|
|
376
602
|
const holderProfile = aggregate.holderProfileId
|
|
377
603
|
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
378
604
|
: null;
|
|
@@ -395,41 +621,17 @@ export const sendRefreshableCredential = async (
|
|
|
395
621
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found' });
|
|
396
622
|
}
|
|
397
623
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
(:Profile {profileId: $issuerProfileId})
|
|
402
|
-
RETURN boost LIMIT 1`,
|
|
403
|
-
{ boostId: boost.id, issuerProfileId: issuerProfile.profileId }
|
|
404
|
-
);
|
|
405
|
-
|
|
406
|
-
if (ownership.records.length === 0) {
|
|
407
|
-
throw new TRPCError({
|
|
408
|
-
code: 'UNAUTHORIZED',
|
|
409
|
-
message: 'Profile does not own this boost',
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Transient plaintext proof verification — the result is never persisted.
|
|
415
|
-
// Do not dereference credentialStatus here: the descriptor is fingerprinted below,
|
|
416
|
-
// while canonical lifecycle revocation is enforced by the graph relationships.
|
|
417
|
-
const learnCard = await getLearnCard();
|
|
418
|
-
const verification = await learnCard.invoke.verifyCredential(credential, {
|
|
419
|
-
checks: ['proof'],
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
if (
|
|
423
|
-
verification.errors.length > 0 ||
|
|
424
|
-
verification.warnings.length > 0 ||
|
|
425
|
-
!verification.checks.includes('proof')
|
|
426
|
-
) {
|
|
624
|
+
// Match ordinary send authorization: a delegated issuer owns its refresh
|
|
625
|
+
// aggregate even when another profile created the reusable boost template.
|
|
626
|
+
if (boost && !(await canProfileIssueBoost(issuerProfile, boost))) {
|
|
427
627
|
throw new TRPCError({
|
|
428
|
-
code: '
|
|
429
|
-
message: '
|
|
628
|
+
code: 'UNAUTHORIZED',
|
|
629
|
+
message: 'Profile does not have permissions to issue boost',
|
|
430
630
|
});
|
|
431
631
|
}
|
|
432
632
|
|
|
633
|
+
await verifyManagedRefreshProof((await getLearnCard()).invoke, credential, publicIssuerDid);
|
|
634
|
+
|
|
433
635
|
const rootMaterialDigest = computeCredentialMaterialDigest(
|
|
434
636
|
credential as unknown as Record<string, unknown>
|
|
435
637
|
);
|
|
@@ -452,6 +654,8 @@ export const sendRefreshableCredential = async (
|
|
|
452
654
|
issuerProfileId: issuerProfile.profileId,
|
|
453
655
|
holderProfileId: holderProfile.profileId,
|
|
454
656
|
boostId: boost?.id,
|
|
657
|
+
activityId,
|
|
658
|
+
integrationId,
|
|
455
659
|
});
|
|
456
660
|
|
|
457
661
|
if (!rootId) {
|
|
@@ -461,6 +665,10 @@ export const sendRefreshableCredential = async (
|
|
|
461
665
|
});
|
|
462
666
|
}
|
|
463
667
|
|
|
668
|
+
if (contractTerms) {
|
|
669
|
+
await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
|
|
670
|
+
}
|
|
671
|
+
|
|
464
672
|
const uri = getManagedCredentialUri(rootId, domain);
|
|
465
673
|
const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
|
|
466
674
|
refreshId,
|
|
@@ -474,11 +682,15 @@ export const sendRefreshableCredential = async (
|
|
|
474
682
|
initialNotificationSuppressed,
|
|
475
683
|
});
|
|
476
684
|
|
|
477
|
-
return uri;
|
|
685
|
+
return { uri, receipt: buildReceipt() };
|
|
478
686
|
}
|
|
479
687
|
|
|
480
|
-
// Holder-
|
|
481
|
-
|
|
688
|
+
// Holder-side encryption only (never the brain DID): the holder's did:key plus its
|
|
689
|
+
// managers' did:keys — see getHolderEncryptionRecipients.
|
|
690
|
+
const jwe = await createDagJweForRecipients(
|
|
691
|
+
credential,
|
|
692
|
+
await getHolderEncryptionRecipients(holderProfile)
|
|
693
|
+
);
|
|
482
694
|
|
|
483
695
|
const credentialInstance = await storeCredential(jwe);
|
|
484
696
|
|
|
@@ -496,7 +708,7 @@ export const sendRefreshableCredential = async (
|
|
|
496
708
|
OPTIONAL MATCH (boost:Boost {id: $boostId})
|
|
497
709
|
CREATE (refresh)-[:ROOT]->(root)
|
|
498
710
|
CREATE (refresh)-[:HEAD]->(root)
|
|
499
|
-
CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now}]->(root)
|
|
711
|
+
CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
|
|
500
712
|
FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
|
|
501
713
|
CREATE (root)-[:INSTANCE_OF]->(boost)
|
|
502
714
|
)
|
|
@@ -521,6 +733,8 @@ export const sendRefreshableCredential = async (
|
|
|
521
733
|
issuerProfileId: issuerProfile.profileId,
|
|
522
734
|
holderProfileId: holderProfile.profileId,
|
|
523
735
|
boostId: boost?.id ?? null,
|
|
736
|
+
activityId: activityId ?? null,
|
|
737
|
+
integrationId: integrationId ?? null,
|
|
524
738
|
versionKey: `${refreshId}:1`,
|
|
525
739
|
now,
|
|
526
740
|
credentialIssuerDid,
|
|
@@ -572,6 +786,8 @@ export const sendRefreshableCredential = async (
|
|
|
572
786
|
issuerProfileId: issuerProfile.profileId,
|
|
573
787
|
holderProfileId: holderProfile.profileId,
|
|
574
788
|
boostId: boost?.id,
|
|
789
|
+
activityId,
|
|
790
|
+
integrationId,
|
|
575
791
|
});
|
|
576
792
|
|
|
577
793
|
if (!rootId) {
|
|
@@ -581,6 +797,10 @@ export const sendRefreshableCredential = async (
|
|
|
581
797
|
});
|
|
582
798
|
}
|
|
583
799
|
|
|
800
|
+
if (contractTerms) {
|
|
801
|
+
await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
|
|
802
|
+
}
|
|
803
|
+
|
|
584
804
|
const uri = getManagedCredentialUri(rootId, domain);
|
|
585
805
|
const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
|
|
586
806
|
refreshId,
|
|
@@ -594,7 +814,11 @@ export const sendRefreshableCredential = async (
|
|
|
594
814
|
initialNotificationSuppressed,
|
|
595
815
|
});
|
|
596
816
|
|
|
597
|
-
return uri;
|
|
817
|
+
return { uri, receipt: buildReceipt() };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (contractTerms) {
|
|
821
|
+
await ensureCredentialIssuedViaContractRelationship(credentialInstance.id, contractTerms);
|
|
598
822
|
}
|
|
599
823
|
|
|
600
824
|
const uri = getManagedCredentialUri(credentialInstance.id, domain);
|
|
@@ -610,7 +834,7 @@ export const sendRefreshableCredential = async (
|
|
|
610
834
|
initialNotificationSuppressed,
|
|
611
835
|
});
|
|
612
836
|
|
|
613
|
-
return uri;
|
|
837
|
+
return { uri, receipt: buildReceipt() };
|
|
614
838
|
};
|
|
615
839
|
|
|
616
840
|
// --- Publication (LC-2135) -----------------------------------------------------
|
|
@@ -635,48 +859,71 @@ const deliverCredentialRefreshNotification = async (params: {
|
|
|
635
859
|
version: CredentialRefreshVersionNode;
|
|
636
860
|
issuerProfile: ProfileType;
|
|
637
861
|
holderProfile: ProfileType;
|
|
862
|
+
branding?: Partial<TenantBranding>;
|
|
638
863
|
}): Promise<PublishCredentialRefreshNotification> => {
|
|
639
|
-
const { version, issuerProfile, holderProfile } = params;
|
|
640
|
-
|
|
641
|
-
if (version.notificationDeliveredAt) return 'queued';
|
|
864
|
+
const { version, issuerProfile, holderProfile, branding } = params;
|
|
642
865
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
issuerProfile,
|
|
646
|
-
refreshId: version.refreshId,
|
|
647
|
-
version: version.version,
|
|
648
|
-
notificationId: version.notificationId,
|
|
649
|
-
deliveryKey: version.notificationDeliveryKey,
|
|
650
|
-
notifiedAt: version.notificationCreatedAt,
|
|
651
|
-
});
|
|
866
|
+
let inAppOutcome: PublishCredentialRefreshNotification = 'queued';
|
|
867
|
+
let deliveryWindowKey = version.notificationDeliveryKey;
|
|
652
868
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
869
|
+
if (!version.notificationDeliveredAt) {
|
|
870
|
+
const event = buildCredentialRefreshedNotification({
|
|
871
|
+
holderProfile,
|
|
872
|
+
issuerProfile,
|
|
656
873
|
refreshId: version.refreshId,
|
|
657
874
|
version: version.version,
|
|
658
|
-
notificationId:
|
|
659
|
-
deliveryKey:
|
|
660
|
-
notifiedAt:
|
|
875
|
+
notificationId: version.notificationId,
|
|
876
|
+
deliveryKey: version.notificationDeliveryKey,
|
|
877
|
+
notifiedAt: version.notificationCreatedAt,
|
|
661
878
|
});
|
|
879
|
+
deliveryWindowKey = event.deliveryKey;
|
|
662
880
|
|
|
663
|
-
|
|
881
|
+
try {
|
|
882
|
+
await addNotificationToQueue(event.notification);
|
|
883
|
+
await recordCredentialRefreshNotification({
|
|
884
|
+
refreshId: version.refreshId,
|
|
885
|
+
version: version.version,
|
|
886
|
+
notificationId: event.notificationId,
|
|
887
|
+
deliveryKey: event.deliveryKey,
|
|
888
|
+
notifiedAt: event.notifiedAt,
|
|
889
|
+
});
|
|
890
|
+
} catch (error) {
|
|
891
|
+
console.error(
|
|
892
|
+
'Credential Refresh Helpers - Failed to enqueue CREDENTIAL_REFRESHED notification:',
|
|
893
|
+
error
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
inAppOutcome = 'delivery-failed';
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Email is an independent channel: attempted even when the durable in-app
|
|
901
|
+
// event was already delivered or just failed. Its own outcome is never allowed
|
|
902
|
+
// to change the publication result or duplicate the push notification.
|
|
903
|
+
try {
|
|
904
|
+
await deliverCredentialRefreshEmailNotification({
|
|
905
|
+
version: { ...version, notificationDeliveryKey: deliveryWindowKey },
|
|
906
|
+
issuerProfile,
|
|
907
|
+
holderProfile,
|
|
908
|
+
branding,
|
|
909
|
+
});
|
|
664
910
|
} catch (error) {
|
|
665
911
|
console.error(
|
|
666
|
-
'Credential Refresh Helpers -
|
|
912
|
+
'Credential Refresh Helpers - Unexpected error delivering refresh update email:',
|
|
667
913
|
error
|
|
668
914
|
);
|
|
669
|
-
|
|
670
|
-
return 'delivery-failed';
|
|
671
915
|
}
|
|
916
|
+
|
|
917
|
+
return inAppOutcome;
|
|
672
918
|
};
|
|
673
919
|
|
|
674
920
|
const resolveCredentialRefreshReplayNotification = async (params: {
|
|
675
921
|
version: CredentialRefreshVersionNode;
|
|
676
922
|
aggregate: CredentialRefreshRecord;
|
|
677
923
|
issuerProfile: ProfileType;
|
|
924
|
+
branding?: Partial<TenantBranding>;
|
|
678
925
|
}): Promise<PublishCredentialRefreshNotification> => {
|
|
679
|
-
const { version, aggregate, issuerProfile } = params;
|
|
926
|
+
const { version, aggregate, issuerProfile, branding } = params;
|
|
680
927
|
const persistedOutcome = version.notificationOutcome ?? 'suppressed';
|
|
681
928
|
|
|
682
929
|
if (persistedOutcome !== 'queued') return persistedOutcome;
|
|
@@ -686,7 +933,7 @@ const resolveCredentialRefreshReplayNotification = async (params: {
|
|
|
686
933
|
: null;
|
|
687
934
|
|
|
688
935
|
return holderProfile
|
|
689
|
-
? deliverCredentialRefreshNotification({ version, issuerProfile, holderProfile })
|
|
936
|
+
? deliverCredentialRefreshNotification({ version, issuerProfile, holderProfile, branding })
|
|
690
937
|
: 'delivery-failed';
|
|
691
938
|
};
|
|
692
939
|
|
|
@@ -698,8 +945,10 @@ export const deliverPendingCredentialRefreshNotificationForAcceptedCredential =
|
|
|
698
945
|
credentialNodeId: string;
|
|
699
946
|
issuerProfile: ProfileType;
|
|
700
947
|
holderProfile: ProfileType;
|
|
948
|
+
/** Optional tenant branding for the update email; defaults to LearnCard. */
|
|
949
|
+
branding?: Partial<TenantBranding>;
|
|
701
950
|
}): Promise<PublishCredentialRefreshNotification> => {
|
|
702
|
-
const { credentialNodeId, issuerProfile, holderProfile } = params;
|
|
951
|
+
const { credentialNodeId, issuerProfile, holderProfile, branding } = params;
|
|
703
952
|
const result = await neogma.queryRunner.run(
|
|
704
953
|
`MATCH (refresh:CredentialRefresh)-[:ROOT]->(:Credential {id: $credentialNodeId})
|
|
705
954
|
RETURN refresh.refreshId AS refreshId, refresh.state AS state
|
|
@@ -721,6 +970,7 @@ export const deliverPendingCredentialRefreshNotificationForAcceptedCredential =
|
|
|
721
970
|
version: head,
|
|
722
971
|
issuerProfile,
|
|
723
972
|
holderProfile,
|
|
973
|
+
branding,
|
|
724
974
|
});
|
|
725
975
|
};
|
|
726
976
|
|
|
@@ -749,7 +999,10 @@ const assertRefreshVersionInvariants = (
|
|
|
749
999
|
});
|
|
750
1000
|
}
|
|
751
1001
|
|
|
752
|
-
if (
|
|
1002
|
+
if (
|
|
1003
|
+
!aggregate.holderDid ||
|
|
1004
|
+
!getCredentialSubjectIds(credential).includes(aggregate.holderDid)
|
|
1005
|
+
) {
|
|
753
1006
|
throw new TRPCError({
|
|
754
1007
|
code: 'BAD_REQUEST',
|
|
755
1008
|
message: 'Credential subject does not match the intended holder',
|
|
@@ -788,34 +1041,12 @@ const assertRefreshVersionInvariants = (
|
|
|
788
1041
|
}
|
|
789
1042
|
};
|
|
790
1043
|
|
|
791
|
-
/**
|
|
792
|
-
* Transient in-memory proof verification; the plaintext result is never persisted.
|
|
793
|
-
* Only the proof check runs: the credentialStatus check would fetch remote status
|
|
794
|
-
* list credentials at publish time (availability + SSRF hazard), and revocation
|
|
795
|
-
* state moves through its own lifecycle rather than the refresh publication path.
|
|
796
|
-
*/
|
|
797
|
-
const verifyRefreshVersionProof = async (credential: VC): Promise<void> => {
|
|
798
|
-
const learnCard = await getLearnCard();
|
|
799
|
-
const verification = await learnCard.invoke.verifyCredential(credential, {
|
|
800
|
-
checks: ['proof'],
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
if (
|
|
804
|
-
verification.errors.length > 0 ||
|
|
805
|
-
verification.warnings.length > 0 ||
|
|
806
|
-
!verification.checks.includes('proof')
|
|
807
|
-
) {
|
|
808
|
-
throw new TRPCError({
|
|
809
|
-
code: 'BAD_REQUEST',
|
|
810
|
-
message: 'Credential proof could not be verified',
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
};
|
|
814
|
-
|
|
815
1044
|
export type PublishCredentialRefreshParams = {
|
|
816
1045
|
issuerProfile: ProfileType;
|
|
817
1046
|
input: PublishCredentialRefreshInput;
|
|
818
1047
|
domain: string;
|
|
1048
|
+
/** Optional tenant branding for the update email; defaults to LearnCard. */
|
|
1049
|
+
branding?: Partial<TenantBranding>;
|
|
819
1050
|
};
|
|
820
1051
|
|
|
821
1052
|
/**
|
|
@@ -833,6 +1064,14 @@ export const publishCredentialRefresh = async (
|
|
|
833
1064
|
): Promise<PublishCredentialRefreshResult> => {
|
|
834
1065
|
const { issuerProfile, input, domain } = params;
|
|
835
1066
|
const { refreshId, idempotencyKey, notifyHolder, updateSummary } = input;
|
|
1067
|
+
const branding = params.branding;
|
|
1068
|
+
|
|
1069
|
+
// Email-only bounded display title. Read from the issuer-supplied publication
|
|
1070
|
+
// input (never from the holder-encrypted payload); `undefined` falls back to
|
|
1071
|
+
// the original boost template at delivery time, then to generic copy.
|
|
1072
|
+
const credentialDisplayName = extractBoundedCredentialDisplayTitle(
|
|
1073
|
+
input.mode === 'signing-authority' ? input.credential.name : input.signedCredential.name
|
|
1074
|
+
);
|
|
836
1075
|
|
|
837
1076
|
const aggregate = await getCredentialRefresh(refreshId);
|
|
838
1077
|
|
|
@@ -863,6 +1102,17 @@ export const publishCredentialRefresh = async (
|
|
|
863
1102
|
});
|
|
864
1103
|
}
|
|
865
1104
|
|
|
1105
|
+
if (aggregate.inboxCredentialId) {
|
|
1106
|
+
const { getPendingInboxPublication } =
|
|
1107
|
+
await import('@accesslayer/inbox-credential/refresh');
|
|
1108
|
+
const replay = await getPendingInboxPublication(refreshId, idempotencyKey);
|
|
1109
|
+
if (replay) return replay;
|
|
1110
|
+
if (aggregate.state === 'pending_holder') {
|
|
1111
|
+
const { publishPendingInboxRefresh } = await import('./inbox-refresh.helpers');
|
|
1112
|
+
return publishPendingInboxRefresh(aggregate, issuerProfile, input, domain);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
866
1116
|
if (idempotencyKey) {
|
|
867
1117
|
const replayed = await getCredentialRefreshVersionByIdempotencyKey(
|
|
868
1118
|
refreshId,
|
|
@@ -874,6 +1124,7 @@ export const publishCredentialRefresh = async (
|
|
|
874
1124
|
version: replayed,
|
|
875
1125
|
aggregate,
|
|
876
1126
|
issuerProfile,
|
|
1127
|
+
branding,
|
|
877
1128
|
});
|
|
878
1129
|
|
|
879
1130
|
return {
|
|
@@ -902,7 +1153,11 @@ export const publishCredentialRefresh = async (
|
|
|
902
1153
|
signingMode = 'issuer-signed';
|
|
903
1154
|
|
|
904
1155
|
assertRefreshVersionInvariants(signedCredential, aggregate, domain);
|
|
905
|
-
await
|
|
1156
|
+
await verifyManagedRefreshProof(
|
|
1157
|
+
(await getLearnCard()).invoke,
|
|
1158
|
+
signedCredential,
|
|
1159
|
+
getDidWeb(domain, issuerProfile.profileId)
|
|
1160
|
+
);
|
|
906
1161
|
} else {
|
|
907
1162
|
const reference = SigningAuthorityReferenceValidator.safeParse(input.signingAuthority);
|
|
908
1163
|
|
|
@@ -931,22 +1186,33 @@ export const publishCredentialRefresh = async (
|
|
|
931
1186
|
// body as supplied, so the completed credential is rechecked in full plus proof.
|
|
932
1187
|
assertRefreshVersionInvariants(input.credential as VC, aggregate, domain);
|
|
933
1188
|
|
|
1189
|
+
// Managed refresh services require their inline JSON-LD context terms to be
|
|
1190
|
+
// defined or signing fails (LC-2198): prepare the context exactly as the SDK
|
|
1191
|
+
// path does. Idempotent when the issuer already supplied the definitions.
|
|
1192
|
+
const preparedCredential = prepareManagedRefreshContext(input.credential);
|
|
1193
|
+
|
|
934
1194
|
// appendCredentialStatus: false — a refresh version must preserve the
|
|
935
1195
|
// issuer-supplied credentialStatus descriptor; no new status-list entry is
|
|
936
1196
|
// allocated for a version of an already-issued credential.
|
|
937
|
-
signedCredential = (
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1197
|
+
signedCredential = (
|
|
1198
|
+
await issueCredentialWithSigningAuthority(
|
|
1199
|
+
{ type: 'profile', profile: issuerProfile },
|
|
1200
|
+
preparedCredential,
|
|
1201
|
+
signingAuthority,
|
|
1202
|
+
domain,
|
|
1203
|
+
false,
|
|
1204
|
+
undefined,
|
|
1205
|
+
false
|
|
1206
|
+
)
|
|
1207
|
+
).credential as VC;
|
|
946
1208
|
signingMode = 'signing-authority';
|
|
947
1209
|
|
|
948
1210
|
assertRefreshVersionInvariants(signedCredential, aggregate, domain);
|
|
949
|
-
await
|
|
1211
|
+
await verifyManagedRefreshProof(
|
|
1212
|
+
(await getLearnCard()).invoke,
|
|
1213
|
+
signedCredential,
|
|
1214
|
+
getDidWeb(domain, issuerProfile.profileId)
|
|
1215
|
+
);
|
|
950
1216
|
}
|
|
951
1217
|
|
|
952
1218
|
// Reject a strictly older effective/issuance timestamp. Equal or missing
|
|
@@ -998,8 +1264,17 @@ export const publishCredentialRefresh = async (
|
|
|
998
1264
|
})
|
|
999
1265
|
: undefined;
|
|
1000
1266
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1267
|
+
const holderProfileForEncryption = aggregate.holderProfileId
|
|
1268
|
+
? await getProfileByProfileId(aggregate.holderProfileId)
|
|
1269
|
+
: null;
|
|
1270
|
+
|
|
1271
|
+
// Same holder-side recipients as the initial send — see getHolderEncryptionRecipients.
|
|
1272
|
+
const jwe = await createDagJweForRecipients(
|
|
1273
|
+
signedCredential,
|
|
1274
|
+
holderProfileForEncryption
|
|
1275
|
+
? await getHolderEncryptionRecipients(holderProfileForEncryption)
|
|
1276
|
+
: [aggregate.holderDid!]
|
|
1277
|
+
);
|
|
1003
1278
|
const encryptedCredential = JSON.stringify(jwe);
|
|
1004
1279
|
const etag = computeRefreshEtag(encryptedCredential);
|
|
1005
1280
|
|
|
@@ -1013,6 +1288,7 @@ export const publishCredentialRefresh = async (
|
|
|
1013
1288
|
etag,
|
|
1014
1289
|
materialDigest: nextDigest,
|
|
1015
1290
|
updateSummary,
|
|
1291
|
+
credentialDisplayName,
|
|
1016
1292
|
effectiveAt:
|
|
1017
1293
|
effectiveTime !== undefined ? new Date(effectiveTime).toISOString() : undefined,
|
|
1018
1294
|
notificationOutcome: notification,
|
|
@@ -1048,6 +1324,7 @@ export const publishCredentialRefresh = async (
|
|
|
1048
1324
|
version: replayed,
|
|
1049
1325
|
aggregate,
|
|
1050
1326
|
issuerProfile,
|
|
1327
|
+
branding,
|
|
1051
1328
|
}),
|
|
1052
1329
|
};
|
|
1053
1330
|
}
|
|
@@ -1063,6 +1340,7 @@ export const publishCredentialRefresh = async (
|
|
|
1063
1340
|
version: persistedVersion,
|
|
1064
1341
|
issuerProfile,
|
|
1065
1342
|
holderProfile,
|
|
1343
|
+
branding,
|
|
1066
1344
|
})
|
|
1067
1345
|
: 'delivery-failed';
|
|
1068
1346
|
}
|