@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,970 @@
1
+ import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest';
2
+ import type { BitstringStatusListEntry, UnsignedVC } from '@learncard/types';
3
+ import { VPValidator } from '@learncard/types';
4
+ import { assertManagedCredentialBinding, injectManagedRefreshService } from '@learncard/helpers';
5
+
6
+ import { neogma } from '@instance';
7
+ import { getUser } from './helpers/getClient';
8
+ import { sendSpy } from './helpers/spies';
9
+ import { InboxCredential } from '@models';
10
+ import * as statusLists from '@helpers/status-list.helpers';
11
+ import { bindRefreshStatusEntries } from '@helpers/managed-status.helpers';
12
+ import { mutateCredentialStatus } from '@helpers/credential-status-mutation.helpers';
13
+ import { storeCredential } from '@accesslayer/credential/create';
14
+ import { getCredentialUri, sendCredential } from '@helpers/credential.helpers';
15
+ import { getUriParts } from '@helpers/uri.helpers';
16
+ import { releaseUnlinkedCredential } from '@accesslayer/credential/delete';
17
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
18
+ import * as credentialRelationships from '@accesslayer/credential/relationships/create';
19
+ import {
20
+ computeCredentialStatusDigest,
21
+ matchesCredentialStatusDigest,
22
+ } from '@helpers/credential-refresh-materiality.helpers';
23
+
24
+ let issuer: Awaited<ReturnType<typeof getUser>>;
25
+ let holder: Awaited<ReturnType<typeof getUser>>;
26
+ const domain = 'localhost:3000';
27
+
28
+ vi.mock('@services/delivery/delivery.factory', () => ({
29
+ getDeliveryService: () => ({ send: sendSpy }),
30
+ }));
31
+
32
+ const bit = async (entry: BitstringStatusListEntry): Promise<boolean> => {
33
+ const list = await statusLists.getStatusListById(entry.statusListCredential.split('/').pop()!);
34
+ if (!list) throw new Error('Missing public status list');
35
+ const signed = await statusLists.getSignedStatusListCredential(list.id);
36
+ const subject = Array.isArray(signed?.credentialSubject)
37
+ ? signed.credentialSubject[0]
38
+ : signed?.credentialSubject;
39
+ if (typeof subject?.encodedList !== 'string')
40
+ throw new Error('Missing signed public bitstring');
41
+ const bytes = statusLists.decodeBitstring(subject.encodedList, list.size);
42
+ const index = Number(entry.statusListIndex);
43
+ return Boolean(bytes[Math.floor(index / 8)]! & (1 << (index % 8)));
44
+ };
45
+
46
+ const send = async (arrayShape = false, suppliedEntries?: BitstringStatusListEntry[]) => {
47
+ const template: UnsignedVC = {
48
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
49
+ type: ['VerifiableCredential'],
50
+ issuer: issuer.learnCard.id.did(),
51
+ credentialSubject: { id: holder.learnCard.id.did() },
52
+ validFrom: '2026-01-01T00:00:00Z',
53
+ };
54
+ const allocation = await issuer.clients.fullAuth.boost.prepareRefreshableSend({
55
+ ...(suppliedEntries ? {} : { statusBinding: 'server' as const }),
56
+ recipient: 'status-holder',
57
+ template: { credential: template },
58
+ credentialId: 'urn:uuid:managed-status-test',
59
+ statusPurposes: arrayShape ? ['suspension'] : [],
60
+ });
61
+ const entries = suppliedEntries ?? allocation.statusEntries!;
62
+ if (!suppliedEntries)
63
+ expect(entries.map(entry => entry.statusPurpose)).toEqual(
64
+ arrayShape ? ['revocation', 'suspension'] : ['revocation']
65
+ );
66
+ const unsigned = injectManagedRefreshService(
67
+ {
68
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
69
+ id: 'urn:uuid:managed-status-test',
70
+ type: ['VerifiableCredential'],
71
+ issuer: issuer.learnCard.id.did(),
72
+ credentialSubject: { id: allocation.holderDid },
73
+ validFrom: '2026-01-01T00:00:00Z',
74
+ credentialStatus: arrayShape ? entries : entries[0],
75
+ } satisfies UnsignedVC,
76
+ allocation.refreshService
77
+ );
78
+ const credential = await issuer.learnCard.invoke.issueCredential(unsigned);
79
+ const result = await issuer.clients.fullAuth.boost.send({
80
+ type: 'boost',
81
+ recipient: 'status-holder',
82
+ refresh: true,
83
+ signedCredential: credential,
84
+ templateUri: allocation.boostUri,
85
+ });
86
+ const uri = result.credentialUri;
87
+ const root = (
88
+ await neogma.queryRunner.run(
89
+ 'MATCH (:CredentialRefresh {refreshId: $refreshId})-[:ROOT]->(root) RETURN root.id AS id',
90
+ { refreshId: allocation.refreshId }
91
+ )
92
+ ).records[0]!.get('id') as string;
93
+ return { allocation, entries, unsigned, credential, uri, root, result };
94
+ };
95
+
96
+ describe('Managed public status', () => {
97
+ const ordinary = async () => {
98
+ const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
99
+ statusPurposes: ['revocation'],
100
+ });
101
+ const credential = await issuer.learnCard.invoke.issueCredential({
102
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
103
+ type: ['VerifiableCredential'],
104
+ issuer: issuer.learnCard.id.did(),
105
+ credentialSubject: { id: holder.learnCard.id.did() },
106
+ credentialStatus: entries,
107
+ });
108
+ const from = await getProfileByProfileId('status-issuer');
109
+ const to = await getProfileByProfileId('status-holder');
110
+ if (!from || !to) throw new Error('Missing test profiles');
111
+ return { entries, credential, from, to };
112
+ };
113
+
114
+ it('forwards foreign-owner plaintext without reserving or mutating its public bit', async () => {
115
+ const { entries, credential, from, to } = await ordinary();
116
+ await expect(storeCredential(credential, undefined, to.profileId)).rejects.toMatchObject({
117
+ code: 'BAD_REQUEST',
118
+ });
119
+ const uri = await sendCredential(to, from, credential, domain);
120
+ const bindings = await neogma.queryRunner.run('MATCH (b:CredentialStatusBinding) RETURN b');
121
+ expect(bindings.records).toHaveLength(0);
122
+ const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
123
+ try {
124
+ expect(
125
+ await mutateCredentialStatus(
126
+ getUriParts(uri).id,
127
+ from.profileId,
128
+ 'revocation',
129
+ true
130
+ )
131
+ ).toBe(true);
132
+ expect(warning).toHaveBeenCalledWith(
133
+ '[credential-status] RELATIONSHIP_ONLY_UNCONTROLLED_STATUS_LIST'
134
+ );
135
+ } finally {
136
+ warning.mockRestore();
137
+ }
138
+ expect(await bit(entries[0]!)).toBe(false);
139
+ });
140
+
141
+ it('delivers identical signed content twice with one binding and revokes the shared bit', async () => {
142
+ const { entries, credential, from, to } = await ordinary();
143
+ const first = await sendCredential(from, to, credential, domain);
144
+ // Object key order is not part of signed JSON identity; proof content is.
145
+ const { proof, ...body } = credential;
146
+ const second = await sendCredential(from, from, { proof, ...body }, domain);
147
+ expect(second).not.toBe(first);
148
+ const bindings = await neogma.queryRunner.run('MATCH (b:CredentialStatusBinding) RETURN b');
149
+ expect(bindings.records).toHaveLength(1);
150
+ expect(
151
+ await mutateCredentialStatus(getUriParts(second).id, from.profileId, 'revocation', true)
152
+ ).toBe(true);
153
+ expect(await bit(entries[0]!)).toBe(true);
154
+ await expect(storeCredential(credential, undefined, from.profileId)).rejects.toMatchObject({
155
+ code: 'BAD_REQUEST',
156
+ });
157
+ });
158
+
159
+ it.each([false, true])(
160
+ 'preserves shared reservations during unlinked cleanup (concurrent=%s)',
161
+ async concurrent => {
162
+ const { entries, credential, from, to } = await ordinary();
163
+ const first = await storeCredential(credential, undefined, from.profileId);
164
+ let second: string;
165
+ if (concurrent) {
166
+ [second] = await Promise.all([
167
+ sendCredential(from, to, credential, domain),
168
+ releaseUnlinkedCredential(first.id),
169
+ ]);
170
+ } else {
171
+ second = await sendCredential(from, to, credential, domain);
172
+ await releaseUnlinkedCredential(first.id);
173
+ }
174
+ const secondId = getUriParts(second).id;
175
+ const bindings = await neogma.queryRunner.run(
176
+ 'MATCH (b:CredentialStatusBinding) RETURN b.credentialId AS id'
177
+ );
178
+ expect(bindings.records).toHaveLength(1);
179
+ expect(bindings.records[0]!.get('id')).toBe(secondId);
180
+ const removed = await neogma.queryRunner.run(
181
+ 'MATCH (c:Credential {id: $id}) RETURN c',
182
+ { id: first.id }
183
+ );
184
+ expect(removed.records).toHaveLength(0);
185
+ const different = await issuer.learnCard.invoke.issueCredential({
186
+ ...credential,
187
+ proof: undefined,
188
+ id: 'urn:uuid:cleanup-reuse',
189
+ });
190
+ await expect(sendCredential(from, to, different, domain)).rejects.toMatchObject({
191
+ code: 'BAD_REQUEST',
192
+ });
193
+ expect(await mutateCredentialStatus(secondId, to.profileId, 'revocation', true)).toBe(
194
+ true
195
+ );
196
+ expect(await bit(entries[0]!)).toBe(true);
197
+ }
198
+ );
199
+
200
+ it('rejects different content or proof reusing an ordinary bound index', async () => {
201
+ const { credential, from, to } = await ordinary();
202
+ await sendCredential(from, to, credential, domain);
203
+ const different = await issuer.learnCard.invoke.issueCredential({
204
+ ...credential,
205
+ proof: undefined,
206
+ id: 'urn:uuid:different',
207
+ });
208
+ await expect(sendCredential(from, from, different, domain)).rejects.toMatchObject({
209
+ code: 'BAD_REQUEST',
210
+ });
211
+ await expect(
212
+ sendCredential(from, from, { ...credential, proof: [] }, domain)
213
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
214
+ });
215
+
216
+ it.each(['credentialSubject', 'proof'] as const)(
217
+ 'rejects differences in own JSON prototype-named keys within %s',
218
+ async field => {
219
+ const { credential, from, to } = await ordinary();
220
+ Object.defineProperty(credential[field], 'details', { value: {}, enumerable: true });
221
+ await sendCredential(from, to, credential, domain);
222
+ const different = structuredClone(credential);
223
+ Object.defineProperty(different[field], 'details', {
224
+ value: JSON.parse('{"__proto__":{"claim":"changed"}}'),
225
+ enumerable: true,
226
+ });
227
+ await expect(sendCredential(from, from, different, domain)).rejects.toMatchObject({
228
+ code: 'BAD_REQUEST',
229
+ });
230
+ }
231
+ );
232
+
233
+ it('preserves legacy contract-autoboost revocation when sender differs from list owner', async () => {
234
+ const { entries, credential, to } = await ordinary();
235
+ // Historical autoboost storage had no validated status owner or reservation mode.
236
+ const stored = await storeCredential(credential);
237
+ await neogma.queryRunner.run(
238
+ 'MATCH (sender:Profile {profileId: $sender}), (c:Credential {id: $id}) CREATE (sender)-[:CREDENTIAL_SENT {to: $recipient}]->(c)',
239
+ { sender: to.profileId, recipient: to.profileId, id: stored.id }
240
+ );
241
+ expect(await mutateCredentialStatus(stored.id, to.profileId, 'revocation', true)).toBe(
242
+ true
243
+ );
244
+ expect(await bit(entries[0]!)).toBe(true);
245
+ });
246
+
247
+ it('does not treat a stored owner mismatch as a legacy delivery', async () => {
248
+ const { entries, credential, from, to } = await ordinary();
249
+ const stored = await storeCredential(credential, undefined, from.profileId);
250
+ await neogma.queryRunner.run(
251
+ `MATCH (sender:Profile {profileId: $sender}), (c:Credential {id: $id})
252
+ SET c.statusOwnerProfileId = $sender
253
+ CREATE (sender)-[:CREDENTIAL_SENT {to: $recipient}]->(c)`,
254
+ { sender: to.profileId, recipient: to.profileId, id: stored.id }
255
+ );
256
+ await expect(
257
+ mutateCredentialStatus(stored.id, to.profileId, 'revocation', true)
258
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' });
259
+ expect(await bit(entries[0]!)).toBe(false);
260
+ const delivery = await neogma.queryRunner.run(
261
+ 'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $id}) RETURN sent.status AS status',
262
+ { id: stored.id }
263
+ );
264
+ expect(delivery.records[0]!.get('status')).toBeNull();
265
+ });
266
+
267
+ it('accepts external managed status lists and preserves ordinary storage', async () => {
268
+ const allocation =
269
+ await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
270
+ holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
271
+ credentialId: 'urn:uuid:external-managed-status',
272
+ });
273
+ const credential = await issuer.learnCard.invoke.issueCredential(
274
+ injectManagedRefreshService(
275
+ {
276
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
277
+ id: 'urn:uuid:external-managed-status',
278
+ type: ['VerifiableCredential'],
279
+ issuer: issuer.learnCard.id.did(),
280
+ credentialSubject: { id: holder.learnCard.id.did() },
281
+ credentialStatus: {
282
+ id: 'https://other.example/status-lists/id#0',
283
+ type: 'BitstringStatusListEntry',
284
+ statusPurpose: 'revocation',
285
+ statusListIndex: '0',
286
+ statusListCredential: 'https://other.example/status-lists/id',
287
+ },
288
+ },
289
+ allocation.refreshService
290
+ )
291
+ );
292
+ await expect(
293
+ issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
294
+ refreshId: allocation.refreshId,
295
+ credential,
296
+ })
297
+ ).resolves.toEqual(expect.any(String));
298
+ await expect(
299
+ storeCredential(credential, undefined, 'status-issuer')
300
+ ).resolves.toBeDefined();
301
+ });
302
+
303
+ it.each([false, true])(
304
+ 'recovers authenticated absent legacy status (array=%s)',
305
+ async arrayShape => {
306
+ const allocation =
307
+ await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
308
+ holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
309
+ credentialId: 'urn:uuid:legacy-absent',
310
+ });
311
+ const unsigned = injectManagedRefreshService(
312
+ {
313
+ '@context': ['https://www.w3.org/2018/credentials/v1'],
314
+ id: 'urn:uuid:legacy-absent',
315
+ type: ['VerifiableCredential'],
316
+ issuer: issuer.learnCard.id.did(),
317
+ issuanceDate: '2026-01-01T00:00:00Z',
318
+ credentialSubject: { id: holder.learnCard.id.did() },
319
+ },
320
+ allocation.refreshService
321
+ );
322
+ if (arrayShape)
323
+ unsigned.refreshService = [
324
+ allocation.refreshService,
325
+ {
326
+ id: 'https://issuer.example/refresh',
327
+ type: 'ManualRefreshService2018',
328
+ },
329
+ ];
330
+ const credential = await issuer.learnCard.invoke.issueCredential(unsigned);
331
+ expect(credential.credentialStatus).toBeUndefined();
332
+ const uri = await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
333
+ refreshId: allocation.refreshId,
334
+ credential,
335
+ });
336
+ await neogma.queryRunner.run(
337
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries, r.credentialStatusBinding, r.refreshServiceBinding',
338
+ { refreshId: allocation.refreshId }
339
+ );
340
+ const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
341
+ expect(receipt?.refreshServiceBinding).toBe('managed-entry');
342
+ expect(receipt?.credentialStatus).toBeUndefined();
343
+ expect(() => assertManagedCredentialBinding(credential, receipt!)).not.toThrow();
344
+ const stored = await neogma.queryRunner.run(
345
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) RETURN r.credentialStatusBinding AS binding',
346
+ { refreshId: allocation.refreshId }
347
+ );
348
+ expect(stored.records[0]!.get('binding')).toBe('null');
349
+ await expect(
350
+ issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
351
+ refreshId: allocation.refreshId,
352
+ credentialStatus: null,
353
+ })
354
+ ).resolves.toBe(true);
355
+ expect(await holder.clients.fullAuth.credential.acceptCredential({ uri })).toBeTruthy();
356
+ }
357
+ );
358
+
359
+ it.each([false, true])(
360
+ 'repairs the exact legacy receipt before claim (array=%s)',
361
+ async arrayShape => {
362
+ const { allocation, credential, uri } = await send(arrayShape);
363
+ await neogma.queryRunner.run(
364
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries, r.credentialStatusBinding',
365
+ { refreshId: allocation.refreshId }
366
+ );
367
+ await expect(
368
+ holder.clients.fullAuth.credentialRefresh.getReceipt({ uri })
369
+ ).rejects.toThrow('Delivery binding unavailable');
370
+ await expect(
371
+ issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
372
+ refreshId: allocation.refreshId,
373
+ credentialStatus: null,
374
+ })
375
+ ).rejects.toThrow('Credential status does not match the original credential');
376
+ await issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
377
+ refreshId: allocation.refreshId,
378
+ credentialStatus: credential.credentialStatus!,
379
+ });
380
+ const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
381
+ expect(receipt?.credentialStatus).toEqual(credential.credentialStatus);
382
+ expect(await holder.clients.fullAuth.credential.acceptCredential({ uri })).toBeTruthy();
383
+ }
384
+ );
385
+
386
+ it.each([false, true])(
387
+ 'returns the exact signed status shape before acceptance (array=%s)',
388
+ async arrayShape => {
389
+ const { credential, uri } = await send(arrayShape);
390
+ const receipt = await holder.clients.fullAuth.credentialRefresh.getReceipt({ uri });
391
+ expect(receipt?.credentialStatus).toEqual(credential.credentialStatus);
392
+ expect(receipt?.refreshService).toEqual(credential.refreshService);
393
+ expect(receipt).not.toHaveProperty('credentialSubject');
394
+ expect(receipt).not.toHaveProperty('proof');
395
+ }
396
+ );
397
+
398
+ it('never exposes a managed receipt after delivery revocation', async () => {
399
+ const { uri, root } = await send();
400
+ await neogma.queryRunner.run(
401
+ 'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $root}) SET sent.status = "revoked"',
402
+ { root }
403
+ );
404
+ await expect(
405
+ holder.clients.fullAuth.credentialRefresh.getReceipt({ uri })
406
+ ).rejects.toThrow();
407
+ });
408
+
409
+ it('distinguishes an authorized ordinary delivery from a missing delivery', async () => {
410
+ await neogma.queryRunner.run(
411
+ 'MATCH (p:Profile {profileId: "status-issuer"}) CREATE (p)-[:CREDENTIAL_SENT {to: "status-holder"}]->(:Credential {id: "ordinary-root"})'
412
+ );
413
+ expect(
414
+ await holder.clients.fullAuth.credentialRefresh.getReceipt({
415
+ uri: getCredentialUri('ordinary-root', 'localhost%3A3000'),
416
+ })
417
+ ).toBeNull();
418
+ await expect(
419
+ holder.clients.fullAuth.credentialRefresh.getReceipt({
420
+ uri: getCredentialUri('unknown-root', 'localhost%3A3000'),
421
+ })
422
+ ).rejects.toThrow();
423
+ });
424
+
425
+ it.each([false, true])(
426
+ 'preserves client allocation unless server binding is requested (server=%s)',
427
+ async serverBinding => {
428
+ const template: UnsignedVC = {
429
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
430
+ type: ['VerifiableCredential'],
431
+ issuer: issuer.learnCard.id.did(),
432
+ credentialSubject: { id: holder.learnCard.id.did() },
433
+ };
434
+ const input = {
435
+ recipient: 'status-holder',
436
+ template: { credential: template },
437
+ idempotencyKey: 'status-binding-compatibility',
438
+ ...(serverBinding ? { statusBinding: 'server' as const } : {}),
439
+ };
440
+ const prepared = await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
441
+ const aggregate = await neogma.queryRunner.run(
442
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) RETURN r.statusEntries AS entries',
443
+ { refreshId: prepared.refreshId }
444
+ );
445
+ if (serverBinding) {
446
+ expect(prepared.statusEntries).toHaveLength(1);
447
+ expect(JSON.parse(aggregate.records[0]!.get('entries'))).toEqual(
448
+ prepared.statusEntries
449
+ );
450
+ } else {
451
+ expect(prepared.statusEntries).toBeUndefined();
452
+ expect(aggregate.records[0]!.get('entries')).toBeNull();
453
+ const bindings = await neogma.queryRunner.run(
454
+ 'MATCH (b:CredentialStatusBinding) RETURN b'
455
+ );
456
+ expect(bindings.records).toHaveLength(0);
457
+ }
458
+ expect(await issuer.clients.fullAuth.boost.prepareRefreshableSend(input)).toEqual(
459
+ prepared
460
+ );
461
+ await expect(
462
+ issuer.clients.fullAuth.boost.prepareRefreshableSend({
463
+ ...input,
464
+ statusBinding: serverBinding ? undefined : 'server',
465
+ })
466
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
467
+
468
+ // Previous SDKs independently allocate through this route after preparation.
469
+ const clientEntries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
470
+ statusPurposes: ['revocation'],
471
+ });
472
+ const handoff = async (entries: BitstringStatusListEntry[]) => {
473
+ const signedCredential = await issuer.learnCard.invoke.issueCredential(
474
+ injectManagedRefreshService(
475
+ {
476
+ ...template,
477
+ id: prepared.credentialId,
478
+ credentialSubject: { id: prepared.holderDid },
479
+ credentialStatus: entries,
480
+ },
481
+ prepared.refreshService
482
+ )
483
+ );
484
+ return issuer.clients.fullAuth.boost.send({
485
+ type: 'boost',
486
+ recipient: input.recipient,
487
+ templateUri: prepared.boostUri,
488
+ idempotencyKey: input.idempotencyKey,
489
+ refresh: true,
490
+ signedCredential,
491
+ });
492
+ };
493
+ if (serverBinding) {
494
+ await expect(handoff(clientEntries)).rejects.toThrow(
495
+ 'Credential status does not match allocated entries'
496
+ );
497
+ }
498
+ const entries = prepared.statusEntries ?? clientEntries;
499
+ const result = await handoff(entries);
500
+ expect(result.refresh?.refreshId).toBe(prepared.refreshId);
501
+ expect(await bit(entries[0]!)).toBe(false);
502
+ await issuer.clients.fullAuth.boost.revokeBoostRecipient({
503
+ boostUri: prepared.boostUri,
504
+ recipientProfileId: input.recipient,
505
+ credentialUri: result.credentialUri,
506
+ });
507
+ expect(await bit(entries[0]!)).toBe(true);
508
+ const retry = await issuer.clients.fullAuth.boost.prepareRefreshableSend(input);
509
+ expect(retry.completed).toEqual(result);
510
+ expect(retry.statusEntries).toEqual(prepared.statusEntries);
511
+ }
512
+ );
513
+
514
+ it('unsuspends legacy revocation-only deliveries and supports new suspension and revocation', async () => {
515
+ const { root, entries } = await send();
516
+ await neogma.queryRunner.run(
517
+ 'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $root}) SET sent.status = $status',
518
+ { root, status: 'suspended' }
519
+ );
520
+ expect(await mutateCredentialStatus(root, 'status-holder', 'suspension', false)).toBe(true);
521
+ expect(await mutateCredentialStatus(root, 'status-holder', 'suspension', true)).toBe(true);
522
+ expect(await bit(entries[0]!)).toBe(false);
523
+ expect(await mutateCredentialStatus(root, 'status-holder', 'revocation', true)).toBe(true);
524
+ expect(await bit(entries[0]!)).toBe(true);
525
+ });
526
+
527
+ it('uses the stored status owner when the delivery sender differs', async () => {
528
+ const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
529
+ statusPurposes: ['revocation'],
530
+ });
531
+ const credential = await issuer.learnCard.invoke.issueCredential({
532
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
533
+ type: ['VerifiableCredential'],
534
+ issuer: issuer.learnCard.id.did(),
535
+ credentialSubject: { id: holder.learnCard.id.did() },
536
+ credentialStatus: entries,
537
+ });
538
+ const stored = await storeCredential(credential, undefined, 'status-issuer');
539
+ await neogma.queryRunner.run(
540
+ 'MATCH (sender:Profile {profileId: $sender}), (c:Credential {id: $id}) CREATE (sender)-[:CREDENTIAL_SENT {to: $recipient}]->(c)',
541
+ { sender: 'status-holder', recipient: 'status-holder', id: stored.id }
542
+ );
543
+ expect(await mutateCredentialStatus(stored.id, 'status-holder', 'revocation', true)).toBe(
544
+ true
545
+ );
546
+ expect(await bit(entries[0]!)).toBe(true);
547
+ });
548
+
549
+ it('rolls back a failed ordinary send and retries the same signed status index', async () => {
550
+ const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
551
+ statusPurposes: ['revocation'],
552
+ });
553
+ const credential = await issuer.learnCard.invoke.issueCredential({
554
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
555
+ type: ['VerifiableCredential'],
556
+ issuer: issuer.learnCard.id.did(),
557
+ credentialSubject: { id: holder.learnCard.id.did() },
558
+ credentialStatus: entries,
559
+ });
560
+ const from = await getProfileByProfileId('status-issuer');
561
+ const to = await getProfileByProfileId('status-holder');
562
+ if (!from || !to) throw new Error('Missing test profiles');
563
+ const failure = vi
564
+ .spyOn(credentialRelationships, 'createSentCredentialRelationship')
565
+ .mockRejectedValueOnce(new Error('forced relationship failure'));
566
+ try {
567
+ await expect(sendCredential(from, to, credential, domain)).rejects.toThrow(
568
+ 'forced relationship failure'
569
+ );
570
+ } finally {
571
+ failure.mockRestore();
572
+ }
573
+ await expect(sendCredential(from, to, credential, domain)).resolves.toEqual(
574
+ expect.any(String)
575
+ );
576
+ const rows = await neogma.queryRunner.run(
577
+ 'MATCH (binding:CredentialStatusBinding) RETURN binding'
578
+ );
579
+ expect(rows.records).toHaveLength(1);
580
+ });
581
+
582
+ it('releases unlinked reservations but preserves linked credentials', async () => {
583
+ const entries = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
584
+ statusPurposes: ['revocation'],
585
+ });
586
+ const credential = await issuer.learnCard.invoke.issueCredential({
587
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
588
+ type: ['VerifiableCredential'],
589
+ issuer: issuer.learnCard.id.did(),
590
+ credentialSubject: { id: holder.learnCard.id.did() },
591
+ credentialStatus: entries,
592
+ });
593
+ const first = await storeCredential(credential, undefined, 'status-issuer');
594
+ await releaseUnlinkedCredential(first.id);
595
+ const second = await storeCredential(credential, undefined, 'status-issuer');
596
+ await neogma.queryRunner.run(
597
+ 'MATCH (p:Profile {profileId: $profile}), (c:Credential {id: $id}) CREATE (p)-[:CREDENTIAL_SENT {to: $recipient}]->(c)',
598
+ { profile: 'status-issuer', recipient: 'status-holder', id: second.id }
599
+ );
600
+ await releaseUnlinkedCredential(second.id);
601
+ await expect(storeCredential(credential, undefined, 'status-issuer')).rejects.toThrow(
602
+ 'already bound'
603
+ );
604
+ });
605
+
606
+ it('does not create orphan credentials for status-free inbox claims without a boost', async () => {
607
+ const credential = await issuer.learnCard.invoke.issueCredential({
608
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
609
+ type: ['VerifiableCredential'],
610
+ issuer: issuer.learnCard.id.did(),
611
+ credentialSubject: { id: holder.learnCard.id.did() },
612
+ });
613
+ const inbox = await issuer.clients.fullAuth.inbox.issue({
614
+ credential,
615
+ recipient: { type: 'email', value: 'holder@example.com' },
616
+ });
617
+ const localExchangeId = new URL(inbox.claimUrl!).pathname.split('/').pop()!;
618
+ const start = await holder.clients.fullAuth.workflows.participateInExchange({
619
+ localWorkflowId: 'inbox-claim',
620
+ localExchangeId,
621
+ });
622
+ const presentation = VPValidator.parse(
623
+ await holder.learnCard.invoke.getDidAuthVp({
624
+ challenge: start.verifiablePresentationRequest?.challenge,
625
+ domain: start.verifiablePresentationRequest?.domain,
626
+ })
627
+ );
628
+ const response = await holder.clients.fullAuth.workflows.participateInExchange({
629
+ localWorkflowId: 'inbox-claim',
630
+ localExchangeId,
631
+ verifiablePresentation: presentation,
632
+ });
633
+ expect(response.inboxDeliveries).toHaveLength(1);
634
+ const nodes = await neogma.queryRunner.run('MATCH (c:Credential) RETURN c');
635
+ expect(nodes.records).toHaveLength(0);
636
+ });
637
+
638
+ it('keeps inbox escrow pending when a workflow claim reuses a managed index', async () => {
639
+ const { entries } = await send();
640
+ const credential = await issuer.learnCard.invoke.issueCredential({
641
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
642
+ type: ['VerifiableCredential'],
643
+ id: 'urn:uuid:ordinary-inbox-reuse',
644
+ issuer: issuer.learnCard.id.did(),
645
+ credentialSubject: { id: holder.learnCard.id.did() },
646
+ credentialStatus: entries,
647
+ });
648
+ const inbox = await issuer.clients.fullAuth.inbox.issue({
649
+ credential,
650
+ recipient: { type: 'email', value: 'holder@example.com' },
651
+ });
652
+ const localExchangeId = new URL(inbox.claimUrl!).pathname.split('/').pop()!;
653
+ const start = await holder.clients.fullAuth.workflows.participateInExchange({
654
+ localWorkflowId: 'inbox-claim',
655
+ localExchangeId,
656
+ });
657
+ const presentation = VPValidator.parse(
658
+ await holder.learnCard.invoke.getDidAuthVp({
659
+ challenge: start.verifiablePresentationRequest?.challenge,
660
+ domain: start.verifiablePresentationRequest?.domain,
661
+ })
662
+ );
663
+ const response = await holder.clients.fullAuth.workflows.participateInExchange({
664
+ localWorkflowId: 'inbox-claim',
665
+ localExchangeId,
666
+ verifiablePresentation: presentation,
667
+ });
668
+ expect(response.inboxDeliveries).toEqual([]);
669
+ const stored = await InboxCredential.findOne({ where: { id: inbox.issuanceId } });
670
+ expect(stored?.currentStatus).toBe('PENDING');
671
+ expect(stored?.credential).toBeDefined();
672
+ });
673
+
674
+ it('returns a clear repair error for a credential without supported status entries', async () => {
675
+ const { allocation } = await send();
676
+ await neogma.queryRunner.run(
677
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) SET r.credentialStatusDigest = $digest REMOVE r.statusEntries',
678
+ { refreshId: allocation.refreshId, digest: computeCredentialStatusDigest([]) }
679
+ );
680
+ await expect(
681
+ issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
682
+ refreshId: allocation.refreshId,
683
+ credentialStatus: [],
684
+ })
685
+ ).rejects.toThrow('no supported public status entries');
686
+ });
687
+
688
+ beforeAll(async () => {
689
+ process.env.CREDENTIAL_REFRESH_ENABLED = 'true';
690
+ issuer = await getUser('d'.repeat(64));
691
+ holder = await getUser('b'.repeat(64));
692
+ });
693
+ afterAll(() => {
694
+ delete process.env.CREDENTIAL_REFRESH_ENABLED;
695
+ });
696
+ beforeEach(async () => {
697
+ await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
698
+ await issuer.clients.fullAuth.profile.createProfile({ profileId: 'status-issuer' });
699
+ await holder.clients.fullAuth.profile.createProfile({ profileId: 'status-holder' });
700
+ });
701
+
702
+ it('shares public bits across v1/v2/v3 and rolls back failed unsuspension', async () => {
703
+ const { allocation, entries, unsigned } = await send(true);
704
+ for (const month of ['02', '03']) {
705
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
706
+ mode: 'issuer-signed',
707
+ refreshId: allocation.refreshId,
708
+ signedCredential: await issuer.learnCard.invoke.issueCredential({
709
+ ...unsigned,
710
+ validFrom: `2026-${month}-01T00:00:00Z`,
711
+ }),
712
+ });
713
+ }
714
+ const versions = await neogma.queryRunner.run(
715
+ 'MATCH (c:Credential {refreshId: $refreshId}) RETURN c.id AS id, c.version AS version ORDER BY version',
716
+ { refreshId: allocation.refreshId }
717
+ );
718
+ expect(versions.records).toHaveLength(3);
719
+ expect(Number(versions.records[2]!.get('version'))).toBe(3);
720
+ for (const row of versions.records) {
721
+ expect(await statusLists.getStatusEntriesForCredential(String(row.get('id')))).toEqual(
722
+ entries
723
+ );
724
+ }
725
+ const suspension = entries.find(entry => entry.statusPurpose === 'suspension')!;
726
+ const mutation = {
727
+ boostUri: allocation.boostUri,
728
+ recipientProfileId: 'status-holder',
729
+ credentialUri: getCredentialUri(
730
+ String(versions.records[2]!.get('id')),
731
+ 'localhost%3A3000'
732
+ ),
733
+ };
734
+ await issuer.clients.fullAuth.boost.suspendBoostRecipient(mutation);
735
+ expect(await bit(suspension)).toBe(true);
736
+ const failure = vi
737
+ .spyOn(statusLists, 'setStatusListEntryBit')
738
+ .mockRejectedValueOnce(new Error('forced write failure'));
739
+ await expect(
740
+ issuer.clients.fullAuth.boost.unsuspendBoostRecipient(mutation)
741
+ ).rejects.toThrow('forced write failure');
742
+ failure.mockRestore();
743
+ expect(await bit(suspension)).toBe(true);
744
+ expect(await issuer.clients.fullAuth.boost.unsuspendBoostRecipient(mutation)).toBe(true);
745
+ expect(await bit(suspension)).toBe(false);
746
+ await issuer.clients.fullAuth.boost.revokeBoostRecipient(mutation);
747
+ expect(await bit(entries[0]!)).toBe(true);
748
+ });
749
+
750
+ it('reconciles an earlier revocation when the original send is retried', async () => {
751
+ const { allocation, credential, root, entries } = await send();
752
+ await neogma.queryRunner.run(
753
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries',
754
+ { refreshId: allocation.refreshId }
755
+ );
756
+ await mutateCredentialStatus(root, 'status-holder', 'revocation', true);
757
+ expect(await bit(entries[0]!)).toBe(false);
758
+ await issuer.clients.fullAuth.credentialRefresh.sendRefreshableCredential({
759
+ refreshId: allocation.refreshId,
760
+ credential,
761
+ boostUri: allocation.boostUri,
762
+ });
763
+ expect(await bit(entries[0]!)).toBe(true);
764
+ });
765
+
766
+ it('serializes concurrent binding and revocation without losing the public bit', async () => {
767
+ const { allocation, entries, root } = await send();
768
+ await Promise.all([
769
+ bindRefreshStatusEntries(allocation.refreshId, entries, 'status-issuer', domain),
770
+ mutateCredentialStatus(root, 'status-holder', 'revocation', true),
771
+ ]);
772
+ expect(await bit(entries[0]!)).toBe(true);
773
+ });
774
+
775
+ it('rejects ordinary delivery reusing a managed index and concurrent cross-kind reservations', async () => {
776
+ const { allocation, credential, entries } = await send();
777
+ const ordinaryCredential = await issuer.learnCard.invoke.issueCredential({
778
+ '@context': ['https://www.w3.org/ns/credentials/v2'],
779
+ type: ['VerifiableCredential'],
780
+ id: 'urn:uuid:ordinary-status-test',
781
+ issuer: issuer.learnCard.id.did(),
782
+ credentialSubject: { id: holder.learnCard.id.did() },
783
+ credentialStatus: entries,
784
+ });
785
+ await expect(
786
+ issuer.clients.fullAuth.boost.sendBoost({
787
+ uri: allocation.boostUri,
788
+ profileId: 'status-holder',
789
+ credential: ordinaryCredential,
790
+ })
791
+ ).rejects.toThrow('already bound');
792
+ const other = await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
793
+ holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
794
+ credentialId: 'urn:uuid:race',
795
+ });
796
+ const fresh = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
797
+ statusPurposes: ['revocation'],
798
+ });
799
+ const outcomes = await Promise.allSettled([
800
+ bindRefreshStatusEntries(other.refreshId, fresh, 'status-issuer', domain),
801
+ storeCredential(
802
+ { ...credential, id: 'urn:uuid:ordinary-race', credentialStatus: fresh },
803
+ undefined,
804
+ 'status-issuer'
805
+ ),
806
+ ]);
807
+ expect(outcomes.filter(result => result.status === 'fulfilled')).toHaveLength(1);
808
+ expect(outcomes.filter(result => result.status === 'rejected')).toHaveLength(1);
809
+ });
810
+
811
+ it.each([false, true])(
812
+ 'repairs already revoked credentials without changing status shape (array=%s)',
813
+ async arrayShape => {
814
+ const { allocation, credential, root, entries } = await send(arrayShape);
815
+ await neogma.queryRunner.run(
816
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries',
817
+ { refreshId: allocation.refreshId }
818
+ );
819
+ await mutateCredentialStatus(root, 'status-holder', 'revocation', true);
820
+ expect(await bit(entries[0]!)).toBe(false);
821
+ await issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
822
+ refreshId: allocation.refreshId,
823
+ credentialStatus: credential.credentialStatus!,
824
+ });
825
+ expect(await bit(entries[0]!)).toBe(true);
826
+ expect(
827
+ matchesCredentialStatusDigest(
828
+ credential.credentialStatus,
829
+ computeCredentialStatusDigest(credential.credentialStatus)
830
+ )
831
+ ).toBe(true);
832
+ expect(
833
+ matchesCredentialStatusDigest(
834
+ arrayShape ? entries[0] : entries,
835
+ computeCredentialStatusDigest(credential.credentialStatus)
836
+ )
837
+ ).toBe(false);
838
+ }
839
+ );
840
+
841
+ it('rejects another refresh reusing an index, wrong owners, purpose, bounds and malformed local URLs', async () => {
842
+ const { entries } = await send();
843
+ const allocation =
844
+ await issuer.clients.fullAuth.credentialRefresh.allocateCredentialRefresh({
845
+ holder: { profileId: 'status-holder', did: holder.learnCard.id.did() },
846
+ credentialId: 'urn:uuid:second',
847
+ });
848
+ await expect(
849
+ bindRefreshStatusEntries(allocation.refreshId, entries, 'status-issuer', domain)
850
+ ).rejects.toThrow('already bound');
851
+ const entry = entries[0]!;
852
+ await expect(
853
+ statusLists.validateStatusEntries(entries, 'status-holder', domain)
854
+ ).rejects.toThrow('owner');
855
+ await expect(
856
+ statusLists.setStatusListEntryBit(entry, true, 'status-holder')
857
+ ).rejects.toThrow('owner');
858
+ await expect(
859
+ statusLists.validateStatusEntries(
860
+ [{ ...entry, statusPurpose: 'suspension' }],
861
+ 'status-issuer',
862
+ domain
863
+ )
864
+ ).rejects.toThrow('purpose');
865
+ await expect(
866
+ statusLists.validateStatusEntries(
867
+ [{ ...entry, statusListIndex: '999999999' }],
868
+ 'status-issuer',
869
+ domain
870
+ )
871
+ ).rejects.toThrow('range');
872
+ await expect(
873
+ statusLists.validateStatusEntries(
874
+ [{ ...entry, statusListCredential: 'https://localhost:3000/not-status-lists/id' }],
875
+ 'status-issuer',
876
+ domain
877
+ )
878
+ ).rejects.toMatchObject({
879
+ code: 'BAD_REQUEST',
880
+ message: 'Status list URL must be canonical and local',
881
+ });
882
+ });
883
+
884
+ const externalEntry = (
885
+ statusPurpose: 'revocation' | 'suspension'
886
+ ): BitstringStatusListEntry => ({
887
+ type: 'BitstringStatusListEntry',
888
+ statusPurpose,
889
+ statusListCredential: 'https://provider.example/status-list',
890
+ statusListIndex: '7',
891
+ });
892
+
893
+ it('sends, publishes and repairs external status while rejecting provider-side revocation', async () => {
894
+ const entry = externalEntry('revocation');
895
+ const { allocation, unsigned, root, result } = await send(false, [entry]);
896
+ expect(result.statusManagement).toBe('external');
897
+ expect(
898
+ (await neogma.queryRunner.run('MATCH (b:CredentialStatusBinding) RETURN b')).records
899
+ ).toHaveLength(0);
900
+ await issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
901
+ mode: 'issuer-signed',
902
+ refreshId: allocation.refreshId,
903
+ signedCredential: await issuer.learnCard.invoke.issueCredential({
904
+ ...unsigned,
905
+ validFrom: '2026-02-01T00:00:00Z',
906
+ }),
907
+ });
908
+ await expect(
909
+ issuer.clients.fullAuth.credentialRefresh.publishCredentialRefresh({
910
+ mode: 'issuer-signed',
911
+ refreshId: allocation.refreshId,
912
+ signedCredential: await issuer.learnCard.invoke.issueCredential({
913
+ ...unsigned,
914
+ validFrom: '2026-03-01T00:00:00Z',
915
+ credentialStatus: { ...entry, statusListIndex: '8' },
916
+ }),
917
+ })
918
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
919
+ await neogma.queryRunner.run(
920
+ 'MATCH (r:CredentialRefresh {refreshId: $refreshId}) REMOVE r.statusEntries',
921
+ { refreshId: allocation.refreshId }
922
+ );
923
+ await issuer.clients.fullAuth.credentialRefresh.attachStatusEntries({
924
+ refreshId: allocation.refreshId,
925
+ credentialStatus: entry,
926
+ });
927
+ expect(await statusLists.getStatusEntriesForCredential(root)).toEqual([entry]);
928
+ await expect(
929
+ issuer.clients.fullAuth.boost.revokeBoostRecipient({
930
+ boostUri: allocation.boostUri,
931
+ recipientProfileId: 'status-holder',
932
+ credentialUri: result.credentialUri,
933
+ })
934
+ ).rejects.toMatchObject({
935
+ code: 'PRECONDITION_FAILED',
936
+ message:
937
+ "This credential's public revocation is managed outside LearnCard. Revoke it with your status-list provider.",
938
+ });
939
+ const state = await neogma.queryRunner.run(
940
+ 'MATCH ()-[sent:CREDENTIAL_SENT]->(:Credential {id: $root}) RETURN sent.status AS status',
941
+ { root }
942
+ );
943
+ expect(state.records[0]!.get('status')).toBeNull();
944
+ });
945
+
946
+ it('revokes mixed local revocation but rejects external suspension and unsuspension', async () => {
947
+ const local = await issuer.clients.fullAuth.boost.allocateCredentialStatus({
948
+ statusPurposes: ['revocation'],
949
+ });
950
+ const { allocation, result } = await send(true, [...local, externalEntry('suspension')]);
951
+ expect(result.statusManagement).toBe('mixed');
952
+ const input = {
953
+ boostUri: allocation.boostUri,
954
+ recipientProfileId: 'status-holder',
955
+ credentialUri: result.credentialUri,
956
+ };
957
+ for (const mutate of [
958
+ issuer.clients.fullAuth.boost.suspendBoostRecipient,
959
+ issuer.clients.fullAuth.boost.unsuspendBoostRecipient,
960
+ ]) {
961
+ await expect(mutate(input)).rejects.toMatchObject({
962
+ code: 'PRECONDITION_FAILED',
963
+ message:
964
+ "This credential's public suspension is managed outside LearnCard. Update it with your status-list provider.",
965
+ });
966
+ }
967
+ await issuer.clients.fullAuth.boost.revokeBoostRecipient(input);
968
+ expect(await bit(local[0]!)).toBe(true);
969
+ });
970
+ });