@learncard/network-brain-service 3.19.0 → 3.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/.env.example +34 -2
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +42 -0
  4. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  5. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  6. package/dist/accesslayer/presentation/read.d.ts.map +1 -1
  7. package/dist/accesslayer/presentation/relationships/create.d.ts +2 -2
  8. package/dist/accesslayer/presentation/relationships/create.d.ts.map +1 -1
  9. package/dist/accesslayer/presentation/relationships/read.d.ts.map +1 -1
  10. package/dist/accesslayer/share-link/abandon.d.ts +3 -0
  11. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -0
  12. package/dist/accesslayer/share-link/cleanup.d.ts +6 -0
  13. package/dist/accesslayer/share-link/cleanup.d.ts.map +1 -0
  14. package/dist/accesslayer/share-link/errors.d.ts +8 -0
  15. package/dist/accesslayer/share-link/errors.d.ts.map +1 -0
  16. package/dist/accesslayer/share-link/finalize.d.ts +3 -0
  17. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -0
  18. package/dist/accesslayer/share-link/helpers.d.ts +56 -0
  19. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -0
  20. package/dist/accesslayer/share-link/index.d.ts +12 -0
  21. package/dist/accesslayer/share-link/index.d.ts.map +1 -0
  22. package/dist/accesslayer/share-link/list.d.ts +5 -0
  23. package/dist/accesslayer/share-link/list.d.ts.map +1 -0
  24. package/dist/accesslayer/share-link/read.d.ts +5 -0
  25. package/dist/accesslayer/share-link/read.d.ts.map +1 -0
  26. package/dist/accesslayer/share-link/receipt.d.ts +11 -0
  27. package/dist/accesslayer/share-link/receipt.d.ts.map +1 -0
  28. package/dist/accesslayer/share-link/recovery.d.ts +6 -0
  29. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -0
  30. package/dist/accesslayer/share-link/reserve.d.ts +4 -0
  31. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -0
  32. package/dist/accesslayer/share-link/revoke.d.ts +3 -0
  33. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -0
  34. package/dist/accesslayer/share-link/transaction-execution.d.ts +18 -0
  35. package/dist/accesslayer/share-link/transaction-execution.d.ts.map +1 -0
  36. package/dist/accesslayer/share-link/transaction.d.ts +15 -0
  37. package/dist/accesslayer/share-link/transaction.d.ts.map +1 -0
  38. package/dist/accesslayer/share-link/types.d.ts +370 -0
  39. package/dist/accesslayer/share-link/types.d.ts.map +1 -0
  40. package/dist/app.d.ts +4 -0
  41. package/dist/app.d.ts.map +1 -1
  42. package/dist/config/environment.d.ts +9 -0
  43. package/dist/config/environment.d.ts.map +1 -1
  44. package/dist/constants/auth-grant.d.ts +2 -0
  45. package/dist/constants/auth-grant.d.ts.map +1 -1
  46. package/dist/helpers/boost.helpers.d.ts.map +1 -1
  47. package/dist/helpers/credentialSubject.helpers.d.ts +3 -0
  48. package/dist/helpers/credentialSubject.helpers.d.ts.map +1 -0
  49. package/dist/helpers/credentialSubject.helpers.test.d.ts +2 -0
  50. package/dist/helpers/credentialSubject.helpers.test.d.ts.map +1 -0
  51. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  52. package/dist/helpers/notificationMessages.d.ts +1 -1
  53. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  54. package/dist/helpers/presentation-metadata.d.ts +4 -0
  55. package/dist/helpers/presentation-metadata.d.ts.map +1 -0
  56. package/dist/helpers/presentation.helpers.d.ts +1 -1
  57. package/dist/helpers/presentation.helpers.d.ts.map +1 -1
  58. package/dist/helpers/profile.helpers.d.ts +1 -0
  59. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  60. package/dist/helpers/share-content-client/adapters.d.ts +10 -0
  61. package/dist/helpers/share-content-client/adapters.d.ts.map +1 -0
  62. package/dist/helpers/share-content-client/canonical.d.ts +7 -0
  63. package/dist/helpers/share-content-client/canonical.d.ts.map +1 -0
  64. package/dist/helpers/share-content-client/client.d.ts +5 -0
  65. package/dist/helpers/share-content-client/client.d.ts.map +1 -0
  66. package/dist/helpers/share-content-client/config.d.ts +11 -0
  67. package/dist/helpers/share-content-client/config.d.ts.map +1 -0
  68. package/dist/helpers/share-content-client/index.d.ts +6 -0
  69. package/dist/helpers/share-content-client/index.d.ts.map +1 -0
  70. package/dist/helpers/share-content-client/responses.d.ts +16 -0
  71. package/dist/helpers/share-content-client/responses.d.ts.map +1 -0
  72. package/dist/helpers/share-content-client/types.d.ts +145 -0
  73. package/dist/helpers/share-content-client/types.d.ts.map +1 -0
  74. package/dist/helpers/share-link-coordinator/budget-helpers.d.ts +5 -0
  75. package/dist/helpers/share-link-coordinator/budget-helpers.d.ts.map +1 -0
  76. package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts +3 -0
  77. package/dist/helpers/share-link-coordinator/cleanup-runner.d.ts.map +1 -0
  78. package/dist/helpers/share-link-coordinator/coordinator.d.ts +4 -0
  79. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -0
  80. package/dist/helpers/share-link-coordinator/index.d.ts +5 -0
  81. package/dist/helpers/share-link-coordinator/index.d.ts.map +1 -0
  82. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts +9 -0
  83. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -0
  84. package/dist/helpers/share-link-coordinator/runtime.d.ts +11 -0
  85. package/dist/helpers/share-link-coordinator/runtime.d.ts.map +1 -0
  86. package/dist/helpers/share-link-coordinator/stat.d.ts +4 -0
  87. package/dist/helpers/share-link-coordinator/stat.d.ts.map +1 -0
  88. package/dist/helpers/share-link-coordinator/types.d.ts +160 -0
  89. package/dist/helpers/share-link-coordinator/types.d.ts.map +1 -0
  90. package/dist/helpers/share-link-lifecycle/index.d.ts +5 -0
  91. package/dist/helpers/share-link-lifecycle/index.d.ts.map +1 -0
  92. package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts +4 -0
  93. package/dist/helpers/share-link-lifecycle/lease.helpers.d.ts.map +1 -0
  94. package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts +21 -0
  95. package/dist/helpers/share-link-lifecycle/recovery.helpers.d.ts.map +1 -0
  96. package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts +9 -0
  97. package/dist/helpers/share-link-lifecycle/request-hash.helpers.d.ts.map +1 -0
  98. package/dist/helpers/share-link-lifecycle/types.d.ts +14 -0
  99. package/dist/helpers/share-link-lifecycle/types.d.ts.map +1 -0
  100. package/dist/helpers/share-link-maintenance/budget.d.ts +12 -0
  101. package/dist/helpers/share-link-maintenance/budget.d.ts.map +1 -0
  102. package/dist/helpers/share-link-maintenance/config.d.ts +48 -0
  103. package/dist/helpers/share-link-maintenance/config.d.ts.map +1 -0
  104. package/dist/helpers/share-link-maintenance/index.d.ts +8 -0
  105. package/dist/helpers/share-link-maintenance/index.d.ts.map +1 -0
  106. package/dist/helpers/share-link-maintenance/runner.d.ts +19 -0
  107. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -0
  108. package/dist/helpers/share-link-maintenance/runtime.d.ts +36 -0
  109. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -0
  110. package/dist/helpers/share-link-maintenance/scheduler.d.ts +18 -0
  111. package/dist/helpers/share-link-maintenance/scheduler.d.ts.map +1 -0
  112. package/dist/helpers/share-link-maintenance/telemetry.d.ts +5 -0
  113. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -0
  114. package/dist/helpers/share-link-maintenance/types.d.ts +53 -0
  115. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -0
  116. package/dist/helpers/share-link-owner/config.d.ts +16 -0
  117. package/dist/helpers/share-link-owner/config.d.ts.map +1 -0
  118. package/dist/helpers/share-link-owner/diagnostics.d.ts +26 -0
  119. package/dist/helpers/share-link-owner/diagnostics.d.ts.map +1 -0
  120. package/dist/helpers/share-link-owner/lazy-initializer.d.ts +2 -0
  121. package/dist/helpers/share-link-owner/lazy-initializer.d.ts.map +1 -0
  122. package/dist/helpers/share-link-owner-projection.d.ts +4 -0
  123. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -0
  124. package/dist/helpers/share-link-passcode-abuse.d.ts +2 -0
  125. package/dist/helpers/share-link-passcode-abuse.d.ts.map +1 -0
  126. package/dist/helpers/share-link-passcode.d.ts +6 -0
  127. package/dist/helpers/share-link-passcode.d.ts.map +1 -0
  128. package/dist/helpers/share-link-policy/index.d.ts +4 -0
  129. package/dist/helpers/share-link-policy/index.d.ts.map +1 -0
  130. package/dist/helpers/share-link-policy/production.d.ts +13 -0
  131. package/dist/helpers/share-link-policy/production.d.ts.map +1 -0
  132. package/dist/helpers/share-link-policy/resolver.d.ts +6 -0
  133. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -0
  134. package/dist/helpers/share-link-policy/types.d.ts +16 -0
  135. package/dist/helpers/share-link-policy/types.d.ts.map +1 -0
  136. package/dist/helpers/share-link-public/config.d.ts +13 -0
  137. package/dist/helpers/share-link-public/config.d.ts.map +1 -0
  138. package/dist/helpers/share-link-view-notification.d.ts +2 -0
  139. package/dist/helpers/share-link-view-notification.d.ts.map +1 -0
  140. package/dist/index.js +2 -2
  141. package/dist/models/Presentation.d.ts +2 -0
  142. package/dist/models/Presentation.d.ts.map +1 -1
  143. package/dist/models/Profile.d.ts +2 -0
  144. package/dist/models/Profile.d.ts.map +1 -1
  145. package/dist/models/ShareLink.d.ts +44 -0
  146. package/dist/models/ShareLink.d.ts.map +1 -0
  147. package/dist/models/ShareViewReceipt.d.ts +22 -0
  148. package/dist/models/ShareViewReceipt.d.ts.map +1 -0
  149. package/dist/models/index.d.ts +1 -0
  150. package/dist/models/index.d.ts.map +1 -1
  151. package/dist/models/share-link-constraints.d.ts +3 -0
  152. package/dist/models/share-link-constraints.d.ts.map +1 -0
  153. package/dist/public-share-link-http.d.ts +4 -0
  154. package/dist/public-share-link-http.d.ts.map +1 -0
  155. package/dist/routes/app-store.d.ts.map +1 -1
  156. package/dist/routes/boosts.d.ts.map +1 -1
  157. package/dist/routes/contracts.d.ts +2 -2
  158. package/dist/routes/contracts.d.ts.map +1 -1
  159. package/dist/routes/credentials.d.ts +1 -1
  160. package/dist/routes/index.d.ts +153 -0
  161. package/dist/routes/index.d.ts.map +1 -1
  162. package/dist/routes/presentations.d.ts +1 -0
  163. package/dist/routes/presentations.d.ts.map +1 -1
  164. package/dist/routes/profile-manager.d.ts.map +1 -1
  165. package/dist/routes/profiles.d.ts.map +1 -1
  166. package/dist/routes/public-share-links.d.ts +230 -0
  167. package/dist/routes/public-share-links.d.ts.map +1 -0
  168. package/dist/routes/share-links.d.ts +844 -0
  169. package/dist/routes/share-links.d.ts.map +1 -0
  170. package/dist/services/delivery/adapters/postmark.adapter.d.ts.map +1 -1
  171. package/dist/types/consentflowcontract.d.ts +1 -1
  172. package/lambda.ts +4 -1
  173. package/package.json +22 -16
  174. package/scripts/seed-demo-persona.ts +425 -0
  175. package/serverless.yml +36 -0
  176. package/shareLinkMaintenanceLambda.ts +49 -0
  177. package/src/accesslayer/consentflowcontract/relationships/create.ts +9 -16
  178. package/src/accesslayer/consentflowcontract/relationships/update.ts +15 -28
  179. package/src/accesslayer/presentation/read.ts +57 -25
  180. package/src/accesslayer/presentation/relationships/create.ts +54 -11
  181. package/src/accesslayer/presentation/relationships/read.ts +21 -6
  182. package/src/accesslayer/share-link/abandon.ts +100 -0
  183. package/src/accesslayer/share-link/cleanup.ts +271 -0
  184. package/src/accesslayer/share-link/errors.ts +32 -0
  185. package/src/accesslayer/share-link/finalize.ts +265 -0
  186. package/src/accesslayer/share-link/helpers.ts +419 -0
  187. package/src/accesslayer/share-link/index.ts +11 -0
  188. package/src/accesslayer/share-link/list.ts +162 -0
  189. package/src/accesslayer/share-link/read.ts +107 -0
  190. package/src/accesslayer/share-link/receipt.ts +548 -0
  191. package/src/accesslayer/share-link/recovery.ts +545 -0
  192. package/src/accesslayer/share-link/reserve.ts +684 -0
  193. package/src/accesslayer/share-link/revoke.ts +197 -0
  194. package/src/accesslayer/share-link/transaction-execution.ts +72 -0
  195. package/src/accesslayer/share-link/transaction.ts +126 -0
  196. package/src/accesslayer/share-link/types.ts +601 -0
  197. package/src/app.ts +6 -0
  198. package/src/config/environment.ts +14 -0
  199. package/src/constants/auth-grant.ts +5 -0
  200. package/src/docker-entry.ts +57 -0
  201. package/src/helpers/boost.helpers.ts +4 -31
  202. package/src/helpers/credentialSubject.helpers.test.ts +114 -0
  203. package/src/helpers/credentialSubject.helpers.ts +40 -0
  204. package/src/helpers/inbox.helpers.ts +7 -4
  205. package/src/helpers/notificationMessages.ts +18 -1
  206. package/src/helpers/presentation-metadata.ts +41 -0
  207. package/src/helpers/presentation.helpers.ts +27 -20
  208. package/src/helpers/profile.helpers.ts +7 -0
  209. package/src/helpers/share-content-client/adapters.ts +55 -0
  210. package/src/helpers/share-content-client/canonical.ts +25 -0
  211. package/src/helpers/share-content-client/client.ts +440 -0
  212. package/src/helpers/share-content-client/config.ts +189 -0
  213. package/src/helpers/share-content-client/index.ts +27 -0
  214. package/src/helpers/share-content-client/responses.ts +273 -0
  215. package/src/helpers/share-content-client/types.ts +253 -0
  216. package/src/helpers/share-link-coordinator/budget-helpers.ts +53 -0
  217. package/src/helpers/share-link-coordinator/cleanup-runner.ts +286 -0
  218. package/src/helpers/share-link-coordinator/coordinator.ts +714 -0
  219. package/src/helpers/share-link-coordinator/index.ts +8 -0
  220. package/src/helpers/share-link-coordinator/recovery-runner.ts +428 -0
  221. package/src/helpers/share-link-coordinator/runtime.ts +88 -0
  222. package/src/helpers/share-link-coordinator/stat.ts +30 -0
  223. package/src/helpers/share-link-coordinator/types.ts +353 -0
  224. package/src/helpers/share-link-lifecycle/index.ts +4 -0
  225. package/src/helpers/share-link-lifecycle/lease.helpers.ts +35 -0
  226. package/src/helpers/share-link-lifecycle/recovery.helpers.ts +173 -0
  227. package/src/helpers/share-link-lifecycle/request-hash.helpers.ts +130 -0
  228. package/src/helpers/share-link-lifecycle/types.ts +58 -0
  229. package/src/helpers/share-link-maintenance/budget.ts +63 -0
  230. package/src/helpers/share-link-maintenance/config.ts +312 -0
  231. package/src/helpers/share-link-maintenance/index.ts +24 -0
  232. package/src/helpers/share-link-maintenance/runner.ts +223 -0
  233. package/src/helpers/share-link-maintenance/runtime.ts +334 -0
  234. package/src/helpers/share-link-maintenance/scheduler.ts +126 -0
  235. package/src/helpers/share-link-maintenance/telemetry.ts +70 -0
  236. package/src/helpers/share-link-maintenance/types.ts +89 -0
  237. package/src/helpers/share-link-owner/config.ts +48 -0
  238. package/src/helpers/share-link-owner/diagnostics.ts +89 -0
  239. package/src/helpers/share-link-owner/lazy-initializer.ts +29 -0
  240. package/src/helpers/share-link-owner-projection.ts +52 -0
  241. package/src/helpers/share-link-passcode-abuse.ts +36 -0
  242. package/src/helpers/share-link-passcode.ts +47 -0
  243. package/src/helpers/share-link-policy/index.ts +7 -0
  244. package/src/helpers/share-link-policy/production.ts +88 -0
  245. package/src/helpers/share-link-policy/resolver.ts +108 -0
  246. package/src/helpers/share-link-policy/types.ts +48 -0
  247. package/src/helpers/share-link-public/config.ts +41 -0
  248. package/src/helpers/share-link-view-notification.ts +18 -0
  249. package/src/models/Presentation.ts +2 -2
  250. package/src/models/Profile.ts +3 -2
  251. package/src/models/ShareLink.ts +130 -0
  252. package/src/models/ShareViewReceipt.ts +69 -0
  253. package/src/models/index.ts +2 -0
  254. package/src/models/share-link-constraints.ts +93 -0
  255. package/src/public-share-link-http.ts +49 -0
  256. package/src/routes/app-store.ts +2 -11
  257. package/src/routes/boosts.ts +2 -11
  258. package/src/routes/contracts.ts +2 -11
  259. package/src/routes/index.ts +139 -44
  260. package/src/routes/presentations.ts +19 -3
  261. package/src/routes/profile-manager.ts +10 -2
  262. package/src/routes/profiles.ts +17 -6
  263. package/src/routes/public-share-links.ts +814 -0
  264. package/src/routes/share-links.ts +640 -0
  265. package/src/services/delivery/adapters/postmark.adapter.ts +4 -1
  266. package/test/dids.spec.ts +10 -1
  267. package/test/guardian-gated-routes.spec.ts +13 -0
  268. package/test/helpers/share-link-fixtures.ts +94 -0
  269. package/test/managed-recipient-inbox.spec.ts +130 -0
  270. package/test/notificationMessages.spec.ts +13 -0
  271. package/test/openapi.spec.ts +20 -0
  272. package/test/presentations.spec.ts +54 -6
  273. package/test/profiles.spec.ts +67 -1
  274. package/test/share-content-client.contract.spec.ts +365 -0
  275. package/test/share-content-client.unit.spec.ts +462 -0
  276. package/test/share-link-abuse.unit.spec.ts +65 -0
  277. package/test/share-link-coordinator.neo4j.spec.ts +684 -0
  278. package/test/share-link-coordinator.unit.spec.ts +990 -0
  279. package/test/share-link-dependency-diagnostics.unit.spec.ts +127 -0
  280. package/test/share-link-lifecycle.neo4j.spec.ts +1268 -0
  281. package/test/share-link-lifecycle.unit.spec.ts +205 -0
  282. package/test/share-link-list-cursor.unit.spec.ts +86 -0
  283. package/test/share-link-list.neo4j.spec.ts +324 -0
  284. package/test/share-link-maintenance.neo4j.spec.ts +355 -0
  285. package/test/share-link-maintenance.unit.spec.ts +1113 -0
  286. package/test/share-link-owner-policy.unit.spec.ts +210 -0
  287. package/test/share-link-owner-route.unit.spec.ts +752 -0
  288. package/test/share-link-owner-runtime.unit.spec.ts +102 -0
  289. package/test/share-link-passcode.unit.spec.ts +29 -0
  290. package/test/share-link-policy.unit.spec.ts +135 -0
  291. package/test/share-link-presentation-metadata.unit.spec.ts +49 -0
  292. package/test/share-link-production-policy.unit.spec.ts +77 -0
  293. package/test/share-link-public-config.unit.spec.ts +64 -0
  294. package/test/share-link-public-http-adapter.unit.spec.ts +315 -0
  295. package/test/share-link-public-route.unit.spec.ts +800 -0
  296. package/test/share-link-receipt.neo4j.spec.ts +943 -0
  297. package/test/share-link-recovery.neo4j.spec.ts +935 -0
  298. package/test/share-link-recovery.unit.spec.ts +726 -0
  299. package/vitest.config.ts +1 -0
  300. package/vitest.share-links-neo4j.config.ts +19 -0
  301. package/vitest.share-links-unit.config.ts +28 -0
@@ -0,0 +1,684 @@
1
+ import {
2
+ SHARE_LINK_OPERATION_RETENTION_MS,
3
+ computeLeaseExpiry,
4
+ isLeaseActive,
5
+ } from '@helpers/share-link-lifecycle';
6
+ import {
7
+ DEFAULT_SHARE_LINK_POLICY,
8
+ type ShareLinkPolicySnapshot,
9
+ } from '@helpers/share-link-policy/types';
10
+
11
+ import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
12
+ import type { ShareLinkRecord } from '../../models/ShareLink';
13
+ import { failShareLink } from './errors';
14
+ import {
15
+ deleteReservation,
16
+ enqueueCleanupJob,
17
+ generateObjectRef,
18
+ generateOperationId,
19
+ lockShare,
20
+ markOperationAbandoned,
21
+ readOperation,
22
+ readReservation,
23
+ readShareById,
24
+ toShareLinkOperationRecord,
25
+ toShareLinkRecord,
26
+ toShareLinkReservationRecord,
27
+ writeOperationInProgress,
28
+ type ShareLinkOperationKey,
29
+ } from './helpers';
30
+ import { withShareLinkTransaction, type ShareLinkTransaction } from './transaction';
31
+ import type {
32
+ ReserveCreateInput,
33
+ ReserveReplacementInput,
34
+ ReserveShareLinkResult,
35
+ ShareLinkReservationRecord,
36
+ } from './types';
37
+
38
+ const pruneAfterFrom = (now: Date): string =>
39
+ new Date(now.getTime() + SHARE_LINK_OPERATION_RETENTION_MS).toISOString();
40
+
41
+ function assertSafeVersion(value: unknown, field: string): asserts value is number {
42
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
43
+ failShareLink('INVALID_INPUT', `${field} must be a positive safe integer`);
44
+ }
45
+ }
46
+
47
+ const parseRecordedResult = (
48
+ operationProps: Record<string, unknown> | null,
49
+ current: ShareLinkRecord
50
+ ): ShareLinkRecord => {
51
+ const resultJson = operationProps?.resultJson;
52
+
53
+ if (typeof resultJson !== 'string') return current;
54
+
55
+ try {
56
+ return toShareLinkRecord(JSON.parse(resultJson) as Record<string, unknown>);
57
+ } catch {
58
+ return current;
59
+ }
60
+ };
61
+
62
+ type ReservationProps = {
63
+ shareId: string;
64
+ namespace: string;
65
+ ownerProfileId: string;
66
+ opKind: 'create' | 'update';
67
+ clientRequestId: string;
68
+ requestHash: string;
69
+ operationId: string;
70
+ objectRef: string | null;
71
+ contentVersion: number | null;
72
+ baseVersion: number;
73
+ baseContentVersion: number;
74
+ contentHash: string | null;
75
+ contentBytes: number | null;
76
+ recoveryHash: string | null;
77
+ recoveryBytes: number | null;
78
+ title: string;
79
+ note: string | null;
80
+ expiresAt: string | null;
81
+ selectedCount: number;
82
+ passcodeHash: string | null;
83
+ notifyOnView: boolean;
84
+ policyIsMinor: boolean | null;
85
+ policyResolved: boolean;
86
+ policyDefaultExpiryDays: number;
87
+ policyViewCountingEnabled: boolean;
88
+ generation: number;
89
+ leaseOwner: string;
90
+ leaseExpiresAt: string;
91
+ createdAt: string;
92
+ updatedAt: string;
93
+ };
94
+
95
+ const writeReservation = async (
96
+ tx: ShareLinkTransaction,
97
+ props: ReservationProps
98
+ ): Promise<void> => {
99
+ await tx.run('CREATE (r:ShareLinkReservation) SET r = $props', { props });
100
+ };
101
+
102
+ const bumpShareGeneration = async (
103
+ tx: ShareLinkTransaction,
104
+ shareId: string,
105
+ generation: number,
106
+ now: string
107
+ ): Promise<void> => {
108
+ await tx.run(
109
+ `MATCH (s:ShareLink {id: $shareId})
110
+ SET s.generation = $generation, s.lockTick = coalesce(s.lockTick, 0) + 1, s.updatedAt = $now`,
111
+ { shareId, generation, now }
112
+ );
113
+ };
114
+
115
+ /**
116
+ * Irreversibly supersedes a stale reservation: queue cleanup for its exact
117
+ * immutable object, mark its operation abandoned so a late finalize is rejected,
118
+ * and remove the reservation. Used when the lease has expired.
119
+ */
120
+ const supersedeReservation = async (
121
+ tx: ShareLinkTransaction,
122
+ reservationProps: Record<string, unknown>,
123
+ now: string
124
+ ): Promise<ShareLinkReservationRecord> => {
125
+ const reservation = toShareLinkReservationRecord(reservationProps);
126
+
127
+ if (reservation.objectRef) {
128
+ await enqueueCleanupJob(tx, {
129
+ objectRef: reservation.objectRef,
130
+ operationId: reservation.operationId,
131
+ namespace: reservation.namespace,
132
+ ownerProfileId: reservation.ownerProfileId,
133
+ shareId: reservation.shareId,
134
+ contentVersion: reservation.contentVersion ?? reservation.baseContentVersion,
135
+ reason: 'abandoned',
136
+ now,
137
+ });
138
+ }
139
+
140
+ await markOperationAbandoned(tx, {
141
+ namespace: reservation.namespace,
142
+ ownerProfileId: reservation.ownerProfileId,
143
+ opKind: reservation.opKind,
144
+ clientRequestId: reservation.clientRequestId,
145
+ now,
146
+ });
147
+ await deleteReservation(tx, reservation.shareId);
148
+
149
+ return reservation;
150
+ };
151
+
152
+ /**
153
+ * Reserves the first content revision for a new share.
154
+ *
155
+ * Idempotent on `(namespace, ownerProfileId, 'create', clientRequestId)`: the same
156
+ * request hash returns the recorded committed result or resumes the in-flight
157
+ * reservation; a different hash under the same key is a conflict. A duplicate
158
+ * share id owned by someone else fails closed without leaking which owner holds it.
159
+ */
160
+ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveShareLinkResult> => {
161
+ await ensureShareLinkConstraints();
162
+
163
+ const now = input.now ?? new Date();
164
+ const nowIso = now.toISOString();
165
+ const leaseExpiresAt = computeLeaseExpiry(now, input.leaseMs);
166
+ const pruneAfter = pruneAfterFrom(now);
167
+ const opKey: ShareLinkOperationKey = {
168
+ namespace: input.namespace,
169
+ ownerProfileId: input.ownerProfileId,
170
+ opKind: 'create',
171
+ clientRequestId: input.clientRequestId,
172
+ };
173
+
174
+ assertSafeVersion(input.selectedCount, 'selectedCount');
175
+
176
+ return withShareLinkTransaction(async tx => {
177
+ // Lock before reading operation/reservation state when the share exists; a
178
+ // brand-new share has no node to lock and is protected by the unique id
179
+ // constraint instead.
180
+ const shareProps = await lockShare(tx, input.shareId);
181
+ const existingOperation = await readOperation(tx, opKey);
182
+
183
+ // When an expired reservation for the SAME logical operation is
184
+ // superseded, its originally persisted effective expiry must survive the
185
+ // replacement. The coordinator re-derives an omitted-expiry default from
186
+ // the current clock on every call, so trusting `input.expiresAt` here
187
+ // would silently extend a retried create. The replacement still receives
188
+ // a fresh immutable object ref (queued cleanup for the abandoned upload).
189
+ // If abandonment already deleted the reservation, the pending share
190
+ // still holds that logical create's original effective expiry.
191
+ let supersededExpiresAt: { value: string | null } | null = null;
192
+
193
+ if (existingOperation) {
194
+ const operation = toShareLinkOperationRecord(existingOperation);
195
+
196
+ if (operation.requestHash !== input.requestHash) {
197
+ failShareLink(
198
+ 'CONFLICT',
199
+ 'clientRequestId was already used with a different create request'
200
+ );
201
+ }
202
+
203
+ const share = shareProps ? toShareLinkRecord(shareProps) : null;
204
+
205
+ if (operation.status === 'committed') {
206
+ if (!share) {
207
+ failShareLink('CONFLICT', 'recorded create result is no longer available');
208
+ }
209
+
210
+ return {
211
+ outcome: 'already_committed',
212
+ recorded: parseRecordedResult(existingOperation, share),
213
+ current: share,
214
+ };
215
+ }
216
+
217
+ const existingReservationProps = await readReservation(tx, input.shareId);
218
+
219
+ if (existingReservationProps) {
220
+ const existingReservation = toShareLinkReservationRecord(existingReservationProps);
221
+ const sameOperation =
222
+ existingReservation.operationId === operation.operationId &&
223
+ existingReservation.opKind === 'create' &&
224
+ existingReservation.clientRequestId === input.clientRequestId;
225
+
226
+ if (!sameOperation) {
227
+ failShareLink(
228
+ 'OPERATION_IN_FLIGHT',
229
+ 'another create/update reservation is already in flight for this share'
230
+ );
231
+ }
232
+
233
+ if (isLeaseActive(existingReservation.leaseExpiresAt, now)) {
234
+ if (!share) {
235
+ failShareLink('CONFLICT', 'in-flight reservation exists without a share');
236
+ }
237
+
238
+ return {
239
+ outcome: 'reserved',
240
+ state: 'resumed',
241
+ share,
242
+ reservation: existingReservation,
243
+ };
244
+ }
245
+
246
+ // Same logical operation, expired lease: retain its expiry.
247
+ supersededExpiresAt = { value: existingReservation.expiresAt };
248
+ await supersedeReservation(tx, existingReservationProps, nowIso);
249
+ }
250
+
251
+ if (!share) {
252
+ failShareLink('CONFLICT', 'operation record exists but the share is missing');
253
+ }
254
+ if (share.status === 'stopped') {
255
+ failShareLink('CONFLICT', 'cannot re-drive create for a stopped share');
256
+ }
257
+
258
+ return createReservationForShare(tx, {
259
+ share,
260
+ opKey,
261
+ operationId: generateOperationId(),
262
+ requestHash: input.requestHash,
263
+ objectRef: generateObjectRef(),
264
+ contentVersion: 1,
265
+ baseContentVersion: share.contentVersion,
266
+ content: input.content,
267
+ selectedCount: input.selectedCount,
268
+ title: input.title,
269
+ note: input.note ?? null,
270
+ expiresAt:
271
+ supersededExpiresAt !== null ? supersededExpiresAt.value : share.expiresAt,
272
+ passcodeHash: share.passcodeHash ?? input.passcodeHash ?? null,
273
+ notifyOnView: share.notifyOnView ?? input.notifyOnView ?? false,
274
+ policy: input.policy ?? DEFAULT_SHARE_LINK_POLICY,
275
+ leaseOwner: input.leaseOwner,
276
+ leaseExpiresAt,
277
+ nowIso,
278
+ pruneAfter,
279
+ });
280
+ }
281
+
282
+ if (shareProps) {
283
+ const share = toShareLinkRecord(shareProps);
284
+
285
+ // Never disclose whether another owner holds this id.
286
+ if (
287
+ share.namespace !== input.namespace ||
288
+ share.ownerProfileId !== input.ownerProfileId
289
+ ) {
290
+ failShareLink('CONFLICT', 'share id is not available');
291
+ }
292
+
293
+ failShareLink('CONFLICT', 'share id is not available');
294
+ }
295
+
296
+ const operationId = generateOperationId();
297
+ const objectRef = generateObjectRef();
298
+ const policy = input.policy ?? DEFAULT_SHARE_LINK_POLICY;
299
+
300
+ const shareRecord: Record<string, unknown> = {
301
+ id: input.shareId,
302
+ namespace: input.namespace,
303
+ ownerProfileId: input.ownerProfileId,
304
+ version: 1,
305
+ contentVersion: 1,
306
+ generation: 1,
307
+ status: 'pending',
308
+ contentState: 'staging',
309
+ activeObjectRef: null,
310
+ activeContentHash: null,
311
+ activeContentBytes: null,
312
+ activeRecoveryHash: null,
313
+ lastOperationId: null,
314
+ createdByClientRequestId: input.clientRequestId,
315
+ title: input.title,
316
+ note: input.note ?? null,
317
+ selectedCount: input.selectedCount,
318
+ expiresAt: input.expiresAt ?? null,
319
+ stoppedAt: null,
320
+ viewCount: 0,
321
+ lastViewedAt: null,
322
+ passcodeHash: input.passcodeHash ?? null,
323
+ notifyOnView: input.notifyOnView ?? false,
324
+ minorPolicyIsMinor: policy.isMinor,
325
+ minorPolicyResolved: policy.policyResolved,
326
+ minorPolicyDefaultExpiryDays: policy.defaultExpiryDays,
327
+ minorPolicyViewCountingEnabled: policy.viewCountingEnabled,
328
+ createdAt: nowIso,
329
+ updatedAt: nowIso,
330
+ };
331
+
332
+ await tx.run('CREATE (s:ShareLink) SET s = $props', { props: shareRecord });
333
+ await writeOperationInProgress(tx, {
334
+ ...opKey,
335
+ operationId,
336
+ shareId: input.shareId,
337
+ requestHash: input.requestHash,
338
+ now: nowIso,
339
+ pruneAfter,
340
+ });
341
+ await writeReservation(tx, {
342
+ shareId: input.shareId,
343
+ namespace: input.namespace,
344
+ ownerProfileId: input.ownerProfileId,
345
+ opKind: 'create',
346
+ clientRequestId: input.clientRequestId,
347
+ requestHash: input.requestHash,
348
+ operationId,
349
+ objectRef,
350
+ contentVersion: 1,
351
+ baseVersion: 1,
352
+ baseContentVersion: 1,
353
+ contentHash: input.content.contentHash,
354
+ contentBytes: input.content.contentBytes,
355
+ recoveryHash: input.content.recoveryHash,
356
+ recoveryBytes: input.content.recoveryBytes,
357
+ title: input.title,
358
+ note: input.note ?? null,
359
+ expiresAt: input.expiresAt ?? null,
360
+ selectedCount: input.selectedCount,
361
+ passcodeHash: input.passcodeHash ?? null,
362
+ notifyOnView: input.notifyOnView ?? false,
363
+ policyIsMinor: policy.isMinor,
364
+ policyResolved: policy.policyResolved,
365
+ policyDefaultExpiryDays: policy.defaultExpiryDays,
366
+ policyViewCountingEnabled: policy.viewCountingEnabled,
367
+ generation: 1,
368
+ leaseOwner: input.leaseOwner,
369
+ leaseExpiresAt,
370
+ createdAt: nowIso,
371
+ updatedAt: nowIso,
372
+ });
373
+
374
+ const createdShare = await readShareById(tx, input.shareId);
375
+ const createdReservation = await readReservation(tx, input.shareId);
376
+
377
+ if (!createdShare || !createdReservation) {
378
+ failShareLink('CONFLICT', 'failed to persist the create reservation');
379
+ }
380
+
381
+ return {
382
+ outcome: 'reserved',
383
+ state: 'created',
384
+ share: toShareLinkRecord(createdShare),
385
+ reservation: toShareLinkReservationRecord(createdReservation),
386
+ };
387
+ });
388
+ };
389
+
390
+ type CreateReservationForShareInput = {
391
+ share: ShareLinkRecord;
392
+ opKey: ShareLinkOperationKey;
393
+ operationId: string;
394
+ requestHash: string;
395
+ objectRef: string | null;
396
+ contentVersion: number | null;
397
+ baseContentVersion: number;
398
+ content: ReserveCreateInput['content'] | ReserveReplacementInput['content'];
399
+ selectedCount: number;
400
+ title: string;
401
+ note: string | null;
402
+ expiresAt: string | null;
403
+ passcodeHash: string | null;
404
+ notifyOnView: boolean;
405
+ policy: ShareLinkPolicySnapshot;
406
+ leaseOwner: string;
407
+ leaseExpiresAt: string;
408
+ nowIso: string;
409
+ pruneAfter: string;
410
+ };
411
+
412
+ const createReservationForShare = async (
413
+ tx: ShareLinkTransaction,
414
+ input: CreateReservationForShareInput
415
+ ): Promise<ReserveShareLinkResult> => {
416
+ const generation = input.share.generation + 1;
417
+
418
+ await writeOperationInProgress(tx, {
419
+ ...input.opKey,
420
+ operationId: input.operationId,
421
+ shareId: input.share.id,
422
+ requestHash: input.requestHash,
423
+ now: input.nowIso,
424
+ pruneAfter: input.pruneAfter,
425
+ });
426
+ await bumpShareGeneration(tx, input.share.id, generation, input.nowIso);
427
+ await writeReservation(tx, {
428
+ shareId: input.share.id,
429
+ namespace: input.share.namespace,
430
+ ownerProfileId: input.share.ownerProfileId,
431
+ opKind: input.opKey.opKind === 'create' ? 'create' : 'update',
432
+ clientRequestId: input.opKey.clientRequestId,
433
+ requestHash: input.requestHash,
434
+ operationId: input.operationId,
435
+ objectRef: input.objectRef,
436
+ contentVersion: input.contentVersion,
437
+ baseVersion: input.share.version,
438
+ baseContentVersion: input.baseContentVersion,
439
+ contentHash: input.content ? input.content.contentHash : null,
440
+ contentBytes: input.content ? input.content.contentBytes : null,
441
+ recoveryHash: input.content ? input.content.recoveryHash : null,
442
+ recoveryBytes: input.content ? input.content.recoveryBytes : null,
443
+ title: input.title,
444
+ note: input.note,
445
+ expiresAt: input.expiresAt,
446
+ selectedCount: input.selectedCount,
447
+ passcodeHash: input.passcodeHash,
448
+ notifyOnView: input.notifyOnView,
449
+ policyIsMinor: input.policy.isMinor,
450
+ policyResolved: input.policy.policyResolved,
451
+ policyDefaultExpiryDays: input.policy.defaultExpiryDays,
452
+ policyViewCountingEnabled: input.policy.viewCountingEnabled,
453
+ generation,
454
+ leaseOwner: input.leaseOwner,
455
+ leaseExpiresAt: input.leaseExpiresAt,
456
+ createdAt: input.nowIso,
457
+ updatedAt: input.nowIso,
458
+ });
459
+
460
+ const updatedShare = await readShareById(tx, input.share.id);
461
+ const reservation = await readReservation(tx, input.share.id);
462
+
463
+ if (!updatedShare || !reservation) {
464
+ failShareLink('CONFLICT', 'failed to persist the reservation');
465
+ }
466
+
467
+ return {
468
+ outcome: 'reserved',
469
+ state: 'created',
470
+ share: toShareLinkRecord(updatedShare),
471
+ reservation: toShareLinkReservationRecord(reservation),
472
+ };
473
+ };
474
+
475
+ /**
476
+ * Reserves a replacement revision (content and/or metadata) for an existing share.
477
+ *
478
+ * The currently visible object reference is deliberately left untouched: old
479
+ * content stays visible while the replacement is staged and only becomes visible
480
+ * when `finalizeReservation` commits. A metadata-only reservation (no `content`)
481
+ * carries no object reference and leaves the visible content version unchanged.
482
+ *
483
+ * At most one reservation may be in flight. A reservation whose bounded lease has
484
+ * expired may be superseded with a higher generation; its staged object is queued
485
+ * for tracked cleanup and any late finalize is fenced out.
486
+ */
487
+ export const reserveReplacement = async (
488
+ input: ReserveReplacementInput
489
+ ): Promise<ReserveShareLinkResult> => {
490
+ await ensureShareLinkConstraints();
491
+ assertSafeVersion(input.expectedVersion, 'expectedVersion');
492
+
493
+ const now = input.now ?? new Date();
494
+ const nowIso = now.toISOString();
495
+ const leaseExpiresAt = computeLeaseExpiry(now, input.leaseMs);
496
+ const pruneAfter = pruneAfterFrom(now);
497
+ const opKey: ShareLinkOperationKey = {
498
+ namespace: input.namespace,
499
+ ownerProfileId: input.ownerProfileId,
500
+ opKind: 'update',
501
+ clientRequestId: input.clientRequestId,
502
+ };
503
+
504
+ if (input.content) {
505
+ assertSafeVersion(input.content.selectedCount, 'content.selectedCount');
506
+ assertSafeVersion(input.content.contentVersion, 'content.contentVersion');
507
+ }
508
+
509
+ return withShareLinkTransaction(async tx => {
510
+ const lockedProps = await lockShare(tx, input.shareId);
511
+
512
+ if (!lockedProps) {
513
+ failShareLink('NOT_FOUND', 'share not found');
514
+ }
515
+
516
+ const share = toShareLinkRecord(lockedProps);
517
+
518
+ if (share.namespace !== input.namespace || share.ownerProfileId !== input.ownerProfileId) {
519
+ failShareLink('NOT_FOUND', 'share not found');
520
+ }
521
+
522
+ const existingOperation = await readOperation(tx, opKey);
523
+
524
+ if (existingOperation) {
525
+ const operation = toShareLinkOperationRecord(existingOperation);
526
+
527
+ if (operation.requestHash !== input.requestHash) {
528
+ failShareLink(
529
+ 'CONFLICT',
530
+ 'clientRequestId was already used with a different update request'
531
+ );
532
+ }
533
+
534
+ if (operation.status === 'committed') {
535
+ return {
536
+ outcome: 'already_committed',
537
+ recorded: parseRecordedResult(existingOperation, share),
538
+ current: share,
539
+ };
540
+ }
541
+
542
+ const existingReservationProps = await readReservation(tx, input.shareId);
543
+
544
+ if (existingReservationProps) {
545
+ const existingReservation = toShareLinkReservationRecord(existingReservationProps);
546
+ const sameOperation =
547
+ existingReservation.operationId === operation.operationId &&
548
+ existingReservation.opKind === 'update' &&
549
+ existingReservation.clientRequestId === input.clientRequestId;
550
+
551
+ if (!sameOperation) {
552
+ failShareLink(
553
+ 'OPERATION_IN_FLIGHT',
554
+ 'another create/update reservation is already in flight for this share'
555
+ );
556
+ }
557
+
558
+ if (isLeaseActive(existingReservation.leaseExpiresAt, now)) {
559
+ return {
560
+ outcome: 'reserved',
561
+ state: 'resumed',
562
+ share,
563
+ reservation: existingReservation,
564
+ };
565
+ }
566
+
567
+ await supersedeReservation(tx, existingReservationProps, nowIso);
568
+ }
569
+
570
+ return reserveReplacementForLockedShare(tx, {
571
+ share,
572
+ input,
573
+ opKey,
574
+ leaseExpiresAt,
575
+ nowIso,
576
+ pruneAfter,
577
+ });
578
+ }
579
+
580
+ if (share.status === 'stopped') {
581
+ failShareLink('CONFLICT', 'cannot update a stopped share');
582
+ }
583
+
584
+ const existingReservationProps = await readReservation(tx, input.shareId);
585
+
586
+ if (existingReservationProps) {
587
+ const existingReservation = toShareLinkReservationRecord(existingReservationProps);
588
+
589
+ if (isLeaseActive(existingReservation.leaseExpiresAt, now)) {
590
+ failShareLink(
591
+ 'OPERATION_IN_FLIGHT',
592
+ 'another create/update reservation is already in flight for this share'
593
+ );
594
+ }
595
+
596
+ await supersedeReservation(tx, existingReservationProps, nowIso);
597
+ }
598
+
599
+ if (share.contentState === 'staging') {
600
+ failShareLink('CONFLICT', 'share content is still staging');
601
+ }
602
+
603
+ if (share.version !== input.expectedVersion) {
604
+ failShareLink(
605
+ 'CONFLICT',
606
+ `expectedVersion ${input.expectedVersion} does not match the current version ${share.version}`
607
+ );
608
+ }
609
+
610
+ return reserveReplacementForLockedShare(tx, {
611
+ share,
612
+ input,
613
+ opKey,
614
+ leaseExpiresAt,
615
+ nowIso,
616
+ pruneAfter,
617
+ });
618
+ });
619
+ };
620
+
621
+ const reserveReplacementForLockedShare = async (
622
+ tx: ShareLinkTransaction,
623
+ args: {
624
+ share: ShareLinkRecord;
625
+ input: ReserveReplacementInput;
626
+ opKey: ShareLinkOperationKey;
627
+ leaseExpiresAt: string;
628
+ nowIso: string;
629
+ pruneAfter: string;
630
+ }
631
+ ): Promise<ReserveShareLinkResult> => {
632
+ const { share, input, opKey, leaseExpiresAt, nowIso, pruneAfter } = args;
633
+
634
+ // Every path, including an abandoned request replay, enforces these guards.
635
+ if (share.status !== 'active' || share.contentState !== 'finalized') {
636
+ failShareLink('CONFLICT', 'share is not active and finalized');
637
+ }
638
+ if (share.version !== input.expectedVersion) {
639
+ failShareLink('CONFLICT', 'share version changed since the request');
640
+ }
641
+
642
+ if (input.content) {
643
+ const expectedContentVersion = share.contentVersion + 1;
644
+
645
+ if (input.content.contentVersion !== expectedContentVersion) {
646
+ failShareLink(
647
+ 'PRECONDITION_FAILED',
648
+ `contentVersion ${input.content.contentVersion} must equal ${expectedContentVersion}`
649
+ );
650
+ }
651
+ }
652
+
653
+ return createReservationForShare(tx, {
654
+ share,
655
+ opKey,
656
+ operationId: generateOperationId(),
657
+ requestHash: input.requestHash,
658
+ objectRef: input.content ? generateObjectRef() : null,
659
+ contentVersion: input.content ? input.content.contentVersion : null,
660
+ baseContentVersion: share.contentVersion,
661
+ content: input.content,
662
+ selectedCount: input.content ? input.content.selectedCount : share.selectedCount,
663
+ title: input.title ?? share.title,
664
+ note: input.note !== undefined ? input.note : share.note,
665
+ expiresAt: input.expiresAt !== undefined ? input.expiresAt : share.expiresAt,
666
+ passcodeHash:
667
+ input.passcodeHash !== undefined ? input.passcodeHash : (share.passcodeHash ?? null),
668
+ notifyOnView:
669
+ (input.notifyOnView !== undefined ? input.notifyOnView : share.notifyOnView === true) &&
670
+ (input.policy?.viewCountingEnabled ?? share.minorPolicyViewCountingEnabled),
671
+ policy:
672
+ input.policy ??
673
+ ({
674
+ isMinor: share.minorPolicyIsMinor,
675
+ policyResolved: share.minorPolicyResolved,
676
+ defaultExpiryDays: share.minorPolicyDefaultExpiryDays === 365 ? 365 : 30,
677
+ viewCountingEnabled: share.minorPolicyViewCountingEnabled,
678
+ } satisfies ShareLinkPolicySnapshot),
679
+ leaseOwner: input.leaseOwner,
680
+ leaseExpiresAt,
681
+ nowIso,
682
+ pruneAfter,
683
+ });
684
+ };