@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,726 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ SHARE_LINK_RECOVERY_DEFAULT_BATCH,
5
+ SHARE_LINK_RECOVERY_MAX_BATCH,
6
+ clampShareLinkRecoveryBatch,
7
+ classifyOperationBinding,
8
+ classifyReservationBinding,
9
+ computeShareLinkPayloadHash,
10
+ } from '@helpers/share-link-lifecycle';
11
+ import {
12
+ recoverShareLinkOperation,
13
+ runShareLinkRecoveryOnce,
14
+ } from '@helpers/share-link-coordinator';
15
+ import type {
16
+ RecoveryRunnerDependencies,
17
+ ShareLinkRecoveryRepository,
18
+ } from '@helpers/share-link-coordinator';
19
+
20
+ import type { ShareContentClient } from '../src/helpers/share-content-client';
21
+ import type {
22
+ ShareLinkOperationKind,
23
+ ShareLinkReservationRecord,
24
+ } from '../src/accesslayer/share-link';
25
+ import type { ShareLinkRecord } from '../src/models/ShareLink';
26
+
27
+ const NAMESPACE = 'test-namespace';
28
+ const OWNER = 'owner-1';
29
+ const SHARE_ID = Buffer.alloc(16, 1).toString('base64url');
30
+ const OBJECT_REF = 'o'.repeat(43);
31
+ const OPERATION_ID = '11111111-1111-4111-8111-111111111111';
32
+ const CLIENT_REQUEST_ID = '22222222-2222-4222-8222-222222222222';
33
+
34
+ const envelope = {
35
+ v: 1 as const,
36
+ alg: 'A256GCM' as const,
37
+ iv: Buffer.alloc(12, 2).toString('base64url'),
38
+ ct: Buffer.alloc(32, 3).toString('base64url'),
39
+ };
40
+
41
+ const recovery = { protected: 'a', iv: 'b', ciphertext: 'c', tag: 'd' };
42
+
43
+ const payloadHash = computeShareLinkPayloadHash({
44
+ envelope,
45
+ ownerEncryptedRecovery: recovery,
46
+ });
47
+
48
+ const makeShare = (overrides: Partial<ShareLinkRecord> = {}): ShareLinkRecord => ({
49
+ id: SHARE_ID,
50
+ namespace: NAMESPACE,
51
+ ownerProfileId: OWNER,
52
+ version: 1,
53
+ contentVersion: 1,
54
+ generation: 1,
55
+ status: 'pending',
56
+ contentState: 'staging',
57
+ activeObjectRef: null,
58
+ activeObjectOperationId: null,
59
+ activeContentHash: null,
60
+ activeContentBytes: null,
61
+ activeRecoveryHash: null,
62
+ lastOperationId: null,
63
+ createdByClientRequestId: null,
64
+ title: 'Shared credentials',
65
+ note: null,
66
+ selectedCount: 1,
67
+ expiresAt: null,
68
+ stoppedAt: null,
69
+ viewCount: 0,
70
+ lastViewedAt: null,
71
+ minorPolicyIsMinor: null,
72
+ minorPolicyResolved: false,
73
+ minorPolicyDefaultExpiryDays: 30,
74
+ minorPolicyViewCountingEnabled: false,
75
+ createdAt: '2026-09-20T00:00:00.000Z',
76
+ updatedAt: '2026-09-20T00:00:00.000Z',
77
+ ...overrides,
78
+ });
79
+
80
+ const makeReservation = (
81
+ overrides: Partial<ShareLinkReservationRecord> = {}
82
+ ): ShareLinkReservationRecord => ({
83
+ shareId: SHARE_ID,
84
+ namespace: NAMESPACE,
85
+ ownerProfileId: OWNER,
86
+ opKind: 'create' as ShareLinkOperationKind,
87
+ clientRequestId: CLIENT_REQUEST_ID,
88
+ requestHash: 'r'.repeat(64),
89
+ operationId: OPERATION_ID,
90
+ objectRef: OBJECT_REF,
91
+ contentVersion: 1,
92
+ baseVersion: 1,
93
+ baseContentVersion: 1,
94
+ contentHash: payloadHash,
95
+ contentBytes: 32,
96
+ recoveryHash: 'c'.repeat(64),
97
+ recoveryBytes: 12,
98
+ title: 'Shared credentials',
99
+ note: null,
100
+ expiresAt: null,
101
+ selectedCount: 1,
102
+ generation: 1,
103
+ leaseOwner: 'recovery-1',
104
+ leaseExpiresAt: '2099-01-01T00:00:00.000Z',
105
+ createdAt: '2026-09-20T00:00:00.000Z',
106
+ updatedAt: '2026-09-20T00:00:00.000Z',
107
+ ...overrides,
108
+ });
109
+
110
+ const activeStat = (overrides: Record<string, unknown> = {}) => ({
111
+ kind: 'active' as const,
112
+ namespace: NAMESPACE,
113
+ ownerProfileId: OWNER,
114
+ shareId: SHARE_ID,
115
+ contentVersion: 1,
116
+ objectId: OBJECT_REF,
117
+ operationId: OPERATION_ID,
118
+ contentHash: 'd'.repeat(64),
119
+ payloadHash,
120
+ ciphertextBytes: 32,
121
+ recoveryBytes: 12,
122
+ createdAt: '2026-09-20T00:00:00.000Z',
123
+ ...overrides,
124
+ });
125
+
126
+ const tombstoneStat = () => ({
127
+ kind: 'tombstone' as const,
128
+ namespace: NAMESPACE,
129
+ ownerProfileId: OWNER,
130
+ shareId: SHARE_ID,
131
+ contentVersion: 1,
132
+ objectId: OBJECT_REF,
133
+ operationId: OPERATION_ID,
134
+ deletedAt: '2026-09-20T00:00:00.000Z',
135
+ });
136
+
137
+ const recoveryKey = () => ({
138
+ namespace: NAMESPACE,
139
+ ownerProfileId: OWNER,
140
+ shareId: SHARE_ID,
141
+ operationId: OPERATION_ID,
142
+ });
143
+
144
+ const makeRecoveryRepository = (
145
+ overrides: Partial<ShareLinkRecoveryRepository> = {}
146
+ ): ShareLinkRecoveryRepository =>
147
+ ({
148
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [] })),
149
+ readShareLinkRecoveryTarget: vi.fn(async () => ({ state: 'absent' })),
150
+ claimRecoverableReservation: vi.fn(async () => ({
151
+ outcome: 'claimed',
152
+ share: makeShare(),
153
+ reservation: makeReservation(),
154
+ })),
155
+ abandonRecoveredReservation: vi.fn(async () => ({
156
+ outcome: 'abandoned',
157
+ cleanupQueuedFor: OBJECT_REF,
158
+ })),
159
+ finalizeReservation: vi.fn(async () => ({
160
+ outcome: 'finalized',
161
+ share: makeShare({ status: 'active', contentState: 'finalized', version: 2 }),
162
+ cleanupQueuedFor: null,
163
+ })),
164
+ ...overrides,
165
+ }) as unknown as ShareLinkRecoveryRepository;
166
+
167
+ const makeClient = (stat: ShareContentClient['stat']) =>
168
+ ({ stat }) as unknown as Pick<ShareContentClient, 'stat'>;
169
+
170
+ const makeDependencies = (
171
+ repository: ShareLinkRecoveryRepository,
172
+ stat: ShareContentClient['stat'] = vi.fn(async () => ({
173
+ ok: true,
174
+ value: activeStat(),
175
+ })) as never
176
+ ): RecoveryRunnerDependencies => ({
177
+ repository,
178
+ client: makeClient(stat),
179
+ claimant: 'recovery-1',
180
+ namespace: NAMESPACE,
181
+ leaseMs: 60_000,
182
+ now: () => new Date('2026-09-20T00:00:00.000Z'),
183
+ });
184
+
185
+ describe('share-link recovery batch bounds and binding helpers', () => {
186
+ it('bounds the discovery batch to the documented 1..100 cap', () => {
187
+ expect(SHARE_LINK_RECOVERY_DEFAULT_BATCH).toBe(25);
188
+ expect(SHARE_LINK_RECOVERY_MAX_BATCH).toBe(100);
189
+ expect(clampShareLinkRecoveryBatch(undefined)).toBe(25);
190
+ expect(clampShareLinkRecoveryBatch(0)).toBe(25);
191
+ expect(clampShareLinkRecoveryBatch(-3)).toBe(25);
192
+ expect(clampShareLinkRecoveryBatch(1)).toBe(1);
193
+ expect(clampShareLinkRecoveryBatch(50)).toBe(50);
194
+ expect(clampShareLinkRecoveryBatch(100)).toBe(100);
195
+ expect(clampShareLinkRecoveryBatch(101)).toBe(100);
196
+ expect(clampShareLinkRecoveryBatch(10_000)).toBe(100);
197
+ });
198
+
199
+ it('accepts a well-formed content reservation binding', () => {
200
+ expect(
201
+ classifyReservationBinding(
202
+ {
203
+ shareId: SHARE_ID,
204
+ namespace: NAMESPACE,
205
+ ownerProfileId: OWNER,
206
+ operationId: OPERATION_ID,
207
+ opKind: 'create',
208
+ clientRequestId: CLIENT_REQUEST_ID,
209
+ generation: 3,
210
+ baseVersion: 2,
211
+ baseContentVersion: 1,
212
+ leaseOwner: 'worker-1',
213
+ leaseExpiresAt: '2099-01-01T00:00:00.000Z',
214
+ objectRef: OBJECT_REF,
215
+ contentVersion: 2,
216
+ contentHash: payloadHash,
217
+ contentBytes: 32,
218
+ recoveryHash: 'c'.repeat(64),
219
+ recoveryBytes: 12,
220
+ },
221
+ recoveryKey()
222
+ )
223
+ ).toBe('ok');
224
+ });
225
+
226
+ it('accepts a well-formed metadata-only reservation binding', () => {
227
+ expect(
228
+ classifyReservationBinding(
229
+ {
230
+ shareId: SHARE_ID,
231
+ namespace: NAMESPACE,
232
+ ownerProfileId: OWNER,
233
+ operationId: OPERATION_ID,
234
+ opKind: 'update',
235
+ clientRequestId: CLIENT_REQUEST_ID,
236
+ generation: 3,
237
+ baseVersion: 2,
238
+ baseContentVersion: 1,
239
+ leaseOwner: 'worker-1',
240
+ leaseExpiresAt: '2099-01-01T00:00:00.000Z',
241
+ objectRef: null,
242
+ contentVersion: null,
243
+ contentHash: null,
244
+ contentBytes: null,
245
+ recoveryHash: null,
246
+ recoveryBytes: null,
247
+ },
248
+ recoveryKey()
249
+ )
250
+ ).toBe('ok');
251
+ });
252
+
253
+ it('classifies a mismatched scoped key distinctly from a malformed record', () => {
254
+ const base = {
255
+ shareId: SHARE_ID,
256
+ namespace: NAMESPACE,
257
+ ownerProfileId: OWNER,
258
+ operationId: OPERATION_ID,
259
+ opKind: 'create',
260
+ clientRequestId: CLIENT_REQUEST_ID,
261
+ generation: 1,
262
+ baseVersion: 1,
263
+ baseContentVersion: 1,
264
+ leaseOwner: 'worker-1',
265
+ leaseExpiresAt: '2099-01-01T00:00:00.000Z',
266
+ objectRef: OBJECT_REF,
267
+ contentVersion: 1,
268
+ contentHash: payloadHash,
269
+ contentBytes: 32,
270
+ recoveryHash: 'c'.repeat(64),
271
+ recoveryBytes: 12,
272
+ };
273
+
274
+ expect(
275
+ classifyReservationBinding({ ...base, operationId: 'other-op' }, recoveryKey())
276
+ ).toBe('binding_mismatch');
277
+ expect(classifyReservationBinding({ ...base, namespace: 'other-ns' }, recoveryKey())).toBe(
278
+ 'binding_mismatch'
279
+ );
280
+
281
+ const { generation: _generation, ...missingGeneration } = base;
282
+ expect(classifyReservationBinding(missingGeneration, recoveryKey())).toBe(
283
+ 'malformed_binding'
284
+ );
285
+ expect(
286
+ classifyReservationBinding({ ...base, generation: 'not-a-number' }, recoveryKey())
287
+ ).toBe('malformed_binding');
288
+ expect(classifyReservationBinding({ ...base, opKind: 'delete' }, recoveryKey())).toBe(
289
+ 'malformed_binding'
290
+ );
291
+ expect(
292
+ classifyReservationBinding(
293
+ { ...base, leaseExpiresAt: 'not-a-timestamp' },
294
+ recoveryKey()
295
+ )
296
+ ).toBe('malformed_binding');
297
+
298
+ // Object ref without its content version (or vice versa) is inconsistent.
299
+ expect(classifyReservationBinding({ ...base, contentVersion: null }, recoveryKey())).toBe(
300
+ 'malformed_binding'
301
+ );
302
+ expect(classifyReservationBinding({ ...base, objectRef: null }, recoveryKey())).toBe(
303
+ 'malformed_binding'
304
+ );
305
+ // Content bindings present without an object are malformed.
306
+ expect(
307
+ classifyReservationBinding(
308
+ {
309
+ ...base,
310
+ objectRef: null,
311
+ contentVersion: null,
312
+ contentHash: payloadHash,
313
+ },
314
+ recoveryKey()
315
+ )
316
+ ).toBe('malformed_binding');
317
+ // An object without its hashes/sizes is malformed.
318
+ expect(classifyReservationBinding({ ...base, contentHash: null }, recoveryKey())).toBe(
319
+ 'malformed_binding'
320
+ );
321
+ });
322
+
323
+ it('classifies operation records against the reservation binding', () => {
324
+ const expected = {
325
+ namespace: NAMESPACE,
326
+ ownerProfileId: OWNER,
327
+ shareId: SHARE_ID,
328
+ operationId: OPERATION_ID,
329
+ opKind: 'create',
330
+ clientRequestId: CLIENT_REQUEST_ID,
331
+ };
332
+ const base = {
333
+ namespace: NAMESPACE,
334
+ ownerProfileId: OWNER,
335
+ shareId: SHARE_ID,
336
+ operationId: OPERATION_ID,
337
+ opKind: 'create',
338
+ clientRequestId: CLIENT_REQUEST_ID,
339
+ status: 'in_progress',
340
+ };
341
+
342
+ expect(classifyOperationBinding(base, expected)).toBe('ok');
343
+ expect(classifyOperationBinding({ ...base, shareId: 'other' }, expected)).toBe(
344
+ 'binding_mismatch'
345
+ );
346
+ expect(classifyOperationBinding({ ...base, status: 'committed' }, expected)).toBe('ok');
347
+ expect(classifyOperationBinding({ ...base, opKind: 'update' }, expected)).toBe(
348
+ 'binding_mismatch'
349
+ );
350
+ expect(classifyOperationBinding({ ...base, status: 'weird' }, expected)).toBe(
351
+ 'malformed_binding'
352
+ );
353
+ const { status: _status, ...withoutStatus } = base;
354
+ expect(classifyOperationBinding(withoutStatus, expected)).toBe('malformed_binding');
355
+ });
356
+ });
357
+
358
+ describe('one-shot share-link recovery runner', () => {
359
+ it('discovers, claims, verifies a matching stat and finalizes once', async () => {
360
+ const repository = makeRecoveryRepository({
361
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
362
+ });
363
+ const stat = vi.fn(async () => ({ ok: true as const, value: activeStat() }));
364
+ const summary = await runShareLinkRecoveryOnce(
365
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
366
+ );
367
+
368
+ expect(summary).toMatchObject({
369
+ discovered: 1,
370
+ claimed: 1,
371
+ finalized: 1,
372
+ abandoned: 0,
373
+ deferred: 0,
374
+ claimLost: 0,
375
+ anomalies: 0,
376
+ });
377
+ expect(summary.categories).toEqual({ finalized: 1 });
378
+ expect(repository.finalizeReservation).toHaveBeenCalledTimes(1);
379
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
380
+ const finalizeArgs = (repository.finalizeReservation as ReturnType<typeof vi.fn>).mock
381
+ .calls[0][0];
382
+ expect(finalizeArgs).toMatchObject({
383
+ shareId: SHARE_ID,
384
+ operationId: OPERATION_ID,
385
+ objectRef: OBJECT_REF,
386
+ generation: 1,
387
+ leaseOwner: 'recovery-1',
388
+ });
389
+ });
390
+
391
+ it('abandons a missing object under the live recovery fence', async () => {
392
+ const repository = makeRecoveryRepository({
393
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
394
+ });
395
+ const stat = vi.fn(async () => ({ ok: false as const, error: 'NOT_FOUND' as const }));
396
+ const summary = await runShareLinkRecoveryOnce(
397
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
398
+ );
399
+
400
+ expect(summary).toMatchObject({ finalized: 0, abandoned: 1, deferred: 0 });
401
+ expect(summary.categories).toEqual({ abandoned_missing: 1 });
402
+ expect(repository.finalizeReservation).not.toHaveBeenCalled();
403
+ expect(repository.abandonRecoveredReservation).toHaveBeenCalledTimes(1);
404
+ });
405
+
406
+ it('abandons a permanent tombstone under the live recovery fence', async () => {
407
+ const repository = makeRecoveryRepository({
408
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
409
+ });
410
+ const stat = vi.fn(async () => ({ ok: true as const, value: tombstoneStat() }));
411
+ const summary = await runShareLinkRecoveryOnce(
412
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
413
+ );
414
+
415
+ expect(summary).toMatchObject({ abandoned: 1 });
416
+ expect(summary.categories).toEqual({ abandoned_tombstone: 1 });
417
+ expect(repository.abandonRecoveredReservation).toHaveBeenCalledTimes(1);
418
+ });
419
+
420
+ it('defers a divergent active stat without destroying content', async () => {
421
+ const repository = makeRecoveryRepository({
422
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
423
+ });
424
+ const stat = vi.fn(async () => ({
425
+ ok: true as const,
426
+ value: activeStat({ payloadHash: 'e'.repeat(64) }),
427
+ }));
428
+ const summary = await runShareLinkRecoveryOnce(
429
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
430
+ );
431
+
432
+ expect(summary).toMatchObject({ finalized: 0, abandoned: 0, deferred: 1 });
433
+ expect(summary.categories).toEqual({ deferred_stat_mismatch: 1 });
434
+ expect(repository.finalizeReservation).not.toHaveBeenCalled();
435
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
436
+ });
437
+
438
+ it('defers transient dependency failures without destroying content', async () => {
439
+ const repository = makeRecoveryRepository({
440
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
441
+ });
442
+ const stat = vi.fn(async () => ({ ok: false as const, error: 'TIMEOUT' as const }));
443
+ const summary = await runShareLinkRecoveryOnce(
444
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
445
+ );
446
+
447
+ expect(summary).toMatchObject({ deferred: 1 });
448
+ expect(summary.categories).toEqual({ deferred_stat_transient: 1 });
449
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
450
+ });
451
+
452
+ it('defers a deterministic auth/transport error without destroying content', async () => {
453
+ const repository = makeRecoveryRepository({
454
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
455
+ });
456
+ const stat = vi.fn(async () => ({ ok: false as const, error: 'UNAUTHORIZED' as const }));
457
+ const summary = await runShareLinkRecoveryOnce(
458
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
459
+ );
460
+
461
+ expect(summary).toMatchObject({ deferred: 1 });
462
+ expect(summary.categories).toEqual({ deferred_stat_error: 1 });
463
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
464
+ });
465
+
466
+ it('defers a thrown storage dependency as a bounded category without throwing', async () => {
467
+ const repository = makeRecoveryRepository({
468
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
469
+ });
470
+ const stat = vi.fn(async () => {
471
+ throw new Error('socket hang up');
472
+ });
473
+ const summary = await runShareLinkRecoveryOnce(
474
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
475
+ );
476
+
477
+ expect(summary).toMatchObject({ finalized: 0, abandoned: 0, deferred: 1 });
478
+ expect(summary.categories).toEqual({ deferred_stat_error: 1 });
479
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
480
+ });
481
+
482
+ it('finalizes a metadata-only reservation without any storage I/O', async () => {
483
+ const repository = makeRecoveryRepository({
484
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
485
+ claimRecoverableReservation: vi.fn(async () => ({
486
+ outcome: 'claimed' as const,
487
+ share: makeShare({ status: 'active', contentState: 'finalized', version: 2 }),
488
+ reservation: makeReservation({
489
+ opKind: 'update' as ShareLinkOperationKind,
490
+ objectRef: null,
491
+ contentVersion: null,
492
+ contentHash: null,
493
+ contentBytes: null,
494
+ recoveryHash: null,
495
+ recoveryBytes: null,
496
+ }),
497
+ })),
498
+ });
499
+ const stat = vi.fn();
500
+ const summary = await runShareLinkRecoveryOnce(
501
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
502
+ );
503
+
504
+ expect(summary).toMatchObject({ finalized: 1 });
505
+ expect(stat).not.toHaveBeenCalled();
506
+ const finalizeArgs = (repository.finalizeReservation as ReturnType<typeof vi.fn>).mock
507
+ .calls[0][0];
508
+ expect(finalizeArgs.objectRef).toBeNull();
509
+ });
510
+
511
+ it('fails closed on a malformed persisted binding without acting', async () => {
512
+ const repository = makeRecoveryRepository({
513
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
514
+ claimRecoverableReservation: vi.fn(async () => ({
515
+ outcome: 'not_claimable' as const,
516
+ reason: 'malformed_binding' as const,
517
+ })),
518
+ });
519
+ const stat = vi.fn();
520
+ const summary = await runShareLinkRecoveryOnce(
521
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
522
+ );
523
+
524
+ expect(summary).toMatchObject({ claimed: 0, anomalies: 1, finalized: 0, abandoned: 0 });
525
+ expect(summary.categories).toEqual({ malformed_binding: 1 });
526
+ expect(stat).not.toHaveBeenCalled();
527
+ expect(repository.finalizeReservation).not.toHaveBeenCalled();
528
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
529
+ });
530
+
531
+ it('records a lost claim without retrying it in the same pass', async () => {
532
+ const repository = makeRecoveryRepository({
533
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
534
+ claimRecoverableReservation: vi.fn(async () => ({
535
+ outcome: 'not_claimable' as const,
536
+ reason: 'lease_active' as const,
537
+ })),
538
+ });
539
+ const stat = vi.fn();
540
+ const summary = await runShareLinkRecoveryOnce(
541
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat'])
542
+ );
543
+
544
+ expect(summary).toMatchObject({ discovered: 1, claimed: 0, claimLost: 1 });
545
+ expect(summary.categories).toEqual({ claim_lost: 1 });
546
+ expect(repository.claimRecoverableReservation).toHaveBeenCalledTimes(1);
547
+ expect(repository.discoverRecoverableReservations).toHaveBeenCalledTimes(1);
548
+ expect(stat).not.toHaveBeenCalled();
549
+ });
550
+
551
+ it('retains recoverable work when finalize fails unexpectedly', async () => {
552
+ const repository = makeRecoveryRepository({
553
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
554
+ finalizeReservation: vi.fn(async () => {
555
+ throw new Error('neo4j unavailable');
556
+ }),
557
+ });
558
+ const summary = await runShareLinkRecoveryOnce(makeDependencies(repository));
559
+
560
+ expect(summary).toMatchObject({ finalized: 0, deferred: 1 });
561
+ expect(summary.categories).toEqual({ deferred_finalize: 1 });
562
+ expect(repository.abandonRecoveredReservation).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it('never discloses owner ids, share ids, object refs or metadata in the summary', async () => {
566
+ const repository = makeRecoveryRepository({
567
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
568
+ });
569
+ const summary = await runShareLinkRecoveryOnce(makeDependencies(repository));
570
+ const serialized = JSON.stringify(summary);
571
+
572
+ expect(serialized).not.toContain(SHARE_ID);
573
+ expect(serialized).not.toContain(OBJECT_REF);
574
+ expect(serialized).not.toContain(OWNER);
575
+ expect(serialized).not.toContain('Shared credentials');
576
+ });
577
+
578
+ it('rejects an invalid namespace or claimant before any I/O', async () => {
579
+ const repository = makeRecoveryRepository();
580
+
581
+ await expect(
582
+ runShareLinkRecoveryOnce({ ...makeDependencies(repository), namespace: 'bad:ns' })
583
+ ).rejects.toMatchObject({ code: 'INVALID_INPUT' });
584
+ await expect(
585
+ runShareLinkRecoveryOnce({ ...makeDependencies(repository), claimant: '' })
586
+ ).rejects.toMatchObject({ code: 'INVALID_INPUT' });
587
+
588
+ expect(repository.discoverRecoverableReservations).not.toHaveBeenCalled();
589
+ });
590
+ });
591
+
592
+ describe('scoped-key share-link recovery retry', () => {
593
+ it('returns committed current state without claiming or re-uploading after response loss', async () => {
594
+ const repository = makeRecoveryRepository({
595
+ readShareLinkRecoveryTarget: vi.fn(async () => ({
596
+ state: 'committed' as const,
597
+ share: makeShare({ status: 'active', contentState: 'finalized', version: 5 }),
598
+ })),
599
+ });
600
+ const stat = vi.fn();
601
+ const result = await recoverShareLinkOperation(
602
+ makeDependencies(repository, stat as never),
603
+ {
604
+ ...recoveryKey(),
605
+ }
606
+ );
607
+
608
+ expect(result).toEqual({
609
+ status: 'committed',
610
+ share: expect.objectContaining({ version: 5 }),
611
+ });
612
+ expect(repository.claimRecoverableReservation).not.toHaveBeenCalled();
613
+ expect(stat).not.toHaveBeenCalled();
614
+ });
615
+
616
+ it('reports absent for a wrong namespace/owner/operation key without storage I/O', async () => {
617
+ const repository = makeRecoveryRepository({
618
+ readShareLinkRecoveryTarget: vi.fn(async () => ({ state: 'absent' as const })),
619
+ });
620
+ const stat = vi.fn();
621
+ const result = await recoverShareLinkOperation(
622
+ makeDependencies(repository, stat as never),
623
+ recoveryKey()
624
+ );
625
+
626
+ expect(result).toEqual({ status: 'absent' });
627
+ expect(stat).not.toHaveBeenCalled();
628
+ });
629
+
630
+ it('loads and claims a persisted reservation then verifies storage', async () => {
631
+ const repository = makeRecoveryRepository({
632
+ readShareLinkRecoveryTarget: vi.fn(async () => ({
633
+ state: 'reservation' as const,
634
+ share: makeShare(),
635
+ reservation: makeReservation(),
636
+ })),
637
+ });
638
+ const stat = vi.fn(async () => ({ ok: true as const, value: activeStat() }));
639
+ const result = await recoverShareLinkOperation(
640
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat']),
641
+ recoveryKey()
642
+ );
643
+
644
+ expect(result).toMatchObject({ status: 'committed' });
645
+ expect(repository.claimRecoverableReservation).toHaveBeenCalledTimes(1);
646
+ expect(stat).toHaveBeenCalledTimes(1);
647
+ });
648
+
649
+ it('abandons an unrecoverable missing object under the recovery fence', async () => {
650
+ const repository = makeRecoveryRepository({
651
+ readShareLinkRecoveryTarget: vi.fn(async () => ({
652
+ state: 'reservation' as const,
653
+ share: makeShare(),
654
+ reservation: makeReservation(),
655
+ })),
656
+ });
657
+ const stat = vi.fn(async () => ({ ok: false as const, error: 'NOT_FOUND' as const }));
658
+ const result = await recoverShareLinkOperation(
659
+ makeDependencies(repository, stat as unknown as ShareContentClient['stat']),
660
+ recoveryKey()
661
+ );
662
+
663
+ expect(result).toEqual({ status: 'abandoned' });
664
+ expect(repository.abandonRecoveredReservation).toHaveBeenCalledTimes(1);
665
+ });
666
+
667
+ it('surfaces a bounded not-claimable reason instead of a raw error', async () => {
668
+ const repository = makeRecoveryRepository({
669
+ readShareLinkRecoveryTarget: vi.fn(async () => ({
670
+ state: 'reservation' as const,
671
+ share: makeShare(),
672
+ reservation: makeReservation(),
673
+ })),
674
+ claimRecoverableReservation: vi.fn(async () => ({
675
+ outcome: 'not_claimable' as const,
676
+ reason: 'stale_generation' as const,
677
+ })),
678
+ });
679
+ const result = await recoverShareLinkOperation(makeDependencies(repository), recoveryKey());
680
+
681
+ expect(result).toEqual({ status: 'not_claimable', reason: 'stale_generation' });
682
+ });
683
+ });
684
+
685
+ describe('recovery review regressions', () => {
686
+ it('rejects a foreign configured namespace before any repository read', async () => {
687
+ const repository = makeRecoveryRepository({
688
+ readShareLinkRecoveryTarget: vi.fn(async () => ({
689
+ state: 'committed',
690
+ share: makeShare(),
691
+ })),
692
+ });
693
+ await expect(
694
+ recoverShareLinkOperation(makeDependencies(repository), {
695
+ ...recoveryKey(),
696
+ namespace: 'foreign',
697
+ })
698
+ ).rejects.toMatchObject({ code: 'INVALID_INPUT' });
699
+ expect(repository.readShareLinkRecoveryTarget).not.toHaveBeenCalled();
700
+ });
701
+
702
+ it.each(['active', 'missing'] as const)(
703
+ 'leaves production lease time to the repository after locking: %s',
704
+ async outcome => {
705
+ const repository = makeRecoveryRepository({
706
+ discoverRecoverableReservations: vi.fn(async () => ({ keys: [recoveryKey()] })),
707
+ });
708
+ const stat: ShareContentClient['stat'] =
709
+ outcome === 'active'
710
+ ? async () => ({ ok: true, value: activeStat() })
711
+ : async () => ({ ok: false, error: 'NOT_FOUND' });
712
+ await runShareLinkRecoveryOnce({
713
+ ...makeDependencies(repository, stat),
714
+ now: undefined,
715
+ });
716
+ expect(repository.claimRecoverableReservation).toHaveBeenCalledWith(
717
+ expect.objectContaining({ now: undefined })
718
+ );
719
+ const mutation =
720
+ outcome === 'active'
721
+ ? repository.finalizeReservation
722
+ : repository.abandonRecoveredReservation;
723
+ expect(mutation).toHaveBeenCalledWith(expect.objectContaining({ now: undefined }));
724
+ }
725
+ );
726
+ });