@learncard/network-brain-service 3.18.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/CHANGELOG.md +80 -0
- package/compose.inbox.yml +7 -0
- package/dist/accesslayer/auth-grant/read.d.ts +1 -0
- package/dist/accesslayer/auth-grant/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/helpers/activity.helpers.d.ts +4 -0
- package/dist/helpers/activity.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 +2 -1
- 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/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/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/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/auth-grants.d.ts +10 -0
- package/dist/routes/auth-grants.d.ts.map +1 -1
- package/dist/routes/boosts.d.ts +494 -0
- package/dist/routes/boosts.d.ts.map +1 -1
- package/dist/routes/credential-refreshes.d.ts.map +1 -1
- package/dist/routes/credentials.d.ts.map +1 -1
- package/dist/routes/inbox.d.ts +953 -11
- package/dist/routes/inbox.d.ts.map +1 -1
- package/dist/routes/index.d.ts +42 -126
- package/dist/routes/index.d.ts.map +1 -1
- 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/credential-refresh.d.ts +10 -1
- package/dist/types/credential-refresh.d.ts.map +1 -1
- 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/auth-grant/read.ts +2 -1
- 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/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 +375 -99
- package/src/helpers/credential.helpers.ts +4 -1
- package/src/helpers/finalize-inbox.helpers.ts +29 -14
- 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 +268 -32
- 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/inbox-queue-local.ts +28 -0
- package/src/models/AuthGrant.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 +2 -2
- package/src/routes/auth-grants.ts +13 -11
- package/src/routes/boosts.ts +914 -92
- 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 +82 -7
- package/src/routes/skills.ts +1 -1
- package/src/routes/workflows.ts +43 -24
- 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/credential-refresh.ts +21 -2
- package/src/types/inbox-batch.ts +46 -0
- package/test/atomic-counter.e2e.ts +12 -0
- package/test/auth-grant.spec.ts +21 -21
- package/test/credential-refresh-email.spec.ts +663 -0
- package/test/credential-refresh-publication.spec.ts +2 -0
- 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/openapi.spec.ts +41 -1
- package/test/refresh-send.spec.ts +1799 -0
- package/test/registry.spec.ts +56 -11
- package/vitest.config.ts +9 -0
- package/vitest.inbox-e2e.config.ts +5 -1
- package/vitest.integration.config.ts +2 -0
|
@@ -116,7 +116,10 @@ describe('credential refresh issuer route gating', () => {
|
|
|
116
116
|
markConstraintsReady = resolve;
|
|
117
117
|
})
|
|
118
118
|
);
|
|
119
|
-
mocks.sendRefreshableCredential.mockResolvedValue(
|
|
119
|
+
mocks.sendRefreshableCredential.mockResolvedValue({
|
|
120
|
+
uri: 'lc:network:credential:root',
|
|
121
|
+
receipt: { refreshId: 'opaque-refresh-id' },
|
|
122
|
+
});
|
|
120
123
|
|
|
121
124
|
const caller = credentialRefreshesRouter.createCaller({
|
|
122
125
|
domain: 'network.example.com',
|
|
@@ -174,7 +177,10 @@ describe('credential refresh issuer route gating', () => {
|
|
|
174
177
|
|
|
175
178
|
it('forwards initial notification suppression to managed delivery', async () => {
|
|
176
179
|
process.env.CREDENTIAL_REFRESH_ENABLED = 'true';
|
|
177
|
-
mocks.sendRefreshableCredential.mockResolvedValue(
|
|
180
|
+
mocks.sendRefreshableCredential.mockResolvedValue({
|
|
181
|
+
uri: 'lc:network:credential:root',
|
|
182
|
+
receipt: { refreshId: 'opaque-refresh-id' },
|
|
183
|
+
});
|
|
178
184
|
|
|
179
185
|
const caller = credentialRefreshesRouter.createCaller({
|
|
180
186
|
domain: 'network.example.com',
|
|
@@ -124,7 +124,7 @@ export const credentialRefreshesRouter = t.router({
|
|
|
124
124
|
.mutation(async ({ ctx, input }) => {
|
|
125
125
|
const { profile } = ctx.user;
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
const { uri } = await sendRefreshableCredential({
|
|
128
128
|
issuerProfile: profile,
|
|
129
129
|
refreshId: input.refreshId,
|
|
130
130
|
credential: input.credential,
|
|
@@ -132,6 +132,8 @@ export const credentialRefreshesRouter = t.router({
|
|
|
132
132
|
skipNotification: input.skipNotification,
|
|
133
133
|
domain: ctx.domain,
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
return uri;
|
|
135
137
|
}),
|
|
136
138
|
|
|
137
139
|
publishCredentialRefresh: credentialRefreshRoute
|
|
@@ -156,6 +158,7 @@ export const credentialRefreshesRouter = t.router({
|
|
|
156
158
|
issuerProfile: profile,
|
|
157
159
|
input: input as PublishCredentialRefreshInput,
|
|
158
160
|
domain: ctx.domain,
|
|
161
|
+
branding: ctx.tenant?.emailBranding,
|
|
159
162
|
});
|
|
160
163
|
}),
|
|
161
164
|
|
|
@@ -71,6 +71,7 @@ export const credentialsRouter = t.router({
|
|
|
71
71
|
// Log credential activity FIRST to get activityId
|
|
72
72
|
const activityId = await logCredentialSent({
|
|
73
73
|
actorProfileId: profile.profileId,
|
|
74
|
+
onBehalfOf: ctx.user.onBehalfOf,
|
|
74
75
|
recipientType: 'profile',
|
|
75
76
|
recipientIdentifier: targetProfile.profileId,
|
|
76
77
|
recipientProfileId: targetProfile.profileId,
|
|
@@ -118,7 +119,7 @@ export const credentialsRouter = t.router({
|
|
|
118
119
|
const { profile } = ctx.user;
|
|
119
120
|
const { uri } = input;
|
|
120
121
|
|
|
121
|
-
return acceptCredential(profile, uri, input?.options ?? {});
|
|
122
|
+
return acceptCredential(profile, uri, input?.options ?? {}, ctx.tenant?.emailBranding);
|
|
122
123
|
}),
|
|
123
124
|
|
|
124
125
|
receivedCredentials: profileRoute
|
package/src/routes/inbox.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { trace } from '@tracing';
|
|
2
|
+
import { submitInboxBatch, getInboxBatch } from '@helpers/inbox-queue.helpers';
|
|
3
|
+
import {
|
|
4
|
+
assertInboxRefreshEnabled,
|
|
5
|
+
inboxRefreshRequestDigest,
|
|
6
|
+
getInboxRefreshReplay,
|
|
7
|
+
getInboxRefreshReceipt,
|
|
8
|
+
resumeInboxRefreshDelivery,
|
|
9
|
+
} from '@helpers/inbox-refresh.helpers';
|
|
1
10
|
import { getDidWeb } from '@helpers/did.helpers';
|
|
2
11
|
import { TRPCError } from '@trpc/server';
|
|
3
12
|
import { z } from 'zod';
|
|
@@ -6,6 +15,9 @@ import { t, profileRoute, openRoute, verifiedContactRoute, scopedRoute } from '@
|
|
|
6
15
|
import {
|
|
7
16
|
PaginationOptionsValidator,
|
|
8
17
|
IssueInboxCredentialValidator,
|
|
18
|
+
IssueInboxCredentialBatchValidator,
|
|
19
|
+
InboxBatchReceiptValidator,
|
|
20
|
+
InboxBatchStatusValidator,
|
|
9
21
|
IssueInboxCredentialResponseValidator,
|
|
10
22
|
InboxCredentialValidator,
|
|
11
23
|
PaginatedInboxCredentialsValidator,
|
|
@@ -18,15 +30,14 @@ import {
|
|
|
18
30
|
JWEValidator,
|
|
19
31
|
} from '@learncard/types';
|
|
20
32
|
import { getInboxCredentialMeta } from '@helpers/credential-meta.helpers';
|
|
21
|
-
import { claimIntoInbox, issueToInbox } from '@helpers/inbox.helpers';
|
|
22
|
-
import { prepareCredentialFromBoost, getBoostUri } from '@helpers/boost.helpers';
|
|
33
|
+
import { claimIntoInbox, issueToInbox, resolveInboxCredentialInput } from '@helpers/inbox.helpers';
|
|
23
34
|
import {
|
|
24
35
|
hasMustacheVariables,
|
|
25
36
|
renderBoostTemplate,
|
|
26
37
|
parseRenderedTemplate,
|
|
27
38
|
} from '@helpers/template.helpers';
|
|
28
39
|
import { getProfileByVerifiedContactMethod } from '@accesslayer/contact-method/relationships/read';
|
|
29
|
-
import {
|
|
40
|
+
import { getBoostsForProfile } from '@accesslayer/boost/read';
|
|
30
41
|
import {
|
|
31
42
|
generateGuardianApprovalToken,
|
|
32
43
|
generateGuardianApprovalUrl,
|
|
@@ -447,55 +458,36 @@ export const inboxRouter = t.router({
|
|
|
447
458
|
.output(IssueInboxCredentialResponseValidator)
|
|
448
459
|
.mutation(async ({ ctx, input }) => {
|
|
449
460
|
const { profile } = ctx.user;
|
|
450
|
-
const { recipient,
|
|
451
|
-
|
|
452
|
-
// Resolve credential from templateUri if provided
|
|
453
|
-
let credential = inputCredential;
|
|
454
|
-
let resolvedBoostUri: string | undefined;
|
|
455
|
-
|
|
456
|
-
if (templateUri && !credential) {
|
|
457
|
-
const boostInstance = await getBoostByUri(templateUri);
|
|
458
|
-
|
|
459
|
-
if (!boostInstance) {
|
|
460
|
-
throw new TRPCError({
|
|
461
|
-
code: 'NOT_FOUND',
|
|
462
|
-
message: `Boost not found: ${templateUri}`,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
if (!boostInstance.dataValues.boost) {
|
|
467
|
-
throw new TRPCError({
|
|
468
|
-
code: 'BAD_REQUEST',
|
|
469
|
-
message: `Boost does not contain a credential template: ${templateUri}`,
|
|
470
|
-
});
|
|
471
|
-
}
|
|
461
|
+
const { recipient, configuration } = input;
|
|
472
462
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
463
|
+
const refreshDigest = input.refresh ? inboxRefreshRequestDigest(input) : undefined;
|
|
464
|
+
if (input.refresh) {
|
|
465
|
+
await assertInboxRefreshEnabled(ctx.user.scope);
|
|
466
|
+
const replay = await getInboxRefreshReplay(
|
|
467
|
+
profile.profileId,
|
|
468
|
+
input.idempotencyKey,
|
|
469
|
+
refreshDigest!
|
|
470
|
+
);
|
|
471
|
+
if (replay && (replay.claimUrl || replay.inbox.currentStatus !== 'PENDING')) {
|
|
472
|
+
await resumeInboxRefreshDelivery(replay.inbox.refreshId!, ctx.domain);
|
|
473
|
+
const receipt = await getInboxRefreshReceipt(
|
|
474
|
+
replay.inbox.refreshId!,
|
|
475
|
+
ctx.domain
|
|
483
476
|
);
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
477
|
+
return {
|
|
478
|
+
issuanceId: replay.inbox.id,
|
|
479
|
+
status: replay.inbox.currentStatus,
|
|
480
|
+
recipient,
|
|
481
|
+
claimUrl: replay.claimUrl,
|
|
482
|
+
refresh: receipt,
|
|
483
|
+
...(replay.inbox.currentStatus === 'ISSUED'
|
|
484
|
+
? { recipientDid: receipt.holderDid }
|
|
485
|
+
: {}),
|
|
486
|
+
};
|
|
490
487
|
}
|
|
491
488
|
}
|
|
492
489
|
|
|
493
|
-
|
|
494
|
-
throw new TRPCError({
|
|
495
|
-
code: 'BAD_REQUEST',
|
|
496
|
-
message: 'Either credential or templateUri must be provided',
|
|
497
|
-
});
|
|
498
|
-
}
|
|
490
|
+
const { credential, resolvedBoostUri } = await resolveInboxCredentialInput(input, ctx);
|
|
499
491
|
|
|
500
492
|
// Normalize signing authority name if provided
|
|
501
493
|
const normalizedConfiguration = configuration?.signingAuthority
|
|
@@ -513,11 +505,18 @@ export const inboxRouter = t.router({
|
|
|
513
505
|
profile,
|
|
514
506
|
recipient,
|
|
515
507
|
credential,
|
|
516
|
-
|
|
508
|
+
{
|
|
509
|
+
...normalizedConfiguration,
|
|
510
|
+
boostUri: resolvedBoostUri,
|
|
511
|
+
refresh: input.refresh,
|
|
512
|
+
idempotencyKey: input.idempotencyKey,
|
|
513
|
+
refreshRequestDigest: refreshDigest,
|
|
514
|
+
},
|
|
517
515
|
ctx
|
|
518
516
|
);
|
|
519
517
|
|
|
520
518
|
return {
|
|
519
|
+
refresh: result.refresh,
|
|
521
520
|
issuanceId: result.inboxCredential.id,
|
|
522
521
|
status: result.status,
|
|
523
522
|
recipient,
|
|
@@ -536,6 +535,42 @@ export const inboxRouter = t.router({
|
|
|
536
535
|
}
|
|
537
536
|
}),
|
|
538
537
|
|
|
538
|
+
issueBatch: profileRoute
|
|
539
|
+
.meta({
|
|
540
|
+
openapi: {
|
|
541
|
+
protect: true,
|
|
542
|
+
method: 'POST',
|
|
543
|
+
path: '/inbox/issue-batch',
|
|
544
|
+
tags: ['Universal Inbox'],
|
|
545
|
+
summary: 'Issue Credentials to Universal Inbox (Batch)',
|
|
546
|
+
description:
|
|
547
|
+
'Queue 1–100 credentials for background issuance. Returns a durable batch ID; poll GET /inbox/batches/{batchId} for ordered results. Request and item idempotency keys are issuer-scoped for 24 hours. Maximum JSON payload: 4 MiB.',
|
|
548
|
+
},
|
|
549
|
+
requiredScope: 'inbox:write',
|
|
550
|
+
})
|
|
551
|
+
.input(IssueInboxCredentialBatchValidator)
|
|
552
|
+
.output(InboxBatchReceiptValidator)
|
|
553
|
+
.mutation(({ ctx, input }) =>
|
|
554
|
+
trace('route', 'issueBatch', () => submitInboxBatch(ctx.user.profile, input, ctx), {
|
|
555
|
+
itemCount: input.items.length,
|
|
556
|
+
})
|
|
557
|
+
),
|
|
558
|
+
|
|
559
|
+
getBatch: profileRoute
|
|
560
|
+
.meta({
|
|
561
|
+
openapi: {
|
|
562
|
+
protect: true,
|
|
563
|
+
method: 'GET',
|
|
564
|
+
path: '/inbox/batches/{batchId}',
|
|
565
|
+
tags: ['Universal Inbox'],
|
|
566
|
+
summary: 'Get Inbox Batch Progress',
|
|
567
|
+
},
|
|
568
|
+
requiredScope: 'inbox:read',
|
|
569
|
+
})
|
|
570
|
+
.input(z.object({ batchId: z.string() }))
|
|
571
|
+
.output(InboxBatchStatusValidator)
|
|
572
|
+
.query(({ ctx, input }) => getInboxBatch(ctx.user.profile.profileId, input.batchId)),
|
|
573
|
+
|
|
539
574
|
claim: verifiedContactRoute
|
|
540
575
|
.meta({
|
|
541
576
|
openapi: {
|
|
@@ -682,6 +717,7 @@ export const inboxRouter = t.router({
|
|
|
682
717
|
// Log initial activity so embed claims appear in the dashboard
|
|
683
718
|
const activityId = await logCredentialSent({
|
|
684
719
|
actorProfileId: issuerProfile.profileId,
|
|
720
|
+
onBehalfOf: ctx.user?.onBehalfOf,
|
|
685
721
|
recipientType: contactMethod.type as 'email' | 'phone',
|
|
686
722
|
recipientIdentifier: contactMethod.value,
|
|
687
723
|
integrationId: integration.id,
|
|
@@ -825,7 +861,17 @@ export const inboxRouter = t.router({
|
|
|
825
861
|
});
|
|
826
862
|
}
|
|
827
863
|
|
|
828
|
-
return
|
|
864
|
+
return {
|
|
865
|
+
...inboxCredential,
|
|
866
|
+
...(inboxCredential.refreshId
|
|
867
|
+
? {
|
|
868
|
+
refresh: await getInboxRefreshReceipt(
|
|
869
|
+
inboxCredential.refreshId,
|
|
870
|
+
ctx.domain
|
|
871
|
+
),
|
|
872
|
+
}
|
|
873
|
+
: {}),
|
|
874
|
+
};
|
|
829
875
|
}),
|
|
830
876
|
|
|
831
877
|
// ─── Guardian Credential Approval Routes ─────────────────────────────────────
|
package/src/routes/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify';
|
|
|
9
9
|
import { OpenApiMeta } from 'trpc-to-openapi';
|
|
10
10
|
import jwtDecode from 'jwt-decode';
|
|
11
11
|
import * as Sentry from '@sentry/serverless';
|
|
12
|
-
import { AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
|
|
12
|
+
import { ACT_AS_HEADER, AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
|
|
13
13
|
import { ContactMethodType } from '@learncard/types';
|
|
14
14
|
|
|
15
15
|
import { RegExpTransformer } from '@learncard/helpers';
|
|
@@ -48,7 +48,11 @@ export type Context = {
|
|
|
48
48
|
did: string;
|
|
49
49
|
isChallengeValid: boolean;
|
|
50
50
|
scope?: string;
|
|
51
|
+
isAuthGrant?: boolean;
|
|
52
|
+
actAsPolicy?: string;
|
|
53
|
+
onBehalfOf?: string;
|
|
51
54
|
};
|
|
55
|
+
actAs?: string;
|
|
52
56
|
contactMethod?: ContactMethodType;
|
|
53
57
|
domain: string;
|
|
54
58
|
tenant: ResolvedTenant;
|
|
@@ -84,6 +88,14 @@ export const createContext = async (
|
|
|
84
88
|
| { req: { headers: Map<string, string> } }
|
|
85
89
|
): Promise<Context> => {
|
|
86
90
|
const event = 'event' in options ? options.event : options.req;
|
|
91
|
+
const headerEntries =
|
|
92
|
+
'get' in event.headers
|
|
93
|
+
? Array.from(event.headers as Map<string, string>)
|
|
94
|
+
: Object.entries(event.headers);
|
|
95
|
+
const rawActAs = headerEntries.find(
|
|
96
|
+
([name]) => name.toLowerCase() === ACT_AS_HEADER.toLowerCase()
|
|
97
|
+
)?.[1];
|
|
98
|
+
const actAs = Array.isArray(rawActAs) ? rawActAs.join(',') : rawActAs;
|
|
87
99
|
const authHeader =
|
|
88
100
|
'get' in event.headers
|
|
89
101
|
? (event.headers as Map<string, string>).get('authorization')
|
|
@@ -141,6 +153,7 @@ export const createContext = async (
|
|
|
141
153
|
if (!challenge)
|
|
142
154
|
return {
|
|
143
155
|
user: { did, isChallengeValid: false, scope: AUTH_GRANT_NO_ACCESS_SCOPE },
|
|
156
|
+
actAs,
|
|
144
157
|
domain,
|
|
145
158
|
tenant,
|
|
146
159
|
sourceIp,
|
|
@@ -148,6 +161,8 @@ export const createContext = async (
|
|
|
148
161
|
|
|
149
162
|
let isChallengeValid = false;
|
|
150
163
|
let scope = AUTH_GRANT_FULL_ACCESS_SCOPE;
|
|
164
|
+
let isAuthGrant = false;
|
|
165
|
+
let actAsPolicy: string | undefined;
|
|
151
166
|
|
|
152
167
|
// If the user is using a provisional auth token for a contact method:
|
|
153
168
|
if (challenge?.includes(CONTACT_METHOD_SESSION_PREFIX)) {
|
|
@@ -159,6 +174,7 @@ export const createContext = async (
|
|
|
159
174
|
if (!contactMethod) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
160
175
|
return {
|
|
161
176
|
contactMethod,
|
|
177
|
+
actAs,
|
|
162
178
|
domain,
|
|
163
179
|
tenant,
|
|
164
180
|
sourceIp,
|
|
@@ -166,9 +182,14 @@ export const createContext = async (
|
|
|
166
182
|
}
|
|
167
183
|
// If the user is using a real auth grant i.e. an API Token.
|
|
168
184
|
} else if (challenge?.includes(AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX)) {
|
|
169
|
-
const {
|
|
170
|
-
|
|
171
|
-
|
|
185
|
+
const {
|
|
186
|
+
isChallengeValid: _isChallengeValid,
|
|
187
|
+
scope: _scope,
|
|
188
|
+
actAs: policy,
|
|
189
|
+
} = await isAuthGrantChallengeValidForDID(challenge, did);
|
|
190
|
+
|
|
191
|
+
isAuthGrant = true;
|
|
192
|
+
actAsPolicy = policy;
|
|
172
193
|
isChallengeValid = _isChallengeValid;
|
|
173
194
|
scope = _scope;
|
|
174
195
|
// If the user is using a real challenge signed by their private key.
|
|
@@ -182,7 +203,8 @@ export const createContext = async (
|
|
|
182
203
|
Sentry.setUser({ id: did });
|
|
183
204
|
|
|
184
205
|
return {
|
|
185
|
-
user: { did, isChallengeValid, scope },
|
|
206
|
+
user: { did, isChallengeValid, scope, isAuthGrant, actAsPolicy },
|
|
207
|
+
actAs,
|
|
186
208
|
domain,
|
|
187
209
|
tenant,
|
|
188
210
|
_guardianApprovalToken,
|
|
@@ -192,7 +214,7 @@ export const createContext = async (
|
|
|
192
214
|
}
|
|
193
215
|
}
|
|
194
216
|
|
|
195
|
-
return { domain, tenant, _guardianApprovalToken, sourceIp };
|
|
217
|
+
return { domain, tenant, _guardianApprovalToken, sourceIp, actAs };
|
|
196
218
|
};
|
|
197
219
|
|
|
198
220
|
export const openRoute = t.procedure
|
|
@@ -245,6 +267,55 @@ export const resolveProfileFromContextDid = async (
|
|
|
245
267
|
return getProfileByDid(did);
|
|
246
268
|
};
|
|
247
269
|
|
|
270
|
+
type ActingUser = NonNullable<Context['user']> & { profile: ProfileType | null };
|
|
271
|
+
|
|
272
|
+
const resolveActAs = async (
|
|
273
|
+
user: ActingUser,
|
|
274
|
+
actAs: string | undefined,
|
|
275
|
+
domain: string
|
|
276
|
+
): Promise<ActingUser> => {
|
|
277
|
+
const requested = actAs?.trim();
|
|
278
|
+
if (!requested) return user;
|
|
279
|
+
|
|
280
|
+
const { profile } = user;
|
|
281
|
+
const prefix = `did:web:${domain}:users:`;
|
|
282
|
+
const profileId = requested.startsWith(prefix) ? requested.slice(prefix.length) : requested;
|
|
283
|
+
if (!profileId || /[:/?#]/.test(profileId)) {
|
|
284
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid act-as profile identifier' });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Two DB lookups per request when the act-as header is present (target profile,
|
|
288
|
+
// then its managers) — acceptable at backend issuance volume; not worth caching.
|
|
289
|
+
const target = await getProfileByProfileId(profileId);
|
|
290
|
+
if (!target)
|
|
291
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: 'Act-as target profile not found' });
|
|
292
|
+
|
|
293
|
+
const managers = await getProfilesThatManageAProfile(target.profileId);
|
|
294
|
+
if (!profile || !managers.some(manager => manager.profileId === profile.profileId)) {
|
|
295
|
+
throw new TRPCError({
|
|
296
|
+
code: 'FORBIDDEN',
|
|
297
|
+
message: `You do not manage profile "${profileId}".`,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const policy = user.actAsPolicy;
|
|
302
|
+
const permitted =
|
|
303
|
+
policy === '*' ||
|
|
304
|
+
policy
|
|
305
|
+
?.split(',')
|
|
306
|
+
.map(id => id.trim())
|
|
307
|
+
.includes(target.profileId);
|
|
308
|
+
if (user.isAuthGrant && !permitted) {
|
|
309
|
+
throw new TRPCError({
|
|
310
|
+
code: 'FORBIDDEN',
|
|
311
|
+
message: `This API token may not act as "${profileId}". Grant actAs on the token.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
Sentry.setUser({ id: target.profileId, username: target.displayName });
|
|
316
|
+
return { ...user, profile: target, did: target.did, onBehalfOf: profile.profileId };
|
|
317
|
+
};
|
|
318
|
+
|
|
248
319
|
export const didRoute = openRoute.use(async ({ ctx, next }) => {
|
|
249
320
|
if (!ctx.user?.did) {
|
|
250
321
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
@@ -254,7 +325,11 @@ export const didRoute = openRoute.use(async ({ ctx, next }) => {
|
|
|
254
325
|
|
|
255
326
|
if (profile) Sentry.setUser({ id: profile.profileId, username: profile.displayName });
|
|
256
327
|
|
|
257
|
-
|
|
328
|
+
const user = ctx.user.isChallengeValid
|
|
329
|
+
? await resolveActAs({ ...ctx.user, profile }, ctx.actAs, ctx.domain)
|
|
330
|
+
: { ...ctx.user, profile };
|
|
331
|
+
|
|
332
|
+
return next({ ctx: { ...ctx, user } });
|
|
258
333
|
});
|
|
259
334
|
|
|
260
335
|
export const didAndChallengeRoute = didRoute.use(({ ctx, next }) => {
|
package/src/routes/skills.ts
CHANGED
|
@@ -241,7 +241,7 @@ export const skillsRouter = t.router({
|
|
|
241
241
|
|
|
242
242
|
if (rateCount > SEMANTIC_SEARCH_RATE_LIMIT_PER_MIN) {
|
|
243
243
|
throw new TRPCError({
|
|
244
|
-
code: 'TOO_MANY_REQUESTS'
|
|
244
|
+
code: 'TOO_MANY_REQUESTS',
|
|
245
245
|
message: `Rate limit exceeded: max ${SEMANTIC_SEARCH_RATE_LIMIT_PER_MIN} semantic searches per minute`,
|
|
246
246
|
});
|
|
247
247
|
}
|
package/src/routes/workflows.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finalizeInboxRefresh } from '@helpers/inbox-refresh.helpers';
|
|
1
2
|
import { TRPCError } from '@trpc/server';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import base64url from 'base64url';
|
|
@@ -606,14 +607,28 @@ async function handleInboxClaimPresentation(
|
|
|
606
607
|
const credentialProcessingPromises = pendingCredentials.map(async inboxCredential => {
|
|
607
608
|
try {
|
|
608
609
|
let finalCredential: VC;
|
|
609
|
-
const credentialPayload =
|
|
610
|
-
|
|
611
|
-
|
|
610
|
+
const credentialPayload = inboxCredential.refreshId
|
|
611
|
+
? undefined
|
|
612
|
+
: await decryptInboxCredential(inboxCredential.credential);
|
|
613
|
+
|
|
614
|
+
if (inboxCredential.refreshId) {
|
|
615
|
+
// A contact token authorizes delivery to the VP's DID. Never attach an unrelated
|
|
616
|
+
// contact profile's decryption key to that holder's refresh aggregate.
|
|
617
|
+
const boundProfile = await getProfileByDid(holderDid);
|
|
618
|
+
finalCredential = (
|
|
619
|
+
await finalizeInboxRefresh({
|
|
620
|
+
inboxId: inboxCredential.id,
|
|
621
|
+
holderDid,
|
|
622
|
+
holderProfile: boundProfile,
|
|
623
|
+
domain: ctx.domain,
|
|
624
|
+
})
|
|
625
|
+
).credential;
|
|
626
|
+
} else if (inboxCredential.isSigned) {
|
|
612
627
|
// Credential is already signed
|
|
613
|
-
finalCredential = JSON.parse(credentialPayload) as VC;
|
|
628
|
+
finalCredential = JSON.parse(credentialPayload!) as VC;
|
|
614
629
|
} else {
|
|
615
630
|
// Need to sign the credential using signing authority
|
|
616
|
-
const unsignedCredential = JSON.parse(credentialPayload) as UnsignedVC;
|
|
631
|
+
const unsignedCredential = JSON.parse(credentialPayload!) as UnsignedVC;
|
|
617
632
|
const inboxCredentialSigningAuthorityEndpoint =
|
|
618
633
|
(inboxCredential.signingAuthority?.endpoint as string) ?? undefined;
|
|
619
634
|
const inboxCredentialSigningAuthorityName =
|
|
@@ -676,25 +691,29 @@ async function handleInboxClaimPresentation(
|
|
|
676
691
|
).credential as VC;
|
|
677
692
|
}
|
|
678
693
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
694
|
+
let encryptedDelivery: JWE | undefined;
|
|
695
|
+
if (!inboxCredential.refreshId) {
|
|
696
|
+
// Avoid the seeded encryption wrapper, which adds the service as a recipient.
|
|
697
|
+
const deliveryLearnCard = await getEmptyLearnCard();
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
encryptedDelivery = await deliveryLearnCard.invoke.createDagJwe(
|
|
701
|
+
finalCredential,
|
|
702
|
+
[holderDid]
|
|
703
|
+
);
|
|
704
|
+
if (!encryptedDelivery.recipients?.length) {
|
|
705
|
+
throw new Error('No supported delivery encryption recipients');
|
|
706
|
+
}
|
|
707
|
+
} catch (error) {
|
|
708
|
+
deliveryEncryptionFailed = true;
|
|
709
|
+
throw error;
|
|
688
710
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
711
|
+
const finalized = await finalizeAndWipeInboxCredential(inboxCredential.id, {
|
|
712
|
+
recipientDid: holderDid,
|
|
713
|
+
credential: encryptedDelivery,
|
|
714
|
+
});
|
|
715
|
+
if (!finalized) throw new Error('Inbox credential is no longer pending');
|
|
692
716
|
}
|
|
693
|
-
const finalized = await finalizeAndWipeInboxCredential(inboxCredential.id, {
|
|
694
|
-
recipientDid: holderDid,
|
|
695
|
-
credential: encryptedDelivery,
|
|
696
|
-
});
|
|
697
|
-
if (!finalized) throw new Error('Inbox credential is no longer pending');
|
|
698
717
|
|
|
699
718
|
// Record the claim only after finalization succeeds so failed compare-and-swap
|
|
700
719
|
// attempts cannot leave a misleading audit relationship behind.
|
|
@@ -708,7 +727,7 @@ async function handleInboxClaimPresentation(
|
|
|
708
727
|
|
|
709
728
|
// Store credential and create boost relationship if this was a boost issuance
|
|
710
729
|
const boostUri = inboxCredential.boostUri;
|
|
711
|
-
if (holderProfile && boostUri) {
|
|
730
|
+
if (!inboxCredential.refreshId && holderProfile && boostUri) {
|
|
712
731
|
try {
|
|
713
732
|
const boost = await getBoostByUri(boostUri);
|
|
714
733
|
const issuerProfile = await getProfileByDid(inboxCredential.issuerDid);
|
|
@@ -717,7 +736,7 @@ async function handleInboxClaimPresentation(
|
|
|
717
736
|
// Store the credential in the database
|
|
718
737
|
const credentialInstance = await storeCredential({
|
|
719
738
|
kind: 'issued-credential',
|
|
720
|
-
credential: encryptedDelivery
|
|
739
|
+
credential: encryptedDelivery!,
|
|
721
740
|
statusEntries: getBitstringStatusListEntries(finalCredential),
|
|
722
741
|
});
|
|
723
742
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { Notification } from '../delivery.service';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({ send: vi.fn(), legacySend: vi.fn(), render: vi.fn() }));
|
|
5
|
+
vi.mock('@environment', () => ({ environment: {} }));
|
|
6
|
+
vi.mock('postmark', () => ({
|
|
7
|
+
ServerClient: class {
|
|
8
|
+
sendEmail = mocks.send;
|
|
9
|
+
sendEmailWithTemplate = mocks.legacySend;
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@learncard/email-templates', () => ({
|
|
13
|
+
resolveBranding: () => ({}),
|
|
14
|
+
renderEmail: mocks.render,
|
|
15
|
+
}));
|
|
16
|
+
import { PostmarkAdapter } from './postmark.adapter';
|
|
17
|
+
|
|
18
|
+
const notification: Notification = {
|
|
19
|
+
contactMethod: { type: 'email', value: 'holder@example.com' },
|
|
20
|
+
templateId: 'credential-updated',
|
|
21
|
+
templateModel: { issuer: { name: 'School' }, credential: { name: 'Biology' } },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
mocks.render.mockResolvedValue({ html: '<p>Updated</p>', text: 'Updated', subject: 'Updated' });
|
|
27
|
+
mocks.send.mockResolvedValue({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('update email provider attempts', () => {
|
|
31
|
+
it('sends the locally rendered update once', async () => {
|
|
32
|
+
await new PostmarkAdapter('test-key').send(notification);
|
|
33
|
+
expect(mocks.send).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(mocks.legacySend).not.toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not try a legacy template after an ambiguous provider failure', async () => {
|
|
38
|
+
mocks.send.mockRejectedValueOnce(new Error('Acknowledgement lost'));
|
|
39
|
+
await expect(new PostmarkAdapter('test-key').send(notification)).rejects.toThrow(
|
|
40
|
+
'Acknowledgement lost'
|
|
41
|
+
);
|
|
42
|
+
expect(mocks.send).toHaveBeenCalledTimes(1);
|
|
43
|
+
expect(mocks.legacySend).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('fails without sending when the local update template cannot render', async () => {
|
|
47
|
+
mocks.render.mockRejectedValueOnce(new Error('Render failed'));
|
|
48
|
+
await expect(new PostmarkAdapter('test-key').send(notification)).rejects.toThrow(
|
|
49
|
+
'Render failed'
|
|
50
|
+
);
|
|
51
|
+
expect(mocks.send).not.toHaveBeenCalled();
|
|
52
|
+
expect(mocks.legacySend).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -41,6 +41,7 @@ const LOCAL_TEMPLATE_MAP: Record<string, TemplateId> = {
|
|
|
41
41
|
'guardian-credential-approval': 'guardian-credential-approval',
|
|
42
42
|
'guardian-email-otp': 'guardian-email-otp',
|
|
43
43
|
'guardian-rejected-credential': 'guardian-rejected-credential',
|
|
44
|
+
'credential-updated': 'credential-updated',
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
export class PostmarkAdapter implements DeliveryService {
|
|
@@ -85,6 +86,8 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
85
86
|
`[PostmarkAdapter] Local render failed for "${notification.templateId}":`,
|
|
86
87
|
renderError
|
|
87
88
|
);
|
|
89
|
+
// This template is owned locally; there is no legacy provider alias.
|
|
90
|
+
if (notification.templateId === 'credential-updated') throw renderError;
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
if (rendered) {
|
|
@@ -104,6 +107,9 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
104
107
|
`[PostmarkAdapter] sendEmail API failed for "${notification.templateId}":`,
|
|
105
108
|
sendError
|
|
106
109
|
);
|
|
110
|
+
// A lost provider acknowledgement may already have sent the email.
|
|
111
|
+
// Do not bypass the refresh delivery claim with a second attempt.
|
|
112
|
+
if (notification.templateId === 'credential-updated') throw sendError;
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
}
|
|
@@ -217,6 +223,12 @@ export class PostmarkAdapter implements DeliveryService {
|
|
|
217
223
|
recipient: model.recipient,
|
|
218
224
|
};
|
|
219
225
|
|
|
226
|
+
case 'credential-updated':
|
|
227
|
+
return {
|
|
228
|
+
issuer: model.issuer,
|
|
229
|
+
credential: model.credential,
|
|
230
|
+
};
|
|
231
|
+
|
|
220
232
|
default:
|
|
221
233
|
return model as TemplateDataMap[TemplateId];
|
|
222
234
|
}
|
|
@@ -7,7 +7,8 @@ export const getRegistryService = (): RegistryService => {
|
|
|
7
7
|
if (
|
|
8
8
|
environment.NODE_ENV === 'test' ||
|
|
9
9
|
environment.TRUSTED_ISSUERS_WHITELIST ||
|
|
10
|
-
environment.IS_CI
|
|
10
|
+
environment.IS_CI ||
|
|
11
|
+
environment.IS_E2E_TEST
|
|
11
12
|
) {
|
|
12
13
|
return new WhitelistAdapter();
|
|
13
14
|
}
|