@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,353 @@
1
+ import type {
2
+ AbandonRecoveredReservationInput,
3
+ AbandonRecoveredReservationResult,
4
+ AbandonReservationInput,
5
+ AbandonReservationResult,
6
+ ClaimCleanupJobsInput,
7
+ ClaimCleanupJobsResult,
8
+ ClaimRecoverableReservationInput,
9
+ ClaimRecoverableReservationResult,
10
+ CompleteCleanupJobInput,
11
+ CompleteCleanupJobResult,
12
+ CurrentShareContentResult,
13
+ DiscoverRecoverableReservationsInput,
14
+ DiscoverRecoverableReservationsResult,
15
+ FinalizeReservationInput,
16
+ FinalizeReservationResult,
17
+ GetCurrentShareContentInput,
18
+ GetShareLinkInput,
19
+ RecoveryClaimRejection,
20
+ ReserveCreateInput,
21
+ ReserveReplacementInput,
22
+ ReserveShareLinkResult,
23
+ RevokeShareLinkInput,
24
+ RevokeShareLinkResult,
25
+ ShareLinkRecoveryKey,
26
+ ShareLinkRecoveryTarget,
27
+ ShareLinkReservationRecord,
28
+ } from '../../accesslayer/share-link';
29
+ import type {
30
+ ShareContentClient,
31
+ ShareContentClientErrorCode,
32
+ ShareContentClientResult,
33
+ ShareContentContentProjection,
34
+ ShareContentPutRequest,
35
+ ShareContentRecoveryProjection,
36
+ } from '../share-content-client/types';
37
+ import type { ShareEnvelope } from '@learncard/types';
38
+ import type { ShareLinkRecord } from '../../models/ShareLink';
39
+ import type { ShareLinkPolicyResolver } from '../share-link-policy/types';
40
+
41
+ /**
42
+ * Bounded, monotonic wall-clock budget shared by the recovery and cleanup runners.
43
+ *
44
+ * `exhausted()` is true once the remaining time is at or below the reserve the
45
+ * orchestrator needs to finalize (write telemetry, return a summary). A runner
46
+ * checks it before every claim so it never starts work it cannot finish; it must
47
+ * never be implemented with `Promise.race` because that would leave graph or
48
+ * remote mutations running untracked after the pass returns.
49
+ */
50
+ export type MaintenanceBudget = {
51
+ remainingMs: () => number;
52
+ exhausted: () => boolean;
53
+ /**
54
+ * Optional finalization reserve in milliseconds. Direct/legacy callers omit
55
+ * it (only `exhausted()` is enforced). The maintenance pass supplies it so a
56
+ * runner can additionally require the remaining time to cover the whole next
57
+ * unit's bounded graph/HTTP calls before it starts one.
58
+ */
59
+ reserveMs?: () => number;
60
+ };
61
+
62
+ /**
63
+ * Structural type of the C3 lifecycle repository consumed by the coordinator.
64
+ *
65
+ * It is a type-only import of the reviewed lifecycle API: production wiring
66
+ * passes the real functions, tests pass fakes. The coordinator never performs
67
+ * network I/O inside a Neo4j transaction because it always awaits the
68
+ * repository call first and only then drives the LearnCloud client.
69
+ */
70
+ export type ShareLinkLifecycleRepository = {
71
+ reserveCreate: (input: ReserveCreateInput) => Promise<ReserveShareLinkResult>;
72
+ reserveReplacement: (input: ReserveReplacementInput) => Promise<ReserveShareLinkResult>;
73
+ finalizeReservation: (input: FinalizeReservationInput) => Promise<FinalizeReservationResult>;
74
+ abandonReservation: (input: AbandonReservationInput) => Promise<AbandonReservationResult>;
75
+ revokeShareLink: (input: RevokeShareLinkInput) => Promise<RevokeShareLinkResult>;
76
+ getShareLink: (input: GetShareLinkInput) => Promise<ShareLinkRecord | null>;
77
+ getCurrentShareContent: (
78
+ input: GetCurrentShareContentInput
79
+ ) => Promise<CurrentShareContentResult>;
80
+ claimCleanupJobs: (input: ClaimCleanupJobsInput) => Promise<ClaimCleanupJobsResult>;
81
+ completeCleanupJob: (input: CompleteCleanupJobInput) => Promise<CompleteCleanupJobResult>;
82
+ };
83
+
84
+ /** Dependencies injected into the coordinator. */
85
+ export type ShareLinkCoordinatorDependencies = {
86
+ repository: ShareLinkLifecycleRepository;
87
+ client: Pick<ShareContentClient, 'put' | 'get' | 'stat' | 'delete' | 'readRecovery'>;
88
+ /** Stable bounded lease owner for this worker/process. */
89
+ leaseOwner: string;
90
+ leaseMs?: number;
91
+ now?: () => Date;
92
+ /**
93
+ * Server-side policy resolver. Omitted dependencies fall back to the
94
+ * conservative unknown-age policy (30 days, no views), never to adult.
95
+ */
96
+ policyResolver?: ShareLinkPolicyResolver;
97
+ };
98
+
99
+ /** Trusted server-side context. Never taken from the caller's request body. */
100
+ export type ShareOwnerContext = {
101
+ namespace: string;
102
+ ownerProfileId: string;
103
+ };
104
+
105
+ export type ShareContentPendingReason =
106
+ | 'upload_unavailable'
107
+ | 'stat_unavailable'
108
+ | 'stat_missing'
109
+ | 'stat_mismatch'
110
+ | 'finalize_unavailable';
111
+
112
+ export type ShareLinkCommitResult =
113
+ | { status: 'committed'; share: ShareLinkRecord }
114
+ | { status: 'replayed'; share: ShareLinkRecord; recorded: ShareLinkRecord }
115
+ | {
116
+ status: 'pending';
117
+ reservation: ShareLinkReservationRecord;
118
+ reason: ShareContentPendingReason;
119
+ };
120
+
121
+ export type ShareLinkRevokeResult = {
122
+ status: 'revoked' | 'already_stopped';
123
+ share: ShareLinkRecord;
124
+ cleanupQueuedFor: string[];
125
+ };
126
+
127
+ export type ShareLinkResumeResult =
128
+ | { status: 'committed'; share: ShareLinkRecord }
129
+ | { status: 'replayed'; share: ShareLinkRecord }
130
+ | {
131
+ status: 'pending';
132
+ reservation: ShareLinkReservationRecord;
133
+ reason: ShareContentPendingReason;
134
+ }
135
+ | { status: 'abandoned'; cleanupQueuedFor: string | null };
136
+
137
+ /** Opaque content payload supplied on a stateless retry. */
138
+ export type ShareContentPayload = {
139
+ envelope: ShareEnvelope;
140
+ ownerEncryptedRecovery: Record<string, unknown>;
141
+ };
142
+
143
+ /**
144
+ * INTERNAL ONLY. A pending reservation descriptor may only be produced by this
145
+ * server from a prior coordinator call; it must never be accepted from a client
146
+ * request body. Durable restart recovery must instead use the persisted-state
147
+ * entry points (`runShareLinkRecoveryOnce` / `recoverShareLinkOperation`), which
148
+ * never trust a caller-supplied object ref, hash, generation or lease.
149
+ */
150
+ export type PendingShareContentOperation = {
151
+ reservation: ShareLinkReservationRecord;
152
+ };
153
+
154
+ export type ShareLinkCoordinatorErrorCode =
155
+ | 'INVALID_INPUT'
156
+ | 'NOT_FOUND'
157
+ | 'CONFLICT'
158
+ | 'UNAUTHORIZED'
159
+ | 'SIGNING_FAILED'
160
+ | 'PAYLOAD_TOO_LARGE'
161
+ | 'UNAVAILABLE'
162
+ | 'LEASE_EXPIRED'
163
+ | 'STALE_GENERATION'
164
+ | 'OPERATION_IN_FLIGHT'
165
+ | 'PRECONDITION_FAILED'
166
+ | 'UNEXPECTED';
167
+
168
+ export class ShareLinkCoordinatorError extends Error {
169
+ readonly code: ShareLinkCoordinatorErrorCode;
170
+
171
+ constructor(code: ShareLinkCoordinatorErrorCode, message: string) {
172
+ super(message);
173
+ this.name = 'ShareLinkCoordinatorError';
174
+ this.code = code;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Fixed, non-sensitive cleanup outcome categories. Client transport codes are
180
+ * reused verbatim; the extra literals cover a claim that could not be confirmed
181
+ * and a throwing client/complete dependency. No value carries an identifier.
182
+ */
183
+ export type ShareCleanupCategory =
184
+ | ShareContentClientErrorCode
185
+ | 'claim_lost'
186
+ | 'claim_error'
187
+ | 'client_error'
188
+ | 'budget_exhausted';
189
+
190
+ /**
191
+ * One-shot cleanup runner result.
192
+ *
193
+ * `skipped` is a count, never a per-item array: a deterministic delete failure
194
+ * must not leak object refs, share/owner ids or exception text into telemetry.
195
+ * `categories` is a fixed histogram drawn only from `ShareCleanupCategory`.
196
+ */
197
+ export type CleanupRunSummary = {
198
+ claimed: number;
199
+ completed: number;
200
+ retried: number;
201
+ claimLost: number;
202
+ skipped: number;
203
+ categories: Partial<Record<ShareCleanupCategory, number>>;
204
+ };
205
+
206
+ export type CleanupRunnerDependencies = {
207
+ repository: Pick<ShareLinkLifecycleRepository, 'claimCleanupJobs' | 'completeCleanupJob'>;
208
+ client: Pick<ShareContentClient, 'delete'>;
209
+ claimant: string;
210
+ /** Required explicit namespace; cleanup is never global. */
211
+ namespace: string;
212
+ limit?: number;
213
+ claimMs?: number;
214
+ /**
215
+ * Max jobs per claim transaction. The scheduled maintenance pass sets this to
216
+ * 1 so a deadline cannot strand a whole claimed batch; the default preserves
217
+ * the reviewed one-shot batch behaviour.
218
+ */
219
+ claimBatchSize?: number;
220
+ /** Optional bounded budget; stops before claiming work it cannot finish. */
221
+ budget?: MaintenanceBudget;
222
+ /** Bounded managed-transaction timeout for each claim/complete unit. */
223
+ transactionTimeoutMs?: number;
224
+ /**
225
+ * Configured HTTP allowance for one bounded delete. Only used for whole-unit
226
+ * admission; the transport timeout itself is fixed when the client is built.
227
+ */
228
+ requestTimeoutMs?: number;
229
+ /** Maintenance-only: single explicit graph attempt with no inline retry. */
230
+ noInlineRetry?: boolean;
231
+ now?: () => Date;
232
+ };
233
+
234
+ /**
235
+ * Structural type of the durable reservation recovery repository.
236
+ *
237
+ * Recovery authority is always loaded from persisted Brain state: the runner is
238
+ * handed a namespace (bounded discovery) or an exact scoped key, never a
239
+ * serialized caller reservation. The coordinator only calls `finalizeReservation`
240
+ * after that repository call returns, so no remote I/O happens inside a Neo4j
241
+ * transaction.
242
+ */
243
+ export type ShareLinkRecoveryRepository = {
244
+ discoverRecoverableReservations: (
245
+ input: DiscoverRecoverableReservationsInput
246
+ ) => Promise<DiscoverRecoverableReservationsResult>;
247
+ readShareLinkRecoveryTarget: (input: ShareLinkRecoveryKey) => Promise<ShareLinkRecoveryTarget>;
248
+ claimRecoverableReservation: (
249
+ input: ClaimRecoverableReservationInput
250
+ ) => Promise<ClaimRecoverableReservationResult>;
251
+ abandonRecoveredReservation: (
252
+ input: AbandonRecoveredReservationInput
253
+ ) => Promise<AbandonRecoveredReservationResult>;
254
+ finalizeReservation: (input: FinalizeReservationInput) => Promise<FinalizeReservationResult>;
255
+ };
256
+
257
+ /**
258
+ * Bounded, non-sensitive recovery categories. These are the only per-item
259
+ * outcomes a runner summary ever exposes; records, ciphertext, owner ids and
260
+ * object refs never leave the repository call.
261
+ */
262
+ export type ShareRecoveryCategory =
263
+ | 'finalized'
264
+ | 'abandoned_missing'
265
+ | 'abandoned_tombstone'
266
+ | 'deferred_stat_mismatch'
267
+ | 'deferred_stat_transient'
268
+ | 'deferred_stat_error'
269
+ | 'deferred_finalize'
270
+ | 'claim_lost'
271
+ | 'malformed_binding'
272
+ | 'budget_exhausted';
273
+
274
+ /** Aggregate result of one bounded recovery pass; no per-item identifiers. */
275
+ export type ShareRecoveryRunSummary = {
276
+ discovered: number;
277
+ claimed: number;
278
+ finalized: number;
279
+ abandoned: number;
280
+ deferred: number;
281
+ claimLost: number;
282
+ anomalies: number;
283
+ categories: Partial<Record<ShareRecoveryCategory, number>>;
284
+ };
285
+
286
+ export type RecoveryRunnerDependencies = {
287
+ repository: ShareLinkRecoveryRepository;
288
+ client: Pick<ShareContentClient, 'stat'>;
289
+ /** Stable bounded lease owner for this recovery worker/process. */
290
+ claimant: string;
291
+ /** Required explicit namespace; discovery is never global. */
292
+ namespace: string;
293
+ limit?: number;
294
+ leaseMs?: number;
295
+ /** Optional bounded budget; stops before claiming work it cannot finish. */
296
+ budget?: MaintenanceBudget;
297
+ /** Bounded transaction timeout propagated to every recovery graph path. */
298
+ transactionTimeoutMs?: number;
299
+ /**
300
+ * Configured HTTP allowance for one bounded stat. Only used for whole-unit
301
+ * admission; the transport timeout itself is fixed when the client is built.
302
+ */
303
+ requestTimeoutMs?: number;
304
+ /** Maintenance-only: single explicit graph attempt with no inline retry. */
305
+ noInlineRetry?: boolean;
306
+ now?: () => Date;
307
+ };
308
+
309
+ /** Scoped-key recovery result. Internal only; never returned by a public route. */
310
+ export type ScopedShareRecoveryResult =
311
+ | { status: 'committed'; share: ShareLinkRecord }
312
+ | { status: 'abandoned' }
313
+ | { status: 'deferred'; category: ShareRecoveryCategory }
314
+ | { status: 'absent' }
315
+ | { status: 'not_claimable'; reason: RecoveryClaimRejection };
316
+
317
+ export type ShareLinkCoordinator = {
318
+ createShareLink: (
319
+ request: unknown,
320
+ context: ShareOwnerContext
321
+ ) => Promise<ShareLinkCommitResult>;
322
+ updateShareLink: (
323
+ request: unknown,
324
+ context: ShareOwnerContext
325
+ ) => Promise<ShareLinkCommitResult>;
326
+ revokeShareLink: (
327
+ request: unknown,
328
+ context: ShareOwnerContext
329
+ ) => Promise<ShareLinkRevokeResult>;
330
+ resumePendingOperation: (
331
+ pending: PendingShareContentOperation,
332
+ content?: ShareContentPayload
333
+ ) => Promise<ShareLinkResumeResult>;
334
+ abandonPendingOperation: (
335
+ pending: PendingShareContentOperation
336
+ ) => Promise<AbandonReservationResult>;
337
+ getShareLink: (shareId: string, context: ShareOwnerContext) => Promise<ShareLinkRecord | null>;
338
+ getActiveShareContent: (
339
+ shareId: string,
340
+ context: ShareOwnerContext
341
+ ) => Promise<CurrentShareContentResult>;
342
+ fetchShareContent: (
343
+ shareId: string,
344
+ context: ShareOwnerContext,
345
+ options?: { allowExpired?: boolean }
346
+ ) => Promise<ShareContentClientResult<ShareContentContentProjection>>;
347
+ readOwnerRecovery: (
348
+ shareId: string,
349
+ context: ShareOwnerContext
350
+ ) => Promise<ShareContentClientResult<ShareContentRecoveryProjection>>;
351
+ };
352
+
353
+ export type { ShareContentPutRequest };
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export * from './request-hash.helpers';
3
+ export * from './lease.helpers';
4
+ export * from './recovery.helpers';
@@ -0,0 +1,35 @@
1
+ import {
2
+ SHARE_LINK_CLEANUP_BASE_BACKOFF_MS,
3
+ SHARE_LINK_CLEANUP_MAX_BACKOFF_MS,
4
+ SHARE_LINK_DEFAULT_LEASE_MS,
5
+ } from './types';
6
+
7
+ /** ISO timestamp `leaseMs` milliseconds after `now`. */
8
+ export const computeLeaseExpiry = (
9
+ now: Date,
10
+ leaseMs: number = SHARE_LINK_DEFAULT_LEASE_MS
11
+ ): string => new Date(now.getTime() + Math.max(0, Math.trunc(leaseMs))).toISOString();
12
+
13
+ /**
14
+ * A lease is active only while `now` is strictly before its expiry. At the exact
15
+ * expiry instant the lease has lapsed so a stale worker cannot finalize.
16
+ */
17
+ export const isLeaseActive = (leaseExpiresAt: string | null, now: Date): boolean => {
18
+ if (!leaseExpiresAt) return false;
19
+
20
+ const expiry = Date.parse(leaseExpiresAt);
21
+
22
+ return Number.isFinite(expiry) && now.getTime() < expiry;
23
+ };
24
+
25
+ /**
26
+ * Exponential cleanup backoff capped at one day. `attempts` is the number of
27
+ * completed retry cycles already recorded on the job (starts at 0 for a new job,
28
+ * so the first retry waits one base interval).
29
+ */
30
+ export const computeCleanupBackoffMs = (attempts: number): number => {
31
+ const safeAttempts = Number.isSafeInteger(attempts) && attempts > 0 ? attempts : 0;
32
+ const backoff = SHARE_LINK_CLEANUP_BASE_BACKOFF_MS * 2 ** safeAttempts;
33
+
34
+ return Math.min(backoff, SHARE_LINK_CLEANUP_MAX_BACKOFF_MS);
35
+ };
@@ -0,0 +1,173 @@
1
+ import type { ShareLinkOperationKind } from './types';
2
+
3
+ /**
4
+ * Bounded discovery batch for durable reservation recovery.
5
+ *
6
+ * The runner must never enumerate an unbounded set: an operator gets one capped
7
+ * pass, and any remaining expired work is picked up by a later pass after its
8
+ * lease lapses again. The hard cap matches the cleanup-job batch cap.
9
+ */
10
+ export const SHARE_LINK_RECOVERY_DEFAULT_BATCH = 25;
11
+ export const SHARE_LINK_RECOVERY_MAX_BATCH = 100;
12
+
13
+ /** Clamps a caller-supplied recovery batch into the documented 1..100 window. */
14
+ export const clampShareLinkRecoveryBatch = (limit: number | undefined): number => {
15
+ if (!Number.isSafeInteger(limit) || (limit as number) < 1) {
16
+ return SHARE_LINK_RECOVERY_DEFAULT_BATCH;
17
+ }
18
+
19
+ return Math.min(limit as number, SHARE_LINK_RECOVERY_MAX_BATCH);
20
+ };
21
+
22
+ /** Exact scoped recovery key, always derived from persisted Brain state. */
23
+ export type ShareLinkRecoveryKeyShape = {
24
+ namespace: string;
25
+ ownerProfileId: string;
26
+ shareId: string;
27
+ operationId: string;
28
+ };
29
+
30
+ /** Outcome of checking one persisted record against an exact scoped key. */
31
+ export type ShareLinkRecoveryBindingCheck = 'ok' | 'binding_mismatch' | 'malformed_binding';
32
+
33
+ const RESERVATION_OP_KINDS: readonly ShareLinkOperationKind[] = ['create', 'update'];
34
+ const OPERATION_STATUSES = ['in_progress', 'committed', 'abandoned'] as const;
35
+
36
+ const isOneOf = <T extends string>(value: unknown, allowed: readonly T[]): value is T =>
37
+ typeof value === 'string' && (allowed as readonly string[]).includes(value);
38
+
39
+ const isNonEmptyString = (value: unknown): value is string =>
40
+ typeof value === 'string' && value.length > 0;
41
+
42
+ const isPositiveSafeInteger = (value: unknown): value is number =>
43
+ Number.isSafeInteger(value) && (value as number) >= 1;
44
+
45
+ const isNonNegativeSafeInteger = (value: unknown): value is number =>
46
+ Number.isSafeInteger(value) && (value as number) >= 0;
47
+
48
+ const isTimestamp = (value: unknown): value is string =>
49
+ typeof value === 'string' && Number.isFinite(Date.parse(value));
50
+
51
+ const isAbsentOrNull = (value: unknown): boolean => value === undefined || value === null;
52
+
53
+ /**
54
+ * Validates a persisted `:ShareLinkReservation` against an exact scoped key.
55
+ *
56
+ * Any missing/typed-wrong core field, an inconsistent object/content tuple, or a
57
+ * content binding without its hashes and byte counts is `malformed_binding` and
58
+ * must never be reclaimed. A well-formed record that simply does not match the
59
+ * key is `binding_mismatch`, which is an expected stale/foreign selection rather
60
+ * than corruption.
61
+ */
62
+ export const classifyReservationBinding = (
63
+ props: Record<string, unknown>,
64
+ key: ShareLinkRecoveryKeyShape
65
+ ): ShareLinkRecoveryBindingCheck => {
66
+ if (
67
+ !isNonEmptyString(props.shareId) ||
68
+ !isNonEmptyString(props.namespace) ||
69
+ !isNonEmptyString(props.ownerProfileId) ||
70
+ !isNonEmptyString(props.operationId) ||
71
+ !isNonEmptyString(props.clientRequestId) ||
72
+ !isNonEmptyString(props.leaseOwner) ||
73
+ !isPositiveSafeInteger(props.generation) ||
74
+ !isPositiveSafeInteger(props.baseVersion) ||
75
+ !isPositiveSafeInteger(props.baseContentVersion) ||
76
+ !isTimestamp(props.leaseExpiresAt)
77
+ ) {
78
+ return 'malformed_binding';
79
+ }
80
+
81
+ if (!isOneOf(props.opKind, RESERVATION_OP_KINDS)) return 'malformed_binding';
82
+
83
+ const hasObject = !isAbsentOrNull(props.objectRef);
84
+ const hasContentVersion = !isAbsentOrNull(props.contentVersion);
85
+
86
+ if (hasObject !== hasContentVersion) return 'malformed_binding';
87
+
88
+ if (hasObject) {
89
+ if (
90
+ !isNonEmptyString(props.objectRef) ||
91
+ !isPositiveSafeInteger(props.contentVersion) ||
92
+ !isNonEmptyString(props.contentHash) ||
93
+ !isNonNegativeSafeInteger(props.contentBytes) ||
94
+ !isNonEmptyString(props.recoveryHash) ||
95
+ !isNonNegativeSafeInteger(props.recoveryBytes)
96
+ ) {
97
+ return 'malformed_binding';
98
+ }
99
+ } else if (
100
+ !isAbsentOrNull(props.contentHash) ||
101
+ !isAbsentOrNull(props.contentBytes) ||
102
+ !isAbsentOrNull(props.recoveryHash) ||
103
+ !isAbsentOrNull(props.recoveryBytes)
104
+ ) {
105
+ return 'malformed_binding';
106
+ }
107
+
108
+ if (
109
+ props.namespace !== key.namespace ||
110
+ props.ownerProfileId !== key.ownerProfileId ||
111
+ props.shareId !== key.shareId ||
112
+ props.operationId !== key.operationId
113
+ ) {
114
+ return 'binding_mismatch';
115
+ }
116
+
117
+ return 'ok';
118
+ };
119
+
120
+ /** Expected binding for a reservation's idempotency `:ShareLinkOperation` record. */
121
+ export type ShareLinkRecoveryOperationExpectation = {
122
+ namespace: string;
123
+ ownerProfileId: string;
124
+ shareId: string;
125
+ operationId: string;
126
+ opKind?: string;
127
+ clientRequestId?: string;
128
+ };
129
+
130
+ /**
131
+ * Validates a persisted `:ShareLinkOperation` against the reservation it must
132
+ * drive. A missing/wrong-typed field or an unknown status is `malformed_binding`;
133
+ * a well-formed record bound to a different operation is `binding_mismatch`.
134
+ */
135
+ export const classifyOperationBinding = (
136
+ props: Record<string, unknown>,
137
+ expected: ShareLinkRecoveryOperationExpectation
138
+ ): ShareLinkRecoveryBindingCheck => {
139
+ if (
140
+ !isNonEmptyString(props.namespace) ||
141
+ !isNonEmptyString(props.ownerProfileId) ||
142
+ !isNonEmptyString(props.shareId) ||
143
+ !isNonEmptyString(props.operationId) ||
144
+ !isNonEmptyString(props.opKind) ||
145
+ !isNonEmptyString(props.clientRequestId)
146
+ ) {
147
+ return 'malformed_binding';
148
+ }
149
+
150
+ if (!isOneOf(props.status, OPERATION_STATUSES)) return 'malformed_binding';
151
+
152
+ if (
153
+ props.namespace !== expected.namespace ||
154
+ props.ownerProfileId !== expected.ownerProfileId ||
155
+ props.shareId !== expected.shareId ||
156
+ props.operationId !== expected.operationId
157
+ ) {
158
+ return 'binding_mismatch';
159
+ }
160
+
161
+ if (expected.opKind !== undefined && props.opKind !== expected.opKind) {
162
+ return 'binding_mismatch';
163
+ }
164
+
165
+ if (
166
+ expected.clientRequestId !== undefined &&
167
+ props.clientRequestId !== expected.clientRequestId
168
+ ) {
169
+ return 'binding_mismatch';
170
+ }
171
+
172
+ return 'ok';
173
+ };
@@ -0,0 +1,130 @@
1
+ import { createHash, createHmac } from 'node:crypto';
2
+
3
+ import { getShareLinkRequestHashRuntimeEnvironment } from '@environment';
4
+
5
+ import type { ShareLinkOperationKind } from './types';
6
+
7
+ /**
8
+ * Deterministic JSON encoding used for the canonical operation request hash.
9
+ *
10
+ * Object keys are sorted recursively so two semantically identical requests that
11
+ * differ only in key insertion order hash the same. Values that are not valid JSON
12
+ * (undefined, functions, bigint, symbols) are rejected instead of being silently
13
+ * dropped: a dropped field would weaken the "same request id, different request"
14
+ * conflict check.
15
+ */
16
+ export const canonicalizeJson = (
17
+ value: unknown,
18
+ ancestors = new Set<object>(),
19
+ depth = 0
20
+ ): string => {
21
+ if (depth > 32) throw new TypeError('canonical JSON exceeds depth bound');
22
+ if (value === null) return 'null';
23
+
24
+ switch (typeof value) {
25
+ case 'boolean':
26
+ return value ? 'true' : 'false';
27
+ case 'number':
28
+ if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
29
+ throw new TypeError('canonicalizeJson only supports finite numbers');
30
+ }
31
+ return JSON.stringify(value);
32
+ case 'string':
33
+ return JSON.stringify(value);
34
+ case 'object': {
35
+ if (ancestors.has(value)) throw new TypeError('cyclic JSON');
36
+ ancestors.add(value);
37
+ if (Array.isArray(value)) {
38
+ try {
39
+ const entries: string[] = [];
40
+ for (let i = 0; i < value.length; i++) {
41
+ if (!(i in value)) throw new TypeError('sparse JSON array');
42
+ entries.push(canonicalizeJson(value[i], ancestors, depth + 1));
43
+ }
44
+ return `[${entries.join(',')}]`;
45
+ } finally {
46
+ ancestors.delete(value);
47
+ }
48
+ }
49
+ if (
50
+ Object.getPrototypeOf(value) !== Object.prototype &&
51
+ Object.getPrototypeOf(value) !== null
52
+ ) {
53
+ throw new TypeError('only plain JSON objects supported');
54
+ }
55
+
56
+ const record = value as Record<string, unknown>;
57
+ const keys = Object.keys(record).sort();
58
+ const entries = keys
59
+ .filter(key => record[key] !== undefined)
60
+ .map(
61
+ key =>
62
+ `${JSON.stringify(key)}:${canonicalizeJson(record[key], ancestors, depth + 1)}`
63
+ );
64
+
65
+ ancestors.delete(value);
66
+ return `{${entries.join(',')}}`;
67
+ }
68
+ default:
69
+ throw new TypeError(`canonicalizeJson cannot encode a ${typeof value} value`);
70
+ }
71
+ };
72
+
73
+ /**
74
+ * Dedicated key for operation fingerprints. It must be stable across every Brain
75
+ * instance and deploy, otherwise an in-flight retry could become a conflict.
76
+ *
77
+ * Test runs use a fixed, test-only key. Enabled non-test deployments must
78
+ * provision an independent secret before serving share-link owner requests.
79
+ */
80
+ export const getShareLinkRequestHashSecret = (): string => {
81
+ const runtime = getShareLinkRequestHashRuntimeEnvironment();
82
+ const secret = runtime.SHARE_LINK_REQUEST_HASH_SECRET;
83
+ if (secret && Buffer.byteLength(secret, 'utf8') >= 32) return secret;
84
+ if (runtime.NODE_ENV === 'test') return 'share-link-test-only-request-hash-secret';
85
+ throw new Error('SHARE_LINK_REQUEST_HASH_SECRET must contain at least 32 bytes');
86
+ };
87
+
88
+ /**
89
+ * Canonical fingerprint of the full validated request.
90
+ *
91
+ * The caller passes the *validated* request including the opaque envelope and the
92
+ * owner-encrypted recovery: the hash therefore covers the encrypted recovery even
93
+ * though no ciphertext is ever persisted in Neo4j. `clientRequestId` is the
94
+ * operation key identity and is deliberately excluded from the payload hash; all
95
+ * other fields, including `opKind`, are covered. Protected requests use a
96
+ * server-keyed HMAC so a persisted fingerprint cannot become a fast offline
97
+ * oracle for a short passcode. Unprotected requests retain the previous SHA-256
98
+ * format so their in-flight operation retries remain compatible.
99
+ */
100
+ export const computeShareLinkRequestHash = (
101
+ opKind: ShareLinkOperationKind,
102
+ request: Record<string, unknown>
103
+ ): string => {
104
+ const { clientRequestId: _clientRequestId, ...requestPayload } = request;
105
+
106
+ const canonical = canonicalizeJson({ opKind, request: requestPayload });
107
+ if (typeof requestPayload.passcode !== 'string') {
108
+ return createHash('sha256').update(canonical).digest('hex');
109
+ }
110
+ return createHmac('sha256', getShareLinkRequestHashSecret())
111
+ .update('share-link-request:protected:v2\0')
112
+ .update(canonical)
113
+ .digest('hex');
114
+ };
115
+
116
+ /** Digest of validated opaque content, computable before reserving object IDs.
117
+ * Compare this with LearnCloud stat.payloadHash, not its identity-bound contentHash.
118
+ */
119
+ export const computeShareLinkPayloadHash = (payload: {
120
+ envelope: unknown;
121
+ ownerEncryptedRecovery: unknown;
122
+ }): string =>
123
+ createHash('sha256')
124
+ .update(
125
+ canonicalizeJson({
126
+ envelope: payload.envelope,
127
+ ownerEncryptedRecovery: payload.ownerEncryptedRecovery,
128
+ })
129
+ )
130
+ .digest('hex');