@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
@@ -1,3 +1,4 @@
1
+ import { isServiceProfileExemptFromGuardianship } from '@helpers/profile.helpers';
1
2
  import { QueryBuilder, BindParam } from 'neogma';
2
3
  import mapValues from 'lodash/mapValues';
3
4
  import {
@@ -38,13 +39,15 @@ import { ConsentFlowGuardianApprovalValidator } from '@learncard/types';
38
39
  import { FlatBoostType } from 'types/boost';
39
40
  import { constructUri, getIdFromUri } from '@helpers/uri.helpers';
40
41
  import { flattenObject, inflateObject } from '@helpers/objects.helpers';
41
- import { convertDataQueryToNeo4jQuery, shouldIncludeCategory } from '@helpers/contract.helpers';
42
+ import { shouldIncludeCategory } from '@helpers/contract.helpers';
42
43
  import { ProfileType } from 'types/profile';
43
44
  import { CredentialType } from 'types/credential';
44
45
  import { getBoostUri } from '@helpers/boost.helpers';
45
46
  import { getCredentialUri } from '@helpers/credential.helpers';
46
47
  import { getProfilesThatManageAProfile } from '@accesslayer/profile/relationships/read';
47
- import { getDidWeb } from '@helpers/did.helpers';
48
+ import { getDidWeb, updateDidForProfile } from '@helpers/did.helpers';
49
+ import { getProfileByProfileId } from '@accesslayer/profile/read';
50
+ import { sanitizeProfileForTier } from '@helpers/profile-privacy.helpers';
48
51
 
49
52
  export const isProfileConsentFlowContractAdmin = async (
50
53
  profile: ProfileType,
@@ -279,6 +282,7 @@ export const getConsentedContractsForProfile = async (
279
282
  owner: LCNProfile;
280
283
  terms: DbTermsType;
281
284
  autoBoosts: string[];
285
+ recipients: ProfileType[];
282
286
  }[]
283
287
  > => {
284
288
  let query = new QueryBuilder(
@@ -313,9 +317,19 @@ export const getConsentedContractsForProfile = async (
313
317
  owner: LCNProfile;
314
318
  terms: FlatDbTermsType;
315
319
  boost: any[];
320
+ recipients: { properties: ProfileType }[];
316
321
  }>(
317
322
  await query
318
- .return('DISTINCT contract, owner, terms, COLLECT(boost) as boost')
323
+ .with('contract, owner, terms, COLLECT(boost) as boost')
324
+ .match({
325
+ optional: true,
326
+ related: [
327
+ { identifier: 'contract' },
328
+ ConsentFlowContract.getRelationshipByAlias('sharesDataWith'),
329
+ { identifier: 'recipient', model: Profile },
330
+ ],
331
+ })
332
+ .return('contract, owner, terms, boost, COLLECT(DISTINCT recipient) as recipients')
319
333
  .orderBy('terms.updatedAt')
320
334
  .limit(limit)
321
335
  .run()
@@ -323,6 +337,7 @@ export const getConsentedContractsForProfile = async (
323
337
 
324
338
  return results.map(result => ({
325
339
  ...result,
340
+ recipients: result.recipients.map(recipient => recipient.properties),
326
341
  contract: inflateObject(result.contract),
327
342
  terms: inflateObject(result.terms),
328
343
  autoBoosts: result.boost.map(boost => getBoostUri(boost.properties.id, domain)),
@@ -412,11 +427,21 @@ export const getHolderExportMetadataForProfile = async (
412
427
  createdAt: record.contract.createdAt,
413
428
  updatedAt: record.contract.updatedAt,
414
429
  uri: constructUri('contract', record.contract.id, domain),
415
- owner: record.owner,
430
+ owner: updateDidForProfile(domain, record.owner),
416
431
  ...(record.contract.expiresAt ? { expiresAt: record.contract.expiresAt } : {}),
417
432
  autoBoosts: record.autoBoosts,
433
+ // This is the audience at export time, not a historical consent snapshot.
434
+ audienceVersion: Number(record.contract.audienceVersion ?? 0),
435
+ recipients: record.recipients.map(recipient => {
436
+ const publicProfile = updateDidForProfile(domain, recipient);
437
+ return {
438
+ ...sanitizeProfileForTier(publicProfile, 'unauthenticated'),
439
+ did: publicProfile.did,
440
+ };
441
+ }),
418
442
  },
419
443
  terms: record.terms.terms,
444
+ ...(record.terms.referral ? { referral: record.terms.referral } : {}),
420
445
  transactions,
421
446
  });
422
447
  }
@@ -442,103 +467,135 @@ export const getHolderExportMetadataForProfile = async (
442
467
  };
443
468
  };
444
469
 
445
- export const getConsentedDataForProfile = async (
446
- profileId: string,
447
- { query = {}, limit, cursor }: { query?: ConsentFlowDataQuery; limit: number; cursor?: string }
448
- ): Promise<ConsentFlowContractData[]> => {
449
- const _dbQuery = new QueryBuilder(
450
- new BindParam({
451
- params: flattenObject({ terms: convertDataQueryToNeo4jQuery(query) }),
452
- cursor,
453
- })
454
- ).match({
455
- related: [
456
- { identifier: 'terms', model: ConsentFlowTerms },
457
- ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
458
- { model: ConsentFlowContract },
459
- ConsentFlowContract.getRelationshipByAlias('createdBy'),
460
- { model: Profile, where: { profileId } },
461
- ],
462
- // The following is custom Cypher logic that has the following behavior:
463
- // If query key is true, ensure all resulting terms have that key
464
- // If query key is false, ensure no resulting terms have that key
465
- // If query key is not present, return all terms whether they have it or not
466
- }).where(`
467
- all(key IN keys($params) WHERE
468
- CASE $params[key]
469
- WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
470
- WHEN false THEN terms[key] IS NULL OR terms[key] = []
471
- END
472
- )
473
- `);
474
-
475
- const dbQuery = cursor ? _dbQuery.raw('terms.updatedAt < $cursor') : _dbQuery;
476
-
477
- const results = convertQueryResultToPropertiesObjectArray<{
478
- terms: FlatDbTermsType;
479
- }>(await dbQuery.return('DISTINCT terms').orderBy('terms.updatedAt DESC').limit(limit).run());
480
-
481
- const terms = results.map(result => inflateObject<any>(result.terms));
470
+ /** Match filters against currently permitted data before counting a page. */
471
+ const matchesConsentedDataQuery = (
472
+ term: DbTermsType,
473
+ query: ConsentFlowDataQuery,
474
+ now: number
475
+ ): boolean =>
476
+ // Legacy boolean data queries test field presence, including an explicit false value.
477
+ (query.anonymize === undefined || query.anonymize === (term.terms.read.anonymize != null)) &&
478
+ Object.entries(query.personal ?? {}).every(
479
+ ([key, required]) => required === (term.terms.read.personal?.[key] != null)
480
+ ) &&
481
+ Object.entries(query.credentials?.categories ?? {}).every(([name, required]) => {
482
+ const category = term.terms.read.credentials.categories[name];
483
+ const shared =
484
+ term.terms.read.credentials.sharing !== false &&
485
+ !!category?.sharing &&
486
+ isConsentExpiryActive(category.shareUntil, now) &&
487
+ !!category.shared?.length;
488
+ return required === shared;
489
+ });
482
490
 
483
- return terms.map(term => ({
484
- date: term.updatedAt!,
485
- credentials: {
486
- categories: mapValues(
487
- term.terms.read.credentials.categories,
488
- category => category.shared ?? []
489
- ),
490
- },
491
- personal: term.terms.read.personal,
491
+ const projectConsentedData = (
492
+ term: DbTermsType,
493
+ query: ConsentFlowDataQuery,
494
+ now: number
495
+ ): ConsentFlowContractData => ({
496
+ date: term.updatedAt!,
497
+ credentials: {
498
+ categories: mapValues(term.terms.read.credentials?.categories ?? {}, (category, name) =>
499
+ term.terms.read.credentials.sharing !== false &&
500
+ category.sharing &&
501
+ isConsentExpiryActive(category.shareUntil, now) &&
502
+ shouldIncludeCategory(query.credentials?.categories, name)
503
+ ? (category.shared ?? [])
504
+ : []
505
+ ),
506
+ },
507
+ personal: term.terms.read.personal ?? {},
508
+ });
509
+
510
+ /** Filters reduce candidates in Neo4j; batches then reject invalid/expired grants.
511
+ * Do not cap scanned candidates: doing so would silently hide valid matches later in a page.
512
+ * Expiry and negative category filters still require the authoritative JS permission check.
513
+ */
514
+ const getPermittedContractData = async (
515
+ viewerProfileId: string,
516
+ { query = {}, limit, cursor }: { query?: ConsentFlowDataQuery; limit: number; cursor?: string },
517
+ contractId?: string
518
+ ): Promise<ConsentFlowContractData[]> => {
519
+ const now = Date.now();
520
+ const records: ConsentFlowContractData[] = [];
521
+ const personalFilters = Object.entries(query.personal ?? {}).map(([name, required]) => ({
522
+ key: `terms.read.personal.${name}`,
523
+ required,
492
524
  }));
525
+ const sharedCategories = Object.entries(query.credentials?.categories ?? {})
526
+ .filter(([, required]) => required)
527
+ .map(([name]) => ({
528
+ sharingKey: `terms.read.credentials.categories.${name}.sharing`,
529
+ sharedKey: `terms.read.credentials.categories.${name}.shared`,
530
+ }));
531
+ const batchSize = Math.max(limit, 50);
532
+ let offset = 0;
533
+ while (records.length < limit) {
534
+ const dbQuery = new QueryBuilder(
535
+ new BindParam({ cursor, personalFilters, sharedCategories })
536
+ )
537
+ .match({
538
+ related: [
539
+ { identifier: 'terms', model: ConsentFlowTerms },
540
+ ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
541
+ {
542
+ model: ConsentFlowContract,
543
+ identifier: 'contract',
544
+ ...(contractId ? { where: { id: contractId } } : {}),
545
+ },
546
+ '-[:CREATED_BY|SHARES_DATA_WITH]->',
547
+ { model: Profile, where: { profileId: viewerProfileId } },
548
+ ],
549
+ })
550
+ .where(
551
+ "(terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))" +
552
+ (cursor ? ' AND terms.updatedAt < $cursor' : '') +
553
+ ' AND all(filter IN $personalFilters WHERE filter.required = (terms[filter.key] IS NOT NULL))' +
554
+ ' AND (size($sharedCategories) = 0 OR coalesce(terms.`terms.read.credentials.sharing`, true) = true)' +
555
+ ' AND all(category IN $sharedCategories WHERE terms[category.sharingKey] = true AND' +
556
+ " (terms[category.sharedKey] IS NOT NULL OR any(key IN keys(terms) WHERE key STARTS WITH category.sharedKey + '.')))"
557
+ );
558
+ const results = convertQueryResultToPropertiesObjectArray<{
559
+ terms: FlatDbTermsType;
560
+ contract: FlatDbContractType;
561
+ }>(
562
+ await dbQuery
563
+ .return('DISTINCT terms, contract')
564
+ .orderBy('terms.updatedAt DESC, terms.id ASC')
565
+ .skip(offset)
566
+ .limit(batchSize)
567
+ .run()
568
+ );
569
+ for (const result of results) {
570
+ const term = DbTermsValidator.safeParse(inflateObject(result.terms));
571
+ const contract = DbContractValidator.safeParse(inflateObject(result.contract));
572
+ if (
573
+ !term.success ||
574
+ !contract.success ||
575
+ !isConsentExpiryActive(term.data.expiresAt, now) ||
576
+ !isConsentExpiryActive(contract.data.expiresAt, now) ||
577
+ !matchesConsentedDataQuery(term.data, query, now)
578
+ )
579
+ continue;
580
+ records.push(projectConsentedData(term.data, query, now));
581
+ if (records.length >= limit) return records;
582
+ }
583
+ if (results.length < batchSize) break;
584
+ offset += batchSize;
585
+ }
586
+ return records;
493
587
  };
494
588
 
589
+ export const getConsentedDataForProfile = async (
590
+ profileId: string,
591
+ options: { query?: ConsentFlowDataQuery; limit: number; cursor?: string }
592
+ ): Promise<ConsentFlowContractData[]> => getPermittedContractData(profileId, options);
593
+
495
594
  export const getConsentedDataForContract = async (
496
595
  id: string,
497
- { query = {}, limit, cursor }: { query?: ConsentFlowDataQuery; limit: number; cursor?: string }
498
- ): Promise<ConsentFlowContractData[]> => {
499
- const _dbQuery = new QueryBuilder(
500
- new BindParam({
501
- params: flattenObject({ terms: flattenObject(convertDataQueryToNeo4jQuery(query)) }),
502
- cursor,
503
- })
504
- ).match({
505
- related: [
506
- { identifier: 'terms', model: ConsentFlowTerms },
507
- ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
508
- { model: ConsentFlowContract, where: { id: id } },
509
- ],
510
- // The following is custom Cypher logic that has the following behavior:
511
- // If query key is true, ensure all resulting terms have that key
512
- // If query key is false, ensure no resulting terms have that key
513
- // If query key is not present, return all terms whether they have it or not
514
- }).where(`
515
- all(key IN keys($params) WHERE
516
- CASE $params[key]
517
- WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
518
- WHEN false THEN terms[key] IS NULL OR terms[key] = []
519
- END
520
- )
521
- `);
522
-
523
- const dbQuery = cursor ? _dbQuery.where('terms.updatedAt < $cursor') : _dbQuery;
524
-
525
- const results = convertQueryResultToPropertiesObjectArray<{
526
- terms: FlatDbTermsType;
527
- }>(await dbQuery.return('DISTINCT terms').orderBy('terms.updatedAt DESC').limit(limit).run());
528
-
529
- const terms = results.map(result => inflateObject<any>(result.terms));
530
-
531
- return terms.map(term => ({
532
- date: term.updatedAt!,
533
- credentials: {
534
- categories: mapValues(
535
- term.terms.read.credentials.categories,
536
- category => category.shared ?? []
537
- ),
538
- },
539
- personal: term.terms.read.personal,
540
- }));
541
- };
596
+ viewerProfileId: string,
597
+ options: { query?: ConsentFlowDataQuery; limit: number; cursor?: string }
598
+ ): Promise<ConsentFlowContractData[]> => getPermittedContractData(viewerProfileId, options, id);
542
599
 
543
600
  // Empty expiry values historically mean no expiry. Invalid nonempty values never authorize access.
544
601
  const isConsentExpiryActive = (expiresAt: string | undefined, now: number): boolean => {
@@ -562,7 +619,17 @@ export const getConsentedDataBetweenProfiles = async (
562
619
  const { whereClause: contractWhereClause, params: contractQueryParams } =
563
620
  buildWhereForQueryBuilder('contract', convertedContractQuery as any);
564
621
  const now = Date.now();
565
- const managers = await getProfilesThatManageAProfile(consenterProfileId);
622
+ const [managers, consenterProfile] = await Promise.all([
623
+ getProfilesThatManageAProfile(consenterProfileId),
624
+ getProfileByProfileId(consenterProfileId),
625
+ ]);
626
+ const isManagedProfile = managers.length > 0;
627
+ const requiresManagerApproval =
628
+ isManagedProfile &&
629
+ !isServiceProfileExemptFromGuardianship(
630
+ consenterProfile?.isServiceProfile,
631
+ consenterProfile?.type
632
+ );
566
633
  const records: ConsentFlowContractDataForDid[] = [];
567
634
  const batchSize = Math.max(limit, 50);
568
635
  let offset = 0;
@@ -572,9 +639,6 @@ export const getConsentedDataBetweenProfiles = async (
572
639
  while (records.length < limit) {
573
640
  const _dbQuery = new QueryBuilder(
574
641
  new BindParam({
575
- params: flattenObject({
576
- terms: flattenObject(convertDataQueryToNeo4jQuery(params)),
577
- }),
578
642
  cursor,
579
643
  ...contractQueryParams,
580
644
  })
@@ -585,7 +649,7 @@ export const getConsentedDataBetweenProfiles = async (
585
649
  { identifier: 'terms', model: ConsentFlowTerms },
586
650
  ConsentFlowTerms.getRelationshipByAlias('consentsTo'),
587
651
  { model: ConsentFlowContract, identifier: 'contract' },
588
- ConsentFlowContract.getRelationshipByAlias('createdBy'),
652
+ '-[:CREATED_BY|SHARES_DATA_WITH]->',
589
653
  { model: Profile, where: { profileId: ownerProfileId } },
590
654
  ],
591
655
  // The following is custom Cypher logic that has the following behavior:
@@ -594,12 +658,6 @@ export const getConsentedDataBetweenProfiles = async (
594
658
  // If query key is not present, return all terms whether they have it or not
595
659
  }).where(`
596
660
  (terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))
597
- AND all(key IN keys($params) WHERE
598
- CASE $params[key]
599
- WHEN true THEN terms[key] IS NOT NULL AND terms[key] <> []
600
- WHEN false THEN terms[key] IS NULL OR terms[key] = []
601
- END
602
- )
603
661
  AND ${contractWhereClause}
604
662
  `);
605
663
 
@@ -648,12 +706,14 @@ AND ${contractWhereClause}
648
706
  )
649
707
  continue;
650
708
 
709
+ if (!matchesConsentedDataQuery(term, params, now)) continue;
710
+
651
711
  const parsedApproval = ConsentFlowGuardianApprovalValidator.safeParse(
652
712
  term.guardianApproval
653
713
  );
654
714
  const approval = parsedApproval.success ? parsedApproval.data : undefined;
655
715
  const required =
656
- managers.length > 0 || !!term.guardianApproval || result.hadGuardianApproval;
716
+ requiresManagerApproval || !!term.guardianApproval || result.hadGuardianApproval;
657
717
  const approved =
658
718
  !!approval &&
659
719
  approval.contractUpdatedAt === contract.updatedAt &&
@@ -664,6 +724,7 @@ AND ${contractWhereClause}
664
724
  getDidWeb(domain, manager.profileId) === approval.guardianDid)
665
725
  );
666
726
 
727
+ const permittedData = projectConsentedData(term, params, now);
667
728
  records.push({
668
729
  date: result.date,
669
730
  ...(result.createdAt ? { createdAt: result.createdAt } : {}),
@@ -677,7 +738,22 @@ AND ${contractWhereClause}
677
738
  termsUri: constructUri('terms', term.id, domain),
678
739
  status: term.status,
679
740
  ...(term.expiresAt?.trim() ? { expiresAt: term.expiresAt } : {}),
680
- terms: term.terms,
741
+ terms: {
742
+ ...term.terms,
743
+ read: {
744
+ ...term.terms.read,
745
+ credentials: {
746
+ ...term.terms.read.credentials,
747
+ categories: mapValues(
748
+ term.terms.read.credentials.categories,
749
+ (category, name) => ({
750
+ ...category,
751
+ shared: permittedData.credentials.categories[name] ?? [],
752
+ })
753
+ ),
754
+ },
755
+ },
756
+ },
681
757
  credentials: Object.entries(term.terms.read.credentials.categories).flatMap(
682
758
  ([category, { shared, sharing, shareUntil }]) => {
683
759
  if (
@@ -0,0 +1,125 @@
1
+ import { BindParam, QueryBuilder } from 'neogma';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { ConsentFlowContract, Profile } from '@models';
4
+ import { ProfileType } from 'types/profile';
5
+
6
+ /** Lock the contract before checking its audience, in the same database statement. */
7
+ export const lockContractAudience = (query: QueryBuilder, contractId: string): QueryBuilder =>
8
+ query
9
+ .match({ model: ConsentFlowContract, identifier: 'contract', where: { id: contractId } })
10
+ .set('contract.audienceLock = coalesce(contract.audienceLock, 0) + 1')
11
+ .with('contract');
12
+
13
+ /** Retry only rolled-back transient transactions, before any notifications or issuance. */
14
+ export const runAudienceMutation = async (query: QueryBuilder): ReturnType<QueryBuilder['run']> => {
15
+ for (let attempt = 0; ; attempt += 1) {
16
+ try {
17
+ return await query.run();
18
+ } catch (error) {
19
+ if (
20
+ attempt >= 8 ||
21
+ !error ||
22
+ typeof error !== 'object' ||
23
+ !('code' in error) ||
24
+ error.code !== 'Neo.TransientError.Transaction.DeadlockDetected'
25
+ )
26
+ throw error;
27
+ await new Promise(resolve =>
28
+ setTimeout(resolve, Math.min(1000, 25 * 2 ** attempt) + Math.random() * 25)
29
+ );
30
+ }
31
+ }
32
+ };
33
+
34
+ export const audienceVersionWhere = `
35
+ (($audienceVersion IS NULL AND coalesce(contract.audienceVersion, 0) = 0)
36
+ OR coalesce(contract.audienceVersion, 0) = $audienceVersion)
37
+ `;
38
+
39
+ /** Checked under the contract lock; a referral accepts only the invitation the learner reviewed. */
40
+ export const consentMutationWhere = `
41
+ (CASE WHEN contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' THEN true
42
+ ELSE datetime(contract.expiresAt) > datetime.realtime() END)
43
+ AND ($expectedRequestId IS NULL OR EXISTS {
44
+ MATCH (contract)-[request:REQUESTED_FOR]->(:Profile {profileId: $consenterProfileId})
45
+ WHERE request.requestId = $expectedRequestId AND request.status = 'pending'
46
+ })
47
+ `;
48
+
49
+ export const assertAudienceMutation = (count: number): void => {
50
+ if (!count)
51
+ throw new TRPCError({
52
+ code: 'CONFLICT',
53
+ message: 'The sharing audience or consent changed. Review the contract and try again.',
54
+ });
55
+ };
56
+
57
+ export const getRecipientsForContract = async (contractId: string): Promise<ProfileType[]> => {
58
+ const result = await new QueryBuilder()
59
+ .match({
60
+ related: [
61
+ { model: ConsentFlowContract, where: { id: contractId } },
62
+ ConsentFlowContract.getRelationshipByAlias('sharesDataWith'),
63
+ { model: Profile, identifier: 'recipient' },
64
+ ],
65
+ })
66
+ .return('DISTINCT recipient')
67
+ .run();
68
+ return result.records.map(record => record.get('recipient').properties);
69
+ };
70
+
71
+ export const canReadContractData = async (
72
+ contractId: string,
73
+ profileId: string
74
+ ): Promise<boolean> => {
75
+ const result = await new QueryBuilder(new BindParam({ contractId, profileId }))
76
+ .match(
77
+ '(contract:ConsentFlowContract {id: $contractId})-[:CREATED_BY|SHARES_DATA_WITH]->(profile:Profile {profileId: $profileId})'
78
+ )
79
+ .return('count(profile) AS count')
80
+ .run();
81
+ return Number(result.records[0]?.get('count') ?? 0) > 0;
82
+ };
83
+
84
+ /** Additions freeze after first consent; removals never restore that permission. */
85
+ export const addRecipientToContract = async (
86
+ contractId: string,
87
+ profileId: string
88
+ ): Promise<boolean> => {
89
+ const query = lockContractAudience(new QueryBuilder(new BindParam({ profileId })), contractId)
90
+ .match('(recipient:Profile {profileId: $profileId})')
91
+ .where(
92
+ `NOT EXISTS { MATCH (contract)-[:CREATED_BY]->(recipient) }
93
+ AND (EXISTS { MATCH (contract)-[:SHARES_DATA_WITH]->(recipient) }
94
+ OR (coalesce(contract.hasConsented, false) = false
95
+ AND NOT EXISTS { MATCH (:ConsentFlowTerms)-[:CONSENTS_TO]->(contract) }
96
+ AND size([(contract)-[:SHARES_DATA_WITH]->(existing:Profile) | existing]) < 50))`
97
+ )
98
+ .merge('(contract)-[audience:SHARES_DATA_WITH]->(recipient)')
99
+ .raw(
100
+ 'ON CREATE SET contract.audienceVersion = coalesce(contract.audienceVersion, 0) + 1, audience.addedAt = $addedAt'
101
+ );
102
+ query.getBindParam().add({ addedAt: new Date().toISOString() });
103
+ const result = await runAudienceMutation(query.return('count(audience) AS count'));
104
+ if (!Number(result.records[0]?.get('count') ?? 0))
105
+ throw new TRPCError({
106
+ code: 'CONFLICT',
107
+ message:
108
+ 'Recipients can only be added before first consent, and the owner is already in the audience.',
109
+ });
110
+ return true;
111
+ };
112
+
113
+ export const removeRecipientFromContract = async (
114
+ contractId: string,
115
+ profileId: string
116
+ ): Promise<boolean> => {
117
+ const result = await runAudienceMutation(
118
+ lockContractAudience(new QueryBuilder(new BindParam({ profileId })), contractId)
119
+ .match('(contract)-[audience:SHARES_DATA_WITH]->(:Profile {profileId: $profileId})')
120
+ .delete('audience')
121
+ .set('contract.audienceVersion = coalesce(contract.audienceVersion, 0) + 1')
122
+ .return('count(contract) AS count')
123
+ );
124
+ return Number(result.records[0]?.get('count') ?? 0) > 0;
125
+ };
@@ -0,0 +1,125 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { BindParam, QueryBuilder } from 'neogma';
3
+ import { TRPCError } from '@trpc/server';
4
+ import type { ConsentFlowTerms } from '@learncard/types';
5
+ import { inflateObject } from '@helpers/objects.helpers';
6
+ import type { DbTermsType } from 'types/consentflowcontract';
7
+ import {
8
+ audienceVersionWhere,
9
+ assertAudienceMutation,
10
+ lockContractAudience,
11
+ runAudienceMutation,
12
+ } from './recipients';
13
+
14
+ const canonicalize = (value: unknown): unknown => {
15
+ if (Array.isArray(value)) return value.map(canonicalize);
16
+ if (value && typeof value === 'object')
17
+ return Object.fromEntries(
18
+ Object.entries(value)
19
+ .sort(([a], [b]) => a.localeCompare(b))
20
+ .map(([key, entry]) => [key, canonicalize(entry)])
21
+ );
22
+ return value;
23
+ };
24
+
25
+ /** Bind retries to the original decision without persisting the recipient token. */
26
+ export const smartResumeFingerprint = (submission: unknown): string =>
27
+ createHash('sha256')
28
+ .update(JSON.stringify(canonicalize(submission)))
29
+ .digest('hex');
30
+
31
+ /**
32
+ * Claim publication only after consent committed, under the same audience lock.
33
+ * A bounded lease prevents concurrent retries; completed retries reuse the redirect.
34
+ * A failed publication retains consent and can retry the identical submission.
35
+ */
36
+ export const publishSmartResume = async ({
37
+ contractId,
38
+ profileId,
39
+ termsId,
40
+ fingerprint,
41
+ audienceVersion,
42
+ expectedRequestId,
43
+ upload,
44
+ }: {
45
+ contractId: string;
46
+ profileId: string;
47
+ termsId: string;
48
+ fingerprint: string;
49
+ audienceVersion?: number;
50
+ expectedRequestId?: string;
51
+ upload: (terms: ConsentFlowTerms) => Promise<string | undefined>;
52
+ }): Promise<string | undefined> => {
53
+ const leaseId = randomUUID();
54
+ // Token request and credentials POST each time out after 30s. URI resolution
55
+ // must also finish before this lease expires, or publication must be retried.
56
+ const leaseDurationMs = 120_000;
57
+ const result = await runAudienceMutation(
58
+ lockContractAudience(
59
+ new QueryBuilder(
60
+ new BindParam({
61
+ profileId,
62
+ termsId,
63
+ fingerprint,
64
+ audienceVersion: audienceVersion ?? null,
65
+ expectedRequestId: expectedRequestId ?? null,
66
+ leaseId,
67
+ leaseDurationMs,
68
+ })
69
+ ),
70
+ contractId
71
+ )
72
+ .where(audienceVersionWhere)
73
+ .match(
74
+ '(profile:Profile {profileId: $profileId})-[:CREATED_BY]->(terms:ConsentFlowTerms {id: $termsId})-[:CONSENTS_TO]->(contract)'
75
+ )
76
+ .where(
77
+ `terms.smartResumeFingerprint = $fingerprint
78
+ AND ($expectedRequestId IS NULL OR EXISTS {
79
+ MATCH (contract)-[request:REQUESTED_FOR]->(profile)
80
+ WHERE request.requestId = $expectedRequestId AND request.status = 'accepted'
81
+ })
82
+ AND terms.smartResumeMutationVersion = coalesce(terms.mutationVersion, 0)
83
+ AND (terms.status = 'live' OR (terms.status = 'stale' AND terms.oneTime = true))
84
+ AND CASE WHEN terms.expiresAt IS NULL OR trim(terms.expiresAt) = '' THEN true ELSE datetime(terms.expiresAt) > datetime.realtime() END
85
+ AND CASE WHEN contract.expiresAt IS NULL OR trim(contract.expiresAt) = '' THEN true ELSE datetime(contract.expiresAt) > datetime.realtime() END
86
+ AND (terms.smartResumePublicationStatus IN ['pending', 'failed', 'succeeded']
87
+ OR (terms.smartResumePublicationStatus = 'sending' AND terms.smartResumeLeaseUntil < datetime.realtime().epochMillis))`
88
+ )
89
+ .set(
90
+ `terms.smartResumeLeaseId = CASE WHEN terms.smartResumePublicationStatus = 'succeeded' THEN terms.smartResumeLeaseId ELSE $leaseId END,
91
+ terms.smartResumeLeaseUntil = CASE WHEN terms.smartResumePublicationStatus = 'succeeded' THEN terms.smartResumeLeaseUntil ELSE datetime.realtime().epochMillis + $leaseDurationMs END,
92
+ terms.smartResumePublicationStatus = CASE WHEN terms.smartResumePublicationStatus = 'succeeded' THEN 'succeeded' ELSE 'sending' END`
93
+ )
94
+ .return('terms')
95
+ );
96
+ assertAudienceMutation(result.records.length);
97
+ const accepted = inflateObject(result.records[0]!.get('terms').properties) as DbTermsType;
98
+ if (accepted.smartResumePublicationStatus === 'succeeded')
99
+ return accepted.smartResumeRedirectUrl;
100
+
101
+ const finish = async (status: 'failed' | 'succeeded', redirectUrl?: string): Promise<void> => {
102
+ await new QueryBuilder(
103
+ new BindParam({ termsId, leaseId, status, redirectUrl: redirectUrl ?? null })
104
+ )
105
+ .match('(terms:ConsentFlowTerms {id: $termsId})')
106
+ .where('terms.smartResumeLeaseId = $leaseId')
107
+ .set(
108
+ 'terms.smartResumePublicationStatus = $status, terms.smartResumeRedirectUrl = $redirectUrl'
109
+ )
110
+ .run();
111
+ };
112
+ try {
113
+ const redirectUrl = await upload(accepted.terms);
114
+ await finish('succeeded', redirectUrl);
115
+ return redirectUrl;
116
+ } catch (cause) {
117
+ await finish('failed');
118
+ throw new TRPCError({
119
+ code: 'BAD_GATEWAY',
120
+ message:
121
+ 'Your consent was saved, but the SmartResume upload failed. Retry to finish connecting.',
122
+ cause,
123
+ });
124
+ }
125
+ };