@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
@@ -0,0 +1,761 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { neogma } from '@instance';
4
+ import { getIdFromUri } from '@helpers/uri.helpers';
5
+ import { getConsentFlowContractVersionRecord } from '@accesslayer/consentflowcontract/versions';
6
+ import { getContractTermsByUri } from '@accesslayer/consentflowcontract/relationships/read';
7
+ import { getClient, getUser } from './helpers/getClient';
8
+ import { normalContract, normalFullTerms } from './helpers/contract';
9
+ import { testUnsignedBoost } from './helpers/send';
10
+ import type { ConsentFlowContractChanges } from '@learncard/types';
11
+ import { migrateLegacyConsentFlowContracts } from '@helpers/consentflow-legacy-migration.helpers';
12
+ import { seedLegacyAutoBoost } from './helpers/legacy-autoboost';
13
+
14
+ const setup = async (expiresAt?: string, oneTime = false) => {
15
+ const suffix = randomBytes(5).toString('hex');
16
+ const owner = await getUser(randomBytes(32).toString('hex'));
17
+ const learner = await getUser(randomBytes(32).toString('hex'));
18
+ await owner.clients.fullAuth.profile.createProfile({ profileId: `version-owner-${suffix}` });
19
+ await learner.clients.fullAuth.profile.createProfile({ profileId: `version-user-${suffix}` });
20
+ const uri = await owner.clients.fullAuth.contracts.createConsentFlowContract({
21
+ name: 'Version QA',
22
+ contract: normalContract,
23
+ expiresAt,
24
+ });
25
+ const { termsUri } = await learner.clients.fullAuth.contracts.consentToContract({
26
+ contractUri: uri,
27
+ terms: normalFullTerms,
28
+ oneTime,
29
+ });
30
+ return { owner, learner, uri, termsUri };
31
+ };
32
+
33
+ describe('safe live contract patches', () => {
34
+ it('saves choices for an already handled update as an update transaction', async () => {
35
+ const { owner, learner, uri, termsUri } = await setup();
36
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
37
+ uri,
38
+ changes: {
39
+ contract: {
40
+ read: { credentials: { categories: { Gamma: { required: false } } } },
41
+ },
42
+ },
43
+ });
44
+ const terms = structuredClone(normalFullTerms);
45
+ terms.read.credentials.categories.Gamma = {
46
+ shareAll: false,
47
+ sharing: true,
48
+ shared: [],
49
+ };
50
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
51
+ uri: termsUri,
52
+ terms,
53
+ acceptVersion: 2,
54
+ });
55
+ terms.read.credentials.categories.Gamma.sharing = false;
56
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
57
+ uri: termsUri,
58
+ terms,
59
+ });
60
+
61
+ const saved = (await getContractTermsByUri(termsUri))!;
62
+ expect(saved.terms).toMatchObject({
63
+ consentedVersion: 2,
64
+ lastReviewedVersion: 2,
65
+ status: 'live',
66
+ });
67
+ expect(saved.terms.terms.read.credentials.categories.Gamma.sharing).toBe(false);
68
+ const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
69
+ uri: termsUri,
70
+ });
71
+ expect(history.records.slice(0, 2).map(record => record.action)).toEqual([
72
+ 'update',
73
+ 'reconsent',
74
+ ]);
75
+ });
76
+
77
+ it('preserves consumed one-time consent when updates omit oneTime', async () => {
78
+ const { owner, learner, uri, termsUri } = await setup(undefined, true);
79
+ const terms = structuredClone(normalFullTerms);
80
+ terms.read.personal.name = 'Updated name';
81
+ await expect(
82
+ learner.clients.fullAuth.contracts.updateConsentedContractTerms({
83
+ uri: termsUri,
84
+ terms,
85
+ })
86
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
87
+ expect((await getContractTermsByUri(termsUri))!.terms).toMatchObject({
88
+ oneTime: true,
89
+ status: 'consumed',
90
+ });
91
+ expect((await getContractTermsByUri(termsUri))!.terms.terms.read.personal.name).toBe(
92
+ normalFullTerms.read.personal.name
93
+ );
94
+
95
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
96
+ uri,
97
+ changes: {
98
+ contract: {
99
+ read: { personal: { name: { required: true } } },
100
+ },
101
+ },
102
+ reason: 'Require a name for the connected feature.',
103
+ });
104
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
105
+ uri: termsUri,
106
+ terms,
107
+ acceptVersion: 2,
108
+ });
109
+ expect((await getContractTermsByUri(termsUri))!.terms).toMatchObject({
110
+ oneTime: true,
111
+ status: 'consumed',
112
+ consentedVersion: 2,
113
+ lastReviewedVersion: 2,
114
+ });
115
+ });
116
+ it('recovers old quiet-update review baselines and advances them only after explicit review', async () => {
117
+ const { owner, learner, uri, termsUri } = await setup();
118
+ for (const key of ['Alpha', 'Beta']) {
119
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
120
+ uri,
121
+ changes: {
122
+ contract: {
123
+ read: {
124
+ credentials: {
125
+ categories: {
126
+ [key]: { required: false },
127
+ },
128
+ },
129
+ },
130
+ },
131
+ },
132
+ });
133
+ }
134
+ await neogma.queryRunner.run(
135
+ `MATCH (terms:ConsentFlowTerms {id:$id}) REMOVE terms.lastReviewedVersion`,
136
+ { id: getIdFromUri(termsUri) }
137
+ );
138
+ await neogma.queryRunner.run(
139
+ `MATCH (migration:ConsentFlowContractMigration {id:'holder-review-v1'}) DELETE migration`
140
+ );
141
+ await migrateLegacyConsentFlowContracts();
142
+ const recovered = (await getContractTermsByUri(termsUri))!;
143
+ expect(recovered.terms).toMatchObject({
144
+ consentedVersion: 3,
145
+ lastReviewedVersion: 1,
146
+ status: 'live',
147
+ });
148
+ expect(recovered.cumulativeUpdate?.diff.added.map(scope => scope.key)).toEqual([
149
+ 'Alpha',
150
+ 'Beta',
151
+ ]);
152
+ const selected = structuredClone(recovered.terms.terms);
153
+ selected.read.credentials.categories.Alpha = { sharing: true, shareAll: true, shared: [] };
154
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
155
+ uri: termsUri,
156
+ terms: selected,
157
+ acceptVersion: 3,
158
+ });
159
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
160
+ uri,
161
+ changes: {
162
+ contract: {
163
+ read: {
164
+ credentials: {
165
+ categories: {
166
+ Gamma: { required: false },
167
+ },
168
+ },
169
+ },
170
+ },
171
+ },
172
+ });
173
+ const next = (await getContractTermsByUri(termsUri))!;
174
+ expect(next.terms).toMatchObject({ consentedVersion: 4, lastReviewedVersion: 3 });
175
+ expect(next.cumulativeUpdate?.diff.added.map(scope => scope.key)).toEqual(['Gamma']);
176
+ expect(next.terms.terms.read.credentials.categories.Alpha.sharing).toBe(true);
177
+ expect(next.terms.terms.read.credentials.categories.Gamma.sharing).toBe(false);
178
+ });
179
+
180
+ it('does not publish or mutate holders for an unchanged resulting request', async () => {
181
+ const { owner, learner, uri, termsUri } = await setup();
182
+ const before = (await getContractTermsByUri(termsUri))!.terms;
183
+ const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
184
+ uri: termsUri,
185
+ });
186
+ const unchanged: ConsentFlowContractChanges[] = [
187
+ {},
188
+ { name: 'Version QA' },
189
+ { contract: { read: { personal: {} } } },
190
+ ];
191
+ for (const changes of unchanged) {
192
+ const result = await owner.clients.fullAuth.contracts.updateConsentFlowContract({
193
+ uri,
194
+ changes,
195
+ expectedVersion: 1,
196
+ });
197
+ expect(result).toMatchObject({ version: 1, affectedUsers: 0 });
198
+ }
199
+ expect((await getContractTermsByUri(termsUri))!.terms).toEqual(before);
200
+ expect(
201
+ await learner.clients.fullAuth.contracts.getTermsTransactionHistory({ uri: termsUri })
202
+ ).toEqual(history);
203
+ expect(
204
+ (await owner.clients.fullAuth.contracts.getContractVersions({ uri })).map(
205
+ v => v.version
206
+ )
207
+ ).toEqual([1]);
208
+ expect(
209
+ (await owner.clients.fullAuth.contracts.getConsentFlowContract({ uri })).latestUpdate
210
+ ).toBeUndefined();
211
+ });
212
+
213
+ it.each([false, true])('rejects an obsolete draft version (dryRun=%s)', async dryRun => {
214
+ const { owner, uri } = await setup();
215
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
216
+ uri,
217
+ changes: { subtitle: 'Intervening publication' },
218
+ expectedVersion: 1,
219
+ });
220
+ await expect(
221
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
222
+ uri,
223
+ changes: { subtitle: 'Older draft' },
224
+ expectedVersion: 1,
225
+ dryRun,
226
+ })
227
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
228
+ expect(
229
+ await owner.clients.fullAuth.contracts.getConsentFlowContract({ uri })
230
+ ).toMatchObject({ version: 2, subtitle: 'Intervening publication' });
231
+ });
232
+
233
+ it.each(['2000-01-01T00:00:00.000Z', 'now'])(
234
+ 'rejects a deadline at or before publication time: %s',
235
+ async deadline => {
236
+ const { owner, uri, termsUri } = await setup();
237
+ await expect(
238
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
239
+ uri,
240
+ changes: { name: 'New request identity' },
241
+ reason: 'Review the new request identity.',
242
+ reconsentDeadline: deadline === 'now' ? new Date().toISOString() : deadline,
243
+ })
244
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
245
+ expect((await getContractTermsByUri(termsUri))!.terms).toMatchObject({
246
+ status: 'live',
247
+ consentedVersion: 1,
248
+ });
249
+ expect(
250
+ (await owner.clients.fullAuth.contracts.getContractVersions({ uri })).map(
251
+ v => v.version
252
+ )
253
+ ).toEqual([1]);
254
+ }
255
+ );
256
+
257
+ it('keeps a valid future approval deadline', async () => {
258
+ const { owner, uri, termsUri } = await setup();
259
+ const deadline = new Date(Date.now() + 60 * 60 * 1000).toISOString();
260
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
261
+ uri,
262
+ changes: { name: 'New request identity' },
263
+ reason: 'Review the new request identity.',
264
+ reconsentDeadline: deadline,
265
+ });
266
+ expect((await getContractTermsByUri(termsUri))!.terms).toMatchObject({
267
+ status: 'needs_reconsent',
268
+ reconsentDeadline: deadline,
269
+ });
270
+ });
271
+
272
+ it('preserves existing delegated issuers without authorizing new delegated setups', async () => {
273
+ const suffix = randomBytes(5).toString('hex');
274
+ const owner = await getUser(randomBytes(32).toString('hex'));
275
+ const learner = await getUser(randomBytes(32).toString('hex'));
276
+ const writer = await getUser(randomBytes(32).toString('hex'));
277
+ const ownerId = `legacy-owner-${suffix}`;
278
+ const writerId = `legacy-writer-${suffix}`;
279
+ await owner.clients.fullAuth.profile.createProfile({ profileId: ownerId });
280
+ await learner.clients.fullAuth.profile.createProfile({
281
+ profileId: `legacy-holder-${suffix}`,
282
+ });
283
+ await writer.clients.fullAuth.profile.createProfile({ profileId: writerId });
284
+ await writer.clients.fullAuth.profile.registerSigningAuthority({
285
+ endpoint: 'http://localhost:5000/api',
286
+ name: 'writer-sa',
287
+ did: writer.learnCard.id.did(),
288
+ });
289
+ const boostUri = await writer.clients.fullAuth.boost.createBoost({
290
+ credential: testUnsignedBoost,
291
+ category: 'Achievement',
292
+ name: 'Existing writer delivery',
293
+ });
294
+ const config = {
295
+ boostUri,
296
+ signingAuthority: { endpoint: 'http://localhost:5000/api', name: 'writer-sa' },
297
+ };
298
+ const uri = await owner.clients.fullAuth.contracts.createConsentFlowContract({
299
+ name: 'Existing delegated request',
300
+ contract: normalContract,
301
+ writers: [writerId],
302
+ });
303
+ await seedLegacyAutoBoost(uri, { ...config, issuerProfileId: writerId });
304
+ const { termsUri } = await learner.clients.fullAuth.contracts.consentToContract({
305
+ contractUri: uri,
306
+ terms: { ...normalFullTerms, deniedWriters: [writerId] },
307
+ });
308
+ await expect(
309
+ writer.clients.fullAuth.contracts.addAutoBoostsToContract({
310
+ contractUri: uri,
311
+ autoboosts: [config],
312
+ })
313
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
314
+ await expect(
315
+ writer.clients.fullAuth.contracts.removeAutoBoostsFromContract({
316
+ contractUri: uri,
317
+ boostUris: [boostUri],
318
+ })
319
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
320
+ await expect(
321
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
322
+ uri,
323
+ changes: { subtitle: 'Updated description', autoboosts: [config] },
324
+ expectedVersion: 1,
325
+ })
326
+ ).resolves.toMatchObject({ version: 2, kind: 'relaxed' });
327
+ expect(
328
+ (await getConsentFlowContractVersionRecord(getIdFromUri(uri), 2))?.autoboosts
329
+ ).toMatchObject([{ issuer: writerId, signingAuthorityName: 'writer-sa' }]);
330
+ expect((await getContractTermsByUri(termsUri))!.terms).toMatchObject({
331
+ status: 'live',
332
+ consentedVersion: 2,
333
+ });
334
+ await owner.clients.fullAuth.profile.registerSigningAuthority({
335
+ endpoint: 'http://localhost:5000/api',
336
+ name: 'owner-sa',
337
+ did: owner.learnCard.id.did(),
338
+ });
339
+ await expect(
340
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
341
+ uri,
342
+ changes: {
343
+ autoboosts: [
344
+ {
345
+ ...config,
346
+ signingAuthority: {
347
+ endpoint: 'http://localhost:5000/api',
348
+ name: 'owner-sa',
349
+ },
350
+ },
351
+ ],
352
+ },
353
+ reason: 'Change delivery signer.',
354
+ })
355
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
356
+ expect(
357
+ (await owner.clients.fullAuth.contracts.getContractVersions({ uri })).map(
358
+ v => v.version
359
+ )
360
+ ).toEqual([2, 1]);
361
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
362
+ uri,
363
+ changes: { autoboosts: [] },
364
+ });
365
+ await expect(
366
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
367
+ uri,
368
+ changes: { autoboosts: [config] },
369
+ reason: 'Add a new delivery.',
370
+ })
371
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
372
+ const ownerBoost = await owner.clients.fullAuth.boost.createBoost({
373
+ credential: testUnsignedBoost,
374
+ category: 'Achievement',
375
+ name: 'Owner delivery',
376
+ });
377
+ await expect(
378
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
379
+ uri,
380
+ changes: {
381
+ autoboosts: [
382
+ {
383
+ boostUri: ownerBoost,
384
+ signingAuthority: {
385
+ endpoint: 'http://localhost:5000/api',
386
+ name: 'owner-sa',
387
+ },
388
+ },
389
+ ],
390
+ },
391
+ reason: 'Add owner delivery.',
392
+ expectedVersion: 3,
393
+ })
394
+ ).resolves.toMatchObject({ version: 4, kind: 'requires_consent' });
395
+ expect(
396
+ (await getConsentFlowContractVersionRecord(getIdFromUri(uri), 4))?.autoboosts
397
+ ).toMatchObject([{ issuer: ownerId }]);
398
+ expect((await getContractTermsByUri(termsUri))!.terms.status).toBe('needs_reconsent');
399
+ });
400
+
401
+ it('merges every scope map and preserves existing grants unless explicitly removed', async () => {
402
+ const { owner, uri, termsUri } = await setup();
403
+ const result = await owner.clients.fullAuth.contracts.updateConsentFlowContract({
404
+ uri,
405
+ changes: {
406
+ contract: {
407
+ read: {
408
+ credentials: { categories: { Transcript: { required: false } } },
409
+ personal: { nickname: { required: false } },
410
+ },
411
+ write: {
412
+ credentials: { categories: { Certificate: { required: false } } },
413
+ personal: { phone: { required: false } },
414
+ },
415
+ },
416
+ },
417
+ });
418
+ expect(result.kind).toBe('expanded_optional');
419
+ const current = await getConsentFlowContractVersionRecord(getIdFromUri(uri), 2);
420
+ expect(current?.contract.read.credentials.categories).toMatchObject(
421
+ normalContract.read.credentials.categories
422
+ );
423
+ expect(current?.contract.write.credentials.categories).toMatchObject(
424
+ normalContract.write.credentials.categories
425
+ );
426
+ expect(current?.contract.read.personal).toMatchObject(normalContract.read.personal);
427
+ expect(current?.contract.write.personal).toMatchObject(normalContract.write.personal);
428
+ const granted = (await getContractTermsByUri(termsUri))!.terms;
429
+ expect(granted.status).toBe('live');
430
+ expect(granted.terms.read.personal).toMatchObject(normalFullTerms.read.personal);
431
+ expect(granted.terms.read.credentials.categories.ID).toEqual(
432
+ normalFullTerms.read.credentials.categories.ID
433
+ );
434
+ expect(granted.terms.write.credentials.categories.Certificate).toBe(false);
435
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
436
+ uri,
437
+ changes: { contract: { read: { credentials: { categories: {} } } } },
438
+ });
439
+ expect(
440
+ (await getContractTermsByUri(termsUri))!.terms.terms.read.credentials.categories.ID
441
+ ).toEqual(normalFullTerms.read.credentials.categories.ID);
442
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
443
+ uri,
444
+ changes: { contract: { read: { credentials: { categories: { ID: null } } } } },
445
+ });
446
+ const removed = (await getContractTermsByUri(termsUri))!.terms;
447
+ expect(removed.terms.read.credentials.categories.ID).toBeUndefined();
448
+ expect(removed.terms.read.credentials.categories.Achievement).toEqual(
449
+ normalFullTerms.read.credentials.categories.Achievement
450
+ );
451
+ });
452
+
453
+ it('clears expiry explicitly without changing the ticket-approved quiet-extension policy', async () => {
454
+ const { owner, uri, termsUri } = await setup('2100-01-01T00:00:00.000Z');
455
+ const result = await owner.clients.fullAuth.contracts.updateConsentFlowContract({
456
+ uri,
457
+ changes: { expiresAt: null },
458
+ });
459
+ expect(result.kind).toBe('relaxed');
460
+ expect(
461
+ (await getConsentFlowContractVersionRecord(getIdFromUri(uri), 2))?.expiresAt
462
+ ).toBeUndefined();
463
+ expect((await getContractTermsByUri(termsUri))!.terms.status).toBe('live');
464
+ });
465
+
466
+ it.each(['writer', 'autoboost'] as const)(
467
+ 'rejects an unknown %s without committing or retaining a lock',
468
+ async kind => {
469
+ const { owner, uri } = await setup();
470
+ const changes =
471
+ kind === 'writer'
472
+ ? { writers: ['missing-writer'] }
473
+ : {
474
+ autoboosts: [
475
+ {
476
+ id: 'missing-boost',
477
+ signingAuthorityEndpoint: 'http://localhost:5000/api',
478
+ signingAuthorityName: 'default',
479
+ },
480
+ ],
481
+ };
482
+ await expect(
483
+ owner.clients.fullAuth.contracts.updateConsentFlowContract({
484
+ uri,
485
+ changes,
486
+ reason: 'Test requested access',
487
+ })
488
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
489
+ const state = await neogma.queryRunner.run(
490
+ `MATCH (contract:ConsentFlowContract {id:$id}) RETURN contract.currentVersion AS version, contract._contractVersionLock AS lock`,
491
+ { id: getIdFromUri(uri) }
492
+ );
493
+ expect(Number(state.records[0]!.get('version'))).toBe(1);
494
+ expect(state.records[0]!.get('lock')).toBeNull();
495
+ expect(await getConsentFlowContractVersionRecord(getIdFromUri(uri), 2)).toBeNull();
496
+ }
497
+ );
498
+
499
+ it('removes the final requested scope without making the request unreadable', async () => {
500
+ const { owner, learner } = await setup();
501
+ const uri = await owner.clients.fullAuth.contracts.createConsentFlowContract({
502
+ name: 'Single option',
503
+ contract: {
504
+ read: { credentials: { categories: {} }, personal: {} },
505
+ write: { credentials: { categories: { Only: { required: false } } }, personal: {} },
506
+ },
507
+ });
508
+ const { termsUri } = await learner.clients.fullAuth.contracts.consentToContract({
509
+ contractUri: uri,
510
+ terms: {
511
+ read: { credentials: { categories: {} }, personal: {} },
512
+ write: { credentials: { categories: { Only: true } }, personal: {} },
513
+ },
514
+ });
515
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
516
+ uri,
517
+ changes: { contract: { write: { credentials: { categories: { Only: null } } } } },
518
+ });
519
+ const current = await getConsentFlowContractVersionRecord(getIdFromUri(uri), 2);
520
+ expect(current?.contract.write.credentials.categories).toEqual({});
521
+ const terms = (await getContractTermsByUri(termsUri))!.terms;
522
+ expect(terms.status).toBe('live');
523
+ expect(terms.terms.write.credentials.categories).toEqual({});
524
+ });
525
+
526
+ it('keeps cumulative optional additions visible after later quiet publications', async () => {
527
+ const { owner, learner, uri, termsUri } = await setup();
528
+ for (const name of ['Alpha', 'Beta']) {
529
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
530
+ uri,
531
+ changes: {
532
+ contract: {
533
+ read: { credentials: { categories: { [name]: { required: false } } } },
534
+ },
535
+ },
536
+ });
537
+ }
538
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
539
+ uri,
540
+ changes: { subtitle: 'Updated description' },
541
+ });
542
+
543
+ const listed = await learner.clients.fullAuth.contracts.getConsentedContracts({});
544
+ const record = listed.records.find(item => item.uri === termsUri)!;
545
+ expect(record.cumulativeUpdate).toMatchObject({
546
+ fromVersion: 1,
547
+ toVersion: 4,
548
+ diff: {
549
+ added: expect.arrayContaining([
550
+ expect.objectContaining({ key: 'Alpha', required: false }),
551
+ expect.objectContaining({ key: 'Beta', required: false }),
552
+ ]),
553
+ },
554
+ });
555
+ });
556
+ it('requires review and queues a holder notification when a required scope reason changes', async () => {
557
+ const { owner, learner } = await setup();
558
+ const contract = structuredClone(normalContract);
559
+ contract.read.credentials.categories.Transcript = {
560
+ required: true,
561
+ reason: 'Verify enrollment',
562
+ };
563
+ const uri = await owner.clients.fullAuth.contracts.createConsentFlowContract({
564
+ name: 'Required transcript',
565
+ contract,
566
+ });
567
+ const terms = structuredClone(normalFullTerms);
568
+ terms.read.credentials.categories.Transcript = {
569
+ sharing: true,
570
+ shareAll: true,
571
+ shared: [],
572
+ };
573
+ const { termsUri } = await learner.clients.fullAuth.contracts.consentToContract({
574
+ contractUri: uri,
575
+ terms,
576
+ });
577
+ const reason = 'Personalize recommendations';
578
+ const result = await owner.clients.fullAuth.contracts.updateConsentFlowContract({
579
+ uri,
580
+ changes: {
581
+ contract: {
582
+ read: {
583
+ credentials: { categories: { Transcript: { required: true, reason } } },
584
+ },
585
+ },
586
+ },
587
+ reason: 'The reason for requesting this information has changed.',
588
+ });
589
+ expect(result).toMatchObject({ version: 2, kind: 'requires_consent', affectedUsers: 1 });
590
+ const holder = (await getContractTermsByUri(termsUri))!;
591
+ expect(holder.terms).toMatchObject({ status: 'needs_reconsent', consentedVersion: 1 });
592
+ expect(holder.cumulativeUpdate?.diff.changed).toContainEqual(
593
+ expect.objectContaining({ key: 'Transcript', reason })
594
+ );
595
+ const learnerProfile = (await learner.clients.fullAuth.profile.getProfile())!;
596
+ const notifications = (await getClient().test.notificationQueue()).filter(
597
+ notification =>
598
+ notification.type === 'CONSENT_FLOW_CONTRACT_UPDATED' &&
599
+ notification.data?.contractUri === uri &&
600
+ notification.data?.toVersion === result.version
601
+ );
602
+ expect(notifications).toHaveLength(1);
603
+ expect(notifications[0]).toMatchObject({
604
+ to: { profileId: learnerProfile.profileId },
605
+ message: { body: 'The reason for requesting this information has changed.' },
606
+ data: {
607
+ kind: 'requires_consent',
608
+ diff: { changed: [expect.objectContaining({ key: 'Transcript', reason })] },
609
+ },
610
+ });
611
+ });
612
+
613
+ it('accepts every required change across multiple unreviewed versions', async () => {
614
+ const { owner, learner, uri, termsUri } = await setup();
615
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
616
+ uri,
617
+ changes: {
618
+ contract: {
619
+ read: { credentials: { categories: { Transcript: { required: true } } } },
620
+ },
621
+ },
622
+ reason: 'Verify completed courses.',
623
+ });
624
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
625
+ uri,
626
+ changes: {
627
+ contract: {
628
+ read: {
629
+ credentials: { categories: { 'Learning History': { required: true } } },
630
+ },
631
+ write: {
632
+ credentials: {
633
+ categories: {
634
+ 'Social Badge': { required: false, defaultEnabled: true },
635
+ },
636
+ },
637
+ },
638
+ },
639
+ },
640
+ reason: 'Personalize learning and recognize progress.',
641
+ });
642
+ const listed = await learner.clients.fullAuth.contracts.getConsentedContracts({});
643
+ const record = listed.records.find(item => item.uri === termsUri)!;
644
+ expect(record.consentedVersion).toBe(1);
645
+ expect(record.cumulativeUpdate).toMatchObject({
646
+ fromVersion: 1,
647
+ toVersion: 3,
648
+ diff: {
649
+ added: expect.arrayContaining([
650
+ expect.objectContaining({ area: 'read', key: 'Transcript', required: true }),
651
+ expect.objectContaining({
652
+ area: 'read',
653
+ key: 'Learning History',
654
+ required: true,
655
+ }),
656
+ expect.objectContaining({
657
+ area: 'write',
658
+ key: 'Social Badge',
659
+ required: false,
660
+ }),
661
+ ]),
662
+ },
663
+ });
664
+
665
+ const terms = structuredClone(record.terms);
666
+ terms.read.credentials.shareAll = false;
667
+ terms.read.credentials.categories['Learning History'] = {
668
+ sharing: true,
669
+ shareAll: true,
670
+ shared: [],
671
+ };
672
+ terms.write.credentials.categories['Social Badge'] = true;
673
+ await expect(
674
+ learner.clients.fullAuth.contracts.updateConsentedContractTerms({
675
+ uri: termsUri,
676
+ terms,
677
+ acceptVersion: 3,
678
+ audienceVersion: 0,
679
+ })
680
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
681
+ expect((await getContractTermsByUri(termsUri))!.terms.consentedVersion).toBe(1);
682
+
683
+ for (const scope of record.cumulativeUpdate!.diff.added) {
684
+ if (scope.area === 'read' && scope.type === 'credential' && scope.required) {
685
+ terms.read.credentials.categories[scope.key] = {
686
+ sharing: true,
687
+ shareAll: true,
688
+ shared: [],
689
+ };
690
+ }
691
+ }
692
+ expect(
693
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
694
+ uri: termsUri,
695
+ terms,
696
+ acceptVersion: record.cumulativeUpdate!.toVersion,
697
+ audienceVersion: 0,
698
+ })
699
+ ).toBe(true);
700
+ const saved = (await getContractTermsByUri(termsUri))!.terms;
701
+ expect(saved).toMatchObject({ consentedVersion: 3, status: 'live' });
702
+ expect(saved.terms.read.credentials.categories.Transcript?.sharing).toBe(true);
703
+ expect(saved.terms.read.credentials.categories['Learning History']?.sharing).toBe(true);
704
+ expect(saved.terms.write.credentials.categories).toMatchObject({
705
+ ...normalFullTerms.write.credentials.categories,
706
+ 'Social Badge': true,
707
+ });
708
+ });
709
+
710
+ it('clears pending optional defaults only after successfully saving the holder choices', async () => {
711
+ const { owner, learner, uri, termsUri } = await setup();
712
+ await owner.clients.fullAuth.contracts.updateConsentFlowContract({
713
+ uri,
714
+ changes: {
715
+ contract: {
716
+ write: {
717
+ credentials: {
718
+ categories: {
719
+ 'Social Badge': { required: false, defaultEnabled: true },
720
+ },
721
+ },
722
+ },
723
+ },
724
+ },
725
+ });
726
+ const pending = (
727
+ await learner.clients.fullAuth.contracts.getConsentedContracts({})
728
+ ).records.find(item => item.uri === termsUri)!;
729
+ expect(pending.newOptionsUntil).toEqual(expect.any(String));
730
+ const terms = structuredClone(pending.terms);
731
+ delete terms.write.credentials.categories['Social Badge'];
732
+ await expect(
733
+ learner.clients.fullAuth.contracts.updateConsentedContractTerms({
734
+ uri: termsUri,
735
+ terms: {
736
+ ...terms,
737
+ read: { ...terms.read, personal: { unrequested: 'not allowed' } },
738
+ },
739
+ audienceVersion: 0,
740
+ })
741
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
742
+ expect((await getContractTermsByUri(termsUri))!.terms.newOptionsUntil).toBe(
743
+ pending.newOptionsUntil
744
+ );
745
+ expect(
746
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
747
+ uri: termsUri,
748
+ terms,
749
+ audienceVersion: 0,
750
+ })
751
+ ).toBe(true);
752
+ const reopened = (
753
+ await learner.clients.fullAuth.contracts.getConsentedContracts({})
754
+ ).records.find(item => item.uri === termsUri)!;
755
+ expect(reopened.newOptionsUntil).toBeUndefined();
756
+ expect(reopened.terms.write.credentials.categories['Social Badge']).toBeUndefined();
757
+ expect(reopened.terms.write.credentials.categories).toEqual(
758
+ normalFullTerms.write.credentials.categories
759
+ );
760
+ });
761
+ });