@learncard/network-brain-service 3.19.1 → 4.0.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 (315) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +120 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/bundle.mjs +9 -1
  7. package/config/config.dev.json +29 -0
  8. package/config/config.example.json +29 -0
  9. package/config/config.json +1 -0
  10. package/config/config.production.json +29 -0
  11. package/config/config.scouts.dev.json +24 -0
  12. package/config/config.scouts.production.json +24 -0
  13. package/contractEventsLambda.ts +18 -0
  14. package/contractEventsLambdaApp.ts +19 -0
  15. package/didWebLambda.ts +13 -18
  16. package/didWebLambdaApp.ts +22 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  18. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  20. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  22. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  23. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  24. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  26. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  28. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  29. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  30. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  32. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  34. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  35. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  36. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  41. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  47. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  48. package/dist/accesslayer/credential/create.d.ts +2 -1
  49. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  50. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  51. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  53. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  55. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  57. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  58. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  59. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  60. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  61. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  65. package/dist/accesslayer/share-link/types.d.ts +8 -3
  66. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +49 -0
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  70. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  71. package/dist/config/environment.d.ts +5 -0
  72. package/dist/config/environment.d.ts.map +1 -1
  73. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  74. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  75. package/dist/config/stageConfig.d.ts +112 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -0
  77. package/dist/config/stageConfig.test.d.ts +2 -0
  78. package/dist/config/stageConfig.test.d.ts.map +1 -0
  79. package/dist/didkit_wasm_bg.wasm +0 -0
  80. package/dist/docker-entry.d.ts +1 -1
  81. package/dist/docker-entry.d.ts.map +1 -1
  82. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  83. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  84. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  85. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  86. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  87. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  89. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  91. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  92. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  93. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  95. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  96. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  97. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  99. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  100. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  101. package/dist/helpers/notificationMessages.d.ts +1 -1
  102. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  103. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  104. package/dist/helpers/notifications.helpers.d.ts +6 -0
  105. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  106. package/dist/helpers/profile.helpers.d.ts +1 -0
  107. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  108. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  109. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  110. package/dist/helpers/share-link-attachment.d.ts +18 -0
  111. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  113. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  114. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  115. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  116. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  117. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  119. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  122. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  123. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  124. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  125. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  127. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  128. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  129. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  130. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  131. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  132. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  133. package/dist/index.js +2 -2
  134. package/dist/models/AppManifestVersion.d.ts +20 -0
  135. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  136. package/dist/models/AppStoreListing.d.ts +13 -0
  137. package/dist/models/AppStoreListing.d.ts.map +1 -1
  138. package/dist/models/ConsentFlowContract.d.ts +13 -2
  139. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  141. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  142. package/dist/models/Integration.d.ts +2 -0
  143. package/dist/models/Integration.d.ts.map +1 -1
  144. package/dist/models/ShareLink.d.ts +2 -0
  145. package/dist/models/ShareLink.d.ts.map +1 -1
  146. package/dist/models/index.d.ts +1 -0
  147. package/dist/models/index.d.ts.map +1 -1
  148. package/dist/models/share-link-constraints.d.ts.map +1 -1
  149. package/dist/openapi.d.ts +1 -1
  150. package/dist/openapi.d.ts.map +1 -1
  151. package/dist/publish-metafile.json +1 -0
  152. package/dist/routes/app-store.d.ts +619 -0
  153. package/dist/routes/app-store.d.ts.map +1 -1
  154. package/dist/routes/boosts.d.ts +2 -2
  155. package/dist/routes/boosts.d.ts.map +1 -1
  156. package/dist/routes/contracts.d.ts +146 -3
  157. package/dist/routes/contracts.d.ts.map +1 -1
  158. package/dist/routes/credentials.d.ts +34 -0
  159. package/dist/routes/credentials.d.ts.map +1 -1
  160. package/dist/routes/inbox.d.ts +52 -2
  161. package/dist/routes/inbox.d.ts.map +1 -1
  162. package/dist/routes/index.d.ts.map +1 -1
  163. package/dist/routes/profile-manager.d.ts.map +1 -1
  164. package/dist/routes/profiles.d.ts.map +1 -1
  165. package/dist/routes/public-share-links.d.ts +55 -0
  166. package/dist/routes/public-share-links.d.ts.map +1 -1
  167. package/dist/routes/share-links.d.ts +175 -14
  168. package/dist/routes/share-links.d.ts.map +1 -1
  169. package/dist/routes/test.d.ts +5 -0
  170. package/dist/routes/test.d.ts.map +1 -1
  171. package/dist/routes/workflows.d.ts.map +1 -1
  172. package/dist/types/app-manifest.d.ts +339 -0
  173. package/dist/types/app-manifest.d.ts.map +1 -0
  174. package/dist/types/app-store-listing.d.ts +34 -0
  175. package/dist/types/app-store-listing.d.ts.map +1 -1
  176. package/dist/types/consentflowcontract.d.ts +30 -3
  177. package/dist/types/consentflowcontract.d.ts.map +1 -1
  178. package/dist/types/credential-refresh.d.ts +1 -1
  179. package/esbuildPlugins.cjs +1 -0
  180. package/lambda.ts +49 -195
  181. package/lambdaApp.ts +203 -0
  182. package/package.json +20 -19
  183. package/project.json +8 -1
  184. package/scripts/export-openapi.ts +20 -0
  185. package/scripts/offline-schema-environment.ts +18 -0
  186. package/scripts/verify-python-contracts.ts +72 -0
  187. package/serverless-local.yml +3 -6
  188. package/serverless.function-env.cjs +50 -0
  189. package/serverless.local.yml +0 -1
  190. package/serverless.yml +29 -74
  191. package/shareLinkMaintenanceLambda.ts +13 -44
  192. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  193. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  194. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  195. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  196. package/src/accesslayer/app-store-listing/read.ts +6 -0
  197. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  198. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  199. package/src/accesslayer/app-store-listing/update.ts +15 -0
  200. package/src/accesslayer/boost/relationships/create.ts +8 -2
  201. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  202. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  203. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  204. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  205. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  206. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  207. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  208. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  209. package/src/accesslayer/credential/create.test.ts +2 -115
  210. package/src/accesslayer/credential/create.ts +11 -6
  211. package/src/accesslayer/credential/relationships/create.ts +17 -10
  212. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  213. package/src/accesslayer/inbox-credential/update.ts +4 -2
  214. package/src/accesslayer/share-link/abandon.ts +20 -0
  215. package/src/accesslayer/share-link/attachment.ts +360 -0
  216. package/src/accesslayer/share-link/finalize.ts +55 -7
  217. package/src/accesslayer/share-link/helpers.ts +33 -2
  218. package/src/accesslayer/share-link/recovery.ts +20 -0
  219. package/src/accesslayer/share-link/reserve.ts +85 -6
  220. package/src/accesslayer/share-link/revoke.ts +35 -0
  221. package/src/accesslayer/share-link/types.ts +9 -4
  222. package/src/cache/app-store.caches.ts +35 -0
  223. package/src/config/applyDockerStageConfig.ts +6 -0
  224. package/src/config/environment.test.ts +17 -12
  225. package/src/config/environment.ts +1 -0
  226. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  227. package/src/config/stageConfig.test.ts +25 -0
  228. package/src/config/stageConfig.ts +24 -0
  229. package/src/docker-entry.ts +24 -0
  230. package/src/helpers/app-manifest.helpers.ts +185 -0
  231. package/src/helpers/claim-hooks.helpers.ts +20 -7
  232. package/src/helpers/consent-referral.helpers.ts +6 -0
  233. package/src/helpers/consent-scopes.helpers.ts +58 -0
  234. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  235. package/src/helpers/contract-events.helpers.ts +540 -0
  236. package/src/helpers/contract-requests.helpers.ts +211 -0
  237. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  238. package/src/helpers/inbox.helpers.ts +51 -66
  239. package/src/helpers/inline-template.helpers.ts +560 -0
  240. package/src/helpers/notificationMessages.ts +59 -0
  241. package/src/helpers/notificationQueue.helpers.ts +23 -5
  242. package/src/helpers/notifications.helpers.ts +25 -1
  243. package/src/helpers/profile.helpers.ts +6 -0
  244. package/src/helpers/share-link-attachment-access.ts +87 -0
  245. package/src/helpers/share-link-attachment.ts +115 -0
  246. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  247. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  248. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  249. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  250. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  251. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  252. package/src/helpers/share-link-maintenance/types.ts +2 -0
  253. package/src/helpers/share-link-owner-projection.ts +2 -0
  254. package/src/helpers/share-link-policy/production.ts +37 -17
  255. package/src/helpers/share-link-policy/resolver.ts +16 -11
  256. package/src/helpers/share-link-policy/types.ts +15 -7
  257. package/src/helpers/signingAuthority.helpers.ts +23 -14
  258. package/src/helpers/smartResume.helpers.ts +123 -0
  259. package/src/models/AppManifestVersion.ts +43 -0
  260. package/src/models/AppStoreListing.ts +32 -0
  261. package/src/models/ConsentFlowContract.ts +34 -3
  262. package/src/models/ConsentFlowTerms.ts +10 -0
  263. package/src/models/ConsentFlowTransaction.ts +3 -0
  264. package/src/models/Integration.ts +2 -0
  265. package/src/models/ShareLink.ts +4 -0
  266. package/src/models/index.ts +23 -0
  267. package/src/models/share-link-constraints.ts +5 -0
  268. package/src/openapi.ts +44 -8
  269. package/src/routes/app-store.ts +1054 -27
  270. package/src/routes/boosts.ts +12 -14
  271. package/src/routes/contracts.ts +382 -262
  272. package/src/routes/inbox.ts +4 -2
  273. package/src/routes/index.ts +4 -1
  274. package/src/routes/profile-manager.ts +19 -0
  275. package/src/routes/profiles.ts +12 -0
  276. package/src/routes/public-share-links.ts +169 -0
  277. package/src/routes/share-links.ts +69 -0
  278. package/src/routes/workflows.ts +3 -2
  279. package/src/types/app-manifest.ts +28 -0
  280. package/src/types/app-store-listing.ts +13 -0
  281. package/src/types/consentflowcontract.ts +25 -4
  282. package/test/app-manifest.helpers.spec.ts +170 -0
  283. package/test/app-manifest.routes.spec.ts +299 -0
  284. package/test/app-store-listing-updates.spec.ts +236 -0
  285. package/test/app-store-listing.spec.ts +65 -3
  286. package/test/consent-data-boundaries.spec.ts +352 -0
  287. package/test/consent-recipients.spec.ts +513 -0
  288. package/test/consent-scopes.helpers.spec.ts +67 -0
  289. package/test/consentflow.spec.ts +63 -2
  290. package/test/contract-requests.spec.ts +1588 -0
  291. package/test/credential-storage.neo4j.spec.ts +122 -0
  292. package/test/guardian-gated-routes.spec.ts +36 -0
  293. package/test/inbox-security.e2e.ts +26 -10
  294. package/test/inbox.spec.ts +184 -8
  295. package/test/inline-template.helpers.spec.ts +49 -0
  296. package/test/managed-recipient-inbox.spec.ts +24 -2
  297. package/test/openapi.spec.ts +14 -73
  298. package/test/profiles.spec.ts +30 -0
  299. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  300. package/test/share-link-attachment.unit.spec.ts +244 -0
  301. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  302. package/test/share-link-maintenance.unit.spec.ts +32 -5
  303. package/test/share-link-owner-route.unit.spec.ts +70 -2
  304. package/test/share-link-policy.unit.spec.ts +18 -4
  305. package/test/share-link-production-policy.unit.spec.ts +58 -9
  306. package/test/share-link-public-route.unit.spec.ts +279 -0
  307. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  308. package/test/signing-authority-encryption.spec.ts +75 -0
  309. package/test/smart-resume-consent.spec.ts +233 -0
  310. package/test/smart-resume-upload.spec.ts +65 -0
  311. package/tsconfig.build.json +1 -1
  312. package/tsconfig.json +1 -0
  313. package/vitest.config.ts +13 -0
  314. package/vitest.inbox-e2e.config.ts +2 -0
  315. package/workerLambda.test.ts +100 -0
@@ -0,0 +1,360 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { int } from 'neo4j-driver';
3
+ import type { ShareLinkAttachment } from '@learncard/types';
4
+ import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
5
+ import { failShareLink } from './errors';
6
+ import {
7
+ enqueueCleanupJob,
8
+ lockShare,
9
+ readNodeProperties,
10
+ readReservation,
11
+ toShareLinkRecord,
12
+ toShareLinkReservationRecord,
13
+ normalizeNeo4jValue,
14
+ } from './helpers';
15
+ import {
16
+ withShareLinkTransaction,
17
+ type ShareLinkTransaction,
18
+ type ShareLinkTransactionOptions,
19
+ } from './transaction';
20
+
21
+ export type ShareAttachmentBinding = {
22
+ namespace: string;
23
+ ownerProfileId: string;
24
+ shareId: string;
25
+ contentVersion: number;
26
+ attachmentId: string;
27
+ chunkCount: number;
28
+ };
29
+
30
+ export const shareAttachmentObjectId = (attachmentId: string, chunkIndex: number): string =>
31
+ `resume-pdf-${attachmentId}-${chunkIndex}`;
32
+ export const shareAttachmentTuple = (binding: ShareAttachmentBinding, chunkIndex: number) => ({
33
+ namespace: binding.namespace,
34
+ ownerProfileId: binding.ownerProfileId,
35
+ shareId: binding.shareId,
36
+ contentVersion: binding.contentVersion,
37
+ objectId: shareAttachmentObjectId(binding.attachmentId, chunkIndex),
38
+ operationId: binding.attachmentId,
39
+ });
40
+
41
+ const stageKey = (binding: ShareAttachmentBinding): string =>
42
+ createHash('sha256')
43
+ .update(
44
+ JSON.stringify([
45
+ binding.namespace,
46
+ binding.ownerProfileId,
47
+ binding.shareId,
48
+ binding.contentVersion,
49
+ binding.attachmentId,
50
+ ])
51
+ )
52
+ .digest('hex');
53
+
54
+ const lockStage = async (tx: ShareLinkTransaction, binding: ShareAttachmentBinding) => {
55
+ const result = await tx.run(
56
+ `MATCH (a:ShareLinkAttachmentStage {key: $key})
57
+ SET a.lockTick = coalesce(a.lockTick, 0) + 1 RETURN a`,
58
+ { key: stageKey(binding) }
59
+ );
60
+ const props = readNodeProperties(result, 'a');
61
+ if (props && props.chunkCount !== binding.chunkCount) {
62
+ failShareLink('CONFLICT', 'attachment staging conflict');
63
+ }
64
+ return props;
65
+ };
66
+
67
+ /** Stage guard is durable; remote I/O happens only after this transaction closes. */
68
+ export const beginShareAttachmentChunk = async (
69
+ binding: ShareAttachmentBinding,
70
+ chunkIndex: number
71
+ ): Promise<void> => {
72
+ if (
73
+ !Number.isInteger(binding.chunkCount) ||
74
+ binding.chunkCount < 1 ||
75
+ binding.chunkCount > 16 ||
76
+ !Number.isInteger(chunkIndex) ||
77
+ chunkIndex < 0 ||
78
+ chunkIndex >= binding.chunkCount
79
+ )
80
+ failShareLink('INVALID_INPUT', 'invalid attachment chunk');
81
+ await ensureShareLinkConstraints();
82
+ await withShareLinkTransaction(async tx => {
83
+ const shareProps = await lockShare(tx, binding.shareId);
84
+ if (shareProps) {
85
+ const share = toShareLinkRecord(shareProps);
86
+ if (
87
+ share.namespace !== binding.namespace ||
88
+ share.ownerProfileId !== binding.ownerProfileId
89
+ ) {
90
+ failShareLink('NOT_FOUND', 'share not found');
91
+ }
92
+ if (
93
+ share.status === 'stopped' ||
94
+ !(
95
+ (share.status === 'pending' && binding.contentVersion === 1) ||
96
+ binding.contentVersion === share.contentVersion + 1 ||
97
+ (binding.contentVersion === share.contentVersion &&
98
+ share.attachmentId === binding.attachmentId &&
99
+ share.attachmentChunkCount === binding.chunkCount)
100
+ )
101
+ )
102
+ failShareLink('CONFLICT', 'attachment version conflict');
103
+ const pending = await readReservation(tx, binding.shareId);
104
+ if (pending) {
105
+ const reservation = toShareLinkReservationRecord(pending);
106
+ if (
107
+ reservation.contentVersion !== binding.contentVersion ||
108
+ reservation.attachmentId !== binding.attachmentId ||
109
+ reservation.attachmentChunkCount !== binding.chunkCount
110
+ ) {
111
+ failShareLink('CONFLICT', 'another publication is pending');
112
+ }
113
+ }
114
+ } else if (binding.contentVersion !== 1) {
115
+ failShareLink('NOT_FOUND', 'share not found');
116
+ }
117
+ // Serialize pre-create stages by owner as well as by share, so simultaneous
118
+ // random IDs cannot exceed the bounded uncommitted storage allowance.
119
+ const quotaKey = createHash('sha256')
120
+ .update(JSON.stringify([binding.namespace, binding.ownerProfileId]))
121
+ .digest('hex');
122
+ await tx.run(
123
+ `MERGE (q:ShareAttachmentOwnerQuota {key: $quotaKey})
124
+ ON CREATE SET q.namespace = $namespace, q.ownerProfileId = $ownerProfileId
125
+ SET q.lockTick = coalesce(q.lockTick, 0) + 1`,
126
+ {
127
+ quotaKey,
128
+ namespace: binding.namespace,
129
+ ownerProfileId: binding.ownerProfileId,
130
+ }
131
+ );
132
+ const existing = await lockStage(tx, binding);
133
+ if (!existing) {
134
+ const result = await tx.run(
135
+ `MATCH (a:ShareLinkAttachmentStage)
136
+ WHERE a.namespace = $namespace AND a.ownerProfileId = $ownerProfileId
137
+ AND a.status = 'staging' AND a.expiresAt > $now
138
+ RETURN count(a) AS count`,
139
+ {
140
+ namespace: binding.namespace,
141
+ ownerProfileId: binding.ownerProfileId,
142
+ now: new Date().toISOString(),
143
+ }
144
+ );
145
+ const count = normalizeNeo4jValue(result.records[0]?.get('count'));
146
+ if (typeof count !== 'number' || count >= 8)
147
+ failShareLink('PRECONDITION_FAILED', 'attachment staging limit reached');
148
+ }
149
+ await tx.run(
150
+ `MERGE (a:ShareLinkAttachmentStage {key: $key})
151
+ ON CREATE SET a += $props
152
+ SET a.lockTick = coalesce(a.lockTick, 0) + 1 RETURN a`,
153
+ {
154
+ key: stageKey(binding),
155
+ props: {
156
+ ...binding,
157
+ status: 'staging',
158
+ completedChunks: [],
159
+ createdAt: new Date().toISOString(),
160
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
161
+ },
162
+ }
163
+ );
164
+ const stage = await lockStage(tx, binding);
165
+ if (!stage || stage.status === 'deleted')
166
+ failShareLink('CONFLICT', 'attachment staging conflict');
167
+ if (
168
+ stage.status === 'staging' &&
169
+ typeof stage.expiresAt === 'string' &&
170
+ stage.expiresAt <= new Date().toISOString()
171
+ ) {
172
+ failShareLink('CONFLICT', 'attachment staging expired');
173
+ }
174
+ });
175
+ };
176
+
177
+ /**
178
+ * Record successful immutable puts even if staging expired during remote I/O.
179
+ * This is bookkeeping, not publication authority: pin/finalize enforce expiry,
180
+ * and maintenance cleans up an expired, uncommitted stage.
181
+ */
182
+ export const completeShareAttachmentChunk = async (
183
+ binding: ShareAttachmentBinding,
184
+ chunkIndex: number
185
+ ): Promise<void> => {
186
+ await withShareLinkTransaction(async tx => {
187
+ const stage = await lockStage(tx, binding);
188
+ if (!stage || stage.status === 'deleted')
189
+ failShareLink('CONFLICT', 'attachment staging conflict');
190
+ await tx.run(
191
+ `MATCH (a:ShareLinkAttachmentStage {key: $key})
192
+ SET a.completedChunks = CASE WHEN $chunkIndex IN a.completedChunks THEN a.completedChunks
193
+ ELSE a.completedChunks + $chunkIndex END`,
194
+ { key: stageKey(binding), chunkIndex }
195
+ );
196
+ });
197
+ };
198
+
199
+ /** Called while the share lock is held; pins complete staged chunks to request intent. */
200
+ export const pinShareAttachment = async (
201
+ tx: ShareLinkTransaction,
202
+ binding: ShareAttachmentBinding,
203
+ requestHash: string
204
+ ): Promise<void> => {
205
+ const stage = await lockStage(tx, binding);
206
+ if (
207
+ !stage ||
208
+ stage.status === 'deleted' ||
209
+ (stage.requestHash !== undefined &&
210
+ stage.requestHash !== null &&
211
+ stage.requestHash !== requestHash)
212
+ ) {
213
+ failShareLink('PRECONDITION_FAILED', 'attachment is not ready');
214
+ }
215
+ if (
216
+ stage.status === 'staging' &&
217
+ typeof stage.expiresAt === 'string' &&
218
+ stage.expiresAt <= new Date().toISOString()
219
+ ) {
220
+ failShareLink('PRECONDITION_FAILED', 'attachment staging expired');
221
+ }
222
+ const completed = stage.completedChunks;
223
+ if (
224
+ !Array.isArray(completed) ||
225
+ !Array.from({ length: binding.chunkCount }, (_, index) => index).every(index =>
226
+ completed.includes(index)
227
+ )
228
+ )
229
+ failShareLink('PRECONDITION_FAILED', 'attachment is incomplete');
230
+ await tx.run(
231
+ `MATCH (a:ShareLinkAttachmentStage {key: $key}) SET a.status = 'pinned',
232
+ a.requestHash = $requestHash`,
233
+ { key: stageKey(binding), requestHash }
234
+ );
235
+ };
236
+
237
+ export const verifyPinnedShareAttachment = async (
238
+ tx: ShareLinkTransaction,
239
+ binding: ShareAttachmentBinding,
240
+ requestHash: string
241
+ ): Promise<void> => {
242
+ const stage = await lockStage(tx, binding);
243
+ if (!stage || stage.status !== 'pinned' || stage.requestHash !== requestHash) {
244
+ failShareLink('PRECONDITION_FAILED', 'attachment is not ready');
245
+ }
246
+ };
247
+
248
+ /** Queues only exact tuples, after withdrawing graph authority to commit those bytes. */
249
+ export const queueShareAttachmentCleanup = async (
250
+ tx: ShareLinkTransaction,
251
+ binding: ShareAttachmentBinding,
252
+ reason: 'superseded' | 'abandoned' | 'stopped',
253
+ now: string
254
+ ): Promise<void> => {
255
+ const stage = await lockStage(tx, binding);
256
+ if (stage)
257
+ await tx.run(`MATCH (a:ShareLinkAttachmentStage {key: $key}) SET a.status = 'deleted'`, {
258
+ key: stageKey(binding),
259
+ });
260
+ for (let chunkIndex = 0; chunkIndex < binding.chunkCount; chunkIndex++) {
261
+ const tuple = shareAttachmentTuple(binding, chunkIndex);
262
+ await enqueueCleanupJob(tx, { ...tuple, objectRef: tuple.objectId, reason, now });
263
+ }
264
+ };
265
+
266
+ /** Refuses active or in-flight assets; a tombstoned stage can never be repinned. */
267
+ export const deleteUnreferencedShareAttachment = async (
268
+ binding: ShareAttachmentBinding
269
+ ): Promise<boolean> => {
270
+ await ensureShareLinkConstraints();
271
+ return withShareLinkTransaction(async tx => {
272
+ const shareProps = await lockShare(tx, binding.shareId);
273
+ if (shareProps) {
274
+ const share = toShareLinkRecord(shareProps);
275
+ if (
276
+ share.namespace !== binding.namespace ||
277
+ share.ownerProfileId !== binding.ownerProfileId
278
+ ) {
279
+ failShareLink('NOT_FOUND', 'share not found');
280
+ }
281
+ if (
282
+ share.attachmentId === binding.attachmentId &&
283
+ share.contentVersion === binding.contentVersion
284
+ )
285
+ return false;
286
+ const props = await readReservation(tx, binding.shareId);
287
+ const reservation = props ? toShareLinkReservationRecord(props) : null;
288
+ if (
289
+ reservation?.attachmentId === binding.attachmentId &&
290
+ reservation.contentVersion === binding.contentVersion
291
+ )
292
+ return false;
293
+ }
294
+ const stage = await lockStage(tx, binding);
295
+ if (!stage) return false;
296
+ if (stage.status === 'pinned') return false;
297
+ await queueShareAttachmentCleanup(tx, binding, 'abandoned', new Date().toISOString());
298
+ return true;
299
+ });
300
+ };
301
+
302
+ export const attachmentBindingFor = (
303
+ scope: { namespace: string; ownerProfileId: string; shareId: string },
304
+ contentVersion: number,
305
+ attachment: ShareLinkAttachment
306
+ ): ShareAttachmentBinding => ({
307
+ ...scope,
308
+ contentVersion,
309
+ attachmentId: attachment.id,
310
+ chunkCount: attachment.chunkCount,
311
+ });
312
+
313
+ /** Bounded periodic collection of never-committed stages after their 24-hour retry window. */
314
+ export const expireShareAttachmentStages = async (input: {
315
+ namespace: string;
316
+ limit?: number;
317
+ now?: Date;
318
+ transaction?: ShareLinkTransactionOptions;
319
+ }): Promise<number> => {
320
+ if (!input.namespace) failShareLink('INVALID_INPUT', 'namespace is required');
321
+ const limit = Math.max(1, Math.min(10, input.limit ?? 10));
322
+ return withShareLinkTransaction(async tx => {
323
+ const candidates = await tx.run(
324
+ `MATCH (a:ShareLinkAttachmentStage)
325
+ WHERE a.namespace = $namespace AND a.status = 'staging' AND a.expiresAt <= $now
326
+ RETURN a ORDER BY a.expiresAt LIMIT $limit`,
327
+ {
328
+ namespace: input.namespace,
329
+ now: (input.now ?? new Date()).toISOString(),
330
+ limit: int(limit),
331
+ }
332
+ );
333
+ let queued = 0;
334
+ for (let index = 0; index < candidates.records.length; index++) {
335
+ const props = readNodeProperties(candidates, 'a', index);
336
+ if (!props) continue;
337
+ const binding: ShareAttachmentBinding = {
338
+ namespace: String(props.namespace),
339
+ ownerProfileId: String(props.ownerProfileId),
340
+ shareId: String(props.shareId),
341
+ attachmentId: String(props.attachmentId),
342
+ contentVersion: Number(props.contentVersion),
343
+ chunkCount: Number(props.chunkCount),
344
+ };
345
+ await lockShare(tx, binding.shareId);
346
+ const stage = await lockStage(tx, binding);
347
+ const now = (input.now ?? new Date()).toISOString();
348
+ if (
349
+ !stage ||
350
+ stage.status !== 'staging' ||
351
+ typeof stage.expiresAt !== 'string' ||
352
+ stage.expiresAt > now
353
+ )
354
+ continue;
355
+ await queueShareAttachmentCleanup(tx, binding, 'abandoned', now);
356
+ queued++;
357
+ }
358
+ return queued;
359
+ }, input.transaction);
360
+ };
@@ -5,6 +5,7 @@ import type { ShareLinkPolicySnapshot } from '@helpers/share-link-policy/types';
5
5
 
6
6
  import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
7
7
  import { failShareLink } from './errors';
8
+ import { verifyPinnedShareAttachment, queueShareAttachmentCleanup } from './attachment';
8
9
  import {
9
10
  deleteReservation,
10
11
  enqueueCleanupJob,
@@ -157,12 +158,33 @@ export const finalizeReservation = async (
157
158
  }
158
159
 
159
160
  const previousObjectRef = share.activeObjectRef;
161
+ if (
162
+ reservation.contentVersion !== null &&
163
+ reservation.attachmentId &&
164
+ reservation.attachmentChunkCount
165
+ ) {
166
+ if (input.verifiedAttachmentId !== reservation.attachmentId) {
167
+ failShareLink('PRECONDITION_FAILED', 'attachment was not verified');
168
+ }
169
+ await verifyPinnedShareAttachment(
170
+ tx,
171
+ {
172
+ namespace: reservation.namespace,
173
+ ownerProfileId: reservation.ownerProfileId,
174
+ shareId: reservation.shareId,
175
+ contentVersion: reservation.contentVersion,
176
+ attachmentId: reservation.attachmentId,
177
+ chunkCount: reservation.attachmentChunkCount,
178
+ },
179
+ reservation.requestHash
180
+ );
181
+ }
160
182
  const nextObjectRef = reservation.objectRef ?? share.activeObjectRef;
161
183
  const nextContentVersion = reservation.contentVersion ?? share.contentVersion;
162
184
 
163
- // A fresh graph-local recheck is needed before enabling counting.
164
- // In particular, shares created while age was unavailable retain
165
- // an unknown snapshot until the owner explicitly updates them.
185
+ // Unknown snapshots and old service restrictions may be refreshed on
186
+ // explicit edits, but only after a fresh graph-local check. The
187
+ // reservation still caps eligibility for stale replays and recovery.
166
188
  const currentPolicy: ShareLinkPolicySnapshot = {
167
189
  isMinor: share.minorPolicyIsMinor,
168
190
  policyResolved: share.minorPolicyResolved,
@@ -172,18 +194,23 @@ export const finalizeReservation = async (
172
194
  const checkedPolicy = input.resolveCurrentPolicy
173
195
  ? await input
174
196
  .resolveCurrentPolicy(tx, share.ownerProfileId, now)
175
- .catch(() => DEFAULT_SHARE_LINK_POLICY)
197
+ .catch(() => ({ ...DEFAULT_SHARE_LINK_POLICY, isServiceProfile: false }))
176
198
  : null;
177
199
  const reservationPolicy = checkedPolicy
178
200
  ? mergeShareLinkPolicyConservatively(reservation.policy, checkedPolicy)
179
201
  : mergeShareLinkPolicyConservatively(reservation.policy, currentPolicy);
202
+ const mayRefreshExplicitService =
203
+ checkedPolicy?.isServiceProfile === true &&
204
+ checkedPolicy.isMinor === false &&
205
+ reservation.opKind === 'update';
180
206
  const mayReplaceUnknown =
181
207
  checkedPolicy !== null &&
182
208
  !currentPolicy.policyResolved &&
183
209
  (reservation.opKind === 'create' || reservation.opKind === 'update');
184
- const effectivePolicy = mayReplaceUnknown
185
- ? reservationPolicy
186
- : mergeShareLinkPolicyConservatively(currentPolicy, reservationPolicy);
210
+ const effectivePolicy =
211
+ mayRefreshExplicitService || mayReplaceUnknown
212
+ ? reservationPolicy
213
+ : mergeShareLinkPolicyConservatively(currentPolicy, reservationPolicy);
187
214
 
188
215
  await tx.run(
189
216
  `MATCH (s:ShareLink {id: $shareId})
@@ -193,6 +220,12 @@ export const finalizeReservation = async (
193
220
  props: {
194
221
  version: share.version + 1,
195
222
  contentVersion: nextContentVersion,
223
+ attachmentId: reservation.objectRef
224
+ ? (reservation.attachmentId ?? null)
225
+ : (share.attachmentId ?? null),
226
+ attachmentChunkCount: reservation.objectRef
227
+ ? (reservation.attachmentChunkCount ?? null)
228
+ : (share.attachmentChunkCount ?? null),
196
229
  status: 'active',
197
230
  contentState: 'finalized',
198
231
  activeObjectRef: nextObjectRef,
@@ -244,6 +277,21 @@ export const finalizeReservation = async (
244
277
  let cleanupQueuedFor: string | null = null;
245
278
 
246
279
  if (previousObjectRef && previousObjectRef !== nextObjectRef) {
280
+ if (share.attachmentId && share.attachmentChunkCount) {
281
+ await queueShareAttachmentCleanup(
282
+ tx,
283
+ {
284
+ namespace: share.namespace,
285
+ ownerProfileId: share.ownerProfileId,
286
+ shareId: share.id,
287
+ contentVersion: share.contentVersion,
288
+ attachmentId: share.attachmentId,
289
+ chunkCount: share.attachmentChunkCount,
290
+ },
291
+ 'superseded',
292
+ nowIso
293
+ );
294
+ }
247
295
  await enqueueCleanupJob(tx, {
248
296
  objectRef: previousObjectRef,
249
297
  operationId: share.activeObjectOperationId!,
@@ -1,6 +1,7 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
 
3
3
  import type { ShareLinkOperationKind } from '@helpers/share-link-lifecycle';
4
+ import type { ShareLinkPolicySnapshot } from '@helpers/share-link-policy/types';
4
5
 
5
6
  import type { ShareContentState, ShareLinkRecord, ShareLinkStatus } from '../../models/ShareLink';
6
7
  import type { Neo4jQueryResult, ShareLinkTransaction } from './transaction';
@@ -94,6 +95,8 @@ export const toShareLinkRecord = (props: Record<string, unknown>): ShareLinkReco
94
95
  ownerProfileId: asString(props.ownerProfileId),
95
96
  version: asNumber(props.version, 1),
96
97
  contentVersion: asNumber(props.contentVersion, 1),
98
+ attachmentId: asNullableString(props.attachmentId),
99
+ attachmentChunkCount: asNullableNumber(props.attachmentChunkCount),
97
100
  generation: asNumber(props.generation, 1),
98
101
  status: isOneOf(props.status, SHARE_LINK_STATUSES) ? props.status : 'pending',
99
102
  contentState: isOneOf(props.contentState, SHARE_CONTENT_STATES)
@@ -135,6 +138,8 @@ export const toShareLinkReservationRecord = (
135
138
  operationId: asString(props.operationId),
136
139
  objectRef: asNullableString(props.objectRef),
137
140
  contentVersion: asNullableNumber(props.contentVersion),
141
+ attachmentId: asNullableString(props.attachmentId),
142
+ attachmentChunkCount: asNullableNumber(props.attachmentChunkCount),
138
143
  baseVersion: asNumber(props.baseVersion, 1),
139
144
  baseContentVersion: asNumber(props.baseContentVersion, 1),
140
145
  contentHash: asNullableString(props.contentHash),
@@ -160,6 +165,20 @@ export const toShareLinkReservationRecord = (
160
165
  updatedAt: asString(props.updatedAt),
161
166
  });
162
167
 
168
+ /** Legacy operation records have no policy ceiling; callers fall back to their reservation. */
169
+ export const toShareLinkOperationPolicy = (
170
+ props: Record<string, unknown>
171
+ ): ShareLinkPolicySnapshot | null => {
172
+ if (typeof props.policyViewCountingEnabled !== 'boolean') return null;
173
+
174
+ return {
175
+ isMinor: asNullableBoolean(props.policyIsMinor),
176
+ policyResolved: asBoolean(props.policyResolved, false),
177
+ defaultExpiryDays: asNumber(props.policyDefaultExpiryDays, 30) === 365 ? 365 : 30,
178
+ viewCountingEnabled: props.policyViewCountingEnabled,
179
+ };
180
+ };
181
+
163
182
  export const toShareLinkOperationRecord = (
164
183
  props: Record<string, unknown>
165
184
  ): ShareLinkOperationRecord => ({
@@ -296,6 +315,7 @@ export type WriteOperationInput = ShareLinkOperationKey & {
296
315
  requestHash: string;
297
316
  now: string;
298
317
  pruneAfter: string;
318
+ policy?: ShareLinkPolicySnapshot;
299
319
  };
300
320
 
301
321
  /**
@@ -322,8 +342,19 @@ export const writeOperationInProgress = async (
322
342
  o.status = 'in_progress',
323
343
  o.resultJson = null,
324
344
  o.resultVersion = null,
325
- o.updatedAt = $now`,
326
- input
345
+ o.updatedAt = $now
346
+ SET o += $policyProps`,
347
+ {
348
+ ...input,
349
+ policyProps: input.policy
350
+ ? {
351
+ policyIsMinor: input.policy.isMinor,
352
+ policyResolved: input.policy.policyResolved,
353
+ policyDefaultExpiryDays: input.policy.defaultExpiryDays,
354
+ policyViewCountingEnabled: input.policy.viewCountingEnabled,
355
+ }
356
+ : {},
357
+ }
327
358
  );
328
359
  };
329
360
 
@@ -11,6 +11,7 @@ import { neogma } from '@instance';
11
11
 
12
12
  import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
13
13
  import { failShareLink } from './errors';
14
+ import { queueShareAttachmentCleanup } from './attachment';
14
15
  import {
15
16
  deleteReservation,
16
17
  enqueueCleanupJob,
@@ -514,6 +515,25 @@ export const abandonRecoveredReservation = async (
514
515
  }
515
516
 
516
517
  let cleanupQueuedFor: string | null = null;
518
+ if (
519
+ reservation.attachmentId &&
520
+ reservation.attachmentChunkCount &&
521
+ reservation.contentVersion
522
+ ) {
523
+ await queueShareAttachmentCleanup(
524
+ tx,
525
+ {
526
+ namespace: reservation.namespace,
527
+ ownerProfileId: reservation.ownerProfileId,
528
+ shareId: reservation.shareId,
529
+ contentVersion: reservation.contentVersion,
530
+ attachmentId: reservation.attachmentId,
531
+ chunkCount: reservation.attachmentChunkCount,
532
+ },
533
+ 'abandoned',
534
+ nowIso
535
+ );
536
+ }
517
537
 
518
538
  if (reservation.objectRef) {
519
539
  await enqueueCleanupJob(tx, {