@learncard/network-brain-service 4.1.3 → 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 +143 -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
@@ -0,0 +1,155 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Transaction } from 'neo4j-driver';
3
+ import type { BitstringStatusListEntry } from '@learncard/types';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ run: vi.fn(),
7
+ transaction: vi.fn(),
8
+ entries: vi.fn(),
9
+ setBit: vi.fn(),
10
+ }));
11
+ vi.mock('@instance', () => ({ neogma: { getTransaction: mocks.transaction } }));
12
+ vi.mock('./status-list.helpers', () => ({
13
+ CREDENTIAL_STATUS_ROOT_QUERY: 'MATCH (credential:Credential {id: $credentialId})',
14
+ getStatusEntriesForCredential: mocks.entries,
15
+ setStatusListEntryBit: mocks.setBit,
16
+ isNetworkStatusEntry: (entry: BitstringStatusListEntry) =>
17
+ new URL(entry.statusListCredential).hostname === 'network.example',
18
+ }));
19
+ import {
20
+ mutateCredentialStatus,
21
+ reconcileCredentialStatus,
22
+ } from './credential-status-mutation.helpers';
23
+
24
+ const entry: BitstringStatusListEntry = {
25
+ type: 'BitstringStatusListEntry',
26
+ statusPurpose: 'revocation',
27
+ statusListIndex: '7',
28
+ statusListCredential: 'https://network.example/status-lists/list',
29
+ };
30
+ const tx = { run: mocks.run } as unknown as Transaction;
31
+ const record = (values: Record<string, unknown>) => ({
32
+ get: (key: string): unknown => values[key] ?? null,
33
+ });
34
+
35
+ describe('credential lifecycle status mutations', () => {
36
+ beforeEach(() => {
37
+ vi.resetAllMocks();
38
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
39
+ mocks.transaction.mockImplementation(
40
+ async (_existing: unknown, callback: (transaction: Transaction) => Promise<unknown>) =>
41
+ callback(tx)
42
+ );
43
+ mocks.run.mockResolvedValue({
44
+ records: [record({ rootId: 'root', owner: 'status-owner' })],
45
+ });
46
+ mocks.entries.mockResolvedValue([entry]);
47
+ mocks.setBit.mockResolvedValue(true);
48
+ });
49
+ afterEach(() => {
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it.each([
54
+ { value: true, previous: null, next: 'suspended' },
55
+ { value: false, previous: 'suspended', next: null },
56
+ ])(
57
+ 'updates revocation-only relationship suspension value=$value',
58
+ async ({ value, previous, next }) => {
59
+ mocks.run.mockResolvedValue({
60
+ records: [record({ rootId: 'root', owner: 'status-owner', status: previous })],
61
+ });
62
+ await expect(
63
+ mutateCredentialStatus('version', 'recipient', 'suspension', value)
64
+ ).resolves.toBe(true);
65
+ expect(mocks.setBit).not.toHaveBeenCalled();
66
+ expect(console.warn).toHaveBeenCalledWith(expect.any(String), {
67
+ rootId: 'root',
68
+ purpose: 'suspension',
69
+ });
70
+ expect(mocks.run).toHaveBeenLastCalledWith(
71
+ expect.stringContaining('SET sent.status = $status'),
72
+ expect.objectContaining({ status: next })
73
+ );
74
+ }
75
+ );
76
+ it('revokes using the stored status owner before the sender', async () => {
77
+ await expect(
78
+ mutateCredentialStatus('version', 'recipient', 'revocation', true)
79
+ ).resolves.toBe(true);
80
+ expect(mocks.run.mock.calls[0]?.[0]).toContain(
81
+ 'coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner'
82
+ );
83
+ expect(mocks.setBit).toHaveBeenCalledWith(entry, true, 'status-owner', tx);
84
+ expect(mocks.run).toHaveBeenLastCalledWith(
85
+ expect.stringContaining('SET sent.status = $status'),
86
+ expect.objectContaining({ status: 'revoked' })
87
+ );
88
+ });
89
+ it.each(['revocation', 'suspension'] as const)(
90
+ 'fails before relationship writes when advertised %s bit fails',
91
+ async purpose => {
92
+ mocks.entries.mockResolvedValue([{ ...entry, statusPurpose: purpose }]);
93
+ mocks.run
94
+ .mockResolvedValueOnce({
95
+ records: [
96
+ record({
97
+ rootId: 'root',
98
+ owner: 'status-owner',
99
+ storedOwner: null,
100
+ managed: false,
101
+ }),
102
+ ],
103
+ })
104
+ .mockResolvedValueOnce({ records: [record({ status: null })] })
105
+ .mockResolvedValueOnce({ records: [record({ owner: 'status-owner' })] });
106
+ mocks.setBit.mockResolvedValue(false);
107
+ await expect(
108
+ mutateCredentialStatus('version', 'recipient', purpose, true)
109
+ ).rejects.toThrow('Public credential status could not be updated');
110
+ expect(mocks.run).toHaveBeenCalledTimes(3);
111
+ expect(mocks.run.mock.calls[2]).toEqual([
112
+ 'MATCH (list:BitstringStatusList {statusListCredential: $url}) RETURN list.ownerProfileId AS owner',
113
+ { url: entry.statusListCredential },
114
+ ]);
115
+ expect(mocks.setBit).toHaveBeenCalledWith(
116
+ { ...entry, statusPurpose: purpose },
117
+ true,
118
+ 'status-owner',
119
+ tx
120
+ );
121
+ for (const [query] of mocks.run.mock.calls) {
122
+ expect(query).not.toMatch(/SET sent\.|SET refresh\.state/);
123
+ }
124
+ }
125
+ );
126
+ it('reconciles using the stored status owner', async () => {
127
+ mocks.run.mockResolvedValueOnce({
128
+ records: [record({ owner: 'status-owner', status: 'revoked' })],
129
+ });
130
+ await reconcileCredentialStatus('root', tx);
131
+ expect(mocks.run.mock.calls[0]?.[0]).toContain(
132
+ 'coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner'
133
+ );
134
+ expect(mocks.setBit).toHaveBeenCalledWith(entry, true, 'status-owner', tx);
135
+ });
136
+ it.each(['revocation', 'suspension'] as const)(
137
+ 'rejects managed external %s before any writes',
138
+ async purpose => {
139
+ mocks.run.mockResolvedValue({ records: [record({ rootId: 'root', managed: true })] });
140
+ mocks.entries.mockResolvedValue([
141
+ {
142
+ ...entry,
143
+ statusPurpose: purpose,
144
+ statusListCredential: 'https://provider.example/list',
145
+ },
146
+ ]);
147
+ await expect(
148
+ mutateCredentialStatus('version', 'recipient', purpose, true)
149
+ ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
150
+ expect(mocks.run).toHaveBeenCalledTimes(1);
151
+ expect(mocks.run.mock.calls[0]?.[0]).not.toContain('SET ');
152
+ expect(mocks.setBit).not.toHaveBeenCalled();
153
+ }
154
+ );
155
+ });
@@ -0,0 +1,166 @@
1
+ import { TRPCError } from '@trpc/server';
2
+ import type { BitstringStatusListEntry, BitstringStatusPurpose } from '@learncard/types';
3
+ import type { Transaction } from 'neo4j-driver';
4
+
5
+ import { neogma } from '@instance';
6
+ import {
7
+ CREDENTIAL_STATUS_ROOT_QUERY,
8
+ getStatusEntriesForCredential,
9
+ isNetworkStatusEntry,
10
+ setStatusListEntryBit,
11
+ } from './status-list.helpers';
12
+
13
+ const assertNetworkManagedPurpose = (
14
+ entries: BitstringStatusListEntry[],
15
+ purpose: BitstringStatusPurpose
16
+ ): void => {
17
+ if (entries.some(entry => entry.statusPurpose === purpose && !isNetworkStatusEntry(entry))) {
18
+ throw new TRPCError({
19
+ code: 'PRECONDITION_FAILED',
20
+ message:
21
+ purpose === 'revocation'
22
+ ? "This credential's public revocation is managed outside LearnCard. Revoke it with your status-list provider."
23
+ : "This credential's public suspension is managed outside LearnCard. Update it with your status-list provider.",
24
+ });
25
+ }
26
+ };
27
+
28
+ /** Commit relationship state and signed public bits together, including all managed versions. */
29
+ export const mutateCredentialStatus = async (
30
+ credentialId: string,
31
+ profileId: string,
32
+ purpose: BitstringStatusPurpose,
33
+ value: boolean
34
+ ): Promise<boolean> =>
35
+ neogma.getTransaction(null, async tx => {
36
+ const result = await tx.run(
37
+ `${CREDENTIAL_STATUS_ROOT_QUERY}
38
+ MATCH (sender)-[sent:CREDENTIAL_SENT {to: $profileId}]->(root)
39
+ WHERE sender:Profile OR sender:AppStoreListing
40
+ WITH root, refresh, sent
41
+ OPTIONAL MATCH (issuer:Profile)-[:CREDENTIAL_SENT]->(root)
42
+ RETURN root.id AS rootId, sent.status AS status,
43
+ coalesce(refresh.issuerProfileId, root.statusOwnerProfileId) AS storedOwner,
44
+ refresh IS NOT NULL AS managed,
45
+ root.statusReservationMode AS reservationMode,
46
+ coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner`,
47
+ { credentialId, profileId, now: new Date().toISOString() }
48
+ );
49
+ if (result.records.length === 0) return false;
50
+ const row = result.records[0]!;
51
+ const rootId = String(row.get('rootId'));
52
+ if (row.get('managed')) {
53
+ assertNetworkManagedPurpose(await getStatusEntriesForCredential(rootId, tx), purpose);
54
+ }
55
+ const locked = await tx.run(
56
+ `MATCH (root:Credential {id: $rootId})
57
+ SET root._statusMutationLock = true REMOVE root._statusMutationLock
58
+ WITH root
59
+ OPTIONAL MATCH (refresh:CredentialRefresh)-[:ROOT]->(root)
60
+ FOREACH (_ IN CASE WHEN refresh IS NULL THEN [] ELSE [1] END |
61
+ SET refresh._statusBindingLock = true REMOVE refresh._statusBindingLock)
62
+ WITH root
63
+ MATCH ()-[sent:CREDENTIAL_SENT {to: $profileId}]->(root)
64
+ RETURN sent.status AS status`,
65
+ { rootId, profileId }
66
+ );
67
+ const allEntries = await getStatusEntriesForCredential(rootId, tx);
68
+ if (row.get('managed')) assertNetworkManagedPurpose(allEntries, purpose);
69
+ const status = locked.records[0]?.get('status');
70
+ if (purpose === 'suspension' && status === 'revoked') return false;
71
+ if (!value && status !== 'suspended') return false;
72
+ const entries = allEntries.filter(entry => entry.statusPurpose === purpose);
73
+ if (allEntries.length > 0 && entries.length === 0) {
74
+ console.warn(
75
+ '[mutateCredentialStatus] no public status entry for purpose; relationship-only update',
76
+ { rootId, purpose }
77
+ );
78
+ }
79
+ for (const entry of [...entries].sort((a, b) =>
80
+ a.statusListCredential.localeCompare(b.statusListCredential)
81
+ )) {
82
+ let owner = String(row.get('owner'));
83
+ const lenient = !row.get('managed') && row.get('reservationMode') === 'lenient';
84
+ const legacy = !row.get('managed') && !row.get('storedOwner');
85
+ if (lenient || legacy) {
86
+ const list = await tx.run(
87
+ 'MATCH (list:BitstringStatusList {statusListCredential: $url}) RETURN list.ownerProfileId AS owner',
88
+ { url: entry.statusListCredential }
89
+ );
90
+ const listOwner: unknown = list.records[0]?.get('owner');
91
+ if (typeof listOwner !== 'string' || (lenient && listOwner !== owner)) {
92
+ console.warn('[credential-status] RELATIONSHIP_ONLY_UNCONTROLLED_STATUS_LIST');
93
+ continue;
94
+ }
95
+ // Before stored owners existed, lifecycle updates used the matched list,
96
+ // including contract autoboosts delivered by a different sender.
97
+ if (legacy) owner = listOwner;
98
+ }
99
+ if (!(await setStatusListEntryBit(entry, value, owner, tx))) {
100
+ throw new TRPCError({
101
+ code: 'INTERNAL_SERVER_ERROR',
102
+ message: 'Public credential status could not be updated; retry the operation',
103
+ });
104
+ }
105
+ }
106
+ await tx.run(
107
+ `MATCH (root:Credential {id: $rootId})<-[sent:CREDENTIAL_SENT {to: $profileId}]-(sender)
108
+ WHERE sender:Profile OR sender:AppStoreListing
109
+ SET sent.status = $status
110
+ FOREACH (_ IN CASE WHEN $purpose = 'revocation' THEN [1] ELSE [] END |
111
+ SET sent.revokedAt = $now)
112
+ FOREACH (_ IN CASE WHEN $purpose = 'suspension' AND $value THEN [1] ELSE [] END |
113
+ SET sent.suspendedAt = $now)
114
+ FOREACH (_ IN CASE WHEN NOT $value THEN [1] ELSE [] END |
115
+ SET sent.unsuspendedAt = $now)
116
+ WITH root
117
+ OPTIONAL MATCH (refresh:CredentialRefresh)-[:ROOT]->(root)
118
+ FOREACH (_ IN CASE WHEN refresh IS NOT NULL AND $purpose = 'revocation' THEN [1] ELSE [] END |
119
+ SET refresh.state = 'revoked', refresh.updatedAt = $now)`,
120
+ {
121
+ rootId,
122
+ profileId,
123
+ purpose,
124
+ value,
125
+ status: purpose === 'revocation' ? 'revoked' : value ? 'suspended' : null,
126
+ now: new Date().toISOString(),
127
+ }
128
+ );
129
+ return true;
130
+ });
131
+
132
+ /** Reconcile historical lifecycle state while holding the same root lock as mutations. */
133
+ export const reconcileCredentialStatus = async (
134
+ rootId: string,
135
+ transaction: Transaction
136
+ ): Promise<void> => {
137
+ const result = await transaction.run(
138
+ `MATCH (root:Credential {id: $rootId})
139
+ SET root._statusMutationLock = true REMOVE root._statusMutationLock
140
+ WITH root
141
+ OPTIONAL MATCH (issuer:Profile)-[sent:CREDENTIAL_SENT]->(root)
142
+ OPTIONAL MATCH (refresh:CredentialRefresh)-[:ROOT]->(root)
143
+ RETURN coalesce(refresh.issuerProfileId, root.statusOwnerProfileId, issuer.profileId) AS owner,
144
+ sent.status AS status, refresh.state AS state`,
145
+ { rootId }
146
+ );
147
+ const row = result.records[0];
148
+ if (!row)
149
+ throw new TRPCError({ code: 'CONFLICT', message: 'Credential has no original delivery' });
150
+ const entries = await getStatusEntriesForCredential(rootId, transaction);
151
+ for (const entry of [...entries].sort((a, b) =>
152
+ a.statusListCredential.localeCompare(b.statusListCredential)
153
+ )) {
154
+ if (!isNetworkStatusEntry(entry)) continue;
155
+ const value =
156
+ entry.statusPurpose === 'revocation'
157
+ ? row.get('status') === 'revoked' || row.get('state') === 'revoked'
158
+ : row.get('status') === 'suspended';
159
+ if (!(await setStatusListEntryBit(entry, value, String(row.get('owner')), transaction))) {
160
+ throw new TRPCError({
161
+ code: 'INTERNAL_SERVER_ERROR',
162
+ message: 'Public credential status could not be updated; retry the operation',
163
+ });
164
+ }
165
+ }
166
+ };
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server';
3
3
  import { UnsignedVC, VC, JWE, LCNNotificationTypeEnumValidator } from '@learncard/types';
4
4
 
5
5
  import { storeCredential } from '@accesslayer/credential/create';
6
+ import { neogma } from '@instance';
6
7
  import type { IssuedCredential } from 'types/credential';
7
8
  import {
8
9
  createReceivedCredentialRelationship,
@@ -42,18 +43,25 @@ export const sendCredential = async (
42
43
  domain: string,
43
44
  metadata?: Record<string, unknown> | undefined,
44
45
  activityId?: string,
45
- integrationId?: string
46
+ integrationId?: string,
47
+ /** Local profile that owns any local status lists; defaults to the sender. */
48
+ statusOwnerProfileId: string = from.profileId
46
49
  ): Promise<string> => {
47
- const credentialInstance = await storeCredential(credential);
48
-
49
- await createSentCredentialRelationship(
50
- { type: 'profile', profile: from },
51
- to,
52
- credentialInstance,
53
- metadata,
54
- activityId,
55
- integrationId
56
- );
50
+ const credentialInstance = await neogma.getTransaction(null, async tx => {
51
+ const instance = await storeCredential(credential, tx, statusOwnerProfileId, {
52
+ lenient: true,
53
+ });
54
+ await createSentCredentialRelationship(
55
+ { type: 'profile', profile: from },
56
+ to,
57
+ instance,
58
+ metadata,
59
+ activityId,
60
+ integrationId,
61
+ tx
62
+ );
63
+ return instance;
64
+ });
57
65
 
58
66
  const uri = getCredentialUri(credentialInstance.id, domain);
59
67
 
@@ -67,7 +67,7 @@ const finalizeAcceptedDelivery = async (
67
67
  await transaction.rollback();
68
68
  return false;
69
69
  }
70
- const instance = await storeCredential(issued, transaction);
70
+ const instance = await storeCredential(issued, transaction, sender.profileId);
71
71
  if (boost) await createBoostInstanceOfRelationship(instance, boost, transaction);
72
72
  await createSentCredentialRelationship(
73
73
  { type: 'profile', profile: sender },
@@ -20,6 +20,9 @@ import { AUTH_GRANT_NO_ACCESS_SCOPE } from '../constants/auth-grant';
20
20
  import { ensureCredentialRefreshConstraints } from '../models/credential-refresh-constraints';
21
21
  import { getCredentialRefresh, generateRefreshId } from '@accesslayer/credential-refresh';
22
22
  import { getInboxCredentialById } from '@accesslayer/inbox-credential/read';
23
+ import { getBoostById } from '@accesslayer/boost/read';
24
+ import { getPublicBoostDisplayName } from './credential-display.helpers';
25
+ import { extractBoundedCredentialDisplayTitle } from './credential-refresh-email-content.helpers';
23
26
  import {
24
27
  advancePendingInboxRefresh,
25
28
  bindPendingInboxRefresh,
@@ -46,8 +49,10 @@ import {
46
49
  } from './credential-refresh.helpers';
47
50
  import {
48
51
  getCredentialRefreshDigestSecret,
52
+ getCredentialRefreshDigestCandidateSecrets,
49
53
  computeCredentialMaterialDigest,
50
54
  computeCredentialStatusDigest,
55
+ matchesCredentialStatusDigest,
51
56
  computeCredentialSubjectDigest,
52
57
  } from './credential-refresh-materiality.helpers';
53
58
  import { appendBitstringStatusListEntries } from './status-list.helpers';
@@ -67,8 +72,11 @@ export const assertInboxRefreshEnabled = async (scope?: string): Promise<void> =
67
72
  await ensureCredentialRefreshConstraints();
68
73
  };
69
74
 
70
- export const inboxRefreshRequestDigest = (request: unknown): string =>
71
- createHmac('sha256', getCredentialRefreshDigestSecret())
75
+ export const inboxRefreshRequestDigest = (
76
+ request: unknown,
77
+ secret: string = getCredentialRefreshDigestSecret()
78
+ ): string =>
79
+ createHmac('sha256', secret)
72
80
  .update('inbox-refresh-issue:v1\0')
73
81
  .update(canonicalizeCredentialJson(request))
74
82
  .digest('base64url');
@@ -76,14 +84,23 @@ export const inboxRefreshRequestDigest = (request: unknown): string =>
76
84
  export const getInboxRefreshIssueKey = (issuerProfileId: string, key: string): string =>
77
85
  `${issuerProfileId}:${key}`;
78
86
 
87
+ export const getInboxRefreshRequestDigestCandidates = (request: unknown): string[] =>
88
+ getCredentialRefreshDigestCandidateSecrets().map(secret =>
89
+ inboxRefreshRequestDigest(request, secret)
90
+ );
91
+
92
+ export const matchesInboxRefreshRequestDigest = (request: unknown, stored: string): boolean =>
93
+ getInboxRefreshRequestDigestCandidates(request).includes(stored);
94
+
79
95
  export const getInboxRefreshReplay = async (
80
96
  issuerProfileId: string,
81
97
  key: string | undefined,
82
- requestDigest: string
98
+ requestDigest: string,
99
+ requestDigestCandidates: string[] = [requestDigest]
83
100
  ) => {
84
101
  if (!key) return null;
85
102
  const existing = await getInboxRefreshIssue(getInboxRefreshIssueKey(issuerProfileId, key));
86
- if (existing && existing.requestDigest !== requestDigest)
103
+ if (existing && !requestDigestCandidates.includes(existing.requestDigest))
87
104
  throw new TRPCError({
88
105
  code: 'CONFLICT',
89
106
  message: 'This idempotencyKey was used for a different inbox issuance.',
@@ -220,7 +237,7 @@ export const publishPendingInboxRefresh = async (
220
237
  computeCredentialSubjectDigest(body.credentialSubject) !==
221
238
  computeCredentialSubjectDigest(previous.credentialSubject) ||
222
239
  body.boostId !== previous.boostId ||
223
- computeCredentialStatusDigest(body.credentialStatus) !== aggregate.credentialStatusDigest ||
240
+ !matchesCredentialStatusDigest(body.credentialStatus, aggregate.credentialStatusDigest) ||
224
241
  canonicalizeCredentialJson(body.refreshService) !==
225
242
  canonicalizeCredentialJson(previous.refreshService)
226
243
  )
@@ -237,14 +254,20 @@ export const publishPendingInboxRefresh = async (
237
254
  message: 'Credential effective time is older than the current version',
238
255
  });
239
256
  const prepared = injectManagedRefreshService(body, serviceFor(aggregate.refreshId, domain));
257
+ // Public label only: the anchor's explicit display name, never the VC's own name.
258
+ const displayBoost = aggregate.boostId ? await getBoostById(aggregate.boostId) : null;
259
+ const credentialName = extractBoundedCredentialDisplayTitle(
260
+ getPublicBoostDisplayName(displayBoost)
261
+ );
240
262
  const result = await advancePendingInboxRefresh({
241
263
  refreshId: aggregate.refreshId,
242
264
  expectedVersion: aggregate.currentVersion,
243
265
  credential: await encryptInboxCredential(JSON.stringify(prepared)),
244
- credentialName: typeof prepared.name === 'string' ? prepared.name : undefined,
266
+ ...(credentialName ? { credentialName } : {}),
245
267
  materialDigest: computeCredentialMaterialDigest(prepared),
246
268
  idempotencyKey: input.idempotencyKey,
247
269
  updateSummary: input.updateSummary,
270
+ updateReason: input.updateReason,
248
271
  });
249
272
  if (!result)
250
273
  throw new TRPCError({
@@ -328,8 +351,10 @@ export const finalizeInboxRefresh = async (params: {
328
351
  getCredentialIssuerId(credential) !== aggregate.issuerDid ||
329
352
  computeCredentialSubjectDigest(credential.credentialSubject) !==
330
353
  computeCredentialSubjectDigest(unsigned.credentialSubject) ||
331
- computeCredentialStatusDigest(credential.credentialStatus) !==
332
- aggregate.credentialStatusDigest ||
354
+ !matchesCredentialStatusDigest(
355
+ credential.credentialStatus,
356
+ aggregate.credentialStatusDigest
357
+ ) ||
333
358
  canonicalizeCredentialJson(credential.refreshService) !==
334
359
  canonicalizeCredentialJson(unsigned.refreshService) ||
335
360
  computeCredentialMaterialDigest(credential) !==
@@ -386,6 +411,7 @@ export const finalizeInboxRefresh = async (params: {
386
411
  ? {}
387
412
  : { effectiveAt: new Date(effectiveTime).toISOString() }),
388
413
  ...(aggregate.updateSummary ? { updateSummary: aggregate.updateSummary } : {}),
414
+ ...(aggregate.updateReason ? { updateReason: aggregate.updateReason } : {}),
389
415
  etag,
390
416
  },
391
417
  aggregate: {
@@ -207,6 +207,7 @@ export const issueToInbox = async (
207
207
  refresh?: boolean;
208
208
  idempotencyKey?: string;
209
209
  refreshRequestDigest?: string;
210
+ refreshRequestDigestCandidates?: string[];
210
211
  } = {},
211
212
  ctx: Context,
212
213
  /** Queue ownership checkpoint immediately before delivery or inbox persistence. */
@@ -484,6 +485,7 @@ export const issueToInbox = async (
484
485
  configuration.idempotencyKey ?? randomUUID()
485
486
  ),
486
487
  requestDigest: configuration.refreshRequestDigest!,
488
+ requestDigestCandidates: configuration.refreshRequestDigestCandidates,
487
489
  },
488
490
  }
489
491
  : {}),
@@ -231,6 +231,29 @@ export const createDagJweForRecipients = async <T>(
231
231
  return wallet.invoke.createDagJwe(cleartext, recipients);
232
232
  };
233
233
 
234
+ /**
235
+ * Resolves `did`'s key-agreement verification-method id — the exact `kid` DIDKit
236
+ * assigns that recipient in a DAG-JWE (see `createDagJweForRecipients`). Used to
237
+ * publish the expected holder-mode encryption recipient set (LC-2345) without ever
238
+ * performing the encryption itself.
239
+ */
240
+ export const getKeyAgreementKeyId = async (
241
+ did: string,
242
+ seed = environment.SEED
243
+ ): Promise<string> => {
244
+ const learnCard = await getLearnCard(seed);
245
+ const doc = await learnCard.invoke.resolveDid(did);
246
+ const [agreement] = doc.keyAgreement ?? [];
247
+
248
+ if (!agreement) {
249
+ throw new Error(`No key-agreement verification method found for DID: ${did}`);
250
+ }
251
+
252
+ const id = typeof agreement === 'string' ? agreement : agreement.id;
253
+
254
+ return id.startsWith('#') ? `${doc.id}${id}` : id;
255
+ };
256
+
234
257
  export const getServerDidWebDID = (): string => {
235
258
  const domainName = environment.DOMAIN_NAME;
236
259
  const isOffline = !!environment.IS_OFFLINE;
@@ -0,0 +1,101 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Transaction } from 'neo4j-driver';
3
+ import type { BitstringStatusListEntry } from '@learncard/types';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ run: vi.fn(),
7
+ transaction: vi.fn(),
8
+ allocate: vi.fn(),
9
+ validate: vi.fn(),
10
+ getRefresh: vi.fn(),
11
+ reconcile: vi.fn(),
12
+ }));
13
+ vi.mock('@instance', () => ({ neogma: { getTransaction: mocks.transaction } }));
14
+ vi.mock('./status-list.helpers', () => ({
15
+ allocateStatusListEntry: mocks.allocate,
16
+ validateStatusEntries: mocks.validate,
17
+ isNetworkStatusEntry: (entry: BitstringStatusListEntry) =>
18
+ new URL(entry.statusListCredential).hostname === 'network.example',
19
+ }));
20
+ vi.mock('@accesslayer/credential-refresh', () => ({ getCredentialRefresh: mocks.getRefresh }));
21
+ vi.mock('./credential-status-mutation.helpers', () => ({
22
+ reconcileCredentialStatus: mocks.reconcile,
23
+ }));
24
+ import { bindRefreshStatusEntries, prepareRefreshStatusEntries } from './managed-status.helpers';
25
+
26
+ const entry: BitstringStatusListEntry = {
27
+ type: 'BitstringStatusListEntry',
28
+ statusPurpose: 'revocation',
29
+ statusListIndex: '7',
30
+ statusListCredential: 'https://network.example/status-lists/list',
31
+ };
32
+ const tx = { run: mocks.run } as unknown as Transaction;
33
+ const record = (values: Record<string, unknown>) => ({
34
+ get: (key: string): unknown => values[key] ?? null,
35
+ });
36
+
37
+ describe('managed status binding', () => {
38
+ beforeEach(() => {
39
+ vi.resetAllMocks();
40
+ mocks.transaction.mockImplementation(
41
+ async (_existing: unknown, callback: (transaction: Transaction) => Promise<unknown>) =>
42
+ callback(tx)
43
+ );
44
+ mocks.getRefresh.mockResolvedValue(null);
45
+ mocks.allocate.mockResolvedValue(entry);
46
+ mocks.run.mockImplementation(async (query: string) => ({
47
+ records: query.includes('RETURN refresh.statusEntries AS entries')
48
+ ? [record({ rootId: 'root' })]
49
+ : [],
50
+ }));
51
+ });
52
+ it('returns after the locked read on a second matching bind', async () => {
53
+ await bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example');
54
+ expect(mocks.reconcile).toHaveBeenCalledWith('root', tx);
55
+ mocks.run.mockClear();
56
+ mocks.reconcile.mockClear();
57
+ mocks.run.mockResolvedValue({
58
+ records: [record({ entries: JSON.stringify([entry]), rootId: 'root' })],
59
+ });
60
+ await bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example');
61
+ expect(mocks.run).toHaveBeenCalledTimes(1);
62
+ expect(mocks.run.mock.calls[0]?.[0]).toContain('SET refresh._statusBindingLock = true');
63
+ expect(mocks.reconcile).not.toHaveBeenCalled();
64
+ });
65
+ it('rejects mismatching stored coordinates', async () => {
66
+ mocks.run.mockResolvedValue({
67
+ records: [
68
+ record({
69
+ entries: JSON.stringify([{ ...entry, statusListIndex: '8' }]),
70
+ rootId: 'root',
71
+ }),
72
+ ],
73
+ });
74
+ await expect(
75
+ bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example')
76
+ ).rejects.toThrow('Credential status does not match allocated entries');
77
+ expect(mocks.run).toHaveBeenCalledTimes(1);
78
+ expect(mocks.reconcile).not.toHaveBeenCalled();
79
+ });
80
+ it('skips the legacy scan only for freshly allocated entries', async () => {
81
+ await expect(
82
+ prepareRefreshStatusEntries('refresh', 'owner', 'network.example')
83
+ ).resolves.toEqual([entry]);
84
+ expect(mocks.run).not.toHaveBeenCalledWith(
85
+ expect.stringContaining('credential.statusEntries CONTAINS $url'),
86
+ expect.anything()
87
+ );
88
+ expect(mocks.run).toHaveBeenCalledWith(
89
+ expect.stringContaining('SET binding.refreshId = $refreshId'),
90
+ expect.anything()
91
+ );
92
+ expect(mocks.reconcile).toHaveBeenCalledWith('root', tx);
93
+ });
94
+ it('retains the legacy scan for pre-signed and repair entries', async () => {
95
+ await bindRefreshStatusEntries('refresh', [entry], 'owner', 'network.example', tx);
96
+ expect(mocks.run).toHaveBeenCalledWith(
97
+ expect.stringContaining('credential.statusEntries CONTAINS $url'),
98
+ { ownerProfileId: 'owner', refreshId: 'refresh', url: entry.statusListCredential }
99
+ );
100
+ });
101
+ });