@learncard/network-brain-service 3.19.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +109 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/config/config.dev.json +29 -0
  7. package/config/config.example.json +29 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +29 -0
  10. package/config/config.scouts.dev.json +24 -0
  11. package/config/config.scouts.production.json +24 -0
  12. package/contractEventsLambda.ts +18 -0
  13. package/contractEventsLambdaApp.ts +19 -0
  14. package/didWebLambda.ts +13 -18
  15. package/didWebLambdaApp.ts +22 -0
  16. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  18. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  20. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  22. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  23. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  24. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  26. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  28. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  29. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  30. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  32. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  34. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  35. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  36. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/credential/create.d.ts +2 -1
  48. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  49. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  50. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  51. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  53. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  55. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  57. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  58. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  59. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  60. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  61. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/types.d.ts +8 -3
  65. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  66. package/dist/cache/app-store.caches.d.ts +49 -0
  67. package/dist/cache/app-store.caches.d.ts.map +1 -1
  68. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  69. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  70. package/dist/config/environment.d.ts +5 -0
  71. package/dist/config/environment.d.ts.map +1 -1
  72. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  73. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  74. package/dist/config/stageConfig.d.ts +112 -0
  75. package/dist/config/stageConfig.d.ts.map +1 -0
  76. package/dist/config/stageConfig.test.d.ts +2 -0
  77. package/dist/config/stageConfig.test.d.ts.map +1 -0
  78. package/dist/didkit_wasm_bg.wasm +0 -0
  79. package/dist/docker-entry.d.ts +1 -1
  80. package/dist/docker-entry.d.ts.map +1 -1
  81. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  82. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  83. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  84. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  85. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  86. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  87. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  89. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  91. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  92. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  93. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  95. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  96. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  97. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  99. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  100. package/dist/helpers/notificationMessages.d.ts +1 -1
  101. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  102. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  103. package/dist/helpers/notifications.helpers.d.ts +6 -0
  104. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  105. package/dist/helpers/profile.helpers.d.ts +1 -0
  106. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  107. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  108. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  109. package/dist/helpers/share-link-attachment.d.ts +18 -0
  110. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  111. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  113. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  114. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  115. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  116. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  117. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  119. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  122. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  123. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  124. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  125. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  127. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  128. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  129. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  130. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  131. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  132. package/dist/index.js +2 -2
  133. package/dist/models/AppManifestVersion.d.ts +20 -0
  134. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  135. package/dist/models/AppStoreListing.d.ts +13 -0
  136. package/dist/models/AppStoreListing.d.ts.map +1 -1
  137. package/dist/models/ConsentFlowContract.d.ts +13 -2
  138. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  139. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  141. package/dist/models/Integration.d.ts +2 -0
  142. package/dist/models/Integration.d.ts.map +1 -1
  143. package/dist/models/ShareLink.d.ts +2 -0
  144. package/dist/models/ShareLink.d.ts.map +1 -1
  145. package/dist/models/index.d.ts +1 -0
  146. package/dist/models/index.d.ts.map +1 -1
  147. package/dist/models/share-link-constraints.d.ts.map +1 -1
  148. package/dist/openapi.d.ts +1 -1
  149. package/dist/openapi.d.ts.map +1 -1
  150. package/dist/routes/app-store.d.ts +619 -0
  151. package/dist/routes/app-store.d.ts.map +1 -1
  152. package/dist/routes/boosts.d.ts +2 -2
  153. package/dist/routes/boosts.d.ts.map +1 -1
  154. package/dist/routes/contracts.d.ts +146 -3
  155. package/dist/routes/contracts.d.ts.map +1 -1
  156. package/dist/routes/credentials.d.ts +34 -0
  157. package/dist/routes/credentials.d.ts.map +1 -1
  158. package/dist/routes/inbox.d.ts +52 -2
  159. package/dist/routes/inbox.d.ts.map +1 -1
  160. package/dist/routes/index.d.ts.map +1 -1
  161. package/dist/routes/profile-manager.d.ts.map +1 -1
  162. package/dist/routes/profiles.d.ts.map +1 -1
  163. package/dist/routes/public-share-links.d.ts +55 -0
  164. package/dist/routes/public-share-links.d.ts.map +1 -1
  165. package/dist/routes/share-links.d.ts +175 -14
  166. package/dist/routes/share-links.d.ts.map +1 -1
  167. package/dist/routes/test.d.ts +5 -0
  168. package/dist/routes/test.d.ts.map +1 -1
  169. package/dist/routes/workflows.d.ts.map +1 -1
  170. package/dist/types/app-manifest.d.ts +339 -0
  171. package/dist/types/app-manifest.d.ts.map +1 -0
  172. package/dist/types/app-store-listing.d.ts +34 -0
  173. package/dist/types/app-store-listing.d.ts.map +1 -1
  174. package/dist/types/consentflowcontract.d.ts +30 -3
  175. package/dist/types/consentflowcontract.d.ts.map +1 -1
  176. package/dist/types/credential-refresh.d.ts +1 -1
  177. package/esbuildPlugins.cjs +1 -0
  178. package/lambda.ts +49 -195
  179. package/lambdaApp.ts +203 -0
  180. package/package.json +20 -18
  181. package/scripts/export-openapi.ts +20 -0
  182. package/scripts/offline-schema-environment.ts +18 -0
  183. package/scripts/verify-python-contracts.ts +72 -0
  184. package/serverless-local.yml +3 -6
  185. package/serverless.function-env.cjs +50 -0
  186. package/serverless.local.yml +0 -1
  187. package/serverless.yml +27 -74
  188. package/shareLinkMaintenanceLambda.ts +13 -44
  189. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  190. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  191. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  192. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  193. package/src/accesslayer/app-store-listing/read.ts +6 -0
  194. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  195. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  196. package/src/accesslayer/app-store-listing/update.ts +15 -0
  197. package/src/accesslayer/boost/relationships/create.ts +8 -2
  198. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  199. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  200. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  201. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  202. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  203. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  204. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  205. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  206. package/src/accesslayer/credential/create.test.ts +2 -115
  207. package/src/accesslayer/credential/create.ts +11 -6
  208. package/src/accesslayer/credential/relationships/create.ts +17 -10
  209. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  210. package/src/accesslayer/inbox-credential/update.ts +4 -2
  211. package/src/accesslayer/share-link/abandon.ts +20 -0
  212. package/src/accesslayer/share-link/attachment.ts +360 -0
  213. package/src/accesslayer/share-link/finalize.ts +55 -7
  214. package/src/accesslayer/share-link/helpers.ts +33 -2
  215. package/src/accesslayer/share-link/recovery.ts +20 -0
  216. package/src/accesslayer/share-link/reserve.ts +85 -6
  217. package/src/accesslayer/share-link/revoke.ts +35 -0
  218. package/src/accesslayer/share-link/types.ts +9 -4
  219. package/src/cache/app-store.caches.ts +35 -0
  220. package/src/config/applyDockerStageConfig.ts +6 -0
  221. package/src/config/environment.test.ts +17 -12
  222. package/src/config/environment.ts +1 -0
  223. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  224. package/src/config/stageConfig.test.ts +25 -0
  225. package/src/config/stageConfig.ts +24 -0
  226. package/src/docker-entry.ts +24 -0
  227. package/src/helpers/app-manifest.helpers.ts +185 -0
  228. package/src/helpers/claim-hooks.helpers.ts +20 -7
  229. package/src/helpers/consent-referral.helpers.ts +6 -0
  230. package/src/helpers/consent-scopes.helpers.ts +58 -0
  231. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  232. package/src/helpers/contract-events.helpers.ts +540 -0
  233. package/src/helpers/contract-requests.helpers.ts +211 -0
  234. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  235. package/src/helpers/inbox.helpers.ts +51 -66
  236. package/src/helpers/inline-template.helpers.ts +560 -0
  237. package/src/helpers/notificationMessages.ts +59 -0
  238. package/src/helpers/notificationQueue.helpers.ts +23 -5
  239. package/src/helpers/notifications.helpers.ts +25 -1
  240. package/src/helpers/profile.helpers.ts +6 -0
  241. package/src/helpers/share-link-attachment-access.ts +87 -0
  242. package/src/helpers/share-link-attachment.ts +115 -0
  243. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  244. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  245. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  246. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  247. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  248. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  249. package/src/helpers/share-link-maintenance/types.ts +2 -0
  250. package/src/helpers/share-link-owner-projection.ts +2 -0
  251. package/src/helpers/share-link-policy/production.ts +37 -17
  252. package/src/helpers/share-link-policy/resolver.ts +16 -11
  253. package/src/helpers/share-link-policy/types.ts +15 -7
  254. package/src/helpers/signingAuthority.helpers.ts +23 -14
  255. package/src/helpers/smartResume.helpers.ts +123 -0
  256. package/src/models/AppManifestVersion.ts +43 -0
  257. package/src/models/AppStoreListing.ts +32 -0
  258. package/src/models/ConsentFlowContract.ts +34 -3
  259. package/src/models/ConsentFlowTerms.ts +10 -0
  260. package/src/models/ConsentFlowTransaction.ts +3 -0
  261. package/src/models/Integration.ts +2 -0
  262. package/src/models/ShareLink.ts +4 -0
  263. package/src/models/index.ts +23 -0
  264. package/src/models/share-link-constraints.ts +5 -0
  265. package/src/openapi.ts +44 -8
  266. package/src/routes/app-store.ts +1054 -27
  267. package/src/routes/boosts.ts +12 -14
  268. package/src/routes/contracts.ts +382 -262
  269. package/src/routes/inbox.ts +4 -2
  270. package/src/routes/index.ts +4 -1
  271. package/src/routes/profile-manager.ts +19 -0
  272. package/src/routes/profiles.ts +12 -0
  273. package/src/routes/public-share-links.ts +169 -0
  274. package/src/routes/share-links.ts +69 -0
  275. package/src/routes/workflows.ts +3 -2
  276. package/src/types/app-manifest.ts +28 -0
  277. package/src/types/app-store-listing.ts +13 -0
  278. package/src/types/consentflowcontract.ts +25 -4
  279. package/test/app-manifest.helpers.spec.ts +170 -0
  280. package/test/app-manifest.routes.spec.ts +299 -0
  281. package/test/app-store-listing-updates.spec.ts +236 -0
  282. package/test/app-store-listing.spec.ts +65 -3
  283. package/test/consent-data-boundaries.spec.ts +352 -0
  284. package/test/consent-recipients.spec.ts +513 -0
  285. package/test/consent-scopes.helpers.spec.ts +67 -0
  286. package/test/consentflow.spec.ts +63 -2
  287. package/test/contract-requests.spec.ts +1588 -0
  288. package/test/credential-storage.neo4j.spec.ts +122 -0
  289. package/test/guardian-gated-routes.spec.ts +36 -0
  290. package/test/inbox-security.e2e.ts +26 -10
  291. package/test/inbox.spec.ts +184 -8
  292. package/test/inline-template.helpers.spec.ts +49 -0
  293. package/test/managed-recipient-inbox.spec.ts +24 -2
  294. package/test/openapi.spec.ts +14 -73
  295. package/test/profiles.spec.ts +30 -0
  296. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  297. package/test/share-link-attachment.unit.spec.ts +244 -0
  298. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  299. package/test/share-link-maintenance.unit.spec.ts +32 -5
  300. package/test/share-link-owner-route.unit.spec.ts +70 -2
  301. package/test/share-link-policy.unit.spec.ts +18 -4
  302. package/test/share-link-production-policy.unit.spec.ts +58 -9
  303. package/test/share-link-public-route.unit.spec.ts +279 -0
  304. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  305. package/test/signing-authority-encryption.spec.ts +75 -0
  306. package/test/smart-resume-consent.spec.ts +233 -0
  307. package/test/smart-resume-upload.spec.ts +65 -0
  308. package/tsconfig.build.json +1 -1
  309. package/tsconfig.json +1 -0
  310. package/vitest.config.ts +13 -0
  311. package/vitest.inbox-e2e.config.ts +2 -0
  312. package/workerLambda.test.ts +100 -0
@@ -1,10 +1,9 @@
1
1
  import {
2
+ AllocateCredentialRefreshInputValidator,
2
3
  PublishCredentialRefreshInputValidator,
3
4
  SendBoostResponseValidator,
4
5
  } from '@learncard/types';
5
6
 
6
- import { openApiDocument } from '../src/openapi';
7
-
8
7
  const unsignedCredential = {
9
8
  '@context': ['https://www.w3.org/ns/credentials/v2'],
10
9
  type: ['VerifiableCredential'],
@@ -23,79 +22,21 @@ const signedCredential = {
23
22
  },
24
23
  };
25
24
 
26
- // The import itself is the test: `src/openapi.ts` calls generateOpenApiDocument
27
- // at module load, so a Zod 4 / trpc-to-openapi regression throws here instead of
28
- // at Lambda cold start.
29
- describe('OpenAPI generation', () => {
30
- it('generates the document at boot without throwing', () => {
31
- expect(openApiDocument).toBeDefined();
32
- expect(Object.keys(openApiDocument.paths ?? {}).length).toBeGreaterThan(0);
33
- });
34
-
35
- it('mounts the anonymous public share routes on paths distinct from the owner route', () => {
36
- const paths = Object.keys(openApiDocument.paths ?? {});
37
-
38
- // The import above would have thrown Duplicate procedure for the old
39
- // colliding GET /share-links/{id} registration.
40
- expect(paths.filter(path => path === '/share-links/{id}')).toHaveLength(1);
41
- expect(paths).toContain('/public/share-links/{id}');
42
- expect(paths).toContain('/public/share-links/{id}/content');
43
- expect(paths).toContain('/public/share-links/acknowledge-view');
44
- });
45
-
46
- it('mounts the bounded owner list on GET /share-links, distinct from /share-links/{id}', () => {
47
- const paths = Object.keys(openApiDocument.paths ?? {});
48
-
49
- // A collection path with no trailing segment cannot be captured by the
50
- // `{id}` param route, so /list -> /{id} shadowing is impossible.
51
- expect(paths.filter(path => path === '/share-links')).toHaveLength(1);
52
- expect(openApiDocument.paths?.['/share-links']?.get).toBeDefined();
53
- });
54
-
55
- it('includes the skill-search route whose $regex query previously broke generation', () => {
56
- const paths = Object.keys(openApiDocument.paths ?? {});
57
-
58
- expect(paths).toContain('/boost/skills/search');
59
- });
60
-
61
- it('exposes a root-relative server URL that resolves on any tenant domain', () => {
62
- expect(openApiDocument.servers?.[0]?.url).toBe('/api');
63
- });
64
-
65
- it('documents the mutually exclusive managed refresh publication modes', () => {
66
- const operation = openApiDocument.paths?.['/credential-refresh/publish']?.post;
67
- const requestBody = operation?.requestBody;
68
-
69
- expect(requestBody).toBeDefined();
70
- expect(typeof requestBody).toBe('object');
71
-
72
- const content = requestBody && !('$ref' in requestBody) ? requestBody.content : undefined;
73
- const schema = content?.['application/json']?.schema;
74
- const oneOf = schema && !('$ref' in schema) ? schema.oneOf : undefined;
75
-
76
- expect(oneOf).toEqual([
77
- expect.objectContaining({
78
- properties: { mode: { const: 'issuer-signed' } },
79
- required: ['signedCredential'],
80
- not: {
81
- anyOf: [{ required: ['credential'] }, { required: ['signingAuthority'] }],
82
- },
83
- }),
84
- expect.objectContaining({
85
- properties: { mode: { const: 'signing-authority' } },
86
- required: ['credential', 'signingAuthority'],
87
- not: { required: ['signedCredential'] },
88
- }),
89
- ]);
25
+ describe('Managed credential refresh contracts', () => {
26
+ it.each([
27
+ ['DID-only holder', { did: 'did:example:holder' }, true],
28
+ ['string profile ID', { did: 'did:example:holder', profileId: 'synthetic-profile' }, true],
29
+ ['explicit null profile ID', { did: 'did:example:holder', profileId: null }, false],
30
+ ])('preserves the optional-but-nonnullable allocation contract: %s', (_name, holder, valid) => {
31
+ const result = AllocateCredentialRefreshInputValidator.safeParse({
32
+ credentialId: 'urn:uuid:synthetic-credential',
33
+ holder,
34
+ });
35
+ expect(result.success).toBe(valid);
36
+ if (result.success) expect(result.data.holder).toEqual(holder);
90
37
  });
91
38
 
92
- it('documents the unified send route with the refresh receipt in its response schema', () => {
93
- const paths = openApiDocument.paths ?? {};
94
-
95
- expect(paths['/send']).toBeDefined();
96
-
97
- // The managed receipt must survive response schema generation: the schema is
98
- // derived from SendBoostResponseValidator, which carries the optional receipt.
39
+ it('strips credential contents from issuance receipts', () => {
99
40
  const response = SendBoostResponseValidator.safeParse({
100
41
  type: 'boost',
101
42
  uri: 'https://localhost%3A3000/boost/abc',
@@ -1347,6 +1347,36 @@ describe('Profiles', () => {
1347
1347
  ).resolves.not.toThrow();
1348
1348
  });
1349
1349
 
1350
+ it('does not allow a legacy service-flagged child to change its type', async () => {
1351
+ await Profile.update(
1352
+ { type: 'child', isServiceProfile: true },
1353
+ { where: { profileId: 'usera' } }
1354
+ );
1355
+
1356
+ await expect(
1357
+ userA.clients.fullAuth.profile.updateProfile({
1358
+ type: 'adult',
1359
+ profileId: 'escaped-child',
1360
+ displayName: 'Escaped',
1361
+ })
1362
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
1363
+
1364
+ await expect(userA.clients.fullAuth.profile.getProfile()).resolves.toMatchObject({
1365
+ profileId: 'usera',
1366
+ type: 'child',
1367
+ isServiceProfile: true,
1368
+ });
1369
+ await userA.clients.fullAuth.profile.updateProfile({
1370
+ type: 'child',
1371
+ displayName: 'Updated child',
1372
+ });
1373
+ await expect(userA.clients.fullAuth.profile.getProfile()).resolves.toMatchObject({
1374
+ type: 'child',
1375
+ isServiceProfile: true,
1376
+ displayName: 'Updated child',
1377
+ });
1378
+ });
1379
+
1350
1380
  it('should allow you to update your profile image', async () => {
1351
1381
  await expect(
1352
1382
  userA.clients.fullAuth.profile.updateProfile({
@@ -0,0 +1,262 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { beforeAll, beforeEach, afterAll, describe, expect, it } from 'vitest';
3
+ import { neogma } from '@instance';
4
+ import { ensureShareLinkConstraints } from '../src/models/share-link-constraints';
5
+ import {
6
+ beginShareAttachmentChunk,
7
+ completeShareAttachmentChunk,
8
+ deleteUnreferencedShareAttachment,
9
+ expireShareAttachmentStages,
10
+ shareAttachmentObjectId,
11
+ type ShareAttachmentBinding,
12
+ } from '../src/accesslayer/share-link/attachment';
13
+ import {
14
+ reserveCreate,
15
+ reserveReplacement,
16
+ finalizeReservation,
17
+ revokeShareLink,
18
+ abandonReservation,
19
+ getShareLink,
20
+ listCleanupJobsForShare,
21
+ } from '../src/accesslayer/share-link';
22
+ import { contentBinding } from './helpers/share-link-fixtures';
23
+ import type { ShareLinkReservationRecord } from '../src/accesslayer/share-link/types';
24
+
25
+ // Only this suite's random namespace is cleaned; no shared/user graph is touched.
26
+ const namespace = `attachment-tests-${randomUUID()}`;
27
+ const clear = async (): Promise<void> => {
28
+ await neogma.queryRunner.run('MATCH (n) WHERE n.namespace = $namespace DETACH DELETE n', {
29
+ namespace,
30
+ });
31
+ };
32
+ beforeAll(async () => {
33
+ await ensureShareLinkConstraints();
34
+ });
35
+ beforeEach(clear);
36
+ afterAll(async () => {
37
+ await clear();
38
+ await neogma.queryRunner.getDriver().close();
39
+ });
40
+
41
+ const binding = (count = 2, owner = 'owner'): ShareAttachmentBinding => ({
42
+ namespace,
43
+ ownerProfileId: owner,
44
+ shareId: randomBytes(16).toString('base64url'),
45
+ contentVersion: 1,
46
+ attachmentId: randomUUID(),
47
+ chunkCount: count,
48
+ });
49
+ const stage = async (
50
+ value: ShareAttachmentBinding,
51
+ indices = Array.from({ length: value.chunkCount }, (_, i) => i)
52
+ ) => {
53
+ for (const index of indices) {
54
+ await beginShareAttachmentChunk(value, index);
55
+ await completeShareAttachmentChunk(value, index);
56
+ }
57
+ };
58
+ const createInput = (value: ShareAttachmentBinding) => ({
59
+ ...value,
60
+ clientRequestId: randomUUID(),
61
+ title: 'Synthetic resume',
62
+ selectedCount: 1,
63
+ content: contentBinding(value.attachmentId),
64
+ attachment: { id: value.attachmentId, chunkCount: value.chunkCount },
65
+ requestHash: 'a'.repeat(64),
66
+ leaseOwner: 'worker',
67
+ });
68
+ const reserve = async (value: ShareAttachmentBinding): Promise<ShareLinkReservationRecord> => {
69
+ const result = await reserveCreate(createInput(value));
70
+ if (result.outcome !== 'reserved') throw new Error('expected reserved operation');
71
+ return result.reservation;
72
+ };
73
+ const finish = async (record: ShareLinkReservationRecord, verified = true) =>
74
+ finalizeReservation({
75
+ namespace,
76
+ ownerProfileId: record.ownerProfileId,
77
+ shareId: record.shareId,
78
+ operationId: record.operationId,
79
+ objectRef: record.objectRef,
80
+ generation: record.generation,
81
+ leaseOwner: record.leaseOwner,
82
+ verifiedContentHash: record.contentHash ?? undefined,
83
+ verifiedAttachmentId: verified ? (record.attachmentId ?? undefined) : undefined,
84
+ });
85
+
86
+ describe('managed attachment graph fences', () => {
87
+ it.each([
88
+ [0, 0],
89
+ [17, 0],
90
+ [2.5, 0],
91
+ [2, -1],
92
+ [2, 2],
93
+ [2, 0.5],
94
+ [2, NaN],
95
+ ])(
96
+ 'rejects invalid chunk bounds before persisting staging/quota nodes: %#',
97
+ async (count, index) => {
98
+ await expect(beginShareAttachmentChunk(binding(count), index)).rejects.toMatchObject({
99
+ code: 'INVALID_INPUT',
100
+ });
101
+ const result = await neogma.queryRunner.run(
102
+ 'MATCH (n) WHERE n.namespace = $namespace RETURN count(n) AS count',
103
+ { namespace }
104
+ );
105
+ expect(result.records[0].get('count').toNumber()).toBe(0);
106
+ }
107
+ );
108
+ it('refuses incomplete staged PDFs and rolls back the pending share', async () => {
109
+ const value = binding();
110
+ await stage(value, [0]);
111
+ await expect(reserve(value)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
112
+ expect(
113
+ await getShareLink({
114
+ shareId: value.shareId,
115
+ namespace,
116
+ ownerProfileId: value.ownerProfileId,
117
+ })
118
+ ).toBeNull();
119
+ await completeShareAttachmentChunk(value, 1);
120
+ const pending = await reserve(value);
121
+ await expect(finish(pending, false)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
122
+ const committed = await finish(pending);
123
+ expect(committed.share.attachmentId).toBe(value.attachmentId);
124
+ expect(committed.share.attachmentChunkCount).toBe(2);
125
+ });
126
+
127
+ it('supports idempotent same asset staging but rejects foreign owners and mismatched count', async () => {
128
+ const value = binding();
129
+ await stage(value);
130
+ const pending = await reserve(value);
131
+ await finish(pending);
132
+ await beginShareAttachmentChunk(value, 0);
133
+ await completeShareAttachmentChunk(value, 0);
134
+ await expect(
135
+ beginShareAttachmentChunk({ ...value, ownerProfileId: 'foreign' }, 0)
136
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
137
+ await expect(
138
+ beginShareAttachmentChunk({ ...value, chunkCount: 1 }, 0)
139
+ ).rejects.toMatchObject({ code: 'CONFLICT' });
140
+ expect(await deleteUnreferencedShareAttachment(value)).toBe(false);
141
+ });
142
+
143
+ it('serializes cleanup versus pinning so exactly one can acquire the asset', async () => {
144
+ const value = binding();
145
+ await stage(value);
146
+ const [reserved, deleted] = await Promise.allSettled([
147
+ reserve(value),
148
+ deleteUnreferencedShareAttachment(value),
149
+ ]);
150
+ expect(deleted.status).toBe('fulfilled');
151
+ if (reserved.status === 'fulfilled') {
152
+ expect(deleted.status === 'fulfilled' && deleted.value).toBe(false);
153
+ await finish(reserved.value);
154
+ } else {
155
+ expect(deleted.status === 'fulfilled' && deleted.value).toBe(true);
156
+ expect(reserved.reason.code).toBe('PRECONDITION_FAILED');
157
+ await expect(beginShareAttachmentChunk(value, 0)).rejects.toMatchObject({
158
+ code: 'CONFLICT',
159
+ });
160
+ }
161
+ });
162
+
163
+ it('abandonment permanently withdraws asset commit authority and queues every exact chunk', async () => {
164
+ const value = binding(3);
165
+ await stage(value);
166
+ const pending = await reserve(value);
167
+ const abandoned = await abandonReservation({
168
+ namespace,
169
+ ownerProfileId: value.ownerProfileId,
170
+ shareId: value.shareId,
171
+ operationId: pending.operationId,
172
+ });
173
+ expect(abandoned.outcome).toBe('abandoned');
174
+ await expect(finish(pending)).rejects.toMatchObject({ code: 'CONFLICT' });
175
+ await expect(beginShareAttachmentChunk(value, 0)).rejects.toMatchObject({
176
+ code: 'CONFLICT',
177
+ });
178
+ const jobs = await listCleanupJobsForShare(value.shareId);
179
+ expect(
180
+ jobs
181
+ .filter(job => job.operationId === value.attachmentId)
182
+ .map(job => job.objectRef)
183
+ .sort()
184
+ ).toEqual(
185
+ [0, 1, 2].map(index => shareAttachmentObjectId(value.attachmentId, index)).sort()
186
+ );
187
+ expect(
188
+ jobs.every(
189
+ job => job.ownerProfileId === value.ownerProfileId && job.namespace === namespace
190
+ )
191
+ ).toBe(true);
192
+ });
193
+
194
+ it('content replacement preserves the share and queues old chunks, then stopping queues current chunks', async () => {
195
+ const old = binding();
196
+ await stage(old);
197
+ const first = await finish(await reserve(old));
198
+ const next = { ...old, attachmentId: randomUUID(), contentVersion: 2, chunkCount: 3 };
199
+ await stage(next);
200
+ const updated = await reserveReplacement({
201
+ namespace,
202
+ ownerProfileId: old.ownerProfileId,
203
+ shareId: old.shareId,
204
+ expectedVersion: first.share.version,
205
+ clientRequestId: randomUUID(),
206
+ requestHash: 'b'.repeat(64),
207
+ leaseOwner: 'worker',
208
+ content: {
209
+ ...contentBinding(next.attachmentId),
210
+ contentVersion: 2,
211
+ selectedCount: 1,
212
+ attachment: { id: next.attachmentId, chunkCount: next.chunkCount },
213
+ },
214
+ });
215
+ if (updated.outcome !== 'reserved') throw new Error('expected update');
216
+ const second = await finish(updated.reservation);
217
+ expect(second.share.id).toBe(first.share.id);
218
+ expect(second.share.contentVersion).toBe(2);
219
+ expect(second.share.attachmentId).toBe(next.attachmentId);
220
+ let jobs = await listCleanupJobsForShare(old.shareId);
221
+ expect(jobs.filter(job => job.operationId === old.attachmentId)).toHaveLength(2);
222
+ expect(jobs.filter(job => job.operationId === next.attachmentId)).toHaveLength(0);
223
+ await revokeShareLink({
224
+ namespace,
225
+ ownerProfileId: old.ownerProfileId,
226
+ shareId: old.shareId,
227
+ });
228
+ jobs = await listCleanupJobsForShare(old.shareId);
229
+ expect(jobs.filter(job => job.operationId === next.attachmentId)).toHaveLength(3);
230
+ expect(jobs.filter(job => job.operationId === old.attachmentId)).toHaveLength(2);
231
+ });
232
+
233
+ it('bounds concurrent never-committed stages to eight and collects expired orphans', async () => {
234
+ const stages = Array.from({ length: 9 }, () => binding(1));
235
+ const results = await Promise.allSettled(
236
+ stages.map(value => beginShareAttachmentChunk(value, 0))
237
+ );
238
+ expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(8);
239
+ expect(results.filter(result => result.status === 'rejected')).toHaveLength(1);
240
+ const queued = await expireShareAttachmentStages({
241
+ namespace,
242
+ now: new Date(Date.now() + 25 * 60 * 60 * 1000),
243
+ });
244
+ expect(queued).toBe(8);
245
+ const completed = stages[results.findIndex(result => result.status === 'fulfilled')];
246
+ await expect(completeShareAttachmentChunk(completed!, 0)).rejects.toMatchObject({
247
+ code: 'CONFLICT',
248
+ });
249
+ await expect(beginShareAttachmentChunk(binding(1), 0)).resolves.toBeUndefined();
250
+ }, 60_000);
251
+
252
+ it('prevents cross-owner attachment UUID reuse from poisoning global immutable object cleanup', async () => {
253
+ const first = binding(1);
254
+ await stage(first);
255
+ const attacker = { ...binding(1, 'attacker'), attachmentId: first.attachmentId };
256
+ await expect(beginShareAttachmentChunk(attacker, 0)).rejects.toMatchObject({
257
+ code: 'CONFLICT',
258
+ });
259
+ expect(await deleteUnreferencedShareAttachment(attacker)).toBe(false);
260
+ expect(await listCleanupJobsForShare(first.shareId)).toHaveLength(0);
261
+ });
262
+ });
@@ -0,0 +1,244 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createShareLinkAttachmentOwnerApi } from '../src/helpers/share-link-attachment';
4
+ import { verifyShareAttachmentStats } from '../src/helpers/share-link-coordinator/attachment-stat';
5
+ import {
6
+ mintShareAttachmentAccessToken,
7
+ verifyShareAttachmentAccessToken,
8
+ } from '../src/helpers/share-link-attachment-access';
9
+ import { computeShareLinkRequestHash } from '../src/helpers/share-link-lifecycle';
10
+ import type { ShareLinkReservationRecord } from '../src/accesslayer/share-link/types';
11
+ import type { ShareLinkRecord } from '../src/models/ShareLink';
12
+ import type { ShareContentTuple } from '../src/helpers/share-content-client/types';
13
+ import { ShareLinkRepositoryError } from '../src/accesslayer/share-link/errors';
14
+
15
+ const shareId = Buffer.alloc(16, 7).toString('base64url');
16
+ const attachmentId = randomUUID();
17
+ const owner = { namespace: 'deployment', ownerProfileId: 'owner' };
18
+ const envelope = {
19
+ v: 1 as const,
20
+ alg: 'A256GCM' as const,
21
+ iv: Buffer.alloc(12, 1).toString('base64url'),
22
+ ct: Buffer.alloc(32, 2).toString('base64url'),
23
+ };
24
+ const input = {
25
+ id: shareId,
26
+ contentVersion: 1,
27
+ attachmentId,
28
+ chunkIndex: 0,
29
+ chunkCount: 1,
30
+ envelope,
31
+ ownerEncryptedRecovery: { protected: 'p', iv: 'i', ciphertext: 'c', tag: 't' },
32
+ };
33
+ const summary = (tuple: ShareContentTuple) => ({
34
+ kind: 'active' as const,
35
+ ...tuple,
36
+ contentHash: 'a'.repeat(64),
37
+ payloadHash: 'b'.repeat(64),
38
+ ciphertextBytes: 32,
39
+ recoveryBytes: 4,
40
+ createdAt: '2026-10-03T00:00:00Z',
41
+ });
42
+
43
+ describe('attachment owner transport and immutable commit binding', () => {
44
+ it('retains incomplete stage after lost put acknowledgement and safely retries the same tuple', async () => {
45
+ const repository = {
46
+ begin: vi.fn(async () => undefined),
47
+ complete: vi.fn(async () => undefined),
48
+ deleteUnreferenced: vi.fn(async () => true),
49
+ };
50
+ const client = {
51
+ put: vi.fn(async (value: ShareContentTuple) => ({
52
+ ok: true as const,
53
+ value: { status: 'idempotent' as const, record: summary(value) },
54
+ })),
55
+ delete: vi.fn(async (tuple: ShareContentTuple) => ({
56
+ ok: true as const,
57
+ value: {
58
+ kind: 'tombstone' as const,
59
+ ...tuple,
60
+ deletedAt: '2026-10-03T00:00:00Z',
61
+ },
62
+ })),
63
+ };
64
+ client.put.mockResolvedValueOnce({ ok: false, error: 'NETWORK_ERROR' } as never);
65
+ const api = createShareLinkAttachmentOwnerApi(client, repository);
66
+ await expect(api.put(input, owner)).rejects.toMatchObject({ code: 'UNAVAILABLE' });
67
+ expect(repository.complete).not.toHaveBeenCalled();
68
+ await expect(api.put(input, owner)).resolves.toEqual({ ok: true });
69
+ expect(client.put).toHaveBeenCalledTimes(2);
70
+ expect(client.put.mock.calls[0]![0]).toEqual(client.put.mock.calls[1]![0]);
71
+ expect(repository.complete).toHaveBeenCalledOnce();
72
+ });
73
+
74
+ it('preserves definitive staging failures so callers can discard an expired attempt', async () => {
75
+ const repository = {
76
+ begin: vi
77
+ .fn()
78
+ .mockRejectedValue(
79
+ new ShareLinkRepositoryError('PRECONDITION_FAILED', 'private detail')
80
+ ),
81
+ complete: vi.fn(),
82
+ deleteUnreferenced: vi.fn(),
83
+ };
84
+ const client = { put: vi.fn(), delete: vi.fn() };
85
+ const api = createShareLinkAttachmentOwnerApi(client, repository);
86
+ await expect(api.put(input, owner)).rejects.toMatchObject({
87
+ code: 'PRECONDITION_FAILED',
88
+ message: 'attachment upload unavailable',
89
+ });
90
+ expect(client.put).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('never deletes a referenced/ambiguous stage and reports physical cleanup failure while durable jobs remain', async () => {
94
+ const client = {
95
+ put: vi.fn(),
96
+ delete: vi.fn().mockResolvedValue({ ok: false, error: 'UNAVAILABLE' }),
97
+ };
98
+ const repository = {
99
+ begin: vi.fn(),
100
+ complete: vi.fn(),
101
+ deleteUnreferenced: vi.fn(async () => false),
102
+ };
103
+ const api = createShareLinkAttachmentOwnerApi(client, repository);
104
+ const key = { id: shareId, contentVersion: 1, attachmentId, chunkCount: 2 };
105
+ await expect(api.delete(key, owner)).resolves.toEqual({ ok: false });
106
+ expect(client.delete).not.toHaveBeenCalled();
107
+ repository.deleteUnreferenced.mockResolvedValueOnce(true);
108
+ await expect(api.delete(key, owner)).resolves.toEqual({ ok: false });
109
+ expect(client.delete).toHaveBeenCalledTimes(2);
110
+ expect(client.delete.mock.calls[1]![0]).toEqual({
111
+ ...owner,
112
+ shareId,
113
+ contentVersion: 1,
114
+ objectId: `resume-pdf-${attachmentId}-1`,
115
+ operationId: attachmentId,
116
+ });
117
+ });
118
+
119
+ it('requires all staged objects stat-present under exactly the committed owner/version/asset tuple', async () => {
120
+ const reservation = {
121
+ ...owner,
122
+ shareId,
123
+ contentVersion: 1,
124
+ attachmentId,
125
+ attachmentChunkCount: 2,
126
+ } as ShareLinkReservationRecord;
127
+ const client = {
128
+ stat: vi.fn(async (tuple: ShareContentTuple) => ({
129
+ ok: true as const,
130
+ value: summary(tuple),
131
+ })),
132
+ };
133
+ await expect(verifyShareAttachmentStats(client, reservation)).resolves.toBe(true);
134
+ expect(client.stat).toHaveBeenCalledTimes(2);
135
+ client.stat.mockResolvedValueOnce({ ok: false, error: 'NOT_FOUND' } as never);
136
+ await expect(verifyShareAttachmentStats(client, reservation)).resolves.toBe(false);
137
+ client.stat.mockImplementationOnce(async tuple => ({
138
+ ok: true as const,
139
+ value: summary({ ...tuple, ownerProfileId: 'foreign' }),
140
+ }));
141
+ await expect(verifyShareAttachmentStats(client, reservation)).resolves.toBe(false);
142
+ await expect(
143
+ verifyShareAttachmentStats(client, reservation, {
144
+ exhausted: () => true,
145
+ remainingMs: () => 0,
146
+ })
147
+ ).resolves.toBe(false);
148
+ });
149
+
150
+ it('binds attachment identity and count into client-request idempotency intent', () => {
151
+ const base = { id: shareId, attachment: { id: attachmentId, chunkCount: 2 } };
152
+ const hash = computeShareLinkRequestHash('create', base);
153
+ expect(
154
+ computeShareLinkRequestHash('create', {
155
+ ...base,
156
+ attachment: { id: randomUUID(), chunkCount: 2 },
157
+ })
158
+ ).not.toBe(hash);
159
+ expect(
160
+ computeShareLinkRequestHash('create', {
161
+ ...base,
162
+ attachment: { id: attachmentId, chunkCount: 1 },
163
+ })
164
+ ).not.toBe(hash);
165
+ });
166
+
167
+ it('reserves the bounded remote call and finalization time before each chunk stat', async () => {
168
+ const reservation = {
169
+ ...owner,
170
+ shareId,
171
+ contentVersion: 1,
172
+ attachmentId,
173
+ attachmentChunkCount: 2,
174
+ } as ShareLinkReservationRecord;
175
+ let remaining = 1600;
176
+ const client = {
177
+ stat: vi.fn(async (tuple: ShareContentTuple) => {
178
+ remaining = 1499;
179
+ return { ok: true as const, value: summary(tuple) };
180
+ }),
181
+ };
182
+ const budget = {
183
+ remainingMs: () => remaining,
184
+ reserveMs: () => 500,
185
+ exhausted: () => false,
186
+ };
187
+ await expect(verifyShareAttachmentStats(client, reservation, budget, 1000)).resolves.toBe(
188
+ false
189
+ );
190
+ expect(client.stat).toHaveBeenCalledOnce();
191
+ });
192
+ });
193
+
194
+ describe('short-lived attachment password grant', () => {
195
+ const record = {
196
+ id: shareId,
197
+ ownerProfileId: 'owner',
198
+ contentVersion: 1,
199
+ attachmentId,
200
+ attachmentChunkCount: 16,
201
+ passcodeHash: '$argon2id$password-verifier',
202
+ } as ShareLinkRecord;
203
+ const now = new Date('2026-10-03T00:00:00Z');
204
+ it('exposes neither password verifier nor identity scope and rejects changed bindings', () => {
205
+ const token = mintShareAttachmentAccessToken(record, 'deployment', '203.0.113.1', now);
206
+ const body = Buffer.from(token.split('.')[0]!, 'base64url').toString('utf8');
207
+ expect(body).not.toContain('password-verifier');
208
+ expect(body).not.toContain('owner');
209
+ expect(body).not.toContain('deployment');
210
+ expect(body).not.toContain('203.0.113.1');
211
+ expect(
212
+ verifyShareAttachmentAccessToken(token, record, 'deployment', '203.0.113.1', now)
213
+ ).toBe(true);
214
+ for (const changed of [
215
+ { ...record, contentVersion: 2 },
216
+ { ...record, attachmentId: randomUUID() },
217
+ { ...record, attachmentChunkCount: 15 },
218
+ { ...record, ownerProfileId: 'someone-else' },
219
+ { ...record, passcodeHash: 'changed' },
220
+ ]) {
221
+ expect(
222
+ verifyShareAttachmentAccessToken(token, changed, 'deployment', '203.0.113.1', now)
223
+ ).toBe(false);
224
+ }
225
+ expect(verifyShareAttachmentAccessToken(token, record, 'foreign', '203.0.113.1', now)).toBe(
226
+ false
227
+ );
228
+ expect(
229
+ verifyShareAttachmentAccessToken(token, record, 'deployment', '203.0.113.2', now)
230
+ ).toBe(false);
231
+ expect(
232
+ verifyShareAttachmentAccessToken(
233
+ token,
234
+ record,
235
+ 'deployment',
236
+ '203.0.113.1',
237
+ new Date(now.getTime() + 60_000)
238
+ )
239
+ ).toBe(false);
240
+ expect(
241
+ verifyShareAttachmentAccessToken(`${token}x`, record, 'deployment', '203.0.113.1', now)
242
+ ).toBe(false);
243
+ });
244
+ });