@learncard/network-brain-service 4.1.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +164 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
@@ -1,3 +1,4 @@
1
+ import * as notificationTransport from '@helpers/notification-webhook-fetch.helpers';
1
2
  import type { Context, SQSBatchResponse, SQSEvent, SQSRecord } from 'aws-lambda';
2
3
  import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
 
@@ -93,12 +94,7 @@ const invokeWorker = async (...records: SQSRecord[]): Promise<SQSBatchResponse>
93
94
  return result as SQSBatchResponse;
94
95
  };
95
96
 
96
- const fetchResponse = (stored: boolean): Response =>
97
- ({
98
- ok: true,
99
- status: 200,
100
- text: async () => JSON.stringify(stored),
101
- } as Response);
97
+ const fetchResponse = (stored: boolean): Response => new Response(JSON.stringify(stored));
102
98
 
103
99
  const getSenderDeliveryState = async (): Promise<{
104
100
  delivered: boolean;
@@ -139,18 +135,20 @@ describe('notifications SQS worker', () => {
139
135
  profileB = (await getProfileByProfileId('userb'))!;
140
136
  vi.spyOn(LearnCardHelpers, 'getDidWebLearnCard').mockResolvedValue({
141
137
  invoke: { getDidAuthVp: async () => 'test-did-auth-vp' },
142
- } as any);
138
+ } as unknown as Awaited<ReturnType<typeof LearnCardHelpers.getDidWebLearnCard>>);
143
139
  });
144
140
 
145
141
  it('returns independent batch failures for downstream false and transport errors', async () => {
146
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
147
- const body = JSON.parse(String(init?.body)) as LCNNotification;
142
+ const fetchSpy = vi
143
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
144
+ .mockImplementation(async (_url, init) => {
145
+ const body = JSON.parse(String(init?.body)) as LCNNotification;
148
146
 
149
- if (body.message?.title === 'not-stored') return fetchResponse(false);
150
- if (body.message?.title === 'transport-error') throw new Error('network failure');
147
+ if (body.message?.title === 'not-stored') return fetchResponse(false);
148
+ if (body.message?.title === 'transport-error') throw new Error('network failure');
151
149
 
152
- return fetchResponse(true);
153
- });
150
+ return fetchResponse(true);
151
+ });
154
152
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
155
153
 
156
154
  try {
@@ -191,7 +189,9 @@ describe('notifications SQS worker', () => {
191
189
  attemptedAt: new Date().toISOString(),
192
190
  }
193
191
  );
194
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(fetchResponse(true));
192
+ const fetchSpy = vi
193
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
194
+ .mockResolvedValue(fetchResponse(true));
195
195
 
196
196
  try {
197
197
  await expect(
@@ -222,11 +222,13 @@ describe('notifications SQS worker', () => {
222
222
  sender: profileA,
223
223
  triggerId: 'credential:worker-unknown-ack',
224
224
  });
225
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
226
- ok: true,
227
- status: 200,
228
- text: async () => JSON.stringify({ status: 'ok' }),
229
- } as Response);
225
+ const fetchSpy = vi
226
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
227
+ .mockResolvedValue({
228
+ ok: true,
229
+ status: 200,
230
+ text: async () => JSON.stringify({ status: 'ok' }),
231
+ } as Response);
230
232
  const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
231
233
 
232
234
  try {
@@ -251,11 +253,13 @@ describe('notifications SQS worker', () => {
251
253
  });
252
254
 
253
255
  it('preserves compatible unrecognized 2xx handling for legacy queue records', async () => {
254
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
255
- ok: true,
256
- status: 200,
257
- text: async () => JSON.stringify({ status: 'ok' }),
258
- } as Response);
256
+ const fetchSpy = vi
257
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
258
+ .mockResolvedValue({
259
+ ok: true,
260
+ status: 200,
261
+ text: async () => JSON.stringify({ status: 'ok' }),
262
+ } as Response);
259
263
  const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
260
264
 
261
265
  try {
@@ -281,7 +285,9 @@ describe('notifications SQS worker', () => {
281
285
  counterpartProfileId: profileB.profileId,
282
286
  })
283
287
  );
284
- const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(fetchResponse(true));
288
+ const fetchSpy = vi
289
+ .spyOn(notificationTransport, 'fetchNotificationWebhook')
290
+ .mockResolvedValue(fetchResponse(true));
285
291
  const originalRun = neogma.queryRunner.run.bind(neogma.queryRunner);
286
292
  const queryRunnerSpy = vi
287
293
  .spyOn(neogma.queryRunner, 'run')
@@ -43,6 +43,7 @@ import { getCredentialRefresh, getCredentialRefreshHead } from '@accesslayer/cre
43
43
  import { testUnsignedBoost } from './helpers/send';
44
44
  import {
45
45
  claimRefreshSendIntent,
46
+ computeRefreshSendRequestDigest,
46
47
  getRefreshSendIntent,
47
48
  recordRefreshSendIntent,
48
49
  recordRefreshSendIntentPending,
@@ -215,7 +216,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
215
216
  });
216
217
 
217
218
  describe('inline refresh preparation', () => {
218
- it('preserves metadata, permissions, skills and contract linkage, then reuses one boost', async () => {
219
+ it('keeps only public display and contract linkage, then reuses one boost', async () => {
219
220
  const contractUri = await issuer.clients.fullAuth.contracts.createConsentFlowContract({
220
221
  contract: {
221
222
  read: { anonymize: false },
@@ -242,8 +243,10 @@ describe('Unified send with managed refresh (LC-2198)', () => {
242
243
  });
243
244
  const baseline = await getMutationBaseline();
244
245
  const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
246
+ statusBinding: 'server',
245
247
  recipient: HOLDER_PROFILE_ID,
246
248
  contractUri,
249
+ display: { name: 'Prepared Boost', category: 'TestBoosts' },
247
250
  template: {
248
251
  credential: testUnsignedBoost,
249
252
  name: 'Prepared Boost',
@@ -258,11 +261,12 @@ describe('Unified send with managed refresh (LC-2198)', () => {
258
261
  expect(boost).toMatchObject({
259
262
  name: 'Prepared Boost',
260
263
  category: 'TestBoosts',
261
- claimPermissions: { canView: true },
262
264
  });
265
+ expect(boost.boost.display).toEqual({ name: 'Prepared Boost', category: 'TestBoosts' });
266
+ expect(boost.boost.credentialSubject).toEqual({ id: 'did:example:123' });
263
267
  expect(
264
268
  await issuer.clients.fullAuth.boost.getBoostSkills({ uri: prepared.boostUri })
265
- ).toEqual([expect.objectContaining({ id: 'skill-1', proficiencyLevel: 2 })]);
269
+ ).toEqual([]);
266
270
  const links = await runQuery(
267
271
  'MATCH (:ConsentFlowContract)-[:RELATED_TO]->(b:Boost {name: $name}) RETURN count(b) AS count',
268
272
  { name: 'Prepared Boost' }
@@ -281,6 +285,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
281
285
  {
282
286
  ...testUnsignedBoost,
283
287
  id: prepared.credentialId,
288
+ credentialStatus: prepared.statusEntries,
284
289
  issuer: issuer.learnCard.id.did(),
285
290
  credentialSubject: {
286
291
  ...testUnsignedBoost.credentialSubject,
@@ -390,6 +395,66 @@ describe('Unified send with managed refresh (LC-2198)', () => {
390
395
  });
391
396
 
392
397
  describe('whole-call idempotency (idempotencyKey)', () => {
398
+ it.each(['prepared', 'delivered', 'stale'] as const)(
399
+ 'retries a %s intent across retained digest key rotation',
400
+ async state => {
401
+ try {
402
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'old-send-test-key');
403
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v1');
404
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', '{}');
405
+ const input = {
406
+ recipient: HOLDER_PROFILE_ID,
407
+ template: inlineTemplate(),
408
+ idempotencyKey: `rotation-${state}`,
409
+ };
410
+ const send = () =>
411
+ state === 'delivered'
412
+ ? issuer.clients.fullAuth.boost.send({
413
+ ...input,
414
+ type: 'boost',
415
+ refresh: true,
416
+ })
417
+ : issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
418
+ const first = await send();
419
+ const original = await getRefreshSendIntent(
420
+ ISSUER_PROFILE_ID,
421
+ input.idempotencyKey
422
+ );
423
+ if (state === 'stale') {
424
+ await neogma.queryRunner.run(
425
+ `MATCH (i:RefreshSendIntent {intentKey: $key})
426
+ SET i.state = 'preparing', i.updatedAt = '2000-01-01T00:00:00.000Z'`,
427
+ { key: original!.intentKey }
428
+ );
429
+ }
430
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_SECRET', 'new-send-test-key');
431
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_KEY_VERSION', 'v2');
432
+ vi.stubEnv(
433
+ 'CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS',
434
+ JSON.stringify({ v1: 'old-send-test-key' })
435
+ );
436
+ expect(await send()).toEqual(first);
437
+ const retried = await getRefreshSendIntent(
438
+ ISSUER_PROFILE_ID,
439
+ input.idempotencyKey
440
+ );
441
+ expect(retried?.requestDigest).toBe(original?.requestDigest);
442
+ if (state === 'stale')
443
+ expect(retried?.claimToken).not.toBe(original?.claimToken);
444
+ await expect(
445
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
446
+ ...input,
447
+ recipient: OUTSIDER_PROFILE_ID,
448
+ })
449
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
450
+ vi.stubEnv('CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS', '{}');
451
+ await expect(send()).rejects.toMatchObject({ code: 'CONFLICT' });
452
+ } finally {
453
+ vi.unstubAllEnvs();
454
+ }
455
+ }
456
+ );
457
+
393
458
  const inlineTemplate = () => ({
394
459
  credential: testUnsignedVcV2,
395
460
  name: 'Idempotent Refreshable',
@@ -468,6 +533,16 @@ describe('Unified send with managed refresh (LC-2198)', () => {
468
533
  };
469
534
 
470
535
  const first = (await issuer.clients.fullAuth.boost.send(input)) as SendResult;
536
+ // Direct signing-authority requests retain their pre-capability fingerprint.
537
+ expect(
538
+ (await getRefreshSendIntent(ISSUER_PROFILE_ID, input.idempotencyKey))?.requestDigest
539
+ ).toBe(
540
+ computeRefreshSendRequestDigest({
541
+ recipientProfileId: HOLDER_PROFILE_ID,
542
+ holderDid: getDidWeb(DOMAIN, HOLDER_PROFILE_ID),
543
+ template: input.template,
544
+ })
545
+ );
471
546
  const second = (await issuer.clients.fullAuth.boost.send(input)) as SendResult;
472
547
 
473
548
  expect(second).toEqual(first);
@@ -481,6 +556,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
481
556
  it('completes an abandoned prepare on the next send with the same key', async () => {
482
557
  // Simulates the SDK failing after prepare (e.g. while signing).
483
558
  const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
559
+ statusBinding: 'server',
484
560
  recipient: HOLDER_PROFILE_ID,
485
561
  template: inlineTemplate(),
486
562
  idempotencyKey: 'abandoned-1',
@@ -491,6 +567,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
491
567
  {
492
568
  ...testUnsignedVcV2,
493
569
  id: prepared.credentialId,
570
+ credentialStatus: prepared.statusEntries,
494
571
  issuer: issuer.learnCard.id.did(),
495
572
  validFrom: '2026-01-01T00:00:00Z',
496
573
  credentialSubject: { id: prepared.holderDid },
@@ -515,6 +592,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
515
592
  // A later prepare with the same key reports the completed result: the SDK
516
593
  // returns it without signing again.
517
594
  const again = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
595
+ statusBinding: 'server',
518
596
  recipient: HOLDER_PROFILE_ID,
519
597
  template: inlineTemplate(),
520
598
  idempotencyKey: 'abandoned-1',
@@ -527,6 +605,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
527
605
  async materialChange => {
528
606
  const idempotencyKey = 'pre-signed-post-bind';
529
607
  const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
608
+ statusBinding: 'server',
530
609
  recipient: HOLDER_PROFILE_ID,
531
610
  template: inlineTemplate(),
532
611
  idempotencyKey,
@@ -535,6 +614,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
535
614
  {
536
615
  ...testUnsignedVcV2,
537
616
  id: prepared.credentialId,
617
+ credentialStatus: prepared.statusEntries,
538
618
  issuer: issuer.learnCard.id.did(),
539
619
  validFrom: '2026-01-01T00:00:00Z',
540
620
  credentialSubject: { id: prepared.holderDid },
@@ -614,7 +694,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
614
694
  expect(await countNodes('CredentialActivity')).toBe(1);
615
695
  });
616
696
 
617
- it.each(['templateData', 'integrationId', 'credentialId'] as const)(
697
+ it.each(['integrationId'] as const)(
618
698
  'rejects changing %s after preparation without allocating again',
619
699
  async field => {
620
700
  const input = {
@@ -642,7 +722,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
642
722
  }
643
723
  );
644
724
 
645
- it.each(['templateData', 'integrationId'] as const)(
725
+ it.each(['integrationId'] as const)(
646
726
  'rejects changing %s after delivery instead of returning the old receipt',
647
727
  async field => {
648
728
  const input = {
@@ -676,6 +756,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
676
756
  const intent = await getRefreshSendIntent(ISSUER_PROFILE_ID, input.idempotencyKey);
677
757
  const receipt = first.refresh!;
678
758
  const reordered = {
759
+ statusManagement: first.statusManagement,
679
760
  refresh: {
680
761
  holderDid: receipt.holderDid,
681
762
  issuerDid: receipt.issuerDid,
@@ -1346,10 +1427,257 @@ describe('Unified send with managed refresh (LC-2198)', () => {
1346
1427
 
1347
1428
  expect(result.refresh).toBeDefined();
1348
1429
  expect(storedTemplate.refreshService).toBeUndefined();
1430
+ expect(storedTemplate.display).toEqual({});
1431
+ expect(storedTemplate.name).toBe('Credential');
1432
+ expect(storedTemplate.credentialSubject).toEqual({ id: 'did:example:123' });
1433
+ });
1434
+
1435
+ it('rejects reusing a display-only anchor via templateUri without a template, instead of a server error', async () => {
1436
+ const { signed } = await buildHandoff();
1437
+ const created = (await issuer.clients.fullAuth.boost.send({
1438
+ type: 'boost',
1439
+ recipient: HOLDER_PROFILE_ID,
1440
+ signedCredential: signed,
1441
+ refresh: true,
1442
+ })) as SendResult;
1443
+
1444
+ await expect(
1445
+ issuer.clients.fullAuth.boost.send({
1446
+ type: 'boost',
1447
+ recipient: HOLDER_PROFILE_ID,
1448
+ templateUri: created.uri,
1449
+ refresh: true,
1450
+ })
1451
+ ).rejects.toMatchObject({
1452
+ code: 'BAD_REQUEST',
1453
+ message: expect.stringContaining(
1454
+ 'Display-only anchors require an issuance credential'
1455
+ ),
1456
+ });
1457
+ });
1458
+
1459
+ it('rejects reusing a display-only anchor in an ordinary signing-authority send', async () => {
1460
+ const { signed } = await buildHandoff();
1461
+ const created = (await issuer.clients.fullAuth.boost.send({
1462
+ type: 'boost',
1463
+ recipient: HOLDER_PROFILE_ID,
1464
+ signedCredential: signed,
1465
+ refresh: true,
1466
+ })) as SendResult;
1467
+
1468
+ await expect(
1469
+ issuer.clients.fullAuth.boost.send({
1470
+ type: 'boost',
1471
+ recipient: HOLDER_PROFILE_ID,
1472
+ templateUri: created.uri,
1473
+ })
1474
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1349
1475
  });
1350
1476
  });
1351
1477
 
1352
1478
  describe('normal sends are unchanged', () => {
1479
+ it.each([undefined, 'private-display-key'])(
1480
+ 'stores only public display for inline refresh (%s)',
1481
+ async idempotencyKey => {
1482
+ const display = {
1483
+ name: 'Public credential',
1484
+ category: 'Achievement',
1485
+ image: 'https://example.com/icon.png',
1486
+ };
1487
+ const result = await issuer.clients.fullAuth.boost.send({
1488
+ type: 'boost',
1489
+ recipient: HOLDER_PROFILE_ID,
1490
+ template: { credential: testUnsignedBoost, name: 'PRIVATE TEMPLATE TITLE' },
1491
+ display,
1492
+ refresh: true,
1493
+ idempotencyKey,
1494
+ });
1495
+ const stored = await issuer.clients.fullAuth.boost.getBoost({ uri: result.uri });
1496
+ expect(stored.name).toBe(display.name);
1497
+ expect(stored.boost).toEqual({
1498
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1499
+ type: ['VerifiableCredential'],
1500
+ issuer: getDidWeb(DOMAIN, ISSUER_PROFILE_ID),
1501
+ credentialSubject: { id: 'did:example:123' },
1502
+ name: display.name,
1503
+ display,
1504
+ displayOnlyAnchor: true,
1505
+ });
1506
+ expect(JSON.stringify(stored)).not.toContain('PRIVATE TEMPLATE TITLE');
1507
+ const signedInput = signingAuthorityMocks.issueCredential.mock.calls[0]?.[1];
1508
+ expect(signedInput.name).toBe(testUnsignedBoost.name);
1509
+ }
1510
+ );
1511
+
1512
+ it.each([undefined, 'omit-display-fallback-key'])(
1513
+ 'ignores the inline template name but copies its category when display is omitted (%s)',
1514
+ async idempotencyKey => {
1515
+ const result = await issuer.clients.fullAuth.boost.send({
1516
+ type: 'boost',
1517
+ recipient: HOLDER_PROFILE_ID,
1518
+ template: {
1519
+ credential: testUnsignedBoost,
1520
+ name: 'Private Template Name',
1521
+ category: 'TemplateCategory',
1522
+ },
1523
+ refresh: true,
1524
+ idempotencyKey,
1525
+ });
1526
+ const stored = await issuer.clients.fullAuth.boost.getBoost({ uri: result.uri });
1527
+ expect(stored.name).toBe('Credential');
1528
+ // Unlike name/image, category is copied from the template: consent-flow
1529
+ // write authorization keys off a Boost's category and treats an absent
1530
+ // one as unrestricted, so dropping it would let an unconsented category
1531
+ // through as an unrestricted write merely by omitting `display`.
1532
+ expect(stored.category).toBe('TemplateCategory');
1533
+ expect(stored.boost).toEqual({
1534
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
1535
+ type: ['VerifiableCredential'],
1536
+ issuer: getDidWeb(DOMAIN, ISSUER_PROFILE_ID),
1537
+ credentialSubject: { id: 'did:example:123' },
1538
+ name: 'Credential',
1539
+ display: { category: 'TemplateCategory' },
1540
+ displayOnlyAnchor: true,
1541
+ });
1542
+ expect(JSON.stringify(stored)).not.toContain('Private Template Name');
1543
+ }
1544
+ );
1545
+
1546
+ it.each([undefined, 'category-not-consented-key'])(
1547
+ 'never records a contract-flow write for an inline template category the recipient did not consent to (%s)',
1548
+ async idempotencyKey => {
1549
+ const contractUri =
1550
+ await issuer.clients.fullAuth.contracts.createConsentFlowContract({
1551
+ contract: {
1552
+ read: { anonymize: false },
1553
+ write: {
1554
+ credentials: {
1555
+ categories: {
1556
+ AllowedCategory: { required: false },
1557
+ RestrictedCategory: { required: false },
1558
+ },
1559
+ },
1560
+ },
1561
+ },
1562
+ name: 'Category Enforcement Contract',
1563
+ writers: [ISSUER_PROFILE_ID],
1564
+ });
1565
+ await holder.clients.fullAuth.contracts.consentToContract({
1566
+ contractUri,
1567
+ terms: {
1568
+ read: { anonymize: false },
1569
+ write: {
1570
+ credentials: {
1571
+ categories: { AllowedCategory: true, RestrictedCategory: false },
1572
+ },
1573
+ },
1574
+ },
1575
+ });
1576
+
1577
+ const result = await issuer.clients.fullAuth.boost.send({
1578
+ type: 'boost',
1579
+ recipient: HOLDER_PROFILE_ID,
1580
+ template: { credential: testUnsignedBoost, category: 'RestrictedCategory' },
1581
+ contractUri,
1582
+ refresh: true,
1583
+ idempotencyKey,
1584
+ });
1585
+
1586
+ const stored = await issuer.clients.fullAuth.boost.getBoost({ uri: result.uri });
1587
+ // The category must reach the anchor for the consent check below to see
1588
+ // it at all; this is what the previous test already locks in.
1589
+ expect(stored.category).toBe('RestrictedCategory');
1590
+
1591
+ const transactions = await runQuery(
1592
+ `MATCH (root:Credential {refreshVersionKey: $key})-[:ISSUED_VIA_TRANSACTION]->(:ConsentFlowTransaction)-[:IS_FOR]->(:ConsentFlowTerms)
1593
+ RETURN count(*) AS count`,
1594
+ { key: `${result.refresh!.refreshId}:1` }
1595
+ );
1596
+ expect(toNum(transactions.records[0].get('count'))).toBe(0);
1597
+ }
1598
+ );
1599
+
1600
+ it.each([undefined, 'category-consented-key'])(
1601
+ 'records a contract-flow write for an inline template category the recipient consented to (%s)',
1602
+ async idempotencyKey => {
1603
+ const contractUri =
1604
+ await issuer.clients.fullAuth.contracts.createConsentFlowContract({
1605
+ contract: {
1606
+ read: { anonymize: false },
1607
+ write: {
1608
+ credentials: {
1609
+ categories: { AllowedCategory: { required: false } },
1610
+ },
1611
+ },
1612
+ },
1613
+ name: 'Category Enforcement Allowed Contract',
1614
+ writers: [ISSUER_PROFILE_ID],
1615
+ });
1616
+ await holder.clients.fullAuth.contracts.consentToContract({
1617
+ contractUri,
1618
+ terms: {
1619
+ read: { anonymize: false },
1620
+ write: { credentials: { categories: { AllowedCategory: true } } },
1621
+ },
1622
+ });
1623
+
1624
+ const result = await issuer.clients.fullAuth.boost.send({
1625
+ type: 'boost',
1626
+ recipient: HOLDER_PROFILE_ID,
1627
+ template: { credential: testUnsignedBoost, category: 'AllowedCategory' },
1628
+ contractUri,
1629
+ refresh: true,
1630
+ idempotencyKey,
1631
+ });
1632
+
1633
+ const transactions = await runQuery(
1634
+ `MATCH (root:Credential {refreshVersionKey: $key})-[:ISSUED_VIA_TRANSACTION]->(:ConsentFlowTransaction)-[:IS_FOR]->(:ConsentFlowTerms)
1635
+ RETURN count(*) AS count`,
1636
+ { key: `${result.refresh!.refreshId}:1` }
1637
+ );
1638
+ expect(toNum(transactions.records[0].get('count'))).toBe(1);
1639
+ }
1640
+ );
1641
+
1642
+ it('allocates a suspension status entry for a delegated signing-authority send', async () => {
1643
+ const result = (await issuer.clients.fullAuth.boost.send({
1644
+ type: 'boost',
1645
+ recipient: HOLDER_PROFILE_ID,
1646
+ template: { credential: testUnsignedBoost },
1647
+ refresh: true,
1648
+ statusPurposes: ['suspension'],
1649
+ })) as SendResult;
1650
+
1651
+ expect(result.refresh!.credentialStatus).toEqual(
1652
+ expect.arrayContaining([
1653
+ expect.objectContaining({ statusPurpose: 'revocation' }),
1654
+ expect.objectContaining({ statusPurpose: 'suspension' }),
1655
+ ])
1656
+ );
1657
+ });
1658
+
1659
+ it('sends an ordinary reusable Boost whose credential has its own display object, not a display-only anchor', async () => {
1660
+ // Real app-created BoostCredentials have their own top-level `display`
1661
+ // object (appearance fields like backgroundColor); this must never be
1662
+ // mistaken for the display-only anchor marker.
1663
+ const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1664
+ credential: {
1665
+ ...testUnsignedBoost,
1666
+ display: { backgroundColor: '#fff', backgroundImage: '' },
1667
+ },
1668
+ });
1669
+
1670
+ const result = (await issuer.clients.fullAuth.boost.send({
1671
+ type: 'boost',
1672
+ recipient: HOLDER_PROFILE_ID,
1673
+ templateUri: boostUri,
1674
+ refresh: true,
1675
+ })) as SendResult;
1676
+
1677
+ expect(result.refresh).toBeDefined();
1678
+ expect(result.credentialUri).toBeDefined();
1679
+ });
1680
+
1353
1681
  it('returns no refresh receipt when refresh is omitted', async () => {
1354
1682
  const boostUri = await issuer.clients.fullAuth.boost.createBoost({
1355
1683
  credential: testUnsignedBoost,
@@ -1723,6 +2051,7 @@ describe('Unified send with managed refresh (LC-2198)', () => {
1723
2051
  type: ['VerifiableCredential'],
1724
2052
  issuer: receipt.issuerDid,
1725
2053
  name: 'SA Updated Badge',
2054
+ credentialStatus: receipt.credentialStatus,
1726
2055
  credentialSubject: { id: receipt.holderDid },
1727
2056
  refreshService: receipt.refreshService,
1728
2057
  validFrom: new Date().toISOString(),
@@ -192,7 +192,7 @@ describe('SmartResume consent before publication', () => {
192
192
  upload.mockRejectedValueOnce(new Error('Synthetic outage'));
193
193
  await expect(accept({ oneTime: true })).rejects.toMatchObject({ code: 'BAD_GATEWAY' });
194
194
  const result = await accept({ oneTime: true });
195
- expect((await savedTerms())?.status).toBe('stale');
195
+ expect((await savedTerms())?.status).toBe('consumed');
196
196
  expect(
197
197
  (
198
198
  await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
@@ -30,6 +30,15 @@ const script: VerificationScript = async v => {
30
30
  .rejects.toThrow(/Campaign administrator access required/);
31
31
  });
32
32
 
33
+ await v.step(null, 'campaign-dispatch-authorization', async () => {
34
+ await v
35
+ .expect(learner.invoke.campaignsSendTestToMe(campaignId))
36
+ .rejects.toThrow(/Campaign administrator access required/);
37
+ await v
38
+ .expect(learner.invoke.campaignsDispatch(campaignId))
39
+ .rejects.toThrow(/Campaign administrator access required/);
40
+ });
41
+
33
42
  await v.step(null, 'campaign-sdk-user-isolation', async () => {
34
43
  v.expect(await learner.invoke.getMyCampaigns()).toEqual([]);
35
44
  v.expect(await other.invoke.getMyCampaigns()).toEqual([]);
@@ -39,6 +48,37 @@ const script: VerificationScript = async v => {
39
48
  .rejects.toThrow(/Campaign not found/);
40
49
  });
41
50
 
51
+ const page = await v.newPage('announcement-preferences', { signInAs: v.actors.learner });
52
+ await v.step(page, 'announcement-preferences-ui-persistence', async () => {
53
+ await page.goto('/privacy-and-data');
54
+ const email = page.getByRole('switch', { name: 'Email', exact: true });
55
+ const push = page.getByRole('switch', { name: 'Push notifications', exact: true });
56
+ await v.expect(email).toHaveAttribute('aria-checked', 'true');
57
+ await email.click();
58
+ await v.expect(email).toHaveAttribute('aria-checked', 'false');
59
+ await push.click();
60
+ await v.expect(push).toHaveAttribute('aria-checked', 'false');
61
+ await page.reload();
62
+ await v.expect(email).toHaveAttribute('aria-checked', 'false');
63
+ await v.expect(push).toHaveAttribute('aria-checked', 'false');
64
+ v.expect(await learner.invoke.getAnnouncementPreferences()).toEqual({
65
+ announcementEmailOptOut: true,
66
+ announcementPushOptOut: true,
67
+ });
68
+ v.expect(await other.invoke.getAnnouncementPreferences()).toEqual({
69
+ announcementEmailOptOut: false,
70
+ announcementPushOptOut: false,
71
+ });
72
+ });
73
+ const anonymous = await v.newPage('unsubscribe-public');
74
+ await v.step(anonymous, 'unsubscribe-public-error-retry', async () => {
75
+ await anonymous.goto('/unsubscribe?token=invalid');
76
+ await v.expect(anonymous.getByRole('alert')).toBeVisible();
77
+ await anonymous.getByRole('button', { name: 'Try Again' }).click();
78
+ await v.expect(anonymous.getByRole('alert')).toBeVisible();
79
+ await v.expect(anonymous).toHaveURL(/\/unsubscribe\?token=invalid/);
80
+ });
81
+
42
82
  return {
43
83
  authorization: 'passed',
44
84
  userIsolation: 'passed',
@@ -11,9 +11,11 @@ export default createVitestConfig(serviceIntegrationPreset, {
11
11
  alias: { '@instance': require.resolve('./test/helpers/mock-instance.ts') },
12
12
  env: {
13
13
  IS_E2E_TEST: 'true',
14
+ IS_OFFLINE: 'true',
14
15
  DOMAIN_NAME: 'localhost%3A3000',
15
16
  SEED: 'a'.repeat(64),
16
17
  CAMPAIGN_ADMIN_PROFILE_IDS: 'campaign-admin',
18
+ CAMPAIGN_UNSUBSCRIBE_SECRET: 'campaign-test-only-secret-at-least-32-bytes',
17
19
  TRACE_CONSOLE: 'false',
18
20
  NEO4J_SKIP_INDICES: 'true',
19
21
  },