@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,158 @@
1
+ import Fastify from 'fastify';
2
+ import { readFileSync } from 'node:fs';
3
+ import { parse } from 'yaml';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { TRPCError } from '@trpc/server';
6
+ const unsubscribe = vi.hoisted(() => vi.fn());
7
+ vi.mock('@helpers/campaign-unsubscribe.helpers', () => ({
8
+ unsubscribeFromCampaignEmails: unsubscribe,
9
+ }));
10
+ import { campaignUnsubscribeFastifyPlugin } from './campaign-unsubscribe';
11
+ beforeEach(() => {
12
+ unsubscribe.mockReset();
13
+ });
14
+ describe('one-click unsubscribe HTTP', () => {
15
+ it.each(['serverless.yml', 'serverless-local.yml', 'serverless.local.yml'])(
16
+ 'exposes the browser and provider routes in %s',
17
+ manifest => {
18
+ const config = parse(readFileSync(new URL(`../${manifest}`, import.meta.url), 'utf8'));
19
+ expect(config.functions.campaignUnsubscribe.handler).toBe(
20
+ 'lambda.campaignUnsubscribeHandler'
21
+ );
22
+ expect(config.functions.campaignUnsubscribe.events).toEqual(
23
+ expect.arrayContaining([
24
+ { httpApi: 'GET /campaigns/unsubscribe/browser' },
25
+ { httpApi: 'POST /campaigns/unsubscribe/browser' },
26
+ { httpApi: 'POST /campaigns/unsubscribe' },
27
+ ])
28
+ );
29
+ }
30
+ );
31
+
32
+ it.each([
33
+ ['es-MX', 'es', 'ltr', 'Cancelar suscripción'],
34
+ ['fr-FR', 'fr', 'ltr', 'Se désinscrire'],
35
+ ['ar-EG', 'ar', 'rtl', 'إلغاء الاشتراك'],
36
+ ])('localizes the browser page for %s', async (language, locale, direction, label) => {
37
+ const app = Fastify();
38
+ await app.register(campaignUnsubscribeFastifyPlugin);
39
+ try {
40
+ const page = await app.inject({
41
+ url: '/campaigns/unsubscribe/browser?token=signed',
42
+ headers: { 'accept-language': language },
43
+ });
44
+ expect(page.body).toContain(`lang="${locale}" dir="${direction}"`);
45
+ expect(page.body).toContain(`${label}</button>`);
46
+ expect(unsubscribe).not.toHaveBeenCalled();
47
+ } finally {
48
+ await app.close();
49
+ }
50
+ });
51
+
52
+ it('serves an anonymous browser confirmation and applies only explicit form submissions', async () => {
53
+ const app = Fastify();
54
+ await app.register(campaignUnsubscribeFastifyPlugin);
55
+ try {
56
+ unsubscribe.mockResolvedValue(true);
57
+ const page = await app.inject('/campaigns/unsubscribe/browser?token=signed');
58
+ expect(page.statusCode).toBe(200);
59
+ expect(page.headers['content-type']).toContain('text/html');
60
+ expect(page.headers['cache-control']).toBe('no-store');
61
+ expect(page.headers['referrer-policy']).toBe('no-referrer');
62
+ expect(page.headers['content-security-policy']).toContain("form-action 'self'");
63
+ expect(page.body).toContain('action="/campaigns/unsubscribe/browser"');
64
+ expect(page.body).toContain('name="token" value="signed"');
65
+ expect(page.body).toContain('Unsubscribe</button>');
66
+ expect(unsubscribe).not.toHaveBeenCalled();
67
+ const submitted = await app.inject({
68
+ method: 'POST',
69
+ url: '/campaigns/unsubscribe/browser',
70
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
71
+ payload: 'token=signed',
72
+ });
73
+ expect(submitted.statusCode).toBe(200);
74
+ expect(submitted.body).toContain('You’re unsubscribed');
75
+ expect(submitted.body).not.toContain('signed');
76
+ expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('signed');
77
+ } finally {
78
+ await app.close();
79
+ }
80
+ });
81
+
82
+ it('renders invalid and transient errors without leaking provider errors or unsafe markup', async () => {
83
+ const app = Fastify();
84
+ await app.register(campaignUnsubscribeFastifyPlugin);
85
+ try {
86
+ const attack = await app.inject(
87
+ '/campaigns/unsubscribe/browser?token=%22%3E%3Cscript%3E'
88
+ );
89
+ expect(attack.statusCode).toBe(400);
90
+ expect(attack.body).not.toContain('<script>');
91
+ expect(unsubscribe).not.toHaveBeenCalled();
92
+ for (const error of [
93
+ new TRPCError({ code: 'BAD_REQUEST' }),
94
+ new Error('private details'),
95
+ ]) {
96
+ unsubscribe.mockRejectedValueOnce(error);
97
+ const response = await app.inject({
98
+ method: 'POST',
99
+ url: '/campaigns/unsubscribe/browser',
100
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
101
+ payload: 'token=signed',
102
+ });
103
+ expect(response.statusCode).toBe(error instanceof TRPCError ? 400 : 503);
104
+ expect(response.body).toContain('role="alert"');
105
+ expect(response.body).not.toContain('private details');
106
+ expect(response.body.includes('Try Again')).toBe(!(error instanceof TRPCError));
107
+ }
108
+ } finally {
109
+ await app.close();
110
+ }
111
+ });
112
+
113
+ it('handles actual form POSTs without auth and does not mutate on GET', async () => {
114
+ const app = Fastify();
115
+ await app.register(campaignUnsubscribeFastifyPlugin);
116
+ try {
117
+ unsubscribe.mockResolvedValue(true);
118
+ const response = await app.inject({
119
+ method: 'POST',
120
+ url: '/campaigns/unsubscribe?token=signed',
121
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
122
+ payload: 'List-Unsubscribe=One-Click',
123
+ });
124
+ expect(response.statusCode).toBe(200);
125
+ expect(response.headers['cache-control']).toBe('no-store');
126
+ expect(unsubscribe).toHaveBeenCalledWith('signed');
127
+ unsubscribe.mockClear();
128
+ expect(
129
+ (await app.inject({ method: 'GET', url: '/campaigns/unsubscribe?token=signed' }))
130
+ .statusCode
131
+ ).toBe(404);
132
+ expect(
133
+ (
134
+ await app.inject({
135
+ method: 'POST',
136
+ url: '/campaigns/unsubscribe?token=signed',
137
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
138
+ payload: 'other=value',
139
+ })
140
+ ).statusCode
141
+ ).toBe(400);
142
+ expect(unsubscribe).not.toHaveBeenCalled();
143
+ unsubscribe.mockRejectedValue(new TRPCError({ code: 'BAD_REQUEST' }));
144
+ expect(
145
+ (
146
+ await app.inject({
147
+ method: 'POST',
148
+ url: '/campaigns/unsubscribe?token=tampered',
149
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
150
+ payload: 'List-Unsubscribe=One-Click',
151
+ })
152
+ ).statusCode
153
+ ).toBe(400);
154
+ } finally {
155
+ await app.close();
156
+ }
157
+ });
158
+ });
@@ -0,0 +1,86 @@
1
+ import Fastify, { type FastifyPluginAsync } from 'fastify';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { unsubscribeFromCampaignEmails } from '@helpers/campaign-unsubscribe.helpers';
4
+ import { renderCampaignUnsubscribePage } from '@helpers/campaign-unsubscribe-page';
5
+
6
+ /** RFC 8058 endpoint: form POSTs work without cookies, redirects, or a JavaScript client. */
7
+ export const campaignUnsubscribeFastifyPlugin: FastifyPluginAsync = async app => {
8
+ app.addContentTypeParser(
9
+ 'application/x-www-form-urlencoded',
10
+ { parseAs: 'string', bodyLimit: 1024 },
11
+ (_request, body, done) => {
12
+ done(null, new URLSearchParams(String(body)));
13
+ }
14
+ );
15
+ app.route<{ Querystring: { token?: string } }>({
16
+ method: ['GET', 'POST'],
17
+ url: '/campaigns/unsubscribe/browser',
18
+ bodyLimit: 4096,
19
+ logLevel: 'silent',
20
+ handler: async (request, reply) => {
21
+ const language = request.headers['accept-language'];
22
+ reply
23
+ .type('text/html; charset=utf-8')
24
+ .header('Cache-Control', 'no-store')
25
+ .header('Referrer-Policy', 'no-referrer')
26
+ .header('X-Content-Type-Options', 'nosniff')
27
+ .header(
28
+ 'Content-Security-Policy',
29
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
30
+ );
31
+ const token =
32
+ request.method === 'GET'
33
+ ? request.query.token
34
+ : request.body instanceof URLSearchParams
35
+ ? request.body.get('token')
36
+ : undefined;
37
+ if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(token))
38
+ return reply
39
+ .code(400)
40
+ .send(renderCampaignUnsubscribePage('invalid', undefined, language));
41
+ if (request.method === 'GET')
42
+ return reply.send(renderCampaignUnsubscribePage('confirm', token, language));
43
+ try {
44
+ await unsubscribeFromCampaignEmails(token);
45
+ return reply.send(renderCampaignUnsubscribePage('success', undefined, language));
46
+ } catch (error) {
47
+ const invalid = error instanceof TRPCError && error.code === 'BAD_REQUEST';
48
+ return reply
49
+ .code(invalid ? 400 : 503)
50
+ .send(
51
+ renderCampaignUnsubscribePage(
52
+ invalid ? 'invalid' : 'error',
53
+ invalid ? undefined : token,
54
+ language
55
+ )
56
+ );
57
+ }
58
+ },
59
+ });
60
+ app.post<{ Querystring: { token?: string } }>(
61
+ '/campaigns/unsubscribe',
62
+ { bodyLimit: 1024, logLevel: 'silent' },
63
+ async (request, reply) => {
64
+ reply.header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer');
65
+ if (
66
+ !(request.body instanceof URLSearchParams) ||
67
+ request.body.get('List-Unsubscribe') !== 'One-Click' ||
68
+ typeof request.query.token !== 'string'
69
+ ) {
70
+ return reply.code(400).send({ success: false });
71
+ }
72
+ try {
73
+ await unsubscribeFromCampaignEmails(request.query.token);
74
+ return reply.send({ success: true });
75
+ } catch (error) {
76
+ return reply
77
+ .code(error instanceof TRPCError && error.code === 'BAD_REQUEST' ? 400 : 503)
78
+ .send({ success: false });
79
+ }
80
+ }
81
+ );
82
+ };
83
+
84
+ export const app = Fastify({ logger: false, bodyLimit: 1024 });
85
+ app.register(campaignUnsubscribeFastifyPlugin);
86
+ export default app;
@@ -21,6 +21,29 @@ const REQUIRED_ENVIRONMENT = {
21
21
  };
22
22
 
23
23
  describe('credential refresh configuration', () => {
24
+ it.each(['invalid-json', '[]', 'null', '{"v1":123}'])(
25
+ 'rejects invalid previous digest keys at startup: %s',
26
+ value => {
27
+ expect(() =>
28
+ parseBrainServiceEnvironment({
29
+ ...REQUIRED_ENVIRONMENT,
30
+ NODE_ENV: 'test',
31
+ CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS: value,
32
+ })
33
+ ).toThrow(/Must be a JSON object mapping digest versions to string keys/);
34
+ }
35
+ );
36
+
37
+ it('transforms previous digest keys into a typed record at startup', () => {
38
+ expect(
39
+ parseBrainServiceEnvironment({
40
+ ...REQUIRED_ENVIRONMENT,
41
+ NODE_ENV: 'test',
42
+ CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS: '{"v1":"previous-test-key"}',
43
+ }).CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS
44
+ ).toEqual({ v1: 'previous-test-key' });
45
+ });
46
+
24
47
  it('parses typed defaults and explicit runtime values at call time', () => {
25
48
  const previousEnabled = process.env.CREDENTIAL_REFRESH_ENABLED;
26
49
  const previousSecret = process.env.CREDENTIAL_REFRESH_DIGEST_SECRET;
@@ -311,3 +334,32 @@ describe('LC-2206 skill embedding configuration', () => {
311
334
  }
312
335
  );
313
336
  });
337
+
338
+ describe('campaign dispatch configuration', () => {
339
+ it('defaults to 1000 recipients and 60 seconds, accepting an explicit zero delay', () => {
340
+ expect(
341
+ parseBrainServiceEnvironment({ ...REQUIRED_ENVIRONMENT, NODE_ENV: 'test' })
342
+ ).toMatchObject({
343
+ CAMPAIGN_DISPATCH_PAGE_SIZE: 1000,
344
+ CAMPAIGN_DISPATCH_DELAY_SECONDS: 60,
345
+ });
346
+ expect(
347
+ parseBrainServiceEnvironment({
348
+ ...REQUIRED_ENVIRONMENT,
349
+ NODE_ENV: 'test',
350
+ CAMPAIGN_DISPATCH_PAGE_SIZE: '25',
351
+ CAMPAIGN_DISPATCH_DELAY_SECONDS: '0',
352
+ })
353
+ ).toMatchObject({ CAMPAIGN_DISPATCH_PAGE_SIZE: 25, CAMPAIGN_DISPATCH_DELAY_SECONDS: 0 });
354
+ });
355
+ it.each([
356
+ { CAMPAIGN_DISPATCH_PAGE_SIZE: '0' },
357
+ { CAMPAIGN_DISPATCH_PAGE_SIZE: '1001' },
358
+ { CAMPAIGN_DISPATCH_DELAY_SECONDS: '901' },
359
+ { CAMPAIGN_DISPATCH_DELAY_SECONDS: '-1' },
360
+ ])('rejects unsupported queue settings %j', settings => {
361
+ expect(() =>
362
+ parseBrainServiceEnvironment({ ...REQUIRED_ENVIRONMENT, NODE_ENV: 'test', ...settings })
363
+ ).toThrow();
364
+ });
365
+ });
@@ -31,6 +31,31 @@ const credentialRefreshNotificationWindowHours = z.preprocess(
31
31
  z.coerce.number().finite().positive().default(24)
32
32
  );
33
33
 
34
+ const credentialRefreshPreviousDigestKeys = z
35
+ .string()
36
+ .default('{}')
37
+ .transform((value, context) => {
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(value);
41
+ } catch {
42
+ context.addIssue({
43
+ code: 'custom',
44
+ message: 'Must be a JSON object mapping digest versions to string keys',
45
+ });
46
+ return z.NEVER;
47
+ }
48
+ const result = z.record(z.string(), z.string()).safeParse(parsed);
49
+ if (!result.success) {
50
+ context.addIssue({
51
+ code: 'custom',
52
+ message: 'Must be a JSON object mapping digest versions to string keys',
53
+ });
54
+ return z.NEVER;
55
+ }
56
+ return result.data;
57
+ });
58
+
34
59
  export const brainServiceEnvironmentShape = {
35
60
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
36
61
  PORT: environmentPort.default(3000),
@@ -45,6 +70,22 @@ export const brainServiceEnvironmentShape = {
45
70
  SIGNING_AUTHORITY_ENDPOINT: optionalEnvironmentUrl,
46
71
  APP_STORE_ADMIN_PROFILE_IDS: optionalEnvironmentString,
47
72
  CAMPAIGN_ADMIN_PROFILE_IDS: optionalEnvironmentString,
73
+ CAMPAIGN_QUEUE_URL: optionalEnvironmentUrl,
74
+ CAMPAIGN_UNSUBSCRIBE_SECRET: optionalEnvironmentString,
75
+ CAMPAIGN_APP_ORIGINS: optionalEnvironmentString,
76
+ POSTMARK_BROADCAST_STREAM: z.string().trim().min(1).default('broadcast'),
77
+ CAMPAIGN_EMAIL_PAGE_SIZE: z.preprocess(
78
+ value => (value === '' ? undefined : value),
79
+ z.coerce.number().int().min(1).max(1000).default(500)
80
+ ),
81
+ CAMPAIGN_DISPATCH_PAGE_SIZE: z.preprocess(
82
+ value => (value === '' ? undefined : value),
83
+ z.coerce.number().int().min(1).max(1000).default(1000)
84
+ ),
85
+ CAMPAIGN_DISPATCH_DELAY_SECONDS: z.preprocess(
86
+ value => (value === '' ? undefined : value),
87
+ z.coerce.number().int().min(0).max(900).default(60)
88
+ ),
48
89
  SKILL_FRAMEWORK_ADMIN_PROFILE_IDS: optionalEnvironmentString,
49
90
  SKILL_FRAMEWORK_SEED_OWNER_PROFILE_ID: optionalEnvironmentString,
50
91
  TRUSTED_BRAIN_SERVICES: optionalEnvironmentString,
@@ -111,6 +152,7 @@ export const brainServiceEnvironmentShape = {
111
152
  SKILL_SEMANTIC_SEARCH_RATE_LIMIT_PER_MIN: optionalEnvironmentPort,
112
153
  BITSTRING_STATUS_LIST_SIZE: optionalEnvironmentPort,
113
154
  CREDENTIAL_REFRESH_DIGEST_SECRET: optionalEnvironmentString,
155
+ CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS: credentialRefreshPreviousDigestKeys,
114
156
  SHARE_LINK_REQUEST_HASH_SECRET: optionalEnvironmentString,
115
157
  CREDENTIAL_REFRESH_ENABLED: optionalEnvironmentBoolean.default(false),
116
158
  CREDENTIAL_REFRESH_NOTIFICATION_WINDOW_HOURS: credentialRefreshNotificationWindowHours,
@@ -334,6 +376,10 @@ export const getNotificationRuntimeEnvironment = (): NotificationRuntimeEnvironm
334
376
 
335
377
  const credentialRefreshRuntimeEnvironmentSchema = z
336
378
  .object({
379
+ CREDENTIAL_REFRESH_DIGEST_KEY_VERSION: z
380
+ .string()
381
+ .regex(/^[A-Za-z0-9_-]+$/)
382
+ .default('v1'),
337
383
  NODE_ENV: brainServiceEnvironmentShape.NODE_ENV,
338
384
  DOMAIN_NAME: brainServiceEnvironmentShape.DOMAIN_NAME,
339
385
  CREDENTIAL_REFRESH_DIGEST_SECRET:
@@ -360,6 +406,18 @@ export type CredentialRefreshRuntimeEnvironment = z.output<
360
406
  typeof credentialRefreshRuntimeEnvironmentSchema
361
407
  >;
362
408
 
409
+ /** Previous keys are startup-validated and only read again when matching an older digest. */
410
+ export const getCredentialRefreshPreviousDigestKeys = (): Record<string, string> =>
411
+ parseEnvironment(
412
+ z.object({ CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS: credentialRefreshPreviousDigestKeys }),
413
+ process.env,
414
+ {
415
+ project: 'brain-service credential refresh',
416
+ source: 'process environment',
417
+ examplePath: 'services/learn-card-network/brain-service/.env.example',
418
+ }
419
+ ).CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS;
420
+
363
421
  /** Reads refresh settings at call time so tests and local route registration can override them. */
364
422
  export const getCredentialRefreshRuntimeEnvironment = (): CredentialRefreshRuntimeEnvironment =>
365
423
  parseEnvironment(credentialRefreshRuntimeEnvironmentSchema, process.env, {
@@ -1,7 +1,9 @@
1
1
  import { createRequire } from 'node:module';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
4
 
4
5
  const require = createRequire(import.meta.url);
6
+ const { load } = require('js-yaml');
5
7
  const functions: {
6
8
  provider: (input?: {
7
9
  options?: Record<string, unknown>;
@@ -16,12 +18,15 @@ const INFRA_KEYS = {
16
18
  REDIS_PORT: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'] },
17
19
  NOTIFICATIONS_QUEUE_URL: '${construct:notifications-queue.queueUrl}',
18
20
  INBOX_QUEUE_URL: '${construct:inbox-queue.queueUrl}',
21
+ CAMPAIGN_QUEUE_URL: '${construct:campaign-queue.queueUrl}',
19
22
  };
20
23
 
21
24
  beforeEach(() => {
22
25
  vi.stubEnv('RUNTIME_SECRETS_ID', '');
23
26
  vi.stubEnv('GIT_SHA', '');
24
27
  vi.stubEnv('SEED', 'seed');
28
+ vi.stubEnv('CAMPAIGN_UNSUBSCRIBE_SECRET', 'synthetic-campaign-unsubscribe-secret');
29
+ vi.stubEnv('POSTMARK_BROADCAST_STREAM', 'deploy-time-stream');
25
30
  vi.stubEnv('NEO4J_PASSWORD', 'neo4j-password');
26
31
  vi.stubEnv('SHARE_LINK_REQUEST_HASH_SECRET', 'hash-secret');
27
32
  vi.stubEnv('SENTRY_AUTH_TOKEN', 'build-token');
@@ -30,6 +35,58 @@ beforeEach(() => {
30
35
  afterEach(() => vi.unstubAllEnvs());
31
36
 
32
37
  describe('brain-service serverless provider environment', () => {
38
+ it('routes the shared notifications worker through NAT to the internet', () => {
39
+ const service = load(
40
+ readFileSync(new URL('../../serverless.yml', import.meta.url), 'utf8')
41
+ );
42
+ const resources = service.resources.Resources;
43
+ const properties = (name: string) => resources[name].Properties;
44
+
45
+ // All notifications use this worker, including third-party profile webhooks.
46
+ expect(service.constructs['notifications-queue'].worker.vpc).toEqual({
47
+ securityGroupIds: [{ 'Fn::GetAtt': ['ServerlessSecurityGroup', 'GroupId'] }],
48
+ subnetIds: [{ Ref: 'PrivateSubnetA' }],
49
+ });
50
+ expect(properties('SubnetRouteTableAssociationLambdaPrivateA')).toEqual({
51
+ SubnetId: { Ref: 'PrivateSubnetA' },
52
+ RouteTableId: { Ref: 'PrivateRouteTable' },
53
+ });
54
+ expect(properties('PrivateRoute')).toEqual({
55
+ RouteTableId: { Ref: 'PrivateRouteTable' },
56
+ DestinationCidrBlock: '0.0.0.0/0',
57
+ NatGatewayId: { Ref: 'NatGateway' },
58
+ });
59
+ expect(properties('NatGateway')).toEqual({
60
+ AllocationId: { 'Fn::GetAtt': ['IP', 'AllocationId'] },
61
+ SubnetId: { Ref: 'PublicSubnetA' },
62
+ });
63
+ expect(properties('IP').Domain).toBe('vpc');
64
+ expect(properties('SubnetRouteTableAssociationLambdaPublicA')).toEqual({
65
+ SubnetId: { Ref: 'PublicSubnetA' },
66
+ RouteTableId: { Ref: 'PublicRouteTable' },
67
+ });
68
+ expect(properties('PublicRoute')).toEqual({
69
+ RouteTableId: { Ref: 'PublicRouteTable' },
70
+ DestinationCidrBlock: '0.0.0.0/0',
71
+ GatewayId: { Ref: 'InternetGateway' },
72
+ });
73
+ expect(properties('VPCGatewayAttachment')).toEqual({
74
+ VpcId: { Ref: 'VPC' },
75
+ InternetGatewayId: { Ref: 'InternetGateway' },
76
+ });
77
+ for (const name of [
78
+ 'PrivateSubnetA',
79
+ 'PublicSubnetA',
80
+ 'PrivateRouteTable',
81
+ 'PublicRouteTable',
82
+ 'ServerlessSecurityGroup',
83
+ ]) {
84
+ expect(properties(name).VpcId).toEqual({ Ref: 'VPC' });
85
+ }
86
+ // With no explicit egress rules, AWS supplies the default allow-all outbound rule.
87
+ expect(properties('ServerlessSecurityGroup')).not.toHaveProperty('SecurityGroupEgress');
88
+ });
89
+
33
90
  it('always emits Redis and queue infrastructure as unresolved references', () => {
34
91
  expect(functions.provider({ options: { stage: 'dev' } })).toMatchObject(INFRA_KEYS);
35
92
  });
@@ -60,6 +117,8 @@ describe('brain-service serverless provider environment', () => {
60
117
  RUNTIME_SECRETS_ID: 'brain-service/dev/runtime-secrets',
61
118
  });
62
119
  expect(result).not.toHaveProperty('SEED');
120
+ expect(result).not.toHaveProperty('CAMPAIGN_UNSUBSCRIBE_SECRET');
121
+ expect(result).not.toHaveProperty('POSTMARK_BROADCAST_STREAM');
63
122
  expect(result).not.toHaveProperty('NEO4J_PASSWORD');
64
123
  expect(result).not.toHaveProperty('SHARE_LINK_REQUEST_HASH_SECRET');
65
124
  });
@@ -369,6 +369,7 @@ export const credentialRefreshFastifyPlugin: FastifyPluginAsync<
369
369
  ...(record.etag ? { etag: record.etag } : {}),
370
370
  ...(record.signingMode ? { signingMode: record.signingMode } : {}),
371
371
  ...(record.updateSummary ? { updateSummary: record.updateSummary } : {}),
372
+ ...(record.updateReason ? { updateReason: record.updateReason } : {}),
372
373
  })),
373
374
  hasMore,
374
375
  ...(nextCursor ? { cursor: nextCursor } : {}),
@@ -1,6 +1,7 @@
1
1
  import './config/applyDockerStageConfig';
2
2
 
3
3
  import { dispatchContractEvents } from '@helpers/contract-events.helpers';
4
+ import { suspendExpiredConsentFlowTerms } from '@helpers/consentflow-deadline.helpers';
4
5
  import { configureInboxBatchBodyLimit } from '@helpers/inbox-batch-http.helpers';
5
6
  import { environment } from '@environment';
6
7
  import Fastify from 'fastify';
@@ -16,6 +17,7 @@ import {
16
17
  DeleteMessageCommand,
17
18
  } from '@aws-sdk/client-sqs';
18
19
 
20
+ import { migrateLegacyConsentFlowContracts } from '@helpers/consentflow-legacy-migration.helpers';
19
21
  import { neogma } from '@instance';
20
22
  import { appRouter, type AppRouter, createContext } from './app';
21
23
  import { publicShareLinkCacheControlHeaders } from './routes';
@@ -25,6 +27,7 @@ import { inboxBatchResponseMeta } from './helpers/inbox-batch-http.helpers';
25
27
  import { didFastifyPlugin } from './dids';
26
28
  import { skillsViewerFastifyPlugin } from './skills-viewer';
27
29
  import { statusListsFastifyPlugin } from './status-lists';
30
+ import { campaignUnsubscribeFastifyPlugin } from './campaign-unsubscribe';
28
31
  import { credentialRefreshFastifyPlugin } from './credential-refresh';
29
32
  import { deliverQueuedNotification } from '@helpers/notificationQueue.helpers';
30
33
  import { startSkillEmbeddingBackfill } from '@helpers/skill-embedding.helpers';
@@ -47,6 +50,9 @@ let shareLinkMaintenanceScheduler: ShareLinkMaintenanceScheduler | null = null;
47
50
  let contractEventTimer: ReturnType<typeof setTimeout> | null = null;
48
51
  let contractEventPass: Promise<unknown> | null = null;
49
52
  let contractEventsStopped = false;
53
+ let consentDeadlineTimer: NodeJS.Timeout | undefined;
54
+ let consentDeadlinePass: Promise<unknown> | null = null;
55
+ let consentDeadlineStopped = false;
50
56
 
51
57
  const runContractEventPass = (): void => {
52
58
  if (contractEventsStopped) return;
@@ -60,12 +66,27 @@ const runContractEventPass = (): void => {
60
66
  contractEventTimer = setTimeout(runContractEventPass, 60_000);
61
67
  });
62
68
  };
69
+ const runConsentDeadlinePass = (): void => {
70
+ if (consentDeadlineStopped) return;
71
+ consentDeadlinePass = suspendExpiredConsentFlowTerms()
72
+ .catch(() => {
73
+ console.warn('consent_deadline: recovery_pending');
74
+ })
75
+ .finally(() => {
76
+ consentDeadlinePass = null;
77
+ if (!consentDeadlineStopped)
78
+ consentDeadlineTimer = setTimeout(runConsentDeadlinePass, 60_000);
79
+ });
80
+ };
63
81
 
64
82
  server.addHook('onClose', async () => {
65
83
  contractEventsStopped = true;
66
84
  if (contractEventTimer) clearTimeout(contractEventTimer);
67
85
  await contractEventPass;
68
86
  await shareLinkMaintenanceScheduler?.stop();
87
+ consentDeadlineStopped = true;
88
+ clearTimeout(consentDeadlineTimer);
89
+ await consentDeadlinePass;
69
90
  });
70
91
  // Register before either OpenAPI or tRPC registers its wildcard route. The hook raises the
71
92
  // parser ceiling for those shared routes, then narrows it back to 4 MiB only for batch issuance.
@@ -169,10 +190,13 @@ server.register(didFastifyPlugin);
169
190
  server.register(skillsViewerFastifyPlugin);
170
191
  server.register(statusListsFastifyPlugin);
171
192
  server.register(credentialRefreshFastifyPlugin);
193
+ server.register(campaignUnsubscribeFastifyPlugin);
172
194
 
173
195
  (async () => {
174
196
  try {
175
- console.log('Server starting on port ', environment.PORT || 3000);
197
+ // Complete the idempotent legacy backfill before accepting traffic. Every
198
+ // replica can safely run it; Neo4j node locks serialize first-time writes.
199
+ await migrateLegacyConsentFlowContracts();
176
200
  await server.listen({ host: '0.0.0.0', port: Number(environment.PORT || 3000) });
177
201
 
178
202
  try {
@@ -190,6 +214,7 @@ server.register(credentialRefreshFastifyPlugin);
190
214
  }
191
215
 
192
216
  contractEventTimer = setTimeout(runContractEventPass, 60_000);
217
+ consentDeadlineTimer = setTimeout(runConsentDeadlinePass, 60_000);
193
218
 
194
219
  // Explicit startup after readiness. Disabled/invalid config stays inert.
195
220
  const maintenanceRuntime = createShareLinkMaintenanceRuntime();