@learncard/network-brain-service 4.1.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +143 -0
  3. package/README.md +27 -1
  4. package/bootstrapLambda.test.ts +70 -32
  5. package/config/config.dev.json +1 -0
  6. package/config/config.example.json +2 -1
  7. package/config/config.production.json +1 -0
  8. package/config/config.scouts.dev.json +1 -0
  9. package/config/config.scouts.production.json +1 -0
  10. package/consentFlowDeadlineLambda.test.ts +108 -0
  11. package/consentFlowDeadlineLambda.ts +18 -0
  12. package/consentFlowDeadlineLambdaApp.ts +14 -0
  13. package/dist/accesslayer/boost/create.d.ts +8 -2
  14. package/dist/accesslayer/boost/create.d.ts.map +1 -1
  15. package/dist/accesslayer/campaign/audience.d.ts +4 -4
  16. package/dist/accesslayer/campaign/audience.d.ts.map +1 -1
  17. package/dist/accesslayer/campaign/create.d.ts +1 -1
  18. package/dist/accesslayer/campaign/create.d.ts.map +1 -1
  19. package/dist/accesslayer/campaign/delivery.d.ts +5 -0
  20. package/dist/accesslayer/campaign/delivery.d.ts.map +1 -0
  21. package/dist/accesslayer/campaign/dispatch.d.ts +5 -0
  22. package/dist/accesslayer/campaign/dispatch.d.ts.map +1 -0
  23. package/dist/accesslayer/campaign/dispatch.test.d.ts +2 -0
  24. package/dist/accesslayer/campaign/dispatch.test.d.ts.map +1 -0
  25. package/dist/accesslayer/campaign/email.d.ts +10 -0
  26. package/dist/accesslayer/campaign/email.d.ts.map +1 -0
  27. package/dist/accesslayer/campaign/email.test.d.ts +2 -0
  28. package/dist/accesslayer/campaign/email.test.d.ts.map +1 -0
  29. package/dist/accesslayer/campaign/preferences.d.ts +5 -0
  30. package/dist/accesslayer/campaign/preferences.d.ts.map +1 -0
  31. package/dist/accesslayer/campaign/read.d.ts.map +1 -1
  32. package/dist/accesslayer/campaign/test.d.ts +5 -0
  33. package/dist/accesslayer/campaign/test.d.ts.map +1 -0
  34. package/dist/accesslayer/campaign/test.test.d.ts +2 -0
  35. package/dist/accesslayer/campaign/test.test.d.ts.map +1 -0
  36. package/dist/accesslayer/campaign/update.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/delete.d.ts +1 -1
  39. package/dist/accesslayer/consentflowcontract/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +2 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  43. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +19 -4
  44. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +6 -1
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/consentflowcontract/versions.d.ts +68 -0
  48. package/dist/accesslayer/consentflowcontract/versions.d.ts.map +1 -0
  49. package/dist/accesslayer/credential/create.d.ts +3 -1
  50. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  51. package/dist/accesslayer/credential/delete.d.ts +1 -0
  52. package/dist/accesslayer/credential/delete.d.ts.map +1 -1
  53. package/dist/accesslayer/credential/relationships/create.d.ts +15 -3
  54. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  55. package/dist/accesslayer/credential/relationships/read.d.ts +2 -0
  56. package/dist/accesslayer/credential/relationships/read.d.ts.map +1 -1
  57. package/dist/accesslayer/credential/relationships/update.d.ts.map +1 -1
  58. package/dist/accesslayer/credential-refresh/read.d.ts +1 -0
  59. package/dist/accesslayer/credential-refresh/read.d.ts.map +1 -1
  60. package/dist/accesslayer/credential-refresh/update.d.ts.map +1 -1
  61. package/dist/accesslayer/inbox-credential/create.d.ts +1 -0
  62. package/dist/accesslayer/inbox-credential/create.d.ts.map +1 -1
  63. package/dist/accesslayer/inbox-credential/refresh.d.ts +7 -0
  64. package/dist/accesslayer/inbox-credential/refresh.d.ts.map +1 -1
  65. package/dist/app.d.ts +11 -1
  66. package/dist/app.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +61 -1
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/campaign-unsubscribe.d.ts +7 -0
  70. package/dist/campaign-unsubscribe.d.ts.map +1 -0
  71. package/dist/campaign-unsubscribe.test.d.ts +2 -0
  72. package/dist/campaign-unsubscribe.test.d.ts.map +1 -0
  73. package/dist/config/environment.d.ts +42 -0
  74. package/dist/config/environment.d.ts.map +1 -1
  75. package/dist/config/stageConfig.d.ts +4 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -1
  77. package/dist/credential-refresh.d.ts.map +1 -1
  78. package/dist/helpers/boost.helpers.d.ts +4 -1
  79. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  80. package/dist/helpers/campaign-app-origin.helpers.d.ts +2 -0
  81. package/dist/helpers/campaign-app-origin.helpers.d.ts.map +1 -0
  82. package/dist/helpers/campaign-email.helpers.d.ts +13 -0
  83. package/dist/helpers/campaign-email.helpers.d.ts.map +1 -0
  84. package/dist/helpers/campaign-notifications.helpers.d.ts +7 -0
  85. package/dist/helpers/campaign-notifications.helpers.d.ts.map +1 -0
  86. package/dist/helpers/campaign-queue.helpers.d.ts +7 -0
  87. package/dist/helpers/campaign-queue.helpers.d.ts.map +1 -0
  88. package/dist/helpers/campaign-queue.helpers.test.d.ts +2 -0
  89. package/dist/helpers/campaign-queue.helpers.test.d.ts.map +1 -0
  90. package/dist/helpers/campaign-unsubscribe-page.d.ts +2 -0
  91. package/dist/helpers/campaign-unsubscribe-page.d.ts.map +1 -0
  92. package/dist/helpers/campaign-unsubscribe.helpers.d.ts +7 -0
  93. package/dist/helpers/campaign-unsubscribe.helpers.d.ts.map +1 -0
  94. package/dist/helpers/campaign-webhook.helpers.test.d.ts +2 -0
  95. package/dist/helpers/campaign-webhook.helpers.test.d.ts.map +1 -0
  96. package/dist/helpers/consentflow-deadline.helpers.d.ts +5 -0
  97. package/dist/helpers/consentflow-deadline.helpers.d.ts.map +1 -0
  98. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts +2 -0
  99. package/dist/helpers/consentflow-legacy-migration.helpers.d.ts.map +1 -0
  100. package/dist/helpers/contract-events.helpers.d.ts.map +1 -1
  101. package/dist/helpers/contract.helpers.d.ts +1 -1
  102. package/dist/helpers/contract.helpers.d.ts.map +1 -1
  103. package/dist/helpers/credential-display.helpers.d.ts +5 -0
  104. package/dist/helpers/credential-display.helpers.d.ts.map +1 -0
  105. package/dist/helpers/credential-display.helpers.test.d.ts +2 -0
  106. package/dist/helpers/credential-display.helpers.test.d.ts.map +1 -0
  107. package/dist/helpers/credential-refresh-email.helpers.d.ts.map +1 -1
  108. package/dist/helpers/credential-refresh-materiality.helpers.d.ts +4 -0
  109. package/dist/helpers/credential-refresh-materiality.helpers.d.ts.map +1 -1
  110. package/dist/helpers/credential-refresh.helpers.d.ts +30 -1
  111. package/dist/helpers/credential-refresh.helpers.d.ts.map +1 -1
  112. package/dist/helpers/credential-status-mutation.helpers.d.ts +5 -0
  113. package/dist/helpers/credential-status-mutation.helpers.d.ts.map +1 -0
  114. package/dist/helpers/credential-status-mutation.helpers.test.d.ts +2 -0
  115. package/dist/helpers/credential-status-mutation.helpers.test.d.ts.map +1 -0
  116. package/dist/helpers/credential.helpers.d.ts +1 -1
  117. package/dist/helpers/credential.helpers.d.ts.map +1 -1
  118. package/dist/helpers/inbox-refresh.helpers.d.ts +4 -2
  119. package/dist/helpers/inbox-refresh.helpers.d.ts.map +1 -1
  120. package/dist/helpers/inbox.helpers.d.ts +1 -0
  121. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  122. package/dist/helpers/learnCard.helpers.d.ts +1 -0
  123. package/dist/helpers/learnCard.helpers.d.ts.map +1 -1
  124. package/dist/helpers/managed-status.helpers.d.ts +7 -0
  125. package/dist/helpers/managed-status.helpers.d.ts.map +1 -0
  126. package/dist/helpers/managed-status.helpers.test.d.ts +2 -0
  127. package/dist/helpers/managed-status.helpers.test.d.ts.map +1 -0
  128. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts +2 -0
  129. package/dist/helpers/notification-delivery-boundary.helpers.test.d.ts.map +1 -0
  130. package/dist/helpers/notification-webhook-fetch.helpers.d.ts +13 -0
  131. package/dist/helpers/notification-webhook-fetch.helpers.d.ts.map +1 -0
  132. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts +2 -0
  133. package/dist/helpers/notification-webhook-fetch.helpers.test.d.ts.map +1 -0
  134. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  135. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  136. package/dist/helpers/refresh-send-intent.helpers.d.ts +13 -5
  137. package/dist/helpers/refresh-send-intent.helpers.d.ts.map +1 -1
  138. package/dist/helpers/refresh-send-intent.helpers.test.d.ts +2 -0
  139. package/dist/helpers/refresh-send-intent.helpers.test.d.ts.map +1 -0
  140. package/dist/helpers/send-contract.helpers.d.ts +11 -0
  141. package/dist/helpers/send-contract.helpers.d.ts.map +1 -0
  142. package/dist/helpers/status-list.helpers.d.ts +12 -1
  143. package/dist/helpers/status-list.helpers.d.ts.map +1 -1
  144. package/dist/index.js +2 -2
  145. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  146. package/dist/models/ConsentFlowContractVersion.d.ts +12 -0
  147. package/dist/models/ConsentFlowContractVersion.d.ts.map +1 -0
  148. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  149. package/dist/models/Credential.d.ts +2 -0
  150. package/dist/models/Credential.d.ts.map +1 -1
  151. package/dist/models/CredentialRefresh.d.ts +5 -0
  152. package/dist/models/CredentialRefresh.d.ts.map +1 -1
  153. package/dist/models/Integration.d.ts +1 -1
  154. package/dist/models/Profile.d.ts.map +1 -1
  155. package/dist/models/campaign-constraints.d.ts.map +1 -1
  156. package/dist/models/index.d.ts +1 -0
  157. package/dist/models/index.d.ts.map +1 -1
  158. package/dist/openapi.d.ts.map +1 -1
  159. package/dist/publish-metafile.json +1 -1
  160. package/dist/routes/activity.d.ts +15 -5
  161. package/dist/routes/activity.d.ts.map +1 -1
  162. package/dist/routes/app-store.d.ts +15 -5
  163. package/dist/routes/app-store.d.ts.map +1 -1
  164. package/dist/routes/auth-grants.d.ts +12 -2
  165. package/dist/routes/auth-grants.d.ts.map +1 -1
  166. package/dist/routes/bench.d.ts +11 -1
  167. package/dist/routes/bench.d.ts.map +1 -1
  168. package/dist/routes/boosts.d.ts +70 -15
  169. package/dist/routes/boosts.d.ts.map +1 -1
  170. package/dist/routes/campaigns.d.ts +183 -51
  171. package/dist/routes/campaigns.d.ts.map +1 -1
  172. package/dist/routes/claim-hooks.d.ts +11 -1
  173. package/dist/routes/claim-hooks.d.ts.map +1 -1
  174. package/dist/routes/contact-methods.d.ts +11 -1
  175. package/dist/routes/contact-methods.d.ts.map +1 -1
  176. package/dist/routes/contracts.d.ts +374 -8
  177. package/dist/routes/contracts.d.ts.map +1 -1
  178. package/dist/routes/credential-input-privacy.test.d.ts +2 -0
  179. package/dist/routes/credential-input-privacy.test.d.ts.map +1 -0
  180. package/dist/routes/credential-refreshes.d.ts +210 -1
  181. package/dist/routes/credential-refreshes.d.ts.map +1 -1
  182. package/dist/routes/credential-share-previews.d.ts +11 -1
  183. package/dist/routes/credential-share-previews.d.ts.map +1 -1
  184. package/dist/routes/credentials.d.ts +132 -3
  185. package/dist/routes/credentials.d.ts.map +1 -1
  186. package/dist/routes/did-metadata.d.ts +11 -1
  187. package/dist/routes/did-metadata.d.ts.map +1 -1
  188. package/dist/routes/federation.d.ts +11 -1
  189. package/dist/routes/federation.d.ts.map +1 -1
  190. package/dist/routes/inbox.d.ts +54 -19
  191. package/dist/routes/inbox.d.ts.map +1 -1
  192. package/dist/routes/index.d.ts +100 -1
  193. package/dist/routes/index.d.ts.map +1 -1
  194. package/dist/routes/integrations.d.ts +14 -4
  195. package/dist/routes/integrations.d.ts.map +1 -1
  196. package/dist/routes/presentations.d.ts +11 -1
  197. package/dist/routes/presentations.d.ts.map +1 -1
  198. package/dist/routes/profile-manager.d.ts +11 -1
  199. package/dist/routes/profile-manager.d.ts.map +1 -1
  200. package/dist/routes/profiles.d.ts +11 -1
  201. package/dist/routes/profiles.d.ts.map +1 -1
  202. package/dist/routes/public-share-links.d.ts +22 -2
  203. package/dist/routes/public-share-links.d.ts.map +1 -1
  204. package/dist/routes/share-links.d.ts +36 -16
  205. package/dist/routes/share-links.d.ts.map +1 -1
  206. package/dist/routes/skill-frameworks.d.ts +11 -1
  207. package/dist/routes/skill-frameworks.d.ts.map +1 -1
  208. package/dist/routes/skills.d.ts +11 -1
  209. package/dist/routes/skills.d.ts.map +1 -1
  210. package/dist/routes/storage.d.ts +37 -23
  211. package/dist/routes/storage.d.ts.map +1 -1
  212. package/dist/routes/test.d.ts +79 -3
  213. package/dist/routes/test.d.ts.map +1 -1
  214. package/dist/routes/utilities.d.ts +11 -1
  215. package/dist/routes/utilities.d.ts.map +1 -1
  216. package/dist/routes/workflows.d.ts +11 -1
  217. package/dist/routes/workflows.d.ts.map +1 -1
  218. package/dist/services/delivery/adapters/postmark.adapter.d.ts +3 -0
  219. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  220. package/dist/services/delivery/delivery.service.d.ts +10 -0
  221. package/dist/services/delivery/delivery.service.d.ts.map +1 -1
  222. package/dist/types/activity.d.ts +3 -3
  223. package/dist/types/app-manifest.d.ts +10 -10
  224. package/dist/types/campaign.d.ts +13 -1
  225. package/dist/types/campaign.d.ts.map +1 -1
  226. package/dist/types/consentflowcontract.d.ts +175 -7
  227. package/dist/types/consentflowcontract.d.ts.map +1 -1
  228. package/dist/types/credential-refresh.d.ts +27 -0
  229. package/dist/types/credential-refresh.d.ts.map +1 -1
  230. package/dist/types/credential.d.ts +5 -0
  231. package/dist/types/credential.d.ts.map +1 -1
  232. package/dist/types/integration.d.ts +3 -3
  233. package/lambda.ts +44 -14
  234. package/lambdaApp.ts +20 -0
  235. package/package.json +20 -19
  236. package/project.json +5 -0
  237. package/scripts/migrate-consent-flow-versions.ts +9 -0
  238. package/scripts/report-send-template-exposure.ts +47 -0
  239. package/scripts/verify-python-contracts.ts +16 -1
  240. package/serverless-local.yml +15 -2
  241. package/serverless.function-env.cjs +4 -0
  242. package/serverless.local.yml +12 -0
  243. package/serverless.yml +48 -3
  244. package/src/accesslayer/boost/create.ts +62 -7
  245. package/src/accesslayer/campaign/README.md +275 -6
  246. package/src/accesslayer/campaign/audience.ts +39 -7
  247. package/src/accesslayer/campaign/create.ts +4 -2
  248. package/src/accesslayer/campaign/delivery.ts +66 -0
  249. package/src/accesslayer/campaign/dispatch.test.ts +59 -0
  250. package/src/accesslayer/campaign/dispatch.ts +172 -0
  251. package/src/accesslayer/campaign/email.test.ts +73 -0
  252. package/src/accesslayer/campaign/email.ts +91 -0
  253. package/src/accesslayer/campaign/preferences.ts +53 -0
  254. package/src/accesslayer/campaign/read.ts +3 -1
  255. package/src/accesslayer/campaign/state.ts +1 -1
  256. package/src/accesslayer/campaign/test.test.ts +31 -0
  257. package/src/accesslayer/campaign/test.ts +75 -0
  258. package/src/accesslayer/campaign/update.ts +15 -4
  259. package/src/accesslayer/consentflowcontract/create.ts +20 -1
  260. package/src/accesslayer/consentflowcontract/delete.ts +121 -3
  261. package/src/accesslayer/consentflowcontract/read.ts +15 -9
  262. package/src/accesslayer/consentflowcontract/relationships/create.ts +70 -36
  263. package/src/accesslayer/consentflowcontract/relationships/read.ts +305 -73
  264. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +1 -1
  265. package/src/accesslayer/consentflowcontract/relationships/update.ts +269 -98
  266. package/src/accesslayer/consentflowcontract/versions.ts +790 -0
  267. package/src/accesslayer/credential/create.ts +33 -9
  268. package/src/accesslayer/credential/delete.ts +45 -0
  269. package/src/accesslayer/credential/relationships/create.ts +102 -47
  270. package/src/accesslayer/credential/relationships/update.ts +8 -116
  271. package/src/accesslayer/credential-refresh/read.ts +26 -2
  272. package/src/accesslayer/credential-refresh/update.ts +6 -2
  273. package/src/accesslayer/inbox-credential/create.ts +7 -1
  274. package/src/accesslayer/inbox-credential/refresh.ts +14 -4
  275. package/src/campaign-unsubscribe.test.ts +158 -0
  276. package/src/campaign-unsubscribe.ts +86 -0
  277. package/src/config/environment.test.ts +52 -0
  278. package/src/config/environment.ts +58 -0
  279. package/src/config/serverlessFunctionEnv.test.ts +59 -0
  280. package/src/credential-refresh.ts +1 -0
  281. package/src/docker-entry.ts +26 -1
  282. package/src/helpers/boost-storage.helpers.test.ts +107 -41
  283. package/src/helpers/boost.helpers.ts +88 -40
  284. package/src/helpers/campaign-app-origin.helpers.ts +25 -0
  285. package/src/helpers/campaign-email.helpers.ts +113 -0
  286. package/src/helpers/campaign-notifications.helpers.ts +127 -0
  287. package/src/helpers/campaign-queue.helpers.test.ts +442 -0
  288. package/src/helpers/campaign-queue.helpers.ts +122 -0
  289. package/src/helpers/campaign-unsubscribe-page.ts +107 -0
  290. package/src/helpers/campaign-unsubscribe.helpers.ts +56 -0
  291. package/src/helpers/campaign-webhook.helpers.test.ts +137 -0
  292. package/src/helpers/consentflow-deadline.helpers.ts +22 -0
  293. package/src/helpers/consentflow-legacy-migration.helpers.ts +179 -0
  294. package/src/helpers/contract-events.helpers.ts +35 -3
  295. package/src/helpers/contract-requests.helpers.ts +1 -1
  296. package/src/helpers/contract.helpers.ts +5 -1
  297. package/src/helpers/credential-display.helpers.test.ts +68 -0
  298. package/src/helpers/credential-display.helpers.ts +15 -0
  299. package/src/helpers/credential-refresh-email.helpers.ts +5 -12
  300. package/src/helpers/credential-refresh-materiality.helpers.test.ts +138 -1
  301. package/src/helpers/credential-refresh-materiality.helpers.ts +62 -1
  302. package/src/helpers/credential-refresh-proof.helpers.test.ts +1 -1
  303. package/src/helpers/credential-refresh-proof.helpers.ts +2 -2
  304. package/src/helpers/credential-refresh.helpers.ts +1166 -376
  305. package/src/helpers/credential-status-mutation.helpers.test.ts +155 -0
  306. package/src/helpers/credential-status-mutation.helpers.ts +166 -0
  307. package/src/helpers/credential.helpers.ts +19 -11
  308. package/src/helpers/finalize-inbox.helpers.ts +1 -1
  309. package/src/helpers/inbox-refresh.helpers.ts +34 -8
  310. package/src/helpers/inbox.helpers.ts +2 -0
  311. package/src/helpers/learnCard.helpers.ts +23 -0
  312. package/src/helpers/managed-status.helpers.test.ts +101 -0
  313. package/src/helpers/managed-status.helpers.ts +147 -0
  314. package/src/helpers/notification-delivery-boundary.helpers.test.ts +114 -0
  315. package/src/helpers/notification-webhook-fetch.helpers.test.ts +228 -0
  316. package/src/helpers/notification-webhook-fetch.helpers.ts +166 -0
  317. package/src/helpers/notificationQueue.helpers.ts +5 -0
  318. package/src/helpers/notifications.helpers.ts +19 -12
  319. package/src/helpers/refresh-send-intent.helpers.test.ts +60 -0
  320. package/src/helpers/refresh-send-intent.helpers.ts +81 -18
  321. package/src/helpers/send-contract.helpers.ts +36 -0
  322. package/src/helpers/status-list.helpers.test.ts +29 -12
  323. package/src/helpers/status-list.helpers.ts +329 -20
  324. package/src/models/ConsentFlowContract.ts +3 -0
  325. package/src/models/ConsentFlowContractVersion.ts +50 -0
  326. package/src/models/ConsentFlowTerms.ts +4 -0
  327. package/src/models/Credential.ts +2 -0
  328. package/src/models/CredentialRefresh.ts +2 -0
  329. package/src/models/Profile.ts +2 -0
  330. package/src/models/campaign-constraints.ts +13 -8
  331. package/src/models/index.ts +2 -0
  332. package/src/openapi.ts +21 -0
  333. package/src/routes/app-store.ts +2 -1
  334. package/src/routes/boosts.ts +219 -137
  335. package/src/routes/campaigns.ts +98 -19
  336. package/src/routes/contracts.ts +433 -115
  337. package/src/routes/credential-input-privacy.test.ts +92 -0
  338. package/src/routes/credential-refreshes.ts +273 -2
  339. package/src/routes/federation.ts +7 -2
  340. package/src/routes/inbox.ts +6 -1
  341. package/src/routes/index.ts +31 -2
  342. package/src/routes/workflows.ts +61 -46
  343. package/src/services/delivery/adapters/postmark.adapter.test.ts +70 -1
  344. package/src/services/delivery/adapters/postmark.adapter.ts +84 -11
  345. package/src/services/delivery/delivery.service.ts +4 -0
  346. package/src/types/campaign.ts +14 -1
  347. package/src/types/consentflowcontract.ts +43 -2
  348. package/src/types/credential-refresh.ts +11 -1
  349. package/src/types/credential.ts +2 -0
  350. package/test/boost-status-cleanup.spec.ts +123 -0
  351. package/test/campaigns-email.unit.spec.ts +268 -0
  352. package/test/campaigns.spec.ts +804 -30
  353. package/test/consent-autoboost-idempotency.spec.ts +432 -0
  354. package/test/consent-data-boundaries.spec.ts +322 -26
  355. package/test/consent-legacy-migration.spec.ts +315 -0
  356. package/test/consent-recipients.spec.ts +7 -7
  357. package/test/consent-version-updates.spec.ts +761 -0
  358. package/test/consentflow.spec.ts +698 -226
  359. package/test/contract-requests.spec.ts +55 -9
  360. package/test/credential-refresh-allocation.spec.ts +212 -14
  361. package/test/credential-refresh-email.spec.ts +6 -10
  362. package/test/credential-refresh-endpoint.spec.ts +3 -0
  363. package/test/credential-refresh-publication.spec.ts +52 -15
  364. package/test/credential-storage.neo4j.spec.ts +33 -6
  365. package/test/helpers/legacy-autoboost.ts +48 -0
  366. package/test/holder-mode-send.spec.ts +710 -0
  367. package/test/inbox-refresh.spec.ts +105 -0
  368. package/test/inbox-security.e2e.ts +18 -22
  369. package/test/managed-status-revocation.spec.ts +970 -0
  370. package/test/notifications-worker.spec.ts +31 -25
  371. package/test/refresh-send.spec.ts +334 -5
  372. package/test/smart-resume-consent.spec.ts +1 -1
  373. package/test/verification/campaigns.ts +40 -0
  374. package/vitest.campaigns.config.ts +2 -0
  375. package/vitest.config.ts +14 -0
package/lambdaApp.ts CHANGED
@@ -13,9 +13,11 @@ import app from './src/openapi';
13
13
  import skillsViewerApp from './src/skills-viewer';
14
14
  import statusListsApp from './src/status-lists';
15
15
  import credentialRefreshApp from './src/credential-refresh';
16
+ import campaignUnsubscribeApp from './src/campaign-unsubscribe';
16
17
  import { appRouter, createContext } from './src/app';
17
18
  import { publicShareLinkCacheControlHeaders } from './src/routes';
18
19
  import { deliverQueuedNotification } from './src/helpers/notificationQueue.helpers';
20
+ import { processCampaignDispatchJob } from './src/helpers/campaign-queue.helpers';
19
21
  import { startSkillEmbeddingBackfill } from './src/helpers/skill-embedding.helpers';
20
22
  import { createOpenApiAwsLambdaHandler } from './src/helpers/shim';
21
23
  import {
@@ -64,6 +66,17 @@ export const credentialRefreshHandler: typeof credentialRefreshProxy = async (ev
64
66
  return credentialRefreshProxy(event, context);
65
67
  };
66
68
 
69
+ const campaignUnsubscribeProxy = serverlessHttp(
70
+ toServerlessApplication(campaignUnsubscribeApp.server)
71
+ );
72
+ export const campaignUnsubscribeHandler: typeof campaignUnsubscribeProxy = async (
73
+ event,
74
+ context
75
+ ) => {
76
+ await campaignUnsubscribeApp.ready();
77
+ return campaignUnsubscribeProxy(event, context);
78
+ };
79
+
67
80
  export const _openApiHandler = createOpenApiAwsLambdaHandler({
68
81
  router: appRouter,
69
82
  responseMeta: meta => {
@@ -197,6 +210,13 @@ export const notificationsWorker: SQSHandler = Sentry.AWSLambda.wrapHandler(asyn
197
210
  } satisfies SQSBatchResponse;
198
211
  });
199
212
 
213
+ export const campaignDispatchWorker: SQSHandler = Sentry.AWSLambda.wrapHandler(async event => {
214
+ // batchSize=1; throwing lets SQS apply the construct's retry/DLQ policy.
215
+ for (const record of event.Records) {
216
+ await processCampaignDispatchJob(JSON.parse(record.body));
217
+ }
218
+ });
219
+
200
220
  export const inboxMaintenanceHandler = Sentry.AWSLambda.wrapHandler(async (): Promise<void> => {
201
221
  const counts = await runInboxMaintenance();
202
222
  console.log('Universal Inbox maintenance completed', counts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/network-brain-service",
3
- "version": "4.1.3",
3
+ "version": "5.0.0",
4
4
  "description": "",
5
5
  "engines": {
6
6
  "node": ">=24.12 <25"
@@ -23,6 +23,7 @@
23
23
  "test:boost-recipients": "vitest run --config vitest.boost-recipients-neo4j.config.ts",
24
24
  "seed:dev-app": "bun scripts/seed-dev-app.ts",
25
25
  "seed:demo-persona": "bun --conditions=development scripts/seed-demo-persona.ts",
26
+ "migrate:consent-flow-versions": "bun --conditions=development scripts/migrate-consent-flow-versions.ts",
26
27
  "skill-frameworks": "bun scripts/skill-frameworks.ts",
27
28
  "skill-embeddings:backfill": "bun --conditions=development scripts/skill-embeddings-backfill.ts",
28
29
  "backfill:profile-search": "bun scripts/backfill-profile-search.ts",
@@ -33,7 +34,7 @@
33
34
  "start-p-4000": "PORT=4000 serverless offline --httpPort 4000 --websocketPort 4001 --lambdaPort 4002 --albPort 4003 --host 127.0.0.1 --config serverless-local.yml",
34
35
  "dev": "bun --conditions=development --watch src/docker-entry.ts",
35
36
  "start:docker": "bun --conditions=development src/docker-entry.ts",
36
- "serverless-deploy": "node ../../../scripts/prepare-didkit-native-layer.cjs && serverless deploy --verbose"
37
+ "serverless-deploy": "node ../../../scripts/prepare-didkit-native-layer.cjs && node ../../../scripts/verify-node24-didkit-layer.cjs && serverless deploy --verbose"
37
38
  },
38
39
  "keywords": [],
39
40
  "author": "Learning Economy Foundation (www.learningeconomy.io)",
@@ -54,7 +55,7 @@
54
55
  "serverless": "3.40.0",
55
56
  "serverless-certificate-creator": "1.6.0",
56
57
  "serverless-domain-manager": "6.3.1",
57
- "serverless-esbuild": "1.55.0",
58
+ "serverless-esbuild": "1.57.2",
58
59
  "serverless-http": "3.2.0",
59
60
  "serverless-lift": "1.31.0",
60
61
  "serverless-offline": "^14.4.0",
@@ -68,23 +69,23 @@
68
69
  "@digitalcredentials/issuer-registry-client": "^4.1.0",
69
70
  "@fastify/cors": "^11.2.0",
70
71
  "@google/genai": "^1.27.0",
71
- "@learncard/credential-library": "2.1.6",
72
- "@learncard/core": "9.4.43",
73
- "@learncard/crypto-plugin": "1.1.43",
74
- "@learncard/did-web-plugin": "1.1.43",
75
- "@learncard/didkey-plugin": "1.1.43",
76
- "@learncard/didkit-plugin": "1.10.9",
77
- "@learncard/didkit-plugin-node": "0.3.9",
78
- "@learncard/dynamic-loader-plugin": "1.1.43",
79
- "@learncard/encryption-plugin": "1.1.43",
80
- "@learncard/expiration-plugin": "1.2.43",
81
- "@learncard/email-templates": "1.2.2",
82
- "@learncard/helpers": "1.7.3",
83
- "@learncard/learn-card-plugin": "1.2.43",
72
+ "@learncard/credential-library": "2.1.7",
73
+ "@learncard/core": "9.4.44",
74
+ "@learncard/crypto-plugin": "1.1.44",
75
+ "@learncard/did-web-plugin": "1.1.44",
76
+ "@learncard/didkey-plugin": "1.1.44",
77
+ "@learncard/didkit-plugin": "1.10.10",
78
+ "@learncard/didkit-plugin-node": "0.3.10",
79
+ "@learncard/dynamic-loader-plugin": "1.1.44",
80
+ "@learncard/encryption-plugin": "1.2.0",
81
+ "@learncard/expiration-plugin": "1.2.44",
82
+ "@learncard/email-templates": "1.3.0",
83
+ "@learncard/helpers": "1.8.0",
84
+ "@learncard/learn-card-plugin": "1.2.44",
84
85
  "@learncard/partner-connect-core": "0.2.0",
85
- "@learncard/types": "5.24.2",
86
- "@learncard/vc-plugin": "1.6.9",
87
- "@learncard/vc-templates-plugin": "1.1.43",
86
+ "@learncard/types": "6.0.0",
87
+ "@learncard/vc-plugin": "1.7.0",
88
+ "@learncard/vc-templates-plugin": "1.1.44",
88
89
  "@sentry/serverless": "7.61.0",
89
90
  "@trpc/server": "11.8.0",
90
91
  "@types/lodash": "^4.14.191",
package/project.json CHANGED
@@ -149,6 +149,11 @@
149
149
  "options": {
150
150
  "script": "test:boost-recipients"
151
151
  }
152
+ },
153
+ "build:integration-dependencies": {
154
+ "executor": "nx:noop",
155
+ "cache": false,
156
+ "dependsOn": ["^build"]
152
157
  }
153
158
  }
154
159
  }
@@ -0,0 +1,9 @@
1
+ import { migrateLegacyConsentFlowContracts } from '../src/helpers/consentflow-legacy-migration.helpers';
2
+
3
+ try {
4
+ await migrateLegacyConsentFlowContracts();
5
+ console.info('ConsentFlow contract version migration completed.');
6
+ } catch (error) {
7
+ console.error('ConsentFlow contract version migration failed.', error);
8
+ process.exitCode = 1;
9
+ }
@@ -0,0 +1,47 @@
1
+ import neo4j from 'neo4j-driver';
2
+
3
+ /** Read-only, counts-only inventory. Pass the deployment timestamp as the sole argument. */
4
+ const main = async (): Promise<void> => {
5
+ const cutoff = process.argv[2];
6
+ if (!cutoff || !Number.isFinite(Date.parse(cutoff))) {
7
+ throw new Error('A valid deployment timestamp is required');
8
+ }
9
+ const { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD } = process.env;
10
+ if (!NEO4J_URI || !NEO4J_USERNAME || !NEO4J_PASSWORD) {
11
+ throw new Error('Explicit Neo4j connection environment is required');
12
+ }
13
+ const driver = neo4j.driver(NEO4J_URI, neo4j.auth.basic(NEO4J_USERNAME, NEO4J_PASSWORD));
14
+ const session = driver.session({ defaultAccessMode: neo4j.session.READ });
15
+ try {
16
+ const result = await session.executeRead(tx =>
17
+ tx.run(
18
+ `MATCH (b:Boost)-[created:CREATED_BY]->()
19
+ WHERE created.date < $cutoff
20
+ WITH DISTINCT b
21
+ WITH b, EXISTS { MATCH (r:CredentialRefresh) WHERE r.boostId = b.id }
22
+ OR EXISTS { MATCH (i:RefreshSendIntent) WHERE i.boostUri ENDS WITH ':' + b.id } AS managed,
23
+ EXISTS { MATCH (a:CredentialActivity) WHERE a.source = 'send' AND a.boostUri ENDS WITH ':' + b.id } AS sent
24
+ RETURN count(CASE WHEN managed THEN b END) AS managed,
25
+ count(CASE WHEN sent AND NOT managed THEN b END) AS unclassifiedSendCandidates`,
26
+ { cutoff: new Date(cutoff).toISOString() }
27
+ )
28
+ );
29
+ // Signed-only sends historically had no distinguishing marker. Candidates are
30
+ // an upper bound, not a claim that every counted Boost contains private data.
31
+ const row = result.records[0];
32
+ console.log(
33
+ JSON.stringify({
34
+ managed: Number(row?.get('managed') ?? 0),
35
+ unclassifiedSendCandidates: Number(row?.get('unclassifiedSendCandidates') ?? 0),
36
+ })
37
+ );
38
+ } finally {
39
+ await session.close();
40
+ await driver.close();
41
+ }
42
+ };
43
+
44
+ main().catch(() => {
45
+ console.error('SEND_TEMPLATE_INVENTORY_FAILED');
46
+ process.exitCode = 1;
47
+ });
@@ -21,6 +21,17 @@ configureOfflineSchemaEnvironment();
21
21
  const { AllocateCredentialRefreshInputValidator, PublishCredentialRefreshInputValidator } =
22
22
  await import('@learncard/types');
23
23
  const { activityRouter } = await import('../src/routes/activity');
24
+ const { contractsRouter } = await import('../src/routes/contracts');
25
+ const { boostsRouter } = await import('../src/routes/boosts');
26
+ const consentListInput = contractsRouter._def.procedures.getConsentedContracts._def.inputs.at(
27
+ -1
28
+ ) as ZodType;
29
+ const bodyInputs: Record<string, ZodType> = {
30
+ '/consent-flow-contract/update':
31
+ contractsRouter._def.procedures.updateConsentFlowContract._def.inputs.at(-1) as ZodType,
32
+ '/boost/skills/search':
33
+ boostsRouter._def.procedures.searchSkillsAvailableForBoost._def.inputs.at(-1) as ZodType,
34
+ };
24
35
 
25
36
  const activityInputs: Record<string, ZodType> = {
26
37
  '/activity/credentials': activityRouter._def.procedures.getMyActivities._def.inputs.at(
@@ -38,6 +49,10 @@ for (const record of records) {
38
49
  valid = PublishCredentialRefreshInputValidator.safeParse(record.body).success;
39
50
  } else if (url.pathname === '/credential-refresh/allocate') {
40
51
  valid = AllocateCredentialRefreshInputValidator.safeParse(record.body).success;
52
+ } else if (url.pathname === '/consent-flow-contracts/consent') {
53
+ valid = consentListInput.safeParse(record.body ?? undefined).success;
54
+ } else if (bodyInputs[url.pathname]) {
55
+ valid = bodyInputs[url.pathname]!.safeParse(record.body).success;
41
56
  } else {
42
57
  const validator = activityInputs[url.pathname];
43
58
  assert(validator, `No source validator for captured path ${url.pathname}`);
@@ -67,6 +82,6 @@ for (const validator of Object.values(activityInputs)) {
67
82
  }
68
83
  }
69
84
  console.log(
70
- `Verified ${records.length} captured publication/allocation/activity cases and Z-only boundaries against actual source validators`
85
+ `Verified ${records.length} captured publication/allocation/activity/consent/update/skill-search cases and Z-only boundaries against actual source validators`
71
86
  );
72
87
  process.exit(0);
@@ -7,6 +7,7 @@ frameworkVersion: '3'
7
7
  useDotenv: true
8
8
 
9
9
  plugins:
10
+ - ../../../scripts/serverless-node24-runtime.cjs
10
11
  - serverless-esbuild
11
12
  - serverless-offline
12
13
  - serverless-lift
@@ -29,7 +30,7 @@ custom:
29
30
  minify: true
30
31
  external: ['@learncard/didkit-plugin/dist/didkit_wasm_bg.wasm', 'p-limit']
31
32
  sourcemap: external
32
- target: 'node20'
33
+ target: 'node24'
33
34
  watch:
34
35
  pattern: ['src/**/*.ts', 'dids.ts']
35
36
 
@@ -41,7 +42,7 @@ package:
41
42
 
42
43
  provider:
43
44
  name: aws
44
- runtime: nodejs20.x
45
+ runtime: nodejs24.x
45
46
  memorySize: 2048
46
47
  timeout: 29
47
48
  stage: ${opt:stage, "dev"}
@@ -124,6 +125,18 @@ functions:
124
125
  - httpApi: GET /docs
125
126
  - httpApi: GET /docs/{trpc+}
126
127
 
128
+ campaignUnsubscribe:
129
+ handler: lambda.campaignUnsubscribeHandler
130
+ vpc:
131
+ securityGroupIds:
132
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
133
+ subnetIds:
134
+ - Ref: PrivateSubnetA
135
+ events:
136
+ - httpApi: POST /campaigns/unsubscribe
137
+ - httpApi: GET /campaigns/unsubscribe/browser
138
+ - httpApi: POST /campaigns/unsubscribe/browser
139
+
127
140
  skillsViewer:
128
141
  handler: lambda.skillsViewerHandler
129
142
  vpc:
@@ -24,9 +24,12 @@ const PROVIDER_SECRETS = [
24
24
  'SMART_RESUME_ACCESS_KEY',
25
25
  'SMART_RESUME_CONTRACT_URI',
26
26
  'CREDENTIAL_REFRESH_DIGEST_SECRET',
27
+ 'CREDENTIAL_REFRESH_DIGEST_KEY_VERSION',
28
+ 'CREDENTIAL_REFRESH_DIGEST_PREVIOUS_KEYS',
27
29
  'SHARE_LINK_REQUEST_HASH_SECRET',
28
30
  'APP_STORE_ADMIN_PROFILE_IDS',
29
31
  'CAMPAIGN_ADMIN_PROFILE_IDS',
32
+ 'CAMPAIGN_UNSUBSCRIBE_SECRET',
30
33
  'LOGIN_PROVIDER_DID',
31
34
  'NOTIFICATIONS_SERVICE_WEBHOOK_URL',
32
35
  ];
@@ -46,6 +49,7 @@ exports.provider = ({ options = {}, env = process.env } = {}) => ({
46
49
  REDIS_PORT: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'] },
47
50
  NOTIFICATIONS_QUEUE_URL: '${construct:notifications-queue.queueUrl}',
48
51
  INBOX_QUEUE_URL: '${construct:inbox-queue.queueUrl}',
52
+ CAMPAIGN_QUEUE_URL: '${construct:campaign-queue.queueUrl}',
49
53
  // The deploy commit, emitted only when set so an empty string never counts toward the
50
54
  // 4KB per-function environment limit.
51
55
  ...pickNonEmpty(['GIT_SHA'], env),
@@ -119,6 +119,18 @@ functions:
119
119
  - httpApi: GET /docs
120
120
  - httpApi: GET /docs/{trpc+}
121
121
 
122
+ campaignUnsubscribe:
123
+ handler: lambda.campaignUnsubscribeHandler
124
+ vpc:
125
+ securityGroupIds:
126
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
127
+ subnetIds:
128
+ - Ref: PrivateSubnetA
129
+ events:
130
+ - httpApi: POST /campaigns/unsubscribe
131
+ - httpApi: GET /campaigns/unsubscribe/browser
132
+ - httpApi: POST /campaigns/unsubscribe/browser
133
+
122
134
  skillsViewer:
123
135
  handler: lambda.skillsViewerHandler
124
136
  vpc:
package/serverless.yml CHANGED
@@ -7,6 +7,7 @@ frameworkVersion: '3'
7
7
  useDotenv: true
8
8
 
9
9
  plugins:
10
+ - ../../../scripts/serverless-node24-runtime.cjs
10
11
  - serverless-esbuild
11
12
  - serverless-offline
12
13
  - serverless-domain-manager
@@ -15,6 +16,18 @@ plugins:
15
16
  - serverless-prune-plugin
16
17
 
17
18
  constructs:
19
+ campaign-queue:
20
+ type: queue
21
+ batchSize: 1
22
+ maxRetries: 3
23
+ worker:
24
+ handler: lambda.campaignDispatchWorker
25
+ timeout: 300
26
+ vpc:
27
+ securityGroupIds:
28
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
29
+ subnetIds:
30
+ - Ref: PrivateSubnetA
18
31
  inbox-queue:
19
32
  type: queue
20
33
  batchSize: 1
@@ -33,6 +46,14 @@ constructs:
33
46
  type: queue
34
47
  worker:
35
48
  handler: lambda.notificationsWorker
49
+ timeout: 30
50
+ # Graph/cache bookkeeping requires the VPC. All webhook HTTP delivery
51
+ # must retain the connection-time public-address and no-redirect guard.
52
+ vpc:
53
+ securityGroupIds:
54
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
55
+ subnetIds:
56
+ - Ref: PrivateSubnetA
36
57
 
37
58
  custom:
38
59
  prune:
@@ -57,7 +78,7 @@ custom:
57
78
  # serverless-esbuild default and must be preserved when overriding.
58
79
  exclude: ['aws-sdk', '@learncard/didkit-plugin-node']
59
80
  sourcemap: external
60
- target: 'node20'
81
+ target: 'node24'
61
82
  watch:
62
83
  pattern: ['src/**/*.ts', 'dids.ts']
63
84
 
@@ -126,13 +147,13 @@ layers:
126
147
  name: ${self:service}-${opt:stage, 'dev'}-didkit-native
127
148
  description: Native DIDKit napi addon and runtime DIDKit dependency
128
149
  compatibleRuntimes:
129
- - nodejs20.x
150
+ - nodejs24.x
130
151
  compatibleArchitectures:
131
152
  - x86_64
132
153
 
133
154
  provider:
134
155
  name: aws
135
- runtime: nodejs20.x
156
+ runtime: nodejs24.x
136
157
  # Attach the native DIDKit layer to every function in this service.
137
158
  layers:
138
159
  - { Ref: DidkitNativeLambdaLayer }
@@ -229,6 +250,18 @@ functions:
229
250
  events:
230
251
  - schedule: rate(1 minute)
231
252
 
253
+ # Suspend consent updates whose response deadline has passed.
254
+ consentFlowDeadline:
255
+ handler: consentFlowDeadlineLambda.consentFlowDeadlineHandler
256
+ timeout: 30
257
+ reservedConcurrency: 1
258
+ vpc:
259
+ securityGroupIds:
260
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
261
+ subnetIds:
262
+ - Ref: PrivateSubnetA
263
+ events:
264
+ - schedule: rate(1 minute)
232
265
  # LC-2187 share-link recovery + cleanup. Dedicated entrypoint (no lambda.ts
233
266
  # import-time work). Runs automatically with valid service configuration;
234
267
  # absent wiring remains inert and partial wiring fails closed. The cadence
@@ -291,6 +324,18 @@ functions:
291
324
  - httpApi: GET /docs
292
325
  - httpApi: GET /docs/{trpc+}
293
326
 
327
+ campaignUnsubscribe:
328
+ handler: lambda.campaignUnsubscribeHandler
329
+ vpc:
330
+ securityGroupIds:
331
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
332
+ subnetIds:
333
+ - Ref: PrivateSubnetA
334
+ events:
335
+ - httpApi: POST /campaigns/unsubscribe
336
+ - httpApi: GET /campaigns/unsubscribe/browser
337
+ - httpApi: POST /campaigns/unsubscribe/browser
338
+
294
339
  skillsViewer:
295
340
  handler: lambda.skillsViewerHandler
296
341
  vpc:
@@ -1,5 +1,11 @@
1
1
  import { QueryBuilder, BindParam } from 'neogma';
2
- import { UnsignedVC, VC } from '@learncard/types';
2
+ import {
3
+ UnsignedVC,
4
+ VC,
5
+ CredentialDisplayValidator,
6
+ type CredentialDisplay,
7
+ } from '@learncard/types';
8
+ import { DISPLAY_ONLY_ANCHOR_MARKER } from '@learncard/helpers';
3
9
  import { v4 as uuid } from 'uuid';
4
10
 
5
11
  import { Boost, BoostInstance, Profile, AppStoreListing } from '@models';
@@ -13,10 +19,11 @@ import { ProfileType } from 'types/profile';
13
19
  import { AppStoreListingType } from 'types/app-store-listing';
14
20
 
15
21
  export const createBoost = async (
16
- credential: UnsignedVC | VC,
22
+ credential: UnsignedVC | VC | null,
17
23
  creator: ProfileType,
18
24
  metadata: Omit<BoostType, 'id' | 'boost'> = {},
19
- domain: string
25
+ domain: string,
26
+ publicDisplay?: CredentialDisplay
20
27
  ): Promise<BoostInstance> => {
21
28
  const id = uuid();
22
29
 
@@ -30,10 +37,20 @@ export const createBoost = async (
30
37
  new BindParam({
31
38
  params: {
32
39
  id,
33
- boost: convertCredentialToBoostTemplateJSON(
34
- credential,
35
- getDidWeb(domain, creator.profileId)
36
- ),
40
+ boost: credential
41
+ ? convertCredentialToBoostTemplateJSON(
42
+ credential,
43
+ getDidWeb(domain, creator.profileId)
44
+ )
45
+ : JSON.stringify({
46
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
47
+ type: ['VerifiableCredential'],
48
+ issuer: getDidWeb(domain, creator.profileId),
49
+ credentialSubject: { id: 'did:example:123' },
50
+ name: publicDisplay?.name ?? 'Credential',
51
+ display: CredentialDisplayValidator.parse(publicDisplay ?? {}),
52
+ [DISPLAY_ONLY_ANCHOR_MARKER]: true,
53
+ }),
37
54
  status,
38
55
  ...((flattenObject as any)(metadata) as any),
39
56
  },
@@ -64,6 +81,44 @@ export const createBoost = async (
64
81
  return boost;
65
82
  };
66
83
 
84
+ /**
85
+ * `name`/`image` come only from the caller's explicit `display`. `category` may
86
+ * additionally fall back to the inline template's category: consent-flow write
87
+ * authorization (`resolveContractForSend` and the managed-send contract binding
88
+ * query) keys off a Boost's `category` and treats an absent category as
89
+ * unrestricted. Silently dropping the category — rather than the template's actual
90
+ * value — would let a category the recipient never consented to pass as an
91
+ * unrestricted write merely because the caller omitted `display`.
92
+ */
93
+ export const normalizePublicDisplay = (
94
+ display: CredentialDisplay | undefined,
95
+ template?: { category?: string | null }
96
+ ): CredentialDisplay => {
97
+ const category = display?.category ?? template?.category?.slice(0, 280);
98
+ return CredentialDisplayValidator.parse({
99
+ ...(display ?? {}),
100
+ ...(category ? { category } : {}),
101
+ });
102
+ };
103
+
104
+ /** Display-only anchors never receive credential content or inline template metadata,
105
+ * except that `category` may be inherited from the template (see `normalizePublicDisplay`). */
106
+ export const createDisplayOnlyBoost = (
107
+ display: CredentialDisplay | undefined,
108
+ creator: ProfileType,
109
+ domain: string,
110
+ template?: { category?: string | null }
111
+ ): Promise<BoostInstance> => {
112
+ const publicDisplay = normalizePublicDisplay(display, template);
113
+ return createBoost(
114
+ null,
115
+ creator,
116
+ { ...publicDisplay, name: publicDisplay.name ?? 'Credential' },
117
+ domain,
118
+ publicDisplay
119
+ );
120
+ };
121
+
67
122
  /**
68
123
  * Creates a boost owned by an AppStoreListing (not a Profile).
69
124
  * The boost is created with the app's DID as the issuer.