@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
@@ -1,4 +1,19 @@
1
+ import { environment } from '@environment';
2
+ import { recordInboxRefreshClaimUrl } from '@accesslayer/inbox-credential/refresh';
3
+ import { randomUUID } from 'node:crypto';
4
+ import {
5
+ assertInboxRefreshEnabled,
6
+ prepareInboxRefresh,
7
+ getInboxRefreshReceipt,
8
+ finalizeInboxRefresh,
9
+ getInboxRefreshIssueKey,
10
+ resumeInboxRefreshDelivery,
11
+ } from './inbox-refresh.helpers';
1
12
  import { TRPCError } from '@trpc/server';
13
+ import {
14
+ InboxDeliveryCheckpointError,
15
+ InboxIssuancePreflightError,
16
+ } from './inbox-issuance-error.helpers';
2
17
  import {
3
18
  VC,
4
19
  UnsignedVC,
@@ -32,7 +47,7 @@ import { doesProfileManageProfile } from '@accesslayer/profile-manager/relations
32
47
  import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
33
48
  import { getProfileForInboxCredential } from '@accesslayer/inbox-credential/read';
34
49
  import { sendCredential } from '@helpers/credential.helpers';
35
- import { sendBoost } from '@helpers/boost.helpers';
50
+ import { prepareCredentialFromBoost, getBoostUri, sendBoost } from '@helpers/boost.helpers';
36
51
  import { getBoostByUri } from '@accesslayer/boost/read';
37
52
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
38
53
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -192,9 +207,15 @@ export const issueToInbox = async (
192
207
  activityId?: string;
193
208
  integrationId?: string;
194
209
  guardianEmail?: string;
210
+ refresh?: boolean;
211
+ idempotencyKey?: string;
212
+ refreshRequestDigest?: string;
195
213
  } = {},
196
- ctx: Context
214
+ ctx: Context,
215
+ /** Queue ownership checkpoint immediately before delivery or inbox persistence. */
216
+ beforeDelivery?: () => Promise<void>
197
217
  ): Promise<{
218
+ refresh?: import('@learncard/types').InboxCredentialRefreshReceipt;
198
219
  status: 'PENDING' | 'ISSUED' | 'EXPIRED' | 'DELIVERED' | 'CLAIMED'; // DELIVERED & CLAIMED are deprecated, use ISSUED
199
220
  inboxCredential: InboxCredentialType;
200
221
  claimUrl?: string;
@@ -211,13 +232,35 @@ export const issueToInbox = async (
211
232
  integrationId,
212
233
  guardianEmail,
213
234
  } = configuration;
235
+ if (configuration.refresh) {
236
+ await assertInboxRefreshEnabled(ctx.user?.scope);
237
+ if (
238
+ credential.proof ||
239
+ credential.type.includes('VerifiablePresentation') ||
240
+ Array.isArray(credential.credentialSubject)
241
+ ) {
242
+ throw new TRPCError({
243
+ code: 'BAD_REQUEST',
244
+ message:
245
+ 'Inbox refresh requires an unsigned credential with a single subject and a signing authority.',
246
+ });
247
+ }
248
+ }
214
249
  const isSigned = !!credential?.proof;
215
250
  let signingAuthority: IssueInboxSigningAuthority | undefined = _signingAuthority;
251
+ let deliveryCheckpointReached = beforeDelivery === undefined;
252
+ const checkpointDelivery = async (): Promise<void> => {
253
+ await beforeDelivery?.();
254
+ deliveryCheckpointReached = true;
255
+ };
256
+ const assertDeliveryCheckpoint = (result: InboxDeliveryCheckpointError['result']): void => {
257
+ if (!deliveryCheckpointReached) throw new InboxDeliveryCheckpointError(result);
258
+ };
216
259
 
217
260
  if (recipient.type === 'phone') {
218
261
  const isTrusted = await getRegistryService().isTrusted(issuerProfile.did);
219
262
  if (!isTrusted) {
220
- throw new TRPCError({
263
+ throw new InboxIssuancePreflightError({
221
264
  code: 'FORBIDDEN',
222
265
  message:
223
266
  'Sending credentials via phone is a feature reserved for members of the LearnCard Trusted Registry. Email delivery is available for all issuers. To verify your issuer, visit: https://docs.learncard.com/how-to-guides/verify-my-issuer',
@@ -245,20 +288,35 @@ export const issueToInbox = async (
245
288
  * By providing this parameter, the developer explicitly takes responsibility for the signing process. We trust them and proceed.
246
289
  **/
247
290
  if (!(await verifyCredentialCanBeSigned(credential as UnsignedVC))) {
248
- throw new TRPCError({
291
+ throw new InboxIssuancePreflightError({
249
292
  code: 'BAD_REQUEST',
250
293
  message:
251
294
  'Credential failed to pass a pre-flight issuance test. Please verify that the credential is well-formed and can be issued.',
252
295
  });
253
296
  }
254
297
  } else {
255
- throw new TRPCError({
298
+ throw new InboxIssuancePreflightError({
256
299
  code: 'BAD_REQUEST',
257
300
  message: 'Unsigned credentials require a signing authority',
258
301
  });
259
302
  }
260
303
  }
261
304
 
305
+ if (
306
+ configuration.refresh &&
307
+ (!signingAuthority ||
308
+ !(await getSigningAuthorityForUserByName(
309
+ issuerProfile,
310
+ signingAuthority.endpoint,
311
+ signingAuthority.name
312
+ )))
313
+ ) {
314
+ throw new TRPCError({
315
+ code: 'NOT_FOUND',
316
+ message: 'Signing authority not found for issuer',
317
+ });
318
+ }
319
+
262
320
  // Check if recipient already exists with verified email
263
321
  const existingProfile = await getProfileByVerifiedContactMethod(
264
322
  recipient.type,
@@ -272,7 +330,7 @@ export const issueToInbox = async (
272
330
  : [];
273
331
  const recipientIsManaged = recipientManagers.length > 0;
274
332
 
275
- if (existingProfile && !guardianEmail && !recipientIsManaged) {
333
+ if (existingProfile && !guardianEmail && !recipientIsManaged && !configuration.refresh) {
276
334
  // Auto-deliver to existing user
277
335
  let finalCredential: VC;
278
336
 
@@ -287,7 +345,7 @@ export const issueToInbox = async (
287
345
  );
288
346
 
289
347
  if (!signingAuthorityForUser) {
290
- throw new TRPCError({
348
+ throw new InboxIssuancePreflightError({
291
349
  code: 'NOT_FOUND',
292
350
  message: 'Signing authority not found for issuer',
293
351
  });
@@ -317,31 +375,20 @@ export const issueToInbox = async (
317
375
  credential: encryptedDelivery,
318
376
  statusEntries: getBitstringStatusListEntries(finalCredential),
319
377
  };
320
- if (boostUri) {
321
- const boost = await getBoostByUri(boostUri);
322
- if (boost) {
323
- await sendBoost({
324
- from: { type: 'profile', profile: issuerProfile },
325
- to: existingProfile,
326
- boost,
327
- credential: delivery,
328
- domain: ctx.domain,
329
- activityId,
330
- integrationId,
331
- });
332
- } else {
333
- // Fallback to sendCredential if boost not found
334
- await sendCredential(
335
- issuerProfile,
336
- existingProfile,
337
- delivery,
338
- ctx.domain,
339
- undefined,
340
- activityId,
341
- integrationId
342
- );
343
- }
378
+ const boost = boostUri ? await getBoostByUri(boostUri) : undefined;
379
+ await checkpointDelivery();
380
+ if (boostUri && boost) {
381
+ await sendBoost({
382
+ from: { type: 'profile', profile: issuerProfile },
383
+ to: existingProfile,
384
+ boost,
385
+ credential: delivery,
386
+ domain: ctx.domain,
387
+ activityId,
388
+ integrationId,
389
+ });
344
390
  } else {
391
+ // Fall back to ordinary delivery when no boost exists for the supplied URI.
345
392
  await sendCredential(
346
393
  issuerProfile,
347
394
  existingProfile,
@@ -372,6 +419,7 @@ export const issueToInbox = async (
372
419
  await logCredentialDelivered({
373
420
  activityId,
374
421
  actorProfileId: issuerProfile.profileId,
422
+ onBehalfOf: ctx.user?.onBehalfOf,
375
423
  recipientType: recipient.type as 'email' | 'phone',
376
424
  recipientIdentifier: recipient.value,
377
425
  recipientProfileId: existingProfile.profileId,
@@ -420,6 +468,11 @@ export const issueToInbox = async (
420
468
  });
421
469
  }
422
470
 
471
+ assertDeliveryCheckpoint({
472
+ issuanceId: finalizedInboxCredential.id,
473
+ status: LCNInboxStatusEnumValidator.enum.ISSUED,
474
+ recipientDid: existingProfile.did,
475
+ });
423
476
  return {
424
477
  status: LCNInboxStatusEnumValidator.enum.ISSUED,
425
478
  inboxCredential: finalizedInboxCredential,
@@ -429,7 +482,24 @@ export const issueToInbox = async (
429
482
  // Store in inbox for claiming
430
483
  // Guardian gate if issuer specified guardianEmail OR recipient is a managed child
431
484
  const needsGuardianGate = !!guardianEmail || recipientIsManaged;
485
+ const preparedRefresh = configuration.refresh
486
+ ? await prepareInboxRefresh(credential, issuerProfile, ctx.domain)
487
+ : undefined;
488
+ if (preparedRefresh) credential = preparedRefresh.credential;
489
+ await checkpointDelivery();
432
490
  const inboxCredential = await createInboxCredential({
491
+ ...(preparedRefresh
492
+ ? {
493
+ refresh: {
494
+ aggregate: preparedRefresh.aggregate,
495
+ issueKey: getInboxRefreshIssueKey(
496
+ issuerProfile.profileId,
497
+ configuration.idempotencyKey ?? randomUUID()
498
+ ),
499
+ requestDigest: configuration.refreshRequestDigest!,
500
+ },
501
+ }
502
+ : {}),
433
503
  credential: JSON.stringify(credential),
434
504
  isSigned,
435
505
  recipient,
@@ -444,6 +514,101 @@ export const issueToInbox = async (
444
514
  ...(needsGuardianGate ? { guardianStatus: 'AWAITING_GUARDIAN' as const } : {}),
445
515
  });
446
516
 
517
+ const refresh = inboxCredential.refreshId
518
+ ? await getInboxRefreshReceipt(inboxCredential.refreshId, ctx.domain)
519
+ : undefined;
520
+ if (refresh && existingProfile && !needsGuardianGate) {
521
+ await finalizeInboxRefresh({
522
+ inboxId: inboxCredential.id,
523
+ holderDid: existingProfile.did,
524
+ holderProfile: existingProfile,
525
+ domain: ctx.domain,
526
+ accepted: false,
527
+ });
528
+ await resumeInboxRefreshDelivery(refresh.refreshId, ctx.domain);
529
+ await createDeliveredRelationship(
530
+ issuerProfile.profileId,
531
+ inboxCredential.id,
532
+ existingProfile.did,
533
+ 'auto-delivery'
534
+ );
535
+ if (activityId)
536
+ await logCredentialDelivered({
537
+ activityId,
538
+ actorProfileId: issuerProfile.profileId,
539
+ onBehalfOf: ctx.user?.onBehalfOf,
540
+ recipientType: recipient.type,
541
+ recipientIdentifier: recipient.value,
542
+ recipientProfileId: existingProfile.profileId,
543
+ boostUri,
544
+ inboxCredentialId: inboxCredential.id,
545
+ integrationId,
546
+ source: 'send',
547
+ });
548
+ if (webhookUrl)
549
+ await addNotificationToQueue({
550
+ webhookUrl,
551
+ type: LCNNotificationTypeEnumValidator.enum.ISSUANCE_DELIVERED,
552
+ from: { did: (await getLearnCard()).id.did() },
553
+ to: issuerProfile,
554
+ message: getNotificationMessage(
555
+ 'issuanceDelivered',
556
+ resolveRecipientLocale(issuerProfile),
557
+ {
558
+ issuer: issuerProfile.displayName,
559
+ recipientType: recipient.type,
560
+ recipientValue: recipient.value,
561
+ }
562
+ ),
563
+ data: {
564
+ inbox: {
565
+ issuanceId: inboxCredential.id,
566
+ status: LCNInboxStatusEnumValidator.enum.ISSUED,
567
+ recipient: {
568
+ contactMethod: recipient,
569
+ learnCardId: existingProfile.did,
570
+ },
571
+ timestamp: new Date().toISOString(),
572
+ },
573
+ },
574
+ });
575
+ // Existing accounts receive the signed credential in their app inbox.
576
+ // Send the email there too, without a claim token for an already-bound delivery.
577
+ if (recipient.type === 'email' && !delivery?.suppress) {
578
+ try {
579
+ const appUrl = environment.IS_OFFLINE
580
+ ? generateClaimUrl('')
581
+ : ctx.tenant?.emailBranding?.appUrl || generateClaimUrl('');
582
+ await getDeliveryService(recipient).send({
583
+ contactMethod: recipient,
584
+ templateId: 'universal-inbox-claim',
585
+ templateModel: {
586
+ claimUrl: new URL('/notifications', appUrl).href,
587
+ issuer: { name: issuerProfile.displayName },
588
+ credential: { name: credential.name },
589
+ },
590
+ branding: ctx.tenant?.emailBranding,
591
+ locale: resolveRecipientLocale(existingProfile),
592
+ messageStream: 'universal-inbox',
593
+ });
594
+ } catch {
595
+ // The credential and in-app notification are already durable. An
596
+ // email transport failure must not turn that delivery into an error.
597
+ console.warn('Inbox refresh: initial email failed after auto-delivery.');
598
+ }
599
+ }
600
+ return {
601
+ status: 'ISSUED',
602
+ inboxCredential: {
603
+ ...inboxCredential,
604
+ currentStatus: 'ISSUED',
605
+ credential: undefined,
606
+ },
607
+ recipientDid: existingProfile.did,
608
+ refresh: { ...refresh, holderDid: existingProfile.did },
609
+ };
610
+ }
611
+
447
612
  // Generate claim token
448
613
  let recipientContactMethod = await getContactMethodByValue(recipient.type, recipient.value);
449
614
  if (!recipientContactMethod) {
@@ -817,13 +982,84 @@ export const issueToInbox = async (
817
982
  expiresInDays ? 24 * expiresInDays : 24,
818
983
  false
819
984
  );
820
- const claimUrl = generateClaimUrl(claimToken);
985
+ const claimUrl = refresh
986
+ ? await recordInboxRefreshClaimUrl(inboxCredential.id, generateClaimUrl(claimToken))
987
+ : generateClaimUrl(claimToken);
821
988
 
989
+ assertDeliveryCheckpoint({
990
+ issuanceId: inboxCredential.id,
991
+ status: LCNInboxStatusEnumValidator.enum.PENDING,
992
+ claimUrl,
993
+ ...(guardianEmail ? { guardianStatus: 'AWAITING_GUARDIAN' as const } : {}),
994
+ });
822
995
  return {
823
996
  status: LCNInboxStatusEnumValidator.enum.PENDING,
824
997
  inboxCredential,
825
998
  claimUrl,
999
+ ...(refresh ? { refresh } : {}),
826
1000
  ...(guardianEmail ? { guardianStatus: 'AWAITING_GUARDIAN' as const } : {}),
827
1001
  };
828
1002
  }
829
1003
  };
1004
+
1005
+ /**
1006
+ * Resolves an input into the credential that issueToInbox consumes. Keeping this outside either
1007
+ * route makes direct credentials and Boost templates behave identically for single and batch
1008
+ * issuance, including template rendering, Boost metadata, and standard client-facing errors.
1009
+ */
1010
+ export const resolveInboxCredentialInput = async (
1011
+ input: IssueInboxCredentialType,
1012
+ ctx: Context
1013
+ ): Promise<{ credential: VC | UnsignedVC | VP; resolvedBoostUri?: string }> => {
1014
+ const { templateUri, configuration } = input;
1015
+ // A direct credential wins when both fields are present, matching the existing single-route
1016
+ // behavior. A template is only loaded when the caller supplied no credential.
1017
+ let credential = input.credential;
1018
+ let resolvedBoostUri: string | undefined;
1019
+
1020
+ if (templateUri && !credential) {
1021
+ const boostInstance = await getBoostByUri(templateUri);
1022
+
1023
+ if (!boostInstance) {
1024
+ throw new TRPCError({
1025
+ code: 'NOT_FOUND',
1026
+ message: `Boost not found: ${templateUri}`,
1027
+ });
1028
+ }
1029
+
1030
+ if (!boostInstance.dataValues.boost) {
1031
+ throw new TRPCError({
1032
+ code: 'BAD_REQUEST',
1033
+ message: `Boost does not contain a credential template: ${templateUri}`,
1034
+ });
1035
+ }
1036
+
1037
+ try {
1038
+ // Use shared helper to prepare credential with templateData rendering,
1039
+ // issuance date, boostId injection, and OBv3 alignments
1040
+ resolvedBoostUri = getBoostUri(boostInstance.id, ctx.domain);
1041
+
1042
+ credential = await prepareCredentialFromBoost(
1043
+ boostInstance,
1044
+ resolvedBoostUri,
1045
+ ctx.domain,
1046
+ { templateData: configuration?.templateData as Record<string, unknown> }
1047
+ );
1048
+ } catch (e) {
1049
+ console.error('Failed to prepare boost credential', e);
1050
+ throw new TRPCError({
1051
+ code: 'BAD_REQUEST',
1052
+ message: `Failed to prepare boost credential template: ${templateUri}`,
1053
+ });
1054
+ }
1055
+ }
1056
+
1057
+ if (!credential) {
1058
+ throw new TRPCError({
1059
+ code: 'BAD_REQUEST',
1060
+ message: 'Either credential or templateUri must be provided',
1061
+ });
1062
+ }
1063
+
1064
+ return { credential, resolvedBoostUri };
1065
+ };
@@ -223,8 +223,10 @@ export const buildCredentialRefreshedNotification = (
223
223
  return {
224
224
  notification: {
225
225
  type: LCNNotificationTypeEnumValidator.enum.CREDENTIAL_REFRESHED,
226
- to: holderProfile,
227
- from: issuerProfile,
226
+ // Local webhook delivery normalizes these DIDs in place. Keep the
227
+ // database profiles intact for the subsequent verified-email lookup.
228
+ to: { ...holderProfile },
229
+ from: { ...issuerProfile },
228
230
  message: getNotificationMessage(
229
231
  'credentialRefreshed',
230
232
  resolveRecipientLocale(holderProfile),
@@ -24,7 +24,7 @@ export type RateLimitWindow = {
24
24
  };
25
25
 
26
26
  /**
27
- * Consume one unit against each window, in order, and throw once any is
27
+ * Consume the requested units against each window, in order, and throw once any is
28
28
  * exhausted.
29
29
  *
30
30
  * Fails CLOSED: if the cache is unavailable, `cache.incr` returns `undefined`
@@ -48,11 +48,8 @@ export const enforceRateLimits = async (windows: RateLimitWindow[]): Promise<voi
48
48
  }
49
49
 
50
50
  if (count > limit) {
51
- // tRPC has no native 429; the existing convention in this service is
52
- // to cast to BAD_REQUEST while keeping the semantic code, so clients
53
- // can still discriminate on it.
54
51
  throw new TRPCError({
55
- code: 'TOO_MANY_REQUESTS' as 'BAD_REQUEST',
52
+ code: 'TOO_MANY_REQUESTS',
56
53
  message: `Rate limit exceeded: ${description}`,
57
54
  });
58
55
  }