@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.
Files changed (199) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +80 -0
  3. package/compose.inbox.yml +7 -0
  4. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  5. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  6. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  7. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  8. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  9. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  10. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  11. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  12. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  13. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  14. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  15. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  16. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  18. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  19. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  20. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  21. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  22. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  23. package/dist/cache/atomic-counter.test.d.ts +2 -0
  24. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  25. package/dist/cache/index.d.ts +1 -1
  26. package/dist/cache/index.d.ts.map +1 -1
  27. package/dist/config/environment.d.ts +46 -0
  28. package/dist/config/environment.d.ts.map +1 -1
  29. package/dist/credential-refresh.d.ts.map +1 -1
  30. package/dist/helpers/activity.helpers.d.ts +4 -0
  31. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  32. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  33. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  34. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  35. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  36. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  37. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  38. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  39. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  40. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  41. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  42. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  43. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  44. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  45. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  46. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  47. package/dist/helpers/credential.helpers.d.ts +2 -1
  48. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  49. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  50. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  51. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  52. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  53. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  54. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  55. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  56. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  57. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  58. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  59. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  60. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  61. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  62. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  63. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  64. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  65. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  66. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  67. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  68. package/dist/helpers/inbox.helpers.d.ts +9 -1
  69. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  71. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  72. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  73. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  74. package/dist/helpers/shim.d.ts +1 -1
  75. package/dist/helpers/shim.d.ts.map +1 -1
  76. package/dist/inbox-queue-local.d.ts +2 -0
  77. package/dist/inbox-queue-local.d.ts.map +1 -0
  78. package/dist/index.js +2 -2
  79. package/dist/models/AuthGrant.d.ts.map +1 -1
  80. package/dist/models/CredentialActivity.d.ts +1 -0
  81. package/dist/models/CredentialActivity.d.ts.map +1 -1
  82. package/dist/models/CredentialRefresh.d.ts +5 -2
  83. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  84. package/dist/models/InboxCredential.d.ts +1 -0
  85. package/dist/models/InboxCredential.d.ts.map +1 -1
  86. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  87. package/dist/openapi.d.ts.map +1 -1
  88. package/dist/routes/act-as.test.d.ts +2 -0
  89. package/dist/routes/act-as.test.d.ts.map +1 -0
  90. package/dist/routes/activity.d.ts +3 -0
  91. package/dist/routes/activity.d.ts.map +1 -1
  92. package/dist/routes/auth-grants.d.ts +10 -0
  93. package/dist/routes/auth-grants.d.ts.map +1 -1
  94. package/dist/routes/boosts.d.ts +494 -0
  95. package/dist/routes/boosts.d.ts.map +1 -1
  96. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  97. package/dist/routes/credentials.d.ts.map +1 -1
  98. package/dist/routes/inbox.d.ts +953 -11
  99. package/dist/routes/inbox.d.ts.map +1 -1
  100. package/dist/routes/index.d.ts +42 -126
  101. package/dist/routes/index.d.ts.map +1 -1
  102. package/dist/routes/workflows.d.ts.map +1 -1
  103. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  104. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  105. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  106. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  107. package/dist/types/activity.d.ts +4 -0
  108. package/dist/types/activity.d.ts.map +1 -1
  109. package/dist/types/credential-refresh.d.ts +10 -1
  110. package/dist/types/credential-refresh.d.ts.map +1 -1
  111. package/dist/types/inbox-batch.d.ts +38 -0
  112. package/dist/types/inbox-batch.d.ts.map +1 -0
  113. package/inbox-e2e-setup.ts +53 -1
  114. package/inbox-elasticmq.conf +10 -0
  115. package/lambda.ts +61 -16
  116. package/package.json +18 -16
  117. package/scripts/setup-inbox-queues.ts +60 -0
  118. package/serverless-local.yml +2 -0
  119. package/serverless.yml +75 -0
  120. package/src/accesslayer/auth-grant/read.ts +2 -1
  121. package/src/accesslayer/credential-activity/create.ts +1 -0
  122. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  123. package/src/accesslayer/credential-refresh/index.ts +1 -0
  124. package/src/accesslayer/credential-refresh/read.ts +8 -2
  125. package/src/accesslayer/credential-refresh/update.ts +4 -0
  126. package/src/accesslayer/inbox-batch/store.ts +469 -0
  127. package/src/accesslayer/inbox-credential/create.ts +51 -19
  128. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  129. package/src/accesslayer/inbox-credential/update.ts +1 -1
  130. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  131. package/src/cache/atomic-counter.test.ts +6 -0
  132. package/src/cache/index.ts +25 -13
  133. package/src/config/environment.ts +26 -0
  134. package/src/credential-refresh.ts +5 -1
  135. package/src/docker-entry.ts +8 -0
  136. package/src/helpers/activity.helpers.ts +4 -0
  137. package/src/helpers/app-counter.helpers.ts +1 -1
  138. package/src/helpers/contact-method.helpers.test.ts +43 -0
  139. package/src/helpers/contact-method.helpers.ts +3 -4
  140. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  141. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  142. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  143. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  144. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  145. package/src/helpers/credential-refresh.helpers.ts +375 -99
  146. package/src/helpers/credential.helpers.ts +4 -1
  147. package/src/helpers/finalize-inbox.helpers.ts +29 -14
  148. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  149. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  150. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  151. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  152. package/src/helpers/inbox-batch.helpers.ts +374 -0
  153. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  154. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  155. package/src/helpers/inbox-queue.helpers.ts +372 -0
  156. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  157. package/src/helpers/inbox.helpers.ts +268 -32
  158. package/src/helpers/notifications.helpers.ts +4 -2
  159. package/src/helpers/rateLimit.helpers.ts +2 -5
  160. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  161. package/src/helpers/shim.ts +32 -31
  162. package/src/inbox-queue-local.ts +28 -0
  163. package/src/models/AuthGrant.ts +1 -0
  164. package/src/models/CredentialActivity.ts +3 -5
  165. package/src/models/CredentialRefresh.ts +4 -2
  166. package/src/models/InboxCredential.ts +2 -0
  167. package/src/models/credential-refresh-constraints.test.ts +8 -3
  168. package/src/models/credential-refresh-constraints.ts +5 -0
  169. package/src/openapi.ts +8 -0
  170. package/src/routes/act-as.test.ts +239 -0
  171. package/src/routes/app-store.ts +2 -2
  172. package/src/routes/auth-grants.ts +13 -11
  173. package/src/routes/boosts.ts +914 -92
  174. package/src/routes/credential-refreshes.test.ts +8 -2
  175. package/src/routes/credential-refreshes.ts +4 -1
  176. package/src/routes/credentials.ts +2 -1
  177. package/src/routes/inbox.ts +95 -49
  178. package/src/routes/index.ts +82 -7
  179. package/src/routes/skills.ts +1 -1
  180. package/src/routes/workflows.ts +43 -24
  181. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  182. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  183. package/src/services/registry/registry.factory.ts +2 -1
  184. package/src/types/activity.ts +2 -0
  185. package/src/types/credential-refresh.ts +21 -2
  186. package/src/types/inbox-batch.ts +46 -0
  187. package/test/atomic-counter.e2e.ts +12 -0
  188. package/test/auth-grant.spec.ts +21 -21
  189. package/test/credential-refresh-email.spec.ts +663 -0
  190. package/test/credential-refresh-publication.spec.ts +2 -0
  191. package/test/helpers/inbox-e2e-environment.ts +6 -0
  192. package/test/inbox-batch.spec.ts +1338 -0
  193. package/test/inbox-refresh.spec.ts +534 -0
  194. package/test/openapi.spec.ts +41 -1
  195. package/test/refresh-send.spec.ts +1799 -0
  196. package/test/registry.spec.ts +56 -11
  197. package/vitest.config.ts +9 -0
  198. package/vitest.inbox-e2e.config.ts +5 -1
  199. 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('lc:network:credential:root');
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('lc:network:credential:root');
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
- return sendRefreshableCredential({
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
@@ -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 { getBoostByUri, getBoostsForProfile } from '@accesslayer/boost/read';
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, credential: inputCredential, templateUri, configuration } = input;
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
- try {
474
- // Use shared helper to prepare credential with templateData rendering,
475
- // issuance date, boostId injection, and OBv3 alignments
476
- resolvedBoostUri = getBoostUri(boostInstance.id, ctx.domain);
477
-
478
- credential = await prepareCredentialFromBoost(
479
- boostInstance,
480
- resolvedBoostUri,
481
- ctx.domain,
482
- { templateData: configuration?.templateData as Record<string, unknown> }
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
- } catch (e) {
485
- console.error('Failed to prepare boost credential', e);
486
- throw new TRPCError({
487
- code: 'BAD_REQUEST',
488
- message: `Failed to prepare boost credential template: ${templateUri}`,
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
- if (!credential) {
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
- normalizedConfiguration,
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 inboxCredential;
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 ─────────────────────────────────────
@@ -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 { isChallengeValid: _isChallengeValid, scope: _scope } =
170
- await isAuthGrantChallengeValidForDID(challenge, did);
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
- return next({ ctx: { ...ctx, user: { ...ctx.user, profile } } });
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 }) => {
@@ -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' as 'BAD_REQUEST',
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
  }
@@ -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 = await decryptInboxCredential(inboxCredential.credential);
610
-
611
- if (inboxCredential.isSigned) {
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
- // Avoid the seeded encryption wrapper, which adds the service as a recipient.
680
- const deliveryLearnCard = await getEmptyLearnCard();
681
- let encryptedDelivery: JWE;
682
- try {
683
- encryptedDelivery = await deliveryLearnCard.invoke.createDagJwe(finalCredential, [
684
- holderDid,
685
- ]);
686
- if (!encryptedDelivery.recipients?.length) {
687
- throw new Error('No supported delivery encryption recipients');
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
- } catch (error) {
690
- deliveryEncryptionFailed = true;
691
- throw error;
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
  }