@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
@@ -1,3 +1,4 @@
1
+ import * as notificationTransport from '@helpers/notification-webhook-fetch.helpers';
1
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
3
  import { createServer } from 'node:http';
3
4
  import * as runtime from '@environment';
@@ -122,6 +123,46 @@ describe('generic contract requests and correlated events', () => {
122
123
  } as unknown as Awaited<ReturnType<typeof learnCardHelpers.getDidWebLearnCard>>);
123
124
  };
124
125
 
126
+ it('delivers required-update alert copy without exposing the owner’s private profile fields', async () => {
127
+ const uri = await create();
128
+ await accept(uri);
129
+ await neogma.queryRunner.run(
130
+ `MATCH (owner:Profile {profileId:$profileId})
131
+ SET owner.email = 'owner-private@example.com', owner.dob = '1990-01-01',
132
+ owner.country = 'US', owner.notificationsWebhook = 'https://owner.example/private-hook'`,
133
+ { profileId: owner.profileId }
134
+ );
135
+ delivered = [];
136
+ const reason = 'We need transcripts to personalize your progress report.';
137
+ const result = await owner.clients.fullAuth.contracts.updateConsentFlowContract({
138
+ uri,
139
+ changes: {
140
+ contract: {
141
+ read: {
142
+ credentials: {
143
+ categories: {
144
+ ...normalContract.read.credentials.categories,
145
+ Transcript: { required: true },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ },
151
+ reason,
152
+ });
153
+ await flush(uri);
154
+ const notice = delivered.find(
155
+ notification =>
156
+ notification.type === 'CONSENT_FLOW_CONTRACT_UPDATED' &&
157
+ notification.to.profileId === learner.profileId
158
+ );
159
+ expect(notice?.message).toEqual({ title: result.preview.headline, body: reason });
160
+ expect(notice?.from.profileId).toBe(owner.profileId);
161
+ for (const key of ['email', 'dob', 'country', 'notificationsWebhook']) {
162
+ expect(notice!.from).not.toHaveProperty(key);
163
+ }
164
+ });
165
+
125
166
  it.each(['owner', 'writer', 'recipient'] as const)(
126
167
  'allows %s requests without granting writer data access',
127
168
  async role => {
@@ -859,12 +900,17 @@ describe('generic contract requests and correlated events', () => {
859
900
  const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
860
901
  uri: termsUri,
861
902
  });
862
- expect(history.records.filter(t => t.action === 'consent')).toHaveLength(2);
903
+ expect(
904
+ history.records.filter(transaction => transaction.action === 'consent')
905
+ ).toHaveLength(1);
906
+ expect(
907
+ history.records.filter(transaction => transaction.action === 'reconsent')
908
+ ).toHaveLength(1);
863
909
  expect(history.records.every(t => t.referral?.requestId === original.requestId)).toBe(true);
864
910
  expect(
865
911
  delivered.find(n => n.to.profileId === owner.profileId)?.data?.metadata
866
912
  ).toMatchObject({
867
- event: 'consent_created',
913
+ event: 'consent_updated',
868
914
  requestId: original.requestId,
869
915
  externalReferenceId: original.externalReferenceId,
870
916
  });
@@ -939,8 +985,8 @@ describe('generic contract requests and correlated events', () => {
939
985
  expect((await getContractTermsByUri(termsUri))!.terms.referral).toBeUndefined();
940
986
  expect(await status(uri)).toMatchObject({ status: 'pending' });
941
987
  delivered.length = 0;
942
- await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
943
- uri: termsUri,
988
+ await learner.clients.fullAuth.contracts.consentToContract({
989
+ contractUri: uri,
944
990
  terms: normalFullTerms,
945
991
  expiresAt: '',
946
992
  audienceVersion: 2,
@@ -1052,7 +1098,7 @@ describe('generic contract requests and correlated events', () => {
1052
1098
  const uri = await create([]);
1053
1099
  useDirectWebhook(webhook);
1054
1100
  const fetchSpy = vi
1055
- .spyOn(globalThis, 'fetch')
1101
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
1056
1102
  .mockResolvedValue(new Response('{}', { status: httpStatus ?? 200 }));
1057
1103
  await expect(accept(uri)).resolves.toMatchObject({ termsUri: expect.any(String) });
1058
1104
  const row = (await eventRows(uri))[0]!;
@@ -1075,7 +1121,7 @@ describe('generic contract requests and correlated events', () => {
1075
1121
  const uri = await create([]);
1076
1122
  useDirectWebhook('https://synthetic.example/events');
1077
1123
  const fetchSpy = vi
1078
- .spyOn(globalThis, 'fetch')
1124
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
1079
1125
  .mockResolvedValue(new Response('{"success":false}', { status: httpStatus }));
1080
1126
  await accept(uri);
1081
1127
  const row = (await eventRows(uri))[0]!;
@@ -1104,7 +1150,7 @@ describe('generic contract requests and correlated events', () => {
1104
1150
  vi.mocked(learnCardHelpers.getDidWebLearnCard).mockRejectedValue(
1105
1151
  new Error('synthetic signing outage')
1106
1152
  );
1107
- const fetchSpy = vi.spyOn(globalThis, 'fetch');
1153
+ const fetchSpy = vi.spyOn(notificationTransport, 'fetchNotificationWebhook');
1108
1154
  await accept(uri);
1109
1155
  const row = (await eventRows(uri))[0]!;
1110
1156
  expect(row.get('deliveries')[0].properties.state).toBe('pending');
@@ -1411,7 +1457,7 @@ describe('generic contract requests and correlated events', () => {
1411
1457
  const queued = structuredClone(delivered[0]!);
1412
1458
  useDirectWebhook('https://synthetic.example/events');
1413
1459
  const fetchSpy = vi
1414
- .spyOn(globalThis, 'fetch')
1460
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
1415
1461
  .mockResolvedValue(new Response('{}', { status: 403 }));
1416
1462
  await expect(deliverQueuedNotification(JSON.stringify(queued))).resolves.toBeUndefined();
1417
1463
  await expect(notifications.sendNotification(structuredClone(queued))).resolves.toBe(false);
@@ -1544,7 +1590,7 @@ describe('generic contract requests and correlated events', () => {
1544
1590
  vi.spyOn(runtime, 'getNotificationRuntimeEnvironment').mockReturnValue({
1545
1591
  ...runtime.getNotificationRuntimeEnvironment(),
1546
1592
  NODE_ENV: 'development',
1547
- IS_OFFLINE: false,
1593
+ IS_OFFLINE: true,
1548
1594
  NOTIFICATIONS_QUEUE_URL: undefined,
1549
1595
  IS_E2E_TEST: false,
1550
1596
  NOTIFICATIONS_SERVICE_WEBHOOK_URL: `http://127.0.0.1:${address.port}/events`,
@@ -7,7 +7,14 @@ import {
7
7
  UnsignedVC,
8
8
  } from '@learncard/types';
9
9
 
10
- import { Profile, Credential, Boost } from '@models';
10
+ import {
11
+ Profile,
12
+ Credential,
13
+ Boost,
14
+ ConsentFlowContract,
15
+ ConsentFlowTerms,
16
+ ConsentFlowTransaction,
17
+ } from '@models';
11
18
  import { neogma } from '@instance';
12
19
 
13
20
  import { getClient, getUser } from './helpers/getClient';
@@ -18,6 +25,12 @@ import { getCredentialByUri } from '@accesslayer/credential/read';
18
25
  import { getCredentialRefresh, getCredentialRefreshHead } from '@accesslayer/credential-refresh';
19
26
  import { getDidWeb } from '@helpers/did.helpers';
20
27
  import { testUnsignedBoost } from './helpers/send';
28
+ import { normalContract, normalFullTerms } from './helpers/contract';
29
+ import {
30
+ getContractDetailsByUri,
31
+ getContractTermsForProfile,
32
+ } from '@accesslayer/consentflowcontract/relationships/read';
33
+ import { sendRefreshableCredential } from '@helpers/credential-refresh.helpers';
21
34
 
22
35
  const noAuthClient = getClient();
23
36
 
@@ -107,6 +120,60 @@ const allocateAndSign = async (): Promise<{ allocation: AllocationResult; creden
107
120
  };
108
121
 
109
122
  describe('Credential Refresh Allocation', () => {
123
+ it('returns a content-free receipt to the pending holder, never issuer or outsider', async () => {
124
+ const { allocation, credential } = await allocateAndSign();
125
+ const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
126
+ refreshId: allocation.refreshId,
127
+ credential,
128
+ });
129
+ expect((await getCredentialRefresh(allocation.refreshId))?.state).toBe('awaiting_claim');
130
+ const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
131
+ expect(receipt).toEqual({
132
+ refreshId: allocation.refreshId,
133
+ credentialId: credential.id,
134
+ issuerDid: issuer.learnCard.id.did(),
135
+ holderDid: holder.learnCard.id.did(),
136
+ refreshService: allocation.refreshService,
137
+ encryptionMode: 'network',
138
+ });
139
+ await expect(
140
+ issuer.clients.fullAuth.credentialRefresh.getReceipt({ uri })
141
+ ).rejects.toThrow();
142
+ await expect(
143
+ outsider.clients.fullAuth.credentialRefresh.getReceipt({ uri })
144
+ ).rejects.toThrow();
145
+ await expect(noAuthClient.credentialRefresh.getReceipt({ uri })).rejects.toThrow();
146
+ await expect(
147
+ holder.clients.fullAuth.credentialRefresh.getReceipt({
148
+ uri: uri.replace('localhost%3A3000', 'foreign.example'),
149
+ })
150
+ ).rejects.toThrow();
151
+ });
152
+
153
+ it('does not authorize a receipt after the pending delivery edge is removed', async () => {
154
+ const { allocation, credential } = await allocateAndSign();
155
+ const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
156
+ refreshId: allocation.refreshId,
157
+ credential,
158
+ });
159
+ await runQuery('MATCH ()-[sent:CREDENTIAL_SENT]->() DELETE sent');
160
+ await expect(
161
+ holder.clients.fullAuth.credentialRefresh.getReceipt({ uri })
162
+ ).rejects.toThrow();
163
+ });
164
+
165
+ it('fails closed when a legacy delivery lacks its exact public status binding', async () => {
166
+ const { allocation, credential } = await allocateAndSign();
167
+ const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
168
+ refreshId: allocation.refreshId,
169
+ credential,
170
+ });
171
+ await runQuery('MATCH (r:CredentialRefresh) REMOVE r.credentialStatusBinding');
172
+ await expect(holder.clients.fullAuth.credentialRefresh.getReceipt({ uri })).rejects.toThrow(
173
+ 'Delivery binding unavailable'
174
+ );
175
+ });
176
+
110
177
  beforeAll(async () => {
111
178
  process.env.CREDENTIAL_REFRESH_ENABLED = 'true';
112
179
 
@@ -136,6 +203,9 @@ describe('Credential Refresh Allocation', () => {
136
203
  });
137
204
 
138
205
  beforeEach(async () => {
206
+ await ConsentFlowTransaction.delete({ detach: true, where: {} });
207
+ await ConsentFlowTerms.delete({ detach: true, where: {} });
208
+ await ConsentFlowContract.delete({ detach: true, where: {} });
139
209
  await runQuery('MATCH (r:CredentialRefresh) DETACH DELETE r');
140
210
  await runQuery('MATCH (c:Credential) DETACH DELETE c');
141
211
  await Boost.delete({ detach: true, where: {} });
@@ -342,6 +412,67 @@ describe('Credential Refresh Allocation', () => {
342
412
  expect(await countCredentialNodes()).toEqual(0);
343
413
  expect(await countSentRelationships()).toEqual(0);
344
414
  });
415
+ it('rejects a captured consent snapshot when the contract becomes pending before root binding', async () => {
416
+ const contractUri = await issuer.clients.fullAuth.contracts.createConsentFlowContract({
417
+ contract: normalContract,
418
+ name: 'Refresh consent contract',
419
+ });
420
+ await holder.clients.fullAuth.contracts.consentToContract({
421
+ contractUri,
422
+ terms: normalFullTerms,
423
+ });
424
+ const holderProfile = await holder.clients.fullAuth.profile.getProfile();
425
+ const issuerProfile = await issuer.clients.fullAuth.profile.getProfile();
426
+ const contract = await getContractDetailsByUri(contractUri);
427
+ const capturedTerms = await getContractTermsForProfile(
428
+ holderProfile!,
429
+ contract!.contract
430
+ );
431
+ expect(capturedTerms).toBeDefined();
432
+
433
+ const { allocation, credential } = await allocateAndSign();
434
+ const changedContract = {
435
+ ...normalContract,
436
+ read: {
437
+ ...normalContract.read,
438
+ credentials: {
439
+ ...normalContract.read.credentials,
440
+ categories: {
441
+ ...normalContract.read.credentials.categories,
442
+ Transcript: { required: true },
443
+ },
444
+ },
445
+ },
446
+ };
447
+ await issuer.clients.fullAuth.contracts.updateConsentFlowContract({
448
+ uri: contractUri,
449
+ changes: { contract: changedContract },
450
+ reason: 'We need transcripts to personalize your progress report.',
451
+ });
452
+
453
+ await expect(
454
+ sendRefreshableCredential({
455
+ issuerProfile: { ...issuerProfile!, did: issuer.learnCard.id.did() },
456
+ refreshId: allocation.refreshId,
457
+ credential,
458
+ contractTerms: capturedTerms!,
459
+ domain: DOMAIN,
460
+ })
461
+ ).rejects.toMatchObject({
462
+ code: 'FORBIDDEN',
463
+ message: 'User has not yet approved the latest version of this contract',
464
+ });
465
+ expect(await countCredentialNodes()).toBe(0);
466
+ expect(await countSentRelationships()).toBe(0);
467
+ expect(
468
+ (
469
+ await holder.clients.fullAuth.contracts.getConsentStatus({
470
+ uri: contractUri,
471
+ profileId: HOLDER_PROFILE_ID,
472
+ })
473
+ ).status
474
+ ).toBe('needs_reconsent');
475
+ });
345
476
 
346
477
  it('stores only the holder-encrypted JWE and binds version 1', async () => {
347
478
  const { allocation, credential } = await allocateAndSign();
@@ -642,24 +773,91 @@ describe('Credential Refresh Allocation', () => {
642
773
  expect(associations.records[0]?.get('boostIds')).toHaveLength(1);
643
774
  });
644
775
 
645
- it('removes the losing encrypted node from concurrent identical initial sends', async () => {
776
+ const synchronizeInitialRootReads = () => {
777
+ const originalRun = neogma.queryRunner.run.bind(neogma.queryRunner);
778
+ let arrivals = 0;
779
+ let release!: () => void;
780
+ const bothReadsReady = new Promise<void>(resolve => {
781
+ release = resolve;
782
+ });
783
+
784
+ return vi.spyOn(neogma.queryRunner, 'run').mockImplementation(async (query, params) => {
785
+ const isInitialRootRead =
786
+ params !== undefined &&
787
+ Object.keys(params).length === 1 &&
788
+ 'refreshId' in params;
789
+ const result = await originalRun(query, params);
790
+
791
+ if (isInitialRootRead && result.records.length === 0 && arrivals < 2) {
792
+ arrivals += 1;
793
+ if (arrivals === 2) release();
794
+ await bothReadsReady;
795
+ }
796
+
797
+ return result;
798
+ });
799
+ };
800
+
801
+ it('returns the shared result for concurrent identical initial sends', async () => {
646
802
  const { allocation, credential } = await allocateAndSign();
803
+ const queryRunnerSpy = synchronizeInitialRootReads();
647
804
 
648
- const uris = await Promise.all([
649
- issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
650
- refreshId: allocation.refreshId,
651
- credential,
652
- }),
653
- issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
654
- refreshId: allocation.refreshId,
655
- credential,
656
- }),
805
+ try {
806
+ const results = await Promise.all([
807
+ issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
808
+ refreshId: allocation.refreshId,
809
+ credential,
810
+ }),
811
+ issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
812
+ refreshId: allocation.refreshId,
813
+ credential,
814
+ }),
815
+ ]);
816
+
817
+ expect(results[0]).toEqual(results[1]);
818
+ expect(await countCredentialNodes()).toEqual(1);
819
+ expect(await countSentRelationships()).toEqual(1);
820
+ expect(addNotificationToQueueSpy).toHaveBeenCalledTimes(1);
821
+ } finally {
822
+ queryRunnerSpy.mockRestore();
823
+ }
824
+ });
825
+
826
+ it('rejects concurrent initial sends with different credential material', async () => {
827
+ const allocation = await allocate();
828
+ const [credential, differentCredential] = await Promise.all([
829
+ signAs(issuer, buildUnsignedCredential(allocation)),
830
+ signAs(
831
+ issuer,
832
+ buildUnsignedCredential(allocation, { name: 'Different Transcript' })
833
+ ),
657
834
  ]);
835
+ const queryRunnerSpy = synchronizeInitialRootReads();
658
836
 
659
- expect(new Set(uris).size).toEqual(1);
660
- expect(await countCredentialNodes()).toEqual(1);
837
+ try {
838
+ const results = await Promise.allSettled([
839
+ issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
840
+ refreshId: allocation.refreshId,
841
+ credential,
842
+ }),
843
+ issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
844
+ refreshId: allocation.refreshId,
845
+ credential: differentCredential,
846
+ }),
847
+ ]);
848
+
849
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
850
+ expect(results.filter(result => result.status === 'rejected')).toHaveLength(1);
851
+ expect(results.find(result => result.status === 'rejected')).toMatchObject({
852
+ reason: { code: 'CONFLICT' },
853
+ });
854
+ expect(await countCredentialNodes()).toEqual(1);
855
+ expect(await countSentRelationships()).toEqual(1);
856
+ expect(addNotificationToQueueSpy).toHaveBeenCalledTimes(1);
857
+ } finally {
858
+ queryRunnerSpy.mockRestore();
859
+ }
661
860
  });
662
-
663
861
  it('rejects an initial-send retry whose credential material differs', async () => {
664
862
  const { allocation, credential } = await allocateAndSign();
665
863
 
@@ -251,7 +251,7 @@ describe('credential refresh update emails', () => {
251
251
  expect(emailDeliveries()[0].contactMethod.value).toBe(HOLDER_VERIFIED_EMAIL);
252
252
  });
253
253
 
254
- it('emails the holder verified email with only issuer name and title', async () => {
254
+ it('emails the holder verified email without credential-derived titles', async () => {
255
255
  await addEmail(HOLDER_PROFILE_ID, HOLDER_VERIFIED_EMAIL, { primary: true });
256
256
  await addEmail(HOLDER_PROFILE_ID, HOLDER_UNVERIFIED_EMAIL, { verified: false });
257
257
 
@@ -298,12 +298,12 @@ describe('credential refresh update emails', () => {
298
298
  expect(delivery.templateId).toEqual('credential-updated');
299
299
  expect(delivery.templateModel).toEqual({
300
300
  issuer: { name: expect.any(String) },
301
- credential: { name: SECRET_TITLE },
302
301
  });
303
302
 
304
303
  // No grades, description/body content, or issuer summary may reach the template.
305
304
  const serialized = JSON.stringify(delivery);
306
305
  expect(serialized).not.toContain(SECRET_GRADE_MARKER);
306
+ expect(serialized).not.toContain(SECRET_TITLE);
307
307
  expect(serialized).not.toContain(SECRET_SUBJECT_MARKER);
308
308
  expect(serialized).not.toContain(SECRET_SUMMARY_MARKER);
309
309
  expect(serialized).not.toContain(HOLDER_UNVERIFIED_EMAIL);
@@ -382,9 +382,7 @@ describe('credential refresh update emails', () => {
382
382
  });
383
383
 
384
384
  expect(emailDeliveries()).toHaveLength(1);
385
- expect(emailDeliveries()[0]!.templateModel).toMatchObject({
386
- credential: { name: SECRET_TITLE },
387
- });
385
+ expect(emailDeliveries()[0]!.templateModel).not.toHaveProperty('credential');
388
386
 
389
387
  // A replayed accept does not email twice.
390
388
  await holder.clients.fullAuth.credential.acceptCredential({ uri });
@@ -616,9 +614,7 @@ describe('credential refresh update emails', () => {
616
614
  expect(JSON.stringify(refreshEvents[0])).not.toContain(SECRET_TITLE);
617
615
 
618
616
  // Email still carries the title.
619
- expect(emailDeliveries()[0]!.templateModel).toMatchObject({
620
- credential: { name: SECRET_TITLE },
621
- });
617
+ expect(emailDeliveries()[0]!.templateModel).toMatchObject({});
622
618
  });
623
619
 
624
620
  it('renders generic copy and persists no title when the publication has none', async () => {
@@ -657,7 +653,7 @@ describe('credential refresh update emails', () => {
657
653
  const aggregate = await getCredentialRefresh(allocation.refreshId);
658
654
  const head = await getCredentialRefreshHead(allocation.refreshId);
659
655
 
660
- expect(aggregate?.credentialDisplayName).toEqual(SECRET_TITLE);
661
- expect(head?.credentialDisplayName).toEqual(SECRET_TITLE);
656
+ expect(aggregate?.credentialDisplayName).toBeUndefined();
657
+ expect(head?.credentialDisplayName).toBeUndefined();
662
658
  });
663
659
  });
@@ -588,18 +588,21 @@ describe('Credential Refresh Endpoint', () => {
588
588
  const first = await preAuthSourceLimitedApp.inject({
589
589
  method: 'GET',
590
590
  url: '/refresh/rate-limited-source-a',
591
+ remoteAddress: '192.0.2.234',
591
592
  });
592
593
  expect(first.statusCode).toBe(401);
593
594
 
594
595
  const second = await preAuthSourceLimitedApp.inject({
595
596
  method: 'GET',
596
597
  url: '/refresh/rate-limited-source-b',
598
+ remoteAddress: '192.0.2.234',
597
599
  });
598
600
  expect(second.statusCode).toBe(401);
599
601
 
600
602
  const limited = await preAuthSourceLimitedApp.inject({
601
603
  method: 'GET',
602
604
  url: '/refresh/rate-limited-source-c',
605
+ remoteAddress: '192.0.2.234',
603
606
  });
604
607
  expect(limited.statusCode).toBe(429);
605
608
  expect(limited.json().jwe).toBeUndefined();
@@ -16,6 +16,7 @@ import { getClient, getUser } from './helpers/getClient';
16
16
  import { addNotificationToQueueSpy } from './helpers/spies';
17
17
  import * as Notifications from '@helpers/notifications.helpers';
18
18
  import { getLearnCard, SeedLearnCard } from '@helpers/learnCard.helpers';
19
+ import { computeCredentialMaterialDigest } from '@helpers/credential-refresh-materiality.helpers';
19
20
  import {
20
21
  getCredentialRefresh,
21
22
  getCredentialRefreshHead,
@@ -634,6 +635,45 @@ describe('Credential Refresh Publication', () => {
634
635
  expect(await countCredentialNodes()).toEqual(1);
635
636
  });
636
637
 
638
+ it('retries initial binding and suppresses unchanged publication after digest key rotation', async () => {
639
+ try {
640
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'old-publication-test-key');
641
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v1');
642
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', '{}');
643
+ const { allocation, credential } = await sendOriginal();
644
+ await setCredentialRefreshState(allocation.refreshId, 'active');
645
+
646
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'new-publication-test-key');
647
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v2');
648
+ vi.stubEnv(
649
+ 'CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS',
650
+ JSON.stringify({
651
+ v1: 'old-publication-test-key',
652
+ })
653
+ );
654
+ await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
655
+ refreshId: allocation.refreshId,
656
+ credential,
657
+ });
658
+ expect(await countCredentialNodes()).toEqual(1);
659
+
660
+ const timestampOnly = await signAs(
661
+ issuer,
662
+ buildUnsignedCredential(allocation, { validFrom: '2026-02-01T00:00:00Z' })
663
+ );
664
+ addNotificationToQueueSpy.mockClear();
665
+ const result = await publishIssuerSigned(allocation.refreshId, timestampOnly);
666
+ expect(result.version).toEqual(2);
667
+ expect(result.notification).toEqual('suppressed');
668
+ expect(addNotificationToQueueSpy).not.toHaveBeenCalled();
669
+ expect((await getCredentialRefresh(allocation.refreshId))?.materialDigest).toBe(
670
+ computeCredentialMaterialDigest(timestampOnly as Record<string, unknown>)
671
+ );
672
+ } finally {
673
+ vi.unstubAllEnvs();
674
+ }
675
+ });
676
+
637
677
  it('suppresses the first update when only non-material timestamps changed', async () => {
638
678
  const { allocation } = await sendOriginal();
639
679
  await setCredentialRefreshState(allocation.refreshId, 'active');
@@ -649,13 +689,11 @@ describe('Credential Refresh Publication', () => {
649
689
  });
650
690
 
651
691
  it('rejects removal or replacement of the original credentialStatus descriptor', async () => {
652
- const credentialStatus = {
653
- id: 'https://status.example.com/lists/42#7',
654
- type: 'BitstringStatusListEntry',
655
- statusPurpose: 'revocation',
656
- statusListIndex: '7',
657
- statusListCredential: 'https://status.example.com/lists/42',
658
- };
692
+ const [credentialStatus] = await issuer.clients.fullAuth.boost.allocateCredentialStatus(
693
+ {
694
+ statusPurposes: ['revocation'],
695
+ }
696
+ );
659
697
  const { allocation } = await sendOriginal({ credentialStatus });
660
698
 
661
699
  const removed = await signAs(
@@ -984,13 +1022,12 @@ describe('Credential Refresh Publication', () => {
984
1022
  });
985
1023
 
986
1024
  it('preserves an existing credentialStatus and allocates no new status-list entry', async () => {
987
- const credentialStatus = {
988
- id: 'https://status.example.com/lists/42#7',
989
- type: 'BitstringStatusListEntry',
990
- statusPurpose: 'revocation',
991
- statusListIndex: '7',
992
- statusListCredential: 'https://status.example.com/lists/42',
993
- };
1025
+ const [credentialStatus] = await issuer.clients.fullAuth.boost.allocateCredentialStatus(
1026
+ {
1027
+ statusPurposes: ['revocation'],
1028
+ }
1029
+ );
1030
+ const initialListCount = await countStatusLists();
994
1031
  const { allocation } = await sendOriginal({ credentialStatus });
995
1032
  await registerIssuerSigningAuthority();
996
1033
 
@@ -1005,7 +1042,7 @@ describe('Credential Refresh Publication', () => {
1005
1042
  );
1006
1043
 
1007
1044
  // No status list was created or allocated for the refresh version.
1008
- expect(await countStatusLists()).toEqual(0);
1045
+ expect(await countStatusLists()).toEqual(initialListCount);
1009
1046
  });
1010
1047
 
1011
1048
  it('does not add a credentialStatus when the body has none', async () => {
@@ -43,16 +43,17 @@ describe('credential status storage in Neo4j', () => {
43
43
  credentialStatus: entry,
44
44
  });
45
45
  const jwe = await issuer.learnCard.invoke.createDagJwe(signed, [issuer.learnCard.id.did()]);
46
- const stored = await storeCredential({
47
- kind: 'issued-credential',
48
- credential: jwe,
49
- statusEntries: [entry],
50
- });
46
+ const stored = await storeCredential(
47
+ { kind: 'issued-credential', credential: jwe, statusEntries: [entry] },
48
+ undefined,
49
+ 'storage-issuer'
50
+ );
51
51
  credentialIds.push(stored.id);
52
52
 
53
53
  const row = await getCredentialById(stored.id);
54
54
  expect(row?.credential).toBe(JSON.stringify(jwe));
55
55
  expect(JSON.parse(row?.statusEntries ?? 'null')).toEqual([entry]);
56
+ expect(row?.statusOwnerProfileId).toBe('storage-issuer');
56
57
  expect(await isStatusBitSet(entry)).toBe(false);
57
58
  expect(await setCredentialBitstringStatus(stored.id, 'revocation', true)).toBe(true);
58
59
  expect(await isStatusBitSet(entry)).toBe(true);
@@ -94,12 +95,18 @@ describe('credential status storage in Neo4j', () => {
94
95
  credentialStatus: entry,
95
96
  });
96
97
  const jwe = await issuer.learnCard.invoke.createDagJwe(signed, [issuer.learnCard.id.did()]);
97
- const wire = { ...jwe, kind: 'issued-credential', statusEntries: [entry] };
98
+ const wire = {
99
+ ...jwe,
100
+ kind: 'issued-credential',
101
+ statusEntries: [entry],
102
+ statusOwnerProfileId: 'storage-issuer',
103
+ };
98
104
  const stored = await storeCredential(wire);
99
105
  credentialIds.push(stored.id);
100
106
  const row = await getCredentialById(stored.id);
101
107
  expect(row?.credential).toBe(JSON.stringify(wire));
102
108
  expect(row?.statusEntries).toBeUndefined();
109
+ expect(row?.statusOwnerProfileId).toBeUndefined();
103
110
  expect(await setCredentialBitstringStatus(stored.id, 'revocation', true)).toBe(false);
104
111
  expect(await isStatusBitSet(entry)).toBe(false);
105
112
  });
@@ -119,4 +126,24 @@ describe('credential status storage in Neo4j', () => {
119
126
  expect(row?.statusEntries).toBeUndefined();
120
127
  expect(await setCredentialBitstringStatus(stored.id, 'revocation', true)).toBe(false);
121
128
  });
129
+
130
+ it('rejects a trusted descriptor when the supplied owner does not own its list', async () => {
131
+ const [entry] = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
132
+ statusPurposes: ['revocation'],
133
+ });
134
+ if (!entry) throw new Error('Expected allocated revocation entry');
135
+ const signed = await issuer.learnCard.invoke.issueCredential({
136
+ ...(await issuer.learnCard.invoke.getTestVc()),
137
+ credentialStatus: entry,
138
+ });
139
+ const jwe = await issuer.learnCard.invoke.createDagJwe(signed, [issuer.learnCard.id.did()]);
140
+ await expect(
141
+ storeCredential(
142
+ { kind: 'issued-credential', credential: jwe, statusEntries: [entry] },
143
+ undefined,
144
+ 'another-issuer'
145
+ )
146
+ ).rejects.toThrow('Invalid local credential status coordinates');
147
+ expect(await isStatusBitSet(entry)).toBe(false);
148
+ });
122
149
  });