@learncard/network-brain-service 3.19.1 → 4.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 (312) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +109 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/config/config.dev.json +29 -0
  7. package/config/config.example.json +29 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +29 -0
  10. package/config/config.scouts.dev.json +24 -0
  11. package/config/config.scouts.production.json +24 -0
  12. package/contractEventsLambda.ts +18 -0
  13. package/contractEventsLambdaApp.ts +19 -0
  14. package/didWebLambda.ts +13 -18
  15. package/didWebLambdaApp.ts +22 -0
  16. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  18. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  20. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  22. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  23. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  24. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  26. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  28. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  29. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  30. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  32. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  34. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  35. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  36. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/credential/create.d.ts +2 -1
  48. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  49. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  50. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  51. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  53. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  55. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  57. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  58. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  59. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  60. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  61. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/types.d.ts +8 -3
  65. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  66. package/dist/cache/app-store.caches.d.ts +49 -0
  67. package/dist/cache/app-store.caches.d.ts.map +1 -1
  68. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  69. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  70. package/dist/config/environment.d.ts +5 -0
  71. package/dist/config/environment.d.ts.map +1 -1
  72. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  73. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  74. package/dist/config/stageConfig.d.ts +112 -0
  75. package/dist/config/stageConfig.d.ts.map +1 -0
  76. package/dist/config/stageConfig.test.d.ts +2 -0
  77. package/dist/config/stageConfig.test.d.ts.map +1 -0
  78. package/dist/didkit_wasm_bg.wasm +0 -0
  79. package/dist/docker-entry.d.ts +1 -1
  80. package/dist/docker-entry.d.ts.map +1 -1
  81. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  82. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  83. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  84. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  85. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  86. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  87. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  89. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  91. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  92. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  93. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  95. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  96. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  97. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  99. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  100. package/dist/helpers/notificationMessages.d.ts +1 -1
  101. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  102. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  103. package/dist/helpers/notifications.helpers.d.ts +6 -0
  104. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  105. package/dist/helpers/profile.helpers.d.ts +1 -0
  106. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  107. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  108. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  109. package/dist/helpers/share-link-attachment.d.ts +18 -0
  110. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  111. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  113. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  114. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  115. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  116. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  117. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  119. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  122. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  123. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  124. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  125. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  127. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  128. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  129. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  130. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  131. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  132. package/dist/index.js +2 -2
  133. package/dist/models/AppManifestVersion.d.ts +20 -0
  134. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  135. package/dist/models/AppStoreListing.d.ts +13 -0
  136. package/dist/models/AppStoreListing.d.ts.map +1 -1
  137. package/dist/models/ConsentFlowContract.d.ts +13 -2
  138. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  139. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  141. package/dist/models/Integration.d.ts +2 -0
  142. package/dist/models/Integration.d.ts.map +1 -1
  143. package/dist/models/ShareLink.d.ts +2 -0
  144. package/dist/models/ShareLink.d.ts.map +1 -1
  145. package/dist/models/index.d.ts +1 -0
  146. package/dist/models/index.d.ts.map +1 -1
  147. package/dist/models/share-link-constraints.d.ts.map +1 -1
  148. package/dist/openapi.d.ts +1 -1
  149. package/dist/openapi.d.ts.map +1 -1
  150. package/dist/routes/app-store.d.ts +619 -0
  151. package/dist/routes/app-store.d.ts.map +1 -1
  152. package/dist/routes/boosts.d.ts +2 -2
  153. package/dist/routes/boosts.d.ts.map +1 -1
  154. package/dist/routes/contracts.d.ts +146 -3
  155. package/dist/routes/contracts.d.ts.map +1 -1
  156. package/dist/routes/credentials.d.ts +34 -0
  157. package/dist/routes/credentials.d.ts.map +1 -1
  158. package/dist/routes/inbox.d.ts +52 -2
  159. package/dist/routes/inbox.d.ts.map +1 -1
  160. package/dist/routes/index.d.ts.map +1 -1
  161. package/dist/routes/profile-manager.d.ts.map +1 -1
  162. package/dist/routes/profiles.d.ts.map +1 -1
  163. package/dist/routes/public-share-links.d.ts +55 -0
  164. package/dist/routes/public-share-links.d.ts.map +1 -1
  165. package/dist/routes/share-links.d.ts +175 -14
  166. package/dist/routes/share-links.d.ts.map +1 -1
  167. package/dist/routes/test.d.ts +5 -0
  168. package/dist/routes/test.d.ts.map +1 -1
  169. package/dist/routes/workflows.d.ts.map +1 -1
  170. package/dist/types/app-manifest.d.ts +339 -0
  171. package/dist/types/app-manifest.d.ts.map +1 -0
  172. package/dist/types/app-store-listing.d.ts +34 -0
  173. package/dist/types/app-store-listing.d.ts.map +1 -1
  174. package/dist/types/consentflowcontract.d.ts +30 -3
  175. package/dist/types/consentflowcontract.d.ts.map +1 -1
  176. package/dist/types/credential-refresh.d.ts +1 -1
  177. package/esbuildPlugins.cjs +1 -0
  178. package/lambda.ts +49 -195
  179. package/lambdaApp.ts +203 -0
  180. package/package.json +20 -18
  181. package/scripts/export-openapi.ts +20 -0
  182. package/scripts/offline-schema-environment.ts +18 -0
  183. package/scripts/verify-python-contracts.ts +72 -0
  184. package/serverless-local.yml +3 -6
  185. package/serverless.function-env.cjs +50 -0
  186. package/serverless.local.yml +0 -1
  187. package/serverless.yml +27 -74
  188. package/shareLinkMaintenanceLambda.ts +13 -44
  189. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  190. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  191. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  192. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  193. package/src/accesslayer/app-store-listing/read.ts +6 -0
  194. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  195. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  196. package/src/accesslayer/app-store-listing/update.ts +15 -0
  197. package/src/accesslayer/boost/relationships/create.ts +8 -2
  198. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  199. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  200. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  201. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  202. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  203. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  204. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  205. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  206. package/src/accesslayer/credential/create.test.ts +2 -115
  207. package/src/accesslayer/credential/create.ts +11 -6
  208. package/src/accesslayer/credential/relationships/create.ts +17 -10
  209. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  210. package/src/accesslayer/inbox-credential/update.ts +4 -2
  211. package/src/accesslayer/share-link/abandon.ts +20 -0
  212. package/src/accesslayer/share-link/attachment.ts +360 -0
  213. package/src/accesslayer/share-link/finalize.ts +55 -7
  214. package/src/accesslayer/share-link/helpers.ts +33 -2
  215. package/src/accesslayer/share-link/recovery.ts +20 -0
  216. package/src/accesslayer/share-link/reserve.ts +85 -6
  217. package/src/accesslayer/share-link/revoke.ts +35 -0
  218. package/src/accesslayer/share-link/types.ts +9 -4
  219. package/src/cache/app-store.caches.ts +35 -0
  220. package/src/config/applyDockerStageConfig.ts +6 -0
  221. package/src/config/environment.test.ts +17 -12
  222. package/src/config/environment.ts +1 -0
  223. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  224. package/src/config/stageConfig.test.ts +25 -0
  225. package/src/config/stageConfig.ts +24 -0
  226. package/src/docker-entry.ts +24 -0
  227. package/src/helpers/app-manifest.helpers.ts +185 -0
  228. package/src/helpers/claim-hooks.helpers.ts +20 -7
  229. package/src/helpers/consent-referral.helpers.ts +6 -0
  230. package/src/helpers/consent-scopes.helpers.ts +58 -0
  231. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  232. package/src/helpers/contract-events.helpers.ts +540 -0
  233. package/src/helpers/contract-requests.helpers.ts +211 -0
  234. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  235. package/src/helpers/inbox.helpers.ts +51 -66
  236. package/src/helpers/inline-template.helpers.ts +560 -0
  237. package/src/helpers/notificationMessages.ts +59 -0
  238. package/src/helpers/notificationQueue.helpers.ts +23 -5
  239. package/src/helpers/notifications.helpers.ts +25 -1
  240. package/src/helpers/profile.helpers.ts +6 -0
  241. package/src/helpers/share-link-attachment-access.ts +87 -0
  242. package/src/helpers/share-link-attachment.ts +115 -0
  243. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  244. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  245. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  246. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  247. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  248. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  249. package/src/helpers/share-link-maintenance/types.ts +2 -0
  250. package/src/helpers/share-link-owner-projection.ts +2 -0
  251. package/src/helpers/share-link-policy/production.ts +37 -17
  252. package/src/helpers/share-link-policy/resolver.ts +16 -11
  253. package/src/helpers/share-link-policy/types.ts +15 -7
  254. package/src/helpers/signingAuthority.helpers.ts +23 -14
  255. package/src/helpers/smartResume.helpers.ts +123 -0
  256. package/src/models/AppManifestVersion.ts +43 -0
  257. package/src/models/AppStoreListing.ts +32 -0
  258. package/src/models/ConsentFlowContract.ts +34 -3
  259. package/src/models/ConsentFlowTerms.ts +10 -0
  260. package/src/models/ConsentFlowTransaction.ts +3 -0
  261. package/src/models/Integration.ts +2 -0
  262. package/src/models/ShareLink.ts +4 -0
  263. package/src/models/index.ts +23 -0
  264. package/src/models/share-link-constraints.ts +5 -0
  265. package/src/openapi.ts +44 -8
  266. package/src/routes/app-store.ts +1054 -27
  267. package/src/routes/boosts.ts +12 -14
  268. package/src/routes/contracts.ts +382 -262
  269. package/src/routes/inbox.ts +4 -2
  270. package/src/routes/index.ts +4 -1
  271. package/src/routes/profile-manager.ts +19 -0
  272. package/src/routes/profiles.ts +12 -0
  273. package/src/routes/public-share-links.ts +169 -0
  274. package/src/routes/share-links.ts +69 -0
  275. package/src/routes/workflows.ts +3 -2
  276. package/src/types/app-manifest.ts +28 -0
  277. package/src/types/app-store-listing.ts +13 -0
  278. package/src/types/consentflowcontract.ts +25 -4
  279. package/test/app-manifest.helpers.spec.ts +170 -0
  280. package/test/app-manifest.routes.spec.ts +299 -0
  281. package/test/app-store-listing-updates.spec.ts +236 -0
  282. package/test/app-store-listing.spec.ts +65 -3
  283. package/test/consent-data-boundaries.spec.ts +352 -0
  284. package/test/consent-recipients.spec.ts +513 -0
  285. package/test/consent-scopes.helpers.spec.ts +67 -0
  286. package/test/consentflow.spec.ts +63 -2
  287. package/test/contract-requests.spec.ts +1588 -0
  288. package/test/credential-storage.neo4j.spec.ts +122 -0
  289. package/test/guardian-gated-routes.spec.ts +36 -0
  290. package/test/inbox-security.e2e.ts +26 -10
  291. package/test/inbox.spec.ts +184 -8
  292. package/test/inline-template.helpers.spec.ts +49 -0
  293. package/test/managed-recipient-inbox.spec.ts +24 -2
  294. package/test/openapi.spec.ts +14 -73
  295. package/test/profiles.spec.ts +30 -0
  296. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  297. package/test/share-link-attachment.unit.spec.ts +244 -0
  298. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  299. package/test/share-link-maintenance.unit.spec.ts +32 -5
  300. package/test/share-link-owner-route.unit.spec.ts +70 -2
  301. package/test/share-link-policy.unit.spec.ts +18 -4
  302. package/test/share-link-production-policy.unit.spec.ts +58 -9
  303. package/test/share-link-public-route.unit.spec.ts +279 -0
  304. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  305. package/test/signing-authority-encryption.spec.ts +75 -0
  306. package/test/smart-resume-consent.spec.ts +233 -0
  307. package/test/smart-resume-upload.spec.ts +65 -0
  308. package/tsconfig.build.json +1 -1
  309. package/tsconfig.json +1 -0
  310. package/vitest.config.ts +13 -0
  311. package/vitest.inbox-e2e.config.ts +2 -0
  312. package/workerLambda.test.ts +100 -0
@@ -0,0 +1,1588 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createServer } from 'node:http';
3
+ import * as runtime from '@environment';
4
+ import * as learnCardHelpers from '@helpers/learnCard.helpers';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { getClient, getUser } from './helpers/getClient';
7
+ import { testUnsignedBoost, testVc } from './helpers/send';
8
+ import { getHolderExportMetadataForProfile } from '@accesslayer/consentflowcontract/relationships/read';
9
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
10
+ import { consentToContract as commitConsent } from '@accesslayer/consentflowcontract/relationships/create';
11
+ import { normalContract, normalFullTerms } from './helpers/contract';
12
+ import cache from '@cache';
13
+ import { neogma } from '@instance';
14
+ import {
15
+ getContractDetailsByUri,
16
+ getContractTermsByUri,
17
+ } from '@accesslayer/consentflowcontract/relationships/read';
18
+ import { getStoredContractRequest } from '@accesslayer/consentflowcontract/read';
19
+ import * as contractRead from '@accesslayer/consentflowcontract/read';
20
+ import {
21
+ authorizeContractNotification,
22
+ dispatchContractEvents,
23
+ } from '@helpers/contract-events.helpers';
24
+ import * as notifications from '@helpers/notifications.helpers';
25
+ import { ensureContractEventMaintenance } from '@helpers/contract-event-maintenance.helpers';
26
+ import { deliverQueuedNotification } from '@helpers/notificationQueue.helpers';
27
+ import type { LCNNotification, LCNProfile } from '@learncard/types';
28
+ import { openApiDocument } from '../src/openapi';
29
+
30
+ const actor = async (role: string) => {
31
+ const user = await getUser(randomBytes(32).toString('hex'));
32
+ const profileId = `requests-${role}-${randomBytes(5).toString('hex')}`;
33
+ await user.clients.fullAuth.profile.createProfile({ profileId });
34
+ return { ...user, profileId };
35
+ };
36
+ const id = (uri: string): string => uri.split(':').at(-1)!;
37
+
38
+ describe('generic contract requests and correlated events', () => {
39
+ let owner: Awaited<ReturnType<typeof actor>>;
40
+ let writer: Awaited<ReturnType<typeof actor>>;
41
+ let recipient: Awaited<ReturnType<typeof actor>>;
42
+ let otherRecipient: Awaited<ReturnType<typeof actor>>;
43
+ let learner: Awaited<ReturnType<typeof actor>>;
44
+ let outsider: Awaited<ReturnType<typeof actor>>;
45
+ let delivered: LCNNotification[];
46
+ let queue: ReturnType<typeof vi.spyOn<typeof notifications, 'addNotificationToQueue'>>;
47
+ beforeEach(async () => {
48
+ [owner, writer, recipient, otherRecipient, learner, outsider] = await Promise.all([
49
+ actor('owner'),
50
+ actor('writer'),
51
+ actor('recipient'),
52
+ actor('other-recipient'),
53
+ actor('learner'),
54
+ actor('outsider'),
55
+ ]);
56
+ delivered = [];
57
+ queue = vi
58
+ .spyOn(notifications, 'addNotificationToQueue')
59
+ .mockImplementation(async notification => {
60
+ delivered.push(structuredClone(notification));
61
+ return undefined;
62
+ });
63
+ });
64
+ afterEach(() => vi.restoreAllMocks());
65
+ const create = (recipients = [recipient.profileId, otherRecipient.profileId]) =>
66
+ owner.clients.fullAuth.contracts.createConsentFlowContract({
67
+ name: 'Synthetic referral',
68
+ contract: normalContract,
69
+ writers: [writer.profileId],
70
+ recipients,
71
+ });
72
+ const send = (uri: string, sender = writer, reference = 'synthetic-ref-123') =>
73
+ sender.clients.fullAuth.contracts.sendContractRequest({
74
+ contractUri: uri,
75
+ targetProfileId: learner.profileId,
76
+ externalReferenceId: reference,
77
+ message: 'Synthetic invitation',
78
+ });
79
+ const status = (uri: string, viewer = owner) =>
80
+ viewer.clients.fullAuth.contracts.getRequestStatusForProfile({
81
+ contractUri: uri,
82
+ targetProfileId: learner.profileId,
83
+ });
84
+ const accept = async (uri: string) =>
85
+ learner.clients.fullAuth.contracts.consentToContract({
86
+ contractUri: uri,
87
+ terms: normalFullTerms,
88
+ audienceVersion: (
89
+ await owner.clients.fullAuth.contracts.getConsentFlowContract({ uri })
90
+ ).audienceVersion,
91
+ });
92
+ const due = (eventId: string) =>
93
+ neogma.queryRunner.run(
94
+ 'MATCH (:ConsentFlowEvent {id: $eventId})-[:HAS_DELIVERY]->(d) SET d.nextAttemptAt = $past',
95
+ { eventId, past: '2000-01-01T00:00:00.000Z' }
96
+ );
97
+ const eventRows = async (uri: string) =>
98
+ (
99
+ await neogma.queryRunner.run(
100
+ 'MATCH (e:ConsentFlowEvent {contractId: $contractId}) OPTIONAL MATCH (e)-[:HAS_DELIVERY]->(d) RETURN e, collect(d) AS deliveries',
101
+ { contractId: id(uri) }
102
+ )
103
+ ).records;
104
+
105
+ const flush = async (uri: string): Promise<void> => {
106
+ for (const row of await eventRows(uri))
107
+ await dispatchContractEvents({ eventId: row.get('e').properties.id });
108
+ };
109
+
110
+ const useDirectWebhook = (webhookUrl?: string): void => {
111
+ queue.mockRestore();
112
+ vi.spyOn(runtime, 'getNotificationRuntimeEnvironment').mockReturnValue({
113
+ ...runtime.getNotificationRuntimeEnvironment(),
114
+ NODE_ENV: 'development',
115
+ IS_OFFLINE: false,
116
+ NOTIFICATIONS_QUEUE_URL: undefined,
117
+ IS_E2E_TEST: false,
118
+ NOTIFICATIONS_SERVICE_WEBHOOK_URL: webhookUrl,
119
+ });
120
+ vi.spyOn(learnCardHelpers, 'getDidWebLearnCard').mockResolvedValue({
121
+ invoke: { getDidAuthVp: async () => 'synthetic.auth.jwt' },
122
+ } as unknown as Awaited<ReturnType<typeof learnCardHelpers.getDidWebLearnCard>>);
123
+ };
124
+
125
+ it.each(['owner', 'writer', 'recipient'] as const)(
126
+ 'allows %s requests without granting writer data access',
127
+ async role => {
128
+ const uri = await create();
129
+ const sender = { owner, writer, recipient }[role];
130
+ await expect(send(uri, sender)).resolves.toBe(true);
131
+ // Inline delivery has a one-second budget; finish persisted work before asserting it.
132
+ await flush(uri);
133
+ expect(await status(uri, sender)).toMatchObject({
134
+ status: 'pending',
135
+ requestedBy: sender.profileId,
136
+ externalReferenceId: 'synthetic-ref-123',
137
+ });
138
+ await expect(
139
+ writer.clients.fullAuth.contracts.getConsentedDataForContract({ uri })
140
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
141
+ const incoming =
142
+ await learner.clients.fullAuth.contracts.getAllContractRequestsForProfile({
143
+ targetProfileId: learner.profileId,
144
+ });
145
+ expect(incoming[0]).toMatchObject({
146
+ contract: { uri, name: 'Synthetic referral', read: normalContract.read },
147
+ requestedBy: sender.profileId,
148
+ });
149
+ expect(delivered[0]?.data?.metadata).toMatchObject({
150
+ type: 'contract-request',
151
+ recipientRole: 'target',
152
+ event: 'request_sent',
153
+ requestedBy: sender.profileId,
154
+ });
155
+ }
156
+ );
157
+
158
+ it('accepts only the pending invitation ID supplied by the learner', async () => {
159
+ const uri = await create();
160
+ await send(uri);
161
+ const invitation = (await status(uri))!;
162
+ const before = (await eventRows(uri)).length;
163
+ const input = { contractUri: uri, terms: normalFullTerms, audienceVersion: 2 };
164
+ await expect(
165
+ learner.clients.fullAuth.contracts.consentToContract({
166
+ ...input,
167
+ expectedRequestId: 'different-request',
168
+ })
169
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
170
+ expect((await eventRows(uri)).length).toBe(before);
171
+ expect((await status(uri))?.status).toBe('pending');
172
+ await learner.clients.fullAuth.contracts.consentToContract({
173
+ ...input,
174
+ expectedRequestId: invitation.requestId,
175
+ });
176
+ expect((await status(uri))?.status).toBe('accepted');
177
+ });
178
+ it.each(['cancelled', 'denied'] as const)(
179
+ 'rejects invitation-bound consent after %s without new terms or events',
180
+ async decision => {
181
+ const uri = await create();
182
+ await send(uri);
183
+ const invitation = (await status(uri))!;
184
+ if (decision === 'cancelled')
185
+ await owner.clients.fullAuth.contracts.cancelContractRequest({
186
+ contractUri: uri,
187
+ targetProfileId: learner.profileId,
188
+ });
189
+ else await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
190
+ const before = (await eventRows(uri)).length;
191
+ await expect(
192
+ learner.clients.fullAuth.contracts.consentToContract({
193
+ contractUri: uri,
194
+ terms: normalFullTerms,
195
+ audienceVersion: 2,
196
+ expectedRequestId: invitation.requestId,
197
+ })
198
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
199
+ expect((await eventRows(uri)).length).toBe(before);
200
+ expect((await status(uri))?.status).toBe(decision);
201
+ const terms = await neogma.queryRunner.run(
202
+ 'MATCH (terms:ConsentFlowTerms)-[:CONSENTS_TO]->(:ConsentFlowContract {id: $id}) RETURN terms',
203
+ { id: id(uri) }
204
+ );
205
+ expect(terms.records).toHaveLength(0);
206
+ // A separate consent link stays independent of an invitation's terminal decision.
207
+ await accept(uri);
208
+ expect((await status(uri))?.status).toBe(decision);
209
+ }
210
+ );
211
+ it('rejects cancellation and expiry even after the route has loaded a valid snapshot', async () => {
212
+ const uri = await create();
213
+ await send(uri);
214
+ const invitation = (await status(uri))!;
215
+ const details = (await getContractDetailsByUri(uri))!;
216
+ const profile = {
217
+ ...(await getProfileByProfileId(learner.profileId))!,
218
+ did: learner.did,
219
+ } as LCNProfile;
220
+ await owner.clients.fullAuth.contracts.cancelContractRequest({
221
+ contractUri: uri,
222
+ targetProfileId: learner.profileId,
223
+ });
224
+ const before = (await eventRows(uri)).length;
225
+ await expect(
226
+ commitConsent(
227
+ profile,
228
+ details,
229
+ {
230
+ terms: normalFullTerms,
231
+ audienceVersion: 2,
232
+ expectedRequestId: invitation.requestId,
233
+ },
234
+ 'localhost:4000'
235
+ )
236
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
237
+ expect((await eventRows(uri)).length).toBe(before);
238
+ await neogma.queryRunner.run(
239
+ 'MATCH (contract:ConsentFlowContract {id:$id}) SET contract.expiresAt=$past',
240
+ { id: id(uri), past: '2000-01-01T00:00:00Z' }
241
+ );
242
+ await expect(
243
+ commitConsent(
244
+ profile,
245
+ details,
246
+ { terms: normalFullTerms, audienceVersion: 2 },
247
+ 'localhost:4000'
248
+ )
249
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
250
+ expect((await eventRows(uri)).length).toBe(before);
251
+ });
252
+ it('preserves legacy contracts with a blank expiry', async () => {
253
+ const uri = await create([]);
254
+ await neogma.queryRunner.run(
255
+ 'MATCH (contract:ConsentFlowContract {id:$id}) SET contract.expiresAt=$blank',
256
+ { id: id(uri), blank: ' ' }
257
+ );
258
+ await expect(accept(uri)).resolves.toHaveProperty('termsUri');
259
+ });
260
+ it('rejects expired new consent and re-consent inside the write without creating events', async () => {
261
+ const uri = await create();
262
+ const expire = () =>
263
+ neogma.queryRunner.run(
264
+ 'MATCH (contract:ConsentFlowContract {id:$id}) SET contract.expiresAt=$past',
265
+ { id: id(uri), past: '2000-01-01T00:00:00.000Z' }
266
+ );
267
+ await expire();
268
+ await expect(accept(uri)).rejects.toMatchObject({ code: 'CONFLICT' });
269
+ expect(await eventRows(uri)).toHaveLength(0);
270
+ await neogma.queryRunner.run(
271
+ 'MATCH (contract:ConsentFlowContract {id:$id}) REMOVE contract.expiresAt',
272
+ { id: id(uri) }
273
+ );
274
+ const { termsUri } = await accept(uri);
275
+ await learner.clients.fullAuth.contracts.withdrawConsent({ uri: termsUri });
276
+ const before = (await eventRows(uri)).length;
277
+ await expire();
278
+ await expect(accept(uri)).rejects.toMatchObject({ code: 'CONFLICT' });
279
+ expect((await eventRows(uri)).length).toBe(before);
280
+ expect((await getContractTermsByUri(termsUri))?.terms.status).toBe('withdrawn');
281
+ });
282
+ it('requires verified authentication, scope, target existence and an authorized sender', async () => {
283
+ const uri = await create();
284
+ const input = { contractUri: uri, targetProfileId: learner.profileId };
285
+ await expect(
286
+ owner.clients.partialAuth.contracts.sendContractRequest(input)
287
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
288
+ await expect(
289
+ getClient({
290
+ did: owner.learnCard.id.did(),
291
+ isChallengeValid: true,
292
+ scope: 'contracts:read',
293
+ }).contracts.sendContractRequest(input)
294
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
295
+ await expect(send(uri, outsider)).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
296
+ await expect(
297
+ owner.clients.fullAuth.contracts.sendContractRequest({
298
+ ...input,
299
+ targetProfileId: 'missing-profile',
300
+ })
301
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
302
+ await expect(
303
+ outsider.clients.fullAuth.contracts.getContractSentRequests({ contractUri: uri })
304
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
305
+ await expect(
306
+ owner.clients.fullAuth.contracts.sendContractRequest({
307
+ ...input,
308
+ externalReferenceId: 'x'.repeat(257),
309
+ })
310
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
311
+ await expect(
312
+ owner.clients.fullAuth.contracts.sendContractRequest({
313
+ ...input,
314
+ message: 'x'.repeat(501),
315
+ })
316
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
317
+ });
318
+
319
+ it('enforces the sender/contract request rate limit before mutating state', async () => {
320
+ const uri = await create();
321
+ await cache.set(`contract-request-rate:${id(uri)}:${writer.profileId}`, '500', 3600);
322
+ await expect(send(uri)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
323
+ expect(await status(uri)).toBeNull();
324
+ expect(await eventRows(uri)).toEqual([]);
325
+ });
326
+
327
+ it('rejects sender-to-self requests by profile ID or DID without writing an invitation', async () => {
328
+ const uri = await create();
329
+ for (const targetProfileId of [writer.profileId, writer.learnCard.id.did()])
330
+ await expect(
331
+ writer.clients.fullAuth.contracts.sendContractRequest({
332
+ contractUri: uri,
333
+ targetProfileId,
334
+ })
335
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
336
+ expect(await getStoredContractRequest(id(uri), writer.profileId)).toBeNull();
337
+ expect(await eventRows(uri)).toEqual([]);
338
+ expect(queue).not.toHaveBeenCalled();
339
+ });
340
+
341
+ it('preserves an attributed-request conflict through the legacy AI send route', async () => {
342
+ const uri = await create();
343
+ await send(uri);
344
+ await expect(
345
+ writer.clients.fullAuth.contracts.sendAiInsightsContractRequest({
346
+ contractUri: uri,
347
+ targetProfileId: learner.profileId,
348
+ shareLink: 'https://synthetic.example',
349
+ })
350
+ ).rejects.toMatchObject({
351
+ code: 'CONFLICT',
352
+ message: 'An attributed request already exists for this profile.',
353
+ });
354
+ expect(await status(uri)).toMatchObject({
355
+ status: 'pending',
356
+ requestedBy: writer.profileId,
357
+ });
358
+ expect(await eventRows(uri)).toHaveLength(1);
359
+ });
360
+
361
+ it('deduplicates exact concurrent retries and rejects conflicting sender/reference/message', async () => {
362
+ const uri = await create();
363
+ await Promise.all([send(uri), send(uri), send(uri)]);
364
+ expect(await eventRows(uri)).toHaveLength(1);
365
+ expect(delivered).toHaveLength(1);
366
+ await expect(send(uri, writer, 'different-ref')).rejects.toMatchObject({
367
+ code: 'CONFLICT',
368
+ });
369
+ await expect(send(uri, recipient)).rejects.toMatchObject({ code: 'CONFLICT' });
370
+ await expect(
371
+ writer.clients.fullAuth.contracts.sendContractRequest({
372
+ contractUri: uri,
373
+ targetProfileId: learner.profileId,
374
+ externalReferenceId: 'synthetic-ref-123',
375
+ message: 'changed',
376
+ })
377
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
378
+ });
379
+
380
+ it('permits one winner for concurrent conflicting references', async () => {
381
+ const uri = await create();
382
+ const results = await Promise.allSettled([send(uri, writer, 'a'), send(uri, writer, 'b')]);
383
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
384
+ expect(await eventRows(uri)).toHaveLength(1);
385
+ });
386
+
387
+ it('targets mark existing requests seen; writers cannot change generic read status or targets create phantom requests', async () => {
388
+ const uri = await create();
389
+ const input = { contractUri: uri, targetProfileId: learner.profileId };
390
+ await expect(
391
+ learner.clients.fullAuth.contracts.markContractRequestAsSeen(input)
392
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
393
+ await send(uri);
394
+ const readOnly = getClient({
395
+ did: learner.learnCard.id.did(),
396
+ isChallengeValid: true,
397
+ scope: 'contracts:read',
398
+ });
399
+ await expect(readOnly.contracts.markContractRequestAsSeen(input)).rejects.toMatchObject({
400
+ code: 'UNAUTHORIZED',
401
+ });
402
+ await expect(readOnly.contracts.cancelContractRequest(input)).rejects.toMatchObject({
403
+ code: 'UNAUTHORIZED',
404
+ });
405
+ await expect(
406
+ writer.clients.fullAuth.contracts.markContractRequestAsSeen(input)
407
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
408
+ await learner.clients.fullAuth.contracts.markContractRequestAsSeen(input);
409
+ expect(await status(uri)).toMatchObject({ status: 'pending', readStatus: 'seen' });
410
+ });
411
+
412
+ it.each(['pending', 'denied', 'cancelled', 'accepted'] as const)(
413
+ 'hides another recipients %s referral while preserving sender, manager and learner access',
414
+ async requestStatus => {
415
+ const uri = await create();
416
+ await send(uri, recipient);
417
+ delivered.length = 0;
418
+ if (requestStatus === 'denied')
419
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
420
+ if (requestStatus === 'cancelled')
421
+ await owner.clients.fullAuth.contracts.cancelContractRequest({
422
+ contractUri: uri,
423
+ targetProfileId: learner.profileId,
424
+ });
425
+ if (requestStatus === 'accepted') await accept(uri);
426
+ await flush(uri);
427
+ const expected = {
428
+ requestedBy: recipient.profileId,
429
+ externalReferenceId: 'synthetic-ref-123',
430
+ message: 'Synthetic invitation',
431
+ status: requestStatus,
432
+ };
433
+ for (const viewer of [owner, writer, recipient, learner])
434
+ expect(await status(uri, viewer)).toMatchObject(expected);
435
+ for (const viewer of [owner, writer, recipient]) {
436
+ const list = await viewer.clients.fullAuth.contracts.getContractSentRequests({
437
+ contractUri: uri,
438
+ });
439
+ expect(list).toHaveLength(1);
440
+ expect(list[0]).toMatchObject(expected);
441
+ }
442
+ expect(
443
+ await otherRecipient.clients.fullAuth.contracts.getContractSentRequests({
444
+ contractUri: uri,
445
+ })
446
+ ).toEqual([]);
447
+ expect(await status(uri, otherRecipient)).toBeNull();
448
+ expect(
449
+ await otherRecipient.clients.fullAuth.contracts.getRequestStatusForProfile({
450
+ contractId: id(uri),
451
+ targetProfileId: learner.learnCard.id.did(),
452
+ })
453
+ ).toBeNull();
454
+ expect(
455
+ await otherRecipient.clients.fullAuth.contracts.getRequestStatusForProfile({
456
+ contractUri: uri,
457
+ targetProfileId: outsider.profileId,
458
+ })
459
+ ).toBeNull();
460
+ expect(
461
+ await learner.clients.fullAuth.contracts.getAllContractRequestsForProfile({
462
+ targetProfileId: learner.profileId,
463
+ })
464
+ ).toEqual([expect.objectContaining(expected)]);
465
+ if (requestStatus === 'denied' || requestStatus === 'cancelled')
466
+ expect(delivered.every(n => n.to.profileId !== otherRecipient.profileId)).toBe(
467
+ true
468
+ );
469
+ if (requestStatus === 'accepted') {
470
+ const shared = delivered.find(n => n.to.profileId === otherRecipient.profileId)!;
471
+ expect(shared.data?.metadata?.event).toBe('consent_created');
472
+ expect(JSON.stringify(shared)).not.toContain('synthetic-ref-123');
473
+ expect(shared.data?.metadata).not.toHaveProperty('message');
474
+ const referrer = delivered.find(n => n.to.profileId === recipient.profileId)!;
475
+ expect(referrer.data?.metadata?.externalReferenceId).toBe('synthetic-ref-123');
476
+ expect(referrer.data?.transaction?.referral?.externalReferenceId).toBe(
477
+ 'synthetic-ref-123'
478
+ );
479
+ const data =
480
+ await otherRecipient.clients.fullAuth.contracts.getConsentedDataForContract({
481
+ uri,
482
+ });
483
+ expect(data.records[0]?.personal).toEqual(normalFullTerms.read.personal);
484
+ expect(JSON.stringify(data)).not.toContain('synthetic-ref-123');
485
+ }
486
+ }
487
+ );
488
+
489
+ it('enforces request visibility in the database even if an earlier role lookup is stale', async () => {
490
+ const uri = await create();
491
+ await send(uri, recipient);
492
+ vi.spyOn(contractRead, 'getContractRequestAccess').mockResolvedValue({
493
+ isOwner: false,
494
+ isManager: true,
495
+ isRecipient: true,
496
+ });
497
+ expect(
498
+ await otherRecipient.clients.fullAuth.contracts.getContractSentRequests({
499
+ contractUri: uri,
500
+ })
501
+ ).toEqual([]);
502
+ expect(await status(uri, otherRecipient)).toBeNull();
503
+ expect(await status(uri, recipient)).toMatchObject({
504
+ externalReferenceId: 'synthetic-ref-123',
505
+ });
506
+ });
507
+
508
+ it('filters a shared contracts request list to each recipients own referrals', async () => {
509
+ const uri = await create();
510
+ await send(uri, recipient, 'private-org-a-ref');
511
+ await otherRecipient.clients.fullAuth.contracts.sendContractRequest({
512
+ contractUri: uri,
513
+ targetProfileId: outsider.profileId,
514
+ externalReferenceId: 'private-org-b-ref',
515
+ message: 'Private Org B invitation',
516
+ });
517
+ for (const [viewer, reference, target] of [
518
+ [recipient, 'private-org-a-ref', learner],
519
+ [otherRecipient, 'private-org-b-ref', outsider],
520
+ ] as const) {
521
+ const list = await viewer.clients.fullAuth.contracts.getContractSentRequests({
522
+ contractUri: uri,
523
+ });
524
+ expect(list).toEqual([
525
+ expect.objectContaining({
526
+ externalReferenceId: reference,
527
+ profile: expect.objectContaining({ profileId: target.profileId }),
528
+ }),
529
+ ]);
530
+ }
531
+ for (const manager of [owner, writer]) {
532
+ const list = await manager.clients.fullAuth.contracts.getContractSentRequests({
533
+ contractUri: uri,
534
+ });
535
+ expect(list.map(r => r.externalReferenceId).sort()).toEqual([
536
+ 'private-org-a-ref',
537
+ 'private-org-b-ref',
538
+ ]);
539
+ }
540
+ });
541
+
542
+ it.each(['denied', 'cancelled'] as const)(
543
+ 'suppresses old pending and queued %s notifications to an unrelated recipient',
544
+ async decision => {
545
+ const uri = await create();
546
+ await send(uri, recipient);
547
+ const request = (await status(uri))!;
548
+ queue.mockRejectedValue(new Error('synthetic outage'));
549
+ if (decision === 'denied')
550
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
551
+ else
552
+ await learner.clients.fullAuth.contracts.cancelContractRequest({
553
+ contractUri: uri,
554
+ targetProfileId: learner.profileId,
555
+ });
556
+ const eventId = `${request.requestId}:${decision}`;
557
+ await neogma.queryRunner.run(
558
+ `MATCH (e:ConsentFlowEvent {id:$eventId})
559
+ CREATE (e)-[:HAS_DELIVERY]->(:ConsentFlowEventDelivery {
560
+ id:$deliveryId, toId:$toId, role:'recipient', state:'pending', nextAttemptAt:$past
561
+ })`,
562
+ {
563
+ eventId,
564
+ deliveryId: `${eventId}:${otherRecipient.profileId}`,
565
+ toId: otherRecipient.profileId,
566
+ past: '2000-01-01T00:00:00.000Z',
567
+ }
568
+ );
569
+ delivered.length = 0;
570
+ queue.mockImplementation(async n => {
571
+ delivered.push(structuredClone(n));
572
+ return undefined;
573
+ });
574
+ await due(eventId);
575
+ await dispatchContractEvents({ eventId });
576
+ expect(delivered.map(n => n.to.profileId).sort()).toEqual(
577
+ [owner.profileId, recipient.profileId].sort()
578
+ );
579
+ const rows = (await eventRows(uri)).find(r => r.get('e').properties.id === eventId)!;
580
+ const suppressed = rows
581
+ .get('deliveries')
582
+ .find(
583
+ (d: { properties: { toId: string } }) =>
584
+ d.properties.toId === otherRecipient.profileId
585
+ );
586
+ expect(suppressed.properties.state).toBe('skipped');
587
+ const oldQueued = structuredClone(delivered[0]!);
588
+ oldQueued.to = (await getProfileByProfileId(otherRecipient.profileId))!;
589
+ oldQueued.data!.metadata!.recipientRole = 'recipient';
590
+ oldQueued.data!.metadata!.deliveryKey = `${eventId}:${otherRecipient.profileId}`;
591
+ const sendSpy = vi.spyOn(notifications, 'sendNotification');
592
+ await expect(
593
+ deliverQueuedNotification(JSON.stringify(oldQueued))
594
+ ).resolves.toBeUndefined();
595
+ expect(sendSpy).not.toHaveBeenCalled();
596
+ }
597
+ );
598
+
599
+ it('removes private references from already queued consent data after a recipient loses writer access', async () => {
600
+ const uri = await owner.clients.fullAuth.contracts.createConsentFlowContract({
601
+ name: 'Synthetic referral',
602
+ contract: normalContract,
603
+ writers: [writer.profileId, otherRecipient.profileId],
604
+ recipients: [recipient.profileId, otherRecipient.profileId],
605
+ });
606
+ await send(uri, recipient);
607
+ delivered.length = 0;
608
+ const { termsUri } = await accept(uri);
609
+ await flush(uri);
610
+ const queued = structuredClone(
611
+ delivered.find(n => n.to.profileId === otherRecipient.profileId)!
612
+ );
613
+ expect(queued.data?.metadata?.externalReferenceId).toBe('synthetic-ref-123');
614
+ queued.data!.transaction!.terms = structuredClone(normalFullTerms);
615
+ queued.data!.metadata!.message = 'Private referral text';
616
+ await neogma.queryRunner.run(
617
+ `MATCH (:ConsentFlowContract {id:$contractId})-[w:CAN_WRITE]->(:Profile {profileId:$profileId}) DELETE w`,
618
+ { contractId: id(uri), profileId: otherRecipient.profileId }
619
+ );
620
+ const sent: LCNNotification[] = [];
621
+ vi.spyOn(notifications, 'sendNotification').mockImplementation(async n => {
622
+ sent.push(structuredClone(n));
623
+ return true;
624
+ });
625
+ await deliverQueuedNotification(JSON.stringify(queued));
626
+ expect(sent).toHaveLength(1);
627
+ expect(sent[0]?.data?.transaction?.terms?.read.personal).toEqual(
628
+ normalFullTerms.read.personal
629
+ );
630
+ expect(sent[0]?.data?.metadata).not.toHaveProperty('externalReferenceId');
631
+ expect(sent[0]?.data?.metadata).not.toHaveProperty('message');
632
+ expect(sent[0]?.data?.transaction?.referral).not.toHaveProperty('externalReferenceId');
633
+ expect(JSON.stringify(sent[0])).not.toContain('synthetic-ref-123');
634
+ expect(await status(uri, otherRecipient)).toBeNull();
635
+ await expect(
636
+ otherRecipient.clients.fullAuth.contracts.getTermsTransactionHistory({ uri: termsUri })
637
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
638
+ const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
639
+ uri: termsUri,
640
+ });
641
+ expect(history.records[0]?.referral?.externalReferenceId).toBe('synthetic-ref-123');
642
+ const exported = await getHolderExportMetadataForProfile(
643
+ (await getProfileByProfileId(learner.profileId))!,
644
+ 'localhost%3A3000'
645
+ );
646
+ expect(exported.consentRecords[0]?.referral?.externalReferenceId).toBe('synthetic-ref-123');
647
+ });
648
+
649
+ it('preserves legacy request management for writers without exposing it to recipients', async () => {
650
+ const uri = await create();
651
+ await writer.clients.fullAuth.contracts.sendAiInsightsContractRequest({
652
+ contractUri: uri,
653
+ targetProfileId: learner.profileId,
654
+ shareLink: 'https://synthetic.example/legacy',
655
+ });
656
+ for (const viewer of [owner, writer, learner])
657
+ expect(await status(uri, viewer)).toMatchObject({ status: 'pending' });
658
+ expect(await status(uri, recipient)).toBeNull();
659
+ expect(
660
+ await recipient.clients.fullAuth.contracts.getContractSentRequests({ contractUri: uri })
661
+ ).toEqual([]);
662
+ expect(
663
+ await writer.clients.fullAuth.contracts.getContractSentRequests({ contractUri: uri })
664
+ ).toHaveLength(1);
665
+ });
666
+
667
+ it('retains denied history, emits one decision and rejects reopen/other terminal transitions', async () => {
668
+ const uri = await create();
669
+ await send(uri);
670
+ delivered.length = 0;
671
+ await expect(
672
+ outsider.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri })
673
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
674
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
675
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
676
+ expect(await status(uri)).toMatchObject({
677
+ status: 'denied',
678
+ externalReferenceId: 'synthetic-ref-123',
679
+ });
680
+ await flush(uri);
681
+ expect(delivered.map(n => n.to.profileId).sort()).toEqual(
682
+ [owner.profileId, writer.profileId].sort()
683
+ );
684
+ expect(delivered.find(n => n.to.profileId === writer.profileId)?.data).not.toHaveProperty(
685
+ 'transaction'
686
+ );
687
+ expect(delivered.every(n => n.data?.metadata?.event === 'request_denied')).toBe(true);
688
+ await expect(send(uri)).rejects.toMatchObject({ code: 'CONFLICT' });
689
+ await expect(
690
+ owner.clients.fullAuth.contracts.cancelContractRequest({
691
+ contractUri: uri,
692
+ targetProfileId: learner.profileId,
693
+ })
694
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
695
+ });
696
+
697
+ it.each(['owner', 'writer', 'recipient', 'learner'] as const)(
698
+ 'permits cancellation by %s and retains terminal correlation',
699
+ async role => {
700
+ const uri = await create();
701
+ await send(uri, recipient);
702
+ await expect(
703
+ otherRecipient.clients.fullAuth.contracts.cancelContractRequest({
704
+ contractUri: uri,
705
+ targetProfileId: learner.profileId,
706
+ })
707
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
708
+ await { owner, writer, recipient, learner }[
709
+ role
710
+ ].clients.fullAuth.contracts.cancelContractRequest({
711
+ contractUri: uri,
712
+ targetProfileId: learner.profileId,
713
+ });
714
+ expect(await status(uri)).toMatchObject({
715
+ status: 'cancelled',
716
+ requestedBy: recipient.profileId,
717
+ });
718
+ await expect(send(uri, recipient)).rejects.toMatchObject({ code: 'CONFLICT' });
719
+ }
720
+ );
721
+
722
+ it('stores referral on Terms and transaction history, fan-outs to the audience, and gives the writer only a decision', async () => {
723
+ const uri = await create();
724
+ await send(uri);
725
+ const request = await status(uri);
726
+ delivered.length = 0;
727
+ const { termsUri } = await accept(uri);
728
+ await flush(uri);
729
+ const referral = {
730
+ requestId: request!.requestId,
731
+ requestedBy: writer.profileId,
732
+ externalReferenceId: 'synthetic-ref-123',
733
+ };
734
+ expect((await getContractTermsByUri(termsUri))?.terms.referral).toEqual(referral);
735
+ expect(
736
+ (await learner.clients.fullAuth.contracts.getConsentedContracts()).records[0]?.referral
737
+ ).toEqual(referral);
738
+ const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
739
+ uri: termsUri,
740
+ });
741
+ expect(history.records[0]?.referral).toEqual(referral);
742
+ expect(await status(uri)).toMatchObject({ status: 'accepted' });
743
+ expect(delivered.map(n => n.to.profileId).sort()).toEqual(
744
+ [owner, recipient, otherRecipient, writer].map(a => a.profileId).sort()
745
+ );
746
+ const minimal = delivered.find(n => n.to.profileId === writer.profileId)!;
747
+ expect(minimal.data).not.toHaveProperty('transaction');
748
+ expect(minimal.data?.metadata).toMatchObject({
749
+ event: 'request_accepted',
750
+ recipientRole: 'requester',
751
+ ...referral,
752
+ });
753
+ expect(minimal.data?.metadata).not.toHaveProperty('termsUri');
754
+ expect(JSON.stringify(minimal)).not.toContain('Full Fullerson');
755
+ expect(JSON.stringify(minimal)).not.toContain('achievement1');
756
+ const audienceEvent = delivered.find(n => n.to.profileId === recipient.profileId)!;
757
+ const publicReferral = { requestId: referral.requestId, requestedBy: referral.requestedBy };
758
+ expect(audienceEvent.data?.transaction?.referral).toEqual(publicReferral);
759
+ expect(audienceEvent.data?.metadata).toMatchObject({
760
+ event: 'consent_created',
761
+ termsUri,
762
+ ...publicReferral,
763
+ });
764
+ expect(audienceEvent.data?.metadata).not.toHaveProperty('externalReferenceId');
765
+ const ownerEvent = delivered.find(n => n.to.profileId === owner.profileId)!;
766
+ expect(ownerEvent.data?.metadata?.externalReferenceId).toBe('synthetic-ref-123');
767
+ expect(ownerEvent.data?.transaction?.referral).toEqual(referral);
768
+ expect(new Set(delivered.map(n => n.data?.metadata?.deliveryKey)).size).toBe(4);
769
+ delivered.length = 0;
770
+ await learner.clients.fullAuth.contracts.syncCredentialsToContract({
771
+ termsUri,
772
+ categories: { Achievement: ['urn:synthetic:new'] },
773
+ audienceVersion: 2,
774
+ });
775
+ await flush(uri);
776
+ expect(delivered).toHaveLength(3);
777
+ expect(
778
+ delivered.every(
779
+ n =>
780
+ n.to.profileId !== writer.profileId &&
781
+ n.data?.metadata?.event === 'credentials_synced'
782
+ )
783
+ ).toBe(true);
784
+ delivered.length = 0;
785
+ await learner.clients.fullAuth.contracts.withdrawConsent({ uri: termsUri });
786
+ await flush(uri);
787
+ expect(delivered).toHaveLength(3);
788
+ expect(
789
+ delivered.every(
790
+ n =>
791
+ (n.to.profileId === owner.profileId
792
+ ? n.data?.metadata?.externalReferenceId === 'synthetic-ref-123'
793
+ : n.data?.metadata?.externalReferenceId === undefined) &&
794
+ n.data.metadata.event === 'consent_withdrawn'
795
+ )
796
+ ).toBe(true);
797
+ expect(await getStoredContractRequest(id(uri), learner.profileId)).toMatchObject({
798
+ status: 'accepted',
799
+ ...referral,
800
+ });
801
+ });
802
+
803
+ it('preserves referral attribution on issued outcomes, maintenance transactions and holder exports', async () => {
804
+ const uri = await create();
805
+ await send(uri);
806
+ const { termsUri } = await accept(uri);
807
+ const learnerDid = (await learner.clients.fullAuth.profile.getProfile())!.did;
808
+ const boostUri = await writer.clients.fullAuth.boost.createBoost({
809
+ credential: testUnsignedBoost,
810
+ });
811
+ await writer.clients.fullAuth.contracts.writeCredentialToContract({
812
+ did: learnerDid,
813
+ contractUri: uri,
814
+ boostUri,
815
+ credential: testVc,
816
+ });
817
+ await learner.clients.fullAuth.contracts.deleteCredentialFromAllContracts({
818
+ deletedUris: ['achievement1'],
819
+ });
820
+ const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
821
+ uri: termsUri,
822
+ });
823
+ expect(history.records.find(t => t.action === 'write')?.referral?.externalReferenceId).toBe(
824
+ 'synthetic-ref-123'
825
+ );
826
+ expect(
827
+ history.records.every(t => t.referral?.externalReferenceId === 'synthetic-ref-123')
828
+ ).toBe(true);
829
+ const metadata = await getHolderExportMetadataForProfile(
830
+ (await getProfileByProfileId(learner.profileId))!,
831
+ 'localhost%3A3000'
832
+ );
833
+ expect(metadata.consentRecords[0]?.referral?.externalReferenceId).toBe('synthetic-ref-123');
834
+ expect(
835
+ metadata.consentRecords[0]?.transactions.every(
836
+ t => t.referral?.externalReferenceId === 'synthetic-ref-123'
837
+ )
838
+ ).toBe(true);
839
+ });
840
+
841
+ it('keeps the original accepted referral through withdrawal and re-consent without reopening it', async () => {
842
+ const uri = await create();
843
+ await send(uri);
844
+ const original = (await status(uri))!;
845
+ const { termsUri } = await accept(uri);
846
+ await learner.clients.fullAuth.contracts.withdrawConsent({ uri: termsUri });
847
+ await expect(send(uri, recipient, 'new-referral')).rejects.toMatchObject({
848
+ code: 'CONFLICT',
849
+ });
850
+ delivered.length = 0;
851
+ await accept(uri);
852
+ await flush(uri);
853
+ expect(await status(uri)).toMatchObject({
854
+ status: 'accepted',
855
+ requestId: original.requestId,
856
+ requestedBy: writer.profileId,
857
+ externalReferenceId: original.externalReferenceId,
858
+ });
859
+ const history = await learner.clients.fullAuth.contracts.getTermsTransactionHistory({
860
+ uri: termsUri,
861
+ });
862
+ expect(history.records.filter(t => t.action === 'consent')).toHaveLength(2);
863
+ expect(history.records.every(t => t.referral?.requestId === original.requestId)).toBe(true);
864
+ expect(
865
+ delivered.find(n => n.to.profileId === owner.profileId)?.data?.metadata
866
+ ).toMatchObject({
867
+ event: 'consent_created',
868
+ requestId: original.requestId,
869
+ externalReferenceId: original.externalReferenceId,
870
+ });
871
+ expect(delivered.some(n => n.to.profileId === writer.profileId)).toBe(false);
872
+ });
873
+
874
+ it('gives a removed original referrer only its minimal acceptance decision', async () => {
875
+ const uri = await create();
876
+ await send(uri, recipient);
877
+ await owner.clients.fullAuth.contracts.removeContractRecipient({
878
+ contractUri: uri,
879
+ recipient: recipient.profileId,
880
+ });
881
+ delivered.length = 0;
882
+ await accept(uri);
883
+ await flush(uri);
884
+ const decision = delivered.find(n => n.to.profileId === recipient.profileId)!;
885
+ expect(decision.data?.metadata).toMatchObject({
886
+ event: 'request_accepted',
887
+ recipientRole: 'requester',
888
+ externalReferenceId: 'synthetic-ref-123',
889
+ });
890
+ expect(decision.data).not.toHaveProperty('transaction');
891
+ expect(decision.data?.metadata).not.toHaveProperty('termsUri');
892
+ expect(await authorizeContractNotification(structuredClone(decision))).toBe(true);
893
+ await expect(
894
+ recipient.clients.fullAuth.contracts.getConsentedDataForContract({ uri })
895
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
896
+ });
897
+
898
+ it('serializes consent versus denial without recording a denied request as accepted', async () => {
899
+ const uri = await create();
900
+ await send(uri);
901
+ const [consent, decision] = await Promise.allSettled([
902
+ accept(uri),
903
+ learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri }),
904
+ ]);
905
+ expect(consent.status).toBe('fulfilled');
906
+ if (consent.status !== 'fulfilled') throw new Error('Consent failed');
907
+ const current = await status(uri);
908
+ const terms = (await getContractTermsByUri(consent.value.termsUri))!.terms;
909
+ if (current?.status === 'accepted') {
910
+ expect(decision.status).toBe('rejected');
911
+ expect(terms.referral?.requestId).toBe(current.requestId);
912
+ } else {
913
+ expect(current?.status).toBe('denied');
914
+ expect(decision.status).toBe('fulfilled');
915
+ expect(terms.referral).toBeUndefined();
916
+ }
917
+ });
918
+
919
+ it('rejects expired contracts, and accepts a new request when legacy consent has expired without an existing request', async () => {
920
+ const expired = await owner.clients.fullAuth.contracts.createConsentFlowContract({
921
+ name: 'Expired',
922
+ contract: normalContract,
923
+ expiresAt: '2000-01-01T00:00:00Z',
924
+ });
925
+ await expect(send(expired, owner)).rejects.toMatchObject({ code: 'CONFLICT' });
926
+ const uri = await create();
927
+ const { termsUri } = await accept(uri);
928
+ await neogma.queryRunner.run(
929
+ 'MATCH (:ConsentFlowContract {id:$id})-[r:REQUESTED_FOR]->() DELETE r',
930
+ { id: id(uri) }
931
+ );
932
+ await expect(send(uri)).rejects.toMatchObject({ code: 'CONFLICT' });
933
+ await neogma.queryRunner.run('MATCH (t:ConsentFlowTerms {id:$id}) SET t.expiresAt=$past', {
934
+ id: id(termsUri),
935
+ past: '2000-01-01T00:00:00Z',
936
+ });
937
+ await send(uri);
938
+ await learner.clients.fullAuth.contracts.withdrawConsent({ uri: termsUri });
939
+ expect((await getContractTermsByUri(termsUri))!.terms.referral).toBeUndefined();
940
+ expect(await status(uri)).toMatchObject({ status: 'pending' });
941
+ delivered.length = 0;
942
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
943
+ uri: termsUri,
944
+ terms: normalFullTerms,
945
+ expiresAt: '',
946
+ audienceVersion: 2,
947
+ });
948
+ expect(await status(uri)).toMatchObject({ status: 'accepted' });
949
+ await flush(uri);
950
+ expect(
951
+ delivered.find(n => n.to.profileId === writer.profileId)?.data?.metadata?.event
952
+ ).toBe('request_accepted');
953
+ expect(
954
+ await learner.clients.fullAuth.contracts.getSharedInsightsRequestsForProfile({
955
+ targetProfileId: learner.profileId,
956
+ })
957
+ ).toEqual([]);
958
+ });
959
+
960
+ it('refuses a new request for active consent and preserves legacy AI request payload/cancellation', async () => {
961
+ const uri = await create();
962
+ await accept(uri);
963
+ await expect(send(uri)).rejects.toMatchObject({ code: 'CONFLICT' });
964
+ const legacy = await create([]);
965
+ delivered.length = 0;
966
+ await writer.clients.fullAuth.contracts.sendAiInsightsContractRequest({
967
+ contractUri: legacy,
968
+ targetProfileId: learner.profileId,
969
+ shareLink: 'https://synthetic.example/insights',
970
+ });
971
+ expect(delivered[0]?.data?.metadata).toMatchObject({
972
+ type: 'AI Insight',
973
+ contractUri: legacy,
974
+ });
975
+ expect(await status(legacy)).not.toHaveProperty('requestId');
976
+ await learner.clients.fullAuth.contracts.cancelContractRequest({
977
+ contractUri: legacy,
978
+ targetProfileId: learner.profileId,
979
+ });
980
+ expect(await status(legacy)).toBeNull();
981
+ await expect(
982
+ recipient.clients.fullAuth.contracts.sendAiInsightsContractRequest({
983
+ contractUri: uri,
984
+ targetProfileId: learner.profileId,
985
+ shareLink: 'https://synthetic.example/insights',
986
+ })
987
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
988
+ });
989
+
990
+ it('commits state through a transport outage, retries with the same delivery key, and leases overlapping workers', async () => {
991
+ const uri = await create();
992
+ queue.mockRejectedValue(new Error('synthetic outage'));
993
+ await expect(send(uri)).resolves.toBe(true);
994
+ const request = await status(uri);
995
+ const eventId = request!.requestId!;
996
+ let rows = await eventRows(uri);
997
+ expect(rows[0]!.get('deliveries')[0].properties.state).toBe('pending');
998
+ await due(eventId);
999
+ let calls = 0;
1000
+ queue.mockImplementation(async n => {
1001
+ calls++;
1002
+ delivered.push(structuredClone(n));
1003
+ await new Promise(resolve => setTimeout(resolve, 25));
1004
+ return undefined;
1005
+ });
1006
+ await Promise.all([
1007
+ dispatchContractEvents({ eventId }),
1008
+ dispatchContractEvents({ eventId }),
1009
+ ]);
1010
+ expect(calls).toBe(1);
1011
+ expect(delivered[0]?.data?.metadata).toMatchObject({
1012
+ eventId,
1013
+ deliveryKey: `${eventId}:${learner.profileId}`,
1014
+ });
1015
+ await due(eventId);
1016
+ await dispatchContractEvents({ eventId });
1017
+ expect(calls).toBe(1);
1018
+ rows = await eventRows(uri);
1019
+ expect(rows[0]!.get('deliveries')[0].properties.state).toBe('delivered');
1020
+ });
1021
+
1022
+ it('recovers an abandoned lease, retains false acknowledgements and removes completed payloads', async () => {
1023
+ const uri = await create();
1024
+ queue.mockResolvedValue(false);
1025
+ await send(uri);
1026
+ const eventId = (await status(uri))!.requestId!;
1027
+ await due(eventId);
1028
+ await neogma.queryRunner.run(
1029
+ "MATCH (:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d) SET d.lease='abandoned', d.leaseUntil=$future",
1030
+ { eventId, future: '2999-01-01T00:00:00Z' }
1031
+ );
1032
+ queue.mockClear();
1033
+ await dispatchContractEvents({ eventId });
1034
+ expect(queue).not.toHaveBeenCalled();
1035
+ await neogma.queryRunner.run(
1036
+ 'MATCH (:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d) SET d.leaseUntil=$past',
1037
+ { eventId, past: '2000-01-01T00:00:00Z' }
1038
+ );
1039
+ queue.mockResolvedValue(undefined);
1040
+ await dispatchContractEvents({ eventId });
1041
+ expect(queue).toHaveBeenCalledOnce();
1042
+ const rows = await eventRows(uri);
1043
+ expect(rows[0]!.get('deliveries')[0].properties.state).toBe('delivered');
1044
+ expect(rows[0]!.get('e').properties.message).toBeUndefined();
1045
+ });
1046
+
1047
+ it.each([
1048
+ { name: 'HTTP 403', webhook: 'https://synthetic.example/events', httpStatus: 403 },
1049
+ { name: 'missing webhook', webhook: undefined, httpStatus: undefined },
1050
+ { name: 'disabled webhook', webhook: 'false', httpStatus: undefined },
1051
+ ])('stops retrying $name and clears the consent payload', async ({ webhook, httpStatus }) => {
1052
+ const uri = await create([]);
1053
+ useDirectWebhook(webhook);
1054
+ const fetchSpy = vi
1055
+ .spyOn(globalThis, 'fetch')
1056
+ .mockResolvedValue(new Response('{}', { status: httpStatus ?? 200 }));
1057
+ await expect(accept(uri)).resolves.toMatchObject({ termsUri: expect.any(String) });
1058
+ const row = (await eventRows(uri))[0]!;
1059
+ const eventId = row.get('e').properties.id;
1060
+ expect(row.get('deliveries')[0].properties).toMatchObject({
1061
+ state: 'rejected',
1062
+ attempts: expect.anything(),
1063
+ });
1064
+ expect(row.get('deliveries')[0].properties.nextAttemptAt).toBeUndefined();
1065
+ expect(row.get('e').properties.payload).toBeUndefined();
1066
+ expect(fetchSpy).toHaveBeenCalledTimes(httpStatus ? 1 : 0);
1067
+ await due(eventId);
1068
+ await dispatchContractEvents({ eventId });
1069
+ expect(fetchSpy).toHaveBeenCalledTimes(httpStatus ? 1 : 0);
1070
+ });
1071
+
1072
+ it.each([408, 425, 429, 503, 200])(
1073
+ 'retries HTTP %s with a negative acknowledgement and keeps its stable delivery key',
1074
+ async httpStatus => {
1075
+ const uri = await create([]);
1076
+ useDirectWebhook('https://synthetic.example/events');
1077
+ const fetchSpy = vi
1078
+ .spyOn(globalThis, 'fetch')
1079
+ .mockResolvedValue(new Response('{"success":false}', { status: httpStatus }));
1080
+ await accept(uri);
1081
+ const row = (await eventRows(uri))[0]!;
1082
+ const eventId = row.get('e').properties.id;
1083
+ expect(row.get('deliveries')[0].properties.state).toBe('pending');
1084
+ expect(JSON.parse(row.get('e').properties.payload)).toMatchObject({
1085
+ action: 'consent',
1086
+ });
1087
+ const firstMetadata = JSON.parse(String(fetchSpy.mock.calls[0]![1]?.body)).data
1088
+ .metadata;
1089
+ fetchSpy.mockResolvedValue(new Response('{"success":true}', { status: 200 }));
1090
+ await due(eventId);
1091
+ await dispatchContractEvents({ eventId });
1092
+ const completed = (await eventRows(uri))[0]!;
1093
+ expect(completed.get('deliveries')[0].properties.state).toBe('delivered');
1094
+ expect(completed.get('e').properties.payload).toBeUndefined();
1095
+ expect(JSON.parse(String(fetchSpy.mock.calls[1]![1]?.body)).data.metadata).toEqual(
1096
+ firstMetadata
1097
+ );
1098
+ }
1099
+ );
1100
+
1101
+ it('keeps pre-request signing failures retryable', async () => {
1102
+ const uri = await create([]);
1103
+ useDirectWebhook('https://synthetic.example/events');
1104
+ vi.mocked(learnCardHelpers.getDidWebLearnCard).mockRejectedValue(
1105
+ new Error('synthetic signing outage')
1106
+ );
1107
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
1108
+ await accept(uri);
1109
+ const row = (await eventRows(uri))[0]!;
1110
+ expect(row.get('deliveries')[0].properties.state).toBe('pending');
1111
+ expect(row.get('e').properties.payload).toBeDefined();
1112
+ expect(fetchSpy).not.toHaveBeenCalled();
1113
+ });
1114
+
1115
+ it('backs off unsuccessful deliveries, stops after 12 claims and clears their payload', async () => {
1116
+ const uri = await create([]);
1117
+ queue.mockResolvedValue(false);
1118
+ await accept(uri);
1119
+ const initial = (await eventRows(uri))[0]!.get('e').properties;
1120
+ const eventId = initial.id;
1121
+ await neogma.queryRunner.run(
1122
+ 'MATCH (e:ConsentFlowEvent {id:$eventId}) SET e.payload=$payload',
1123
+ {
1124
+ eventId,
1125
+ payload: JSON.stringify({ ...JSON.parse(initial.payload), terms: normalFullTerms }),
1126
+ }
1127
+ );
1128
+ for (let attempt = 1; attempt <= 12; attempt++) {
1129
+ const row = (await eventRows(uri))[0]!;
1130
+ const delivery = row.get('deliveries')[0].properties;
1131
+ expect(Number(delivery.attempts)).toBe(attempt);
1132
+ if (attempt === 12) {
1133
+ expect(delivery).toMatchObject({
1134
+ state: 'failed',
1135
+ failureReason: 'retry_exhausted',
1136
+ });
1137
+ expect(row.get('e').properties.payload).toBeUndefined();
1138
+ expect(delivery.nextAttemptAt).toBeUndefined();
1139
+ } else {
1140
+ expect(delivery.state).toBe('pending');
1141
+ expect(row.get('e').properties.payload).toBeDefined();
1142
+ const delay = Date.parse(delivery.nextAttemptAt) - Date.parse(delivery.updatedAt);
1143
+ const expectedDelay = Math.min(60_000 * 2 ** (attempt - 1), 3_600_000);
1144
+ expect(delay).toBeGreaterThanOrEqual(expectedDelay - 1_000);
1145
+ expect(delay).toBeLessThanOrEqual(expectedDelay + 1_000);
1146
+ await due(eventId);
1147
+ await dispatchContractEvents({ eventId });
1148
+ }
1149
+ }
1150
+ expect(queue).toHaveBeenCalledTimes(12);
1151
+ await due(eventId);
1152
+ await dispatchContractEvents({ eventId });
1153
+ expect(queue).toHaveBeenCalledTimes(12);
1154
+ });
1155
+
1156
+ it.each(['expired', 'abandoned final attempt'])(
1157
+ 'clears a pending payload after an %s without another external attempt',
1158
+ async scenario => {
1159
+ const uri = await create([]);
1160
+ queue.mockRejectedValue(new Error('synthetic outage'));
1161
+ await accept(uri);
1162
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1163
+ await neogma.queryRunner.run(
1164
+ `MATCH (e:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d)
1165
+ SET e.createdAt = $createdAt, d.nextAttemptAt = $future,
1166
+ d.attempts = $attempts, d.lease = 'abandoned', d.leaseUntil = $past`,
1167
+ {
1168
+ eventId,
1169
+ createdAt:
1170
+ scenario === 'expired'
1171
+ ? new Date(Date.now() - 25 * 3_600_000).toISOString()
1172
+ : new Date().toISOString(),
1173
+ future: '2999-01-01T00:00:00.000Z',
1174
+ past: '2000-01-01T00:00:00.000Z',
1175
+ attempts: scenario === 'expired' ? 1 : 12,
1176
+ }
1177
+ );
1178
+ queue.mockClear();
1179
+ expect(await dispatchContractEvents({ eventId })).toMatchObject({ failed: 1 });
1180
+ const row = (await eventRows(uri))[0]!;
1181
+ expect(row.get('deliveries')[0].properties).toMatchObject({
1182
+ state: 'failed',
1183
+ failureReason: scenario === 'expired' ? 'retry_expired' : 'retry_exhausted',
1184
+ });
1185
+ expect(row.get('e').properties.payload).toBeUndefined();
1186
+ expect(queue).not.toHaveBeenCalled();
1187
+ }
1188
+ );
1189
+
1190
+ it('retains the fan-out payload until the last delivery finishes, including concurrent workers', async () => {
1191
+ const uri = await create();
1192
+ queue.mockRejectedValue(new Error('synthetic outage'));
1193
+ await accept(uri);
1194
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1195
+ queue.mockImplementation(async notification => {
1196
+ if (notification.to.profileId === owner.profileId)
1197
+ throw new notifications.PermanentNotificationDeliveryError('webhook_rejected', 403);
1198
+ throw new Error('synthetic outage');
1199
+ });
1200
+ await due(eventId);
1201
+ await dispatchContractEvents({ eventId });
1202
+ const partial = (await eventRows(uri))[0]!;
1203
+ expect(
1204
+ partial
1205
+ .get('deliveries')
1206
+ .map((d: { properties: { state: string } }) => d.properties.state)
1207
+ .sort()
1208
+ ).toEqual(['pending', 'pending', 'rejected']);
1209
+ expect(partial.get('e').properties.payload).toBeDefined();
1210
+ queue.mockImplementation(async () => {
1211
+ await new Promise(resolve => setTimeout(resolve, 20));
1212
+ throw new notifications.PermanentNotificationDeliveryError('webhook_rejected', 403);
1213
+ });
1214
+ await due(eventId);
1215
+ await Promise.all([
1216
+ dispatchContractEvents({ eventId }),
1217
+ dispatchContractEvents({ eventId }),
1218
+ ]);
1219
+ const completed = (await eventRows(uri))[0]!;
1220
+ expect(
1221
+ completed
1222
+ .get('deliveries')
1223
+ .every((d: { properties: { state: string } }) => d.properties.state === 'rejected')
1224
+ ).toBe(true);
1225
+ expect(completed.get('e').properties.payload).toBeUndefined();
1226
+ });
1227
+
1228
+ it('fences a worker whose lease was replaced while sending', async () => {
1229
+ const uri = await create([]);
1230
+ queue.mockRejectedValue(new Error('synthetic outage'));
1231
+ await accept(uri);
1232
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1233
+ queue.mockImplementation(async () => {
1234
+ await neogma.queryRunner.run(
1235
+ `MATCH (:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d)
1236
+ SET d.lease='replacement', d.leaseUntil='2999-01-01T00:00:00.000Z'`,
1237
+ { eventId }
1238
+ );
1239
+ return undefined;
1240
+ });
1241
+ await due(eventId);
1242
+ await dispatchContractEvents({ eventId });
1243
+ const retained = (await eventRows(uri))[0]!;
1244
+ expect(retained.get('deliveries')[0].properties).toMatchObject({
1245
+ state: 'pending',
1246
+ lease: 'replacement',
1247
+ });
1248
+ expect(retained.get('e').properties.payload).toBeDefined();
1249
+ queue.mockResolvedValue(undefined);
1250
+ await neogma.queryRunner.run(
1251
+ `MATCH (:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d)
1252
+ SET d.leaseUntil='2000-01-01T00:00:00.000Z'`,
1253
+ { eventId }
1254
+ );
1255
+ await dispatchContractEvents({ eventId });
1256
+ const completed = (await eventRows(uri))[0]!;
1257
+ expect(completed.get('deliveries')[0].properties.state).toBe('delivered');
1258
+ expect(completed.get('e').properties.payload).toBeUndefined();
1259
+ });
1260
+
1261
+ it('recovers payload cleanup when all deliveries already finished or no recipients exist', async () => {
1262
+ const uri = await create([]);
1263
+ queue.mockRejectedValue(new Error('synthetic outage'));
1264
+ await accept(uri);
1265
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1266
+ await neogma.queryRunner.run(
1267
+ `MATCH (:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d) SET d.state='failed'`,
1268
+ { eventId }
1269
+ );
1270
+ queue.mockClear();
1271
+ await dispatchContractEvents({ eventId });
1272
+ expect((await eventRows(uri))[0]!.get('e').properties.payload).toBeUndefined();
1273
+ await neogma.queryRunner.run(
1274
+ `MATCH (e:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d)
1275
+ DETACH DELETE d SET e.message='Synthetic retained message'`,
1276
+ { eventId }
1277
+ );
1278
+ await dispatchContractEvents({ eventId });
1279
+ expect((await eventRows(uri))[0]!.get('e').properties.message).toBeUndefined();
1280
+ expect(queue).not.toHaveBeenCalled();
1281
+ });
1282
+
1283
+ it('backfills older retained payloads once and keeps pending payloads until recovery finishes', async () => {
1284
+ const uri = await create([]);
1285
+ queue.mockRejectedValue(new Error('synthetic outage'));
1286
+ await accept(uri);
1287
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1288
+ const orphanId = `orphan-${eventId}`;
1289
+ await neogma.queryRunner.run(`
1290
+ MATCH (migration:ConsentFlowEventMigration {id:'indexed-outbox-v1'}) DELETE migration
1291
+ `);
1292
+ await neogma.queryRunner.run(
1293
+ `
1294
+ MATCH (event:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(delivery)
1295
+ REMOVE event.cleanupPending, delivery.eventCreatedAt
1296
+ SET delivery.nextAttemptAt = '2999-01-01T00:00:00Z'
1297
+ CREATE (:ConsentFlowEvent {id:$orphanId, createdAt:$now, payload:'{}', message:'Old retained body'})
1298
+ `,
1299
+ { eventId, orphanId, now: new Date().toISOString() }
1300
+ );
1301
+ await Promise.all([ensureContractEventMaintenance(), ensureContractEventMaintenance()]);
1302
+ const marker = await neogma.queryRunner.run(
1303
+ "MATCH (m:ConsentFlowEventMigration {id:'indexed-outbox-v1'}) RETURN m.completedAt AS completedAt"
1304
+ );
1305
+ await dispatchContractEvents({ budgetMs: 0 });
1306
+ const orphan = await neogma.queryRunner.run(
1307
+ 'MATCH (e:ConsentFlowEvent {id:$id}) RETURN e',
1308
+ { id: orphanId }
1309
+ );
1310
+ expect(orphan.records[0]!.get('e').properties).not.toHaveProperty('payload');
1311
+ expect(orphan.records[0]!.get('e').properties).not.toHaveProperty('message');
1312
+ expect(orphan.records[0]!.get('e').properties).not.toHaveProperty('cleanupPending');
1313
+ const row = (await eventRows(uri))[0]!;
1314
+ expect(row.get('e').properties.cleanupPending).toBe(true);
1315
+ expect(row.get('e').properties.payload).toBeDefined();
1316
+ expect(row.get('deliveries')[0].properties.eventCreatedAt).toBe(
1317
+ row.get('e').properties.createdAt
1318
+ );
1319
+ await ensureContractEventMaintenance();
1320
+ const repeated = await neogma.queryRunner.run(
1321
+ "MATCH (m:ConsentFlowEventMigration {id:'indexed-outbox-v1'}) RETURN m.completedAt AS completedAt"
1322
+ );
1323
+ expect(repeated.records[0]!.get('completedAt')).toBe(marker.records[0]!.get('completedAt'));
1324
+ queue.mockResolvedValue(undefined);
1325
+ await due(eventId);
1326
+ await dispatchContractEvents();
1327
+ expect((await eventRows(uri))[0]!.get('e').properties).not.toHaveProperty('cleanupPending');
1328
+ await neogma.queryRunner.run('MATCH (e:ConsentFlowEvent {id:$id}) DELETE e', {
1329
+ id: orphanId,
1330
+ });
1331
+ });
1332
+
1333
+ it.each(['expired', 'exhausted'] as const)(
1334
+ 'globally recovers %s deliveries even when their next attempt is in the future',
1335
+ async scenario => {
1336
+ const uri = await create([]);
1337
+ queue.mockRejectedValue(new Error('synthetic outage'));
1338
+ await accept(uri);
1339
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1340
+ const createdAt = new Date(
1341
+ Date.now() - (scenario === 'expired' ? 25 * 3_600_000 : 0)
1342
+ ).toISOString();
1343
+ await neogma.queryRunner.run(
1344
+ `
1345
+ MATCH (e:ConsentFlowEvent {id:$eventId})-[:HAS_DELIVERY]->(d)
1346
+ SET e.createdAt=$createdAt, d.eventCreatedAt=$createdAt,
1347
+ d.nextAttemptAt='2999-01-01T00:00:00Z', d.attempts=$attempts
1348
+ `,
1349
+ { eventId, createdAt, attempts: scenario === 'exhausted' ? 12 : 1 }
1350
+ );
1351
+ queue.mockClear();
1352
+ await dispatchContractEvents();
1353
+ const row = (await eventRows(uri))[0]!;
1354
+ expect(row.get('deliveries')[0].properties).toMatchObject({
1355
+ state: 'failed',
1356
+ failureReason: scenario === 'expired' ? 'retry_expired' : 'retry_exhausted',
1357
+ });
1358
+ expect(row.get('e').properties).not.toHaveProperty('payload');
1359
+ expect(row.get('e').properties).not.toHaveProperty('cleanupPending');
1360
+ expect(queue).not.toHaveBeenCalled();
1361
+ }
1362
+ );
1363
+
1364
+ it('uses indexed cleanup and delivery lookups with a large finished history', async () => {
1365
+ const uri = await create([]);
1366
+ queue.mockRejectedValue(new Error('synthetic outage'));
1367
+ await accept(uri);
1368
+ const eventId = (await eventRows(uri))[0]!.get('e').properties.id;
1369
+ const prefix = `finished-${eventId}-`;
1370
+ await neogma.queryRunner.run(
1371
+ `
1372
+ UNWIND range(1,2000) AS n
1373
+ CREATE (e:ConsentFlowEvent {id:$prefix+toString(n), createdAt:$now})
1374
+ CREATE (e)-[:HAS_DELIVERY]->(:ConsentFlowEventDelivery {id:$prefix+toString(n), state:'delivered'})
1375
+ `,
1376
+ { prefix, now: new Date().toISOString() }
1377
+ );
1378
+ await ensureContractEventMaintenance();
1379
+ await neogma.queryRunner.run('CALL db.awaitIndexes()');
1380
+ const querySpy = vi.spyOn(neogma.queryRunner, 'run');
1381
+ await dispatchContractEvents({ eventId, budgetMs: 0 });
1382
+ await dispatchContractEvents({ budgetMs: 0 });
1383
+ const lookups = querySpy.mock.calls.filter(
1384
+ ([query]) =>
1385
+ String(query).includes('WITH event LIMIT $limit') ||
1386
+ String(query).includes('RETURN event, delivery ORDER BY')
1387
+ );
1388
+ querySpy.mockRestore();
1389
+ expect(lookups).toHaveLength(4);
1390
+ type Plan = { operatorType: string; children?: Plan[] };
1391
+ const operators = (plan: Plan): string[] => [
1392
+ plan.operatorType,
1393
+ ...(plan.children ?? []).flatMap(operators),
1394
+ ];
1395
+ for (const [query, params] of lookups) {
1396
+ const result = await neogma.queryRunner.run(`EXPLAIN ${query}`, params);
1397
+ const plan = operators(result.summary.plan!);
1398
+ expect(plan.some(operator => operator.startsWith('NodeByLabelScan'))).toBe(false);
1399
+ expect(plan.some(operator => operator.includes('Index'))).toBe(true);
1400
+ }
1401
+ await neogma.queryRunner.run(
1402
+ `MATCH (e:ConsentFlowEvent) WHERE e.id STARTS WITH $prefix
1403
+ OPTIONAL MATCH (e)-[:HAS_DELIVERY]->(d) DETACH DELETE e,d`,
1404
+ { prefix }
1405
+ );
1406
+ });
1407
+
1408
+ it('acknowledges permanent queued contract rejection but retries negative storage acknowledgements', async () => {
1409
+ const uri = await create([]);
1410
+ await accept(uri);
1411
+ const queued = structuredClone(delivered[0]!);
1412
+ useDirectWebhook('https://synthetic.example/events');
1413
+ const fetchSpy = vi
1414
+ .spyOn(globalThis, 'fetch')
1415
+ .mockResolvedValue(new Response('{}', { status: 403 }));
1416
+ await expect(deliverQueuedNotification(JSON.stringify(queued))).resolves.toBeUndefined();
1417
+ await expect(notifications.sendNotification(structuredClone(queued))).resolves.toBe(false);
1418
+ fetchSpy.mockResolvedValue(new Response('{"success":false}', { status: 200 }));
1419
+ await expect(deliverQueuedNotification(JSON.stringify(queued))).rejects.toThrow(
1420
+ 'Notification was not durably stored'
1421
+ );
1422
+ });
1423
+
1424
+ it('does not widen a decision fan-out when an exact terminal retry follows a recipient addition', async () => {
1425
+ const uri = await create();
1426
+ await send(uri);
1427
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
1428
+ await flush(uri);
1429
+ delivered.length = 0;
1430
+ await owner.clients.fullAuth.contracts.addContractRecipient({
1431
+ contractUri: uri,
1432
+ recipient: outsider.profileId,
1433
+ });
1434
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
1435
+ await flush(uri);
1436
+ expect(delivered).toEqual([]);
1437
+ const decision = (await eventRows(uri)).find(
1438
+ row => row.get('e').properties.kind === 'request_denied'
1439
+ )!;
1440
+ expect(decision.get('deliveries')).toHaveLength(2);
1441
+ });
1442
+
1443
+ it('suppresses pending and already queued data after recipient removal', async () => {
1444
+ const uri = await create();
1445
+ await send(uri);
1446
+ queue.mockRejectedValue(new Error('synthetic outage'));
1447
+ await accept(uri);
1448
+ const consentEvent = (await eventRows(uri))
1449
+ .find(row => row.get('e').properties.kind === 'consent_created')!
1450
+ .get('e').properties;
1451
+ const targetDelivery = (await eventRows(uri))
1452
+ .find(row => row.get('e').properties.id === consentEvent.id)!
1453
+ .get('deliveries')
1454
+ .find(
1455
+ (d: { properties: { toId: string } }) => d.properties.toId === recipient.profileId
1456
+ ).properties;
1457
+ const queued: LCNNotification = {
1458
+ type: 'CONSENT_FLOW_TRANSACTION',
1459
+ from: { profileId: learner.profileId, did: learner.learnCard.id.did() },
1460
+ to: { profileId: recipient.profileId, did: recipient.learnCard.id.did() },
1461
+ data: {
1462
+ metadata: {
1463
+ eventId: consentEvent.id,
1464
+ deliveryKey: targetDelivery.id,
1465
+ event: 'consent_created',
1466
+ contractUri: uri,
1467
+ termsUri: `lc:network:localhost%3A3000:terms:${consentEvent.termsId}`,
1468
+ recipientRole: 'recipient',
1469
+ },
1470
+ },
1471
+ };
1472
+ await owner.clients.fullAuth.contracts.removeContractRecipient({
1473
+ contractUri: uri,
1474
+ recipient: recipient.profileId,
1475
+ });
1476
+ expect(await authorizeContractNotification(queued)).toBe(false);
1477
+ const transport = vi.spyOn(notifications, 'sendNotification').mockResolvedValue(true);
1478
+ await deliverQueuedNotification(JSON.stringify(queued));
1479
+ expect(transport).not.toHaveBeenCalled();
1480
+ queue.mockImplementation(async n => {
1481
+ delivered.push(structuredClone(n));
1482
+ return undefined;
1483
+ });
1484
+ delivered.length = 0;
1485
+ await due(consentEvent.id);
1486
+ await dispatchContractEvents({ eventId: consentEvent.id });
1487
+ expect(delivered.every(n => n.to.profileId !== recipient.profileId)).toBe(true);
1488
+ });
1489
+
1490
+ it('filters queued old personal/credential payloads after edits and blocks them after withdrawal', async () => {
1491
+ const uri = await create();
1492
+ await send(uri);
1493
+ delivered.length = 0;
1494
+ const { termsUri } = await accept(uri);
1495
+ delivered.length = 0;
1496
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
1497
+ uri: termsUri,
1498
+ terms: normalFullTerms,
1499
+ audienceVersion: 2,
1500
+ });
1501
+ await flush(uri);
1502
+ const queued = structuredClone(
1503
+ delivered.find(n => n.to.profileId === recipient.profileId)!
1504
+ );
1505
+ queued.data!.transaction!.terms = structuredClone(normalFullTerms);
1506
+ const terms = structuredClone(normalFullTerms);
1507
+ terms.read.personal = {};
1508
+ terms.read.credentials.categories.Achievement!.sharing = false;
1509
+ await learner.clients.fullAuth.contracts.updateConsentedContractTerms({
1510
+ uri: termsUri,
1511
+ terms,
1512
+ audienceVersion: 2,
1513
+ });
1514
+ expect(await authorizeContractNotification(queued)).toBe(true);
1515
+ expect(queued.data?.transaction?.terms?.read.personal).toEqual({});
1516
+ expect(
1517
+ queued.data?.transaction?.terms?.read.credentials.categories.Achievement?.shared
1518
+ ).toEqual([]);
1519
+ await learner.clients.fullAuth.contracts.withdrawConsent({ uri: termsUri });
1520
+ expect(await authorizeContractNotification(queued)).toBe(false);
1521
+ });
1522
+
1523
+ it('retries a real loopback webhook after HTTP 503 with the same event and delivery key', async () => {
1524
+ const bodies: LCNNotification[] = [];
1525
+ const server = createServer((request, response) => {
1526
+ let body = '';
1527
+ request.on('data', chunk => {
1528
+ body += chunk;
1529
+ });
1530
+ request.on('end', () => {
1531
+ bodies.push(JSON.parse(body));
1532
+ response.writeHead(bodies.length === 1 ? 503 : 200, {
1533
+ 'Content-Type': 'application/json',
1534
+ });
1535
+ response.end(JSON.stringify({ success: bodies.length > 1 }));
1536
+ });
1537
+ });
1538
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
1539
+ const address = server.address();
1540
+ if (!address || typeof address === 'string') throw new Error('Missing test server address');
1541
+ try {
1542
+ const uri = await create();
1543
+ queue.mockRestore();
1544
+ vi.spyOn(runtime, 'getNotificationRuntimeEnvironment').mockReturnValue({
1545
+ ...runtime.getNotificationRuntimeEnvironment(),
1546
+ NODE_ENV: 'development',
1547
+ IS_OFFLINE: false,
1548
+ NOTIFICATIONS_QUEUE_URL: undefined,
1549
+ IS_E2E_TEST: false,
1550
+ NOTIFICATIONS_SERVICE_WEBHOOK_URL: `http://127.0.0.1:${address.port}/events`,
1551
+ });
1552
+ // The synthetic JWT bypasses signing only; HTTP serialization, status handling,
1553
+ // acknowledgement and persisted retry all use the real notification transport.
1554
+ vi.spyOn(learnCardHelpers, 'getDidWebLearnCard').mockResolvedValue({
1555
+ invoke: { getDidAuthVp: async () => 'synthetic.auth.jwt' },
1556
+ } as unknown as Awaited<ReturnType<typeof learnCardHelpers.getDidWebLearnCard>>);
1557
+ await expect(send(uri)).resolves.toBe(true);
1558
+ expect(bodies).toHaveLength(1);
1559
+ const eventId = (await status(uri))!.requestId!;
1560
+ await due(eventId);
1561
+ await dispatchContractEvents({ eventId });
1562
+ expect(bodies).toHaveLength(2);
1563
+ expect(bodies[0]?.data?.metadata).toEqual(bodies[1]?.data?.metadata);
1564
+ expect(bodies[1]?.data?.metadata).toMatchObject({
1565
+ eventId,
1566
+ externalReferenceId: 'synthetic-ref-123',
1567
+ });
1568
+ await due(eventId);
1569
+ await dispatchContractEvents({ eventId });
1570
+ expect(bodies).toHaveLength(2);
1571
+ } finally {
1572
+ await new Promise<void>((resolve, reject) =>
1573
+ server.close(error => (error ? reject(error) : resolve()))
1574
+ );
1575
+ }
1576
+ });
1577
+
1578
+ it('suppresses stale pending invitations after a decision and exposes generic APIs in OpenAPI', async () => {
1579
+ const uri = await create();
1580
+ await send(uri);
1581
+ const invitation = structuredClone(delivered[0]!);
1582
+ await learner.clients.fullAuth.contracts.denyContractRequest({ contractUri: uri });
1583
+ expect(await authorizeContractNotification(invitation)).toBe(false);
1584
+ expect(openApiDocument.paths?.['/consent-flow-contracts/request']?.post).toBeTruthy();
1585
+ expect(openApiDocument.paths?.['/consent-flow-contracts/deny-request']?.post).toBeTruthy();
1586
+ expect(JSON.stringify(openApiDocument)).toContain('externalReferenceId');
1587
+ });
1588
+ });