@learncard/network-brain-service 3.19.1 → 4.0.1

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 (315) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +120 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/bundle.mjs +9 -1
  7. package/config/config.dev.json +29 -0
  8. package/config/config.example.json +29 -0
  9. package/config/config.json +1 -0
  10. package/config/config.production.json +29 -0
  11. package/config/config.scouts.dev.json +24 -0
  12. package/config/config.scouts.production.json +24 -0
  13. package/contractEventsLambda.ts +18 -0
  14. package/contractEventsLambdaApp.ts +19 -0
  15. package/didWebLambda.ts +13 -18
  16. package/didWebLambdaApp.ts +22 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  18. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  20. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  22. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  23. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  24. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  26. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  28. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  29. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  30. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  32. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  34. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  35. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  36. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  41. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  47. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  48. package/dist/accesslayer/credential/create.d.ts +2 -1
  49. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  50. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  51. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  53. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  55. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  57. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  58. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  59. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  60. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  61. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  65. package/dist/accesslayer/share-link/types.d.ts +8 -3
  66. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +49 -0
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  70. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  71. package/dist/config/environment.d.ts +5 -0
  72. package/dist/config/environment.d.ts.map +1 -1
  73. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  74. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  75. package/dist/config/stageConfig.d.ts +112 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -0
  77. package/dist/config/stageConfig.test.d.ts +2 -0
  78. package/dist/config/stageConfig.test.d.ts.map +1 -0
  79. package/dist/didkit_wasm_bg.wasm +0 -0
  80. package/dist/docker-entry.d.ts +1 -1
  81. package/dist/docker-entry.d.ts.map +1 -1
  82. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  83. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  84. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  85. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  86. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  87. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  89. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  91. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  92. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  93. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  95. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  96. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  97. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  99. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  100. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  101. package/dist/helpers/notificationMessages.d.ts +1 -1
  102. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  103. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  104. package/dist/helpers/notifications.helpers.d.ts +6 -0
  105. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  106. package/dist/helpers/profile.helpers.d.ts +1 -0
  107. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  108. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  109. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  110. package/dist/helpers/share-link-attachment.d.ts +18 -0
  111. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  113. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  114. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  115. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  116. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  117. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  119. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  122. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  123. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  124. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  125. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  127. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  128. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  129. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  130. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  131. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  132. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  133. package/dist/index.js +2 -2
  134. package/dist/models/AppManifestVersion.d.ts +20 -0
  135. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  136. package/dist/models/AppStoreListing.d.ts +13 -0
  137. package/dist/models/AppStoreListing.d.ts.map +1 -1
  138. package/dist/models/ConsentFlowContract.d.ts +13 -2
  139. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  141. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  142. package/dist/models/Integration.d.ts +2 -0
  143. package/dist/models/Integration.d.ts.map +1 -1
  144. package/dist/models/ShareLink.d.ts +2 -0
  145. package/dist/models/ShareLink.d.ts.map +1 -1
  146. package/dist/models/index.d.ts +1 -0
  147. package/dist/models/index.d.ts.map +1 -1
  148. package/dist/models/share-link-constraints.d.ts.map +1 -1
  149. package/dist/openapi.d.ts +1 -1
  150. package/dist/openapi.d.ts.map +1 -1
  151. package/dist/publish-metafile.json +1 -0
  152. package/dist/routes/app-store.d.ts +619 -0
  153. package/dist/routes/app-store.d.ts.map +1 -1
  154. package/dist/routes/boosts.d.ts +2 -2
  155. package/dist/routes/boosts.d.ts.map +1 -1
  156. package/dist/routes/contracts.d.ts +146 -3
  157. package/dist/routes/contracts.d.ts.map +1 -1
  158. package/dist/routes/credentials.d.ts +34 -0
  159. package/dist/routes/credentials.d.ts.map +1 -1
  160. package/dist/routes/inbox.d.ts +52 -2
  161. package/dist/routes/inbox.d.ts.map +1 -1
  162. package/dist/routes/index.d.ts.map +1 -1
  163. package/dist/routes/profile-manager.d.ts.map +1 -1
  164. package/dist/routes/profiles.d.ts.map +1 -1
  165. package/dist/routes/public-share-links.d.ts +55 -0
  166. package/dist/routes/public-share-links.d.ts.map +1 -1
  167. package/dist/routes/share-links.d.ts +175 -14
  168. package/dist/routes/share-links.d.ts.map +1 -1
  169. package/dist/routes/test.d.ts +5 -0
  170. package/dist/routes/test.d.ts.map +1 -1
  171. package/dist/routes/workflows.d.ts.map +1 -1
  172. package/dist/types/app-manifest.d.ts +339 -0
  173. package/dist/types/app-manifest.d.ts.map +1 -0
  174. package/dist/types/app-store-listing.d.ts +34 -0
  175. package/dist/types/app-store-listing.d.ts.map +1 -1
  176. package/dist/types/consentflowcontract.d.ts +30 -3
  177. package/dist/types/consentflowcontract.d.ts.map +1 -1
  178. package/dist/types/credential-refresh.d.ts +1 -1
  179. package/esbuildPlugins.cjs +1 -0
  180. package/lambda.ts +49 -195
  181. package/lambdaApp.ts +203 -0
  182. package/package.json +20 -19
  183. package/project.json +8 -1
  184. package/scripts/export-openapi.ts +20 -0
  185. package/scripts/offline-schema-environment.ts +18 -0
  186. package/scripts/verify-python-contracts.ts +72 -0
  187. package/serverless-local.yml +3 -6
  188. package/serverless.function-env.cjs +50 -0
  189. package/serverless.local.yml +0 -1
  190. package/serverless.yml +29 -74
  191. package/shareLinkMaintenanceLambda.ts +13 -44
  192. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  193. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  194. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  195. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  196. package/src/accesslayer/app-store-listing/read.ts +6 -0
  197. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  198. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  199. package/src/accesslayer/app-store-listing/update.ts +15 -0
  200. package/src/accesslayer/boost/relationships/create.ts +8 -2
  201. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  202. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  203. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  204. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  205. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  206. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  207. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  208. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  209. package/src/accesslayer/credential/create.test.ts +2 -115
  210. package/src/accesslayer/credential/create.ts +11 -6
  211. package/src/accesslayer/credential/relationships/create.ts +17 -10
  212. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  213. package/src/accesslayer/inbox-credential/update.ts +4 -2
  214. package/src/accesslayer/share-link/abandon.ts +20 -0
  215. package/src/accesslayer/share-link/attachment.ts +360 -0
  216. package/src/accesslayer/share-link/finalize.ts +55 -7
  217. package/src/accesslayer/share-link/helpers.ts +33 -2
  218. package/src/accesslayer/share-link/recovery.ts +20 -0
  219. package/src/accesslayer/share-link/reserve.ts +85 -6
  220. package/src/accesslayer/share-link/revoke.ts +35 -0
  221. package/src/accesslayer/share-link/types.ts +9 -4
  222. package/src/cache/app-store.caches.ts +35 -0
  223. package/src/config/applyDockerStageConfig.ts +6 -0
  224. package/src/config/environment.test.ts +17 -12
  225. package/src/config/environment.ts +1 -0
  226. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  227. package/src/config/stageConfig.test.ts +25 -0
  228. package/src/config/stageConfig.ts +24 -0
  229. package/src/docker-entry.ts +24 -0
  230. package/src/helpers/app-manifest.helpers.ts +185 -0
  231. package/src/helpers/claim-hooks.helpers.ts +20 -7
  232. package/src/helpers/consent-referral.helpers.ts +6 -0
  233. package/src/helpers/consent-scopes.helpers.ts +58 -0
  234. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  235. package/src/helpers/contract-events.helpers.ts +540 -0
  236. package/src/helpers/contract-requests.helpers.ts +211 -0
  237. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  238. package/src/helpers/inbox.helpers.ts +51 -66
  239. package/src/helpers/inline-template.helpers.ts +560 -0
  240. package/src/helpers/notificationMessages.ts +59 -0
  241. package/src/helpers/notificationQueue.helpers.ts +23 -5
  242. package/src/helpers/notifications.helpers.ts +25 -1
  243. package/src/helpers/profile.helpers.ts +6 -0
  244. package/src/helpers/share-link-attachment-access.ts +87 -0
  245. package/src/helpers/share-link-attachment.ts +115 -0
  246. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  247. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  248. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  249. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  250. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  251. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  252. package/src/helpers/share-link-maintenance/types.ts +2 -0
  253. package/src/helpers/share-link-owner-projection.ts +2 -0
  254. package/src/helpers/share-link-policy/production.ts +37 -17
  255. package/src/helpers/share-link-policy/resolver.ts +16 -11
  256. package/src/helpers/share-link-policy/types.ts +15 -7
  257. package/src/helpers/signingAuthority.helpers.ts +23 -14
  258. package/src/helpers/smartResume.helpers.ts +123 -0
  259. package/src/models/AppManifestVersion.ts +43 -0
  260. package/src/models/AppStoreListing.ts +32 -0
  261. package/src/models/ConsentFlowContract.ts +34 -3
  262. package/src/models/ConsentFlowTerms.ts +10 -0
  263. package/src/models/ConsentFlowTransaction.ts +3 -0
  264. package/src/models/Integration.ts +2 -0
  265. package/src/models/ShareLink.ts +4 -0
  266. package/src/models/index.ts +23 -0
  267. package/src/models/share-link-constraints.ts +5 -0
  268. package/src/openapi.ts +44 -8
  269. package/src/routes/app-store.ts +1054 -27
  270. package/src/routes/boosts.ts +12 -14
  271. package/src/routes/contracts.ts +382 -262
  272. package/src/routes/inbox.ts +4 -2
  273. package/src/routes/index.ts +4 -1
  274. package/src/routes/profile-manager.ts +19 -0
  275. package/src/routes/profiles.ts +12 -0
  276. package/src/routes/public-share-links.ts +169 -0
  277. package/src/routes/share-links.ts +69 -0
  278. package/src/routes/workflows.ts +3 -2
  279. package/src/types/app-manifest.ts +28 -0
  280. package/src/types/app-store-listing.ts +13 -0
  281. package/src/types/consentflowcontract.ts +25 -4
  282. package/test/app-manifest.helpers.spec.ts +170 -0
  283. package/test/app-manifest.routes.spec.ts +299 -0
  284. package/test/app-store-listing-updates.spec.ts +236 -0
  285. package/test/app-store-listing.spec.ts +65 -3
  286. package/test/consent-data-boundaries.spec.ts +352 -0
  287. package/test/consent-recipients.spec.ts +513 -0
  288. package/test/consent-scopes.helpers.spec.ts +67 -0
  289. package/test/consentflow.spec.ts +63 -2
  290. package/test/contract-requests.spec.ts +1588 -0
  291. package/test/credential-storage.neo4j.spec.ts +122 -0
  292. package/test/guardian-gated-routes.spec.ts +36 -0
  293. package/test/inbox-security.e2e.ts +26 -10
  294. package/test/inbox.spec.ts +184 -8
  295. package/test/inline-template.helpers.spec.ts +49 -0
  296. package/test/managed-recipient-inbox.spec.ts +24 -2
  297. package/test/openapi.spec.ts +14 -73
  298. package/test/profiles.spec.ts +30 -0
  299. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  300. package/test/share-link-attachment.unit.spec.ts +244 -0
  301. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  302. package/test/share-link-maintenance.unit.spec.ts +32 -5
  303. package/test/share-link-owner-route.unit.spec.ts +70 -2
  304. package/test/share-link-policy.unit.spec.ts +18 -4
  305. package/test/share-link-production-policy.unit.spec.ts +58 -9
  306. package/test/share-link-public-route.unit.spec.ts +279 -0
  307. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  308. package/test/signing-authority-encryption.spec.ts +75 -0
  309. package/test/smart-resume-consent.spec.ts +233 -0
  310. package/test/smart-resume-upload.spec.ts +65 -0
  311. package/tsconfig.build.json +1 -1
  312. package/tsconfig.json +1 -0
  313. package/vitest.config.ts +13 -0
  314. package/vitest.inbox-e2e.config.ts +2 -0
  315. package/workerLambda.test.ts +100 -0
@@ -0,0 +1,540 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { QueryBuilder } from 'neogma';
3
+ import { int } from 'neo4j-driver';
4
+ import { DbTermsValidator } from 'types/consentflowcontract';
5
+ import { neogma } from '@instance';
6
+ import { environment } from '@environment';
7
+ import {
8
+ ConsentFlowTransactionValidator,
9
+ ConsentFlowWebhookMetadataValidator,
10
+ LCNNotificationTypeEnumValidator,
11
+ type ConsentFlowTransaction,
12
+ type ConsentFlowWebhookMetadata,
13
+ type LCNNotification,
14
+ } from '@learncard/types';
15
+ import { getContractById, getContractRequestAccess } from '@accesslayer/consentflowcontract/read';
16
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
17
+ import { getContractTermsById } from '@accesslayer/consentflowcontract/relationships/read';
18
+ import {
19
+ addNotificationToQueue,
20
+ PermanentNotificationDeliveryError,
21
+ } from './notifications.helpers';
22
+ import { constructUri, getIdFromUri } from './uri.helpers';
23
+ import { getNotificationMessage, type NotificationMessageKey } from './notificationMessages';
24
+ import { sanitizeProfileForTier, stripSensitiveProfileListFields } from './profile-privacy.helpers';
25
+ import { getStoredContractRequest } from '@accesslayer/consentflowcontract/read';
26
+ import { resolveRecipientLocale } from './getRecipientLocale.helpers';
27
+ import { ensureContractEventMaintenance } from './contract-event-maintenance.helpers';
28
+
29
+ type EventKind = ConsentFlowWebhookMetadata['event'];
30
+ const defaultDomain = (): string => environment.DOMAIN_NAME ?? 'network.learncard.com';
31
+
32
+ const MAX_DELIVERY_ATTEMPTS = 12;
33
+ const DELIVERY_RETRY_WINDOW_MS = 24 * 60 * 60_000;
34
+ const INITIAL_RETRY_DELAY_MS = 60_000;
35
+ const MAX_RETRY_DELAY_MS = 60 * 60_000;
36
+ // At most 50 data recipients, the owner, and a requester outside the data audience.
37
+ const MAX_INLINE_EVENT_DELIVERIES = 50 + 1 + 1;
38
+
39
+ export type ContractEventDispatchSummary = {
40
+ delivered: number;
41
+ pending: number;
42
+ skipped: number;
43
+ rejected: number;
44
+ failed: number;
45
+ };
46
+
47
+ /** Persist a notification intent WITH the consent transaction, before any external work. */
48
+ export const appendConsentEvent = (
49
+ query: QueryBuilder,
50
+ {
51
+ consenterProfileId,
52
+ transaction,
53
+ domain = defaultDomain(),
54
+ messageKey,
55
+ }: {
56
+ consenterProfileId: string;
57
+ transaction: ConsentFlowTransaction;
58
+ domain?: string;
59
+ messageKey: NotificationMessageKey;
60
+ }
61
+ ): QueryBuilder => {
62
+ const kind: EventKind =
63
+ transaction.action === 'consent'
64
+ ? 'consent_created'
65
+ : transaction.action === 'update'
66
+ ? 'consent_updated'
67
+ : transaction.action === 'withdraw'
68
+ ? 'consent_withdrawn'
69
+ : 'credentials_synced';
70
+ query.getBindParam().add({
71
+ eventConsenter: consenterProfileId,
72
+ eventDomain: domain,
73
+ eventKind: kind,
74
+ eventMessageKey: messageKey,
75
+ eventPayload: JSON.stringify(transaction),
76
+ });
77
+ return query.raw(`
78
+ WITH contract, terms, transaction
79
+ MATCH (consenter:Profile {profileId: $eventConsenter})
80
+ OPTIONAL MATCH (contract)-[request:REQUESTED_FOR]->(consenter)
81
+ SET terms.\`referral.requestId\` = CASE WHEN request.requestId IS NOT NULL AND (request.status = 'accepted' OR (request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated']))
82
+ THEN request.requestId ELSE terms.\`referral.requestId\` END,
83
+ terms.\`referral.requestedBy\` = CASE WHEN request.requestId IS NOT NULL AND (request.status = 'accepted' OR (request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated']))
84
+ THEN request.requestedBy ELSE terms.\`referral.requestedBy\` END,
85
+ terms.\`referral.externalReferenceId\` = CASE WHEN request.requestId IS NOT NULL AND (request.status = 'accepted' OR (request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated']))
86
+ THEN request.externalReferenceId ELSE terms.\`referral.externalReferenceId\` END,
87
+ transaction.\`referral.requestId\` = terms.\`referral.requestId\`,
88
+ transaction.\`referral.requestedBy\` = terms.\`referral.requestedBy\`,
89
+ transaction.\`referral.externalReferenceId\` = terms.\`referral.externalReferenceId\`
90
+ WITH contract, terms, transaction, consenter, request,
91
+ request.requestId IS NOT NULL AND request.status = 'pending' AND $eventKind IN ['consent_created', 'consent_updated'] AS requestAccepted
92
+ FOREACH (_ IN CASE WHEN $eventKind = 'consent_created' THEN [1] ELSE [] END |
93
+ MERGE (contract)-[accepted:REQUESTED_FOR]->(consenter)
94
+ ON CREATE SET accepted.status = 'accepted', accepted.readStatus = 'unseen'
95
+ SET accepted.status = CASE WHEN accepted.requestId IS NULL OR accepted.status = 'pending'
96
+ THEN 'accepted' ELSE accepted.status END)
97
+ FOREACH (_ IN CASE WHEN requestAccepted THEN [1] ELSE [] END |
98
+ SET request.status = 'accepted')
99
+ WITH contract, terms, transaction, requestAccepted
100
+ MATCH (contract)-[:CREATED_BY]->(owner:Profile)
101
+ OPTIONAL MATCH (contract)-[:SHARES_DATA_WITH]->(recipient:Profile)
102
+ WITH contract, terms, transaction, owner, requestAccepted, collect(DISTINCT recipient.profileId) AS recipientIds
103
+ CREATE (event:ConsentFlowEvent {id: transaction.id, contractId: contract.id, termsId: terms.id,
104
+ fromId: $eventConsenter, ownerId: owner.profileId, domain: $eventDomain,
105
+ kind: $eventKind, requestAccepted: requestAccepted, messageKey: $eventMessageKey, payload: $eventPayload, cleanupPending: true,
106
+ createdAt: transaction.date, requestId: terms.\`referral.requestId\`,
107
+ requestedBy: terms.\`referral.requestedBy\`, externalReferenceId: terms.\`referral.externalReferenceId\`})
108
+ WITH contract, terms, transaction, event, owner,
109
+ [owner.profileId] + [id IN recipientIds WHERE id <> owner.profileId] AS audience
110
+ WITH contract, terms, transaction, event, owner, audience,
111
+ audience + CASE WHEN event.requestAccepted AND event.requestedBy IS NOT NULL AND NOT event.requestedBy IN audience
112
+ THEN [event.requestedBy] ELSE [] END AS deliveries
113
+ FOREACH (recipientId IN [id IN deliveries WHERE id <> $eventConsenter] |
114
+ CREATE (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {
115
+ id: event.id + ':' + recipientId, toId: recipientId, state: 'pending', nextAttemptAt: event.createdAt,
116
+ eventCreatedAt: event.createdAt, attempts: 0,
117
+ role: CASE WHEN recipientId = owner.profileId THEN 'owner'
118
+ WHEN recipientId IN audience THEN 'recipient' ELSE 'requester' END}))
119
+ WITH DISTINCT contract, terms, transaction
120
+ `);
121
+ };
122
+
123
+ /** Shared Cypher for request creation/decisions; caller holds the contract lock. */
124
+ export const requestEventCypher = `
125
+ WITH contract, request, target
126
+ MATCH (contract)-[:CREATED_BY]->(owner:Profile)
127
+ MERGE (event:ConsentFlowEvent {id: CASE WHEN $eventKind = 'request_sent' THEN request.requestId ELSE request.requestId + ':' + request.status END})
128
+ ON CREATE SET event += {contractId: contract.id, fromId: $eventFrom,
129
+ ownerId: owner.profileId, domain: $domain, kind: $eventKind, createdAt: $now,
130
+ requestId: request.requestId, requestedBy: request.requestedBy,
131
+ externalReferenceId: request.externalReferenceId, message: request.message, fanoutCreated: false, cleanupPending: true}
132
+ WITH request, event, owner, target,
133
+ CASE WHEN coalesce(event.fanoutCreated, false) THEN []
134
+ WHEN $eventKind = 'request_sent' THEN [target.profileId]
135
+ ELSE [owner.profileId] + CASE WHEN request.requestedBy <> owner.profileId
136
+ THEN [request.requestedBy] ELSE [] END END AS deliveries
137
+ FOREACH (recipientId IN [id IN deliveries WHERE id IS NOT NULL AND id <> $eventFrom] |
138
+ MERGE (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {id: event.id + ':' + recipientId})
139
+ ON CREATE SET delivery += {toId: recipientId, state: 'pending', nextAttemptAt: $now,
140
+ eventCreatedAt: event.createdAt, attempts: 0,
141
+ role: CASE WHEN $eventKind = 'request_sent' THEN 'target'
142
+ WHEN recipientId = owner.profileId THEN 'owner' ELSE 'requester' END})
143
+ SET event.fanoutCreated = true
144
+ RETURN DISTINCT request, event.id AS eventId
145
+ `;
146
+
147
+ type StoredEvent = {
148
+ id: string;
149
+ contractId: string;
150
+ termsId?: string;
151
+ fromId: string;
152
+ ownerId: string;
153
+ domain: string;
154
+ kind: EventKind;
155
+ createdAt: string;
156
+ payload?: string;
157
+ messageKey?: NotificationMessageKey;
158
+ requestId?: string;
159
+ requestedBy?: string;
160
+ externalReferenceId?: string;
161
+ message?: string;
162
+ requestAccepted?: boolean;
163
+ };
164
+ type StoredDelivery = {
165
+ id: string;
166
+ toId: string;
167
+ role: ConsentFlowWebhookMetadata['recipientRole'];
168
+ };
169
+
170
+ const expiryActive = (expiry?: string): boolean =>
171
+ !expiry?.trim() || Date.parse(expiry) > Date.now();
172
+
173
+ /** Suppress queued audience data after recipient removal, withdrawal or expiry. */
174
+ export const authorizeContractNotification = async (
175
+ notification: LCNNotification
176
+ ): Promise<boolean> => {
177
+ const parsed = ConsentFlowWebhookMetadataValidator.safeParse(notification.data?.metadata);
178
+ if (!parsed.success) return true; // Existing unrelated notifications retain their behavior.
179
+ const metadata = parsed.data;
180
+ if (metadata.event === 'request_sent') {
181
+ const request = await getStoredContractRequest(
182
+ getIdFromUri(metadata.contractUri),
183
+ notification.to.profileId ?? ''
184
+ );
185
+ return request?.requestId === metadata.requestId && request?.status === 'pending';
186
+ }
187
+ const toProfileId = notification.to.profileId;
188
+ if (!toProfileId) return false;
189
+ const access = await getContractRequestAccess(getIdFromUri(metadata.contractUri), toProfileId);
190
+ const isRequester = toProfileId === metadata.requestedBy;
191
+ if (metadata.event.startsWith('request_')) {
192
+ // Apply the narrowed audience to old pending deliveries and already queued messages too.
193
+ return access.isOwner || isRequester;
194
+ }
195
+ if (!access.isManager && !isRequester) {
196
+ // Consented data is shared with this recipient, but another referrer's CRM ID is not.
197
+ if (notification.data?.metadata) {
198
+ delete notification.data.metadata.externalReferenceId;
199
+ delete notification.data.metadata.message;
200
+ }
201
+ if (notification.data?.transaction?.referral)
202
+ delete notification.data.transaction.referral.externalReferenceId;
203
+ }
204
+ if (metadata.recipientRole !== 'owner' && metadata.recipientRole !== 'recipient') return false;
205
+ if (!access.isRecipient) return false;
206
+ if (metadata.event === 'consent_withdrawn') return true;
207
+ if (!metadata.termsUri) return false;
208
+ const relationship = await getContractTermsById(getIdFromUri(metadata.termsUri));
209
+ const parsedTerms = DbTermsValidator.safeParse(relationship?.terms);
210
+ if (!relationship || !parsedTerms.success) return false;
211
+ const terms = parsedTerms.data;
212
+ if (
213
+ !(terms.status === 'live' || (terms.status === 'stale' && terms.oneTime)) ||
214
+ !expiryActive(terms.expiresAt) ||
215
+ !expiryActive(relationship.contract.expiresAt)
216
+ )
217
+ return false;
218
+ const personal = notification.data?.transaction?.terms?.read.personal;
219
+ if (personal)
220
+ for (const [key, value] of Object.entries(personal))
221
+ if (terms.terms.read.personal[key] !== value) delete personal[key];
222
+ // Legacy sync payload shape is preserved, but only still-authorized URIs are delivered.
223
+ const shared = notification.data?.transaction?.terms?.read.credentials.categories;
224
+ if (shared)
225
+ for (const [category, grant] of Object.entries(shared)) {
226
+ const current = terms.terms.read.credentials.categories[category];
227
+ grant.shared =
228
+ terms.terms.read.credentials.sharing !== false &&
229
+ current?.sharing &&
230
+ expiryActive(current.shareUntil)
231
+ ? (grant.shared ?? []).filter(uri => current.shared?.includes(uri))
232
+ : [];
233
+ }
234
+ return true;
235
+ };
236
+
237
+ const buildNotification = async (
238
+ event: StoredEvent,
239
+ delivery: StoredDelivery
240
+ ): Promise<LCNNotification | null> => {
241
+ const [from, to, owner, contract] = await Promise.all([
242
+ getProfileByProfileId(event.fromId),
243
+ getProfileByProfileId(delivery.toId),
244
+ getProfileByProfileId(event.ownerId),
245
+ getContractById(event.contractId),
246
+ ]);
247
+ if (!from || !to || !owner) return null;
248
+ const eventKind =
249
+ delivery.role === 'requester' && event.requestAccepted ? 'request_accepted' : event.kind;
250
+ const metadata = ConsentFlowWebhookMetadataValidator.parse({
251
+ eventId: event.id,
252
+ deliveryKey: delivery.id,
253
+ event: eventKind,
254
+ contractUri: constructUri('contract', event.contractId, event.domain),
255
+ ...(event.termsId && delivery.role !== 'requester' && delivery.role !== 'target'
256
+ ? { termsUri: constructUri('terms', event.termsId, event.domain) }
257
+ : {}),
258
+ requestId: event.requestId,
259
+ requestedBy: event.requestedBy,
260
+ externalReferenceId: event.externalReferenceId,
261
+ recipientRole: delivery.role,
262
+ });
263
+ const requesterDecision = delivery.role === 'requester';
264
+ const legacyAi = event.messageKey === 'consentFlowInsightsShared';
265
+ const transaction =
266
+ event.payload && delivery.role !== 'requester' && delivery.role !== 'target'
267
+ ? ConsentFlowTransactionValidator.parse({
268
+ ...JSON.parse(event.payload),
269
+ ...(event.requestId
270
+ ? {
271
+ referral: {
272
+ requestId: event.requestId,
273
+ requestedBy: event.requestedBy,
274
+ externalReferenceId: event.externalReferenceId,
275
+ },
276
+ }
277
+ : {}),
278
+ })
279
+ : undefined;
280
+ const notification: LCNNotification = {
281
+ type: LCNNotificationTypeEnumValidator.enum.CONSENT_FLOW_TRANSACTION,
282
+ from: {
283
+ ...stripSensitiveProfileListFields(sanitizeProfileForTier(from, 'unauthenticated')),
284
+ did: from.did,
285
+ },
286
+ to,
287
+ sent: event.createdAt,
288
+ message: getNotificationMessage(
289
+ (requesterDecision ? undefined : event.messageKey) ??
290
+ (event.kind === 'request_sent'
291
+ ? 'contractRequestReceived'
292
+ : eventKind === 'request_accepted'
293
+ ? 'contractRequestAccepted'
294
+ : eventKind === 'request_denied'
295
+ ? 'contractRequestDenied'
296
+ : 'contractRequestCancelled'),
297
+ resolveRecipientLocale(to),
298
+ {
299
+ name: from.displayName,
300
+ referrer: from.displayName || from.profileId,
301
+ contractOwner: owner.displayName || owner.profileId,
302
+ consenter: from.displayName,
303
+ contractName: contract?.name ?? '',
304
+ totalCredentials: String(
305
+ transaction?.terms
306
+ ? Object.values(transaction.terms.read.credentials.categories).reduce(
307
+ (n, category) => n + (category.shared?.length ?? 0),
308
+ 0
309
+ )
310
+ : 0
311
+ ),
312
+ categoryCount: String(
313
+ Object.keys(transaction?.terms?.read.credentials.categories ?? {}).length
314
+ ),
315
+ }
316
+ ),
317
+ data: {
318
+ ...(transaction ? { transaction } : {}),
319
+ metadata: {
320
+ ...metadata,
321
+ ...(legacyAi ? { type: 'AI Insight', contractId: event.contractId } : {}),
322
+ ...(event.kind === 'request_sent'
323
+ ? {
324
+ type: 'contract-request',
325
+ ...(event.message ? { message: event.message } : {}),
326
+ }
327
+ : {}),
328
+ },
329
+ },
330
+ };
331
+ return (await authorizeContractNotification(notification)) ? notification : null;
332
+ };
333
+
334
+ /** At-least-once dispatch. Leases fence overlapping workers; deliveryKey fences consumer retries. */
335
+ export const dispatchContractEvents = async ({
336
+ eventId,
337
+ limit = 25,
338
+ budgetMs = 20_000,
339
+ }: {
340
+ eventId?: string;
341
+ limit?: number;
342
+ budgetMs?: number;
343
+ } = {}): Promise<ContractEventDispatchSummary> => {
344
+ const deadline = Date.now() + budgetMs;
345
+ const summary = { delivered: 0, pending: 0, skipped: 0, rejected: 0, failed: 0 };
346
+ const retryCutoff = new Date(Date.now() - DELIVERY_RETRY_WINDOW_MS).toISOString();
347
+ const batchLimit = int(Math.max(1, Math.min(100, Math.trunc(limit))));
348
+ if (!eventId) await ensureContractEventMaintenance();
349
+ // Also recover cleanup after a crash and clear events with no eligible recipients.
350
+ await neogma.queryRunner.run(
351
+ `
352
+ ${
353
+ eventId
354
+ ? 'MATCH (event:ConsentFlowEvent {id: $eventId})'
355
+ : `MATCH (event:ConsentFlowEvent)
356
+ USING INDEX event:ConsentFlowEvent(cleanupPending)`
357
+ }
358
+ WHERE ${eventId ? '(event.payload IS NOT NULL OR event.message IS NOT NULL OR event.cleanupPending = true)' : 'event.cleanupPending = true'}
359
+ AND NOT EXISTS {
360
+ MATCH (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery)
361
+ WHERE delivery.state = 'pending'
362
+ }
363
+ WITH event LIMIT $limit
364
+ SET event.deliveryLock = coalesce(event.deliveryLock, 0) + 1
365
+ WITH event WHERE NOT EXISTS {
366
+ MATCH (event)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery)
367
+ WHERE delivery.state = 'pending'
368
+ }
369
+ SET event.payload = null, event.message = null, event.cleanupPending = null
370
+ `,
371
+ { eventId: eventId ?? null, limit: batchLimit }
372
+ );
373
+ const rows = await neogma.queryRunner.run(
374
+ `
375
+ ${
376
+ eventId
377
+ ? `MATCH (event:ConsentFlowEvent {id: $eventId})-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery)
378
+ WHERE delivery.state = 'pending'
379
+ AND (delivery.nextAttemptAt <= $now OR event.createdAt <= $retryCutoff
380
+ OR coalesce(delivery.attempts, 0) >= $maxAttempts)`
381
+ : `CALL {
382
+ MATCH (delivery:ConsentFlowEventDelivery)
383
+ USING INDEX delivery:ConsentFlowEventDelivery(state, nextAttemptAt)
384
+ WHERE delivery.state = 'pending' AND delivery.nextAttemptAt <= $now
385
+ MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery)
386
+ RETURN event, delivery
387
+ UNION
388
+ MATCH (delivery:ConsentFlowEventDelivery)
389
+ USING INDEX delivery:ConsentFlowEventDelivery(state, eventCreatedAt)
390
+ WHERE delivery.state = 'pending' AND delivery.eventCreatedAt <= $retryCutoff
391
+ MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery)
392
+ RETURN event, delivery
393
+ UNION
394
+ MATCH (delivery:ConsentFlowEventDelivery)
395
+ USING INDEX delivery:ConsentFlowEventDelivery(state, attempts)
396
+ WHERE delivery.state = 'pending' AND delivery.attempts >= $maxAttempts
397
+ MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery)
398
+ RETURN event, delivery
399
+ }
400
+ WITH event, delivery WHERE true`
401
+ }
402
+ AND (delivery.leaseUntil IS NULL OR delivery.leaseUntil < $now)
403
+ RETURN event, delivery ORDER BY delivery.nextAttemptAt, delivery.id LIMIT $limit
404
+ `,
405
+ {
406
+ now: new Date().toISOString(),
407
+ eventId: eventId ?? null,
408
+ retryCutoff,
409
+ maxAttempts: int(MAX_DELIVERY_ATTEMPTS),
410
+ limit: batchLimit,
411
+ }
412
+ );
413
+ for (const row of rows.records) {
414
+ if (Date.now() >= deadline) break;
415
+ const event: StoredEvent = row.get('event').properties;
416
+ const delivery: StoredDelivery = row.get('delivery').properties;
417
+ const lease = randomUUID();
418
+ const now = new Date().toISOString();
419
+ const claimed = await neogma.queryRunner.run(
420
+ `
421
+ MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {id: $id})
422
+ SET delivery.lock = coalesce(delivery.lock, 0) + 1
423
+ WITH event, delivery WHERE delivery.state = 'pending'
424
+ AND (delivery.nextAttemptAt <= $now OR event.createdAt <= $retryCutoff
425
+ OR coalesce(delivery.attempts, 0) >= $maxAttempts)
426
+ AND (delivery.leaseUntil IS NULL OR delivery.leaseUntil < $now)
427
+ WITH delivery, coalesce(delivery.attempts, 0) AS previousAttempts
428
+ SET delivery.lease = $lease, delivery.leaseUntil = $leaseUntil,
429
+ delivery.attempts = CASE WHEN previousAttempts < $maxAttempts
430
+ THEN previousAttempts + 1 ELSE previousAttempts END
431
+ RETURN previousAttempts
432
+ `,
433
+ {
434
+ id: delivery.id,
435
+ now,
436
+ lease,
437
+ leaseUntil: new Date(Date.now() + 60_000).toISOString(),
438
+ retryCutoff,
439
+ maxAttempts: int(MAX_DELIVERY_ATTEMPTS),
440
+ }
441
+ );
442
+ if (!claimed.records.length) continue;
443
+ // Count claims, including abandoned leases, so crashes cannot reset the retry budget.
444
+ const previousAttempts = Number(claimed.records[0]!.get('previousAttempts'));
445
+ const attempts = Math.min(previousAttempts + 1, MAX_DELIVERY_ATTEMPTS);
446
+ let state = 'pending';
447
+ let failureReason: string | null = null;
448
+ try {
449
+ if (event.createdAt <= retryCutoff || previousAttempts >= MAX_DELIVERY_ATTEMPTS) {
450
+ state = 'failed';
451
+ failureReason =
452
+ event.createdAt <= retryCutoff ? 'retry_expired' : 'retry_exhausted';
453
+ summary.failed++;
454
+ } else {
455
+ const notification = await buildNotification(event, delivery);
456
+ if (!notification) {
457
+ state = 'skipped';
458
+ summary.skipped++;
459
+ } else {
460
+ const delivered = await addNotificationToQueue(notification, {
461
+ propagateDirectWebhookTransportErrors: true,
462
+ throwOnPermanentFailure: true,
463
+ });
464
+ if (delivered === false)
465
+ throw new Error('Contract notification was not stored');
466
+ state = 'delivered';
467
+ summary.delivered++;
468
+ }
469
+ }
470
+ } catch (error) {
471
+ if (error instanceof PermanentNotificationDeliveryError) {
472
+ state = 'rejected';
473
+ failureReason = error.reason;
474
+ summary.rejected++;
475
+ } else if (attempts >= MAX_DELIVERY_ATTEMPTS) {
476
+ state = 'failed';
477
+ failureReason = 'retry_exhausted';
478
+ summary.failed++;
479
+ } else {
480
+ summary.pending++;
481
+ }
482
+ // Never log request references, profile identifiers, or consent payloads.
483
+ console.warn(`contract_events: delivery_${state}`);
484
+ }
485
+ // Serialize fan-out completion so concurrent last deliveries cannot both miss cleanup.
486
+ // History lives on Terms/Transactions; finished intents retain IDs and coarse outcomes.
487
+ await neogma.queryRunner.run(
488
+ `
489
+ MATCH (event:ConsentFlowEvent)-[:HAS_DELIVERY]->(delivery:ConsentFlowEventDelivery {id: $id})
490
+ SET delivery.lock = coalesce(delivery.lock, 0) + 1
491
+ WITH event, delivery WHERE delivery.lease = $lease
492
+ SET event.deliveryLock = coalesce(event.deliveryLock, 0) + 1
493
+ WITH event, delivery WHERE delivery.lease = $lease
494
+ SET delivery.state = $state, delivery.lease = null, delivery.leaseUntil = null,
495
+ delivery.updatedAt = $now, delivery.failureReason = $failureReason,
496
+ delivery.nextAttemptAt = $nextAttemptAt
497
+ WITH event
498
+ WHERE NOT EXISTS {
499
+ MATCH (event)-[:HAS_DELIVERY]->(remaining:ConsentFlowEventDelivery)
500
+ WHERE remaining.state = 'pending'
501
+ }
502
+ SET event.payload = null, event.message = null, event.cleanupPending = null
503
+ `,
504
+ {
505
+ id: delivery.id,
506
+ lease,
507
+ state,
508
+ failureReason,
509
+ now: new Date().toISOString(),
510
+ nextAttemptAt:
511
+ state === 'pending'
512
+ ? new Date(
513
+ Math.min(
514
+ Date.now() +
515
+ Math.min(
516
+ INITIAL_RETRY_DELAY_MS * 2 ** (attempts - 1),
517
+ MAX_RETRY_DELAY_MS
518
+ ),
519
+ Date.parse(event.createdAt) + DELIVERY_RETRY_WINDOW_MS
520
+ )
521
+ ).toISOString()
522
+ : null,
523
+ }
524
+ );
525
+ }
526
+ return summary;
527
+ };
528
+
529
+ /** A committed API mutation must succeed even if dispatch is temporarily unavailable. */
530
+ export const tryDispatchContractEvent = async (eventId: string): Promise<void> => {
531
+ try {
532
+ await dispatchContractEvents({
533
+ eventId,
534
+ limit: MAX_INLINE_EVENT_DELIVERIES,
535
+ budgetMs: 1_000,
536
+ });
537
+ } catch {
538
+ console.warn('contract_events: dispatch_pending');
539
+ }
540
+ };