@learncard/network-brain-service 3.19.0 → 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 (507) hide show
  1. package/.env.example +47 -82
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +147 -0
  4. package/README.md +51 -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/presentation/read.d.ts.map +1 -1
  56. package/dist/accesslayer/presentation/relationships/create.d.ts +2 -2
  57. package/dist/accesslayer/presentation/relationships/create.d.ts.map +1 -1
  58. package/dist/accesslayer/presentation/relationships/read.d.ts.map +1 -1
  59. package/dist/accesslayer/share-link/abandon.d.ts +3 -0
  60. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -0
  61. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  62. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  63. package/dist/accesslayer/share-link/cleanup.d.ts +6 -0
  64. package/dist/accesslayer/share-link/cleanup.d.ts.map +1 -0
  65. package/dist/accesslayer/share-link/errors.d.ts +8 -0
  66. package/dist/accesslayer/share-link/errors.d.ts.map +1 -0
  67. package/dist/accesslayer/share-link/finalize.d.ts +3 -0
  68. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -0
  69. package/dist/accesslayer/share-link/helpers.d.ts +59 -0
  70. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -0
  71. package/dist/accesslayer/share-link/index.d.ts +12 -0
  72. package/dist/accesslayer/share-link/index.d.ts.map +1 -0
  73. package/dist/accesslayer/share-link/list.d.ts +5 -0
  74. package/dist/accesslayer/share-link/list.d.ts.map +1 -0
  75. package/dist/accesslayer/share-link/read.d.ts +5 -0
  76. package/dist/accesslayer/share-link/read.d.ts.map +1 -0
  77. package/dist/accesslayer/share-link/receipt.d.ts +11 -0
  78. package/dist/accesslayer/share-link/receipt.d.ts.map +1 -0
  79. package/dist/accesslayer/share-link/recovery.d.ts +6 -0
  80. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -0
  81. package/dist/accesslayer/share-link/reserve.d.ts +4 -0
  82. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -0
  83. package/dist/accesslayer/share-link/revoke.d.ts +3 -0
  84. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -0
  85. package/dist/accesslayer/share-link/transaction-execution.d.ts +18 -0
  86. package/dist/accesslayer/share-link/transaction-execution.d.ts.map +1 -0
  87. package/dist/accesslayer/share-link/transaction.d.ts +15 -0
  88. package/dist/accesslayer/share-link/transaction.d.ts.map +1 -0
  89. package/dist/accesslayer/share-link/types.d.ts +375 -0
  90. package/dist/accesslayer/share-link/types.d.ts.map +1 -0
  91. package/dist/app.d.ts +4 -0
  92. package/dist/app.d.ts.map +1 -1
  93. package/dist/cache/app-store.caches.d.ts +49 -0
  94. package/dist/cache/app-store.caches.d.ts.map +1 -1
  95. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  96. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  97. package/dist/config/environment.d.ts +14 -0
  98. package/dist/config/environment.d.ts.map +1 -1
  99. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  100. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  101. package/dist/config/stageConfig.d.ts +112 -0
  102. package/dist/config/stageConfig.d.ts.map +1 -0
  103. package/dist/config/stageConfig.test.d.ts +2 -0
  104. package/dist/config/stageConfig.test.d.ts.map +1 -0
  105. package/dist/constants/auth-grant.d.ts +2 -0
  106. package/dist/constants/auth-grant.d.ts.map +1 -1
  107. package/dist/didkit_wasm_bg.wasm +0 -0
  108. package/dist/docker-entry.d.ts +1 -1
  109. package/dist/docker-entry.d.ts.map +1 -1
  110. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  111. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  112. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  113. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  114. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  115. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  116. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  117. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  118. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  119. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  120. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  121. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  122. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  123. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  124. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  125. package/dist/helpers/credentialSubject.helpers.d.ts +3 -0
  126. package/dist/helpers/credentialSubject.helpers.d.ts.map +1 -0
  127. package/dist/helpers/credentialSubject.helpers.test.d.ts +2 -0
  128. package/dist/helpers/credentialSubject.helpers.test.d.ts.map +1 -0
  129. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  130. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  131. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  132. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  133. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  134. package/dist/helpers/notificationMessages.d.ts +1 -1
  135. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  136. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  137. package/dist/helpers/notifications.helpers.d.ts +6 -0
  138. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  139. package/dist/helpers/presentation-metadata.d.ts +4 -0
  140. package/dist/helpers/presentation-metadata.d.ts.map +1 -0
  141. package/dist/helpers/presentation.helpers.d.ts +1 -1
  142. package/dist/helpers/presentation.helpers.d.ts.map +1 -1
  143. package/dist/helpers/profile.helpers.d.ts +2 -0
  144. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  145. package/dist/helpers/share-content-client/adapters.d.ts +10 -0
  146. package/dist/helpers/share-content-client/adapters.d.ts.map +1 -0
  147. package/dist/helpers/share-content-client/canonical.d.ts +7 -0
  148. package/dist/helpers/share-content-client/canonical.d.ts.map +1 -0
  149. package/dist/helpers/share-content-client/client.d.ts +5 -0
  150. package/dist/helpers/share-content-client/client.d.ts.map +1 -0
  151. package/dist/helpers/share-content-client/config.d.ts +11 -0
  152. package/dist/helpers/share-content-client/config.d.ts.map +1 -0
  153. package/dist/helpers/share-content-client/index.d.ts +6 -0
  154. package/dist/helpers/share-content-client/index.d.ts.map +1 -0
  155. package/dist/helpers/share-content-client/responses.d.ts +16 -0
  156. package/dist/helpers/share-content-client/responses.d.ts.map +1 -0
  157. package/dist/helpers/share-content-client/types.d.ts +145 -0
  158. package/dist/helpers/share-content-client/types.d.ts.map +1 -0
  159. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  160. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  161. package/dist/helpers/share-link-attachment.d.ts +18 -0
  162. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  163. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  164. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  165. package/dist/helpers/share-link-coordinator/budget-helpers.d.ts +5 -0
  166. package/dist/helpers/share-link-coordinator/budget-helpers.d.ts.map +1 -0
  167. package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts +3 -0
  168. package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts.map +1 -0
  169. package/dist/helpers/share-link-coordinator/coordinator.d.ts +4 -0
  170. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -0
  171. package/dist/helpers/share-link-coordinator/index.d.ts +5 -0
  172. package/dist/helpers/share-link-coordinator/index.d.ts.map +1 -0
  173. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts +9 -0
  174. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -0
  175. package/dist/helpers/share-link-coordinator/runtime.d.ts +11 -0
  176. package/dist/helpers/share-link-coordinator/runtime.d.ts.map +1 -0
  177. package/dist/helpers/share-link-coordinator/stat.d.ts +4 -0
  178. package/dist/helpers/share-link-coordinator/stat.d.ts.map +1 -0
  179. package/dist/helpers/share-link-coordinator/types.d.ts +160 -0
  180. package/dist/helpers/share-link-coordinator/types.d.ts.map +1 -0
  181. package/dist/helpers/share-link-lifecycle/index.d.ts +5 -0
  182. package/dist/helpers/share-link-lifecycle/index.d.ts.map +1 -0
  183. package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts +4 -0
  184. package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts.map +1 -0
  185. package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts +21 -0
  186. package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts.map +1 -0
  187. package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts +9 -0
  188. package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts.map +1 -0
  189. package/dist/helpers/share-link-lifecycle/types.d.ts +14 -0
  190. package/dist/helpers/share-link-lifecycle/types.d.ts.map +1 -0
  191. package/dist/helpers/share-link-maintenance/budget.d.ts +12 -0
  192. package/dist/helpers/share-link-maintenance/budget.d.ts.map +1 -0
  193. package/dist/helpers/share-link-maintenance/config.d.ts +48 -0
  194. package/dist/helpers/share-link-maintenance/config.d.ts.map +1 -0
  195. package/dist/helpers/share-link-maintenance/index.d.ts +8 -0
  196. package/dist/helpers/share-link-maintenance/index.d.ts.map +1 -0
  197. package/dist/helpers/share-link-maintenance/runner.d.ts +20 -0
  198. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -0
  199. package/dist/helpers/share-link-maintenance/runtime.d.ts +38 -0
  200. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -0
  201. package/dist/helpers/share-link-maintenance/scheduler.d.ts +18 -0
  202. package/dist/helpers/share-link-maintenance/scheduler.d.ts.map +1 -0
  203. package/dist/helpers/share-link-maintenance/telemetry.d.ts +5 -0
  204. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -0
  205. package/dist/helpers/share-link-maintenance/types.d.ts +56 -0
  206. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -0
  207. package/dist/helpers/share-link-owner/config.d.ts +16 -0
  208. package/dist/helpers/share-link-owner/config.d.ts.map +1 -0
  209. package/dist/helpers/share-link-owner/diagnostics.d.ts +26 -0
  210. package/dist/helpers/share-link-owner/diagnostics.d.ts.map +1 -0
  211. package/dist/helpers/share-link-owner/lazy-initializer.d.ts +2 -0
  212. package/dist/helpers/share-link-owner/lazy-initializer.d.ts.map +1 -0
  213. package/dist/helpers/share-link-owner-projection.d.ts +4 -0
  214. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -0
  215. package/dist/helpers/share-link-passcode-abuse.d.ts +2 -0
  216. package/dist/helpers/share-link-passcode-abuse.d.ts.map +1 -0
  217. package/dist/helpers/share-link-passcode.d.ts +6 -0
  218. package/dist/helpers/share-link-passcode.d.ts.map +1 -0
  219. package/dist/helpers/share-link-policy/index.d.ts +4 -0
  220. package/dist/helpers/share-link-policy/index.d.ts.map +1 -0
  221. package/dist/helpers/share-link-policy/production.d.ts +11 -0
  222. package/dist/helpers/share-link-policy/production.d.ts.map +1 -0
  223. package/dist/helpers/share-link-policy/resolver.d.ts +6 -0
  224. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -0
  225. package/dist/helpers/share-link-policy/types.d.ts +22 -0
  226. package/dist/helpers/share-link-policy/types.d.ts.map +1 -0
  227. package/dist/helpers/share-link-public/config.d.ts +13 -0
  228. package/dist/helpers/share-link-public/config.d.ts.map +1 -0
  229. package/dist/helpers/share-link-view-notification.d.ts +2 -0
  230. package/dist/helpers/share-link-view-notification.d.ts.map +1 -0
  231. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  232. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  233. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  234. package/dist/index.js +2 -2
  235. package/dist/models/AppManifestVersion.d.ts +20 -0
  236. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  237. package/dist/models/AppStoreListing.d.ts +13 -0
  238. package/dist/models/AppStoreListing.d.ts.map +1 -1
  239. package/dist/models/ConsentFlowContract.d.ts +13 -2
  240. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  241. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  242. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  243. package/dist/models/Integration.d.ts +2 -0
  244. package/dist/models/Integration.d.ts.map +1 -1
  245. package/dist/models/Presentation.d.ts +2 -0
  246. package/dist/models/Presentation.d.ts.map +1 -1
  247. package/dist/models/Profile.d.ts +2 -0
  248. package/dist/models/Profile.d.ts.map +1 -1
  249. package/dist/models/ShareLink.d.ts +46 -0
  250. package/dist/models/ShareLink.d.ts.map +1 -0
  251. package/dist/models/ShareViewReceipt.d.ts +22 -0
  252. package/dist/models/ShareViewReceipt.d.ts.map +1 -0
  253. package/dist/models/index.d.ts +2 -0
  254. package/dist/models/index.d.ts.map +1 -1
  255. package/dist/models/share-link-constraints.d.ts +3 -0
  256. package/dist/models/share-link-constraints.d.ts.map +1 -0
  257. package/dist/openapi.d.ts +1 -1
  258. package/dist/openapi.d.ts.map +1 -1
  259. package/dist/public-share-link-http.d.ts +4 -0
  260. package/dist/public-share-link-http.d.ts.map +1 -0
  261. package/dist/routes/app-store.d.ts +619 -0
  262. package/dist/routes/app-store.d.ts.map +1 -1
  263. package/dist/routes/boosts.d.ts +2 -2
  264. package/dist/routes/boosts.d.ts.map +1 -1
  265. package/dist/routes/contracts.d.ts +148 -5
  266. package/dist/routes/contracts.d.ts.map +1 -1
  267. package/dist/routes/credentials.d.ts +35 -1
  268. package/dist/routes/credentials.d.ts.map +1 -1
  269. package/dist/routes/inbox.d.ts +52 -2
  270. package/dist/routes/inbox.d.ts.map +1 -1
  271. package/dist/routes/index.d.ts +153 -0
  272. package/dist/routes/index.d.ts.map +1 -1
  273. package/dist/routes/presentations.d.ts +1 -0
  274. package/dist/routes/presentations.d.ts.map +1 -1
  275. package/dist/routes/profile-manager.d.ts.map +1 -1
  276. package/dist/routes/profiles.d.ts.map +1 -1
  277. package/dist/routes/public-share-links.d.ts +285 -0
  278. package/dist/routes/public-share-links.d.ts.map +1 -0
  279. package/dist/routes/share-links.d.ts +1005 -0
  280. package/dist/routes/share-links.d.ts.map +1 -0
  281. package/dist/routes/test.d.ts +5 -0
  282. package/dist/routes/test.d.ts.map +1 -1
  283. package/dist/routes/workflows.d.ts.map +1 -1
  284. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  285. package/dist/types/app-manifest.d.ts +339 -0
  286. package/dist/types/app-manifest.d.ts.map +1 -0
  287. package/dist/types/app-store-listing.d.ts +34 -0
  288. package/dist/types/app-store-listing.d.ts.map +1 -1
  289. package/dist/types/consentflowcontract.d.ts +31 -4
  290. package/dist/types/consentflowcontract.d.ts.map +1 -1
  291. package/dist/types/credential-refresh.d.ts +1 -1
  292. package/esbuildPlugins.cjs +1 -0
  293. package/lambda.ts +49 -192
  294. package/lambdaApp.ts +203 -0
  295. package/package.json +25 -17
  296. package/scripts/export-openapi.ts +20 -0
  297. package/scripts/offline-schema-environment.ts +18 -0
  298. package/scripts/seed-demo-persona.ts +425 -0
  299. package/scripts/verify-python-contracts.ts +72 -0
  300. package/serverless-local.yml +3 -6
  301. package/serverless.function-env.cjs +50 -0
  302. package/serverless.local.yml +0 -1
  303. package/serverless.yml +44 -55
  304. package/shareLinkMaintenanceLambda.ts +18 -0
  305. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  306. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  307. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  308. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  309. package/src/accesslayer/app-store-listing/read.ts +6 -0
  310. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  311. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  312. package/src/accesslayer/app-store-listing/update.ts +15 -0
  313. package/src/accesslayer/boost/relationships/create.ts +8 -2
  314. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  315. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  316. package/src/accesslayer/consentflowcontract/relationships/create.ts +125 -109
  317. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  318. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  319. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  320. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  321. package/src/accesslayer/consentflowcontract/relationships/update.ts +255 -217
  322. package/src/accesslayer/credential/create.test.ts +2 -115
  323. package/src/accesslayer/credential/create.ts +11 -6
  324. package/src/accesslayer/credential/relationships/create.ts +17 -10
  325. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  326. package/src/accesslayer/inbox-credential/update.ts +4 -2
  327. package/src/accesslayer/presentation/read.ts +57 -25
  328. package/src/accesslayer/presentation/relationships/create.ts +54 -11
  329. package/src/accesslayer/presentation/relationships/read.ts +21 -6
  330. package/src/accesslayer/share-link/abandon.ts +120 -0
  331. package/src/accesslayer/share-link/attachment.ts +360 -0
  332. package/src/accesslayer/share-link/cleanup.ts +271 -0
  333. package/src/accesslayer/share-link/errors.ts +32 -0
  334. package/src/accesslayer/share-link/finalize.ts +313 -0
  335. package/src/accesslayer/share-link/helpers.ts +450 -0
  336. package/src/accesslayer/share-link/index.ts +11 -0
  337. package/src/accesslayer/share-link/list.ts +162 -0
  338. package/src/accesslayer/share-link/read.ts +107 -0
  339. package/src/accesslayer/share-link/receipt.ts +548 -0
  340. package/src/accesslayer/share-link/recovery.ts +565 -0
  341. package/src/accesslayer/share-link/reserve.ts +763 -0
  342. package/src/accesslayer/share-link/revoke.ts +232 -0
  343. package/src/accesslayer/share-link/transaction-execution.ts +72 -0
  344. package/src/accesslayer/share-link/transaction.ts +126 -0
  345. package/src/accesslayer/share-link/types.ts +606 -0
  346. package/src/app.ts +6 -0
  347. package/src/cache/app-store.caches.ts +35 -0
  348. package/src/config/applyDockerStageConfig.ts +6 -0
  349. package/src/config/environment.test.ts +17 -12
  350. package/src/config/environment.ts +15 -0
  351. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  352. package/src/config/stageConfig.test.ts +25 -0
  353. package/src/config/stageConfig.ts +24 -0
  354. package/src/constants/auth-grant.ts +5 -0
  355. package/src/docker-entry.ts +81 -0
  356. package/src/helpers/app-manifest.helpers.ts +185 -0
  357. package/src/helpers/boost.helpers.ts +4 -31
  358. package/src/helpers/claim-hooks.helpers.ts +20 -7
  359. package/src/helpers/consent-referral.helpers.ts +6 -0
  360. package/src/helpers/consent-scopes.helpers.ts +58 -0
  361. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  362. package/src/helpers/contract-events.helpers.ts +540 -0
  363. package/src/helpers/contract-requests.helpers.ts +211 -0
  364. package/src/helpers/credentialSubject.helpers.test.ts +114 -0
  365. package/src/helpers/credentialSubject.helpers.ts +40 -0
  366. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  367. package/src/helpers/inbox.helpers.ts +58 -70
  368. package/src/helpers/inline-template.helpers.ts +560 -0
  369. package/src/helpers/notificationMessages.ts +77 -1
  370. package/src/helpers/notificationQueue.helpers.ts +23 -5
  371. package/src/helpers/notifications.helpers.ts +25 -1
  372. package/src/helpers/presentation-metadata.ts +41 -0
  373. package/src/helpers/presentation.helpers.ts +27 -20
  374. package/src/helpers/profile.helpers.ts +13 -0
  375. package/src/helpers/share-content-client/adapters.ts +55 -0
  376. package/src/helpers/share-content-client/canonical.ts +25 -0
  377. package/src/helpers/share-content-client/client.ts +440 -0
  378. package/src/helpers/share-content-client/config.ts +189 -0
  379. package/src/helpers/share-content-client/index.ts +27 -0
  380. package/src/helpers/share-content-client/responses.ts +273 -0
  381. package/src/helpers/share-content-client/types.ts +253 -0
  382. package/src/helpers/share-link-attachment-access.ts +87 -0
  383. package/src/helpers/share-link-attachment.ts +115 -0
  384. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  385. package/src/helpers/share-link-coordinator/budget-helpers.ts +53 -0
  386. package/src/helpers/share-link-coordinator/cleanup-runner.ts +286 -0
  387. package/src/helpers/share-link-coordinator/coordinator.ts +727 -0
  388. package/src/helpers/share-link-coordinator/index.ts +8 -0
  389. package/src/helpers/share-link-coordinator/recovery-runner.ts +443 -0
  390. package/src/helpers/share-link-coordinator/runtime.ts +88 -0
  391. package/src/helpers/share-link-coordinator/stat.ts +30 -0
  392. package/src/helpers/share-link-coordinator/types.ts +353 -0
  393. package/src/helpers/share-link-lifecycle/index.ts +4 -0
  394. package/src/helpers/share-link-lifecycle/lease.helpers.ts +35 -0
  395. package/src/helpers/share-link-lifecycle/recovery.helpers.ts +173 -0
  396. package/src/helpers/share-link-lifecycle/request-hash.helpers.ts +130 -0
  397. package/src/helpers/share-link-lifecycle/types.ts +58 -0
  398. package/src/helpers/share-link-maintenance/budget.ts +63 -0
  399. package/src/helpers/share-link-maintenance/config.ts +312 -0
  400. package/src/helpers/share-link-maintenance/index.ts +24 -0
  401. package/src/helpers/share-link-maintenance/runner.ts +251 -0
  402. package/src/helpers/share-link-maintenance/runtime.ts +340 -0
  403. package/src/helpers/share-link-maintenance/scheduler.ts +126 -0
  404. package/src/helpers/share-link-maintenance/telemetry.ts +73 -0
  405. package/src/helpers/share-link-maintenance/types.ts +91 -0
  406. package/src/helpers/share-link-owner/config.ts +48 -0
  407. package/src/helpers/share-link-owner/diagnostics.ts +89 -0
  408. package/src/helpers/share-link-owner/lazy-initializer.ts +29 -0
  409. package/src/helpers/share-link-owner-projection.ts +54 -0
  410. package/src/helpers/share-link-passcode-abuse.ts +36 -0
  411. package/src/helpers/share-link-passcode.ts +47 -0
  412. package/src/helpers/share-link-policy/index.ts +7 -0
  413. package/src/helpers/share-link-policy/production.ts +108 -0
  414. package/src/helpers/share-link-policy/resolver.ts +113 -0
  415. package/src/helpers/share-link-policy/types.ts +56 -0
  416. package/src/helpers/share-link-public/config.ts +41 -0
  417. package/src/helpers/share-link-view-notification.ts +18 -0
  418. package/src/helpers/signingAuthority.helpers.ts +23 -14
  419. package/src/helpers/smartResume.helpers.ts +123 -0
  420. package/src/models/AppManifestVersion.ts +43 -0
  421. package/src/models/AppStoreListing.ts +32 -0
  422. package/src/models/ConsentFlowContract.ts +34 -3
  423. package/src/models/ConsentFlowTerms.ts +10 -0
  424. package/src/models/ConsentFlowTransaction.ts +3 -0
  425. package/src/models/Integration.ts +2 -0
  426. package/src/models/Presentation.ts +2 -2
  427. package/src/models/Profile.ts +3 -2
  428. package/src/models/ShareLink.ts +134 -0
  429. package/src/models/ShareViewReceipt.ts +69 -0
  430. package/src/models/index.ts +25 -0
  431. package/src/models/share-link-constraints.ts +98 -0
  432. package/src/openapi.ts +44 -8
  433. package/src/public-share-link-http.ts +49 -0
  434. package/src/routes/app-store.ts +1056 -38
  435. package/src/routes/boosts.ts +14 -25
  436. package/src/routes/contracts.ts +384 -273
  437. package/src/routes/inbox.ts +4 -2
  438. package/src/routes/index.ts +142 -44
  439. package/src/routes/presentations.ts +19 -3
  440. package/src/routes/profile-manager.ts +29 -2
  441. package/src/routes/profiles.ts +29 -6
  442. package/src/routes/public-share-links.ts +983 -0
  443. package/src/routes/share-links.ts +709 -0
  444. package/src/routes/workflows.ts +3 -2
  445. package/src/services/delivery/adapters/postmark.adapter.ts +4 -1
  446. package/src/types/app-manifest.ts +28 -0
  447. package/src/types/app-store-listing.ts +13 -0
  448. package/src/types/consentflowcontract.ts +25 -4
  449. package/test/app-manifest.helpers.spec.ts +170 -0
  450. package/test/app-manifest.routes.spec.ts +299 -0
  451. package/test/app-store-listing-updates.spec.ts +236 -0
  452. package/test/app-store-listing.spec.ts +65 -3
  453. package/test/consent-data-boundaries.spec.ts +352 -0
  454. package/test/consent-recipients.spec.ts +513 -0
  455. package/test/consent-scopes.helpers.spec.ts +67 -0
  456. package/test/consentflow.spec.ts +63 -2
  457. package/test/contract-requests.spec.ts +1588 -0
  458. package/test/credential-storage.neo4j.spec.ts +122 -0
  459. package/test/dids.spec.ts +10 -1
  460. package/test/guardian-gated-routes.spec.ts +49 -0
  461. package/test/helpers/share-link-fixtures.ts +94 -0
  462. package/test/inbox-security.e2e.ts +26 -10
  463. package/test/inbox.spec.ts +184 -8
  464. package/test/inline-template.helpers.spec.ts +49 -0
  465. package/test/managed-recipient-inbox.spec.ts +152 -0
  466. package/test/notificationMessages.spec.ts +13 -0
  467. package/test/openapi.spec.ts +14 -53
  468. package/test/presentations.spec.ts +54 -6
  469. package/test/profiles.spec.ts +97 -1
  470. package/test/share-content-client.contract.spec.ts +365 -0
  471. package/test/share-content-client.unit.spec.ts +462 -0
  472. package/test/share-link-abuse.unit.spec.ts +65 -0
  473. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  474. package/test/share-link-attachment.unit.spec.ts +244 -0
  475. package/test/share-link-coordinator.neo4j.spec.ts +684 -0
  476. package/test/share-link-coordinator.unit.spec.ts +990 -0
  477. package/test/share-link-dependency-diagnostics.unit.spec.ts +127 -0
  478. package/test/share-link-lifecycle.neo4j.spec.ts +1411 -0
  479. package/test/share-link-lifecycle.unit.spec.ts +205 -0
  480. package/test/share-link-list-cursor.unit.spec.ts +86 -0
  481. package/test/share-link-list.neo4j.spec.ts +324 -0
  482. package/test/share-link-maintenance.neo4j.spec.ts +355 -0
  483. package/test/share-link-maintenance.unit.spec.ts +1140 -0
  484. package/test/share-link-owner-policy.unit.spec.ts +210 -0
  485. package/test/share-link-owner-route.unit.spec.ts +820 -0
  486. package/test/share-link-owner-runtime.unit.spec.ts +102 -0
  487. package/test/share-link-passcode.unit.spec.ts +29 -0
  488. package/test/share-link-policy.unit.spec.ts +149 -0
  489. package/test/share-link-presentation-metadata.unit.spec.ts +49 -0
  490. package/test/share-link-production-policy.unit.spec.ts +126 -0
  491. package/test/share-link-public-config.unit.spec.ts +64 -0
  492. package/test/share-link-public-http-adapter.unit.spec.ts +315 -0
  493. package/test/share-link-public-route.unit.spec.ts +1079 -0
  494. package/test/share-link-receipt.neo4j.spec.ts +943 -0
  495. package/test/share-link-recovery.neo4j.spec.ts +935 -0
  496. package/test/share-link-recovery.unit.spec.ts +726 -0
  497. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  498. package/test/signing-authority-encryption.spec.ts +75 -0
  499. package/test/smart-resume-consent.spec.ts +233 -0
  500. package/test/smart-resume-upload.spec.ts +65 -0
  501. package/tsconfig.build.json +1 -1
  502. package/tsconfig.json +1 -0
  503. package/vitest.config.ts +14 -0
  504. package/vitest.inbox-e2e.config.ts +2 -0
  505. package/vitest.share-links-neo4j.config.ts +19 -0
  506. package/vitest.share-links-unit.config.ts +28 -0
  507. package/workerLambda.test.ts +100 -0
@@ -0,0 +1,1411 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ import {
4
+ consumeShareViewReceipt,
5
+ persistShareViewReceipt,
6
+ } from '../src/accesslayer/share-link/receipt';
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
9
+ import { v4 as uuid } from 'uuid';
10
+
11
+ import { computeShareLinkRequestHash } from '@helpers/share-link-lifecycle';
12
+ import { neogma } from '@instance';
13
+ import { withShareLinkRead } from '../src/accesslayer/share-link/transaction';
14
+
15
+ import {
16
+ abandonReservation,
17
+ claimCleanupJobs,
18
+ completeCleanupJob,
19
+ finalizeReservation,
20
+ getCleanupJob,
21
+ getCurrentShareContent,
22
+ getShareLink,
23
+ isShareLinkRepositoryError,
24
+ listCleanupJobsForShare,
25
+ reserveCreate,
26
+ reserveReplacement,
27
+ revokeShareLink,
28
+ } from '../src/accesslayer/share-link';
29
+ import { ensureShareLinkConstraints } from '../src/models/share-link-constraints';
30
+ import { toOwnerShareLink } from '../src/helpers/share-link-owner-projection';
31
+ import type { ShareLinkPolicySnapshot } from '../src/helpers/share-link-policy/types';
32
+ import {
33
+ createProductionShareLinkPolicySource,
34
+ productionShareViewEligibilitySource,
35
+ resolveCurrentShareLinkPolicy,
36
+ } from '../src/helpers/share-link-policy/production';
37
+ import { createShareLinkPolicyResolver } from '../src/helpers/share-link-policy/resolver';
38
+ import type { ShareLinkRecord } from '../src/models/ShareLink';
39
+
40
+ import {
41
+ nextShareId,
42
+ contentBinding,
43
+ clearLifecycleGraph,
44
+ reserveShareFixture,
45
+ type ReservedCreate,
46
+ } from './helpers/share-link-fixtures';
47
+
48
+ const NAMESPACE = 'localhost%3A3000';
49
+ const OTHER_NAMESPACE = 'other.example';
50
+ const NOW = new Date('2026-09-20T12:00:00.000Z');
51
+
52
+ const toNumber = (value: unknown): number => {
53
+ if (typeof value === 'number') return value;
54
+ if (value && typeof value === 'object') {
55
+ const maybeInteger = value as { toNumber?: () => number };
56
+ if (typeof maybeInteger.toNumber === 'function') return maybeInteger.toNumber();
57
+ }
58
+ return Number.NaN;
59
+ };
60
+
61
+ const countNodes = async (label: string, key: string, value: string): Promise<number> => {
62
+ const result = await neogma.queryRunner.run(
63
+ `MATCH (n:${label} {${key}: $value}) RETURN count(n) AS total`,
64
+ { value }
65
+ );
66
+
67
+ return toNumber(result.records[0]?.get('total'));
68
+ };
69
+
70
+ const reserveNewShare = async (options?: {
71
+ ownerProfileId?: string;
72
+ shareId?: string;
73
+ expiresAt?: string | null;
74
+ selectedCount?: number;
75
+ now?: Date;
76
+ clientRequestId?: string;
77
+ requestHash?: string;
78
+ namespace?: string;
79
+ }): Promise<ReservedCreate> => {
80
+ return reserveShareFixture({
81
+ ...options,
82
+ namespace: options?.namespace ?? NAMESPACE,
83
+ ownerProfileId: options?.ownerProfileId ?? `owner-${uuid()}`,
84
+ now: options?.now ?? NOW,
85
+ });
86
+ };
87
+ const commitNewShare = async (options?: Parameters<typeof reserveNewShare>[0]) => {
88
+ const created = await reserveNewShare(options);
89
+
90
+ const finalized = await finalizeReservation({
91
+ namespace: created.namespace,
92
+ ownerProfileId: created.ownerProfileId,
93
+ shareId: created.shareId,
94
+ operationId: created.operationId,
95
+ objectRef: created.objectRef,
96
+ generation: created.generation,
97
+ leaseOwner: created.leaseOwner,
98
+ now: options?.now ?? NOW,
99
+ });
100
+
101
+ if (finalized.outcome !== 'finalized') {
102
+ throw new Error(`expected finalized create, got ${finalized.outcome}`);
103
+ }
104
+
105
+ return { ...created, committed: finalized.share };
106
+ };
107
+
108
+ const replacementHash = (shareId: string, seed: string): string =>
109
+ computeShareLinkRequestHash('update', {
110
+ id: shareId,
111
+ ...contentBinding(seed),
112
+ contentVersion: 2,
113
+ selectedCount: 1,
114
+ });
115
+
116
+ describe('share-link lifecycle repository (Neo4j)', () => {
117
+ beforeAll(async () => {
118
+ await ensureShareLinkConstraints();
119
+ });
120
+
121
+ beforeEach(async () => {
122
+ await clearLifecycleGraph();
123
+ });
124
+
125
+ afterAll(async () => {
126
+ await clearLifecycleGraph();
127
+ });
128
+
129
+ it('reserves, finalizes and serves one committed content reference', async () => {
130
+ const created = await reserveNewShare();
131
+
132
+ const staging = await getCurrentShareContent({ shareId: created.shareId, now: NOW });
133
+ expect(staging.state).toBe('not_active');
134
+
135
+ const finalized = await finalizeReservation({
136
+ namespace: created.namespace,
137
+ ownerProfileId: created.ownerProfileId,
138
+ shareId: created.shareId,
139
+ operationId: created.operationId,
140
+ objectRef: created.objectRef,
141
+ generation: created.generation,
142
+ leaseOwner: created.leaseOwner,
143
+ now: NOW,
144
+ });
145
+
146
+ expect(finalized.outcome).toBe('finalized');
147
+ if (finalized.outcome !== 'finalized') return;
148
+
149
+ expect(finalized.share.status).toBe('active');
150
+ expect(finalized.share.contentState).toBe('finalized');
151
+ expect(finalized.share.activeObjectRef).toBe(created.objectRef);
152
+ expect(finalized.share.contentVersion).toBe(1);
153
+ expect(finalized.share.version).toBe(2);
154
+ expect(finalized.cleanupQueuedFor).toBeNull();
155
+
156
+ const current = await getCurrentShareContent({ shareId: created.shareId, now: NOW });
157
+ expect(current.state).toBe('active');
158
+ if (current.state === 'active') {
159
+ expect(current.objectRef).toBe(created.objectRef);
160
+ expect(current.contentVersion).toBe(1);
161
+ }
162
+
163
+ const repeat = await finalizeReservation({
164
+ namespace: created.namespace,
165
+ ownerProfileId: created.ownerProfileId,
166
+ shareId: created.shareId,
167
+ operationId: created.operationId,
168
+ objectRef: created.objectRef,
169
+ generation: created.generation,
170
+ leaseOwner: created.leaseOwner,
171
+ now: NOW,
172
+ });
173
+ expect(repeat.outcome).toBe('already_finalized');
174
+ });
175
+
176
+ it('resumes an in-flight create with the same request and rejects a different input', async () => {
177
+ const created = await reserveNewShare();
178
+
179
+ const resumed = await reserveCreate({
180
+ namespace: created.namespace,
181
+ ownerProfileId: created.ownerProfileId,
182
+ clientRequestId: created.clientRequestId,
183
+ shareId: created.shareId,
184
+ title: 'Shared credentials',
185
+ note: null,
186
+ expiresAt: null,
187
+ selectedCount: 1,
188
+ content: contentBinding(created.shareId),
189
+ requestHash: created.requestHash,
190
+ leaseOwner: 'worker-2',
191
+ now: new Date(NOW.getTime() + 1000),
192
+ });
193
+
194
+ expect(resumed.outcome).toBe('reserved');
195
+ if (resumed.outcome === 'reserved') {
196
+ expect(resumed.state).toBe('resumed');
197
+ expect(resumed.reservation.objectRef).toBe(created.objectRef);
198
+ expect(resumed.reservation.operationId).toBe(created.operationId);
199
+ }
200
+
201
+ await expect(
202
+ reserveCreate({
203
+ namespace: created.namespace,
204
+ ownerProfileId: created.ownerProfileId,
205
+ clientRequestId: created.clientRequestId,
206
+ shareId: created.shareId,
207
+ title: 'Different title',
208
+ note: null,
209
+ expiresAt: null,
210
+ selectedCount: 1,
211
+ content: contentBinding(created.shareId),
212
+ requestHash: computeShareLinkRequestHash('create', {
213
+ id: created.shareId,
214
+ title: 'Different title',
215
+ }),
216
+ leaseOwner: 'worker-2',
217
+ now: NOW,
218
+ })
219
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
220
+ });
221
+
222
+ it('returns the recorded create result without rolling back a newer update', async () => {
223
+ const created = await commitNewShare();
224
+
225
+ const updateClientRequestId = uuid();
226
+ const updateHash = replacementHash(created.shareId, created.shareId);
227
+ const reserved = await reserveReplacement({
228
+ namespace: created.namespace,
229
+ ownerProfileId: created.ownerProfileId,
230
+ clientRequestId: updateClientRequestId,
231
+ shareId: created.shareId,
232
+ expectedVersion: created.committed.version,
233
+ requestHash: updateHash,
234
+ leaseOwner: 'worker-1',
235
+ now: NOW,
236
+ content: {
237
+ ...contentBinding(`${created.shareId}-v2`),
238
+ contentVersion: 2,
239
+ selectedCount: 1,
240
+ },
241
+ });
242
+
243
+ expect(reserved.outcome).toBe('reserved');
244
+ if (reserved.outcome !== 'reserved' || !reserved.reservation.objectRef) return;
245
+
246
+ const finalized = await finalizeReservation({
247
+ namespace: created.namespace,
248
+ ownerProfileId: created.ownerProfileId,
249
+ shareId: created.shareId,
250
+ operationId: reserved.reservation.operationId,
251
+ objectRef: reserved.reservation.objectRef,
252
+ generation: reserved.reservation.generation,
253
+ leaseOwner: reserved.reservation.leaseOwner,
254
+ now: NOW,
255
+ });
256
+ expect(finalized.outcome).toBe('finalized');
257
+ if (finalized.outcome === 'finalized') {
258
+ expect(finalized.share.version).toBe(3);
259
+ expect(finalized.share.contentVersion).toBe(2);
260
+ }
261
+
262
+ const replay = await reserveCreate({
263
+ namespace: created.namespace,
264
+ ownerProfileId: created.ownerProfileId,
265
+ clientRequestId: created.clientRequestId,
266
+ shareId: created.shareId,
267
+ title: 'Shared credentials',
268
+ note: null,
269
+ expiresAt: null,
270
+ selectedCount: 1,
271
+ content: contentBinding(created.shareId),
272
+ requestHash: created.requestHash,
273
+ leaseOwner: 'worker-9',
274
+ now: NOW,
275
+ });
276
+
277
+ expect(replay.outcome).toBe('already_committed');
278
+ if (replay.outcome === 'already_committed') {
279
+ expect(replay.recorded.version).toBe(2);
280
+ expect(replay.current.version).toBe(3);
281
+ expect(replay.current.contentVersion).toBe(2);
282
+ }
283
+
284
+ const share = await getShareLink({ shareId: created.shareId });
285
+ expect(share?.version).toBe(3);
286
+ expect(share?.contentVersion).toBe(2);
287
+ });
288
+
289
+ it('allows only one in-flight reservation for concurrent same-version updates', async () => {
290
+ const created = await commitNewShare();
291
+
292
+ const makeInput = (seed: string) => ({
293
+ namespace: created.namespace,
294
+ ownerProfileId: created.ownerProfileId,
295
+ clientRequestId: uuid(),
296
+ shareId: created.shareId,
297
+ expectedVersion: created.committed.version,
298
+ requestHash: replacementHash(created.shareId, seed),
299
+ leaseOwner: `worker-${seed}`,
300
+ now: NOW,
301
+ content: {
302
+ ...contentBinding(seed),
303
+ contentVersion: 2,
304
+ selectedCount: 1,
305
+ },
306
+ });
307
+
308
+ const results = await Promise.allSettled([
309
+ reserveReplacement(makeInput('a')),
310
+ reserveReplacement(makeInput('b')),
311
+ ]);
312
+
313
+ const fulfilled = results.filter(result => result.status === 'fulfilled');
314
+ const rejected = results.filter(result => result.status === 'rejected');
315
+
316
+ expect(fulfilled).toHaveLength(1);
317
+ expect(rejected).toHaveLength(1);
318
+
319
+ const reason = (rejected[0] as PromiseRejectedResult).reason;
320
+ expect(isShareLinkRepositoryError(reason) && reason.code).toBe('OPERATION_IN_FLIGHT');
321
+ expect(await countNodes('ShareLinkReservation', 'shareId', created.shareId)).toBe(1);
322
+ });
323
+
324
+ it('expires a lease and fences a stale finalize while a newer reservation takes over', async () => {
325
+ const created = await commitNewShare();
326
+ const later = new Date(NOW.getTime() + 2000);
327
+
328
+ const first = await reserveReplacement({
329
+ namespace: created.namespace,
330
+ ownerProfileId: created.ownerProfileId,
331
+ clientRequestId: uuid(),
332
+ shareId: created.shareId,
333
+ expectedVersion: created.committed.version,
334
+ requestHash: replacementHash(created.shareId, 'first'),
335
+ leaseOwner: 'stale-worker',
336
+ leaseMs: 1000,
337
+ now: NOW,
338
+ content: { ...contentBinding('first'), contentVersion: 2, selectedCount: 1 },
339
+ });
340
+ expect(first.outcome).toBe('reserved');
341
+ if (first.outcome !== 'reserved' || !first.reservation.objectRef) return;
342
+
343
+ await expect(
344
+ finalizeReservation({
345
+ namespace: created.namespace,
346
+ ownerProfileId: created.ownerProfileId,
347
+ shareId: created.shareId,
348
+ operationId: first.reservation.operationId,
349
+ objectRef: first.reservation.objectRef,
350
+ generation: first.reservation.generation,
351
+ leaseOwner: first.reservation.leaseOwner,
352
+ now: later,
353
+ })
354
+ ).rejects.toMatchObject({ code: 'LEASE_EXPIRED' });
355
+
356
+ const second = await reserveReplacement({
357
+ namespace: created.namespace,
358
+ ownerProfileId: created.ownerProfileId,
359
+ clientRequestId: uuid(),
360
+ shareId: created.shareId,
361
+ expectedVersion: created.committed.version,
362
+ requestHash: replacementHash(created.shareId, 'second'),
363
+ leaseOwner: 'fresh-worker',
364
+ now: later,
365
+ content: { ...contentBinding('second'), contentVersion: 2, selectedCount: 1 },
366
+ });
367
+ expect(second.outcome).toBe('reserved');
368
+ if (second.outcome !== 'reserved' || !second.reservation.objectRef) return;
369
+
370
+ expect(second.reservation.generation).toBe(first.reservation.generation + 1);
371
+ expect(second.reservation.objectRef).not.toBe(first.reservation.objectRef);
372
+
373
+ await expect(
374
+ finalizeReservation({
375
+ namespace: created.namespace,
376
+ ownerProfileId: created.ownerProfileId,
377
+ shareId: created.shareId,
378
+ operationId: first.reservation.operationId,
379
+ objectRef: first.reservation.objectRef,
380
+ generation: first.reservation.generation,
381
+ leaseOwner: first.reservation.leaseOwner,
382
+ now: later,
383
+ })
384
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
385
+
386
+ const finalized = await finalizeReservation({
387
+ namespace: created.namespace,
388
+ ownerProfileId: created.ownerProfileId,
389
+ shareId: created.shareId,
390
+ operationId: second.reservation.operationId,
391
+ objectRef: second.reservation.objectRef,
392
+ generation: second.reservation.generation,
393
+ leaseOwner: second.reservation.leaseOwner,
394
+ now: later,
395
+ });
396
+ expect(finalized.outcome).toBe('finalized');
397
+ if (finalized.outcome === 'finalized') {
398
+ expect(finalized.share.activeObjectRef).toBe(second.reservation.objectRef);
399
+ }
400
+ });
401
+
402
+ it('resumes a crashed replacement reservation instead of allocating a second object', async () => {
403
+ const created = await commitNewShare();
404
+ const clientRequestId = uuid();
405
+ const requestHash = replacementHash(created.shareId, 'resume');
406
+
407
+ const first = await reserveReplacement({
408
+ namespace: created.namespace,
409
+ ownerProfileId: created.ownerProfileId,
410
+ clientRequestId,
411
+ shareId: created.shareId,
412
+ expectedVersion: created.committed.version,
413
+ requestHash,
414
+ leaseOwner: 'worker-1',
415
+ now: NOW,
416
+ content: { ...contentBinding('resume'), contentVersion: 2, selectedCount: 1 },
417
+ });
418
+ expect(first.outcome).toBe('reserved');
419
+ if (first.outcome !== 'reserved' || !first.reservation.objectRef) return;
420
+
421
+ const second = await reserveReplacement({
422
+ namespace: created.namespace,
423
+ ownerProfileId: created.ownerProfileId,
424
+ clientRequestId,
425
+ shareId: created.shareId,
426
+ expectedVersion: created.committed.version,
427
+ requestHash,
428
+ leaseOwner: 'worker-1',
429
+ now: new Date(NOW.getTime() + 500),
430
+ content: { ...contentBinding('resume'), contentVersion: 2, selectedCount: 1 },
431
+ });
432
+
433
+ expect(second.outcome).toBe('reserved');
434
+ if (second.outcome === 'reserved') {
435
+ expect(second.state).toBe('resumed');
436
+ expect(second.reservation.objectRef).toBe(first.reservation.objectRef);
437
+ expect(second.reservation.operationId).toBe(first.reservation.operationId);
438
+ }
439
+ expect(await countNodes('ShareLinkReservation', 'shareId', created.shareId)).toBe(1);
440
+ });
441
+
442
+ it('rejects a late finalize after the reservation is abandoned and never reuses the object id', async () => {
443
+ const created = await commitNewShare();
444
+ const clientRequestId = uuid();
445
+ const requestHash = replacementHash(created.shareId, 'abandon');
446
+
447
+ const staged = await reserveReplacement({
448
+ namespace: created.namespace,
449
+ ownerProfileId: created.ownerProfileId,
450
+ clientRequestId,
451
+ shareId: created.shareId,
452
+ expectedVersion: created.committed.version,
453
+ requestHash,
454
+ leaseOwner: 'worker-1',
455
+ now: NOW,
456
+ content: { ...contentBinding('abandon'), contentVersion: 2, selectedCount: 1 },
457
+ });
458
+ expect(staged.outcome).toBe('reserved');
459
+ if (staged.outcome !== 'reserved' || !staged.reservation.objectRef) return;
460
+
461
+ const abandoned = await abandonReservation({
462
+ namespace: created.namespace,
463
+ ownerProfileId: created.ownerProfileId,
464
+ shareId: created.shareId,
465
+ operationId: staged.reservation.operationId,
466
+ generation: staged.reservation.generation,
467
+ leaseOwner: staged.reservation.leaseOwner,
468
+ now: NOW,
469
+ });
470
+ expect(abandoned.outcome).toBe('abandoned');
471
+ if (abandoned.outcome === 'abandoned') {
472
+ expect(abandoned.cleanupQueuedFor).toBe(staged.reservation.objectRef);
473
+ }
474
+
475
+ await expect(
476
+ finalizeReservation({
477
+ namespace: created.namespace,
478
+ ownerProfileId: created.ownerProfileId,
479
+ shareId: created.shareId,
480
+ operationId: staged.reservation.operationId,
481
+ objectRef: staged.reservation.objectRef,
482
+ generation: staged.reservation.generation,
483
+ leaseOwner: staged.reservation.leaseOwner,
484
+ now: NOW,
485
+ })
486
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
487
+
488
+ const redriven = await reserveReplacement({
489
+ namespace: created.namespace,
490
+ ownerProfileId: created.ownerProfileId,
491
+ clientRequestId,
492
+ shareId: created.shareId,
493
+ expectedVersion: created.committed.version,
494
+ requestHash,
495
+ leaseOwner: 'worker-2',
496
+ now: NOW,
497
+ content: { ...contentBinding('abandon'), contentVersion: 2, selectedCount: 1 },
498
+ });
499
+ expect(redriven.outcome).toBe('reserved');
500
+ if (redriven.outcome !== 'reserved' || !redriven.reservation.objectRef) return;
501
+ expect(redriven.reservation.objectRef).not.toBe(staged.reservation.objectRef);
502
+
503
+ const finalized = await finalizeReservation({
504
+ namespace: created.namespace,
505
+ ownerProfileId: created.ownerProfileId,
506
+ shareId: created.shareId,
507
+ operationId: redriven.reservation.operationId,
508
+ objectRef: redriven.reservation.objectRef,
509
+ generation: redriven.reservation.generation,
510
+ leaseOwner: redriven.reservation.leaseOwner,
511
+ now: NOW,
512
+ });
513
+ expect(finalized.outcome).toBe('finalized');
514
+ });
515
+
516
+ it('serializes revoke with finalize and stops idempotently', async () => {
517
+ const created = await commitNewShare();
518
+ const staged = await reserveReplacement({
519
+ namespace: created.namespace,
520
+ ownerProfileId: created.ownerProfileId,
521
+ clientRequestId: uuid(),
522
+ shareId: created.shareId,
523
+ expectedVersion: created.committed.version,
524
+ requestHash: replacementHash(created.shareId, 'revoke'),
525
+ leaseOwner: 'worker-1',
526
+ now: NOW,
527
+ content: { ...contentBinding('revoke'), contentVersion: 2, selectedCount: 1 },
528
+ });
529
+ expect(staged.outcome).toBe('reserved');
530
+ if (staged.outcome !== 'reserved' || !staged.reservation.objectRef) return;
531
+
532
+ const revokeClientRequestId = uuid();
533
+ const revokeHash = computeShareLinkRequestHash('revoke', { id: created.shareId });
534
+
535
+ const revoked = await revokeShareLink({
536
+ namespace: created.namespace,
537
+ ownerProfileId: created.ownerProfileId,
538
+ shareId: created.shareId,
539
+ expectedVersion: created.committed.version,
540
+ clientRequestId: revokeClientRequestId,
541
+ requestHash: revokeHash,
542
+ now: NOW,
543
+ });
544
+ expect(revoked.outcome).toBe('revoked');
545
+ expect(revoked.share.status).toBe('stopped');
546
+ expect(revoked.cleanupQueuedFor).toEqual(
547
+ expect.arrayContaining([created.objectRef, staged.reservation.objectRef])
548
+ );
549
+
550
+ await expect(
551
+ finalizeReservation({
552
+ namespace: created.namespace,
553
+ ownerProfileId: created.ownerProfileId,
554
+ shareId: created.shareId,
555
+ operationId: staged.reservation.operationId,
556
+ objectRef: staged.reservation.objectRef,
557
+ generation: staged.reservation.generation,
558
+ leaseOwner: staged.reservation.leaseOwner,
559
+ now: NOW,
560
+ })
561
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
562
+
563
+ const retry = await revokeShareLink({
564
+ namespace: created.namespace,
565
+ ownerProfileId: created.ownerProfileId,
566
+ shareId: created.shareId,
567
+ expectedVersion: created.committed.version,
568
+ clientRequestId: revokeClientRequestId,
569
+ requestHash: revokeHash,
570
+ now: NOW,
571
+ });
572
+ expect(retry.outcome).toBe('already_stopped');
573
+ expect((await getCurrentShareContent({ shareId: created.shareId, now: NOW })).state).toBe(
574
+ 'not_active'
575
+ );
576
+ });
577
+
578
+ it('enqueues the superseded object cleanup in the same transaction as the swap', async () => {
579
+ const created = await commitNewShare();
580
+ const oldObjectRef = created.objectRef;
581
+
582
+ const staged = await reserveReplacement({
583
+ namespace: created.namespace,
584
+ ownerProfileId: created.ownerProfileId,
585
+ clientRequestId: uuid(),
586
+ shareId: created.shareId,
587
+ expectedVersion: created.committed.version,
588
+ requestHash: replacementHash(created.shareId, 'swap'),
589
+ leaseOwner: 'worker-1',
590
+ now: NOW,
591
+ content: { ...contentBinding('swap'), contentVersion: 2, selectedCount: 1 },
592
+ });
593
+ expect(staged.outcome).toBe('reserved');
594
+ if (staged.outcome !== 'reserved' || !staged.reservation.objectRef) return;
595
+
596
+ await expect(
597
+ finalizeReservation({
598
+ namespace: created.namespace,
599
+ ownerProfileId: created.ownerProfileId,
600
+ shareId: created.shareId,
601
+ operationId: staged.reservation.operationId,
602
+ objectRef: staged.reservation.objectRef,
603
+ generation: staged.reservation.generation,
604
+ leaseOwner: staged.reservation.leaseOwner,
605
+ verifiedContentHash: '0'.repeat(64),
606
+ now: NOW,
607
+ })
608
+ ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
609
+
610
+ expect(await getCleanupJob(oldObjectRef)).toBeNull();
611
+
612
+ const before = await getShareLink({ shareId: created.shareId });
613
+ expect(before?.activeObjectRef).toBe(oldObjectRef);
614
+ expect(before?.version).toBe(2);
615
+
616
+ const finalized = await finalizeReservation({
617
+ namespace: created.namespace,
618
+ ownerProfileId: created.ownerProfileId,
619
+ shareId: created.shareId,
620
+ operationId: staged.reservation.operationId,
621
+ objectRef: staged.reservation.objectRef,
622
+ generation: staged.reservation.generation,
623
+ leaseOwner: staged.reservation.leaseOwner,
624
+ now: NOW,
625
+ });
626
+ expect(finalized.outcome).toBe('finalized');
627
+ if (finalized.outcome !== 'finalized') return;
628
+
629
+ expect(finalized.cleanupQueuedFor).toBe(oldObjectRef);
630
+ expect(finalized.share.activeObjectRef).toBe(staged.reservation.objectRef);
631
+
632
+ const job = await getCleanupJob(oldObjectRef);
633
+ expect(job).toMatchObject({
634
+ status: 'queued',
635
+ reason: 'superseded',
636
+ shareId: created.shareId,
637
+ namespace: created.namespace,
638
+ ownerProfileId: created.ownerProfileId,
639
+ });
640
+ expect(
641
+ (await listCleanupJobsForShare(created.shareId)).map(entry => entry.objectRef)
642
+ ).toEqual([oldObjectRef]);
643
+ });
644
+
645
+ it('fails closed for a wrong namespace or owner across every operation', async () => {
646
+ const created = await commitNewShare();
647
+
648
+ await expect(
649
+ reserveReplacement({
650
+ namespace: created.namespace,
651
+ ownerProfileId: 'someone-else',
652
+ clientRequestId: uuid(),
653
+ shareId: created.shareId,
654
+ expectedVersion: 2,
655
+ requestHash: replacementHash(created.shareId, 'foreign'),
656
+ leaseOwner: 'worker-1',
657
+ now: NOW,
658
+ content: { ...contentBinding('foreign'), contentVersion: 2, selectedCount: 1 },
659
+ })
660
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
661
+
662
+ await expect(
663
+ finalizeReservation({
664
+ namespace: created.namespace,
665
+ ownerProfileId: 'someone-else',
666
+ shareId: created.shareId,
667
+ operationId: created.operationId,
668
+ objectRef: created.objectRef,
669
+ generation: created.generation,
670
+ leaseOwner: created.leaseOwner,
671
+ now: NOW,
672
+ })
673
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
674
+
675
+ await expect(
676
+ revokeShareLink({
677
+ namespace: OTHER_NAMESPACE,
678
+ ownerProfileId: created.ownerProfileId,
679
+ shareId: created.shareId,
680
+ now: NOW,
681
+ })
682
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
683
+
684
+ await expect(
685
+ abandonReservation({
686
+ namespace: created.namespace,
687
+ ownerProfileId: 'someone-else',
688
+ shareId: created.shareId,
689
+ operationId: created.operationId,
690
+ now: NOW,
691
+ })
692
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
693
+
694
+ expect(
695
+ await getShareLink({ shareId: created.shareId, ownerProfileId: 'someone-else' })
696
+ ).toBeNull();
697
+ expect(
698
+ await getShareLink({ shareId: created.shareId, namespace: OTHER_NAMESPACE })
699
+ ).toBeNull();
700
+ expect(
701
+ (
702
+ await getCurrentShareContent({
703
+ shareId: created.shareId,
704
+ ownerProfileId: 'someone-else',
705
+ now: NOW,
706
+ })
707
+ ).state
708
+ ).toBe('missing');
709
+ });
710
+
711
+ it('retains expired bytes and permits a metadata-only expiry extension', async () => {
712
+ const created = await commitNewShare({ expiresAt: NOW.toISOString() });
713
+ const afterExpiry = new Date(NOW.getTime() + 1000);
714
+
715
+ const expired = await getCurrentShareContent({
716
+ shareId: created.shareId,
717
+ now: afterExpiry,
718
+ });
719
+ expect(expired.state).toBe('not_active');
720
+ if (expired.state === 'not_active') expect(expired.reason).toBe('expired');
721
+
722
+ const retained = await getShareLink({ shareId: created.shareId });
723
+ expect(retained?.activeObjectRef).toBe(created.objectRef);
724
+
725
+ const futureExpiry = new Date(NOW.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString();
726
+ const extensionHash = computeShareLinkRequestHash('update', {
727
+ id: created.shareId,
728
+ expiresAt: futureExpiry,
729
+ });
730
+ const reserved = await reserveReplacement({
731
+ namespace: created.namespace,
732
+ ownerProfileId: created.ownerProfileId,
733
+ clientRequestId: uuid(),
734
+ shareId: created.shareId,
735
+ expectedVersion: created.committed.version,
736
+ requestHash: extensionHash,
737
+ leaseOwner: 'worker-1',
738
+ now: NOW,
739
+ expiresAt: futureExpiry,
740
+ });
741
+ expect(reserved.outcome).toBe('reserved');
742
+ if (reserved.outcome !== 'reserved') return;
743
+ expect(reserved.reservation.objectRef).toBeNull();
744
+
745
+ const finalized = await finalizeReservation({
746
+ namespace: created.namespace,
747
+ ownerProfileId: created.ownerProfileId,
748
+ shareId: created.shareId,
749
+ operationId: reserved.reservation.operationId,
750
+ objectRef: null,
751
+ generation: reserved.reservation.generation,
752
+ leaseOwner: reserved.reservation.leaseOwner,
753
+ now: NOW,
754
+ });
755
+ expect(finalized.outcome).toBe('finalized');
756
+ if (finalized.outcome === 'finalized') {
757
+ expect(finalized.share.contentVersion).toBe(1);
758
+ expect(finalized.share.activeObjectRef).toBe(created.objectRef);
759
+ expect(finalized.share.expiresAt).toBe(futureExpiry);
760
+ }
761
+
762
+ const active = await getCurrentShareContent({ shareId: created.shareId, now: afterExpiry });
763
+ expect(active.state).toBe('active');
764
+ });
765
+
766
+ it('replaces, preserves, and removes share protection settings', async () => {
767
+ const created = await commitNewShare();
768
+ const policy: ShareLinkPolicySnapshot = {
769
+ isMinor: false,
770
+ policyResolved: true,
771
+ defaultExpiryDays: 365,
772
+ viewCountingEnabled: true,
773
+ };
774
+
775
+ const protect = await reserveReplacement({
776
+ namespace: created.namespace,
777
+ ownerProfileId: created.ownerProfileId,
778
+ clientRequestId: uuid(),
779
+ shareId: created.shareId,
780
+ expectedVersion: created.committed.version,
781
+ requestHash: computeShareLinkRequestHash('update', {
782
+ id: created.shareId,
783
+ passcode: 'replacement',
784
+ notifyOnView: true,
785
+ }),
786
+ passcodeHash: '$argon2id$replacement-hash',
787
+ notifyOnView: true,
788
+ policy,
789
+ leaseOwner: 'worker-1',
790
+ now: NOW,
791
+ });
792
+ if (protect.outcome !== 'reserved') throw new Error('expected protection reservation');
793
+
794
+ const protectedShare = await finalizeReservation({
795
+ ...protect.reservation,
796
+ resolveCurrentPolicy: async () => ({ ...policy, isServiceProfile: false }),
797
+ now: NOW,
798
+ });
799
+ if (protectedShare.outcome !== 'finalized') throw new Error('expected protected share');
800
+ expect(protectedShare.share.passcodeHash).toBe('$argon2id$replacement-hash');
801
+ expect(protectedShare.share.notifyOnView).toBe(true);
802
+
803
+ const preserve = await reserveReplacement({
804
+ namespace: created.namespace,
805
+ ownerProfileId: created.ownerProfileId,
806
+ clientRequestId: uuid(),
807
+ shareId: created.shareId,
808
+ expectedVersion: protectedShare.share.version,
809
+ requestHash: computeShareLinkRequestHash('update', {
810
+ id: created.shareId,
811
+ title: 'Still protected',
812
+ }),
813
+ title: 'Still protected',
814
+ policy,
815
+ leaseOwner: 'worker-1',
816
+ now: NOW,
817
+ });
818
+ if (preserve.outcome !== 'reserved') throw new Error('expected preserve reservation');
819
+
820
+ const preservedShare = await finalizeReservation({
821
+ ...preserve.reservation,
822
+ now: NOW,
823
+ });
824
+ if (preservedShare.outcome !== 'finalized') throw new Error('expected preserved share');
825
+ expect(preservedShare.share.passcodeHash).toBe('$argon2id$replacement-hash');
826
+ expect(preservedShare.share.notifyOnView).toBe(true);
827
+
828
+ const remove = await reserveReplacement({
829
+ namespace: created.namespace,
830
+ ownerProfileId: created.ownerProfileId,
831
+ clientRequestId: uuid(),
832
+ shareId: created.shareId,
833
+ expectedVersion: preservedShare.share.version,
834
+ requestHash: computeShareLinkRequestHash('update', {
835
+ id: created.shareId,
836
+ passcode: null,
837
+ notifyOnView: false,
838
+ }),
839
+ passcodeHash: null,
840
+ notifyOnView: false,
841
+ policy,
842
+ leaseOwner: 'worker-1',
843
+ now: NOW,
844
+ });
845
+ if (remove.outcome !== 'reserved') throw new Error('expected removal reservation');
846
+
847
+ const unprotectedShare = await finalizeReservation({
848
+ ...remove.reservation,
849
+ now: NOW,
850
+ });
851
+ if (unprotectedShare.outcome !== 'finalized') throw new Error('expected unprotected share');
852
+ expect(unprotectedShare.share.passcodeHash).toBeNull();
853
+ expect(unprotectedShare.share.notifyOnView).toBe(false);
854
+ });
855
+
856
+ it('claims, completes and backs off durable cleanup jobs with a fenced claim token', async () => {
857
+ const created = await commitNewShare();
858
+ const staged = await reserveReplacement({
859
+ namespace: created.namespace,
860
+ ownerProfileId: created.ownerProfileId,
861
+ clientRequestId: uuid(),
862
+ shareId: created.shareId,
863
+ expectedVersion: created.committed.version,
864
+ requestHash: replacementHash(created.shareId, 'cleanup'),
865
+ leaseOwner: 'worker-1',
866
+ now: NOW,
867
+ content: { ...contentBinding('cleanup'), contentVersion: 2, selectedCount: 1 },
868
+ });
869
+ expect(staged.outcome).toBe('reserved');
870
+ if (staged.outcome !== 'reserved' || !staged.reservation.objectRef) return;
871
+
872
+ const finalized = await finalizeReservation({
873
+ namespace: created.namespace,
874
+ ownerProfileId: created.ownerProfileId,
875
+ shareId: created.shareId,
876
+ operationId: staged.reservation.operationId,
877
+ objectRef: staged.reservation.objectRef,
878
+ generation: staged.reservation.generation,
879
+ leaseOwner: staged.reservation.leaseOwner,
880
+ now: NOW,
881
+ });
882
+ expect(finalized.outcome).toBe('finalized');
883
+
884
+ const claimed = await claimCleanupJobs({
885
+ namespace: created.namespace,
886
+ claimant: 'cleaner-1',
887
+ now: NOW,
888
+ });
889
+ expect(claimed.jobs.map(job => job.objectRef)).toContain(created.objectRef);
890
+
891
+ const completed = await completeCleanupJob({
892
+ objectRef: created.objectRef,
893
+ claimToken: claimed.claimToken,
894
+ outcome: 'completed',
895
+ now: NOW,
896
+ });
897
+ expect(completed.outcome).toBe('completed');
898
+ expect((await getCleanupJob(created.objectRef))?.status).toBe('completed');
899
+
900
+ const abandoned = await reserveReplacement({
901
+ namespace: created.namespace,
902
+ ownerProfileId: created.ownerProfileId,
903
+ clientRequestId: uuid(),
904
+ shareId: created.shareId,
905
+ expectedVersion: 3,
906
+ requestHash: replacementHash(created.shareId, 'retry'),
907
+ leaseOwner: 'worker-1',
908
+ now: NOW,
909
+ content: { ...contentBinding('retry'), contentVersion: 3, selectedCount: 1 },
910
+ });
911
+ expect(abandoned.outcome).toBe('reserved');
912
+ if (abandoned.outcome !== 'reserved' || !abandoned.reservation.objectRef) return;
913
+
914
+ const abandonResult = await abandonReservation({
915
+ namespace: created.namespace,
916
+ ownerProfileId: created.ownerProfileId,
917
+ shareId: created.shareId,
918
+ operationId: abandoned.reservation.operationId,
919
+ generation: abandoned.reservation.generation,
920
+ leaseOwner: abandoned.reservation.leaseOwner,
921
+ now: NOW,
922
+ });
923
+ expect(abandonResult.outcome).toBe('abandoned');
924
+
925
+ const retryRef = abandoned.reservation.objectRef;
926
+ const claimedRetry = await claimCleanupJobs({
927
+ namespace: created.namespace,
928
+ claimant: 'cleaner-1',
929
+ now: NOW,
930
+ });
931
+ expect(claimedRetry.jobs.map(job => job.objectRef)).toContain(retryRef);
932
+
933
+ const retried = await completeCleanupJob({
934
+ objectRef: retryRef,
935
+ claimToken: claimedRetry.claimToken,
936
+ outcome: 'retry',
937
+ errorMessage: 'throttled',
938
+ now: NOW,
939
+ });
940
+ expect(retried.outcome).toBe('retry');
941
+ if (retried.outcome === 'retry') {
942
+ expect(retried.job.status).toBe('queued');
943
+ expect(retried.job.attempts).toBe(1);
944
+ expect(Date.parse(retried.job.nextAttemptAt)).toBeGreaterThan(NOW.getTime());
945
+ }
946
+
947
+ expect(
948
+ await completeCleanupJob({
949
+ objectRef: retryRef,
950
+ claimToken: 'not-the-claim',
951
+ outcome: 'completed',
952
+ now: NOW,
953
+ })
954
+ ).toEqual({ outcome: 'claim_lost' });
955
+ });
956
+
957
+ it('lets exactly one of two concurrent finalizes commit the swap', async () => {
958
+ const created = await commitNewShare();
959
+ const staged = await reserveReplacement({
960
+ namespace: created.namespace,
961
+ ownerProfileId: created.ownerProfileId,
962
+ clientRequestId: uuid(),
963
+ shareId: created.shareId,
964
+ expectedVersion: created.committed.version,
965
+ requestHash: replacementHash(created.shareId, 'double-finalize'),
966
+ leaseOwner: 'worker-1',
967
+ now: NOW,
968
+ content: { ...contentBinding('double-finalize'), contentVersion: 2, selectedCount: 1 },
969
+ });
970
+ expect(staged.outcome).toBe('reserved');
971
+ if (staged.outcome !== 'reserved' || !staged.reservation.objectRef) return;
972
+
973
+ const finalizeInput = {
974
+ namespace: created.namespace,
975
+ ownerProfileId: created.ownerProfileId,
976
+ shareId: created.shareId,
977
+ operationId: staged.reservation.operationId,
978
+ objectRef: staged.reservation.objectRef,
979
+ generation: staged.reservation.generation,
980
+ leaseOwner: staged.reservation.leaseOwner,
981
+ now: NOW,
982
+ } as const;
983
+
984
+ const results = await Promise.allSettled([
985
+ finalizeReservation({ ...finalizeInput }),
986
+ finalizeReservation({ ...finalizeInput }),
987
+ ]);
988
+
989
+ const outcomes = results.map(result =>
990
+ result.status === 'fulfilled' ? result.value.outcome : 'rejected'
991
+ );
992
+ expect(outcomes).toContain('finalized');
993
+ expect(outcomes).toContain('already_finalized');
994
+
995
+ const share = await getShareLink({ shareId: created.shareId });
996
+ expect(share?.activeObjectRef).toBe(staged.reservation.objectRef);
997
+ expect(await countNodes('ShareLinkReservation', 'shareId', created.shareId)).toBe(0);
998
+ expect(
999
+ (await listCleanupJobsForShare(created.shareId)).filter(
1000
+ job => job.objectRef === created.objectRef
1001
+ )
1002
+ ).toHaveLength(1);
1003
+ });
1004
+
1005
+ it('keeps revoke as the terminal winner against a concurrent finalize', async () => {
1006
+ const created = await commitNewShare();
1007
+ const staged = await reserveReplacement({
1008
+ namespace: created.namespace,
1009
+ ownerProfileId: created.ownerProfileId,
1010
+ clientRequestId: uuid(),
1011
+ shareId: created.shareId,
1012
+ expectedVersion: created.committed.version,
1013
+ requestHash: replacementHash(created.shareId, 'race-revoke'),
1014
+ leaseOwner: 'worker-1',
1015
+ now: NOW,
1016
+ content: { ...contentBinding('race-revoke'), contentVersion: 2, selectedCount: 1 },
1017
+ });
1018
+ expect(staged.outcome).toBe('reserved');
1019
+ if (staged.outcome !== 'reserved' || !staged.reservation.objectRef) return;
1020
+
1021
+ const outcomes = await Promise.allSettled([
1022
+ finalizeReservation({
1023
+ namespace: created.namespace,
1024
+ ownerProfileId: created.ownerProfileId,
1025
+ shareId: created.shareId,
1026
+ operationId: staged.reservation.operationId,
1027
+ objectRef: staged.reservation.objectRef,
1028
+ generation: staged.reservation.generation,
1029
+ leaseOwner: staged.reservation.leaseOwner,
1030
+ now: NOW,
1031
+ }),
1032
+ revokeShareLink({
1033
+ namespace: created.namespace,
1034
+ ownerProfileId: created.ownerProfileId,
1035
+ shareId: created.shareId,
1036
+ now: NOW,
1037
+ }),
1038
+ ]);
1039
+
1040
+ expect(outcomes[1].status).toBe('fulfilled');
1041
+ const share: ShareLinkRecord | null = await getShareLink({ shareId: created.shareId });
1042
+ expect(share?.status).toBe('stopped');
1043
+ expect(await countNodes('ShareLinkReservation', 'shareId', created.shareId)).toBe(0);
1044
+ });
1045
+ it('rejects an abandoned metadata retry after stop or a newer committed version', async () => {
1046
+ for (const stop of [true, false]) {
1047
+ const created = await commitNewShare();
1048
+ const input = {
1049
+ namespace: created.namespace,
1050
+ ownerProfileId: created.ownerProfileId,
1051
+ shareId: created.shareId,
1052
+ clientRequestId: uuid(),
1053
+ expectedVersion: created.committed.version,
1054
+ requestHash: replacementHash(created.shareId, 'metadata'),
1055
+ leaseOwner: 'worker-1',
1056
+ now: NOW,
1057
+ title: 'Old title',
1058
+ };
1059
+ const staged = await reserveReplacement(input);
1060
+ if (staged.outcome !== 'reserved') throw new Error('expected reservation');
1061
+ await abandonReservation({ ...input, operationId: staged.reservation.operationId });
1062
+ if (stop) await revokeShareLink(input);
1063
+ else {
1064
+ const newer = await reserveReplacement({
1065
+ ...input,
1066
+ clientRequestId: uuid(),
1067
+ requestHash: replacementHash(created.shareId, 'newer'),
1068
+ title: 'New title',
1069
+ });
1070
+ if (newer.outcome !== 'reserved') throw new Error('expected reservation');
1071
+ await finalizeReservation({ ...input, ...newer.reservation });
1072
+ }
1073
+ await expect(reserveReplacement(input)).rejects.toMatchObject({ code: 'CONFLICT' });
1074
+ }
1075
+ });
1076
+
1077
+ it('preserves object operation binding across metadata updates and cleanup', async () => {
1078
+ const created = await commitNewShare();
1079
+ const staged = await reserveReplacement({
1080
+ ...created,
1081
+ expectedVersion: created.committed.version,
1082
+ clientRequestId: uuid(),
1083
+ requestHash: replacementHash(created.shareId, 'metadata-binding'),
1084
+ title: 'Renamed',
1085
+ now: NOW,
1086
+ });
1087
+ if (staged.outcome !== 'reserved') throw new Error('expected reservation');
1088
+ await finalizeReservation({ ...staged.reservation, now: NOW });
1089
+ const content = await getCurrentShareContent({ shareId: created.shareId, now: NOW });
1090
+ expect(content.state === 'active' && content.operationId).toBe(created.operationId);
1091
+ await revokeShareLink({ ...created, now: NOW });
1092
+ const jobs = await listCleanupJobsForShare(created.shareId);
1093
+ expect(jobs.find(job => job.objectRef === created.objectRef)?.operationId).toBe(
1094
+ created.operationId
1095
+ );
1096
+ const claims = await Promise.all([
1097
+ claimCleanupJobs({ namespace: created.namespace, claimant: 'a', now: NOW }),
1098
+ claimCleanupJobs({ namespace: created.namespace, claimant: 'b', now: NOW }),
1099
+ ]);
1100
+ expect(claims.flatMap(claim => claim.jobs)).toHaveLength(1);
1101
+ });
1102
+
1103
+ it('persists the reservation policy snapshot and only tightens it at finalize', async () => {
1104
+ const permissive: ShareLinkPolicySnapshot = {
1105
+ isMinor: false,
1106
+ policyResolved: true,
1107
+ defaultExpiryDays: 365,
1108
+ viewCountingEnabled: true,
1109
+ };
1110
+ const restrictive: ShareLinkPolicySnapshot = {
1111
+ isMinor: false,
1112
+ policyResolved: true,
1113
+ defaultExpiryDays: 30,
1114
+ viewCountingEnabled: false,
1115
+ };
1116
+
1117
+ const namespace = NAMESPACE;
1118
+ const ownerProfileId = `owner-${uuid()}`;
1119
+ const shareId = nextShareId();
1120
+ const content = contentBinding('policy');
1121
+
1122
+ const reserved = await reserveCreate({
1123
+ namespace,
1124
+ ownerProfileId,
1125
+ clientRequestId: uuid(),
1126
+ shareId,
1127
+ title: 'Shared credentials',
1128
+ note: null,
1129
+ expiresAt: null,
1130
+ selectedCount: 1,
1131
+ content,
1132
+ requestHash: computeShareLinkRequestHash('create', {
1133
+ id: shareId,
1134
+ title: 'Shared credentials',
1135
+ }),
1136
+ leaseOwner: 'worker-1',
1137
+ policy: permissive,
1138
+ now: NOW,
1139
+ });
1140
+ if (reserved.outcome !== 'reserved' || !reserved.reservation.objectRef) {
1141
+ throw new Error('expected reserved create');
1142
+ }
1143
+
1144
+ // The reservation persists the exact server-derived snapshot.
1145
+ expect(reserved.reservation.policy).toEqual(permissive);
1146
+
1147
+ await finalizeReservation({ ...reserved.reservation, now: NOW });
1148
+
1149
+ const afterCreate = (await getShareLink({ shareId })) as ShareLinkRecord;
1150
+ expect(afterCreate.minorPolicyViewCountingEnabled).toBe(true);
1151
+ expect(afterCreate.minorPolicyDefaultExpiryDays).toBe(365);
1152
+ expect(toOwnerShareLink(afterCreate).minorPolicy.viewCountingEnabled).toBe(true);
1153
+
1154
+ // A genuinely restrictive later reservation tightens the committed policy.
1155
+ const restrictiveReservation = await reserveReplacement({
1156
+ namespace,
1157
+ ownerProfileId,
1158
+ clientRequestId: uuid(),
1159
+ shareId,
1160
+ expectedVersion: afterCreate.version,
1161
+ requestHash: computeShareLinkRequestHash('update', {
1162
+ id: shareId,
1163
+ title: 'Restricted',
1164
+ }),
1165
+ title: 'Restricted',
1166
+ leaseOwner: 'worker-1',
1167
+ policy: restrictive,
1168
+ now: NOW,
1169
+ });
1170
+ if (restrictiveReservation.outcome !== 'reserved') {
1171
+ throw new Error('expected restrictive reservation');
1172
+ }
1173
+ await finalizeReservation({ ...restrictiveReservation.reservation, now: NOW });
1174
+
1175
+ const afterRestrictive = (await getShareLink({ shareId })) as ShareLinkRecord;
1176
+ expect(afterRestrictive.minorPolicyViewCountingEnabled).toBe(false);
1177
+ expect(afterRestrictive.minorPolicyDefaultExpiryDays).toBe(30);
1178
+ expect(toOwnerShareLink(afterRestrictive).minorPolicy.viewCountingEnabled).toBe(false);
1179
+
1180
+ // A stale permissive replay/reservation can never re-enable view counting.
1181
+ const permissiveAgain = await reserveReplacement({
1182
+ namespace,
1183
+ ownerProfileId,
1184
+ clientRequestId: uuid(),
1185
+ shareId,
1186
+ expectedVersion: afterRestrictive.version,
1187
+ requestHash: computeShareLinkRequestHash('update', { id: shareId, title: 'Loosen' }),
1188
+ title: 'Loosen',
1189
+ leaseOwner: 'worker-1',
1190
+ policy: permissive,
1191
+ now: NOW,
1192
+ });
1193
+ if (permissiveAgain.outcome !== 'reserved') {
1194
+ throw new Error('expected permissive replay reservation');
1195
+ }
1196
+ await finalizeReservation({ ...permissiveAgain.reservation, now: NOW });
1197
+
1198
+ const afterReplay = (await getShareLink({ shareId })) as ShareLinkRecord;
1199
+ expect(afterReplay.minorPolicyViewCountingEnabled).toBe(false);
1200
+ expect(afterReplay.minorPolicyDefaultExpiryDays).toBe(30);
1201
+ expect(toOwnerShareLink(afterReplay).minorPolicy.viewCountingEnabled).toBe(false);
1202
+ });
1203
+
1204
+ it('upgrades an unknown share on explicit edit only after a fresh persisted adult check', async () => {
1205
+ const ownerProfileId = `policy-owner-${uuid()}`;
1206
+ const created = await commitNewShare({ ownerProfileId });
1207
+ expect(created.committed.minorPolicyViewCountingEnabled).toBe(false);
1208
+
1209
+ const adultPolicy: ShareLinkPolicySnapshot = {
1210
+ isMinor: false,
1211
+ policyResolved: true,
1212
+ defaultExpiryDays: 365,
1213
+ viewCountingEnabled: true,
1214
+ };
1215
+
1216
+ await neogma.queryRunner.run('CREATE (:Profile {profileId: $profileId, dob: $dob})', {
1217
+ profileId: ownerProfileId,
1218
+ dob: '1990-01-01',
1219
+ });
1220
+
1221
+ try {
1222
+ const update = await reserveReplacement({
1223
+ namespace: created.namespace,
1224
+ ownerProfileId,
1225
+ shareId: created.shareId,
1226
+ expectedVersion: created.committed.version,
1227
+ clientRequestId: uuid(),
1228
+ requestHash: replacementHash(created.shareId, 'adult-policy-edit'),
1229
+ notifyOnView: true,
1230
+ leaseOwner: 'worker-1',
1231
+ policy: adultPolicy,
1232
+ now: NOW,
1233
+ });
1234
+ if (update.outcome !== 'reserved') throw new Error('expected reservation');
1235
+
1236
+ const finalized = await finalizeReservation({
1237
+ ...update.reservation,
1238
+ resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
1239
+ now: NOW,
1240
+ });
1241
+ expect(finalized.share.minorPolicyViewCountingEnabled).toBe(true);
1242
+ expect(finalized.share.notifyOnView).toBe(true);
1243
+
1244
+ // A newly managed profile must not retain the opt-in on a later edit.
1245
+ await neogma.queryRunner.run(
1246
+ `MATCH (p:Profile {profileId: $profileId})
1247
+ CREATE (p)-[:MANAGED_BY]->(:Profile {profileId: $managerId})`,
1248
+ { profileId: ownerProfileId, managerId: `manager-${uuid()}` }
1249
+ );
1250
+ const restricted = await reserveReplacement({
1251
+ namespace: created.namespace,
1252
+ ownerProfileId,
1253
+ shareId: created.shareId,
1254
+ expectedVersion: finalized.share.version,
1255
+ clientRequestId: uuid(),
1256
+ requestHash: replacementHash(created.shareId, 'managed-policy-edit'),
1257
+ notifyOnView: true,
1258
+ leaseOwner: 'worker-1',
1259
+ policy: adultPolicy,
1260
+ now: NOW,
1261
+ });
1262
+ if (restricted.outcome !== 'reserved') throw new Error('expected reservation');
1263
+ const afterRestriction = await finalizeReservation({
1264
+ ...restricted.reservation,
1265
+ resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
1266
+ now: NOW,
1267
+ });
1268
+ expect(afterRestriction.share.minorPolicyViewCountingEnabled).toBe(false);
1269
+ expect(afterRestriction.share.notifyOnView).toBe(false);
1270
+ } finally {
1271
+ await neogma.queryRunner.run(
1272
+ `MATCH (p:Profile {profileId: $profileId})
1273
+ OPTIONAL MATCH (p)-[:MANAGED_BY]->(manager:Profile)
1274
+ DETACH DELETE p, manager`,
1275
+ { profileId: ownerProfileId }
1276
+ );
1277
+ }
1278
+ });
1279
+
1280
+ it('exempts managed service owners but keeps managed child owners restricted in Neo4j', async () => {
1281
+ const serviceProfileId = `service-policy-${uuid()}`;
1282
+ const childProfileId = `child-policy-${uuid()}`;
1283
+ const unknownServiceId = `service-unknown-${uuid()}`;
1284
+ const serviceManagerId = `manager-${uuid()}`;
1285
+ const childManagerId = `manager-${uuid()}`;
1286
+ await neogma.queryRunner.run(
1287
+ `CREATE (service:Profile {profileId: $serviceId, isServiceProfile: true})
1288
+ CREATE (serviceManager:ProfileManager {id: $serviceManagerId, created: $created})
1289
+ CREATE (serviceManager)-[:MANAGES]->(service)
1290
+ CREATE (child:Profile {profileId: $childId, dob: '1990-01-01', isServiceProfile: false})
1291
+ CREATE (child)-[:MANAGED_BY]->(guardian:Profile {profileId: $childManagerId})
1292
+ CREATE (serviceManager)-[:ADMINISTRATED_BY]->(guardian)
1293
+ CREATE (:Profile {profileId: $unknownServiceId, isServiceProfile: true})`,
1294
+ {
1295
+ serviceId: serviceProfileId,
1296
+ serviceManagerId,
1297
+ childId: childProfileId,
1298
+ childManagerId,
1299
+ unknownServiceId,
1300
+ created: NOW.toISOString(),
1301
+ }
1302
+ );
1303
+ try {
1304
+ const shareId = nextShareId();
1305
+ const requestId = uuid();
1306
+ const policy: ShareLinkPolicySnapshot = {
1307
+ isMinor: false,
1308
+ policyResolved: true,
1309
+ defaultExpiryDays: 365,
1310
+ viewCountingEnabled: true,
1311
+ };
1312
+ const reserved = await reserveCreate({
1313
+ namespace: NAMESPACE,
1314
+ ownerProfileId: serviceProfileId,
1315
+ clientRequestId: requestId,
1316
+ shareId,
1317
+ title: 'Service policy integration',
1318
+ note: null,
1319
+ expiresAt: null,
1320
+ selectedCount: 1,
1321
+ content: contentBinding(shareId),
1322
+ requestHash: computeShareLinkRequestHash('create', {
1323
+ id: shareId,
1324
+ title: 'Service policy integration',
1325
+ }),
1326
+ leaseOwner: 'worker-1',
1327
+ policy,
1328
+ now: NOW,
1329
+ });
1330
+ if (reserved.outcome !== 'reserved' || !reserved.reservation.objectRef) {
1331
+ throw new Error('expected service share reservation');
1332
+ }
1333
+ const finalized = await finalizeReservation({
1334
+ ...reserved.reservation,
1335
+ resolveCurrentPolicy: resolveCurrentShareLinkPolicy,
1336
+ now: NOW,
1337
+ });
1338
+ const created = {
1339
+ namespace: NAMESPACE,
1340
+ shareId,
1341
+ committed: finalized.share,
1342
+ objectRef: reserved.reservation.objectRef,
1343
+ operationId: reserved.reservation.operationId,
1344
+ };
1345
+ const preflight = createShareLinkPolicyResolver(
1346
+ createProductionShareLinkPolicySource()
1347
+ );
1348
+ expect((await preflight.resolve(serviceProfileId)).viewCountingEnabled).toBe(true);
1349
+ expect((await preflight.resolve(childProfileId)).viewCountingEnabled).toBe(false);
1350
+ expect((await preflight.resolve(unknownServiceId)).viewCountingEnabled).toBe(true);
1351
+ expect(
1352
+ await withShareLinkRead(tx =>
1353
+ resolveCurrentShareLinkPolicy(tx, serviceProfileId, NOW)
1354
+ )
1355
+ ).toMatchObject({ viewCountingEnabled: true });
1356
+ const receipt = randomBytes(32).toString('base64url');
1357
+ expect(
1358
+ await persistShareViewReceipt({
1359
+ receipt,
1360
+ namespace: created.namespace,
1361
+ ownerProfileId: serviceProfileId,
1362
+ shareId: created.shareId,
1363
+ shareVersion: created.committed.version,
1364
+ contentVersion: created.committed.contentVersion,
1365
+ objectRef: created.objectRef,
1366
+ operationId: created.operationId,
1367
+ ttlSeconds: 60,
1368
+ eligibilitySource: productionShareViewEligibilitySource,
1369
+ now: () => NOW,
1370
+ })
1371
+ ).toBe(true);
1372
+ expect(
1373
+ await consumeShareViewReceipt({
1374
+ receipt,
1375
+ namespace: created.namespace,
1376
+ eligibilitySource: productionShareViewEligibilitySource,
1377
+ now: () => NOW,
1378
+ })
1379
+ ).toBe('consumed');
1380
+ const counted = await neogma.queryRunner.run(
1381
+ 'MATCH (s:ShareLink {id: $shareId}) RETURN s.viewCount AS viewCount',
1382
+ { shareId: created.shareId }
1383
+ );
1384
+ expect(Number(counted.records[0]?.get('viewCount'))).toBe(1);
1385
+ expect(
1386
+ await withShareLinkRead(tx =>
1387
+ resolveCurrentShareLinkPolicy(tx, childProfileId, NOW)
1388
+ )
1389
+ ).toMatchObject({ viewCountingEnabled: false });
1390
+ expect(
1391
+ await withShareLinkRead(tx =>
1392
+ resolveCurrentShareLinkPolicy(tx, unknownServiceId, NOW)
1393
+ )
1394
+ ).toMatchObject({ viewCountingEnabled: true });
1395
+ } finally {
1396
+ await neogma.queryRunner.run(
1397
+ `MATCH (n)
1398
+ WHERE (n:Profile AND n.profileId IN [$serviceId, $childId, $unknownServiceId, $childManagerId])
1399
+ OR (n:ProfileManager AND n.id = $serviceManagerId)
1400
+ DETACH DELETE n`,
1401
+ {
1402
+ serviceId: serviceProfileId,
1403
+ childId: childProfileId,
1404
+ unknownServiceId,
1405
+ serviceManagerId,
1406
+ childManagerId,
1407
+ }
1408
+ );
1409
+ }
1410
+ });
1411
+ });