@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,462 @@
1
+ import { generateKeyPairSync, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto';
2
+
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import {
6
+ computeShareContentRequestBodyHash,
7
+ createShareContentClient,
8
+ resolveShareContentClientConfig,
9
+ type ShareContentPutRequest,
10
+ type ShareContentTuple,
11
+ } from '@helpers/share-content-client';
12
+
13
+ /**
14
+ * Transport tests for the LC-2187 LearnCloud client.
15
+ *
16
+ * Every request is validated by a fake HTTP service that independently verifies
17
+ * the Ed25519 JWS, the JOSE header allowlist, the signed claims binding and the
18
+ * canonical body hash. The client's token is produced by a real Ed25519 signer,
19
+ * so these are genuine signatures, not a stub that merely echoes.
20
+ */
21
+
22
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
23
+
24
+ const SIGNER_DID = 'did:web:brain.test';
25
+ const KID = `${SIGNER_DID}#key-1`;
26
+ const AUDIENCE = 'did:web:learncloud.test';
27
+ const NAMESPACE = 'test-namespace';
28
+
29
+ const encodePart = (value: unknown): string =>
30
+ Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
31
+
32
+ const signClaims = async (claims: Record<string, unknown>): Promise<string> => {
33
+ const header = { alg: 'EdDSA', kid: KID };
34
+ const payload = {
35
+ iss: claims.iss,
36
+ vp: { holder: claims.iss },
37
+ nonce: JSON.stringify(claims),
38
+ };
39
+ const signingInput = `${encodePart(header)}.${encodePart(payload)}`;
40
+ const signature = cryptoSign(null, Buffer.from(signingInput, 'utf8'), privateKey);
41
+
42
+ return `${signingInput}.${signature.toString('base64url')}`;
43
+ };
44
+
45
+ type DecodedToken = {
46
+ header: Record<string, unknown>;
47
+ payload: Record<string, unknown>;
48
+ claims: Record<string, unknown>;
49
+ signatureValid: boolean;
50
+ };
51
+
52
+ const decodeToken = (token: string): DecodedToken => {
53
+ const [headerPart, payloadPart, signaturePart] = token.split('.');
54
+
55
+ const header = JSON.parse(Buffer.from(headerPart ?? '', 'base64url').toString('utf8'));
56
+ const payload = JSON.parse(Buffer.from(payloadPart ?? '', 'base64url').toString('utf8'));
57
+ const signatureValid = cryptoVerify(
58
+ null,
59
+ Buffer.from(`${headerPart}.${payloadPart}`, 'utf8'),
60
+ publicKey,
61
+ Buffer.from(signaturePart ?? '', 'base64url')
62
+ );
63
+
64
+ return { header, payload, claims: JSON.parse(payload.nonce), signatureValid };
65
+ };
66
+
67
+ const baseTuple = (): ShareContentTuple => ({
68
+ namespace: NAMESPACE,
69
+ ownerProfileId: 'owner-1',
70
+ shareId: Buffer.alloc(16, 1).toString('base64url'),
71
+ contentVersion: 1,
72
+ objectId: 'o'.repeat(43),
73
+ operationId: '11111111-1111-4111-8111-111111111111',
74
+ });
75
+
76
+ const basePutRequest = (): ShareContentPutRequest => ({
77
+ ...baseTuple(),
78
+ envelope: {
79
+ v: 1,
80
+ alg: 'A256GCM',
81
+ iv: Buffer.alloc(12, 2).toString('base64url'),
82
+ ct: Buffer.alloc(32, 3).toString('base64url'),
83
+ },
84
+ ownerEncryptedRecovery: { protected: 'a', iv: 'b', ciphertext: 'c', tag: 'd' },
85
+ });
86
+
87
+ const jsonResponse = (body: unknown, status = 200): Response =>
88
+ new Response(JSON.stringify(body), {
89
+ status,
90
+ headers: { 'content-type': 'application/json' },
91
+ });
92
+
93
+ const activeSummary = (tuple: ShareContentTuple, overrides: Record<string, unknown> = {}) => ({
94
+ kind: 'active',
95
+ ...tuple,
96
+ contentHash: 'a'.repeat(64),
97
+ payloadHash: 'b'.repeat(64),
98
+ ciphertextBytes: 32,
99
+ recoveryBytes: 12,
100
+ createdAt: '2026-09-20T00:00:00.000Z',
101
+ ...overrides,
102
+ });
103
+
104
+ type HandlerContext = {
105
+ op: string;
106
+ body: Record<string, unknown>;
107
+ token: string;
108
+ claims: Record<string, unknown>;
109
+ signal: AbortSignal | null | undefined;
110
+ attempt: number;
111
+ };
112
+
113
+ const makeHarness = (
114
+ handler: (context: HandlerContext) => Response | Promise<Response>,
115
+ configOverrides: Record<string, unknown> = {}
116
+ ) => {
117
+ const calls: HandlerContext[] = [];
118
+ const seenJtis = new Set<string>();
119
+
120
+ const fetchImpl = vi.fn(async (input: unknown, init?: RequestInit) => {
121
+ const url = String(input);
122
+ const op = url.split('/').pop() ?? '';
123
+ const headers = (init?.headers ?? {}) as Record<string, string>;
124
+ const authorization = headers.authorization ?? '';
125
+
126
+ expect(authorization.startsWith('Bearer ')).toBe(true);
127
+
128
+ const token = authorization.slice('Bearer '.length);
129
+ const decoded = decodeToken(token);
130
+
131
+ expect(decoded.signatureValid).toBe(true);
132
+ expect(decoded.header.alg).toBe('EdDSA');
133
+ expect(decoded.header.kid).toBe(KID);
134
+ expect(decoded.payload.iss).toBe(SIGNER_DID);
135
+
136
+ const body = JSON.parse(String(init?.body));
137
+ const claims = decoded.claims;
138
+
139
+ expect(claims.op).toBe(op);
140
+ expect(claims.aud).toBe(AUDIENCE);
141
+ expect(claims.iss).toBe(SIGNER_DID);
142
+ expect(claims.requestHash).toBe(computeShareContentRequestBodyHash(body));
143
+ expect(claims.namespace).toBe(body.namespace);
144
+ expect(claims.ownerProfileId).toBe(body.ownerProfileId);
145
+ expect(claims.shareId).toBe(body.shareId);
146
+ expect(claims.contentVersion).toBe(body.contentVersion);
147
+ expect(claims.objectId).toBe(body.objectId);
148
+ expect(claims.operationId).toBe(body.operationId);
149
+
150
+ // Every transport attempt must carry a fresh CSPRNG nonce.
151
+ expect(typeof claims.jti).toBe('string');
152
+ expect((claims.jti as string).length).toBeGreaterThanOrEqual(16);
153
+ expect(seenJtis.has(claims.jti as string)).toBe(false);
154
+ seenJtis.add(claims.jti as string);
155
+
156
+ const context: HandlerContext = {
157
+ op,
158
+ body,
159
+ token,
160
+ claims,
161
+ signal: init?.signal,
162
+ attempt: calls.length,
163
+ };
164
+ calls.push(context);
165
+
166
+ return handler(context);
167
+ });
168
+
169
+ const config = resolveShareContentClientConfig({
170
+ enabled: true,
171
+ origin: 'https://learncloud.test',
172
+ namespace: NAMESPACE,
173
+ audience: AUDIENCE,
174
+ signerDid: SIGNER_DID,
175
+ signer: signClaims,
176
+ sleep: async () => undefined,
177
+ fetchImpl,
178
+ ...configOverrides,
179
+ });
180
+
181
+ return { client: createShareContentClient(config), calls, fetchImpl };
182
+ };
183
+
184
+ describe('share-content-client origin and configuration', () => {
185
+ it('accepts https and explicit loopback http, rejects other insecure origins', () => {
186
+ const make = (options: Record<string, unknown>) =>
187
+ resolveShareContentClientConfig({
188
+ enabled: true,
189
+ origin: 'https://learncloud.test',
190
+ namespace: NAMESPACE,
191
+ audience: AUDIENCE,
192
+ signerDid: SIGNER_DID,
193
+ signer: signClaims,
194
+ ...options,
195
+ });
196
+
197
+ expect(make({ origin: 'https://learncloud.test' }).enabled).toBe(true);
198
+ expect(make({ origin: 'http://localhost:3000', allowInsecureLoopback: true }).enabled).toBe(
199
+ true
200
+ );
201
+ expect(make({ origin: 'http://localhost:3000' }).enabled).toBe(false);
202
+ expect(
203
+ make({ origin: 'http://learncloud.test', allowInsecureLoopback: true }).enabled
204
+ ).toBe(false);
205
+ expect(make({ origin: 'https://learncloud.test/api' }).enabled).toBe(false);
206
+ expect(make({ origin: 'https://user:pass@learncloud.test' }).enabled).toBe(false);
207
+ expect(make({ namespace: 'bad%3Anamespace' }).enabled).toBe(false);
208
+ expect(make({ enabled: false }).enabled).toBe(false);
209
+ });
210
+
211
+ it('returns DISABLED without any network call when the config is disabled', async () => {
212
+ const fetchImpl = vi.fn();
213
+ const client = createShareContentClient(
214
+ resolveShareContentClientConfig({ enabled: false, fetchImpl })
215
+ );
216
+
217
+ const result = await client.put(basePutRequest());
218
+
219
+ expect(result).toEqual({ ok: false, error: 'DISABLED' });
220
+ expect(fetchImpl).not.toHaveBeenCalled();
221
+ });
222
+ });
223
+
224
+ describe('share-content-client request handling', () => {
225
+ it('sends a canonical body hash and returns a validated put result', async () => {
226
+ const { client, calls } = makeHarness(() =>
227
+ jsonResponse({
228
+ ok: true,
229
+ value: { status: 'created', record: activeSummary(baseTuple()) },
230
+ })
231
+ );
232
+
233
+ const result = await client.put(basePutRequest());
234
+
235
+ expect(result.ok).toBe(true);
236
+ if (result.ok) expect(result.value.record.objectId).toBe(baseTuple().objectId);
237
+ expect(calls).toHaveLength(1);
238
+ expect(calls[0]?.op).toBe('put');
239
+ expect(calls[0]?.claims.iat).toEqual(expect.any(Number));
240
+ expect(calls[0]?.claims.exp).toEqual(expect.any(Number));
241
+ });
242
+
243
+ it('never retries a semantic 409 conflict', async () => {
244
+ const { client, fetchImpl } = makeHarness(
245
+ () => jsonResponse({ ok: false, error: 'CONFLICT' }, 409),
246
+ { maxAttempts: 3 }
247
+ );
248
+
249
+ const result = await client.put(basePutRequest());
250
+
251
+ expect(result).toEqual({ ok: false, error: 'CONFLICT' });
252
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ it('retries transient 503 with a fresh nonce each attempt', async () => {
256
+ const { client, calls, fetchImpl } = makeHarness(
257
+ context =>
258
+ context.attempt < 2
259
+ ? jsonResponse({ ok: false, error: 'UNAVAILABLE' }, 503)
260
+ : jsonResponse({
261
+ ok: true,
262
+ value: { status: 'created', record: activeSummary(baseTuple()) },
263
+ }),
264
+ { maxAttempts: 3 }
265
+ );
266
+
267
+ const result = await client.put(basePutRequest());
268
+
269
+ expect(result.ok).toBe(true);
270
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
271
+ expect(new Set(calls.map(call => call.claims.jti)).size).toBe(3);
272
+ });
273
+
274
+ it('maps every fixed error status without leaking the response body', async () => {
275
+ const cases: Array<[number, string]> = [
276
+ [400, 'INVALID_INPUT'],
277
+ [401, 'UNAUTHORIZED'],
278
+ [404, 'NOT_FOUND'],
279
+ [409, 'CONFLICT'],
280
+ [413, 'PAYLOAD_TOO_LARGE'],
281
+ [503, 'UNAVAILABLE'],
282
+ [500, 'UNEXPECTED_STATUS'],
283
+ ];
284
+
285
+ for (const [status, code] of cases) {
286
+ const { client } = makeHarness(
287
+ () => jsonResponse({ ok: false, error: 'secret-internal-detail' }, status),
288
+ { maxAttempts: 1 }
289
+ );
290
+
291
+ const result = await client.put(basePutRequest());
292
+
293
+ expect(result).toEqual({ ok: false, error: code });
294
+ }
295
+ });
296
+
297
+ it('rejects a redirect instead of following it', async () => {
298
+ const { client } = makeHarness(
299
+ () =>
300
+ new Response(null, {
301
+ status: 302,
302
+ headers: { location: 'https://evil.test/internal/share-content/put' },
303
+ }),
304
+ { maxAttempts: 1 }
305
+ );
306
+
307
+ expect(await client.put(basePutRequest())).toEqual({
308
+ ok: false,
309
+ error: 'REDIRECT_REJECTED',
310
+ });
311
+ });
312
+
313
+ it('rejects non-JSON and unmatched-tuple responses', async () => {
314
+ const nonJson = makeHarness(
315
+ () =>
316
+ new Response('not-json', {
317
+ status: 200,
318
+ headers: { 'content-type': 'text/plain' },
319
+ }),
320
+ { maxAttempts: 1 }
321
+ );
322
+ expect(await nonJson.client.put(basePutRequest())).toEqual({
323
+ ok: false,
324
+ error: 'MALFORMED_RESPONSE',
325
+ });
326
+
327
+ const mismatch = makeHarness(() =>
328
+ jsonResponse({
329
+ ok: true,
330
+ value: {
331
+ status: 'created',
332
+ record: activeSummary({ ...baseTuple(), objectId: 'x'.repeat(43) }),
333
+ },
334
+ })
335
+ );
336
+ expect(await mismatch.client.put(basePutRequest())).toEqual({
337
+ ok: false,
338
+ error: 'RESPONSE_TUPLE_MISMATCH',
339
+ });
340
+ });
341
+
342
+ it('rejects an oversized response before full allocation', async () => {
343
+ const { client } = makeHarness(
344
+ () =>
345
+ jsonResponse({
346
+ ok: true,
347
+ value: {
348
+ status: 'created',
349
+ record: activeSummary(baseTuple(), { padding: 'x'.repeat(8192) }),
350
+ },
351
+ }),
352
+ { maxAttempts: 1, maxResponseBytes: 1024 }
353
+ );
354
+
355
+ expect(await client.put(basePutRequest())).toEqual({
356
+ ok: false,
357
+ error: 'RESPONSE_TOO_LARGE',
358
+ });
359
+ });
360
+
361
+ it('rejects an oversized request body before any network call', async () => {
362
+ const { client, fetchImpl } = makeHarness(
363
+ () =>
364
+ jsonResponse({
365
+ ok: true,
366
+ value: { status: 'created', record: activeSummary(baseTuple()) },
367
+ }),
368
+ { maxAttempts: 1, maxRequestBytes: 1024 }
369
+ );
370
+
371
+ const request = basePutRequest();
372
+ request.ownerEncryptedRecovery = { blob: 'x'.repeat(4096) };
373
+
374
+ expect(await client.put(request)).toEqual({ ok: false, error: 'PAYLOAD_TOO_LARGE' });
375
+ expect(fetchImpl).not.toHaveBeenCalled();
376
+ });
377
+
378
+ it('bounds a hanging transport with a timeout', async () => {
379
+ const { client } = makeHarness(
380
+ (context: HandlerContext) =>
381
+ new Promise<Response>((_resolve, reject) => {
382
+ context.signal?.addEventListener('abort', () =>
383
+ reject(new DOMException('aborted', 'AbortError'))
384
+ );
385
+ }),
386
+ { maxAttempts: 1, requestTimeoutMs: 20 }
387
+ );
388
+
389
+ expect(await client.put(basePutRequest())).toEqual({ ok: false, error: 'TIMEOUT' });
390
+ });
391
+
392
+ it('surfaces a transport failure as NETWORK_ERROR', async () => {
393
+ const { client } = makeHarness(() => Promise.reject(new TypeError('socket closed')), {
394
+ maxAttempts: 1,
395
+ });
396
+
397
+ expect(await client.put(basePutRequest())).toEqual({ ok: false, error: 'NETWORK_ERROR' });
398
+ });
399
+
400
+ it('rejects an invalid envelope before any network call', async () => {
401
+ const { client, fetchImpl } = makeHarness(() => jsonResponse({ ok: true, value: {} }));
402
+
403
+ const request = basePutRequest();
404
+ request.envelope = { ...request.envelope, alg: 'A128GCM' } as never;
405
+
406
+ expect(await client.put(request)).toEqual({ ok: false, error: 'INVALID_INPUT' });
407
+ expect(fetchImpl).not.toHaveBeenCalled();
408
+ });
409
+
410
+ it('supports get/stat/delete/readRecovery against the fixed routes', async () => {
411
+ const tuple = baseTuple();
412
+ const { client, calls } = makeHarness(context => {
413
+ if (context.op === 'stat')
414
+ return jsonResponse({
415
+ ok: true,
416
+ value: {
417
+ kind: 'tombstone',
418
+ ...tuple,
419
+ deletedAt: '2026-09-20T00:00:00.000Z',
420
+ },
421
+ });
422
+ if (context.op === 'delete')
423
+ return jsonResponse({
424
+ ok: true,
425
+ value: { kind: 'tombstone', ...tuple, deletedAt: '2026-09-20T00:00:00.000Z' },
426
+ });
427
+ if (context.op === 'readRecovery')
428
+ return jsonResponse({
429
+ ok: true,
430
+ value: {
431
+ kind: 'active',
432
+ ...tuple,
433
+ contentHash: 'a'.repeat(64),
434
+ payloadHash: 'b'.repeat(64),
435
+ ownerEncryptedRecovery: { protected: 'x' },
436
+ recoveryBytes: 12,
437
+ createdAt: '2026-09-20T00:00:00.000Z',
438
+ },
439
+ });
440
+
441
+ return jsonResponse({
442
+ ok: true,
443
+ value: {
444
+ kind: 'active',
445
+ ...tuple,
446
+ contentHash: 'a'.repeat(64),
447
+ payloadHash: 'b'.repeat(64),
448
+ envelope: basePutRequest().envelope,
449
+ ciphertextBytes: 32,
450
+ createdAt: '2026-09-20T00:00:00.000Z',
451
+ },
452
+ });
453
+ });
454
+
455
+ expect((await client.get(tuple)).ok).toBe(true);
456
+ const stat = await client.stat(tuple);
457
+ expect(stat.ok && stat.value.kind).toBe('tombstone');
458
+ expect((await client.delete(tuple)).ok).toBe(true);
459
+ expect((await client.readRecovery(tuple)).ok).toBe(true);
460
+ expect(calls.map(call => call.op)).toEqual(['get', 'stat', 'delete', 'readRecovery']);
461
+ });
462
+ });
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const store = vi.hoisted(() => new Map<string, number>());
4
+ vi.mock('@cache', () => ({
5
+ default: {
6
+ get: async (key: string) => (store.has(key) ? String(store.get(key)) : null),
7
+ incr: async (key: string) => {
8
+ const next = (store.get(key) ?? 0) + 1;
9
+ store.set(key, next);
10
+ return next;
11
+ },
12
+ },
13
+ }));
14
+
15
+ import { reserveSharePasscodeAttempt } from '../src/helpers/share-link-passcode-abuse';
16
+ import { claimShareViewNotification } from '../src/helpers/share-link-view-notification';
17
+
18
+ beforeEach(() => store.clear());
19
+
20
+ describe('share-link abuse windows', () => {
21
+ it('bounds attempts per source and across rotating source addresses, then recovers', async () => {
22
+ for (let index = 0; index < 6; index++) {
23
+ expect(await reserveSharePasscodeAttempt('ns', 'share', 'source-a')).toBe(true);
24
+ }
25
+ expect(await reserveSharePasscodeAttempt('ns', 'share', 'source-a')).toBe(false);
26
+ expect(store.get('share-link-passcode-attempt:ns:share')).toBe(6);
27
+ expect(await reserveSharePasscodeAttempt('ns', 'share', 'source-b')).toBe(true);
28
+ for (let index = 0; index < 17; index++) {
29
+ await reserveSharePasscodeAttempt('ns', 'share', `rotating-${index}`);
30
+ }
31
+ expect(await reserveSharePasscodeAttempt('ns', 'share', 'fresh-source')).toBe(false);
32
+ store.clear(); // Redis expiry at the end of the one-minute window.
33
+ expect(await reserveSharePasscodeAttempt('ns', 'share', 'source-a')).toBe(true);
34
+ });
35
+
36
+ it('does not let one over-limit source consume the shared budget', async () => {
37
+ const attempts = await Promise.all(
38
+ Array.from({ length: 30 }, () =>
39
+ reserveSharePasscodeAttempt('ns', 'single-source', 'source-a')
40
+ )
41
+ );
42
+ expect(attempts.filter(Boolean)).toHaveLength(6);
43
+ expect(store.get('share-link-passcode-attempt:ns:single-source')).toBe(6);
44
+ expect(await reserveSharePasscodeAttempt('ns', 'single-source', 'source-b')).toBe(true);
45
+ });
46
+
47
+ it('admits at most 24 concurrent verifications across rotated sources', async () => {
48
+ const admitted = await Promise.all(
49
+ Array.from({ length: 50 }, (_, index) =>
50
+ reserveSharePasscodeAttempt('ns', 'burst', `source-${index}`)
51
+ )
52
+ );
53
+ expect(admitted.filter(Boolean)).toHaveLength(24);
54
+ });
55
+
56
+ it('atomically claims only one owner notification per share window', async () => {
57
+ const claims = await Promise.all(
58
+ Array.from({ length: 20 }, () => claimShareViewNotification('ns', 'share'))
59
+ );
60
+ expect(claims.filter(Boolean)).toHaveLength(1);
61
+ expect(await claimShareViewNotification('ns', 'another-share')).toBe(true);
62
+ store.clear();
63
+ expect(await claimShareViewNotification('ns', 'share')).toBe(true);
64
+ });
65
+ });