@learncard/network-brain-service 3.17.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/.env.example +4 -0
  2. package/AGENTS.md +177 -159
  3. package/CHANGELOG.md +148 -0
  4. package/README.md +26 -8
  5. package/compose.inbox.yml +7 -0
  6. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  7. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  8. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  9. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +3 -2
  10. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  11. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +1 -0
  12. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  13. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +5 -3
  14. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  15. package/dist/accesslayer/credential/create.d.ts +2 -1
  16. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  17. package/dist/accesslayer/credential/create.test.d.ts +2 -0
  18. package/dist/accesslayer/credential/create.test.d.ts.map +1 -0
  19. package/dist/accesslayer/credential/relationships/read.d.ts +1 -0
  20. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  21. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  22. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  23. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  24. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  25. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  26. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  27. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  28. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  29. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  30. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  31. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  32. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  33. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  34. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  35. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  36. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  37. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  38. package/dist/cache/atomic-counter.test.d.ts +2 -0
  39. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  40. package/dist/cache/index.d.ts +1 -1
  41. package/dist/cache/index.d.ts.map +1 -1
  42. package/dist/config/environment.d.ts +46 -0
  43. package/dist/config/environment.d.ts.map +1 -1
  44. package/dist/credential-refresh.d.ts.map +1 -1
  45. package/dist/didkit_wasm_bg.wasm +0 -0
  46. package/dist/helpers/activity.helpers.d.ts +4 -0
  47. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  48. package/dist/helpers/boost-storage.helpers.test.d.ts +2 -0
  49. package/dist/helpers/boost-storage.helpers.test.d.ts.map +1 -0
  50. package/dist/helpers/boost.helpers.d.ts +3 -7
  51. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  52. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  53. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  54. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  55. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  56. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  57. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  58. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  59. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  60. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  61. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  62. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  63. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  64. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  65. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  66. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  67. package/dist/helpers/credential.helpers.d.ts +4 -2
  68. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  69. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  70. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  71. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  72. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  73. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  74. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  75. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  76. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  77. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  78. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  79. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  80. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  81. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  82. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  83. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  84. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  85. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  86. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  87. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  88. package/dist/helpers/inbox.helpers.d.ts +9 -1
  89. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  90. package/dist/helpers/issuedCredentialStatus.helpers.d.ts +5 -0
  91. package/dist/helpers/issuedCredentialStatus.helpers.d.ts.map +1 -0
  92. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  93. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  94. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  95. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  96. package/dist/helpers/shim.d.ts +1 -1
  97. package/dist/helpers/shim.d.ts.map +1 -1
  98. package/dist/helpers/signingAuthority.helpers.d.ts +3 -2
  99. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  100. package/dist/helpers/signingAuthority.helpers.test.d.ts +2 -0
  101. package/dist/helpers/signingAuthority.helpers.test.d.ts.map +1 -0
  102. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  103. package/dist/helpers/status-list.helpers.test.d.ts +2 -0
  104. package/dist/helpers/status-list.helpers.test.d.ts.map +1 -0
  105. package/dist/inbox-queue-local.d.ts +2 -0
  106. package/dist/inbox-queue-local.d.ts.map +1 -0
  107. package/dist/index.js +2 -2
  108. package/dist/models/AuthGrant.d.ts.map +1 -1
  109. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  110. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  111. package/dist/models/Credential.d.ts +1 -0
  112. package/dist/models/Credential.d.ts.map +1 -1
  113. package/dist/models/CredentialActivity.d.ts +1 -0
  114. package/dist/models/CredentialActivity.d.ts.map +1 -1
  115. package/dist/models/CredentialRefresh.d.ts +5 -2
  116. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  117. package/dist/models/InboxCredential.d.ts +1 -0
  118. package/dist/models/InboxCredential.d.ts.map +1 -1
  119. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  120. package/dist/openapi.d.ts.map +1 -1
  121. package/dist/routes/act-as.test.d.ts +2 -0
  122. package/dist/routes/act-as.test.d.ts.map +1 -0
  123. package/dist/routes/activity.d.ts +3 -0
  124. package/dist/routes/activity.d.ts.map +1 -1
  125. package/dist/routes/app-store.d.ts.map +1 -1
  126. package/dist/routes/auth-grants.d.ts +10 -0
  127. package/dist/routes/auth-grants.d.ts.map +1 -1
  128. package/dist/routes/boosts.d.ts +506 -12
  129. package/dist/routes/boosts.d.ts.map +1 -1
  130. package/dist/routes/contracts.d.ts +48 -2
  131. package/dist/routes/contracts.d.ts.map +1 -1
  132. package/dist/routes/credential-refreshes.d.ts +4 -4
  133. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  134. package/dist/routes/credentials.d.ts +8 -2
  135. package/dist/routes/credentials.d.ts.map +1 -1
  136. package/dist/routes/did-metadata.d.ts +8 -8
  137. package/dist/routes/federation.d.ts +2 -2
  138. package/dist/routes/inbox.d.ts +973 -31
  139. package/dist/routes/inbox.d.ts.map +1 -1
  140. package/dist/routes/index.d.ts +47 -127
  141. package/dist/routes/index.d.ts.map +1 -1
  142. package/dist/routes/presentations.d.ts +6 -6
  143. package/dist/routes/storage.d.ts +63 -63
  144. package/dist/routes/test.d.ts +6 -0
  145. package/dist/routes/test.d.ts.map +1 -1
  146. package/dist/routes/workflows.d.ts +14 -14
  147. package/dist/routes/workflows.d.ts.map +1 -1
  148. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  149. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  150. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  151. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  152. package/dist/types/activity.d.ts +4 -0
  153. package/dist/types/activity.d.ts.map +1 -1
  154. package/dist/types/consentflowcontract.d.ts +22 -8
  155. package/dist/types/consentflowcontract.d.ts.map +1 -1
  156. package/dist/types/credential-refresh.d.ts +10 -1
  157. package/dist/types/credential-refresh.d.ts.map +1 -1
  158. package/dist/types/credential.d.ts +7 -0
  159. package/dist/types/credential.d.ts.map +1 -1
  160. package/dist/types/did-metadata.d.ts +2 -2
  161. package/dist/types/inbox-batch.d.ts +38 -0
  162. package/dist/types/inbox-batch.d.ts.map +1 -0
  163. package/inbox-e2e-setup.ts +53 -1
  164. package/inbox-elasticmq.conf +10 -0
  165. package/lambda.ts +61 -16
  166. package/package.json +18 -16
  167. package/scripts/setup-inbox-queues.ts +60 -0
  168. package/serverless-local.yml +2 -0
  169. package/serverless.yml +75 -0
  170. package/src/accesslayer/app-store-listing/relationships/read.ts +4 -3
  171. package/src/accesslayer/auth-grant/read.ts +2 -1
  172. package/src/accesslayer/consentflowcontract/relationships/create.ts +15 -5
  173. package/src/accesslayer/consentflowcontract/relationships/read.ts +161 -63
  174. package/src/accesslayer/consentflowcontract/relationships/update.ts +43 -10
  175. package/src/accesslayer/credential/create.test.ts +141 -0
  176. package/src/accesslayer/credential/create.ts +27 -3
  177. package/src/accesslayer/credential-activity/create.ts +1 -0
  178. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  179. package/src/accesslayer/credential-refresh/index.ts +1 -0
  180. package/src/accesslayer/credential-refresh/read.ts +8 -2
  181. package/src/accesslayer/credential-refresh/update.ts +4 -0
  182. package/src/accesslayer/inbox-batch/store.ts +469 -0
  183. package/src/accesslayer/inbox-credential/create.ts +51 -19
  184. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  185. package/src/accesslayer/inbox-credential/update.ts +1 -1
  186. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  187. package/src/cache/atomic-counter.test.ts +6 -0
  188. package/src/cache/index.ts +25 -13
  189. package/src/config/environment.ts +26 -0
  190. package/src/credential-refresh.ts +5 -1
  191. package/src/docker-entry.ts +8 -0
  192. package/src/helpers/activity.helpers.ts +4 -0
  193. package/src/helpers/app-counter.helpers.ts +1 -1
  194. package/src/helpers/boost-storage.helpers.test.ts +242 -0
  195. package/src/helpers/boost.helpers.ts +58 -437
  196. package/src/helpers/contact-method.helpers.test.ts +43 -0
  197. package/src/helpers/contact-method.helpers.ts +3 -4
  198. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  199. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  200. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  201. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  202. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  203. package/src/helpers/credential-refresh.helpers.ts +385 -107
  204. package/src/helpers/credential.helpers.ts +6 -2
  205. package/src/helpers/finalize-inbox.helpers.ts +39 -22
  206. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  207. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  208. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  209. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  210. package/src/helpers/inbox-batch.helpers.ts +374 -0
  211. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  212. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  213. package/src/helpers/inbox-queue.helpers.ts +372 -0
  214. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  215. package/src/helpers/inbox.helpers.ts +301 -50
  216. package/src/helpers/issuedCredentialStatus.helpers.ts +11 -0
  217. package/src/helpers/notifications.helpers.ts +4 -2
  218. package/src/helpers/rateLimit.helpers.ts +2 -5
  219. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  220. package/src/helpers/shim.ts +32 -31
  221. package/src/helpers/signingAuthority.helpers.test.ts +198 -0
  222. package/src/helpers/signingAuthority.helpers.ts +41 -18
  223. package/src/helpers/status-list.helpers.test.ts +77 -0
  224. package/src/helpers/status-list.helpers.ts +31 -8
  225. package/src/inbox-queue-local.ts +28 -0
  226. package/src/models/AuthGrant.ts +1 -0
  227. package/src/models/ConsentFlowTerms.ts +4 -0
  228. package/src/models/ConsentFlowTransaction.ts +4 -0
  229. package/src/models/Credential.ts +1 -0
  230. package/src/models/CredentialActivity.ts +3 -5
  231. package/src/models/CredentialRefresh.ts +4 -2
  232. package/src/models/InboxCredential.ts +2 -0
  233. package/src/models/credential-refresh-constraints.test.ts +8 -3
  234. package/src/models/credential-refresh-constraints.ts +5 -0
  235. package/src/openapi.ts +8 -0
  236. package/src/routes/act-as.test.ts +239 -0
  237. package/src/routes/app-store.ts +69 -52
  238. package/src/routes/auth-grants.ts +13 -11
  239. package/src/routes/boosts.ts +943 -120
  240. package/src/routes/contracts.ts +55 -7
  241. package/src/routes/credential-refreshes.test.ts +8 -2
  242. package/src/routes/credential-refreshes.ts +4 -1
  243. package/src/routes/credentials.ts +2 -1
  244. package/src/routes/inbox.ts +95 -49
  245. package/src/routes/index.ts +164 -88
  246. package/src/routes/skills.ts +1 -1
  247. package/src/routes/workflows.ts +80 -31
  248. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  249. package/src/services/delivery/adapters/postmark.adapter.ts +12 -0
  250. package/src/services/registry/registry.factory.ts +2 -1
  251. package/src/types/activity.ts +2 -0
  252. package/src/types/consentflowcontract.ts +21 -3
  253. package/src/types/credential-refresh.ts +21 -2
  254. package/src/types/credential.ts +13 -1
  255. package/src/types/inbox-batch.ts +46 -0
  256. package/test/app-store-listing.spec.ts +66 -30
  257. package/test/atomic-counter.e2e.ts +12 -0
  258. package/test/auth-grant.spec.ts +21 -21
  259. package/test/bitstring-status-list.spec.ts +12 -9
  260. package/test/consentflow.spec.ts +453 -26
  261. package/test/credential-refresh-email.spec.ts +663 -0
  262. package/test/credential-refresh-publication.spec.ts +13 -5
  263. package/test/guardian-gated-routes.spec.ts +337 -180
  264. package/test/helpers/inbox-e2e-environment.ts +6 -0
  265. package/test/inbox-batch.spec.ts +1338 -0
  266. package/test/inbox-refresh.spec.ts +534 -0
  267. package/test/inbox-security.e2e.ts +48 -3
  268. package/test/openapi.spec.ts +41 -1
  269. package/test/refresh-send.spec.ts +1799 -0
  270. package/test/registry.spec.ts +56 -11
  271. package/test/revoke-suspend-per-instance.spec.ts +9 -1
  272. package/vitest.config.ts +13 -0
  273. package/vitest.inbox-e2e.config.ts +5 -1
  274. 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,
@@ -12,6 +27,8 @@ import {
12
27
  } from '@learncard/types';
13
28
 
14
29
  import { ProfileType, SigningAuthorityForUserType } from 'types/profile';
30
+ import type { IssuedCredential } from 'types/credential';
31
+ import { getBitstringStatusListEntries } from '@learncard/helpers';
15
32
  import { createInboxCredential } from '@accesslayer/inbox-credential/create';
16
33
  import { Context } from '@routes';
17
34
  import { getAppDidWeb } from '@helpers/did.helpers';
@@ -30,7 +47,7 @@ import { doesProfileManageProfile } from '@accesslayer/profile-manager/relations
30
47
  import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
31
48
  import { getProfileForInboxCredential } from '@accesslayer/inbox-credential/read';
32
49
  import { sendCredential } from '@helpers/credential.helpers';
33
- import { sendBoost } from '@helpers/boost.helpers';
50
+ import { prepareCredentialFromBoost, getBoostUri, sendBoost } from '@helpers/boost.helpers';
34
51
  import { getBoostByUri } from '@accesslayer/boost/read';
35
52
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
36
53
  import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
@@ -105,14 +122,16 @@ export const claimIntoInbox = async (
105
122
  ? getAppDidWeb(ctx.domain, listingSlug)
106
123
  : undefined;
107
124
 
108
- finalCredential = (await issueCredentialWithSigningAuthority(
109
- { type: 'profile', profile: issuerProfile },
110
- credential as UnsignedVC,
111
- signingAuthorityForUser,
112
- ctx.domain,
113
- false, // don't encrypt
114
- ownerDidOverride
115
- )) as VC;
125
+ finalCredential = (
126
+ await issueCredentialWithSigningAuthority(
127
+ { type: 'profile', profile: issuerProfile },
128
+ credential as UnsignedVC,
129
+ signingAuthorityForUser,
130
+ ctx.domain,
131
+ false, // don't encrypt
132
+ ownerDidOverride
133
+ )
134
+ ).credential as VC;
116
135
  }
117
136
 
118
137
  // Use the explicit-recipient API; the seeded encryption plugin also adds the service DID.
@@ -124,7 +143,11 @@ export const claimIntoInbox = async (
124
143
  await sendCredential(
125
144
  issuerProfile,
126
145
  existingProfile,
127
- encryptedDelivery,
146
+ {
147
+ kind: 'issued-credential',
148
+ credential: encryptedDelivery,
149
+ statusEntries: getBitstringStatusListEntries(finalCredential),
150
+ },
128
151
  ctx.domain,
129
152
  undefined,
130
153
  activityId,
@@ -184,9 +207,15 @@ export const issueToInbox = async (
184
207
  activityId?: string;
185
208
  integrationId?: string;
186
209
  guardianEmail?: string;
210
+ refresh?: boolean;
211
+ idempotencyKey?: string;
212
+ refreshRequestDigest?: string;
187
213
  } = {},
188
- ctx: Context
214
+ ctx: Context,
215
+ /** Queue ownership checkpoint immediately before delivery or inbox persistence. */
216
+ beforeDelivery?: () => Promise<void>
189
217
  ): Promise<{
218
+ refresh?: import('@learncard/types').InboxCredentialRefreshReceipt;
190
219
  status: 'PENDING' | 'ISSUED' | 'EXPIRED' | 'DELIVERED' | 'CLAIMED'; // DELIVERED & CLAIMED are deprecated, use ISSUED
191
220
  inboxCredential: InboxCredentialType;
192
221
  claimUrl?: string;
@@ -203,13 +232,35 @@ export const issueToInbox = async (
203
232
  integrationId,
204
233
  guardianEmail,
205
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
+ }
206
249
  const isSigned = !!credential?.proof;
207
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
+ };
208
259
 
209
260
  if (recipient.type === 'phone') {
210
261
  const isTrusted = await getRegistryService().isTrusted(issuerProfile.did);
211
262
  if (!isTrusted) {
212
- throw new TRPCError({
263
+ throw new InboxIssuancePreflightError({
213
264
  code: 'FORBIDDEN',
214
265
  message:
215
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',
@@ -237,20 +288,35 @@ export const issueToInbox = async (
237
288
  * By providing this parameter, the developer explicitly takes responsibility for the signing process. We trust them and proceed.
238
289
  **/
239
290
  if (!(await verifyCredentialCanBeSigned(credential as UnsignedVC))) {
240
- throw new TRPCError({
291
+ throw new InboxIssuancePreflightError({
241
292
  code: 'BAD_REQUEST',
242
293
  message:
243
294
  'Credential failed to pass a pre-flight issuance test. Please verify that the credential is well-formed and can be issued.',
244
295
  });
245
296
  }
246
297
  } else {
247
- throw new TRPCError({
298
+ throw new InboxIssuancePreflightError({
248
299
  code: 'BAD_REQUEST',
249
300
  message: 'Unsigned credentials require a signing authority',
250
301
  });
251
302
  }
252
303
  }
253
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
+
254
320
  // Check if recipient already exists with verified email
255
321
  const existingProfile = await getProfileByVerifiedContactMethod(
256
322
  recipient.type,
@@ -264,7 +330,7 @@ export const issueToInbox = async (
264
330
  : [];
265
331
  const recipientIsManaged = recipientManagers.length > 0;
266
332
 
267
- if (existingProfile && !guardianEmail && !recipientIsManaged) {
333
+ if (existingProfile && !guardianEmail && !recipientIsManaged && !configuration.refresh) {
268
334
  // Auto-deliver to existing user
269
335
  let finalCredential: VC;
270
336
 
@@ -279,19 +345,21 @@ export const issueToInbox = async (
279
345
  );
280
346
 
281
347
  if (!signingAuthorityForUser) {
282
- throw new TRPCError({
348
+ throw new InboxIssuancePreflightError({
283
349
  code: 'NOT_FOUND',
284
350
  message: 'Signing authority not found for issuer',
285
351
  });
286
352
  }
287
353
 
288
- finalCredential = (await issueCredentialWithSigningAuthority(
289
- { type: 'profile', profile: issuerProfile },
290
- credential as UnsignedVC,
291
- signingAuthorityForUser,
292
- ctx.domain,
293
- false // don't encrypt
294
- )) as VC;
354
+ finalCredential = (
355
+ await issueCredentialWithSigningAuthority(
356
+ { type: 'profile', profile: issuerProfile },
357
+ credential as UnsignedVC,
358
+ signingAuthorityForUser,
359
+ ctx.domain,
360
+ false // don't encrypt
361
+ )
362
+ ).credential as VC;
295
363
  }
296
364
 
297
365
  // Send credential using appropriate helper (sendBoost handles boost tracking)
@@ -301,36 +369,30 @@ export const issueToInbox = async (
301
369
  existingProfile.did,
302
370
  issuerProfile.did,
303
371
  ]);
304
- if (boostUri) {
305
- const boost = await getBoostByUri(boostUri);
306
- if (boost) {
307
- await sendBoost({
308
- from: { type: 'profile', profile: issuerProfile },
309
- to: existingProfile,
310
- boost,
311
- credential: encryptedDelivery,
312
- domain: ctx.domain,
313
- skipCertification: true,
314
- activityId,
315
- integrationId,
316
- });
317
- } else {
318
- // Fallback to sendCredential if boost not found
319
- await sendCredential(
320
- issuerProfile,
321
- existingProfile,
322
- encryptedDelivery,
323
- ctx.domain,
324
- undefined,
325
- activityId,
326
- integrationId
327
- );
328
- }
372
+ // Carry only public status coordinates alongside the newly encrypted payload.
373
+ const delivery: IssuedCredential = {
374
+ kind: 'issued-credential',
375
+ credential: encryptedDelivery,
376
+ statusEntries: getBitstringStatusListEntries(finalCredential),
377
+ };
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
+ });
329
390
  } else {
391
+ // Fall back to ordinary delivery when no boost exists for the supplied URI.
330
392
  await sendCredential(
331
393
  issuerProfile,
332
394
  existingProfile,
333
- encryptedDelivery,
395
+ delivery,
334
396
  ctx.domain,
335
397
  undefined,
336
398
  activityId,
@@ -357,6 +419,7 @@ export const issueToInbox = async (
357
419
  await logCredentialDelivered({
358
420
  activityId,
359
421
  actorProfileId: issuerProfile.profileId,
422
+ onBehalfOf: ctx.user?.onBehalfOf,
360
423
  recipientType: recipient.type as 'email' | 'phone',
361
424
  recipientIdentifier: recipient.value,
362
425
  recipientProfileId: existingProfile.profileId,
@@ -405,6 +468,11 @@ export const issueToInbox = async (
405
468
  });
406
469
  }
407
470
 
471
+ assertDeliveryCheckpoint({
472
+ issuanceId: finalizedInboxCredential.id,
473
+ status: LCNInboxStatusEnumValidator.enum.ISSUED,
474
+ recipientDid: existingProfile.did,
475
+ });
408
476
  return {
409
477
  status: LCNInboxStatusEnumValidator.enum.ISSUED,
410
478
  inboxCredential: finalizedInboxCredential,
@@ -414,7 +482,24 @@ export const issueToInbox = async (
414
482
  // Store in inbox for claiming
415
483
  // Guardian gate if issuer specified guardianEmail OR recipient is a managed child
416
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();
417
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
+ : {}),
418
503
  credential: JSON.stringify(credential),
419
504
  isSigned,
420
505
  recipient,
@@ -429,6 +514,101 @@ export const issueToInbox = async (
429
514
  ...(needsGuardianGate ? { guardianStatus: 'AWAITING_GUARDIAN' as const } : {}),
430
515
  });
431
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
+
432
612
  // Generate claim token
433
613
  let recipientContactMethod = await getContactMethodByValue(recipient.type, recipient.value);
434
614
  if (!recipientContactMethod) {
@@ -802,13 +982,84 @@ export const issueToInbox = async (
802
982
  expiresInDays ? 24 * expiresInDays : 24,
803
983
  false
804
984
  );
805
- const claimUrl = generateClaimUrl(claimToken);
985
+ const claimUrl = refresh
986
+ ? await recordInboxRefreshClaimUrl(inboxCredential.id, generateClaimUrl(claimToken))
987
+ : generateClaimUrl(claimToken);
806
988
 
989
+ assertDeliveryCheckpoint({
990
+ issuanceId: inboxCredential.id,
991
+ status: LCNInboxStatusEnumValidator.enum.PENDING,
992
+ claimUrl,
993
+ ...(guardianEmail ? { guardianStatus: 'AWAITING_GUARDIAN' as const } : {}),
994
+ });
807
995
  return {
808
996
  status: LCNInboxStatusEnumValidator.enum.PENDING,
809
997
  inboxCredential,
810
998
  claimUrl,
999
+ ...(refresh ? { refresh } : {}),
811
1000
  ...(guardianEmail ? { guardianStatus: 'AWAITING_GUARDIAN' as const } : {}),
812
1001
  };
813
1002
  }
814
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
+ };
@@ -0,0 +1,11 @@
1
+ import type { JWE, UnsignedVC, VC } from '@learncard/types';
2
+ import type { IssuedCredential } from '../types/credential';
3
+
4
+ /** Internal envelopes never have the top-level fields of a wire-format VC or JWE. */
5
+ export const isIssuedCredential = (
6
+ value: UnsignedVC | VC | JWE | IssuedCredential
7
+ ): value is IssuedCredential =>
8
+ !('@context' in value) && !('ciphertext' in value) && value.kind === 'issued-credential';
9
+
10
+ export const getIssuedCredentialPayload = (value: VC | JWE | IssuedCredential): VC | JWE =>
11
+ isIssuedCredential(value) ? value.credential : value;
@@ -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
  }