@learncard/network-brain-service 4.1.2 → 5.0.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 (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +164 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
@@ -1,19 +1,29 @@
1
- import { createHash } from 'crypto';
1
+ import { createHash, randomUUID } from 'crypto';
2
2
 
3
3
  import { TRPCError } from '@trpc/server';
4
4
  import { z } from 'zod';
5
5
  import {
6
6
  VC,
7
+ ManagedCredentialRefreshBindingValidator,
7
8
  type AllocateCredentialRefreshResult,
9
+ type BitstringStatusListEntry,
8
10
  type CredentialRefreshSigningMode,
11
+ type CredentialRefreshUpdateReason,
12
+ type CredentialStatus,
13
+ type HolderEncryptedHandoff,
14
+ type JWE,
9
15
  type ManagedCredentialRefreshReceipt,
10
16
  type ManagedCredentialRefreshService,
11
17
  type PublishCredentialRefreshInput,
12
18
  type PublishCredentialRefreshNotification,
13
19
  type PublishCredentialRefreshResult,
20
+ type PublishHolderEncryptedRefreshInput,
14
21
  } from '@learncard/types';
15
22
  import {
23
+ assertExactJweRecipients,
24
+ canonicalizeCredentialJson,
16
25
  getCredentialEffectiveTime,
26
+ getBitstringStatusListEntries,
17
27
  getCredentialIssuerId,
18
28
  getManagedRefreshServices,
19
29
  prepareManagedRefreshContext,
@@ -23,7 +33,7 @@ import { neogma } from '@instance';
23
33
 
24
34
  import { storeCredential } from '@accesslayer/credential/create';
25
35
  import { deleteCredential } from '@accesslayer/credential/delete';
26
- import { getBoostByUri } from '@accesslayer/boost/read';
36
+ import { getBoostByUri, getBoostById } from '@accesslayer/boost/read';
27
37
  import { canProfileIssueBoost } from '@accesslayer/boost/relationships/read';
28
38
  import { getProfileByProfileId } from '@accesslayer/profile/read';
29
39
  import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
@@ -43,22 +53,29 @@ import type {
43
53
  CredentialRefreshVersionNode,
44
54
  } from 'types/credential-refresh';
45
55
 
46
- import { createCredentialIssuedViaContractRelationship } from '@accesslayer/credential/relationships/create';
47
- import type { CredentialInstance } from '@models';
56
+ import { ConsentFlowTransaction, Credential } from '@models';
48
57
  import type { DbTermsType } from 'types/consentflowcontract';
58
+ import { BindParam, QueryBuilder } from 'neogma';
59
+ import { runAudienceMutation } from '@accesslayer/consentflowcontract/relationships/recipients';
60
+ import { termsReferralSnapshotCypher } from './consent-referral.helpers';
49
61
 
50
- import { createDagJweForRecipients, getLearnCard } from './learnCard.helpers';
62
+ import { createDagJweForRecipients, getKeyAgreementKeyId, getLearnCard } from './learnCard.helpers';
51
63
  import { verifyManagedRefreshProof } from './credential-refresh-proof.helpers';
52
64
  import { issueCredentialWithSigningAuthority } from './signingAuthority.helpers';
65
+ import { logCredentialSent } from './activity.helpers';
53
66
  import {
54
67
  computeCredentialMaterialDigest,
68
+ matchesCredentialMaterialDigest,
69
+ matchesCredentialSubjectDigest,
55
70
  computeCredentialStatusDigest,
71
+ matchesCredentialStatusDigest,
56
72
  computeCredentialSubjectDigest,
57
73
  decideCredentialRefreshNotification,
58
74
  } from './credential-refresh-materiality.helpers';
59
75
  import { isInitialRefreshVersionUniquenessRace } from './credential-refresh-initial-binding.helpers';
60
76
  import { ensureInitialNotificationPolicy } from './credential-refresh-notification-policy.helpers';
61
- import { getStatusListBaseUrl } from './status-list.helpers';
77
+ import { getStatusListBaseUrl, getStatusManagement } from './status-list.helpers';
78
+ import { bindRefreshStatusEntries } from './managed-status.helpers';
62
79
  import { constructUri } from './uri.helpers';
63
80
  import {
64
81
  addNotificationToQueue,
@@ -67,10 +84,19 @@ import {
67
84
  } from './notifications.helpers';
68
85
  import { deliverCredentialRefreshEmailNotification } from './credential-refresh-email.helpers';
69
86
  import { extractBoundedCredentialDisplayTitle } from './credential-refresh-email-content.helpers';
87
+ import { getPublicBoostDisplayName } from './credential-display.helpers';
70
88
  import type { TenantBranding } from '@learncard/email-templates';
71
89
  import { getDidWeb } from './did.helpers';
72
90
  import { isRelationshipBlocked } from './connection.helpers';
73
91
  import { ProfileType } from 'types/profile';
92
+ import { resolveContractForSend } from './send-contract.helpers';
93
+ import {
94
+ getRefreshSendIntent,
95
+ getRefreshSendIntentForRefresh,
96
+ markRefreshSendIntentDelivered,
97
+ reconcileBoundRefreshSendIntent,
98
+ recordRefreshSendIntentPending,
99
+ } from './refresh-send-intent.helpers';
74
100
 
75
101
  /**
76
102
  * Managed credential refresh issuance helpers (LC-2135).
@@ -95,6 +121,8 @@ export type AllocateCredentialRefreshParams = {
95
121
  holderDid: string;
96
122
  credentialId: string;
97
123
  domain: string;
124
+ /** Pins the aggregate to student-only encryption (LC-2345). Omit for network mode. */
125
+ encryptionMode?: 'holder';
98
126
  };
99
127
 
100
128
  /**
@@ -105,7 +133,8 @@ export type AllocateCredentialRefreshParams = {
105
133
  export const allocateCredentialRefresh = async (
106
134
  params: AllocateCredentialRefreshParams
107
135
  ): Promise<AllocateCredentialRefreshResult> => {
108
- const { issuerProfile, holderProfile, holderDid, credentialId, domain } = params;
136
+ const { issuerProfile, holderProfile, holderDid, credentialId, domain, encryptionMode } =
137
+ params;
109
138
 
110
139
  const refreshId = generateRefreshId();
111
140
  const now = new Date().toISOString();
@@ -127,6 +156,7 @@ export const allocateCredentialRefresh = async (
127
156
  currentVersion: 1,
128
157
  createdAt: now,
129
158
  updatedAt: now,
159
+ ...(encryptionMode ? { encryptionMode } : {}),
130
160
  };
131
161
 
132
162
  await neogma.queryRunner.run(
@@ -237,10 +267,12 @@ export const peekCredentialRefreshInitialBinding = async (params: {
237
267
 
238
268
  assertInitialCredentialMatches(
239
269
  root,
240
- computeCredentialMaterialDigest(credential as unknown as Record<string, unknown>),
241
- computeCredentialStatusDigest(
242
- (credential as unknown as Record<string, unknown>).credentialStatus
243
- ),
270
+ stored =>
271
+ matchesCredentialMaterialDigest(
272
+ credential as unknown as Record<string, unknown>,
273
+ stored
274
+ ),
275
+ credential.credentialStatus,
244
276
  boostId
245
277
  );
246
278
 
@@ -259,28 +291,6 @@ export const peekCredentialRefreshInitialBinding = async (params: {
259
291
  };
260
292
  };
261
293
 
262
- /**
263
- * Idempotently links a managed root credential to the approved consent-flow contract
264
- * terms it was issued through, on both fresh binds and resume paths.
265
- */
266
- const ensureCredentialIssuedViaContractRelationship = async (
267
- credentialNodeId: string,
268
- terms: DbTermsType
269
- ): Promise<void> => {
270
- const existing = await neogma.queryRunner.run(
271
- `MATCH (root:Credential {id: $nodeId})-[:ISSUED_VIA_TRANSACTION]->()
272
- RETURN 1 AS one LIMIT 1`,
273
- { nodeId: credentialNodeId }
274
- );
275
-
276
- if (existing.records.length > 0) return;
277
-
278
- await createCredentialIssuedViaContractRelationship(
279
- { id: credentialNodeId } as unknown as CredentialInstance,
280
- terms
281
- );
282
- };
283
-
284
294
  /** Collects the subject identifiers of a credential (single object or array form). */
285
295
  const getCredentialSubjectIds = (credential: VC): string[] => {
286
296
  const subjects = Array.isArray(credential.credentialSubject)
@@ -335,6 +345,8 @@ export type SendRefreshableCredentialParams = {
335
345
  export type SendRefreshableCredentialResult = {
336
346
  uri: string;
337
347
  receipt: ManagedCredentialRefreshReceipt;
348
+ /** Public status providers; external entries require provider-side lifecycle updates. */
349
+ statusManagement?: ReturnType<typeof getStatusManagement>;
338
350
  };
339
351
 
340
352
  type InitialRefreshRoot = {
@@ -379,15 +391,15 @@ export const getBoundRefreshBoostId = async (refreshId: string): Promise<string
379
391
 
380
392
  const assertInitialCredentialMatches = (
381
393
  root: InitialRefreshRoot,
382
- materialDigest: string,
383
- credentialStatusDigest: string,
394
+ matchesMaterialDigest: (stored: string) => boolean,
395
+ credentialStatus: unknown,
384
396
  boostId?: string
385
397
  ): void => {
386
398
  if (
387
399
  !root.materialDigest ||
388
400
  !root.credentialStatusDigest ||
389
- root.materialDigest !== materialDigest ||
390
- root.credentialStatusDigest !== credentialStatusDigest
401
+ !matchesMaterialDigest(root.materialDigest) ||
402
+ !matchesCredentialStatusDigest(credentialStatus, root.credentialStatusDigest)
391
403
  ) {
392
404
  throw new TRPCError({
393
405
  code: 'CONFLICT',
@@ -410,34 +422,90 @@ const ensureInitialRefreshRelationships = async (params: {
410
422
  boostId?: string;
411
423
  activityId?: string;
412
424
  integrationId?: string;
425
+ contractTerms?: DbTermsType;
413
426
  }): Promise<string | undefined> => {
414
- const { refreshId, issuerProfileId, holderProfileId, boostId, activityId, integrationId } =
415
- params;
427
+ const {
428
+ refreshId,
429
+ issuerProfileId,
430
+ holderProfileId,
431
+ boostId,
432
+ activityId,
433
+ integrationId,
434
+ contractTerms,
435
+ } = params;
416
436
  const now = new Date().toISOString();
417
- const result = await neogma.queryRunner.run(
418
- `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
419
- WHERE coalesce(refresh.boostId, '') = coalesce($boostId, '')
420
- MATCH (issuer:Profile {profileId: $issuerProfileId})
421
- OPTIONAL MATCH (boost:Boost {id: $boostId})
422
- MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
423
- ON CREATE SET sent.date = $now
424
- SET sent.activityId = coalesce(sent.activityId, $activityId),
425
- sent.integrationId = coalesce(sent.integrationId, $integrationId)
426
- FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
427
- MERGE (root)-[:INSTANCE_OF]->(boost)
428
- )
429
- RETURN root.id AS rootId`,
430
- {
431
- refreshId,
432
- issuerProfileId,
433
- holderProfileId,
434
- boostId: boostId ?? null,
435
- activityId: activityId ?? null,
436
- integrationId: integrationId ?? null,
437
- now,
438
- }
439
- );
440
-
437
+ const values = {
438
+ refreshId,
439
+ issuerProfileId,
440
+ holderProfileId,
441
+ boostId: boostId ?? null,
442
+ activityId: activityId ?? null,
443
+ integrationId: integrationId ?? null,
444
+ now,
445
+ };
446
+ const query = contractTerms
447
+ ? new QueryBuilder(
448
+ new BindParam({
449
+ ...values,
450
+ termsId: contractTerms.id,
451
+ expectedVersion: contractTerms.consentedVersion,
452
+ transactionId: randomUUID(),
453
+ })
454
+ ).raw(
455
+ `MATCH (terms:ConsentFlowTerms {id:$termsId})-[:CONSENTS_TO]->(contract:ConsentFlowContract)
456
+ SET contract.audienceLock = coalesce(contract.audienceLock, 0) + 1
457
+ WITH terms, contract
458
+ WHERE coalesce(contract.status, 'active') <> 'retired'
459
+ AND contract.currentVersion = $expectedVersion
460
+ AND terms.consentedVersion = $expectedVersion
461
+ AND (terms.status = 'live' OR (terms.status = 'consumed' AND terms.oneTime = true
462
+ AND EXISTS {
463
+ MATCH (:CredentialRefresh {refreshId:$refreshId})-[:ROOT]->(:Credential)
464
+ -[:${Credential.getRelationshipByAlias('issuedViaTransaction').name}]->(:ConsentFlowTransaction)
465
+ -[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
466
+ }))
467
+ AND (terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' OR datetime(terms.expiresAt) > datetime.realtime())
468
+ AND (contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' OR datetime(contract.expiresAt) > datetime.realtime())
469
+ AND NOT $issuerProfileId IN coalesce(terms['terms.deniedWriters'], [])
470
+ AND EXISTS { MATCH (:Profile {profileId:$holderProfileId})-[:CREATED_BY]->(terms) }
471
+ MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
472
+ WHERE coalesce(refresh.boostId, '') = coalesce($boostId, '')
473
+ MATCH (issuer:Profile {profileId: $issuerProfileId})
474
+ OPTIONAL MATCH (boost:Boost {id: $boostId})
475
+ WITH terms, root, issuer, boost
476
+ WHERE boost IS NULL OR boost.category IS NULL OR terms['terms.write.credentials.categories.' + boost.category] = true
477
+ MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
478
+ ON CREATE SET sent.date = $now
479
+ SET sent.activityId = coalesce(sent.activityId, $activityId),
480
+ sent.integrationId = coalesce(sent.integrationId, $integrationId)
481
+ FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
482
+ MERGE (root)-[:INSTANCE_OF]->(boost)
483
+ )
484
+ OPTIONAL MATCH (root)-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name}]->(existing:ConsentFlowTransaction)-[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
485
+ WITH root, terms, existing
486
+ FOREACH (_ IN CASE WHEN existing IS NULL THEN [1] ELSE [] END |
487
+ CREATE (transaction:ConsentFlowTransaction {id:$transactionId, action:'write', date:$now, boostId:$boostId})
488
+ -[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
489
+ CREATE (root)-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name} {date:$now}]->(transaction)
490
+ ${termsReferralSnapshotCypher()}
491
+ )
492
+ RETURN root.id AS rootId`
493
+ )
494
+ : new QueryBuilder(new BindParam(values)).raw(
495
+ `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root:Credential)
496
+ WHERE coalesce(refresh.boostId, '') = coalesce($boostId, '')
497
+ MATCH (issuer:Profile {profileId: $issuerProfileId})
498
+ OPTIONAL MATCH (boost:Boost {id: $boostId})
499
+ MERGE (issuer)-[sent:CREDENTIAL_SENT {to: $holderProfileId}]->(root)
500
+ ON CREATE SET sent.date = $now
501
+ SET sent.activityId = coalesce(sent.activityId, $activityId),
502
+ sent.integrationId = coalesce(sent.integrationId, $integrationId)
503
+ FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
504
+ MERGE (root)-[:INSTANCE_OF]->(boost)
505
+ )
506
+ RETURN root.id AS rootId`
507
+ );
508
+ const result = contractTerms ? await runAudienceMutation(query) : await query.run();
441
509
  return result.records[0]?.get('rootId') ?? undefined;
442
510
  };
443
511
 
@@ -493,120 +561,103 @@ const getHolderEncryptionRecipients = async (holderProfile: ProfileType): Promis
493
561
  };
494
562
 
495
563
  /**
496
- * Binds the signed original credential to a previously allocated refresh aggregate.
497
- *
498
- * The plaintext VC is handled only within this request: its proof is verified and
499
- * its invariants are checked against the allocation, then it is encrypted to the
500
- * intended holder and only the resulting JWE is persisted. Creates the normal
501
- * CREDENTIAL_SENT relationship plus ROOT/HEAD bindings and records version 1.
564
+ * Current key-agreement verification-method ids (LC-2345 holder mode): the same
565
+ * holder + managers as {@link getHolderEncryptionRecipients}, resolved to the exact
566
+ * `kid`s a DAG-JWE must carry. Recomputed on every call so a manager change is
567
+ * reflected immediately — callers compare this against a handoff's JWE recipients
568
+ * (`assertExactJweRecipients`) rather than trusting a previously-prepared set.
502
569
  */
503
- export const sendRefreshableCredential = async (
504
- params: SendRefreshableCredentialParams
505
- ): Promise<SendRefreshableCredentialResult> => {
570
+ export const getHolderEncryptionRecipientKeyIds = async (
571
+ holderProfile: ProfileType
572
+ ): Promise<string[]> => {
573
+ const dids = await getHolderEncryptionRecipients(holderProfile);
574
+
575
+ return Promise.all(dids.map(did => getKeyAgreementKeyId(did)));
576
+ };
577
+
578
+ /**
579
+ * True when `issuerDid` is an identity `issuerProfile` controls: either its internal
580
+ * controller did:key or its public network did:web. Both are legitimate on version 1
581
+ * of a managed credential (see {@link sendRefreshableCredential}); later versions must
582
+ * match whichever one version 1 actually used.
583
+ */
584
+ export const isControlledIssuerDid = (
585
+ issuerDid: string,
586
+ issuerProfile: ProfileType,
587
+ domain: string
588
+ ): boolean => [issuerProfile.did, getDidWeb(domain, issuerProfile.profileId)].includes(issuerDid);
589
+
590
+ /**
591
+ * Shared delivery tail for version 1 of a managed credential, common to network mode
592
+ * (plaintext checked and encrypted in {@link sendRefreshableCredential}) and holder
593
+ * mode (already-encrypted handoff checked in {@link sendHolderEncryptedRefreshableCredential}):
594
+ * binds the public status entries, stores the JWE as the Credential, creates the
595
+ * ROOT/HEAD/CREDENTIAL_SENT bindings (and any consent-flow transaction) in one
596
+ * transaction, resolves initial-send races, and sends the (generic-copy) initial
597
+ * notification exactly once.
598
+ */
599
+ const deliverManagedRefreshVersion1Credential = async (params: {
600
+ refreshId: string;
601
+ issuerProfile: ProfileType;
602
+ holderProfile: ProfileType;
603
+ aggregate: CredentialRefreshRecord;
604
+ credentialIssuerDid: string;
605
+ statusEntries: BitstringStatusListEntry[];
606
+ credentialStatus: CredentialStatus | CredentialStatus[] | undefined;
607
+ refreshService: ManagedCredentialRefreshReceipt['refreshService'];
608
+ jwe: JWE;
609
+ materialDigest: string;
610
+ /**
611
+ * Compares a stored root material digest with this delivery. Network sends match
612
+ * under any retained digest key; holder handoffs (ciphertext digests) match exactly.
613
+ */
614
+ matchesMaterialDigest?: (stored: string) => boolean;
615
+ credentialStatusDigest: string;
616
+ credentialSubjectDigest: string | null;
617
+ boostUri?: string;
618
+ skipNotification: boolean;
619
+ activityId?: string;
620
+ integrationId?: string;
621
+ contractTerms?: DbTermsType;
622
+ domain: string;
623
+ }): Promise<SendRefreshableCredentialResult> => {
506
624
  const {
507
- issuerProfile,
508
625
  refreshId,
509
- credential,
626
+ issuerProfile,
627
+ holderProfile,
628
+ aggregate,
629
+ credentialIssuerDid,
630
+ statusEntries,
631
+ credentialStatus,
632
+ refreshService,
633
+ jwe,
634
+ materialDigest: rootMaterialDigest,
635
+ credentialStatusDigest,
636
+ credentialSubjectDigest,
510
637
  boostUri,
511
- skipNotification = false,
638
+ skipNotification,
512
639
  activityId,
513
640
  integrationId,
514
641
  contractTerms,
515
642
  domain,
516
643
  } = params;
644
+ const matchesMaterialDigest =
645
+ params.matchesMaterialDigest ?? ((stored: string) => stored === rootMaterialDigest);
517
646
 
518
- const aggregate = await getCredentialRefresh(refreshId);
519
-
520
- if (!aggregate) {
521
- throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
522
- }
523
-
524
- if (aggregate.issuerProfileId !== issuerProfile.profileId) {
525
- throw new TRPCError({
526
- code: 'UNAUTHORIZED',
527
- message: 'Profile did not allocate this credential refresh',
528
- });
529
- }
530
-
531
- if (
532
- !aggregate.holderDid ||
533
- aggregate.state === 'pending_holder' ||
534
- aggregate.inboxCredentialId
535
- ) {
536
- throw new TRPCError({
537
- code: 'CONFLICT',
538
- message: 'Inbox refresh must be bound through its verified claim flow',
539
- });
540
- }
541
-
542
- if (credential.id !== aggregate.credentialId) {
543
- throw new TRPCError({
544
- code: 'BAD_REQUEST',
545
- message: 'Credential ID does not match the allocated refresh',
546
- });
547
- }
548
-
549
- const credentialIssuerDid = getCredentialIssuerId(credential);
550
- const publicIssuerDid = getDidWeb(domain, issuerProfile.profileId);
551
-
552
- // A profile controls both its internal controller did:key and its public network
553
- // did:web. The network plugin normally issues with did:web, while lower-level
554
- // callers may legitimately issue with the controller DID. Bind the aggregate to
555
- // whichever controlled identity appears on version 1; later versions must match
556
- // that exact normalized issuer.
557
- if (
558
- !credentialIssuerDid ||
559
- ![issuerProfile.did, publicIssuerDid].includes(credentialIssuerDid)
560
- ) {
561
- throw new TRPCError({
562
- code: 'BAD_REQUEST',
563
- message: 'Credential issuer does not match the allocated refresh',
564
- });
565
- }
566
-
567
- if (
568
- !aggregate.holderDid ||
569
- !getCredentialSubjectIds(credential).includes(aggregate.holderDid)
570
- ) {
571
- throw new TRPCError({
572
- code: 'BAD_REQUEST',
573
- message: 'Credential subject does not match the intended holder',
574
- });
575
- }
576
-
577
- if (!hasAllocatedRefreshService(credential, refreshId, domain)) {
578
- throw new TRPCError({
579
- code: 'BAD_REQUEST',
580
- message: 'Credential does not contain the allocated refresh service',
581
- });
582
- }
583
-
584
- // Metadata-only issuance receipt (LC-2198 decision 2): populated from the actual
585
- // signed credential and the validated allocation. It deliberately excludes all
586
- // credential claims, subject bodies, plaintext VC content, and JWE payloads.
647
+ // Metadata-only issuance receipt (LC-2198 decision 2): populated from the
648
+ // validated allocation. It deliberately excludes all credential claims, subject
649
+ // bodies, plaintext VC content, and JWE payloads.
587
650
  const buildReceipt = (): ManagedCredentialRefreshReceipt => ({
588
651
  refreshId,
589
- refreshService: {
590
- id: getCredentialRefreshServiceUrl(refreshId, domain),
591
- type: 'LearnCardCredentialRefresh2026',
592
- authorization: { type: 'LearnCardDIDAuth' },
593
- },
652
+ refreshService,
594
653
  credentialId: aggregate.credentialId,
595
654
  issuerDid: credentialIssuerDid,
596
655
  holderDid: aggregate.holderDid!,
597
- ...(credential.credentialStatus !== undefined && credential.credentialStatus !== null
598
- ? { credentialStatus: credential.credentialStatus }
656
+ ...(credentialStatus !== undefined && credentialStatus !== null
657
+ ? { credentialStatus }
599
658
  : {}),
600
659
  });
601
660
 
602
- const holderProfile = aggregate.holderProfileId
603
- ? await getProfileByProfileId(aggregate.holderProfileId)
604
- : null;
605
-
606
- if (!holderProfile) {
607
- throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
608
- }
609
-
610
661
  if (await isRelationshipBlocked(issuerProfile, holderProfile)) {
611
662
  throw new TRPCError({
612
663
  code: 'NOT_FOUND',
@@ -630,25 +681,16 @@ export const sendRefreshableCredential = async (
630
681
  });
631
682
  }
632
683
 
633
- await verifyManagedRefreshProof((await getLearnCard()).invoke, credential, publicIssuerDid);
634
-
635
- const rootMaterialDigest = computeCredentialMaterialDigest(
636
- credential as unknown as Record<string, unknown>
637
- );
638
- const credentialStatusDigest = computeCredentialStatusDigest(
639
- (credential as unknown as Record<string, unknown>).credentialStatus
640
- );
641
-
642
684
  const existingRoot = await getInitialRefreshRoot(refreshId);
643
685
 
644
686
  if (existingRoot) {
645
687
  assertInitialCredentialMatches(
646
688
  existingRoot,
647
- rootMaterialDigest,
648
- credentialStatusDigest,
689
+ matchesMaterialDigest,
690
+ credentialStatus,
649
691
  boost?.id
650
692
  );
651
-
693
+ await bindRefreshStatusEntries(refreshId, statusEntries, issuerProfile.profileId, domain);
652
694
  const rootId = await ensureInitialRefreshRelationships({
653
695
  refreshId,
654
696
  issuerProfileId: issuerProfile.profileId,
@@ -656,19 +698,16 @@ export const sendRefreshableCredential = async (
656
698
  boostId: boost?.id,
657
699
  activityId,
658
700
  integrationId,
701
+ contractTerms,
659
702
  });
660
-
661
703
  if (!rootId) {
662
704
  throw new TRPCError({
663
- code: 'CONFLICT',
664
- message: 'Credential refresh initial delivery could not be resumed',
705
+ code: contractTerms ? 'FORBIDDEN' : 'CONFLICT',
706
+ message: contractTerms
707
+ ? 'User has not yet approved the latest version of this contract'
708
+ : 'Credential refresh initial delivery could not be resumed',
665
709
  });
666
710
  }
667
-
668
- if (contractTerms) {
669
- await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
670
- }
671
-
672
711
  const uri = getManagedCredentialUri(rootId, domain);
673
712
  const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
674
713
  refreshId,
@@ -681,16 +720,14 @@ export const sendRefreshableCredential = async (
681
720
  holderProfile,
682
721
  initialNotificationSuppressed,
683
722
  });
684
-
685
- return { uri, receipt: buildReceipt() };
723
+ return {
724
+ uri,
725
+ receipt: buildReceipt(),
726
+ statusManagement: getStatusManagement(statusEntries, domain),
727
+ };
686
728
  }
687
729
 
688
- // Holder-side encryption only (never the brain DID): the holder's did:key plus its
689
- // managers' did:keys — see getHolderEncryptionRecipients.
690
- const jwe = await createDagJweForRecipients(
691
- credential,
692
- await getHolderEncryptionRecipients(holderProfile)
693
- );
730
+ await bindRefreshStatusEntries(refreshId, statusEntries, issuerProfile.profileId, domain);
694
731
 
695
732
  const credentialInstance = await storeCredential(jwe);
696
733
 
@@ -700,52 +737,110 @@ export const sendRefreshableCredential = async (
700
737
  let boundRecordsLength = 0;
701
738
 
702
739
  try {
703
- const bound = await neogma.queryRunner.run(
704
- `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})
705
- WHERE NOT EXISTS { MATCH (refresh)-[:ROOT]->(:Credential) }
706
- MATCH (root:Credential {id: $rootCredentialNodeId})
707
- MATCH (issuer:Profile {profileId: $issuerProfileId})
708
- OPTIONAL MATCH (boost:Boost {id: $boostId})
709
- CREATE (refresh)-[:ROOT]->(root)
710
- CREATE (refresh)-[:HEAD]->(root)
711
- CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
712
- FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
713
- CREATE (root)-[:INSTANCE_OF]->(boost)
714
- )
715
- SET root.refreshId = $refreshId,
716
- root.version = 1,
717
- root.refreshVersionKey = $versionKey,
718
- root.publishedAt = $now,
719
- root.signingMode = 'issuer-signed',
720
- refresh.issuerDid = $credentialIssuerDid,
721
- refresh.materialDigest = $materialDigest,
722
- refresh.rootMaterialDigest = $materialDigest,
723
- refresh.credentialStatusDigest = $credentialStatusDigest,
724
- refresh.credentialSubjectDigest = $credentialSubjectDigest,
725
- refresh.boostId = $boostId,
726
- refresh.initialNotificationSuppressed = $initialNotificationSuppressed,
727
- refresh.lastPublishedAt = $now,
728
- refresh.updatedAt = $now
729
- RETURN refresh`,
730
- {
731
- refreshId,
732
- rootCredentialNodeId: credentialInstance.id,
733
- issuerProfileId: issuerProfile.profileId,
734
- holderProfileId: holderProfile.profileId,
735
- boostId: boost?.id ?? null,
736
- activityId: activityId ?? null,
737
- integrationId: integrationId ?? null,
738
- versionKey: `${refreshId}:1`,
739
- now,
740
- credentialIssuerDid,
741
- materialDigest: rootMaterialDigest,
742
- credentialSubjectDigest: computeCredentialSubjectDigest(
743
- credential.credentialSubject
744
- ),
745
- credentialStatusDigest,
746
- initialNotificationSuppressed: skipNotification,
747
- }
748
- );
740
+ const bindingParams = {
741
+ refreshId,
742
+ rootCredentialNodeId: credentialInstance.id,
743
+ issuerProfileId: issuerProfile.profileId,
744
+ holderProfileId: holderProfile.profileId,
745
+ boostId: boost?.id ?? null,
746
+ activityId: activityId ?? null,
747
+ integrationId: integrationId ?? null,
748
+ versionKey: `${refreshId}:1`,
749
+ now,
750
+ credentialIssuerDid,
751
+ materialDigest: rootMaterialDigest,
752
+ credentialSubjectDigest,
753
+ credentialStatusDigest,
754
+ credentialStatusBinding: JSON.stringify(credentialStatus ?? null),
755
+ refreshServiceBinding: JSON.stringify(refreshService),
756
+ initialNotificationSuppressed: skipNotification,
757
+ };
758
+ const bindQuery = contractTerms
759
+ ? new QueryBuilder(
760
+ new BindParam({
761
+ ...bindingParams,
762
+ termsId: contractTerms.id,
763
+ expectedVersion: contractTerms.consentedVersion,
764
+ transactionId: randomUUID(),
765
+ })
766
+ ).raw(
767
+ `MATCH (terms:ConsentFlowTerms {id:$termsId})-[:CONSENTS_TO]->(contract:ConsentFlowContract)
768
+ SET contract.audienceLock = coalesce(contract.audienceLock, 0) + 1
769
+ WITH terms, contract
770
+ WHERE coalesce(contract.status, 'active') <> 'retired'
771
+ AND contract.currentVersion = $expectedVersion
772
+ AND terms.consentedVersion = $expectedVersion
773
+ AND terms.status = 'live'
774
+ AND (terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' OR datetime(terms.expiresAt) > datetime.realtime())
775
+ AND (contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' OR datetime(contract.expiresAt) > datetime.realtime())
776
+ AND NOT $issuerProfileId IN coalesce(terms['terms.deniedWriters'], [])
777
+ AND EXISTS { MATCH (:Profile {profileId:$holderProfileId})-[:CREATED_BY]->(terms) }
778
+ MATCH (refresh:CredentialRefresh {refreshId: $refreshId})
779
+ WHERE NOT EXISTS { MATCH (refresh)-[:ROOT]->(:Credential) }
780
+ MATCH (root:Credential {id: $rootCredentialNodeId})
781
+ MATCH (issuer:Profile {profileId: $issuerProfileId})
782
+ OPTIONAL MATCH (boost:Boost {id: $boostId})
783
+ WITH terms, refresh, root, issuer, boost
784
+ WHERE boost IS NULL OR boost.category IS NULL OR terms['terms.write.credentials.categories.' + boost.category] = true
785
+ CREATE (refresh)-[:ROOT]->(root)
786
+ CREATE (refresh)-[:HEAD]->(root)
787
+ CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
788
+ FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
789
+ CREATE (root)-[:INSTANCE_OF]->(boost)
790
+ )
791
+ CREATE (transaction:ConsentFlowTransaction {id:$transactionId, action:'write', date:$now, boostId:$boostId})
792
+ -[:${ConsentFlowTransaction.getRelationshipByAlias('isFor').name}]->(terms)
793
+ ${termsReferralSnapshotCypher()}
794
+ CREATE (root)-[:${Credential.getRelationshipByAlias('issuedViaTransaction').name} {date:$now}]->(transaction)
795
+ SET root.refreshId = $refreshId,
796
+ root.version = 1,
797
+ root.refreshVersionKey = $versionKey,
798
+ root.publishedAt = $now,
799
+ root.signingMode = 'issuer-signed',
800
+ refresh.issuerDid = $credentialIssuerDid,
801
+ refresh.materialDigest = $materialDigest,
802
+ refresh.rootMaterialDigest = $materialDigest,
803
+ refresh.credentialStatusDigest = $credentialStatusDigest,
804
+ refresh.credentialStatusBinding = $credentialStatusBinding,
805
+ refresh.refreshServiceBinding = $refreshServiceBinding,
806
+ refresh.credentialSubjectDigest = $credentialSubjectDigest,
807
+ refresh.boostId = $boostId,
808
+ refresh.initialNotificationSuppressed = $initialNotificationSuppressed,
809
+ refresh.lastPublishedAt = $now,
810
+ refresh.updatedAt = $now
811
+ RETURN refresh`
812
+ )
813
+ : new QueryBuilder(new BindParam(bindingParams)).raw(
814
+ `MATCH (refresh:CredentialRefresh {refreshId: $refreshId})
815
+ WHERE NOT EXISTS { MATCH (refresh)-[:ROOT]->(:Credential) }
816
+ MATCH (root:Credential {id: $rootCredentialNodeId})
817
+ MATCH (issuer:Profile {profileId: $issuerProfileId})
818
+ OPTIONAL MATCH (boost:Boost {id: $boostId})
819
+ CREATE (refresh)-[:ROOT]->(root)
820
+ CREATE (refresh)-[:HEAD]->(root)
821
+ CREATE (issuer)-[:CREDENTIAL_SENT {to: $holderProfileId, date: $now, activityId: $activityId, integrationId: $integrationId}]->(root)
822
+ FOREACH (_ IN CASE WHEN boost IS NULL THEN [] ELSE [1] END |
823
+ CREATE (root)-[:INSTANCE_OF]->(boost)
824
+ )
825
+ SET root.refreshId = $refreshId,
826
+ root.version = 1,
827
+ root.refreshVersionKey = $versionKey,
828
+ root.publishedAt = $now,
829
+ root.signingMode = 'issuer-signed',
830
+ refresh.issuerDid = $credentialIssuerDid,
831
+ refresh.materialDigest = $materialDigest,
832
+ refresh.rootMaterialDigest = $materialDigest,
833
+ refresh.credentialStatusDigest = $credentialStatusDigest,
834
+ refresh.credentialStatusBinding = $credentialStatusBinding,
835
+ refresh.refreshServiceBinding = $refreshServiceBinding,
836
+ refresh.credentialSubjectDigest = $credentialSubjectDigest,
837
+ refresh.boostId = $boostId,
838
+ refresh.initialNotificationSuppressed = $initialNotificationSuppressed,
839
+ refresh.lastPublishedAt = $now,
840
+ refresh.updatedAt = $now
841
+ RETURN refresh`
842
+ );
843
+ const bound = contractTerms ? await runAudienceMutation(bindQuery) : await bindQuery.run();
749
844
 
750
845
  boundRecordsLength = bound.records.length;
751
846
  } catch (error) {
@@ -769,15 +864,16 @@ export const sendRefreshableCredential = async (
769
864
 
770
865
  if (!concurrentlyBoundRoot) {
771
866
  throw new TRPCError({
772
- code: 'CONFLICT',
773
- message: 'Credential refresh was bound concurrently; retry initial delivery',
867
+ code: contractTerms ? 'FORBIDDEN' : 'CONFLICT',
868
+ message: contractTerms
869
+ ? 'User has not yet approved the latest version of this contract'
870
+ : 'Credential refresh was bound concurrently; retry initial delivery',
774
871
  });
775
872
  }
776
-
777
873
  assertInitialCredentialMatches(
778
874
  concurrentlyBoundRoot,
779
- rootMaterialDigest,
780
- credentialStatusDigest,
875
+ matchesMaterialDigest,
876
+ credentialStatus,
781
877
  boost?.id
782
878
  );
783
879
 
@@ -788,19 +884,16 @@ export const sendRefreshableCredential = async (
788
884
  boostId: boost?.id,
789
885
  activityId,
790
886
  integrationId,
887
+ contractTerms,
791
888
  });
792
-
793
889
  if (!rootId) {
794
890
  throw new TRPCError({
795
- code: 'CONFLICT',
796
- message: 'Credential refresh initial delivery could not be resumed',
891
+ code: contractTerms ? 'FORBIDDEN' : 'CONFLICT',
892
+ message: contractTerms
893
+ ? 'User has not yet approved the latest version of this contract'
894
+ : 'Credential refresh initial delivery could not be resumed',
797
895
  });
798
896
  }
799
-
800
- if (contractTerms) {
801
- await ensureCredentialIssuedViaContractRelationship(rootId, contractTerms);
802
- }
803
-
804
897
  const uri = getManagedCredentialUri(rootId, domain);
805
898
  const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
806
899
  refreshId,
@@ -813,13 +906,14 @@ export const sendRefreshableCredential = async (
813
906
  holderProfile,
814
907
  initialNotificationSuppressed,
815
908
  });
816
-
817
- return { uri, receipt: buildReceipt() };
909
+ return {
910
+ uri,
911
+ receipt: buildReceipt(),
912
+ statusManagement: getStatusManagement(statusEntries, domain),
913
+ };
818
914
  }
819
915
 
820
- if (contractTerms) {
821
- await ensureCredentialIssuedViaContractRelationship(credentialInstance.id, contractTerms);
822
- }
916
+ // Contract-linked root history was committed atomically with the root binding.
823
917
 
824
918
  const uri = getManagedCredentialUri(credentialInstance.id, domain);
825
919
  const initialNotificationSuppressed = await ensureInitialNotificationPolicy(
@@ -834,49 +928,415 @@ export const sendRefreshableCredential = async (
834
928
  initialNotificationSuppressed,
835
929
  });
836
930
 
837
- return { uri, receipt: buildReceipt() };
931
+ return {
932
+ uri,
933
+ receipt: buildReceipt(),
934
+ statusManagement: getStatusManagement(statusEntries, domain),
935
+ };
838
936
  };
839
937
 
840
- // --- Publication (LC-2135) -----------------------------------------------------
841
-
842
938
  /**
843
- * Route-level refinement of the Task 1 contract's permissive signing-authority
844
- * descriptor: resolving ownership requires the registered name + endpoint.
939
+ * Binds the signed original credential to a previously allocated refresh aggregate.
940
+ *
941
+ * The plaintext VC is handled only within this request: its proof is verified and
942
+ * its invariants are checked against the allocation, then it is encrypted to the
943
+ * intended holder and only the resulting JWE is persisted via the shared delivery
944
+ * tail (also used by holder-mode sends, which never see plaintext).
845
945
  */
846
- const SigningAuthorityReferenceValidator = z
847
- .object({
848
- type: z.string().min(1),
849
- name: z.string().min(1),
850
- endpoint: z.string().min(1),
851
- })
852
- .catchall(z.any());
853
-
854
- /** Opaque ETag derived from the stored encrypted bytes (never from plaintext). */
855
- export const computeRefreshEtag = (encryptedCredential: string): string =>
856
- createHash('sha256').update(encryptedCredential).digest('base64url');
946
+ export const sendRefreshableCredential = async (
947
+ params: SendRefreshableCredentialParams
948
+ ): Promise<SendRefreshableCredentialResult> => {
949
+ const {
950
+ issuerProfile,
951
+ refreshId,
952
+ credential,
953
+ boostUri,
954
+ skipNotification = false,
955
+ activityId,
956
+ integrationId,
957
+ contractTerms,
958
+ domain,
959
+ } = params;
857
960
 
858
- const deliverCredentialRefreshNotification = async (params: {
859
- version: CredentialRefreshVersionNode;
860
- issuerProfile: ProfileType;
861
- holderProfile: ProfileType;
862
- branding?: Partial<TenantBranding>;
863
- }): Promise<PublishCredentialRefreshNotification> => {
864
- const { version, issuerProfile, holderProfile, branding } = params;
961
+ const aggregate = await getCredentialRefresh(refreshId);
865
962
 
866
- let inAppOutcome: PublishCredentialRefreshNotification = 'queued';
867
- let deliveryWindowKey = version.notificationDeliveryKey;
963
+ if (!aggregate) {
964
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
965
+ }
868
966
 
869
- if (!version.notificationDeliveredAt) {
870
- const event = buildCredentialRefreshedNotification({
871
- holderProfile,
872
- issuerProfile,
873
- refreshId: version.refreshId,
874
- version: version.version,
875
- notificationId: version.notificationId,
876
- deliveryKey: version.notificationDeliveryKey,
877
- notifiedAt: version.notificationCreatedAt,
967
+ if (aggregate.issuerProfileId !== issuerProfile.profileId) {
968
+ throw new TRPCError({
969
+ code: 'UNAUTHORIZED',
970
+ message: 'Profile did not allocate this credential refresh',
878
971
  });
879
- deliveryWindowKey = event.deliveryKey;
972
+ }
973
+
974
+ // A holder-mode aggregate (LC-2345) never accepts a plaintext credential: it was
975
+ // prepared for an encrypted handoff and its recipient set was published instead
976
+ // of being used here to encrypt.
977
+ if (aggregate.encryptionMode === 'holder') {
978
+ throw new TRPCError({
979
+ code: 'CONFLICT',
980
+ message:
981
+ 'This credential refresh uses holder-mode encryption; send the encrypted handoff via credentialRefresh.sendHolderEncryptedRefreshable instead.',
982
+ });
983
+ }
984
+
985
+ if (
986
+ !aggregate.holderDid ||
987
+ aggregate.state === 'pending_holder' ||
988
+ aggregate.inboxCredentialId
989
+ ) {
990
+ throw new TRPCError({
991
+ code: 'CONFLICT',
992
+ message: 'Inbox refresh must be bound through its verified claim flow',
993
+ });
994
+ }
995
+
996
+ if (credential.id !== aggregate.credentialId) {
997
+ throw new TRPCError({
998
+ code: 'BAD_REQUEST',
999
+ message: 'Credential ID does not match the allocated refresh',
1000
+ });
1001
+ }
1002
+
1003
+ const credentialIssuerDid = getCredentialIssuerId(credential);
1004
+ const publicIssuerDid = getDidWeb(domain, issuerProfile.profileId);
1005
+
1006
+ // A profile controls both its internal controller did:key and its public network
1007
+ // did:web. The network plugin normally issues with did:web, while lower-level
1008
+ // callers may legitimately issue with the controller DID. Bind the aggregate to
1009
+ // whichever controlled identity appears on version 1; later versions must match
1010
+ // that exact normalized issuer.
1011
+ if (
1012
+ !credentialIssuerDid ||
1013
+ !isControlledIssuerDid(credentialIssuerDid, issuerProfile, domain)
1014
+ ) {
1015
+ throw new TRPCError({
1016
+ code: 'BAD_REQUEST',
1017
+ message: 'Credential issuer does not match the allocated refresh',
1018
+ });
1019
+ }
1020
+
1021
+ if (
1022
+ !aggregate.holderDid ||
1023
+ !getCredentialSubjectIds(credential).includes(aggregate.holderDid)
1024
+ ) {
1025
+ throw new TRPCError({
1026
+ code: 'BAD_REQUEST',
1027
+ message: 'Credential subject does not match the intended holder',
1028
+ });
1029
+ }
1030
+
1031
+ if (!hasAllocatedRefreshService(credential, refreshId, domain)) {
1032
+ throw new TRPCError({
1033
+ code: 'BAD_REQUEST',
1034
+ message: 'Credential does not contain the allocated refresh service',
1035
+ });
1036
+ }
1037
+
1038
+ const holderProfile = aggregate.holderProfileId
1039
+ ? await getProfileByProfileId(aggregate.holderProfileId)
1040
+ : null;
1041
+
1042
+ if (!holderProfile) {
1043
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
1044
+ }
1045
+
1046
+ await verifyManagedRefreshProof((await getLearnCard()).invoke, credential, publicIssuerDid);
1047
+
1048
+ const rootMaterialDigest = computeCredentialMaterialDigest(
1049
+ credential as unknown as Record<string, unknown>
1050
+ );
1051
+ const credentialStatusDigest = computeCredentialStatusDigest(
1052
+ (credential as unknown as Record<string, unknown>).credentialStatus
1053
+ );
1054
+
1055
+ // Holder-side encryption only (never the brain DID): the holder's did:key plus its
1056
+ // managers' did:keys — see getHolderEncryptionRecipients.
1057
+ const jwe = await createDagJweForRecipients(
1058
+ credential,
1059
+ await getHolderEncryptionRecipients(holderProfile)
1060
+ );
1061
+
1062
+ return deliverManagedRefreshVersion1Credential({
1063
+ refreshId,
1064
+ issuerProfile,
1065
+ holderProfile,
1066
+ aggregate,
1067
+ credentialIssuerDid,
1068
+ statusEntries: getBitstringStatusListEntries(credential),
1069
+ credentialStatus: credential.credentialStatus,
1070
+ refreshService: ManagedCredentialRefreshBindingValidator.parse(credential.refreshService),
1071
+ jwe,
1072
+ materialDigest: rootMaterialDigest,
1073
+ matchesMaterialDigest: stored =>
1074
+ matchesCredentialMaterialDigest(
1075
+ credential as unknown as Record<string, unknown>,
1076
+ stored
1077
+ ),
1078
+ credentialStatusDigest,
1079
+ credentialSubjectDigest: computeCredentialSubjectDigest(credential.credentialSubject),
1080
+ boostUri,
1081
+ skipNotification,
1082
+ activityId,
1083
+ integrationId,
1084
+ contractTerms,
1085
+ domain,
1086
+ });
1087
+ };
1088
+
1089
+ export type SendHolderEncryptedRefreshableCredentialParams = {
1090
+ issuerProfile: ProfileType;
1091
+ handoff: HolderEncryptedHandoff;
1092
+ idempotencyKey?: string;
1093
+ boostUri?: string;
1094
+ skipNotification?: boolean;
1095
+ domain: string;
1096
+ onBehalfOf?: string;
1097
+ };
1098
+
1099
+ /**
1100
+ * Holder-mode counterpart of {@link sendRefreshableCredential} (LC-2345): the
1101
+ * credential never reaches this process in plaintext. Every invariant that would
1102
+ * normally be read off the signed VC is instead checked against the public fields of
1103
+ * the handoff and the allocation; the JWE's recipient `kid`s must exactly equal the
1104
+ * holder's current encryption recipients (see `getHolderEncryptionRecipientKeyIds`).
1105
+ * Proof and refreshService verification are the receiving device's responsibility
1106
+ * (`ingestManagedCredential`) — they are exactly what the server cannot check here.
1107
+ */
1108
+ export const sendHolderEncryptedRefreshableCredential = async (
1109
+ params: SendHolderEncryptedRefreshableCredentialParams
1110
+ ): Promise<SendRefreshableCredentialResult & { activityId: string }> => {
1111
+ const { issuerProfile, handoff, skipNotification = false, domain } = params;
1112
+
1113
+ const aggregate = await getCredentialRefresh(handoff.refreshId);
1114
+
1115
+ if (!aggregate) {
1116
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
1117
+ }
1118
+
1119
+ if (aggregate.issuerProfileId !== issuerProfile.profileId) {
1120
+ throw new TRPCError({
1121
+ code: 'UNAUTHORIZED',
1122
+ message: 'Profile did not allocate this credential refresh',
1123
+ });
1124
+ }
1125
+
1126
+ if (aggregate.encryptionMode !== 'holder') {
1127
+ throw new TRPCError({
1128
+ code: 'CONFLICT',
1129
+ message:
1130
+ 'This credential refresh was not prepared for holder-mode encryption; use credentialRefresh.sendRefreshableCredential instead.',
1131
+ });
1132
+ }
1133
+
1134
+ if (
1135
+ !aggregate.holderDid ||
1136
+ aggregate.state === 'pending_holder' ||
1137
+ aggregate.inboxCredentialId
1138
+ ) {
1139
+ throw new TRPCError({
1140
+ code: 'CONFLICT',
1141
+ message: 'Inbox refresh must be bound through its verified claim flow',
1142
+ });
1143
+ }
1144
+
1145
+ if (handoff.credentialId !== aggregate.credentialId) {
1146
+ throw new TRPCError({
1147
+ code: 'BAD_REQUEST',
1148
+ message: 'Credential ID does not match the allocated refresh',
1149
+ });
1150
+ }
1151
+
1152
+ if (handoff.holderDid !== aggregate.holderDid) {
1153
+ throw new TRPCError({
1154
+ code: 'BAD_REQUEST',
1155
+ message: 'Credential subject does not match the intended holder',
1156
+ });
1157
+ }
1158
+
1159
+ if (!isControlledIssuerDid(handoff.issuerDid, issuerProfile, domain)) {
1160
+ throw new TRPCError({
1161
+ code: 'BAD_REQUEST',
1162
+ message: 'Credential issuer does not match the allocated refresh',
1163
+ });
1164
+ }
1165
+
1166
+ const intent = params.idempotencyKey
1167
+ ? await getRefreshSendIntent(issuerProfile.profileId, params.idempotencyKey)
1168
+ : await getRefreshSendIntentForRefresh(issuerProfile.profileId, handoff.refreshId);
1169
+ if (
1170
+ !intent ||
1171
+ intent.state === 'preparing' ||
1172
+ intent.refreshId !== handoff.refreshId ||
1173
+ intent.holderDid !== handoff.holderDid ||
1174
+ intent.credentialId !== handoff.credentialId ||
1175
+ !intent.boostUri ||
1176
+ (params.boostUri !== undefined && params.boostUri !== intent.boostUri)
1177
+ ) {
1178
+ throw new TRPCError({
1179
+ code: 'CONFLICT',
1180
+ message: 'This request does not match the prepared refresh send.',
1181
+ });
1182
+ }
1183
+ const boostUri = intent.boostUri;
1184
+ const completed = await reconcileBoundRefreshSendIntent(intent, domain);
1185
+ if (completed?.type === 'boost' && completed.refresh && completed.credentialUri) {
1186
+ return {
1187
+ uri: completed.credentialUri,
1188
+ receipt: completed.refresh,
1189
+ activityId: completed.activityId,
1190
+ };
1191
+ }
1192
+
1193
+ const refreshService = handoff.refreshService ?? {
1194
+ id: getCredentialRefreshServiceUrl(handoff.refreshId, domain),
1195
+ type: 'LearnCardCredentialRefresh2026' as const,
1196
+ authorization: { type: 'LearnCardDIDAuth' as const },
1197
+ };
1198
+ const managedServices = getManagedRefreshServices({ refreshService });
1199
+ if (
1200
+ managedServices.length !== 1 ||
1201
+ managedServices[0]!.id !== getCredentialRefreshServiceUrl(handoff.refreshId, domain)
1202
+ ) {
1203
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid refresh service binding' });
1204
+ }
1205
+
1206
+ const holderProfile = aggregate.holderProfileId
1207
+ ? await getProfileByProfileId(aggregate.holderProfileId)
1208
+ : null;
1209
+
1210
+ if (!holderProfile) {
1211
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
1212
+ }
1213
+
1214
+ try {
1215
+ assertExactJweRecipients(
1216
+ handoff.jwe,
1217
+ await getHolderEncryptionRecipientKeyIds(holderProfile)
1218
+ );
1219
+ } catch {
1220
+ // A stale prepared set (a manager was added or removed since prepare) and a
1221
+ // structurally wrong recipient set are indistinguishable to the server; both
1222
+ // require the caller to refetch via `credentialRefresh.getEncryptionRecipients`.
1223
+ throw new TRPCError({ code: 'CONFLICT', message: 'RECIPIENTS_CHANGED' });
1224
+ }
1225
+
1226
+ const boost = await getBoostByUri(boostUri);
1227
+ if (!boost) throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found' });
1228
+ const contractTerms = await resolveContractForSend({
1229
+ profile: issuerProfile,
1230
+ targetProfile: holderProfile,
1231
+ boost,
1232
+ boostCreated: false,
1233
+ contractUri: intent.contractUri,
1234
+ });
1235
+ if (intent.contractUri && !contractTerms) {
1236
+ throw new TRPCError({
1237
+ code: 'FORBIDDEN',
1238
+ message: 'User has not yet approved the latest version of this contract',
1239
+ });
1240
+ }
1241
+
1242
+ const activityId =
1243
+ intent.pendingActivityId ??
1244
+ (await logCredentialSent({
1245
+ actorProfileId: issuerProfile.profileId,
1246
+ onBehalfOf: params.onBehalfOf,
1247
+ recipientType: 'profile',
1248
+ recipientIdentifier: holderProfile.profileId,
1249
+ recipientProfileId: holderProfile.profileId,
1250
+ boostUri,
1251
+ source: 'send',
1252
+ integrationId: intent.integrationId,
1253
+ }));
1254
+
1255
+ await recordRefreshSendIntentPending(
1256
+ intent,
1257
+ {
1258
+ refreshId: handoff.refreshId,
1259
+ credentialId: handoff.credentialId,
1260
+ issuerDid: handoff.issuerDid,
1261
+ holderDid: handoff.holderDid,
1262
+ credentialStatus: handoff.credentialStatus,
1263
+ refreshService,
1264
+ },
1265
+ activityId
1266
+ );
1267
+
1268
+ const delivered = await deliverManagedRefreshVersion1Credential({
1269
+ activityId,
1270
+ refreshId: handoff.refreshId,
1271
+ issuerProfile,
1272
+ holderProfile,
1273
+ aggregate,
1274
+ credentialIssuerDid: handoff.issuerDid,
1275
+ statusEntries: getBitstringStatusListEntries({
1276
+ credentialStatus: handoff.credentialStatus,
1277
+ }),
1278
+ credentialStatus: handoff.credentialStatus,
1279
+ jwe: handoff.jwe,
1280
+ refreshService,
1281
+ materialDigest: intent.requestDigest,
1282
+ credentialStatusDigest: computeCredentialStatusDigest(handoff.credentialStatus),
1283
+ credentialSubjectDigest: null,
1284
+ boostUri,
1285
+ skipNotification,
1286
+ integrationId: intent.integrationId,
1287
+ contractTerms: contractTerms ?? undefined,
1288
+ domain,
1289
+ });
1290
+ await markRefreshSendIntentDelivered(intent, {
1291
+ type: 'boost',
1292
+ uri: boostUri,
1293
+ credentialUri: delivered.uri,
1294
+ activityId,
1295
+ refresh: delivered.receipt,
1296
+ });
1297
+ return { ...delivered, activityId };
1298
+ };
1299
+
1300
+ // --- Publication (LC-2135) -----------------------------------------------------
1301
+
1302
+ /**
1303
+ * Route-level refinement of the Task 1 contract's permissive signing-authority
1304
+ * descriptor: resolving ownership requires the registered name + endpoint.
1305
+ */
1306
+ const SigningAuthorityReferenceValidator = z
1307
+ .object({
1308
+ type: z.string().min(1),
1309
+ name: z.string().min(1),
1310
+ endpoint: z.string().min(1),
1311
+ })
1312
+ .catchall(z.any());
1313
+
1314
+ /** Opaque ETag derived from the stored encrypted bytes (never from plaintext). */
1315
+ export const computeRefreshEtag = (encryptedCredential: string): string =>
1316
+ createHash('sha256').update(encryptedCredential).digest('base64url');
1317
+
1318
+ const deliverCredentialRefreshNotification = async (params: {
1319
+ version: CredentialRefreshVersionNode;
1320
+ issuerProfile: ProfileType;
1321
+ holderProfile: ProfileType;
1322
+ branding?: Partial<TenantBranding>;
1323
+ }): Promise<PublishCredentialRefreshNotification> => {
1324
+ const { version, issuerProfile, holderProfile, branding } = params;
1325
+
1326
+ let inAppOutcome: PublishCredentialRefreshNotification = 'queued';
1327
+ let deliveryWindowKey = version.notificationDeliveryKey;
1328
+
1329
+ if (!version.notificationDeliveredAt) {
1330
+ const event = buildCredentialRefreshedNotification({
1331
+ holderProfile,
1332
+ issuerProfile,
1333
+ refreshId: version.refreshId,
1334
+ version: version.version,
1335
+ notificationId: version.notificationId,
1336
+ deliveryKey: version.notificationDeliveryKey,
1337
+ notifiedAt: version.notificationCreatedAt,
1338
+ });
1339
+ deliveryWindowKey = event.deliveryKey;
880
1340
 
881
1341
  try {
882
1342
  await addNotificationToQueue(event.notification);
@@ -980,11 +1440,11 @@ export const deliverPendingCredentialRefreshNotificationForAcceptedCredential =
980
1440
  * (signing-authority mode). Error messages are deliberately generic — credential
981
1441
  * content must never appear in exception messages, tracing attributes, or logs.
982
1442
  */
983
- const assertRefreshVersionInvariants = (
1443
+ const assertRefreshVersionInvariants = async (
984
1444
  credential: VC,
985
1445
  aggregate: CredentialRefreshRecord,
986
1446
  domain: string
987
- ): void => {
1447
+ ): Promise<void> => {
988
1448
  if (credential.id !== aggregate.credentialId) {
989
1449
  throw new TRPCError({
990
1450
  code: 'BAD_REQUEST',
@@ -1011,8 +1471,10 @@ const assertRefreshVersionInvariants = (
1011
1471
 
1012
1472
  if (
1013
1473
  !aggregate.credentialSubjectDigest ||
1014
- computeCredentialSubjectDigest(credential.credentialSubject) !==
1474
+ !matchesCredentialSubjectDigest(
1475
+ credential.credentialSubject,
1015
1476
  aggregate.credentialSubjectDigest
1477
+ )
1016
1478
  ) {
1017
1479
  throw new TRPCError({
1018
1480
  code: 'BAD_REQUEST',
@@ -1028,51 +1490,188 @@ const assertRefreshVersionInvariants = (
1028
1490
  });
1029
1491
  }
1030
1492
 
1493
+ if (
1494
+ aggregate.refreshServiceBinding &&
1495
+ canonicalizeCredentialJson(credential.refreshService) !==
1496
+ canonicalizeCredentialJson(JSON.parse(aggregate.refreshServiceBinding))
1497
+ ) {
1498
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid refresh service binding' });
1499
+ }
1500
+
1031
1501
  if (
1032
1502
  !aggregate.credentialStatusDigest ||
1033
- computeCredentialStatusDigest(
1034
- (credential as unknown as Record<string, unknown>).credentialStatus
1035
- ) !== aggregate.credentialStatusDigest
1503
+ !matchesCredentialStatusDigest(
1504
+ credential.credentialStatus,
1505
+ aggregate.credentialStatusDigest
1506
+ )
1036
1507
  ) {
1037
1508
  throw new TRPCError({
1038
1509
  code: 'BAD_REQUEST',
1039
1510
  message: 'Credential status does not match the original credential',
1040
1511
  });
1041
1512
  }
1042
- };
1513
+ await bindRefreshStatusEntries(
1514
+ aggregate.refreshId,
1515
+ getBitstringStatusListEntries(credential),
1516
+ aggregate.issuerProfileId,
1517
+ domain
1518
+ );
1519
+ };
1520
+
1521
+ export type PublishCredentialRefreshParams = {
1522
+ issuerProfile: ProfileType;
1523
+ input: PublishCredentialRefreshInput;
1524
+ domain: string;
1525
+ /** Optional tenant branding for the update email; defaults to LearnCard. */
1526
+ branding?: Partial<TenantBranding>;
1527
+ };
1528
+
1529
+ /**
1530
+ * Publishes a new immutable version of a managed refreshable credential and
1531
+ * atomically advances the aggregate head.
1532
+ *
1533
+ * Plaintext exists only transiently inside this request: invariants and proofs are
1534
+ * checked in memory, a server-keyed HMAC over the canonical user-visible projection
1535
+ * is computed for materiality, and only the holder-encrypted JWE is persisted.
1536
+ * The version chain is advanced through the single-writer compare-and-advance in
1537
+ * the access layer; losers of a concurrent race receive CONFLICT and may retry.
1538
+ */
1539
+ /**
1540
+ * Shared publish tail, common to network mode ({@link publishCredentialRefresh}) and
1541
+ * holder mode ({@link publishHolderEncryptedRefreshableCredential}): advances the
1542
+ * aggregate head through the single-writer compare-and-advance, resolves
1543
+ * conflict/replay, and delivers the notification decision the caller already made
1544
+ * (server-inferred materiality for network mode, the issuer's explicit `notify` for
1545
+ * holder mode, which cannot compare encrypted content across versions).
1546
+ */
1547
+ const advanceManagedRefreshVersion = async (params: {
1548
+ refreshId: string;
1549
+ issuerProfile: ProfileType;
1550
+ aggregate: CredentialRefreshRecord;
1551
+ encryptedCredential: string;
1552
+ signingMode: CredentialRefreshSigningMode;
1553
+ idempotencyKey?: string;
1554
+ etag: string;
1555
+ materialDigest?: string;
1556
+ updateSummary?: string;
1557
+ updateReason?: CredentialRefreshUpdateReason;
1558
+ credentialDisplayName?: string;
1559
+ effectiveAt?: string;
1560
+ notification: PublishCredentialRefreshNotification;
1561
+ notificationPendingAfterClaim: boolean;
1562
+ holderProfile: ProfileType | null;
1563
+ branding?: Partial<TenantBranding>;
1564
+ }): Promise<PublishCredentialRefreshResult> => {
1565
+ const {
1566
+ refreshId,
1567
+ issuerProfile,
1568
+ aggregate,
1569
+ encryptedCredential,
1570
+ signingMode,
1571
+ idempotencyKey,
1572
+ etag,
1573
+ materialDigest,
1574
+ updateSummary,
1575
+ updateReason,
1576
+ credentialDisplayName,
1577
+ effectiveAt,
1578
+ notification,
1579
+ notificationPendingAfterClaim,
1580
+ holderProfile,
1581
+ branding,
1582
+ } = params;
1583
+
1584
+ const notificationEvent = holderProfile
1585
+ ? buildCredentialRefreshedNotification({
1586
+ holderProfile,
1587
+ issuerProfile,
1588
+ refreshId,
1589
+ version: aggregate.currentVersion + 1,
1590
+ })
1591
+ : undefined;
1592
+
1593
+ const advance = await advanceCredentialRefreshHead({
1594
+ refreshId,
1595
+ expectedVersion: aggregate.currentVersion,
1596
+ expectedState: aggregate.state,
1597
+ encryptedCredential,
1598
+ signingMode,
1599
+ idempotencyKey,
1600
+ etag,
1601
+ materialDigest,
1602
+ updateSummary,
1603
+ updateReason,
1604
+ credentialDisplayName,
1605
+ effectiveAt,
1606
+ notificationOutcome: notification,
1607
+ notificationId: notificationEvent?.notificationId,
1608
+ notificationDeliveryKey: notificationEvent?.deliveryKey,
1609
+ notificationCreatedAt: notificationEvent?.notifiedAt,
1610
+ notificationPendingAfterClaim,
1611
+ });
1612
+
1613
+ if (advance.status === 'conflict') {
1614
+ // A concurrent publication advanced the head first; nothing was written.
1615
+ throw new TRPCError({
1616
+ code: 'CONFLICT',
1617
+ message: 'Credential refresh was updated concurrently; retry the publication',
1618
+ });
1619
+ }
1620
+
1621
+ if (advance.status === 'replay') {
1622
+ const replayed = await getCredentialRefreshVersion(refreshId, advance.version);
1623
+
1624
+ if (!replayed) {
1625
+ throw new TRPCError({
1626
+ code: 'CONFLICT',
1627
+ message: 'Credential refresh replay could not be resolved; retry the publication',
1628
+ });
1629
+ }
1630
+
1631
+ return {
1632
+ refreshId,
1633
+ version: replayed.version,
1634
+ publishedAt: replayed.publishedAt,
1635
+ notification: await resolveCredentialRefreshReplayNotification({
1636
+ version: replayed,
1637
+ aggregate,
1638
+ issuerProfile,
1639
+ branding,
1640
+ }),
1641
+ };
1642
+ }
1643
+
1644
+ let deliveredNotification = notification;
1645
+
1646
+ if (notification === 'queued') {
1647
+ const persistedVersion = await getCredentialRefreshVersion(refreshId, advance.version);
1648
+
1649
+ deliveredNotification =
1650
+ persistedVersion && holderProfile
1651
+ ? await deliverCredentialRefreshNotification({
1652
+ version: persistedVersion,
1653
+ issuerProfile,
1654
+ holderProfile,
1655
+ branding,
1656
+ })
1657
+ : 'delivery-failed';
1658
+ }
1043
1659
 
1044
- export type PublishCredentialRefreshParams = {
1045
- issuerProfile: ProfileType;
1046
- input: PublishCredentialRefreshInput;
1047
- domain: string;
1048
- /** Optional tenant branding for the update email; defaults to LearnCard. */
1049
- branding?: Partial<TenantBranding>;
1660
+ return {
1661
+ refreshId,
1662
+ version: advance.version,
1663
+ publishedAt: advance.publishedAt ?? new Date().toISOString(),
1664
+ notification: deliveredNotification,
1665
+ };
1050
1666
  };
1051
1667
 
1052
- /**
1053
- * Publishes a new immutable version of a managed refreshable credential and
1054
- * atomically advances the aggregate head.
1055
- *
1056
- * Plaintext exists only transiently inside this request: invariants and proofs are
1057
- * checked in memory, a server-keyed HMAC over the canonical user-visible projection
1058
- * is computed for materiality, and only the holder-encrypted JWE is persisted.
1059
- * The version chain is advanced through the single-writer compare-and-advance in
1060
- * the access layer; losers of a concurrent race receive CONFLICT and may retry.
1061
- */
1062
1668
  export const publishCredentialRefresh = async (
1063
1669
  params: PublishCredentialRefreshParams
1064
1670
  ): Promise<PublishCredentialRefreshResult> => {
1065
1671
  const { issuerProfile, input, domain } = params;
1066
- const { refreshId, idempotencyKey, notifyHolder, updateSummary } = input;
1672
+ const { refreshId, idempotencyKey, notifyHolder, updateSummary, updateReason } = input;
1067
1673
  const branding = params.branding;
1068
1674
 
1069
- // Email-only bounded display title. Read from the issuer-supplied publication
1070
- // input (never from the holder-encrypted payload); `undefined` falls back to
1071
- // the original boost template at delivery time, then to generic copy.
1072
- const credentialDisplayName = extractBoundedCredentialDisplayTitle(
1073
- input.mode === 'signing-authority' ? input.credential.name : input.signedCredential.name
1074
- );
1075
-
1076
1675
  const aggregate = await getCredentialRefresh(refreshId);
1077
1676
 
1078
1677
  if (!aggregate) {
@@ -1086,6 +1685,21 @@ export const publishCredentialRefresh = async (
1086
1685
  });
1087
1686
  }
1088
1687
 
1688
+ // A holder-mode aggregate never accepts a plaintext or signing-authority
1689
+ // publication (LC-2345): both would require the server to process plaintext.
1690
+ if (aggregate.encryptionMode === 'holder') {
1691
+ throw new TRPCError({
1692
+ code: 'CONFLICT',
1693
+ message:
1694
+ 'This credential refresh uses holder-mode encryption; publish the encrypted handoff via credentialRefresh.publishHolderEncryptedRefreshable instead.',
1695
+ });
1696
+ }
1697
+
1698
+ const displayBoost = aggregate.boostId ? await getBoostById(aggregate.boostId) : null;
1699
+ const credentialDisplayName = extractBoundedCredentialDisplayTitle(
1700
+ getPublicBoostDisplayName(displayBoost)
1701
+ );
1702
+
1089
1703
  if (aggregate.state === 'revoked') {
1090
1704
  throw new TRPCError({
1091
1705
  code: 'CONFLICT',
@@ -1152,7 +1766,7 @@ export const publishCredentialRefresh = async (
1152
1766
  signedCredential = input.signedCredential;
1153
1767
  signingMode = 'issuer-signed';
1154
1768
 
1155
- assertRefreshVersionInvariants(signedCredential, aggregate, domain);
1769
+ await assertRefreshVersionInvariants(signedCredential, aggregate, domain);
1156
1770
  await verifyManagedRefreshProof(
1157
1771
  (await getLearnCard()).invoke,
1158
1772
  signedCredential,
@@ -1184,7 +1798,7 @@ export const publishCredentialRefresh = async (
1184
1798
  // Enforce the full invariant set on the unsigned body before proof creation.
1185
1799
  // The signing authority is the issuer's own registered delegate and signs the
1186
1800
  // body as supplied, so the completed credential is rechecked in full plus proof.
1187
- assertRefreshVersionInvariants(input.credential as VC, aggregate, domain);
1801
+ await assertRefreshVersionInvariants(input.credential as VC, aggregate, domain);
1188
1802
 
1189
1803
  // Managed refresh services require their inline JSON-LD context terms to be
1190
1804
  // defined or signing fails (LC-2198): prepare the context exactly as the SDK
@@ -1207,7 +1821,7 @@ export const publishCredentialRefresh = async (
1207
1821
  ).credential as VC;
1208
1822
  signingMode = 'signing-authority';
1209
1823
 
1210
- assertRefreshVersionInvariants(signedCredential, aggregate, domain);
1824
+ await assertRefreshVersionInvariants(signedCredential, aggregate, domain);
1211
1825
  await verifyManagedRefreshProof(
1212
1826
  (await getLearnCard()).invoke,
1213
1827
  signedCredential,
@@ -1240,7 +1854,12 @@ export const publishCredentialRefresh = async (
1240
1854
  const activeNotification = decideCredentialRefreshNotification({
1241
1855
  state: 'active',
1242
1856
  notifyHolder,
1243
- previousDigest: aggregate.materialDigest,
1857
+ previousDigest: matchesCredentialMaterialDigest(
1858
+ signedCredential as unknown as Record<string, unknown>,
1859
+ aggregate.materialDigest
1860
+ )
1861
+ ? nextDigest
1862
+ : aggregate.materialDigest,
1244
1863
  nextDigest,
1245
1864
  });
1246
1865
  const notification: PublishCredentialRefreshNotification =
@@ -1255,14 +1874,6 @@ export const publishCredentialRefresh = async (
1255
1874
  ? await getProfileByProfileId(aggregate.holderProfileId)
1256
1875
  : null
1257
1876
  : null;
1258
- const notificationEvent = holderProfile
1259
- ? buildCredentialRefreshedNotification({
1260
- holderProfile,
1261
- issuerProfile,
1262
- refreshId,
1263
- version: aggregate.currentVersion + 1,
1264
- })
1265
- : undefined;
1266
1877
 
1267
1878
  const holderProfileForEncryption = aggregate.holderProfileId
1268
1879
  ? await getProfileByProfileId(aggregate.holderProfileId)
@@ -1276,79 +1887,258 @@ export const publishCredentialRefresh = async (
1276
1887
  : [aggregate.holderDid!]
1277
1888
  );
1278
1889
  const encryptedCredential = JSON.stringify(jwe);
1279
- const etag = computeRefreshEtag(encryptedCredential);
1280
1890
 
1281
- const advance = await advanceCredentialRefreshHead({
1891
+ return advanceManagedRefreshVersion({
1282
1892
  refreshId,
1283
- expectedVersion: aggregate.currentVersion,
1284
- expectedState: aggregate.state,
1893
+ issuerProfile,
1894
+ aggregate,
1285
1895
  encryptedCredential,
1286
1896
  signingMode,
1287
1897
  idempotencyKey,
1288
- etag,
1898
+ etag: computeRefreshEtag(encryptedCredential),
1289
1899
  materialDigest: nextDigest,
1290
1900
  updateSummary,
1901
+ updateReason,
1291
1902
  credentialDisplayName,
1292
1903
  effectiveAt:
1293
1904
  effectiveTime !== undefined ? new Date(effectiveTime).toISOString() : undefined,
1294
- notificationOutcome: notification,
1295
- notificationId: notificationEvent?.notificationId,
1296
- notificationDeliveryKey: notificationEvent?.deliveryKey,
1297
- notificationCreatedAt: notificationEvent?.notifiedAt,
1905
+ notification,
1298
1906
  notificationPendingAfterClaim,
1907
+ holderProfile,
1908
+ branding,
1299
1909
  });
1910
+ };
1300
1911
 
1301
- if (advance.status === 'conflict') {
1302
- // A concurrent publication advanced the head first; nothing was written.
1912
+ export type PublishHolderEncryptedRefreshParams = {
1913
+ issuerProfile: ProfileType;
1914
+ input: PublishHolderEncryptedRefreshInput;
1915
+ domain: string;
1916
+ /** Optional tenant branding for the update email; defaults to LearnCard. */
1917
+ branding?: Partial<TenantBranding>;
1918
+ };
1919
+
1920
+ /**
1921
+ * Holder-mode counterpart of {@link publishCredentialRefresh} (LC-2345): a handoff
1922
+ * replaces the signed/unsigned credential, the issuer must equal version 1's exactly
1923
+ * (no signing-authority publication is possible for holder aggregates — it would
1924
+ * require the server to process plaintext), and `notify` replaces server-inferred
1925
+ * materiality, which requires comparing decrypted content across versions.
1926
+ */
1927
+ export const publishHolderEncryptedRefreshableCredential = async (
1928
+ params: PublishHolderEncryptedRefreshParams
1929
+ ): Promise<PublishCredentialRefreshResult> => {
1930
+ const { issuerProfile, input, domain, branding } = params;
1931
+ const { handoff, notify, updateSummary, updateReason, idempotencyKey } = input;
1932
+ const refreshId = handoff.refreshId;
1933
+
1934
+ const aggregate = await getCredentialRefresh(refreshId);
1935
+
1936
+ if (!aggregate) {
1937
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
1938
+ }
1939
+
1940
+ if (aggregate.issuerProfileId !== issuerProfile.profileId) {
1941
+ throw new TRPCError({
1942
+ code: 'UNAUTHORIZED',
1943
+ message: 'Profile did not allocate this credential refresh',
1944
+ });
1945
+ }
1946
+
1947
+ if (aggregate.encryptionMode !== 'holder') {
1303
1948
  throw new TRPCError({
1304
1949
  code: 'CONFLICT',
1305
- message: 'Credential refresh was updated concurrently; retry the publication',
1950
+ message:
1951
+ 'This credential refresh was not prepared for holder-mode encryption; use credentialRefresh.publishCredentialRefresh instead.',
1306
1952
  });
1307
1953
  }
1308
1954
 
1309
- if (advance.status === 'replay') {
1310
- const replayed = await getCredentialRefreshVersion(refreshId, advance.version);
1955
+ if (aggregate.inboxCredentialId) {
1956
+ throw new TRPCError({
1957
+ code: 'CONFLICT',
1958
+ message: 'Inbox refresh must be bound through its verified claim flow',
1959
+ });
1960
+ }
1311
1961
 
1312
- if (!replayed) {
1313
- throw new TRPCError({
1314
- code: 'CONFLICT',
1315
- message: 'Credential refresh replay could not be resolved; retry the publication',
1316
- });
1317
- }
1962
+ const displayBoost = aggregate.boostId ? await getBoostById(aggregate.boostId) : null;
1963
+ const credentialDisplayName = extractBoundedCredentialDisplayTitle(
1964
+ getPublicBoostDisplayName(displayBoost)
1965
+ );
1318
1966
 
1319
- return {
1967
+ if (aggregate.state === 'revoked') {
1968
+ throw new TRPCError({ code: 'CONFLICT', message: 'Credential refresh has been revoked' });
1969
+ }
1970
+
1971
+ const canonicalLifecycle = await getCredentialRefreshCanonicalLifecycle(refreshId);
1972
+
1973
+ if (canonicalLifecycle?.revoked) {
1974
+ throw new TRPCError({ code: 'CONFLICT', message: 'Credential refresh has been revoked' });
1975
+ }
1976
+
1977
+ if (idempotencyKey) {
1978
+ const replayed = await getCredentialRefreshVersionByIdempotencyKey(
1320
1979
  refreshId,
1321
- version: replayed.version,
1322
- publishedAt: replayed.publishedAt,
1323
- notification: await resolveCredentialRefreshReplayNotification({
1324
- version: replayed,
1325
- aggregate,
1326
- issuerProfile,
1327
- branding,
1328
- }),
1329
- };
1980
+ idempotencyKey
1981
+ );
1982
+
1983
+ if (replayed) {
1984
+ return {
1985
+ refreshId,
1986
+ version: replayed.version,
1987
+ publishedAt: replayed.publishedAt,
1988
+ notification: await resolveCredentialRefreshReplayNotification({
1989
+ version: replayed,
1990
+ aggregate,
1991
+ issuerProfile,
1992
+ branding,
1993
+ }),
1994
+ };
1995
+ }
1330
1996
  }
1331
1997
 
1332
- let deliveredNotification = notification;
1998
+ const head = await getCredentialRefreshHead(refreshId);
1333
1999
 
1334
- if (notification === 'queued') {
1335
- const persistedVersion = await getCredentialRefreshVersion(refreshId, advance.version);
2000
+ if (!head) {
2001
+ throw new TRPCError({
2002
+ code: 'CONFLICT',
2003
+ message: 'Credential refresh is not bound to a credential yet',
2004
+ });
2005
+ }
1336
2006
 
1337
- deliveredNotification =
1338
- persistedVersion && holderProfile
1339
- ? await deliverCredentialRefreshNotification({
1340
- version: persistedVersion,
1341
- issuerProfile,
1342
- holderProfile,
1343
- branding,
1344
- })
1345
- : 'delivery-failed';
2007
+ if (handoff.credentialId !== aggregate.credentialId) {
2008
+ throw new TRPCError({
2009
+ code: 'BAD_REQUEST',
2010
+ message: 'Credential ID does not match the allocated refresh',
2011
+ });
1346
2012
  }
1347
2013
 
1348
- return {
2014
+ if (handoff.holderDid !== aggregate.holderDid) {
2015
+ throw new TRPCError({
2016
+ code: 'BAD_REQUEST',
2017
+ message: 'Credential subject does not match the intended holder',
2018
+ });
2019
+ }
2020
+
2021
+ // Later versions must match the exact issuer identity version 1 bound — the same
2022
+ // invariant assertRefreshVersionInvariants enforces for network mode.
2023
+ if (handoff.issuerDid !== aggregate.issuerDid) {
2024
+ throw new TRPCError({
2025
+ code: 'BAD_REQUEST',
2026
+ message: 'Credential issuer does not match the allocated refresh',
2027
+ });
2028
+ }
2029
+
2030
+ if (
2031
+ !matchesCredentialStatusDigest(handoff.credentialStatus, aggregate.credentialStatusDigest)
2032
+ ) {
2033
+ throw new TRPCError({
2034
+ code: 'BAD_REQUEST',
2035
+ message: 'Credential status does not match the original credential',
2036
+ });
2037
+ }
2038
+
2039
+ if (
2040
+ aggregate.refreshServiceBinding &&
2041
+ canonicalizeCredentialJson(
2042
+ handoff.refreshService ?? {
2043
+ id: getCredentialRefreshServiceUrl(refreshId, domain),
2044
+ type: 'LearnCardCredentialRefresh2026',
2045
+ authorization: { type: 'LearnCardDIDAuth' },
2046
+ }
2047
+ ) !== canonicalizeCredentialJson(JSON.parse(aggregate.refreshServiceBinding))
2048
+ ) {
2049
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid refresh service binding' });
2050
+ }
2051
+
2052
+ await bindRefreshStatusEntries(
1349
2053
  refreshId,
1350
- version: advance.version,
1351
- publishedAt: advance.publishedAt ?? new Date().toISOString(),
1352
- notification: deliveredNotification,
1353
- };
2054
+ getBitstringStatusListEntries({ credentialStatus: handoff.credentialStatus }),
2055
+ aggregate.issuerProfileId,
2056
+ domain
2057
+ );
2058
+
2059
+ const holderProfile = aggregate.holderProfileId
2060
+ ? await getProfileByProfileId(aggregate.holderProfileId)
2061
+ : null;
2062
+
2063
+ if (!holderProfile) {
2064
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
2065
+ }
2066
+
2067
+ try {
2068
+ assertExactJweRecipients(
2069
+ handoff.jwe,
2070
+ await getHolderEncryptionRecipientKeyIds(holderProfile)
2071
+ );
2072
+ } catch {
2073
+ throw new TRPCError({ code: 'CONFLICT', message: 'RECIPIENTS_CHANGED' });
2074
+ }
2075
+
2076
+ const notification: PublishCredentialRefreshNotification =
2077
+ aggregate.state === 'active' ? (notify ? 'queued' : 'suppressed') : 'not-applicable';
2078
+ const notificationPendingAfterClaim =
2079
+ aggregate.state === 'awaiting_claim' &&
2080
+ (head.notificationPendingAfterClaim === true || notify === true);
2081
+ const notifiedHolderProfile =
2082
+ notification === 'queued' || notificationPendingAfterClaim ? holderProfile : null;
2083
+
2084
+ const encryptedCredential = JSON.stringify(handoff.jwe);
2085
+
2086
+ return advanceManagedRefreshVersion({
2087
+ refreshId,
2088
+ issuerProfile,
2089
+ aggregate,
2090
+ encryptedCredential,
2091
+ signingMode: 'issuer-signed',
2092
+ idempotencyKey,
2093
+ etag: computeRefreshEtag(encryptedCredential),
2094
+ updateSummary,
2095
+ updateReason,
2096
+ credentialDisplayName,
2097
+ notification,
2098
+ notificationPendingAfterClaim,
2099
+ holderProfile: notifiedHolderProfile,
2100
+ branding,
2101
+ });
2102
+ };
2103
+
2104
+ export type GetCurrentEncryptionRecipientsParams = {
2105
+ issuerProfile: ProfileType;
2106
+ refreshId: string;
2107
+ };
2108
+
2109
+ /** Issuer-only lookup backing `credentialRefresh.getEncryptionRecipients` (LC-2345). */
2110
+ export const getCurrentEncryptionRecipients = async (
2111
+ params: GetCurrentEncryptionRecipientsParams
2112
+ ): Promise<string[]> => {
2113
+ const { issuerProfile, refreshId } = params;
2114
+
2115
+ const aggregate = await getCredentialRefresh(refreshId);
2116
+
2117
+ if (!aggregate) {
2118
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Credential refresh not found' });
2119
+ }
2120
+
2121
+ if (aggregate.issuerProfileId !== issuerProfile.profileId) {
2122
+ throw new TRPCError({
2123
+ code: 'UNAUTHORIZED',
2124
+ message: 'Profile did not allocate this credential refresh',
2125
+ });
2126
+ }
2127
+
2128
+ if (aggregate.encryptionMode !== 'holder') {
2129
+ throw new TRPCError({
2130
+ code: 'CONFLICT',
2131
+ message: 'This credential refresh does not use holder-mode encryption',
2132
+ });
2133
+ }
2134
+
2135
+ const holderProfile = aggregate.holderProfileId
2136
+ ? await getProfileByProfileId(aggregate.holderProfileId)
2137
+ : null;
2138
+
2139
+ if (!holderProfile) {
2140
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Recipient profile not found' });
2141
+ }
2142
+
2143
+ return getHolderEncryptionRecipientKeyIds(holderProfile);
1354
2144
  };