@learncard/network-brain-service 3.18.0 → 3.19.1

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 (478) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +118 -0
  3. package/README.md +42 -0
  4. package/compose.inbox.yml +7 -0
  5. package/dist/accesslayer/auth-grant/read.d.ts +1 -0
  6. package/dist/accesslayer/auth-grant/read.d.ts.map +1 -1
  7. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  8. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  9. package/dist/accesslayer/credential-activity/create.d.ts.map +1 -1
  10. package/dist/accesslayer/credential-refresh/email-delivery.d.ts +18 -0
  11. package/dist/accesslayer/credential-refresh/email-delivery.d.ts.map +1 -0
  12. package/dist/accesslayer/credential-refresh/index.d.ts +1 -0
  13. package/dist/accesslayer/credential-refresh/index.d.ts.map +1 -1
  14. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  15. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  16. package/dist/accesslayer/inbox-batch/store.d.ts +33 -0
  17. package/dist/accesslayer/inbox-batch/store.d.ts.map +1 -0
  18. package/dist/accesslayer/inbox-credential/create.d.ts +5 -0
  19. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  20. package/dist/accesslayer/inbox-credential/refresh.d.ts +71 -0
  21. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -0
  22. package/dist/accesslayer/inbox-credential/update.d.ts +1 -1
  23. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  24. package/dist/accesslayer/presentation/read.d.ts.map +1 -1
  25. package/dist/accesslayer/presentation/relationships/create.d.ts +2 -2
  26. package/dist/accesslayer/presentation/relationships/create.d.ts.map +1 -1
  27. package/dist/accesslayer/presentation/relationships/read.d.ts.map +1 -1
  28. package/dist/accesslayer/share-link/abandon.d.ts +3 -0
  29. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -0
  30. package/dist/accesslayer/share-link/cleanup.d.ts +6 -0
  31. package/dist/accesslayer/share-link/cleanup.d.ts.map +1 -0
  32. package/dist/accesslayer/share-link/errors.d.ts +8 -0
  33. package/dist/accesslayer/share-link/errors.d.ts.map +1 -0
  34. package/dist/accesslayer/share-link/finalize.d.ts +3 -0
  35. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -0
  36. package/dist/accesslayer/share-link/helpers.d.ts +56 -0
  37. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -0
  38. package/dist/accesslayer/share-link/index.d.ts +12 -0
  39. package/dist/accesslayer/share-link/index.d.ts.map +1 -0
  40. package/dist/accesslayer/share-link/list.d.ts +5 -0
  41. package/dist/accesslayer/share-link/list.d.ts.map +1 -0
  42. package/dist/accesslayer/share-link/read.d.ts +5 -0
  43. package/dist/accesslayer/share-link/read.d.ts.map +1 -0
  44. package/dist/accesslayer/share-link/receipt.d.ts +11 -0
  45. package/dist/accesslayer/share-link/receipt.d.ts.map +1 -0
  46. package/dist/accesslayer/share-link/recovery.d.ts +6 -0
  47. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -0
  48. package/dist/accesslayer/share-link/reserve.d.ts +4 -0
  49. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -0
  50. package/dist/accesslayer/share-link/revoke.d.ts +3 -0
  51. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -0
  52. package/dist/accesslayer/share-link/transaction-execution.d.ts +18 -0
  53. package/dist/accesslayer/share-link/transaction-execution.d.ts.map +1 -0
  54. package/dist/accesslayer/share-link/transaction.d.ts +15 -0
  55. package/dist/accesslayer/share-link/transaction.d.ts.map +1 -0
  56. package/dist/accesslayer/share-link/types.d.ts +370 -0
  57. package/dist/accesslayer/share-link/types.d.ts.map +1 -0
  58. package/dist/app.d.ts +4 -0
  59. package/dist/app.d.ts.map +1 -1
  60. package/dist/cache/atomic-counter.test-helpers.d.ts +3 -0
  61. package/dist/cache/atomic-counter.test-helpers.d.ts.map +1 -0
  62. package/dist/cache/atomic-counter.test.d.ts +2 -0
  63. package/dist/cache/atomic-counter.test.d.ts.map +1 -0
  64. package/dist/cache/index.d.ts +1 -1
  65. package/dist/cache/index.d.ts.map +1 -1
  66. package/dist/config/environment.d.ts +55 -0
  67. package/dist/config/environment.d.ts.map +1 -1
  68. package/dist/constants/auth-grant.d.ts +2 -0
  69. package/dist/constants/auth-grant.d.ts.map +1 -1
  70. package/dist/credential-refresh.d.ts.map +1 -1
  71. package/dist/helpers/activity.helpers.d.ts +4 -0
  72. package/dist/helpers/activity.helpers.d.ts.map +1 -1
  73. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  74. package/dist/helpers/contact-method.helpers.d.ts.map +1 -1
  75. package/dist/helpers/contact-method.helpers.test.d.ts +2 -0
  76. package/dist/helpers/contact-method.helpers.test.d.ts.map +1 -0
  77. package/dist/helpers/credential-refresh-email-content.helpers.d.ts +44 -0
  78. package/dist/helpers/credential-refresh-email-content.helpers.d.ts.map +1 -0
  79. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts +2 -0
  80. package/dist/helpers/credential-refresh-email-content.helpers.test.d.ts.map +1 -0
  81. package/dist/helpers/credential-refresh-email.helpers.d.ts +17 -0
  82. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -0
  83. package/dist/helpers/credential-refresh-proof.helpers.d.ts +4 -0
  84. package/dist/helpers/credential-refresh-proof.helpers.d.ts.map +1 -0
  85. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts +2 -0
  86. package/dist/helpers/credential-refresh-proof.helpers.test.d.ts.map +1 -0
  87. package/dist/helpers/credential-refresh.helpers.d.ts +45 -2
  88. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  89. package/dist/helpers/credential.helpers.d.ts +2 -1
  90. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  91. package/dist/helpers/credentialSubject.helpers.d.ts +3 -0
  92. package/dist/helpers/credentialSubject.helpers.d.ts.map +1 -0
  93. package/dist/helpers/credentialSubject.helpers.test.d.ts +2 -0
  94. package/dist/helpers/credentialSubject.helpers.test.d.ts.map +1 -0
  95. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  96. package/dist/helpers/inbox-batch-http.helpers.d.ts +15 -0
  97. package/dist/helpers/inbox-batch-http.helpers.d.ts.map +1 -0
  98. package/dist/helpers/inbox-batch-http.helpers.test.d.ts +2 -0
  99. package/dist/helpers/inbox-batch-http.helpers.test.d.ts.map +1 -0
  100. package/dist/helpers/inbox-batch-status.helpers.d.ts +3 -0
  101. package/dist/helpers/inbox-batch-status.helpers.d.ts.map +1 -0
  102. package/dist/helpers/inbox-batch.helpers.d.ts +10 -0
  103. package/dist/helpers/inbox-batch.helpers.d.ts.map +1 -0
  104. package/dist/helpers/inbox-batch.helpers.test.d.ts +2 -0
  105. package/dist/helpers/inbox-batch.helpers.test.d.ts.map +1 -0
  106. package/dist/helpers/inbox-issuance-error.helpers.d.ts +13 -0
  107. package/dist/helpers/inbox-issuance-error.helpers.d.ts.map +1 -0
  108. package/dist/helpers/inbox-queue.helpers.d.ts +11 -0
  109. package/dist/helpers/inbox-queue.helpers.d.ts.map +1 -0
  110. package/dist/helpers/inbox-queue.helpers.test.d.ts +2 -0
  111. package/dist/helpers/inbox-queue.helpers.test.d.ts.map +1 -0
  112. package/dist/helpers/inbox-refresh.helpers.d.ts +186 -0
  113. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -0
  114. package/dist/helpers/inbox.helpers.d.ts +9 -1
  115. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  116. package/dist/helpers/notificationMessages.d.ts +1 -1
  117. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  118. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  119. package/dist/helpers/presentation-metadata.d.ts +4 -0
  120. package/dist/helpers/presentation-metadata.d.ts.map +1 -0
  121. package/dist/helpers/presentation.helpers.d.ts +1 -1
  122. package/dist/helpers/presentation.helpers.d.ts.map +1 -1
  123. package/dist/helpers/profile.helpers.d.ts +1 -0
  124. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  125. package/dist/helpers/rateLimit.helpers.d.ts.map +1 -1
  126. package/dist/helpers/refresh-send-intent.helpers.d.ts +51 -0
  127. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -0
  128. package/dist/helpers/share-content-client/adapters.d.ts +10 -0
  129. package/dist/helpers/share-content-client/adapters.d.ts.map +1 -0
  130. package/dist/helpers/share-content-client/canonical.d.ts +7 -0
  131. package/dist/helpers/share-content-client/canonical.d.ts.map +1 -0
  132. package/dist/helpers/share-content-client/client.d.ts +5 -0
  133. package/dist/helpers/share-content-client/client.d.ts.map +1 -0
  134. package/dist/helpers/share-content-client/config.d.ts +11 -0
  135. package/dist/helpers/share-content-client/config.d.ts.map +1 -0
  136. package/dist/helpers/share-content-client/index.d.ts +6 -0
  137. package/dist/helpers/share-content-client/index.d.ts.map +1 -0
  138. package/dist/helpers/share-content-client/responses.d.ts +16 -0
  139. package/dist/helpers/share-content-client/responses.d.ts.map +1 -0
  140. package/dist/helpers/share-content-client/types.d.ts +145 -0
  141. package/dist/helpers/share-content-client/types.d.ts.map +1 -0
  142. package/dist/helpers/share-link-coordinator/budget-helpers.d.ts +5 -0
  143. package/dist/helpers/share-link-coordinator/budget-helpers.d.ts.map +1 -0
  144. package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts +3 -0
  145. package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts.map +1 -0
  146. package/dist/helpers/share-link-coordinator/coordinator.d.ts +4 -0
  147. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -0
  148. package/dist/helpers/share-link-coordinator/index.d.ts +5 -0
  149. package/dist/helpers/share-link-coordinator/index.d.ts.map +1 -0
  150. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts +9 -0
  151. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -0
  152. package/dist/helpers/share-link-coordinator/runtime.d.ts +11 -0
  153. package/dist/helpers/share-link-coordinator/runtime.d.ts.map +1 -0
  154. package/dist/helpers/share-link-coordinator/stat.d.ts +4 -0
  155. package/dist/helpers/share-link-coordinator/stat.d.ts.map +1 -0
  156. package/dist/helpers/share-link-coordinator/types.d.ts +160 -0
  157. package/dist/helpers/share-link-coordinator/types.d.ts.map +1 -0
  158. package/dist/helpers/share-link-lifecycle/index.d.ts +5 -0
  159. package/dist/helpers/share-link-lifecycle/index.d.ts.map +1 -0
  160. package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts +4 -0
  161. package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts.map +1 -0
  162. package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts +21 -0
  163. package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts.map +1 -0
  164. package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts +9 -0
  165. package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts.map +1 -0
  166. package/dist/helpers/share-link-lifecycle/types.d.ts +14 -0
  167. package/dist/helpers/share-link-lifecycle/types.d.ts.map +1 -0
  168. package/dist/helpers/share-link-maintenance/budget.d.ts +12 -0
  169. package/dist/helpers/share-link-maintenance/budget.d.ts.map +1 -0
  170. package/dist/helpers/share-link-maintenance/config.d.ts +48 -0
  171. package/dist/helpers/share-link-maintenance/config.d.ts.map +1 -0
  172. package/dist/helpers/share-link-maintenance/index.d.ts +8 -0
  173. package/dist/helpers/share-link-maintenance/index.d.ts.map +1 -0
  174. package/dist/helpers/share-link-maintenance/runner.d.ts +19 -0
  175. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -0
  176. package/dist/helpers/share-link-maintenance/runtime.d.ts +36 -0
  177. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -0
  178. package/dist/helpers/share-link-maintenance/scheduler.d.ts +18 -0
  179. package/dist/helpers/share-link-maintenance/scheduler.d.ts.map +1 -0
  180. package/dist/helpers/share-link-maintenance/telemetry.d.ts +5 -0
  181. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -0
  182. package/dist/helpers/share-link-maintenance/types.d.ts +53 -0
  183. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -0
  184. package/dist/helpers/share-link-owner/config.d.ts +16 -0
  185. package/dist/helpers/share-link-owner/config.d.ts.map +1 -0
  186. package/dist/helpers/share-link-owner/diagnostics.d.ts +26 -0
  187. package/dist/helpers/share-link-owner/diagnostics.d.ts.map +1 -0
  188. package/dist/helpers/share-link-owner/lazy-initializer.d.ts +2 -0
  189. package/dist/helpers/share-link-owner/lazy-initializer.d.ts.map +1 -0
  190. package/dist/helpers/share-link-owner-projection.d.ts +4 -0
  191. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -0
  192. package/dist/helpers/share-link-passcode-abuse.d.ts +2 -0
  193. package/dist/helpers/share-link-passcode-abuse.d.ts.map +1 -0
  194. package/dist/helpers/share-link-passcode.d.ts +6 -0
  195. package/dist/helpers/share-link-passcode.d.ts.map +1 -0
  196. package/dist/helpers/share-link-policy/index.d.ts +4 -0
  197. package/dist/helpers/share-link-policy/index.d.ts.map +1 -0
  198. package/dist/helpers/share-link-policy/production.d.ts +13 -0
  199. package/dist/helpers/share-link-policy/production.d.ts.map +1 -0
  200. package/dist/helpers/share-link-policy/resolver.d.ts +6 -0
  201. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -0
  202. package/dist/helpers/share-link-policy/types.d.ts +16 -0
  203. package/dist/helpers/share-link-policy/types.d.ts.map +1 -0
  204. package/dist/helpers/share-link-public/config.d.ts +13 -0
  205. package/dist/helpers/share-link-public/config.d.ts.map +1 -0
  206. package/dist/helpers/share-link-view-notification.d.ts +2 -0
  207. package/dist/helpers/share-link-view-notification.d.ts.map +1 -0
  208. package/dist/helpers/shim.d.ts +1 -1
  209. package/dist/helpers/shim.d.ts.map +1 -1
  210. package/dist/inbox-queue-local.d.ts +2 -0
  211. package/dist/inbox-queue-local.d.ts.map +1 -0
  212. package/dist/index.js +2 -2
  213. package/dist/models/AuthGrant.d.ts.map +1 -1
  214. package/dist/models/CredentialActivity.d.ts +1 -0
  215. package/dist/models/CredentialActivity.d.ts.map +1 -1
  216. package/dist/models/CredentialRefresh.d.ts +5 -2
  217. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  218. package/dist/models/InboxCredential.d.ts +1 -0
  219. package/dist/models/InboxCredential.d.ts.map +1 -1
  220. package/dist/models/Presentation.d.ts +2 -0
  221. package/dist/models/Presentation.d.ts.map +1 -1
  222. package/dist/models/Profile.d.ts +2 -0
  223. package/dist/models/Profile.d.ts.map +1 -1
  224. package/dist/models/ShareLink.d.ts +44 -0
  225. package/dist/models/ShareLink.d.ts.map +1 -0
  226. package/dist/models/ShareViewReceipt.d.ts +22 -0
  227. package/dist/models/ShareViewReceipt.d.ts.map +1 -0
  228. package/dist/models/credential-refresh-constraints.d.ts.map +1 -1
  229. package/dist/models/index.d.ts +1 -0
  230. package/dist/models/index.d.ts.map +1 -1
  231. package/dist/models/share-link-constraints.d.ts +3 -0
  232. package/dist/models/share-link-constraints.d.ts.map +1 -0
  233. package/dist/openapi.d.ts.map +1 -1
  234. package/dist/public-share-link-http.d.ts +4 -0
  235. package/dist/public-share-link-http.d.ts.map +1 -0
  236. package/dist/routes/act-as.test.d.ts +2 -0
  237. package/dist/routes/act-as.test.d.ts.map +1 -0
  238. package/dist/routes/activity.d.ts +3 -0
  239. package/dist/routes/activity.d.ts.map +1 -1
  240. package/dist/routes/app-store.d.ts.map +1 -1
  241. package/dist/routes/auth-grants.d.ts +10 -0
  242. package/dist/routes/auth-grants.d.ts.map +1 -1
  243. package/dist/routes/boosts.d.ts +494 -0
  244. package/dist/routes/boosts.d.ts.map +1 -1
  245. package/dist/routes/contracts.d.ts +2 -2
  246. package/dist/routes/contracts.d.ts.map +1 -1
  247. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  248. package/dist/routes/credentials.d.ts +1 -1
  249. package/dist/routes/credentials.d.ts.map +1 -1
  250. package/dist/routes/inbox.d.ts +953 -11
  251. package/dist/routes/inbox.d.ts.map +1 -1
  252. package/dist/routes/index.d.ts +154 -85
  253. package/dist/routes/index.d.ts.map +1 -1
  254. package/dist/routes/presentations.d.ts +1 -0
  255. package/dist/routes/presentations.d.ts.map +1 -1
  256. package/dist/routes/profile-manager.d.ts.map +1 -1
  257. package/dist/routes/profiles.d.ts.map +1 -1
  258. package/dist/routes/public-share-links.d.ts +230 -0
  259. package/dist/routes/public-share-links.d.ts.map +1 -0
  260. package/dist/routes/share-links.d.ts +844 -0
  261. package/dist/routes/share-links.d.ts.map +1 -0
  262. package/dist/routes/workflows.d.ts.map +1 -1
  263. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  264. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts +2 -0
  265. package/dist/services/delivery/adapters/postmark.adapter.test.d.ts.map +1 -0
  266. package/dist/services/registry/registry.factory.d.ts.map +1 -1
  267. package/dist/types/activity.d.ts +4 -0
  268. package/dist/types/activity.d.ts.map +1 -1
  269. package/dist/types/consentflowcontract.d.ts +1 -1
  270. package/dist/types/credential-refresh.d.ts +10 -1
  271. package/dist/types/credential-refresh.d.ts.map +1 -1
  272. package/dist/types/inbox-batch.d.ts +38 -0
  273. package/dist/types/inbox-batch.d.ts.map +1 -0
  274. package/inbox-e2e-setup.ts +53 -1
  275. package/inbox-elasticmq.conf +10 -0
  276. package/lambda.ts +64 -16
  277. package/package.json +24 -16
  278. package/scripts/seed-demo-persona.ts +425 -0
  279. package/scripts/setup-inbox-queues.ts +60 -0
  280. package/serverless-local.yml +2 -0
  281. package/serverless.yml +111 -0
  282. package/shareLinkMaintenanceLambda.ts +49 -0
  283. package/src/accesslayer/auth-grant/read.ts +2 -1
  284. package/src/accesslayer/consentflowcontract/relationships/create.ts +9 -16
  285. package/src/accesslayer/consentflowcontract/relationships/update.ts +15 -28
  286. package/src/accesslayer/credential-activity/create.ts +1 -0
  287. package/src/accesslayer/credential-refresh/email-delivery.ts +116 -0
  288. package/src/accesslayer/credential-refresh/index.ts +1 -0
  289. package/src/accesslayer/credential-refresh/read.ts +8 -2
  290. package/src/accesslayer/credential-refresh/update.ts +4 -0
  291. package/src/accesslayer/inbox-batch/store.ts +469 -0
  292. package/src/accesslayer/inbox-credential/create.ts +51 -19
  293. package/src/accesslayer/inbox-credential/refresh.ts +221 -0
  294. package/src/accesslayer/inbox-credential/update.ts +1 -1
  295. package/src/accesslayer/presentation/read.ts +57 -25
  296. package/src/accesslayer/presentation/relationships/create.ts +54 -11
  297. package/src/accesslayer/presentation/relationships/read.ts +21 -6
  298. package/src/accesslayer/share-link/abandon.ts +100 -0
  299. package/src/accesslayer/share-link/cleanup.ts +271 -0
  300. package/src/accesslayer/share-link/errors.ts +32 -0
  301. package/src/accesslayer/share-link/finalize.ts +265 -0
  302. package/src/accesslayer/share-link/helpers.ts +419 -0
  303. package/src/accesslayer/share-link/index.ts +11 -0
  304. package/src/accesslayer/share-link/list.ts +162 -0
  305. package/src/accesslayer/share-link/read.ts +107 -0
  306. package/src/accesslayer/share-link/receipt.ts +548 -0
  307. package/src/accesslayer/share-link/recovery.ts +545 -0
  308. package/src/accesslayer/share-link/reserve.ts +684 -0
  309. package/src/accesslayer/share-link/revoke.ts +197 -0
  310. package/src/accesslayer/share-link/transaction-execution.ts +72 -0
  311. package/src/accesslayer/share-link/transaction.ts +126 -0
  312. package/src/accesslayer/share-link/types.ts +601 -0
  313. package/src/app.ts +6 -0
  314. package/src/cache/atomic-counter.test-helpers.ts +27 -0
  315. package/src/cache/atomic-counter.test.ts +6 -0
  316. package/src/cache/index.ts +25 -13
  317. package/src/config/environment.ts +40 -0
  318. package/src/constants/auth-grant.ts +5 -0
  319. package/src/credential-refresh.ts +5 -1
  320. package/src/docker-entry.ts +65 -0
  321. package/src/helpers/activity.helpers.ts +4 -0
  322. package/src/helpers/app-counter.helpers.ts +1 -1
  323. package/src/helpers/boost.helpers.ts +4 -31
  324. package/src/helpers/contact-method.helpers.test.ts +43 -0
  325. package/src/helpers/contact-method.helpers.ts +3 -4
  326. package/src/helpers/credential-refresh-email-content.helpers.test.ts +222 -0
  327. package/src/helpers/credential-refresh-email-content.helpers.ts +155 -0
  328. package/src/helpers/credential-refresh-email.helpers.ts +201 -0
  329. package/src/helpers/credential-refresh-proof.helpers.test.ts +103 -0
  330. package/src/helpers/credential-refresh-proof.helpers.ts +43 -0
  331. package/src/helpers/credential-refresh.helpers.ts +375 -99
  332. package/src/helpers/credential.helpers.ts +4 -1
  333. package/src/helpers/credentialSubject.helpers.test.ts +114 -0
  334. package/src/helpers/credentialSubject.helpers.ts +40 -0
  335. package/src/helpers/finalize-inbox.helpers.ts +29 -14
  336. package/src/helpers/inbox-batch-http.helpers.test.ts +188 -0
  337. package/src/helpers/inbox-batch-http.helpers.ts +150 -0
  338. package/src/helpers/inbox-batch-status.helpers.ts +11 -0
  339. package/src/helpers/inbox-batch.helpers.test.ts +668 -0
  340. package/src/helpers/inbox-batch.helpers.ts +374 -0
  341. package/src/helpers/inbox-issuance-error.helpers.ts +25 -0
  342. package/src/helpers/inbox-queue.helpers.test.ts +253 -0
  343. package/src/helpers/inbox-queue.helpers.ts +372 -0
  344. package/src/helpers/inbox-refresh.helpers.ts +432 -0
  345. package/src/helpers/inbox.helpers.ts +275 -36
  346. package/src/helpers/notificationMessages.ts +18 -1
  347. package/src/helpers/notifications.helpers.ts +4 -2
  348. package/src/helpers/presentation-metadata.ts +41 -0
  349. package/src/helpers/presentation.helpers.ts +27 -20
  350. package/src/helpers/profile.helpers.ts +7 -0
  351. package/src/helpers/rateLimit.helpers.ts +2 -5
  352. package/src/helpers/refresh-send-intent.helpers.ts +273 -0
  353. package/src/helpers/share-content-client/adapters.ts +55 -0
  354. package/src/helpers/share-content-client/canonical.ts +25 -0
  355. package/src/helpers/share-content-client/client.ts +440 -0
  356. package/src/helpers/share-content-client/config.ts +189 -0
  357. package/src/helpers/share-content-client/index.ts +27 -0
  358. package/src/helpers/share-content-client/responses.ts +273 -0
  359. package/src/helpers/share-content-client/types.ts +253 -0
  360. package/src/helpers/share-link-coordinator/budget-helpers.ts +53 -0
  361. package/src/helpers/share-link-coordinator/cleanup-runner.ts +286 -0
  362. package/src/helpers/share-link-coordinator/coordinator.ts +714 -0
  363. package/src/helpers/share-link-coordinator/index.ts +8 -0
  364. package/src/helpers/share-link-coordinator/recovery-runner.ts +428 -0
  365. package/src/helpers/share-link-coordinator/runtime.ts +88 -0
  366. package/src/helpers/share-link-coordinator/stat.ts +30 -0
  367. package/src/helpers/share-link-coordinator/types.ts +353 -0
  368. package/src/helpers/share-link-lifecycle/index.ts +4 -0
  369. package/src/helpers/share-link-lifecycle/lease.helpers.ts +35 -0
  370. package/src/helpers/share-link-lifecycle/recovery.helpers.ts +173 -0
  371. package/src/helpers/share-link-lifecycle/request-hash.helpers.ts +130 -0
  372. package/src/helpers/share-link-lifecycle/types.ts +58 -0
  373. package/src/helpers/share-link-maintenance/budget.ts +63 -0
  374. package/src/helpers/share-link-maintenance/config.ts +312 -0
  375. package/src/helpers/share-link-maintenance/index.ts +24 -0
  376. package/src/helpers/share-link-maintenance/runner.ts +223 -0
  377. package/src/helpers/share-link-maintenance/runtime.ts +334 -0
  378. package/src/helpers/share-link-maintenance/scheduler.ts +126 -0
  379. package/src/helpers/share-link-maintenance/telemetry.ts +70 -0
  380. package/src/helpers/share-link-maintenance/types.ts +89 -0
  381. package/src/helpers/share-link-owner/config.ts +48 -0
  382. package/src/helpers/share-link-owner/diagnostics.ts +89 -0
  383. package/src/helpers/share-link-owner/lazy-initializer.ts +29 -0
  384. package/src/helpers/share-link-owner-projection.ts +52 -0
  385. package/src/helpers/share-link-passcode-abuse.ts +36 -0
  386. package/src/helpers/share-link-passcode.ts +47 -0
  387. package/src/helpers/share-link-policy/index.ts +7 -0
  388. package/src/helpers/share-link-policy/production.ts +88 -0
  389. package/src/helpers/share-link-policy/resolver.ts +108 -0
  390. package/src/helpers/share-link-policy/types.ts +48 -0
  391. package/src/helpers/share-link-public/config.ts +41 -0
  392. package/src/helpers/share-link-view-notification.ts +18 -0
  393. package/src/helpers/shim.ts +32 -31
  394. package/src/inbox-queue-local.ts +28 -0
  395. package/src/models/AuthGrant.ts +1 -0
  396. package/src/models/CredentialActivity.ts +3 -5
  397. package/src/models/CredentialRefresh.ts +4 -2
  398. package/src/models/InboxCredential.ts +2 -0
  399. package/src/models/Presentation.ts +2 -2
  400. package/src/models/Profile.ts +3 -2
  401. package/src/models/ShareLink.ts +130 -0
  402. package/src/models/ShareViewReceipt.ts +69 -0
  403. package/src/models/credential-refresh-constraints.test.ts +8 -3
  404. package/src/models/credential-refresh-constraints.ts +5 -0
  405. package/src/models/index.ts +2 -0
  406. package/src/models/share-link-constraints.ts +93 -0
  407. package/src/openapi.ts +8 -0
  408. package/src/public-share-link-http.ts +49 -0
  409. package/src/routes/act-as.test.ts +239 -0
  410. package/src/routes/app-store.ts +4 -13
  411. package/src/routes/auth-grants.ts +13 -11
  412. package/src/routes/boosts.ts +916 -103
  413. package/src/routes/contracts.ts +2 -11
  414. package/src/routes/credential-refreshes.test.ts +8 -2
  415. package/src/routes/credential-refreshes.ts +4 -1
  416. package/src/routes/credentials.ts +2 -1
  417. package/src/routes/inbox.ts +95 -49
  418. package/src/routes/index.ts +216 -46
  419. package/src/routes/presentations.ts +19 -3
  420. package/src/routes/profile-manager.ts +10 -2
  421. package/src/routes/profiles.ts +17 -6
  422. package/src/routes/public-share-links.ts +814 -0
  423. package/src/routes/share-links.ts +640 -0
  424. package/src/routes/skills.ts +1 -1
  425. package/src/routes/workflows.ts +43 -24
  426. package/src/services/delivery/adapters/postmark.adapter.test.ts +54 -0
  427. package/src/services/delivery/adapters/postmark.adapter.ts +16 -1
  428. package/src/services/registry/registry.factory.ts +2 -1
  429. package/src/types/activity.ts +2 -0
  430. package/src/types/credential-refresh.ts +21 -2
  431. package/src/types/inbox-batch.ts +46 -0
  432. package/test/atomic-counter.e2e.ts +12 -0
  433. package/test/auth-grant.spec.ts +21 -21
  434. package/test/credential-refresh-email.spec.ts +663 -0
  435. package/test/credential-refresh-publication.spec.ts +2 -0
  436. package/test/dids.spec.ts +10 -1
  437. package/test/guardian-gated-routes.spec.ts +13 -0
  438. package/test/helpers/inbox-e2e-environment.ts +6 -0
  439. package/test/helpers/share-link-fixtures.ts +94 -0
  440. package/test/inbox-batch.spec.ts +1338 -0
  441. package/test/inbox-refresh.spec.ts +534 -0
  442. package/test/managed-recipient-inbox.spec.ts +130 -0
  443. package/test/notificationMessages.spec.ts +13 -0
  444. package/test/openapi.spec.ts +61 -1
  445. package/test/presentations.spec.ts +54 -6
  446. package/test/profiles.spec.ts +67 -1
  447. package/test/refresh-send.spec.ts +1799 -0
  448. package/test/registry.spec.ts +56 -11
  449. package/test/share-content-client.contract.spec.ts +365 -0
  450. package/test/share-content-client.unit.spec.ts +462 -0
  451. package/test/share-link-abuse.unit.spec.ts +65 -0
  452. package/test/share-link-coordinator.neo4j.spec.ts +684 -0
  453. package/test/share-link-coordinator.unit.spec.ts +990 -0
  454. package/test/share-link-dependency-diagnostics.unit.spec.ts +127 -0
  455. package/test/share-link-lifecycle.neo4j.spec.ts +1268 -0
  456. package/test/share-link-lifecycle.unit.spec.ts +205 -0
  457. package/test/share-link-list-cursor.unit.spec.ts +86 -0
  458. package/test/share-link-list.neo4j.spec.ts +324 -0
  459. package/test/share-link-maintenance.neo4j.spec.ts +355 -0
  460. package/test/share-link-maintenance.unit.spec.ts +1113 -0
  461. package/test/share-link-owner-policy.unit.spec.ts +210 -0
  462. package/test/share-link-owner-route.unit.spec.ts +752 -0
  463. package/test/share-link-owner-runtime.unit.spec.ts +102 -0
  464. package/test/share-link-passcode.unit.spec.ts +29 -0
  465. package/test/share-link-policy.unit.spec.ts +135 -0
  466. package/test/share-link-presentation-metadata.unit.spec.ts +49 -0
  467. package/test/share-link-production-policy.unit.spec.ts +77 -0
  468. package/test/share-link-public-config.unit.spec.ts +64 -0
  469. package/test/share-link-public-http-adapter.unit.spec.ts +315 -0
  470. package/test/share-link-public-route.unit.spec.ts +800 -0
  471. package/test/share-link-receipt.neo4j.spec.ts +943 -0
  472. package/test/share-link-recovery.neo4j.spec.ts +935 -0
  473. package/test/share-link-recovery.unit.spec.ts +726 -0
  474. package/vitest.config.ts +10 -0
  475. package/vitest.inbox-e2e.config.ts +5 -1
  476. package/vitest.integration.config.ts +2 -0
  477. package/vitest.share-links-neo4j.config.ts +19 -0
  478. package/vitest.share-links-unit.config.ts +28 -0
@@ -0,0 +1,668 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { IssueInboxCredentialBatchValidator } from '@learncard/types';
4
+ import type { IssueInboxCredentialBatch } from '@learncard/types';
5
+ import type { ProfileType } from 'types/profile';
6
+ import type { Context } from '@routes';
7
+ import { InboxIssuancePreflightError } from './inbox-issuance-error.helpers';
8
+ import { getInboxBatchState } from './inbox-batch-status.helpers';
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ get: vi.fn(),
12
+ setIfAbsent: vi.fn(),
13
+ compareAndSet: vi.fn(),
14
+ beforeIssue: vi.fn(),
15
+ issue: vi.fn(),
16
+ resolve: vi.fn(),
17
+ assertRefresh: vi.fn(),
18
+ refreshDigest: vi.fn(),
19
+ skipCheckpoint: false,
20
+ }));
21
+ vi.mock('./inbox-refresh.helpers', () => ({
22
+ assertInboxRefreshEnabled: mocks.assertRefresh,
23
+ inboxRefreshRequestDigest: mocks.refreshDigest,
24
+ }));
25
+ vi.mock('./inbox.helpers', () => ({
26
+ issueToInbox: async (...args: Parameters<typeof import('./inbox.helpers').issueToInbox>) => {
27
+ // This fake models delivery after the checkpoint. The container test "retries a transient
28
+ // signing failure before delivery" pins the real helper's signing/checkpoint ordering.
29
+ if (!mocks.skipCheckpoint) await args[5]?.();
30
+ const result = await mocks.issue(...args);
31
+ if (mocks.skipCheckpoint) {
32
+ const { InboxDeliveryCheckpointError } = await import('./inbox-issuance-error.helpers');
33
+ throw new InboxDeliveryCheckpointError({
34
+ issuanceId: result.inboxCredential.id,
35
+ status: result.status,
36
+ claimUrl: result.claimUrl,
37
+ recipientDid: result.recipientDid,
38
+ guardianStatus: result.guardianStatus,
39
+ });
40
+ }
41
+ return result;
42
+ },
43
+ resolveInboxCredentialInput: mocks.resolve,
44
+ }));
45
+ import { issueInboxBatch } from './inbox-batch.helpers';
46
+ import Fastify from 'fastify';
47
+ import { configureInboxBatchBodyLimit, INBOX_BATCH_MAX_BYTES } from './inbox-batch-http.helpers';
48
+
49
+ const profile = { profileId: 'issuer' } as ProfileType;
50
+ const ctx = { domain: 'example.test' } as Context;
51
+ const credential = {
52
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
53
+ type: ['VerifiableCredential'],
54
+ issuer: 'did:example:issuer',
55
+ issuanceDate: '2026-01-01T00:00:00Z',
56
+ credentialSubject: { id: 'did:example:holder' },
57
+ };
58
+ const item = (id = 'a') => ({
59
+ recipient: { type: 'email' as const, value: `${id}@example.test` },
60
+ credential,
61
+ });
62
+ const run = (batch: IssueInboxCredentialBatch) =>
63
+ issueInboxBatch(profile, batch, ctx, {
64
+ cache: mocks,
65
+ beforeIssue: mocks.beforeIssue,
66
+ });
67
+
68
+ describe('getInboxBatchState', () => {
69
+ it('does not classify an empty state list as completed', () => {
70
+ expect(getInboxBatchState([])).toBe('QUEUED');
71
+ });
72
+
73
+ it('uses reconciliation, completion, and progress precedence', () => {
74
+ expect(getInboxBatchState(['QUEUED', 'NEEDS_RECONCILIATION'])).toBe('NEEDS_RECONCILIATION');
75
+ expect(getInboxBatchState(['COMPLETED', 'COMPLETED'])).toBe('COMPLETED');
76
+ expect(getInboxBatchState(['QUEUED', 'PROCESSING'])).toBe('PROCESSING');
77
+ });
78
+ });
79
+
80
+ describe('inbox batch worker processing', () => {
81
+ beforeEach(() => {
82
+ vi.resetAllMocks();
83
+ mocks.get.mockResolvedValue(null);
84
+ mocks.setIfAbsent.mockResolvedValue('OK');
85
+ mocks.compareAndSet.mockResolvedValue(true);
86
+ mocks.resolve.mockImplementation(async input => ({ credential: input.credential }));
87
+ mocks.skipCheckpoint = false;
88
+ mocks.refreshDigest.mockReturnValue('refresh-digest');
89
+ mocks.issue.mockImplementation(async (_profile, recipient) => ({
90
+ inboxCredential: { id: recipient.value },
91
+ status: 'PENDING',
92
+ claimUrl: 'https://example.test/claim',
93
+ }));
94
+ });
95
+
96
+ it('inherits refresh and honors item overrides including false and the top-level alias', async () => {
97
+ const batch = IssueInboxCredentialBatchValidator.parse({
98
+ configuration: { refresh: true },
99
+ items: [
100
+ item('inherit'),
101
+ { ...item('off'), configuration: { refresh: false } },
102
+ { ...item('alias'), refresh: false },
103
+ { ...item('explicit'), refresh: false, configuration: { refresh: true } },
104
+ ],
105
+ });
106
+ await run(batch);
107
+ const flags = Object.fromEntries(
108
+ mocks.issue.mock.calls.map(call => [call[1].value, call[3].refresh])
109
+ );
110
+ expect(flags).toEqual({
111
+ 'inherit@example.test': true,
112
+ 'off@example.test': false,
113
+ 'alias@example.test': false,
114
+ 'explicit@example.test': true,
115
+ });
116
+ expect(mocks.assertRefresh).toHaveBeenCalledTimes(2);
117
+ await run(
118
+ IssueInboxCredentialBatchValidator.parse({
119
+ configuration: { refresh: false },
120
+ items: [{ ...item('on'), configuration: { refresh: true } }],
121
+ })
122
+ );
123
+ expect(mocks.issue.mock.calls.at(-1)?.[3].refresh).toBe(true);
124
+ });
125
+
126
+ it('returns and replays refresh receipts and forwards the template anchor and digest', async () => {
127
+ const refresh = {
128
+ refreshId: 'refresh-id',
129
+ credentialId: 'credential-id',
130
+ issuerDid: 'did:example:issuer',
131
+ refreshService: {
132
+ id: 'https://example.test/refresh',
133
+ type: 'LearnCardCredentialRefresh2026',
134
+ },
135
+ };
136
+ mocks.resolve.mockResolvedValue({ credential, resolvedBoostUri: 'boost:template' });
137
+ mocks.issue.mockResolvedValue({
138
+ inboxCredential: { id: 'issued' },
139
+ status: 'PENDING',
140
+ refresh,
141
+ });
142
+ const batch = IssueInboxCredentialBatchValidator.parse({
143
+ configuration: { refresh: true },
144
+ items: [{ ...item(), idempotencyKey: 'refresh-key' }],
145
+ });
146
+ const result = await run(batch);
147
+ expect(result.results[0]).toMatchObject({ success: true, refresh });
148
+ expect(mocks.issue.mock.calls[0]?.[3]).toMatchObject({
149
+ refresh: true,
150
+ boostUri: 'boost:template',
151
+ refreshRequestDigest: 'refresh-digest',
152
+ });
153
+ mocks.get.mockResolvedValue(mocks.compareAndSet.mock.calls[0]![2]);
154
+ const replay = await run(batch);
155
+ expect(replay.results[0]).toMatchObject({ success: true, refresh, deduplicated: true });
156
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
157
+ const changed = await run({ ...batch, configuration: { refresh: false } });
158
+ expect(changed.results[0]).toMatchObject({
159
+ success: false,
160
+ error: { reason: 'IDEMPOTENCY_MISMATCH' },
161
+ });
162
+ });
163
+
164
+ it('rejects unavailable refresh before resolving or issuing an item', async () => {
165
+ mocks.assertRefresh.mockRejectedValue(new TRPCError({ code: 'UNAUTHORIZED' }));
166
+ const result = await run({ configuration: { refresh: true }, items: [item()] });
167
+ expect(result.results[0]).toMatchObject({
168
+ success: false,
169
+ error: { code: 'UNAUTHORIZED' },
170
+ });
171
+ expect(mocks.resolve).not.toHaveBeenCalled();
172
+ expect(mocks.issue).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it('isolates disabled refresh failures while issuing opted-out items', async () => {
176
+ mocks.assertRefresh.mockRejectedValue(new TRPCError({ code: 'NOT_FOUND' }));
177
+ const result = await run(
178
+ IssueInboxCredentialBatchValidator.parse({
179
+ configuration: { refresh: true },
180
+ items: [item('refresh'), { ...item('normal'), configuration: { refresh: false } }],
181
+ })
182
+ );
183
+ expect(result.results).toMatchObject([
184
+ { success: false, error: { code: 'NOT_FOUND' } },
185
+ { success: true, recipient: item('normal').recipient },
186
+ ]);
187
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
188
+ expect(mocks.issue.mock.lastCall![3].refresh).toBe(false);
189
+ });
190
+
191
+ it.each(['true', 1, null])(
192
+ 'rejects invalid refresh value %s at both configuration levels',
193
+ refresh => {
194
+ expect(
195
+ IssueInboxCredentialBatchValidator.safeParse({
196
+ configuration: { refresh },
197
+ items: [item()],
198
+ }).success
199
+ ).toBe(false);
200
+ expect(
201
+ IssueInboxCredentialBatchValidator.safeParse({
202
+ items: [{ ...item(), configuration: { refresh } }],
203
+ }).success
204
+ ).toBe(false);
205
+ }
206
+ );
207
+
208
+ it('bounds concurrency and preserves order when work completes out of order', async () => {
209
+ let active = 0;
210
+ let peak = 0;
211
+ mocks.issue.mockImplementation(async (_profile, recipient) => {
212
+ active++;
213
+ peak = Math.max(peak, active);
214
+ await new Promise(resolve =>
215
+ setTimeout(resolve, recipient.value.startsWith('0') ? 20 : 1)
216
+ );
217
+ active--;
218
+ return { inboxCredential: { id: recipient.value }, status: 'PENDING' };
219
+ });
220
+ const batch = await run({ items: Array.from({ length: 12 }, (_, i) => item(String(i))) });
221
+ expect(peak).toBe(10);
222
+ expect(batch.results.map(r => r.success && r.issuanceId)).toEqual(
223
+ Array.from({ length: 12 }, (_, i) => `${i}@example.test`)
224
+ );
225
+ });
226
+
227
+ it('isolates missing credentials, TRPC errors and unknown exceptions without leaking internals', async () => {
228
+ mocks.issue
229
+ .mockRejectedValueOnce(
230
+ new TRPCError({ code: 'NOT_FOUND', message: 'Template not found' })
231
+ )
232
+ .mockRejectedValueOnce(new Error('private database connection'))
233
+ .mockResolvedValueOnce({ inboxCredential: { id: 'success' }, status: 'PENDING' });
234
+ const result = await run({
235
+ items: [{ recipient: item().recipient }, item('b'), item('c'), item('d')],
236
+ });
237
+ expect(result.results).toMatchObject([
238
+ { success: false, index: 0, error: { code: 'BAD_REQUEST' } },
239
+ {
240
+ success: false,
241
+ index: 1,
242
+ error: { code: 'NOT_FOUND', message: 'Template not found' },
243
+ },
244
+ {
245
+ success: false,
246
+ index: 2,
247
+ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Failed to issue credential' },
248
+ },
249
+ { success: true, index: 3 },
250
+ ]);
251
+ expect(result.summary).toEqual({ total: 4, succeeded: 1, failed: 3, deduplicated: 0 });
252
+ });
253
+
254
+ it('deep-merges before applying defaults, replaces arrays, and leaves inputs untouched', async () => {
255
+ const batch: IssueInboxCredentialBatch = {
256
+ configuration: {
257
+ signingAuthority: { endpoint: 'https://sa.test', name: 'UPPER' },
258
+ delivery: { suppress: true, template: { model: { issuer: { name: 'Issuer' } } } },
259
+ templateData: { student: { course: 'Math' }, grades: [1, 2] },
260
+ },
261
+ items: [
262
+ {
263
+ ...item(),
264
+ configuration: {
265
+ delivery: { template: { model: { recipient: { name: 'Ada' } } } },
266
+ templateData: { student: { name: 'Ada' }, grades: [3] },
267
+ },
268
+ },
269
+ ],
270
+ };
271
+ const original = structuredClone(batch);
272
+ await run(batch);
273
+ expect(mocks.issue.mock.calls[0]?.[3]).toMatchObject({
274
+ signingAuthority: { endpoint: 'https://sa.test', name: 'upper' },
275
+ delivery: {
276
+ suppress: true,
277
+ template: { model: { issuer: { name: 'Issuer' }, recipient: { name: 'Ada' } } },
278
+ },
279
+ templateData: { student: { course: 'Math', name: 'Ada' }, grades: [3] },
280
+ });
281
+ expect(batch).toEqual(original);
282
+ });
283
+
284
+ it('caches only successes for 24 hours and replays using the current index', async () => {
285
+ const batch = await run({
286
+ items: [
287
+ { ...item(), idempotencyKey: 'key' },
288
+ { recipient: item('b').recipient, idempotencyKey: 'failed' },
289
+ ],
290
+ });
291
+ expect(mocks.compareAndSet).toHaveBeenCalledTimes(1);
292
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
293
+ 'inbox-batch-idem:issuer:key',
294
+ expect.any(String),
295
+ expect.any(String),
296
+ 86400
297
+ );
298
+ expect(JSON.parse(mocks.compareAndSet.mock.calls[0]![2])).toMatchObject(
299
+ JSON.parse(JSON.stringify(batch.results[0]))
300
+ );
301
+ mocks.get.mockResolvedValueOnce(
302
+ JSON.stringify({
303
+ ...JSON.parse(mocks.compareAndSet.mock.calls[0]![2]),
304
+ index: 99,
305
+ })
306
+ );
307
+ mocks.issue.mockClear();
308
+ const replay = await run({ items: [{ ...item(), idempotencyKey: 'key' }] });
309
+ expect(replay.results[0]).toMatchObject({ success: true, index: 0, deduplicated: true });
310
+ expect(mocks.issue).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it('does not deduplicate unkeyed items', async () => {
314
+ await run({ items: [item(), item()] });
315
+ expect(mocks.get).not.toHaveBeenCalled();
316
+ expect(mocks.setIfAbsent).not.toHaveBeenCalled();
317
+ expect(mocks.issue).toHaveBeenCalledTimes(2);
318
+ });
319
+
320
+ it('fails closed on cache read/reservation errors and invalid replay records', async () => {
321
+ mocks.get.mockResolvedValueOnce(undefined).mockResolvedValueOnce('invalid json');
322
+ const items = [{ ...item(), idempotencyKey: 'a' }];
323
+ expect((await run({ items })).summary.failed).toBe(1);
324
+ expect((await run({ items })).summary.failed).toBe(1);
325
+ mocks.setIfAbsent.mockResolvedValueOnce(undefined);
326
+ expect((await run({ items })).summary.failed).toBe(1);
327
+ expect(mocks.issue).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it('releases reservations on preparation failure but retains ambiguous issuance outcomes', async () => {
331
+ const items = [{ ...item(), idempotencyKey: 'a' }];
332
+ mocks.resolve.mockRejectedValueOnce(new TRPCError({ code: 'NOT_FOUND' }));
333
+ expect((await run({ items })).results[0]).toMatchObject({
334
+ success: false,
335
+ error: { code: 'NOT_FOUND' },
336
+ });
337
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
338
+ expect.any(String),
339
+ expect.any(String),
340
+ null,
341
+ 86400
342
+ );
343
+ mocks.compareAndSet.mockClear();
344
+ mocks.issue.mockRejectedValueOnce(new Error('connection lost after write'));
345
+ expect((await run({ items })).results[0]).toMatchObject({
346
+ success: false,
347
+ error: { code: 'CONFLICT' },
348
+ });
349
+ expect(mocks.compareAndSet).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it.each([undefined, false])(
353
+ 'preserves issuance metadata when replay commit returns %s',
354
+ async saved => {
355
+ mocks.compareAndSet.mockResolvedValue(saved);
356
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'a' }] });
357
+ expect(result.results[0]).toMatchObject({
358
+ success: false,
359
+ error: { code: 'CONFLICT', message: expect.stringContaining('Credential issued') },
360
+ issuanceId: 'a@example.test',
361
+ claimUrl: 'https://example.test/claim',
362
+ });
363
+ expect(mocks.compareAndSet).toHaveBeenCalledTimes(saved === undefined ? 2 : 1);
364
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
365
+ }
366
+ );
367
+
368
+ it('retries a transient commit failure once without issuing again', async () => {
369
+ mocks.compareAndSet.mockResolvedValueOnce(undefined).mockResolvedValueOnce(true);
370
+ expect((await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).summary.succeeded).toBe(
371
+ 1
372
+ );
373
+ expect(mocks.compareAndSet).toHaveBeenCalledTimes(2);
374
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
375
+ });
376
+
377
+ it('recognizes a committed result when the CAS reply was lost', async () => {
378
+ mocks.compareAndSet.mockImplementation(async (_key, _owner, value) => {
379
+ mocks.get.mockResolvedValue(value);
380
+ return undefined;
381
+ });
382
+ expect((await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).summary.succeeded).toBe(
383
+ 1
384
+ );
385
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ it.each(['FORBIDDEN', 'BAD_REQUEST', 'NOT_FOUND'] as const)(
389
+ 'releases an explicitly side-effect-free %s and allows the corrected item to retry',
390
+ async code => {
391
+ const items = [{ ...item(), idempotencyKey: 'preflight' }];
392
+ mocks.issue.mockRejectedValueOnce(
393
+ new InboxIssuancePreflightError({ code, message: 'Invalid preflight input' })
394
+ );
395
+ expect((await run({ items })).results[0]).toMatchObject({
396
+ success: false,
397
+ error: { code },
398
+ });
399
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
400
+ expect.any(String),
401
+ expect.any(String),
402
+ null,
403
+ 86400
404
+ );
405
+ expect(
406
+ (
407
+ await run({
408
+ items: [{ ...items[0]!, configuration: { delivery: { suppress: true } } }],
409
+ })
410
+ ).summary.succeeded
411
+ ).toBe(1);
412
+ }
413
+ );
414
+
415
+ it('retains reservations for ordinary 4xx failures that may follow a write', async () => {
416
+ mocks.issue.mockRejectedValueOnce(new TRPCError({ code: 'BAD_REQUEST' }));
417
+ expect(
418
+ (await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).results[0]
419
+ ).toMatchObject({
420
+ success: false,
421
+ error: { code: 'CONFLICT' },
422
+ });
423
+ expect(mocks.compareAndSet).not.toHaveBeenCalled();
424
+ });
425
+
426
+ it('rejects later duplicate keys deterministically', async () => {
427
+ const result = await run({
428
+ items: [
429
+ { ...item(), idempotencyKey: 'same' },
430
+ { ...item(), idempotencyKey: 'same' },
431
+ { ...item('different'), idempotencyKey: 'same' },
432
+ ],
433
+ });
434
+ expect(result.results).toMatchObject([
435
+ { index: 0, success: true },
436
+ { index: 1, success: false, error: { code: 'CONFLICT' } },
437
+ { index: 2, success: false, error: { code: 'CONFLICT' } },
438
+ ]);
439
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
440
+ expect(mocks.setIfAbsent).toHaveBeenCalledTimes(1);
441
+ });
442
+
443
+ it('does not attempt later duplicate keys even if the first item fails validation', async () => {
444
+ const result = await run({
445
+ items: [
446
+ { recipient: item().recipient, idempotencyKey: 'same' },
447
+ { ...item(), idempotencyKey: 'same' },
448
+ ],
449
+ });
450
+ expect(result.results).toMatchObject([
451
+ { success: false, error: { code: 'BAD_REQUEST' } },
452
+ { success: false, error: { code: 'CONFLICT' } },
453
+ ]);
454
+ expect(mocks.issue).not.toHaveBeenCalled();
455
+ expect(mocks.setIfAbsent).not.toHaveBeenCalled();
456
+ });
457
+
458
+ it('reports a retryable conflict if the reservation disappears after SET NX contention', async () => {
459
+ mocks.setIfAbsent.mockResolvedValueOnce(null);
460
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'a' }] });
461
+ expect(result.results[0]).toMatchObject({
462
+ success: false,
463
+ error: { code: 'CONFLICT', message: expect.stringContaining('Retry this same key') },
464
+ });
465
+ expect(mocks.issue).not.toHaveBeenCalled();
466
+ });
467
+
468
+ it('rejects replay records missing the request fingerprint', async () => {
469
+ mocks.get.mockResolvedValueOnce(
470
+ JSON.stringify({
471
+ success: true,
472
+ index: 0,
473
+ issuanceId: 'old',
474
+ status: 'PENDING',
475
+ recipient: item().recipient,
476
+ })
477
+ );
478
+ expect((await run({ items: [{ ...item(), idempotencyKey: 'a' }] })).summary.failed).toBe(1);
479
+ expect(mocks.issue).not.toHaveBeenCalled();
480
+ });
481
+
482
+ it('validates guardian self-approval after merging overrides and allows null to clear it', async () => {
483
+ const result = await run({
484
+ configuration: { guardianEmail: 'A@example.test' },
485
+ items: [
486
+ item(),
487
+ { ...item(), configuration: { guardianEmail: 'guardian@example.test' } },
488
+ { ...item('clear'), configuration: { guardianEmail: null } },
489
+ ],
490
+ });
491
+ expect(result.results).toMatchObject([
492
+ {
493
+ success: false,
494
+ error: { code: 'BAD_REQUEST', message: expect.stringContaining('self-approval') },
495
+ },
496
+ { success: true },
497
+ { success: true },
498
+ ]);
499
+ expect(mocks.issue).toHaveBeenCalledTimes(2);
500
+ expect(mocks.issue.mock.calls[1]?.[3]).not.toHaveProperty('guardianEmail');
501
+ });
502
+
503
+ it('checks the durable worker lease before entering issuance', async () => {
504
+ mocks.beforeIssue.mockRejectedValueOnce(new Error('Lease lost'));
505
+ expect(
506
+ (await run({ items: [{ ...item(), idempotencyKey: 'lease' }] })).results[0]
507
+ ).toMatchObject({ success: false });
508
+ expect(mocks.resolve).toHaveBeenCalledTimes(1);
509
+ expect(mocks.issue).not.toHaveBeenCalled();
510
+ expect(mocks.compareAndSet).toHaveBeenCalledWith(
511
+ expect.any(String),
512
+ expect.any(String),
513
+ null,
514
+ 86400
515
+ );
516
+ });
517
+
518
+ it.each([false, true])(
519
+ 'preserves checkpoint reconciliation details when phase persistence fails: %s',
520
+ async fails => {
521
+ mocks.skipCheckpoint = true;
522
+ if (fails) mocks.beforeIssue.mockRejectedValueOnce(new Error('Storage unavailable'));
523
+ const result = await run({
524
+ items: [{ ...item(), idempotencyKey: 'missing-checkpoint' }],
525
+ });
526
+
527
+ expect(result.results[0]).toMatchObject({
528
+ success: false,
529
+ error: {
530
+ code: 'CONFLICT',
531
+ message: expect.stringContaining('Reconcile using issuanceId'),
532
+ },
533
+ issuanceId: 'a@example.test',
534
+ claimUrl: 'https://example.test/claim',
535
+ });
536
+ expect(mocks.beforeIssue).toHaveBeenCalledTimes(1);
537
+ expect(mocks.compareAndSet).not.toHaveBeenCalled();
538
+ }
539
+ );
540
+
541
+ it('preserves preparation errors when releasing a reservation throws', async () => {
542
+ mocks.resolve.mockRejectedValueOnce(
543
+ new TRPCError({ code: 'NOT_FOUND', message: 'Missing template' })
544
+ );
545
+ mocks.compareAndSet.mockRejectedValueOnce(new Error('Storage unavailable'));
546
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'cleanup' }] });
547
+ expect(result.results[0]).toMatchObject({
548
+ success: false,
549
+ error: { code: 'NOT_FOUND', message: 'Missing template' },
550
+ });
551
+ expect(mocks.issue).not.toHaveBeenCalled();
552
+ });
553
+
554
+ it('returns the item outcome when the lease checkpoint and cleanup both fail', async () => {
555
+ mocks.beforeIssue.mockRejectedValueOnce(new Error('Inbox item lease lost'));
556
+ mocks.compareAndSet.mockRejectedValueOnce(new Error('Inbox item lease lost'));
557
+ const result = await run({ items: [{ ...item(), idempotencyKey: 'lost' }] });
558
+ expect(result.results[0]).toMatchObject({
559
+ success: false,
560
+ error: { code: 'INTERNAL_SERVER_ERROR' },
561
+ });
562
+ expect(mocks.issue).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it('serializes overlapping keys across requests and rejects changed payloads', async () => {
566
+ const values = new Map<string, string>();
567
+ mocks.get.mockImplementation(async key => values.get(key) ?? null);
568
+ mocks.setIfAbsent.mockImplementation(async (key, value) => {
569
+ if (values.has(key)) return null;
570
+ values.set(key, value);
571
+ return 'OK';
572
+ });
573
+ mocks.compareAndSet.mockImplementation(async (key, expected, value) => {
574
+ if (values.get(key) !== expected) return false;
575
+ values.set(key, value);
576
+ return true;
577
+ });
578
+ const items = [{ ...item(), idempotencyKey: 'a' }];
579
+ await Promise.all([run({ items }), run({ items })]);
580
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
581
+ expect((await run({ items })).summary.deduplicated).toBe(1);
582
+ expect(
583
+ (await run({ items: [{ ...item('different'), idempotencyKey: 'a' }] })).results[0]
584
+ ).toMatchObject({ success: false, error: { code: 'CONFLICT' } });
585
+ expect(mocks.issue).toHaveBeenCalledTimes(1);
586
+ });
587
+
588
+ it('masks internal TRPC error details', async () => {
589
+ mocks.issue.mockRejectedValueOnce(
590
+ new TRPCError({
591
+ code: 'INTERNAL_SERVER_ERROR',
592
+ message: 'private signing service token',
593
+ })
594
+ );
595
+ expect((await run({ items: [item()] })).results[0]).toMatchObject({
596
+ error: { code: 'INTERNAL_SERVER_ERROR', message: 'Failed to issue credential' },
597
+ });
598
+ });
599
+
600
+ it('applies the tested HTTP parser budget to batch routes only', async () => {
601
+ const server = Fastify();
602
+ configureInboxBatchBodyLimit(server);
603
+ server.post('/api/inbox/issue-batch', async () => ({ accepted: true }));
604
+ server.post('/api/inbox/issue', async () => ({ accepted: true }));
605
+ server.post('/trpc/:path', async () => ({ accepted: true }));
606
+ const payload = {
607
+ padding: 'x'.repeat(
608
+ INBOX_BATCH_MAX_BYTES - Buffer.byteLength(JSON.stringify({ padding: '' }))
609
+ ),
610
+ };
611
+ try {
612
+ expect(
613
+ (await server.inject({ method: 'POST', url: '/api/inbox/issue-batch', payload }))
614
+ .statusCode
615
+ ).toBe(200);
616
+ expect(
617
+ (await server.inject({ method: 'POST', url: '/trpc/inbox.issueBatch', payload }))
618
+ .statusCode
619
+ ).toBe(200);
620
+ expect(
621
+ (await server.inject({ method: 'POST', url: '/api/inbox/issue', payload }))
622
+ .statusCode
623
+ ).toBe(413);
624
+ payload.padding += 'x';
625
+ expect(
626
+ (await server.inject({ method: 'POST', url: '/api/inbox/issue-batch', payload }))
627
+ .statusCode
628
+ ).toBe(413);
629
+ } finally {
630
+ await server.close();
631
+ }
632
+ });
633
+
634
+ it('validates item count and key length', () => {
635
+ expect(IssueInboxCredentialBatchValidator.safeParse({ items: [] }).success).toBe(false);
636
+ expect(
637
+ IssueInboxCredentialBatchValidator.safeParse({ items: Array(101).fill(item()) }).success
638
+ ).toBe(false);
639
+ expect(
640
+ IssueInboxCredentialBatchValidator.safeParse({ items: Array(100).fill(item()) }).success
641
+ ).toBe(true);
642
+ expect(
643
+ IssueInboxCredentialBatchValidator.safeParse({
644
+ items: [{ ...item(), idempotencyKey: 'x'.repeat(257) }],
645
+ }).success
646
+ ).toBe(false);
647
+ });
648
+ });
649
+
650
+ describe('batch admission validation', () => {
651
+ it('reports every invalid item index before processing and honors guardian overrides', () => {
652
+ const result = IssueInboxCredentialBatchValidator.safeParse({
653
+ configuration: { guardianEmail: 'A@example.test' },
654
+ items: [
655
+ { recipient: item('b').recipient },
656
+ item(),
657
+ { ...item(), configuration: { guardianEmail: 'guardian@example.test' } },
658
+ { ...item('clear'), configuration: { guardianEmail: null } },
659
+ ],
660
+ });
661
+ expect(result.success).toBe(false);
662
+ if (!result.success)
663
+ expect(result.error.issues.map(issue => issue.path)).toEqual([
664
+ ['items', 0, 'credential'],
665
+ ['items', 1, 'configuration', 'guardianEmail'],
666
+ ]);
667
+ });
668
+ });