@learncard/network-brain-service 4.1.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +164 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import type { JWE, VC } from '@learncard/types';
3
+ import type { Transaction } from 'neo4j-driver';
3
4
 
4
5
  const mocks = vi.hoisted(() => ({
5
6
  decrypt: vi.fn(),
@@ -12,6 +13,17 @@ const mocks = vi.hoisted(() => ({
12
13
  accept: vi.fn(),
13
14
  notify: vi.fn(async () => undefined),
14
15
  signingAuthority: vi.fn(),
16
+ release: vi.fn(),
17
+ transaction: { run: vi.fn() },
18
+ }));
19
+
20
+ vi.mock('@instance', () => ({
21
+ neogma: {
22
+ getTransaction: async (
23
+ _transaction: Transaction | null,
24
+ action: (transaction: unknown) => Promise<unknown>
25
+ ) => action(mocks.transaction),
26
+ },
15
27
  }));
16
28
 
17
29
  vi.mock('@models', () => ({}));
@@ -24,12 +36,13 @@ vi.mock('./uri.helpers', () => ({
24
36
  `lc:network:${domain}/${kind}:${id}`,
25
37
  }));
26
38
  vi.mock('@accesslayer/credential/create', () => ({ storeCredential: mocks.store }));
39
+ vi.mock('@accesslayer/credential/delete', () => ({ releaseUnlinkedCredential: mocks.release }));
27
40
  vi.mock('@accesslayer/boost/relationships/create', () => ({
28
41
  createBoostInstanceOfRelationship: mocks.instanceOf,
29
42
  }));
30
43
  vi.mock('@accesslayer/credential/relationships/create', () => ({
31
44
  createSentCredentialRelationship: mocks.sent,
32
- createCredentialIssuedViaContractRelationship: mocks.contract,
45
+ createContractCredentialRelationships: mocks.contract,
33
46
  createListingSentCredentialRelationship: mocks.listing,
34
47
  }));
35
48
  vi.mock('./credential.helpers', () => ({
@@ -137,41 +150,13 @@ describe('boost payload storage', () => {
137
150
  ),
138
151
  ],
139
152
  ] as const)(
140
- 'stores %s payload unchanged and preserves graph, claim, and notification behavior',
153
+ 'preserves %s bytes without decrypting or replacing the issuer signature',
141
154
  async (_kind, credential) => {
142
155
  const uri = await sendBoost({ ...options, credential });
143
156
  expect(uri).toBe('lc:network:network.example/credential:stored');
144
157
  expect(mocks.store.mock.calls[0]?.[0]).toBe(credential);
145
158
  expect(mocks.decrypt).not.toHaveBeenCalled();
146
159
  expect(mocks.issue).not.toHaveBeenCalled();
147
- expect(mocks.instanceOf).toHaveBeenCalledExactlyOnceWith(
148
- { id: 'stored' },
149
- options.boost
150
- );
151
- expect(mocks.sent).toHaveBeenCalledExactlyOnceWith(
152
- options.from,
153
- options.to,
154
- { id: 'stored' },
155
- options.metadata,
156
- 'activity',
157
- 'integration'
158
- );
159
- expect(mocks.contract).toHaveBeenCalledExactlyOnceWith(
160
- { id: 'stored' },
161
- options.contractTerms
162
- );
163
- expect(mocks.listing).toHaveBeenCalledExactlyOnceWith(
164
- 'listing',
165
- options.to,
166
- { id: 'stored' },
167
- options.metadata,
168
- 'activity',
169
- 'integration'
170
- );
171
- expect(mocks.accept).toHaveBeenCalledExactlyOnceWith(options.to, uri, {
172
- skipNotification: false,
173
- });
174
- expect(mocks.notify).toHaveBeenCalledOnce();
175
160
  }
176
161
  );
177
162
 
@@ -179,12 +164,10 @@ describe('boost payload storage', () => {
179
164
  const first = deferred();
180
165
  const sent = deferred();
181
166
  const listing = deferred();
182
- const contract = deferred();
183
167
  mocks.instanceOf.mockReturnValueOnce(first.promise);
184
168
  mocks.sent.mockReturnValueOnce(sent.promise);
185
169
  mocks.listing.mockReturnValueOnce(listing.promise);
186
- mocks.contract.mockReturnValueOnce(contract.promise);
187
- const result = sendBoost(options);
170
+ const result = sendBoost({ ...options, contractTerms: undefined });
188
171
  await vi.waitFor(() => expect(mocks.instanceOf).toHaveBeenCalledOnce());
189
172
  expect(mocks.sent).not.toHaveBeenCalled();
190
173
  first.resolve();
@@ -194,19 +177,67 @@ describe('boost payload storage', () => {
194
177
  await vi.waitFor(() => expect(mocks.listing).toHaveBeenCalledOnce());
195
178
  expect(mocks.contract).not.toHaveBeenCalled();
196
179
  listing.resolve();
197
- await vi.waitFor(() => expect(mocks.contract).toHaveBeenCalledOnce());
198
- expect(mocks.accept).not.toHaveBeenCalled();
199
- expect(mocks.notify).not.toHaveBeenCalled();
200
- contract.resolve();
201
180
  await expect(result).resolves.toBe('lc:network:network.example/credential:stored');
181
+ expect(mocks.contract).not.toHaveBeenCalled();
182
+ expect(mocks.instanceOf.mock.calls[0]?.[2]).toBe(mocks.transaction);
183
+ expect(mocks.sent.mock.calls[0]?.[6]).toBe(mocks.transaction);
184
+ expect(mocks.listing.mock.calls[0]?.[6]).toBe(mocks.transaction);
185
+ });
186
+
187
+ it('releases a failed contract send so the same signed payload can be retried', async () => {
188
+ let reserved = false;
189
+ mocks.store
190
+ .mockImplementationOnce(async () => {
191
+ reserved = true;
192
+ return { id: 'stored' };
193
+ })
194
+ .mockImplementationOnce(async () => {
195
+ if (reserved) throw new Error('Status list index is already bound');
196
+ return { id: 'retried' };
197
+ });
198
+ mocks.release.mockImplementationOnce(async () => {
199
+ reserved = false;
200
+ });
201
+ const error = new Error('contract not approved');
202
+ mocks.contract.mockRejectedValueOnce(error);
203
+ await expect(sendBoost({ ...options, credential: vc })).rejects.toBe(error);
204
+ expect(mocks.release).toHaveBeenCalledWith('stored');
205
+ await expect(sendBoost({ ...options, credential: vc })).resolves.toBe(
206
+ 'lc:network:network.example/credential:retried'
207
+ );
208
+ expect(mocks.store.mock.calls[0]?.[0]).toBe(vc);
209
+ expect(mocks.store.mock.calls[1]?.[0]).toBe(vc);
210
+ });
211
+
212
+ it('preserves the relationship error when cleanup fails', async () => {
213
+ const error = new Error('relationship failed');
214
+ mocks.contract.mockRejectedValueOnce(error);
215
+ mocks.release.mockRejectedValueOnce(new Error('cleanup failed'));
216
+ const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
217
+ try {
218
+ await expect(sendBoost(options)).rejects.toBe(error);
219
+ expect(log).toHaveBeenCalledWith('BOOST_UNLINKED_CREDENTIAL_CLEANUP_FAILED');
220
+ } finally {
221
+ log.mockRestore();
222
+ }
202
223
  });
203
224
 
204
225
  it.each(['instanceOf', 'sent', 'listing', 'contract'] as const)(
205
- 'stops after a failed %s relationship without acceptance or notification',
226
+ 'stops after a failed %s graph write without acceptance or notification',
206
227
  async failed => {
207
228
  const error = new Error('graph write failed');
208
229
  mocks[failed].mockRejectedValueOnce(error);
209
- await expect(sendBoost(options)).rejects.toBe(error);
230
+ await expect(
231
+ sendBoost({
232
+ ...options,
233
+ contractTerms: failed === 'contract' ? options.contractTerms : undefined,
234
+ })
235
+ ).rejects.toBe(error);
236
+ if (failed === 'contract') {
237
+ expect(mocks.instanceOf).not.toHaveBeenCalled();
238
+ expect(mocks.sent).not.toHaveBeenCalled();
239
+ expect(mocks.listing).not.toHaveBeenCalled();
240
+ }
210
241
  const order = ['instanceOf', 'sent', 'listing', 'contract'] as const;
211
242
  for (const later of order.slice(order.indexOf(failed) + 1)) {
212
243
  expect(mocks[later]).not.toHaveBeenCalled();
@@ -224,7 +255,7 @@ describe('boost payload storage', () => {
224
255
  listing: { listing_id: 'listing' },
225
256
  ownerProfile: { profileId: 'org' },
226
257
  } as Parameters<typeof sendBoost>[0]['from'];
227
- await sendBoost({ ...options, from, listingId });
258
+ await sendBoost({ ...options, from, listingId, contractTerms: undefined });
228
259
  expect(mocks.sent).toHaveBeenCalledOnce();
229
260
  expect(mocks.listing).toHaveBeenCalledTimes(listingId === 'another-listing' ? 1 : 0);
230
261
  }
@@ -287,7 +318,7 @@ describe('boost payload storage', () => {
287
318
  } else {
288
319
  expect(unsigned).not.toHaveProperty('boostId');
289
320
  }
290
- expect(mocks.store).toHaveBeenCalledWith(issued);
321
+ expect(mocks.store).toHaveBeenCalledWith(issued, mocks.transaction, 'org');
291
322
  }
292
323
  );
293
324
 
@@ -411,6 +442,41 @@ describe('boost payload storage', () => {
411
442
  }
412
443
  );
413
444
 
445
+ describe('display-only anchor detection', () => {
446
+ const anchorBoostWith = (template: Record<string, unknown>) =>
447
+ ({
448
+ dataValues: {
449
+ id: BOOST_ID,
450
+ boost: JSON.stringify({ ...vc, boostId: undefined, ...template }),
451
+ },
452
+ }) as Parameters<typeof prepareCredentialFromBoost>[0];
453
+
454
+ it("does not mistake an ordinary Boost's own display object for a display-only anchor", async () => {
455
+ const credential = await prepareCredentialFromBoost(
456
+ anchorBoostWith({
457
+ display: {
458
+ backgroundColor: '#fff',
459
+ backgroundImage: '',
460
+ displayType: 'card',
461
+ },
462
+ }),
463
+ `lc:network:network.example/boost:${BOOST_ID}`,
464
+ options.domain
465
+ );
466
+ expect(credential.boostId).toBe(`lc:network:network.example/boost:${BOOST_ID}`);
467
+ });
468
+
469
+ it('rejects reusing a boost marked as a display-only anchor without an issuance credential', async () => {
470
+ await expect(
471
+ prepareCredentialFromBoost(
472
+ anchorBoostWith({ displayOnlyAnchor: true, display: {} }),
473
+ `lc:network:network.example/boost:${BOOST_ID}`,
474
+ options.domain
475
+ )
476
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
477
+ });
478
+ });
479
+
414
480
  describe('stampIssuanceDate', () => {
415
481
  const NOW = '2026-10-18T05:00:00.000Z';
416
482
 
@@ -1,6 +1,9 @@
1
1
  import cloneDeep from 'lodash/cloneDeep';
2
+ import type { Transaction } from 'neo4j-driver';
3
+ import { neogma } from '@instance';
2
4
  import {
3
5
  CredentialFieldError,
6
+ isDisplayOnlyAnchorTemplate,
4
7
  isEncrypted,
5
8
  isVC2Format,
6
9
  materializeCredentialFields,
@@ -31,13 +34,14 @@ import {
31
34
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
32
35
 
33
36
  import { getBoostOwner } from '@accesslayer/boost/relationships/read';
34
- import { BoostInstance } from '@models';
37
+ import { BoostInstance, CredentialInstance } from '@models';
35
38
  import { constructUri } from './uri.helpers';
36
39
  import { storeCredential } from '@accesslayer/credential/create';
40
+ import { releaseUnlinkedCredential } from '@accesslayer/credential/delete';
37
41
  import { createBoostInstanceOfRelationship } from '@accesslayer/boost/relationships/create';
38
42
  import {
39
43
  createSentCredentialRelationship,
40
- createCredentialIssuedViaContractRelationship,
44
+ createContractCredentialRelationships,
41
45
  createListingSentCredentialRelationship,
42
46
  } from '@accesslayer/credential/relationships/create';
43
47
  import { acceptCredential, getCredentialUri } from './credential.helpers';
@@ -85,7 +89,7 @@ export const isEditableBoost = (boost: BoostInstance): boolean => {
85
89
  */
86
90
  export const isBoostViewableByClaimLink = async (boost: BoostInstance): Promise<boolean> => {
87
91
  const defaultRoles = await boost.findRelationships({ alias: 'defaultRole' });
88
- const defaultRole = defaultRoles[0]?.target as any;
92
+ const defaultRole = defaultRoles[0]?.target;
89
93
  const canView = defaultRole?.canView ?? defaultRole?.dataValues?.canView;
90
94
 
91
95
  if (typeof canView === 'boolean') return canView;
@@ -138,6 +142,8 @@ export const sendBoost = async ({
138
142
  skipNotification = false,
139
143
  autoAcceptCredential = false,
140
144
  contractTerms,
145
+ contractWriterProfileId,
146
+ allowConsumedOneTime = false,
141
147
  metadata,
142
148
  activityId,
143
149
  integrationId,
@@ -151,6 +157,8 @@ export const sendBoost = async ({
151
157
  skipNotification?: boolean;
152
158
  autoAcceptCredential?: boolean;
153
159
  contractTerms?: DbTermsType;
160
+ contractWriterProfileId?: string;
161
+ allowConsumedOneTime?: boolean;
154
162
  metadata?: Record<string, unknown>;
155
163
  activityId?: string;
156
164
  integrationId?: string;
@@ -179,47 +187,78 @@ export const sendBoost = async ({
179
187
  }
180
188
 
181
189
  // Preserve the issuer's payload without decrypting or counter-signing it.
182
- const credentialInstance = await traceDb('storeCredential', () =>
183
- storeCredential(credential)
184
- );
185
-
186
- // These writes share Credential/Profile/Listing nodes. Separate concurrent
187
- // auto-commit transactions can deadlock on Neo4j relationship-chain locks.
188
- await traceDb('createRelationships', async () => {
189
- await createBoostInstanceOfRelationship(credentialInstance, boost);
190
- await createSentCredentialRelationship(
191
- from,
192
- to,
193
- credentialInstance,
194
- metadata,
195
- activityId,
196
- integrationId
190
+ // Contract auto-boosts are signed by the writer, whose profile owns the status list.
191
+ const persistDelivery = async (
192
+ transaction?: Transaction
193
+ ): Promise<CredentialInstance> => {
194
+ const stored = await traceDb('storeCredential', () =>
195
+ storeCredential(
196
+ credential,
197
+ transaction,
198
+ contractWriterProfileId ?? fromProfile.profileId
199
+ )
197
200
  );
198
-
199
- // Listing issuers already create this edge inside createSentCredentialRelationship.
200
- // Keep explicit attribution for profile/integration issuers or another listing.
201
- if (
202
- listingId &&
203
- (!isAppStoreListingIssuer(from) || from.listing.listing_id !== listingId)
204
- ) {
205
- await createListingSentCredentialRelationship(
206
- listingId,
207
- to,
208
- credentialInstance,
209
- metadata,
210
- activityId,
211
- integrationId
212
- );
201
+ if (!contractTerms) {
202
+ await traceDb('createRelationships', async () => {
203
+ await createBoostInstanceOfRelationship(stored, boost, transaction);
204
+ await createSentCredentialRelationship(
205
+ from,
206
+ to,
207
+ stored,
208
+ metadata,
209
+ activityId,
210
+ integrationId,
211
+ transaction
212
+ );
213
+ if (
214
+ listingId &&
215
+ (!isAppStoreListingIssuer(from) ||
216
+ from.listing.listing_id !== listingId)
217
+ ) {
218
+ await createListingSentCredentialRelationship(
219
+ listingId,
220
+ to,
221
+ stored,
222
+ metadata,
223
+ activityId,
224
+ integrationId,
225
+ transaction
226
+ );
227
+ }
228
+ });
213
229
  }
230
+ return stored;
231
+ };
232
+ const credentialInstance = contractTerms
233
+ ? await persistDelivery()
234
+ : await neogma.getTransaction(null, persistDelivery);
214
235
 
236
+ try {
215
237
  if (contractTerms) {
216
- await createCredentialIssuedViaContractRelationship(
217
- credentialInstance,
218
- contractTerms
238
+ await traceDb('createRelationships', () =>
239
+ createContractCredentialRelationships({
240
+ issuer: from,
241
+ writerProfileId: contractWriterProfileId ?? fromProfile.profileId,
242
+ recipient: to,
243
+ credential: credentialInstance,
244
+ boost,
245
+ terms: contractTerms,
246
+ metadata,
247
+ activityId,
248
+ integrationId,
249
+ listingId,
250
+ allowConsumedOneTime,
251
+ })
219
252
  );
220
253
  }
221
- });
222
-
254
+ } catch (error) {
255
+ try {
256
+ await releaseUnlinkedCredential(credentialInstance.id);
257
+ } catch {
258
+ console.error('BOOST_UNLINKED_CREDENTIAL_CLEANUP_FAILED');
259
+ }
260
+ throw error;
261
+ }
223
262
  if (autoAcceptCredential) {
224
263
  await acceptCredential(to, getCredentialUri(credentialInstance.id, domain), {
225
264
  skipNotification,
@@ -349,11 +388,21 @@ export const prepareCredentialFromBoost = async (
349
388
  recipientDid?: string;
350
389
  recipientName?: string;
351
390
  issuerDid?: string;
391
+ /** Transient issuance body for a display-only managed anchor; never persisted. */
392
+ credential?: UnsignedVC | VC;
352
393
  } = {}
353
394
  ): Promise<UnsignedVC> => {
354
395
  const { templateData, recipientDid, recipientName, issuerDid } = options;
355
396
 
356
- let boostJsonString = boost.dataValues.boost;
397
+ let boostJsonString = options.credential
398
+ ? convertCredentialToBoostTemplateJSON(options.credential)
399
+ : boost.dataValues.boost;
400
+ if (!options.credential && isDisplayOnlyAnchorTemplate(JSON.parse(boostJsonString))) {
401
+ throw new TRPCError({
402
+ code: 'BAD_REQUEST',
403
+ message: 'Display-only anchors require an issuance credential in the request.',
404
+ });
405
+ }
357
406
  const allowAutoAppendEvidence = shouldAutoAppendTemplateEvidence(boostJsonString);
358
407
 
359
408
  // Auto-inject known system variables into templateData
@@ -529,7 +578,6 @@ const credentialContextSupportsDynamicEvidence = (credential: UnsignedVC): boole
529
578
  * Throws TRPCError if not found.
530
579
  */
531
580
  export const resolveBoostByUri = async (boostUri: string): Promise<BoostInstance> => {
532
- const { TRPCError } = await import('@trpc/server');
533
581
  const { getBoostByUri } = await import('@accesslayer/boost/read');
534
582
 
535
583
  const boost = await getBoostByUri(boostUri);
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ import { environment } from '@environment';
3
+
4
+ /** Deployment-owned origins keep queued links on the same environment as the campaign. */
5
+ export const getCampaignAppOrigin = (tenantId: string): string => {
6
+ try {
7
+ const origins = z
8
+ .record(z.string(), z.string())
9
+ .parse(JSON.parse(environment.CAMPAIGN_APP_ORIGINS ?? '{}'));
10
+ const origin = new URL(origins[tenantId]!);
11
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(origin.hostname);
12
+ if (
13
+ (origin.protocol !== 'https:' && !(local && origin.protocol === 'http:')) ||
14
+ origin.username ||
15
+ origin.password ||
16
+ origin.pathname !== '/' ||
17
+ origin.search ||
18
+ origin.hash
19
+ )
20
+ throw new Error('Invalid origin');
21
+ return origin.origin;
22
+ } catch {
23
+ throw new Error('Campaign app origin is not configured for this tenant');
24
+ }
25
+ };
@@ -0,0 +1,113 @@
1
+ import type { Campaign } from '@learncard/types';
2
+ import { resolveTenantFromRequest } from '@learncard/email-templates';
3
+ import { environment } from '@environment';
4
+ import { claimCampaignEmail, acknowledgeCampaignEmail } from '@accesslayer/campaign/email';
5
+ import { PostmarkAdapter } from '../services/delivery/adapters/postmark.adapter';
6
+ import { signCampaignUnsubscribeToken } from './campaign-unsubscribe.helpers';
7
+ import { getCampaignAppOrigin } from './campaign-app-origin.helpers';
8
+
9
+ /** Campaign delivery must never fall through to a logging or transactional adapter. */
10
+ export const assertCampaignEmailConfigured = (): void => {
11
+ if (!environment.POSTMARK_API_KEY || !environment.DOMAIN_NAME)
12
+ throw new Error('Campaign email requires Postmark and a public Brain domain');
13
+ const stream = environment.POSTMARK_BROADCAST_STREAM;
14
+ if (!stream || stream.toLowerCase() === 'outbound')
15
+ throw new Error('Campaign email requires a broadcast stream');
16
+ signCampaignUnsubscribeToken('00000000-0000-4000-8000-000000000000', 'configuration-check');
17
+ };
18
+
19
+ export const campaignEmailLinks = (
20
+ campaign: Campaign,
21
+ unsubscribeId: string
22
+ ): { ctaUrl?: string; unsubscribeUrl: string; oneClickUrl: string } => {
23
+ const token = signCampaignUnsubscribeToken(unsubscribeId, campaign.id);
24
+ let ctaUrl: string | undefined;
25
+ if (campaign.channels.includes('inApp') && campaign.message?.enabled) {
26
+ const tenantId = campaign.tenantId ?? resolveTenantFromRequest({}).id;
27
+ const cta = new URL(`/m/${campaign.id}`, getCampaignAppOrigin(tenantId));
28
+ cta.searchParams.set('utm_source', 'email');
29
+ cta.searchParams.set('utm_campaign', campaign.id);
30
+ ctaUrl = cta.toString();
31
+ }
32
+ const domain = environment.DOMAIN_NAME?.replace(/%3A/gi, ':').replace(/\/trpc\/?$/, '');
33
+ if (!domain) throw new Error('Campaign unsubscribe endpoint is not configured');
34
+ const api = new URL(`https://${domain}`);
35
+ if (api.username || api.password || api.pathname !== '/' || api.search || api.hash)
36
+ throw new Error('Invalid Brain domain');
37
+ if (['localhost', '127.0.0.1', '[::1]'].includes(api.hostname)) api.protocol = 'http:';
38
+ const unsubscribe = new URL('/campaigns/unsubscribe/browser', api);
39
+ unsubscribe.searchParams.set('token', token);
40
+ const oneClick = new URL('/campaigns/unsubscribe', api);
41
+ oneClick.searchParams.set('token', token);
42
+ return {
43
+ ...(ctaUrl ? { ctaUrl } : {}),
44
+ unsubscribeUrl: unsubscribe.toString(),
45
+ oneClickUrl: oneClick.toString(),
46
+ };
47
+ };
48
+
49
+ export class CampaignEmailDeliveryError extends Error {}
50
+
51
+ /** One adapter per wave keeps stream validation ahead of every durable claim. */
52
+ export const prepareCampaignEmailDelivery = async (): Promise<PostmarkAdapter> => {
53
+ assertCampaignEmailConfigured();
54
+ const adapter = new PostmarkAdapter(environment.POSTMARK_API_KEY!);
55
+ await adapter.assertBroadcastStream(environment.POSTMARK_BROADCAST_STREAM);
56
+ return adapter;
57
+ };
58
+
59
+ export const sendCampaignEmail = async (
60
+ campaign: Campaign,
61
+ profileId: string,
62
+ test = false,
63
+ preparedAdapter?: PostmarkAdapter
64
+ ): Promise<boolean> => {
65
+ const adapter = preparedAdapter ?? (await prepareCampaignEmailDelivery());
66
+ let deliver: (() => Promise<void>) | undefined;
67
+ const recipient = await claimCampaignEmail(
68
+ campaign.id,
69
+ campaign.createdAt,
70
+ profileId,
71
+ test,
72
+ async current => {
73
+ try {
74
+ const links = campaignEmailLinks(current.campaign, current.unsubscribeId);
75
+ deliver = await adapter.prepareCampaignEmail({
76
+ contactMethod: { type: 'email', value: current.email },
77
+ templateId: 'campaign-announcement',
78
+ templateModel: {
79
+ ...current.campaign.email!,
80
+ ctaUrl: links.ctaUrl,
81
+ unsubscribeUrl: links.unsubscribeUrl,
82
+ },
83
+ subject: current.campaign.email!.subject,
84
+ messageStream: environment.POSTMARK_BROADCAST_STREAM,
85
+ headers: [
86
+ { Name: 'List-Unsubscribe', Value: `<${links.oneClickUrl}>` },
87
+ { Name: 'List-Unsubscribe-Post', Value: 'List-Unsubscribe=One-Click' },
88
+ ],
89
+ branding: resolveTenantFromRequest({
90
+ 'x-tenant-id': current.campaign.tenantId,
91
+ }).emailBranding,
92
+ locale: current.locale,
93
+ });
94
+ } catch {
95
+ throw new Error('Campaign email preparation failed');
96
+ }
97
+ }
98
+ );
99
+ if (!recipient) return false;
100
+ if (!deliver) throw new Error('Campaign email was not prepared');
101
+ try {
102
+ await deliver();
103
+ if (!test) await acknowledgeCampaignEmail(campaign.id, campaign.createdAt, profileId);
104
+ } catch {
105
+ // Provider errors may contain recipient addresses and bearer unsubscribe URLs.
106
+ throw new CampaignEmailDeliveryError(
107
+ test
108
+ ? 'Campaign test email delivery failed'
109
+ : 'Campaign email delivery failed; reservation retained'
110
+ );
111
+ }
112
+ return true;
113
+ };