@learncard/network-brain-service 4.1.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +143 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
@@ -0,0 +1,790 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { getIdFromUri } from '@helpers/uri.helpers';
3
+ import { flattenObject } from '@helpers/objects.helpers';
4
+ import {
5
+ classifyContractChange,
6
+ diffContractChange,
7
+ buildContractUpdatePreview,
8
+ } from '@learncard/helpers';
9
+ import {
10
+ ConsentFlowContractValidator,
11
+ ConsentFlowContractChangeKindValidator,
12
+ } from '@learncard/types';
13
+ import type {
14
+ ConsentFlowContract as ConsentFlowContractType,
15
+ ConsentFlowContractChangeKind,
16
+ ConsentFlowContractUpdateResult,
17
+ ConsentFlowContractChanges,
18
+ ConsentFlowContractDetails,
19
+ ConsentFlowTermsStatus,
20
+ ConsentFlowContractDiff,
21
+ } from '@learncard/types';
22
+ import { z } from 'zod';
23
+ import { migrateLegacyConsentFlowContracts } from '@helpers/consentflow-legacy-migration.helpers';
24
+ import { neogma } from '@instance';
25
+ import { TRPCError } from '@trpc/server';
26
+ import { v4 as uuid } from 'uuid';
27
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
28
+ import { getSigningAuthorityForUserByName } from '@accesslayer/signing-authority/relationships/read';
29
+ import { getBoostById } from '@accesslayer/boost/read';
30
+ import { canProfileIssueBoost } from '@accesslayer/boost/relationships/read';
31
+ import { tryDispatchContractEvent } from '@helpers/contract-events.helpers';
32
+
33
+ export type StoredContractVersion = {
34
+ id: string;
35
+ version: number;
36
+ createdAt: string;
37
+ reason?: string;
38
+ kind?: ConsentFlowContractChangeKind;
39
+ contract: ConsentFlowContractType;
40
+ name: string;
41
+ subtitle?: string;
42
+ description?: string;
43
+ reasonForAccessing?: string;
44
+ image?: string;
45
+ needsGuardianConsent?: boolean;
46
+ redirectUrl?: string;
47
+ frontDoorBoostUri?: string;
48
+ expiresAt?: string;
49
+ autoboosts: {
50
+ id: string;
51
+ signingAuthorityEndpoint: string;
52
+ signingAuthorityName: string;
53
+ issuer?: string;
54
+ }[];
55
+ writers: string[];
56
+ };
57
+
58
+ const StoredAutoBoostValidator = z.object({
59
+ id: z.string(),
60
+ signingAuthorityEndpoint: z.string(),
61
+ signingAuthorityName: z.string(),
62
+ issuer: z
63
+ .string()
64
+ .nullable()
65
+ .optional()
66
+ .transform(value => value ?? undefined),
67
+ });
68
+
69
+ const parseJson = <T>(value: unknown, validator: z.ZodType<T>, field: string): T => {
70
+ try {
71
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
72
+ return validator.parse(parsed);
73
+ } catch {
74
+ throw new TRPCError({
75
+ code: 'PRECONDITION_FAILED',
76
+ message: `Stored contract version has invalid ${field}`,
77
+ });
78
+ }
79
+ };
80
+
81
+ const parseVersion = (raw: Record<string, unknown>): StoredContractVersion =>
82
+ ({
83
+ ...raw,
84
+ version: Number(raw.version),
85
+ contract: parseJson(raw.contract, ConsentFlowContractValidator, 'contract'),
86
+ autoboosts: parseJson(raw.autoboosts, z.array(StoredAutoBoostValidator), 'autoboosts'),
87
+ writers: parseJson(raw.writers, z.array(z.string()), 'writers'),
88
+ ...(raw.kind ? { kind: ConsentFlowContractChangeKindValidator.parse(raw.kind) } : {}),
89
+ }) as StoredContractVersion;
90
+
91
+ const mergeScopePatch = (
92
+ current: ConsentFlowContractType['read']['personal'],
93
+ patch?: Record<string, ConsentFlowContractType['read']['personal'][string] | null>
94
+ ) => {
95
+ if (!patch) return current;
96
+ const merged = { ...current };
97
+ for (const [key, config] of Object.entries(patch)) {
98
+ if (config === null) delete merged[key];
99
+ else merged[key] = { ...current[key], ...config };
100
+ }
101
+ return merged;
102
+ };
103
+
104
+ const mergeConsentFlowContractPatch = (
105
+ current: ConsentFlowContractType,
106
+ patch: ConsentFlowContractChanges['contract']
107
+ ): ConsentFlowContractType => {
108
+ const read = patch?.read
109
+ ? {
110
+ ...current.read,
111
+ ...patch.read,
112
+ credentials: patch.read.credentials
113
+ ? {
114
+ ...current.read.credentials,
115
+ ...patch.read.credentials,
116
+ categories: mergeScopePatch(
117
+ current.read.credentials.categories,
118
+ patch.read.credentials.categories
119
+ ),
120
+ }
121
+ : current.read.credentials,
122
+ personal: mergeScopePatch(current.read.personal, patch.read.personal),
123
+ }
124
+ : current.read;
125
+ const write = patch?.write
126
+ ? {
127
+ ...current.write,
128
+ ...patch.write,
129
+ credentials: patch.write.credentials
130
+ ? {
131
+ ...current.write.credentials,
132
+ ...patch.write.credentials,
133
+ categories: mergeScopePatch(
134
+ current.write.credentials.categories,
135
+ patch.write.credentials.categories
136
+ ),
137
+ }
138
+ : current.write.credentials,
139
+ personal: mergeScopePatch(current.write.personal, patch.write.personal),
140
+ }
141
+ : current.write;
142
+ return ConsentFlowContractValidator.parse({ ...current, read, write });
143
+ };
144
+ export const getConsentFlowContractVersionRecord = async (
145
+ contractId: string,
146
+ versionNumber: number
147
+ ): Promise<StoredContractVersion | null> => {
148
+ let result = await neogma.queryRunner.run(
149
+ `MATCH (:ConsentFlowContract {id:$contractId})-[:HAS_VERSION]->(v:ConsentFlowContractVersion {version:$version})
150
+ RETURN properties(v) AS version LIMIT 1`,
151
+ { contractId, version: versionNumber }
152
+ );
153
+ if (!result.records.length && versionNumber === 1) {
154
+ await migrateLegacyConsentFlowContracts(contractId);
155
+ result = await neogma.queryRunner.run(
156
+ `MATCH (:ConsentFlowContract {id:$contractId})-[:HAS_VERSION]->(v:ConsentFlowContractVersion {version:$version})
157
+ RETURN properties(v) AS version LIMIT 1`,
158
+ { contractId, version: versionNumber }
159
+ );
160
+ }
161
+ const raw = result.records[0]?.get('version') as Record<string, unknown> | undefined;
162
+ return raw ? parseVersion(raw) : null;
163
+ };
164
+
165
+ /** Loads accepted snapshots in one query per bounded page, not per consenter. */
166
+ export const getConsentFlowContractVersionRecords = async (
167
+ requests: { contractId: string; version: number }[]
168
+ ): Promise<Map<string, StoredContractVersion>> => {
169
+ const unique = new Map(
170
+ requests.map(request => [`${request.contractId}:${request.version}`, request])
171
+ );
172
+ const versions = new Map<string, StoredContractVersion>();
173
+ if (!unique.size) return versions;
174
+ const result = await neogma.queryRunner.run(
175
+ `UNWIND $requests AS request
176
+ MATCH (:ConsentFlowContract {id:request.contractId})-[:HAS_VERSION]->(v:ConsentFlowContractVersion {version:request.version})
177
+ RETURN request.contractId AS contractId, properties(v) AS version`,
178
+ { requests: [...unique.values()] }
179
+ );
180
+ for (const record of result.records) {
181
+ const version = parseVersion(record.get('version') as Record<string, unknown>);
182
+ versions.set(`${record.get('contractId')}:${version.version}`, version);
183
+ }
184
+ return versions;
185
+ };
186
+
187
+ export const getConsentFlowContractVersion = getConsentFlowContractVersionRecord;
188
+
189
+ export const getConsentFlowContractVersions = async (uri: string) => {
190
+ const contractId = getIdFromUri(uri);
191
+ const result = await neogma.queryRunner.run(
192
+ `MATCH (c:ConsentFlowContract {id:$contractId})-[:HAS_VERSION]->(v:ConsentFlowContractVersion)
193
+ RETURN properties(v) AS version ORDER BY v.version DESC`,
194
+ { contractId }
195
+ );
196
+ return result.records.map(record =>
197
+ parseVersion(record.get('version') as Record<string, unknown>)
198
+ );
199
+ };
200
+
201
+ export const getContractVersions = getConsentFlowContractVersions;
202
+ type CumulativeContractUpdateRequest = {
203
+ key: string;
204
+ contractId: string;
205
+ acceptedVersion: number;
206
+ status?: ConsentFlowTermsStatus;
207
+ reconsentDeadline?: string;
208
+ };
209
+
210
+ /** Fetches each accepted/current pair once while retaining per-holder status and deadline. */
211
+ export const getCumulativeContractUpdates = async (
212
+ requests: CumulativeContractUpdateRequest[]
213
+ ): Promise<Map<string, NonNullable<ConsentFlowContractDetails['latestUpdate']>>> => {
214
+ const updates = new Map<string, NonNullable<ConsentFlowContractDetails['latestUpdate']>>();
215
+ if (!requests.length) return updates;
216
+ const pairs = new Map(
217
+ requests.map(request => [
218
+ `${request.contractId}:${request.acceptedVersion}`,
219
+ {
220
+ key: `${request.contractId}:${request.acceptedVersion}`,
221
+ contractId: request.contractId,
222
+ acceptedVersion: request.acceptedVersion,
223
+ },
224
+ ])
225
+ );
226
+ const result = await neogma.queryRunner.run(
227
+ `UNWIND $requests AS request
228
+ OPTIONAL MATCH (c:ConsentFlowContract {id:request.contractId})
229
+ OPTIONAL MATCH (c)-[:HAS_VERSION]->(accepted:ConsentFlowContractVersion {version:request.acceptedVersion})
230
+ OPTIONAL MATCH (c)-[:HAS_VERSION]->(current:ConsentFlowContractVersion {version:c.currentVersion})
231
+ OPTIONAL MATCH (c)-[:HAS_VERSION]->(intermediate:ConsentFlowContractVersion)
232
+ WHERE intermediate.version > request.acceptedVersion
233
+ AND intermediate.version <= c.currentVersion AND intermediate.kind='requires_consent'
234
+ WITH request, c, accepted, current, intermediate ORDER BY intermediate.version ASC
235
+ RETURN request.key AS key, c.id AS contractId, c.currentVersion AS currentVersion,
236
+ c.latestUpdate AS latestUpdate, properties(accepted) AS accepted, properties(current) AS current,
237
+ head(collect(CASE WHEN intermediate IS NULL THEN null ELSE {reason:intermediate.reason} END)) AS firstRequired`,
238
+ { requests: [...pairs.values()] }
239
+ );
240
+ const rows = new Map(result.records.map(record => [String(record.get('key')), record]));
241
+ const parsedPairs = new Map<
242
+ string,
243
+ {
244
+ current: StoredContractVersion;
245
+ diff: ConsentFlowContractDiff;
246
+ kind: ConsentFlowContractChangeKind;
247
+ reason?: string;
248
+ requiredReason?: string;
249
+ deadline?: string;
250
+ }
251
+ >();
252
+ for (const request of requests) {
253
+ const key = `${request.contractId}:${request.acceptedVersion}`;
254
+ const record = rows.get(key);
255
+ if (!record?.get('contractId'))
256
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Contract not found' });
257
+ if (request.acceptedVersion >= Number(record.get('currentVersion') ?? 1)) continue;
258
+ let pair = parsedPairs.get(key);
259
+ if (!pair) {
260
+ if (!record.get('accepted') || !record.get('current'))
261
+ throw new TRPCError({
262
+ code: 'PRECONDITION_FAILED',
263
+ message: 'Contract version snapshot is missing',
264
+ });
265
+ const accepted = parseVersion(record.get('accepted') as Record<string, unknown>);
266
+ const current = parseVersion(record.get('current') as Record<string, unknown>);
267
+ const latest = record.get('latestUpdate')
268
+ ? parseJson(
269
+ record.get('latestUpdate'),
270
+ z.object({
271
+ reason: z.string().optional(),
272
+ reconsentDeadline: z.string().optional(),
273
+ }),
274
+ 'latest update'
275
+ )
276
+ : undefined;
277
+ const firstRequired = record.get('firstRequired') as { reason?: string } | null;
278
+ pair = {
279
+ current,
280
+ diff: diffContractChange(accepted, current),
281
+ kind: classifyContractChange(accepted, current),
282
+ reason: latest?.reason,
283
+ requiredReason: firstRequired?.reason ?? latest?.reason,
284
+ deadline: latest?.reconsentDeadline,
285
+ };
286
+ parsedPairs.set(key, pair);
287
+ }
288
+ const requiresApproval =
289
+ request.status === 'needs_reconsent' || request.status === 'suspended';
290
+ const kind = requiresApproval ? 'requires_consent' : pair.kind;
291
+ const reason = requiresApproval ? pair.requiredReason : pair.reason;
292
+ const deadline = requiresApproval
293
+ ? (request.reconsentDeadline ?? pair.deadline)
294
+ : undefined;
295
+ updates.set(request.key, {
296
+ fromVersion: request.acceptedVersion,
297
+ toVersion: pair.current.version,
298
+ kind,
299
+ ...(reason ? { reason } : {}),
300
+ diff: pair.diff,
301
+ ...(deadline ? { reconsentDeadline: deadline } : {}),
302
+ createdAt: pair.current.createdAt,
303
+ preview: buildContractUpdatePreview({
304
+ name: pair.current.name,
305
+ kind,
306
+ diff: pair.diff,
307
+ status: request.status,
308
+ }),
309
+ });
310
+ }
311
+ return updates;
312
+ };
313
+
314
+ export const getCumulativeContractUpdateById = async (
315
+ contractId: string,
316
+ acceptedVersion: number,
317
+ status?: ConsentFlowTermsStatus,
318
+ reconsentDeadline?: string
319
+ ): Promise<ConsentFlowContractDetails['latestUpdate']> =>
320
+ (
321
+ await getCumulativeContractUpdates([
322
+ { key: contractId, contractId, acceptedVersion, status, reconsentDeadline },
323
+ ])
324
+ ).get(contractId);
325
+
326
+ export const getCumulativeContractUpdate = async (
327
+ uri: string,
328
+ acceptedVersion: number,
329
+ status?: ConsentFlowTermsStatus,
330
+ reconsentDeadline?: string
331
+ ): Promise<ConsentFlowContractDetails['latestUpdate']> =>
332
+ getCumulativeContractUpdateById(getIdFromUri(uri), acceptedVersion, status, reconsentDeadline);
333
+
334
+ type ContractChanges = {
335
+ contract?: ConsentFlowContractChanges['contract'];
336
+ name?: string;
337
+ subtitle?: string;
338
+ description?: string;
339
+ reasonForAccessing?: string;
340
+ image?: string;
341
+ needsGuardianConsent?: boolean;
342
+ redirectUrl?: string;
343
+ frontDoorBoostUri?: string;
344
+ expiresAt?: string | null;
345
+ autoboosts?: Omit<StoredContractVersion['autoboosts'][number], 'issuer'>[];
346
+ writers?: string[];
347
+ recipients?: string[];
348
+ };
349
+
350
+ export const updateConsentFlowContractVersion = async (
351
+ {
352
+ uri,
353
+ changes,
354
+ reason,
355
+ reconsentDeadline,
356
+ dryRun,
357
+ expectedVersion,
358
+ }: {
359
+ uri: string;
360
+ changes: ContractChanges;
361
+ reason?: string;
362
+ reconsentDeadline?: string;
363
+ dryRun?: boolean;
364
+ expectedVersion?: number;
365
+ },
366
+ actorProfileId: string,
367
+ domain: string,
368
+ appBaseUrl?: string
369
+ ): Promise<ConsentFlowContractUpdateResult> => {
370
+ const contractId = getIdFromUri(uri);
371
+ const storedResult = await neogma.queryRunner.run(
372
+ `MATCH (c:ConsentFlowContract {id:$id})
373
+ RETURN properties(c) AS contract, c.status AS contractStatus, c.currentVersion AS currentVersion,
374
+ [(c)-[r:AUTO_RECEIVE]->(b:Boost) | {id:b.id,signingAuthorityEndpoint:r.signingAuthorityEndpoint,signingAuthorityName:r.signingAuthorityName,issuer:r.issuer}] AS autoBoosts,
375
+ [(c)-[:CREATED_BY]->(owner:Profile) | owner.profileId][0] AS ownerProfileId,
376
+ EXISTS { MATCH (c)-[:CREATED_BY]->(:Profile {profileId:$actor}) } AS owner,
377
+ EXISTS { MATCH (c)-[:CAN_WRITE]->(:Profile {profileId:$actor}) } AS writer`,
378
+ { id: contractId, actor: actorProfileId }
379
+ );
380
+ const stored = storedResult.records[0];
381
+ if (!stored) throw new TRPCError({ code: 'NOT_FOUND', message: 'Contract not found' });
382
+ if (!stored.get('owner')) {
383
+ throw new TRPCError({
384
+ code: 'FORBIDDEN',
385
+ message: 'Only the contract owner can update it',
386
+ });
387
+ }
388
+ if (stored.get('contractStatus') === 'retired') {
389
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'Retired contracts cannot be updated' });
390
+ }
391
+ if (changes.recipients !== undefined) {
392
+ throw new TRPCError({
393
+ code: 'BAD_REQUEST',
394
+ message: 'Use addContractRecipient or removeContractRecipient to update recipients',
395
+ });
396
+ }
397
+ const currentVersion = Number(stored.get('currentVersion') ?? 1);
398
+ if (expectedVersion !== undefined && expectedVersion !== currentVersion) {
399
+ throw new TRPCError({
400
+ code: 'CONFLICT',
401
+ message: 'Contract changed since this draft was opened; reload before publishing',
402
+ });
403
+ }
404
+ const current = await getConsentFlowContractVersionRecord(contractId, currentVersion);
405
+ if (!current)
406
+ throw new TRPCError({
407
+ code: 'PRECONDITION_FAILED',
408
+ message: 'Contract version snapshot is missing',
409
+ });
410
+ const existingAutoBoosts =
411
+ (stored.get('autoBoosts') as StoredContractVersion['autoboosts'] | null) ?? [];
412
+ const requestedAutoBoosts = changes.autoboosts ?? current.autoboosts;
413
+ const uniqueAutoBoosts = new Map<string, (typeof requestedAutoBoosts)[number]>();
414
+ for (const config of requestedAutoBoosts) {
415
+ const previous = uniqueAutoBoosts.get(config.id);
416
+ if (
417
+ previous &&
418
+ (previous.signingAuthorityEndpoint !== config.signingAuthorityEndpoint ||
419
+ previous.signingAuthorityName !== config.signingAuthorityName)
420
+ ) {
421
+ throw new TRPCError({
422
+ code: 'BAD_REQUEST',
423
+ message: `Conflicting signing authorities for autoboost: ${config.id}`,
424
+ });
425
+ }
426
+ uniqueAutoBoosts.set(config.id, config);
427
+ }
428
+ const requestedWriterIds = [...new Set(changes.writers ?? current.writers)];
429
+ const targets = await neogma.queryRunner.run(
430
+ `OPTIONAL MATCH (boost:Boost) WHERE boost.id IN $boostIds
431
+ WITH collect(DISTINCT boost.id) AS boostIds
432
+ OPTIONAL MATCH (writer:Profile) WHERE writer.profileId IN $writerIds
433
+ RETURN boostIds, collect(DISTINCT writer.profileId) AS writerIds`,
434
+ { boostIds: [...uniqueAutoBoosts.keys()], writerIds: requestedWriterIds }
435
+ );
436
+ const existingBoostIds = new Set((targets.records[0]?.get('boostIds') as string[]) ?? []);
437
+ const existingWriterIds = new Set((targets.records[0]?.get('writerIds') as string[]) ?? []);
438
+ const missingBoost = [...uniqueAutoBoosts.keys()].find(id => !existingBoostIds.has(id));
439
+ const missingWriter = requestedWriterIds.find(id => !existingWriterIds.has(id));
440
+ if (missingBoost || missingWriter) {
441
+ throw new TRPCError({
442
+ code: 'BAD_REQUEST',
443
+ message: missingBoost
444
+ ? `Unknown AutoBoost: ${missingBoost}`
445
+ : `Unknown writer: ${missingWriter}`,
446
+ });
447
+ }
448
+ const autoboosts = await Promise.all(
449
+ [...uniqueAutoBoosts.values()].map(async config => {
450
+ const existing = existingAutoBoosts.find(item => item.id === config.id);
451
+ const unchanged =
452
+ existing &&
453
+ existing.signingAuthorityEndpoint === config.signingAuthorityEndpoint &&
454
+ existing.signingAuthorityName === config.signingAuthorityName;
455
+ if (existing?.issuer && existing.issuer !== actorProfileId && !unchanged) {
456
+ throw new TRPCError({
457
+ code: 'FORBIDDEN',
458
+ message:
459
+ 'Existing delegated delivery cannot change signers; remove it before configuring owner delivery',
460
+ });
461
+ }
462
+ const issuer = unchanged ? (existing.issuer ?? actorProfileId) : actorProfileId;
463
+ if (!unchanged) {
464
+ const issuerProfile = await getProfileByProfileId(issuer);
465
+ const boost = await getBoostById(config.id);
466
+ if (
467
+ !issuerProfile ||
468
+ !boost ||
469
+ !(await canProfileIssueBoost(issuerProfile, boost))
470
+ ) {
471
+ throw new TRPCError({
472
+ code: 'UNAUTHORIZED',
473
+ message: 'The contract owner cannot issue this AutoBoost',
474
+ });
475
+ }
476
+ const signingAuthority =
477
+ issuerProfile &&
478
+ (await getSigningAuthorityForUserByName(
479
+ issuerProfile,
480
+ config.signingAuthorityEndpoint,
481
+ config.signingAuthorityName.toLowerCase()
482
+ ));
483
+ if (!signingAuthority) {
484
+ throw new TRPCError({
485
+ code: 'FORBIDDEN',
486
+ message: `Signing authority ${config.signingAuthorityName} is not registered for autoboost issuer ${issuer}`,
487
+ });
488
+ }
489
+ }
490
+ return {
491
+ id: config.id,
492
+ signingAuthorityEndpoint: config.signingAuthorityEndpoint,
493
+ signingAuthorityName: config.signingAuthorityName,
494
+ issuer,
495
+ };
496
+ })
497
+ );
498
+ const next: StoredContractVersion = {
499
+ ...current,
500
+ ...Object.fromEntries(
501
+ Object.entries(changes).filter(
502
+ ([key, value]) =>
503
+ key !== 'contract' &&
504
+ key !== 'recipients' &&
505
+ key !== 'expiresAt' &&
506
+ value !== undefined
507
+ )
508
+ ),
509
+ contract: changes.contract
510
+ ? mergeConsentFlowContractPatch(current.contract, changes.contract)
511
+ : current.contract,
512
+ expiresAt:
513
+ changes.expiresAt === null ? undefined : (changes.expiresAt ?? current.expiresAt),
514
+ autoboosts,
515
+ writers: changes.writers ?? current.writers,
516
+ };
517
+ const kind = classifyContractChange(current, next);
518
+ const diff = diffContractChange(current, next);
519
+ if (reason && reason.length > 140) {
520
+ throw new TRPCError({
521
+ code: 'BAD_REQUEST',
522
+ message: 'Reason must be at most 140 characters',
523
+ });
524
+ }
525
+ if (kind === 'requires_consent' && !reason) {
526
+ throw new TRPCError({
527
+ code: 'BAD_REQUEST',
528
+ message: 'A reason is required when asking users for more access',
529
+ });
530
+ }
531
+ if (next.expiresAt && !z.iso.datetime().safeParse(next.expiresAt).success) {
532
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'expiresAt must be a valid ISO date' });
533
+ }
534
+ if (
535
+ reconsentDeadline &&
536
+ (!z.iso.datetime().safeParse(reconsentDeadline).success ||
537
+ Date.parse(reconsentDeadline) <= Date.now())
538
+ ) {
539
+ throw new TRPCError({
540
+ code: 'BAD_REQUEST',
541
+ message: 'reconsentDeadline must be a future ISO date',
542
+ });
543
+ }
544
+ const effectiveDeadline =
545
+ kind === 'requires_consent'
546
+ ? (reconsentDeadline ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString())
547
+ : undefined;
548
+ const preview = buildContractUpdatePreview({ name: next.name, kind, diff });
549
+ const reviewUrl = `${(appBaseUrl ?? domain).replace(/\/+$/, '')}/consent-flow/review?uri=${encodeURIComponent(uri)}`;
550
+ const contractOwnerProfileId = stored.get('ownerProfileId');
551
+ if (typeof contractOwnerProfileId !== 'string') {
552
+ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Contract owner is missing' });
553
+ }
554
+ const currentAutoBoosts = new Map(current.autoboosts.map(config => [config.id, config]));
555
+ const autoBoostsUnchanged =
556
+ autoboosts.length === currentAutoBoosts.size &&
557
+ autoboosts.every(config => {
558
+ const previous = currentAutoBoosts.get(config.id);
559
+ return (
560
+ previous?.signingAuthorityEndpoint === config.signingAuthorityEndpoint &&
561
+ previous.signingAuthorityName === config.signingAuthorityName &&
562
+ (previous.issuer ?? contractOwnerProfileId) === config.issuer
563
+ );
564
+ });
565
+ if (
566
+ isDeepStrictEqual(current.contract, next.contract) &&
567
+ !diff.details?.length &&
568
+ autoBoostsUnchanged
569
+ ) {
570
+ return { version: currentVersion, kind, affectedUsers: 0, preview };
571
+ }
572
+ const affectedUsers = await neogma.queryRunner
573
+ .run(
574
+ `MATCH (profile:Profile)-[:CREATED_BY]->(t:ConsentFlowTerms)-[:CONSENTS_TO]->(c:ConsentFlowContract {id:$id})
575
+ WHERE t.status IN ['live','needs_reconsent','suspended'] RETURN count(DISTINCT profile.profileId) AS count`,
576
+ { id: contractId }
577
+ )
578
+ .then(r => Number(r.records[0]?.get('count') ?? 0));
579
+ const eventId = `${contractId}:version:${currentVersion + 1}`;
580
+ const notificationData = {
581
+ contractUri: uri,
582
+ contractName: next.name,
583
+ fromVersion: currentVersion,
584
+ toVersion: currentVersion + 1,
585
+ kind,
586
+ ...(reason ? { reason } : {}),
587
+ diff,
588
+ ...(effectiveDeadline ? { reconsentDeadline: effectiveDeadline } : {}),
589
+ reviewUrl,
590
+ preview,
591
+ };
592
+ const result = { version: currentVersion + 1, kind, affectedUsers, preview };
593
+ if (dryRun) return result;
594
+ const createdAt = new Date().toISOString();
595
+ const versionId = uuid();
596
+ const removedTermPrefixes = diff.removed.map(
597
+ scope =>
598
+ `terms.${scope.area}.${scope.type === 'credential' ? 'credentials.categories' : 'personal'}.${scope.key}`
599
+ );
600
+ const optionalOffValues: { key: string; value: boolean | string | string[] }[] = diff.added
601
+ .filter(scope => !scope.required)
602
+ .flatMap<{ key: string; value: boolean | string | string[] }>(scope => {
603
+ const prefix = `terms.${scope.area}.${scope.type === 'credential' ? 'credentials.categories' : 'personal'}.${scope.key}`;
604
+ if (scope.area === 'write') return [{ key: prefix, value: false }];
605
+ if (scope.type === 'personal') return [];
606
+ return [
607
+ { key: `${prefix}.sharing`, value: false },
608
+ { key: `${prefix}.shared`, value: [] as string[] },
609
+ { key: `${prefix}.shareAll`, value: false },
610
+ ];
611
+ });
612
+ const disableReadShareAll =
613
+ kind === 'expanded_optional' &&
614
+ diff.added.some(
615
+ scope => scope.area === 'read' && scope.type === 'credential' && !scope.required
616
+ );
617
+ const preservedReadGrantKeys = Object.keys(next.contract.read.credentials.categories)
618
+ .filter(
619
+ key =>
620
+ Object.prototype.hasOwnProperty.call(
621
+ current.contract.read.credentials.categories,
622
+ key
623
+ ) &&
624
+ !diff.added.some(
625
+ scope =>
626
+ scope.area === 'read' && scope.type === 'credential' && scope.key === key
627
+ )
628
+ )
629
+ .map(key => `terms.read.credentials.categories.${key}.shareAll`);
630
+ const writerIds = [...new Set(next.writers)];
631
+ const contractProperties = flattenObject({
632
+ ...Object.fromEntries(
633
+ Object.entries(next).filter(
634
+ ([key, value]) =>
635
+ value !== undefined &&
636
+ ![
637
+ 'id',
638
+ 'version',
639
+ 'createdAt',
640
+ 'reason',
641
+ 'kind',
642
+ 'contract',
643
+ 'autoboosts',
644
+ 'writers',
645
+ ].includes(key)
646
+ )
647
+ ),
648
+ contract: next.contract,
649
+ currentVersion: currentVersion + 1,
650
+ status: 'active',
651
+ updatedAt: createdAt,
652
+ });
653
+ const committed = await neogma.queryRunner.run(
654
+ `MATCH (c:ConsentFlowContract {id:$id})
655
+ SET c._contractVersionLock=coalesce(c._contractVersionLock,0)+1
656
+ WITH c,
657
+ EXISTS { MATCH (c)-[:CREATED_BY]->(:Profile {profileId:$actor}) } AS authorized,
658
+ coalesce(c.currentVersion,1) = $expectedVersion AS currentMatches,
659
+ coalesce(c.status,'active') <> 'retired' AS active
660
+ FOREACH (_ IN CASE WHEN authorized AND currentMatches AND active THEN [] ELSE [1] END |
661
+ REMOVE c._contractVersionLock)
662
+ WITH c WHERE authorized AND currentMatches AND active
663
+ OPTIONAL MATCH (c)-[:CONSENTS_TO]-(t:ConsentFlowTerms)
664
+ WITH c, collect(DISTINCT t) AS terms
665
+ MATCH (c)-[:CREATED_BY]->(owner:Profile)
666
+ OPTIONAL MATCH (c)-[:CONSENTS_TO]-(holderTerms:ConsentFlowTerms)<-[:CREATED_BY]-(holder:Profile)
667
+ WHERE holderTerms.status IN ['live','needs_reconsent','suspended']
668
+ WITH c, terms, owner, collect(DISTINCT holder.profileId) AS holderIds
669
+ OPTIONAL MATCH (c)-[oldAuto:AUTO_RECEIVE]->(:Boost)
670
+ WITH c, terms, owner, holderIds, collect(oldAuto) AS oldAuto
671
+ OPTIONAL MATCH (c)-[oldWriter:CAN_WRITE]->(:Profile)
672
+ WITH c, terms, owner, holderIds, oldAuto, collect(oldWriter) AS oldWriters
673
+ OPTIONAL MATCH (boost:Boost) WHERE boost.id IN $boostIds
674
+ WITH c, terms, owner, holderIds, oldAuto, oldWriters, collect(DISTINCT boost) AS boosts
675
+ FOREACH (_ IN CASE WHEN size(boosts) = size($boostIds) THEN [] ELSE [1] END | REMOVE c._contractVersionLock)
676
+ WITH c, terms, owner, holderIds, oldAuto, oldWriters, boosts
677
+ WHERE size(boosts) = size($boostIds)
678
+ OPTIONAL MATCH (writer:Profile) WHERE writer.profileId IN $writerIds
679
+ WITH c, terms, owner, holderIds, oldAuto, oldWriters, boosts, collect(DISTINCT writer) AS writers
680
+ FOREACH (_ IN CASE WHEN size(writers) = size($writerIds) THEN [] ELSE [1] END | REMOVE c._contractVersionLock)
681
+ WITH c, terms, owner, holderIds, oldAuto, oldWriters, boosts, writers
682
+ WHERE size(writers) = size($writerIds)
683
+ CREATE (v:ConsentFlowContractVersion {id:$versionId, version:$nextVersion, createdAt:$createdAt,
684
+ contract:$contract, autoboosts:$autoboostSnapshot, writers:$writerSnapshot, reason:$reason, kind:$kind,
685
+ name:$name, subtitle:$subtitle, description:$description, reasonForAccessing:$reasonForAccessing,
686
+ image:$image, needsGuardianConsent:$needsGuardianConsent, redirectUrl:$redirectUrl,
687
+ frontDoorBoostUri:$frontDoorBoostUri, expiresAt:$expiresAt})
688
+ CREATE (c)-[:HAS_VERSION]->(v)
689
+ FOREACH (key IN [key IN keys(c)
690
+ WHERE (key STARTS WITH 'contract.' OR key IN ['name','subtitle','description','reasonForAccessing','image','needsGuardianConsent','redirectUrl','frontDoorBoostUri','expiresAt'])
691
+ AND NOT (key IN keys($contractProperties))] | SET c[key]=null)
692
+ SET c.currentVersion=$nextVersion, c.status='active', c += $contractProperties,
693
+ c.latestUpdate=$latestUpdate, c.updatedAt=$createdAt
694
+ FOREACH (relationship IN oldAuto | DELETE relationship)
695
+ FOREACH (relationship IN oldWriters | DELETE relationship)
696
+ FOREACH (config IN $autoBoostConfigs |
697
+ FOREACH (boost IN [target IN boosts WHERE target.id=config.id] |
698
+ CREATE (c)-[:AUTO_RECEIVE {signingAuthorityEndpoint:config.signingAuthorityEndpoint,
699
+ signingAuthorityName:config.signingAuthorityName, issuer:config.issuer}]->(boost)))
700
+ FOREACH (writer IN writers | CREATE (c)-[:CAN_WRITE]->(writer))
701
+ REMOVE c._contractVersionLock
702
+ FOREACH (term IN terms |
703
+ FOREACH (_ IN CASE WHEN term.status IN ['live','needs_reconsent','suspended'] THEN [1] ELSE [] END |
704
+ SET term.lastReviewedVersion=coalesce(term.lastReviewedVersion,term.consentedVersion,1)
705
+ SET term.mutationVersion=coalesce(term.mutationVersion,0)+1
706
+ CREATE (transaction:ConsentFlowTransaction {
707
+ id:randomUUID(), action:'update', date:$createdAt, version:$nextVersion, kind:$kind
708
+ })-[:IS_FOR]->(term))
709
+ FOREACH (_ IN CASE WHEN term.status = 'live' AND $kind <> 'requires_consent' THEN [1] ELSE [] END |
710
+ FOREACH (key IN [key IN keys(term) WHERE any(prefix IN $removedTermPrefixes WHERE key = prefix OR key STARTS WITH prefix + '.')] | SET term[key] = null)
711
+ FOREACH (entry IN $optionalOffValues | SET term[entry.key] = entry.value)
712
+ FOREACH (_ IN CASE WHEN $disableReadShareAll AND coalesce(term['terms.read.credentials.shareAll'],false) THEN [1] ELSE [] END |
713
+ SET term['terms.read.credentials.shareAll']=false
714
+ FOREACH (key IN $preservedReadGrantKeys | SET term[key]=true))
715
+ FOREACH (_ IN CASE WHEN term['guardianApproval.contractUpdatedAt'] = $previousVersionCreatedAt THEN [1] ELSE [] END |
716
+ SET term['guardianApproval.contractUpdatedAt']=$createdAt)
717
+ SET term.consentedVersion=$nextVersion,
718
+ term.newOptionsUntil=CASE WHEN $kind = 'expanded_optional' THEN $optionsUntil ELSE term.newOptionsUntil END,
719
+ term.updatedAt=$createdAt)
720
+ FOREACH (_ IN CASE WHEN $kind = 'requires_consent' AND term.status = 'live' THEN [1] ELSE [] END |
721
+ SET term.status='needs_reconsent', term.reconsentDeadline=coalesce(term.reconsentDeadline,$deadline), term.updatedAt=$createdAt))
722
+ CREATE (event:ConsentFlowEvent {
723
+ id:$eventId, contractId:c.id, fromId:owner.profileId, ownerId:owner.profileId,
724
+ domain:$eventDomain, kind:'contract_updated', payload:$eventPayload,
725
+ cleanupPending:true, createdAt:$createdAt
726
+ })
727
+ FOREACH (holderId IN [id IN holderIds WHERE id <> owner.profileId] |
728
+ CREATE (event)-[:HAS_DELIVERY]->(:ConsentFlowEventDelivery {
729
+ id:event.id + ':' + holderId, toId:holderId, state:'pending',
730
+ nextAttemptAt:$createdAt, eventCreatedAt:$createdAt, attempts:0, role:'target'
731
+ }))
732
+ RETURN c.currentVersion AS version`,
733
+ {
734
+ id: contractId,
735
+ actor: actorProfileId,
736
+ expectedVersion: expectedVersion ?? currentVersion,
737
+ nextVersion: currentVersion + 1,
738
+ versionId,
739
+ createdAt,
740
+ contract: JSON.stringify(next.contract),
741
+ autoboostSnapshot: JSON.stringify(next.autoboosts),
742
+ writerSnapshot: JSON.stringify(next.writers),
743
+ autoBoostConfigs: next.autoboosts,
744
+ boostIds: next.autoboosts.map(config => config.id),
745
+ writerIds,
746
+ reason: reason ?? null,
747
+ kind,
748
+ contractProperties,
749
+ name: next.name,
750
+ subtitle: next.subtitle ?? null,
751
+ description: next.description ?? null,
752
+ reasonForAccessing: next.reasonForAccessing ?? null,
753
+ image: next.image ?? null,
754
+ needsGuardianConsent: next.needsGuardianConsent ?? false,
755
+ redirectUrl: next.redirectUrl ?? null,
756
+ frontDoorBoostUri: next.frontDoorBoostUri ?? null,
757
+ expiresAt: next.expiresAt ?? null,
758
+ latestUpdate: JSON.stringify({
759
+ fromVersion: currentVersion,
760
+ toVersion: currentVersion + 1,
761
+ kind,
762
+ reason,
763
+ diff,
764
+ reconsentDeadline: effectiveDeadline,
765
+ createdAt,
766
+ preview,
767
+ }),
768
+ deadline: effectiveDeadline ?? null,
769
+ optionsUntil:
770
+ kind === 'expanded_optional'
771
+ ? new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString()
772
+ : null,
773
+ removedTermPrefixes,
774
+ optionalOffValues,
775
+ disableReadShareAll,
776
+ preservedReadGrantKeys,
777
+ previousVersionCreatedAt: current.createdAt,
778
+ eventId,
779
+ eventDomain: domain,
780
+ eventPayload: JSON.stringify(notificationData),
781
+ }
782
+ );
783
+ if (!committed.records.length)
784
+ throw new TRPCError({
785
+ code: 'CONFLICT',
786
+ message: 'Contract changed concurrently; retry the update',
787
+ });
788
+ await tryDispatchContractEvent(eventId);
789
+ return result;
790
+ };