@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,800 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { ShareLinkPolicyResolver } from '@helpers/share-link-policy/types';
4
+ import type { ShareLinkRecord } from '../src/models/ShareLink';
5
+ import { SharePasscodeCapacityError } from '../src/helpers/share-link-passcode';
6
+ import type {
7
+ PublicShareLinkRouterDependencies,
8
+ PublicShareLinkSharer,
9
+ } from '../src/routes/public-share-links';
10
+
11
+ /**
12
+ * LC-2187 public share-link route-boundary tests.
13
+ *
14
+ * These exercise the REAL anonymous tRPC procedure chain (`openRouteWithoutInputCapture`:
15
+ * no did/challenge/profile middleware) with only the Sentry transport and the
16
+ * backing repository/receipts/remote fetch faked. No protected procedure replaces
17
+ * the public base and no backing service is real here.
18
+ */
19
+
20
+ const sentry = vi.hoisted(() => ({
21
+ trpcMiddleware: vi.fn(() => async (opts: { next: () => unknown }) => opts.next()),
22
+ setUser: vi.fn(),
23
+ configureScope: vi.fn(),
24
+ }));
25
+
26
+ vi.mock('@sentry/serverless', () => ({
27
+ Handlers: { trpcMiddleware: sentry.trpcMiddleware },
28
+ setUser: sentry.setUser,
29
+ configureScope: sentry.configureScope,
30
+ }));
31
+
32
+ vi.mock('@accesslayer/profile/read', () => ({
33
+ getProfileByDid: vi.fn(async () => null),
34
+ getProfileByProfileId: vi.fn(async () => null),
35
+ }));
36
+
37
+ const rateLimits = vi.hoisted(() => ({ enforceRateLimits: vi.fn(async () => undefined) }));
38
+ vi.mock('@helpers/rateLimit.helpers', () => ({
39
+ enforceRateLimits: rateLimits.enforceRateLimits,
40
+ }));
41
+
42
+ import {
43
+ createPublicShareLinksRouter,
44
+ publicShareContentUrl,
45
+ } from '../src/routes/public-share-links';
46
+
47
+ const NAME = 'deployment-ns';
48
+ const SHARE_ID = Buffer.alloc(16, 7).toString('base64url');
49
+ const OPERATION_ID = '0f5a2c1e-1c2b-4c3d-8e4f-5a6b7c8d9e0f';
50
+ const RECEIPT = Buffer.alloc(32, 9).toString('base64url');
51
+
52
+ const envelope = {
53
+ v: 1 as const,
54
+ alg: 'A256GCM' as const,
55
+ iv: Buffer.alloc(12, 1).toString('base64url'),
56
+ ct: Buffer.alloc(32, 2).toString('base64url'),
57
+ };
58
+
59
+ const shareRecord = (overrides: Partial<ShareLinkRecord> = {}): ShareLinkRecord => ({
60
+ id: SHARE_ID,
61
+ namespace: NAME,
62
+ ownerProfileId: 'owner-1',
63
+ version: 3,
64
+ contentVersion: 2,
65
+ generation: 4,
66
+ status: 'active',
67
+ contentState: 'finalized',
68
+ activeObjectRef: 'internal-object-ref',
69
+ activeObjectOperationId: OPERATION_ID,
70
+ activeContentHash: 'internal-content-hash',
71
+ activeContentBytes: 123,
72
+ activeRecoveryHash: 'internal-recovery-hash',
73
+ lastOperationId: OPERATION_ID,
74
+ createdByClientRequestId: 'client-request-id',
75
+ title: 'Shared credentials',
76
+ note: 'hello',
77
+ selectedCount: 2,
78
+ expiresAt: null,
79
+ stoppedAt: null,
80
+ viewCount: 0,
81
+ lastViewedAt: null,
82
+ minorPolicyIsMinor: null,
83
+ minorPolicyResolved: false,
84
+ minorPolicyDefaultExpiryDays: 30,
85
+ minorPolicyViewCountingEnabled: false,
86
+ createdAt: '2026-09-20T00:00:00.000Z',
87
+ updatedAt: '2026-09-20T00:00:00.000Z',
88
+ ...overrides,
89
+ });
90
+
91
+ const contentProjection = (record: ShareLinkRecord) => ({
92
+ kind: 'active' as const,
93
+ namespace: NAME,
94
+ ownerProfileId: record.ownerProfileId,
95
+ shareId: record.id,
96
+ contentVersion: record.contentVersion,
97
+ objectId: record.activeObjectRef as string,
98
+ operationId: record.activeObjectOperationId as string,
99
+ contentHash: 'tuple-inclusive-content-hash',
100
+ payloadHash: record.activeContentHash as string,
101
+ envelope,
102
+ ciphertextBytes: 32,
103
+ createdAt: '2026-09-20T00:00:00.000Z',
104
+ });
105
+
106
+ const eligiblePolicy: ShareLinkPolicyResolver = {
107
+ resolve: vi.fn(async () => ({
108
+ isMinor: false,
109
+ policyResolved: true,
110
+ defaultExpiryDays: 365 as const,
111
+ viewCountingEnabled: true,
112
+ })),
113
+ };
114
+
115
+ const ineligiblePolicy: ShareLinkPolicyResolver = {
116
+ resolve: vi.fn(async () => ({
117
+ isMinor: null,
118
+ policyResolved: false,
119
+ defaultExpiryDays: 30 as const,
120
+ viewCountingEnabled: false,
121
+ })),
122
+ };
123
+
124
+ const sharer: PublicShareLinkSharer = { displayName: 'Owner One', avatar: 'https://img/x' };
125
+
126
+ const makeDependencies = (
127
+ overrides: {
128
+ getShareLink?: (input: {
129
+ shareId: string;
130
+ namespace?: string;
131
+ }) => Promise<ShareLinkRecord | null>;
132
+ fetchContent?: PublicShareLinkRouterDependencies['repository']['fetchContent'];
133
+ policyResolver?: ShareLinkPolicyResolver;
134
+ persist?: PublicShareLinkRouterDependencies['receipts']['persist'];
135
+ lookupOwner?: PublicShareLinkRouterDependencies['receipts']['lookupOwner'];
136
+ lookupContext?: NonNullable<PublicShareLinkRouterDependencies['receipts']['lookupContext']>;
137
+ consume?: PublicShareLinkRouterDependencies['receipts']['consume'];
138
+ verifyPasscode?: NonNullable<PublicShareLinkRouterDependencies['verifyPasscode']>;
139
+ passcodeAttempts?: NonNullable<PublicShareLinkRouterDependencies['passcodeAttempts']>;
140
+ notifyView?: NonNullable<PublicShareLinkRouterDependencies['notifyView']>;
141
+ getSharer?: (ownerProfileId: string) => Promise<PublicShareLinkSharer | null>;
142
+ enforceRateLimit?: PublicShareLinkRouterDependencies['enforceRateLimit'];
143
+ newReceipt?: () => string;
144
+ } = {}
145
+ ): PublicShareLinkRouterDependencies => {
146
+ const record = shareRecord();
147
+ const base: PublicShareLinkRouterDependencies = {
148
+ namespace: NAME,
149
+ repository: {
150
+ getShareLink: vi.fn(async () => record),
151
+ fetchContent: vi.fn(async () => ({
152
+ ok: true as const,
153
+ value: contentProjection(record),
154
+ })),
155
+ },
156
+ receipts: {
157
+ persist: vi.fn(async () => true),
158
+ lookupOwner: vi.fn(async () => 'owner-1'),
159
+ consume: vi.fn(async () => 'consumed' as const),
160
+ },
161
+ policyResolver: ineligiblePolicy,
162
+ getSharer: vi.fn(async () => sharer),
163
+ passcodeAttempts: {
164
+ reserve: vi.fn(async () => true),
165
+ },
166
+ enforceRateLimit: vi.fn(async () => undefined),
167
+ contentUrlFor: (shareId: string) => `/share-links/${shareId}/content`,
168
+ newReceipt: () => RECEIPT,
169
+ now: () => new Date('2026-09-21T00:00:00.000Z'),
170
+ reportFailure: vi.fn(),
171
+ };
172
+
173
+ return {
174
+ ...base,
175
+ ...overrides,
176
+ repository: {
177
+ ...base.repository,
178
+ ...(overrides.getShareLink ? { getShareLink: overrides.getShareLink } : {}),
179
+ ...(overrides.fetchContent ? { fetchContent: overrides.fetchContent } : {}),
180
+ },
181
+ receipts: {
182
+ ...base.receipts,
183
+ ...(overrides.persist ? { persist: overrides.persist } : {}),
184
+ ...(overrides.lookupOwner ? { lookupOwner: overrides.lookupOwner } : {}),
185
+ ...(overrides.lookupContext ? { lookupContext: overrides.lookupContext } : {}),
186
+ ...(overrides.consume ? { consume: overrides.consume } : {}),
187
+ },
188
+ ...(overrides.policyResolver ? { policyResolver: overrides.policyResolver } : {}),
189
+ ...(overrides.getSharer ? { getSharer: overrides.getSharer } : {}),
190
+ ...(overrides.enforceRateLimit ? { enforceRateLimit: overrides.enforceRateLimit } : {}),
191
+ ...(overrides.newReceipt ? { newReceipt: overrides.newReceipt } : {}),
192
+ ...(overrides.verifyPasscode ? { verifyPasscode: overrides.verifyPasscode } : {}),
193
+ ...(overrides.passcodeAttempts ? { passcodeAttempts: overrides.passcodeAttempts } : {}),
194
+ ...(overrides.notifyView ? { notifyView: overrides.notifyView } : {}),
195
+ };
196
+ };
197
+
198
+ const makeCaller = (
199
+ dependencies: PublicShareLinkRouterDependencies | null,
200
+ options: { getDependenciesError?: Error; sourceIp?: string } = {}
201
+ ) => {
202
+ const router = createPublicShareLinksRouter(async () => {
203
+ if (options.getDependenciesError) throw options.getDependenciesError;
204
+ return dependencies;
205
+ });
206
+
207
+ return router.createCaller({
208
+ domain: 'network.example.com',
209
+ tenant: { id: 'default' },
210
+ sourceIp: options.sourceIp ?? '203.0.113.7',
211
+ } as never);
212
+ };
213
+
214
+ beforeEach(() => {
215
+ rateLimits.enforceRateLimits.mockReset();
216
+ rateLimits.enforceRateLimits.mockResolvedValue(undefined);
217
+ });
218
+
219
+ describe('public share-link resolve', () => {
220
+ it('answers not_found and calls nothing when configuration is disabled', async () => {
221
+ const dependencies = makeDependencies();
222
+ await expect(makeCaller(null).resolve({ id: SHARE_ID })).resolves.toEqual({
223
+ state: 'not_found',
224
+ id: SHARE_ID,
225
+ });
226
+ expect(dependencies.repository.getShareLink).not.toHaveBeenCalled();
227
+ expect(dependencies.receipts.persist).not.toHaveBeenCalled();
228
+ });
229
+
230
+ it('answers not_found (never throws) when lazy initialization fails', async () => {
231
+ await expect(
232
+ makeCaller(makeDependencies(), {
233
+ getDependenciesError: new Error('raw secret'),
234
+ }).resolve({ id: SHARE_ID })
235
+ ).resolves.toEqual({ state: 'not_found', id: SHARE_ID });
236
+ });
237
+
238
+ it('works anonymously and returns only the strict public metadata projection', async () => {
239
+ const caller = makeCaller(makeDependencies());
240
+
241
+ const result = await caller.resolve({ id: SHARE_ID });
242
+
243
+ expect(result).toEqual({
244
+ state: 'active',
245
+ id: SHARE_ID,
246
+ title: 'Shared credentials',
247
+ note: 'hello',
248
+ selectedCount: 2,
249
+ contentVersion: 2,
250
+ contentUrl: `/share-links/${SHARE_ID}/content`,
251
+ sharer,
252
+ createdAt: '2026-09-20T00:00:00.000Z',
253
+ updatedAt: '2026-09-20T00:00:00.000Z',
254
+ expiresAt: null,
255
+ });
256
+ expect(result).not.toHaveProperty('ownerProfileId');
257
+ expect(result).not.toHaveProperty('namespace');
258
+ expect(result).not.toHaveProperty('activeObjectRef');
259
+ expect(result).not.toHaveProperty('viewCount');
260
+ expect(result).not.toHaveProperty('minorPolicy');
261
+ });
262
+
263
+ it('never creates a receipt or a count on resolve', async () => {
264
+ const dependencies = makeDependencies({ policyResolver: eligiblePolicy });
265
+ await makeCaller(dependencies).resolve({ id: SHARE_ID });
266
+
267
+ expect(dependencies.receipts.persist).not.toHaveBeenCalled();
268
+ expect(dependencies.receipts.consume).not.toHaveBeenCalled();
269
+ });
270
+
271
+ it('requires and verifies a passcode without exposing the stored hash', async () => {
272
+ const verifyPasscode = vi.fn(
273
+ async (_hash: string, passcode: string) => passcode === '2468'
274
+ );
275
+ const dependencies = makeDependencies({
276
+ getShareLink: async () => shareRecord({ passcodeHash: '$argon2id$stored' }),
277
+ verifyPasscode,
278
+ });
279
+ const caller = makeCaller(dependencies);
280
+
281
+ await expect(caller.resolve({ id: SHARE_ID })).resolves.toEqual({
282
+ state: 'passcode_required',
283
+ id: SHARE_ID,
284
+ });
285
+ await expect(caller.resolve({ id: SHARE_ID, passcode: '1111' })).resolves.toEqual({
286
+ state: 'passcode_required',
287
+ id: SHARE_ID,
288
+ });
289
+ await expect(caller.resolve({ id: SHARE_ID, passcode: '2468' })).resolves.toMatchObject({
290
+ state: 'active',
291
+ id: SHARE_ID,
292
+ });
293
+ expect(verifyPasscode).toHaveBeenCalledWith('$argon2id$stored', '2468');
294
+ });
295
+
296
+ it('reports local capacity separately from a passcode guard failure', async () => {
297
+ const dependencies = makeDependencies({
298
+ getShareLink: async () => shareRecord({ passcodeHash: '$argon2id$stored' }),
299
+ verifyPasscode: async () => {
300
+ throw new SharePasscodeCapacityError();
301
+ },
302
+ });
303
+ await expect(
304
+ makeCaller(dependencies).resolve({ id: SHARE_ID, passcode: '2468' })
305
+ ).resolves.toEqual({ state: 'try_later', id: SHARE_ID });
306
+ expect(dependencies.reportFailure).toHaveBeenCalledWith('passcode_capacity');
307
+ expect(dependencies.reportFailure).not.toHaveBeenCalledWith('passcode_guard');
308
+ });
309
+
310
+ it('skips Argon2 for unprotected shares', async () => {
311
+ const verifyPasscode = vi.fn(async () => false);
312
+ const dependencies = makeDependencies({ verifyPasscode });
313
+ await expect(makeCaller(dependencies).resolve({ id: SHARE_ID })).resolves.toMatchObject({
314
+ state: 'active',
315
+ });
316
+ await expect(makeCaller(dependencies).content({ id: SHARE_ID })).resolves.toHaveProperty(
317
+ 'envelope'
318
+ );
319
+ expect(verifyPasscode).not.toHaveBeenCalled();
320
+ });
321
+
322
+ it('requires a passcode before revealing protected lifecycle state', async () => {
323
+ const verifyPasscode = vi.fn(async (_hash: string, value: string) => value === '2468');
324
+ for (const record of [
325
+ shareRecord({ passcodeHash: 'stored' }),
326
+ shareRecord({ passcodeHash: 'stored', expiresAt: '2026-09-20T00:00:00.000Z' }),
327
+ shareRecord({
328
+ passcodeHash: 'stored',
329
+ status: 'stopped',
330
+ stoppedAt: '2026-09-20T00:00:00.000Z',
331
+ }),
332
+ ]) {
333
+ const caller = makeCaller(
334
+ makeDependencies({ getShareLink: async () => record, verifyPasscode })
335
+ );
336
+ await expect(caller.resolve({ id: SHARE_ID })).resolves.toEqual({
337
+ state: 'passcode_required',
338
+ id: SHARE_ID,
339
+ });
340
+ await expect(caller.resolve({ id: SHARE_ID, passcode: 'wrong' })).resolves.toEqual({
341
+ state: 'passcode_required',
342
+ id: SHARE_ID,
343
+ });
344
+ await expect(caller.resolve({ id: SHARE_ID, passcode: '2468' })).resolves.toMatchObject(
345
+ {
346
+ state:
347
+ record.status === 'stopped'
348
+ ? 'stopped'
349
+ : record.expiresAt
350
+ ? 'expired'
351
+ : 'active',
352
+ }
353
+ );
354
+ }
355
+ const missing = makeCaller(
356
+ makeDependencies({ getShareLink: async () => null, verifyPasscode })
357
+ );
358
+ verifyPasscode.mockClear();
359
+ await expect(missing.resolve({ id: SHARE_ID, passcode: '2468' })).resolves.toEqual({
360
+ state: 'not_found',
361
+ id: SHARE_ID,
362
+ });
363
+ expect(verifyPasscode).not.toHaveBeenCalled();
364
+ });
365
+
366
+ it('shares the failed-attempt budget across source addresses and both endpoints', async () => {
367
+ let attempts = 0;
368
+ const passcodeAttempts = {
369
+ reserve: vi.fn(async () => ++attempts <= 2),
370
+ };
371
+ const verifyPasscode = vi.fn(async (_hash: string, value: string) => value === '2468');
372
+ const dependencies = makeDependencies({
373
+ getShareLink: async () => shareRecord({ passcodeHash: 'stored' }),
374
+ passcodeAttempts,
375
+ verifyPasscode,
376
+ });
377
+ await makeCaller(dependencies, { sourceIp: '203.0.113.1' }).resolve({
378
+ id: SHARE_ID,
379
+ passcode: 'wrong',
380
+ });
381
+ await expect(
382
+ makeCaller(dependencies, { sourceIp: '203.0.113.2' }).content({
383
+ id: SHARE_ID,
384
+ passcode: 'wrong',
385
+ })
386
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
387
+ await expect(
388
+ makeCaller(dependencies, { sourceIp: '203.0.113.3' }).resolve({
389
+ id: SHARE_ID,
390
+ passcode: '2468',
391
+ })
392
+ ).resolves.toEqual({ state: 'try_later', id: SHARE_ID });
393
+ expect(verifyPasscode).toHaveBeenCalledTimes(2);
394
+ attempts = 0; // the short window has elapsed
395
+ await expect(
396
+ makeCaller(dependencies).resolve({ id: SHARE_ID, passcode: '2468' })
397
+ ).resolves.toMatchObject({ state: 'active' });
398
+ });
399
+
400
+ it('reports stopped and expired without exposing internals', async () => {
401
+ const stopped = makeDependencies({
402
+ getShareLink: async () =>
403
+ shareRecord({ status: 'stopped', stoppedAt: '2026-09-21T01:00:00.000Z' }),
404
+ });
405
+ await expect(makeCaller(stopped).resolve({ id: SHARE_ID })).resolves.toEqual({
406
+ state: 'stopped',
407
+ id: SHARE_ID,
408
+ stoppedAt: '2026-09-21T01:00:00.000Z',
409
+ });
410
+
411
+ const expired = makeDependencies({
412
+ getShareLink: async () => shareRecord({ expiresAt: '2026-09-20T00:00:00.000Z' }),
413
+ });
414
+ await expect(makeCaller(expired).resolve({ id: SHARE_ID })).resolves.toEqual({
415
+ state: 'expired',
416
+ id: SHARE_ID,
417
+ expiresAt: '2026-09-20T00:00:00.000Z',
418
+ });
419
+ });
420
+
421
+ it.each(['staging', 'content_missing'] as const)(
422
+ 'fails closed to not_found for contentState %s',
423
+ async state => {
424
+ const dependencies = makeDependencies({
425
+ getShareLink: async () => shareRecord({ contentState: state, status: 'pending' }),
426
+ });
427
+ await expect(makeCaller(dependencies).resolve({ id: SHARE_ID })).resolves.toEqual({
428
+ state: 'not_found',
429
+ id: SHARE_ID,
430
+ });
431
+ }
432
+ );
433
+
434
+ it('rejects unknown fields strictly and uses a share-id-free rate-limit key', async () => {
435
+ const dependencies = makeDependencies();
436
+ const caller = makeCaller(dependencies);
437
+
438
+ await expect(caller.resolve({ id: SHARE_ID, extra: 1 } as never)).rejects.toMatchObject({
439
+ code: 'BAD_REQUEST',
440
+ });
441
+ expect(dependencies.repository.getShareLink).not.toHaveBeenCalled();
442
+
443
+ await caller.resolve({ id: SHARE_ID });
444
+ const [window] = (dependencies.enforceRateLimit as ReturnType<typeof vi.fn>).mock.calls[0];
445
+ expect(window.key).toBe(`share-link-public-resolve:${NAME}:203.0.113.7`);
446
+ expect(window.key).not.toContain(SHARE_ID);
447
+ });
448
+
449
+ it('fails closed on a rate-limit dependency error', async () => {
450
+ await expect(
451
+ makeCaller(
452
+ makeDependencies({
453
+ enforceRateLimit: vi.fn(async () => {
454
+ throw new Error('cache down');
455
+ }),
456
+ })
457
+ ).resolve({ id: SHARE_ID })
458
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
459
+ });
460
+
461
+ it('fails closed when the metadata revision changes during the awaited sharer lookup', async () => {
462
+ let reads = 0;
463
+ const dependencies = makeDependencies({
464
+ getShareLink: async () => {
465
+ reads += 1;
466
+ return reads === 1 ? shareRecord() : shareRecord({ version: 4 });
467
+ },
468
+ });
469
+
470
+ await expect(makeCaller(dependencies).resolve({ id: SHARE_ID })).resolves.toEqual({
471
+ state: 'not_found',
472
+ id: SHARE_ID,
473
+ });
474
+ });
475
+
476
+ it('fails closed (never leaks a raw message) when the repository throws', async () => {
477
+ const dependencies = makeDependencies({
478
+ getShareLink: async () => {
479
+ throw new Error('raw graph secret');
480
+ },
481
+ });
482
+
483
+ await expect(makeCaller(dependencies).resolve({ id: SHARE_ID })).resolves.toEqual({
484
+ state: 'not_found',
485
+ id: SHARE_ID,
486
+ });
487
+ });
488
+ });
489
+
490
+ describe('public share-link content', () => {
491
+ it('enforces the passcode before fetching protected content', async () => {
492
+ const fetchContent = vi.fn(async () => ({
493
+ ok: true as const,
494
+ value: contentProjection(shareRecord()),
495
+ }));
496
+ const dependencies = makeDependencies({
497
+ getShareLink: async () => shareRecord({ passcodeHash: '$argon2id$stored' }),
498
+ fetchContent,
499
+ verifyPasscode: async (_hash, passcode) => passcode === '2468',
500
+ });
501
+ const caller = makeCaller(dependencies);
502
+
503
+ await expect(caller.content({ id: SHARE_ID })).rejects.toMatchObject({
504
+ code: 'UNAUTHORIZED',
505
+ });
506
+ expect(fetchContent).not.toHaveBeenCalled();
507
+
508
+ await expect(caller.content({ id: SHARE_ID, passcode: '2468' })).resolves.toMatchObject({
509
+ id: SHARE_ID,
510
+ envelope,
511
+ });
512
+ });
513
+ it('withholds content when revoked during the awaited policy lookup', async () => {
514
+ let revoked = false;
515
+ const dependencies = makeDependencies({
516
+ getShareLink: async () => shareRecord(revoked ? { status: 'stopped' } : {}),
517
+ policyResolver: {
518
+ resolve: async () => {
519
+ revoked = true;
520
+ return ineligiblePolicy.resolve('owner-1');
521
+ },
522
+ },
523
+ });
524
+ await expect(makeCaller(dependencies).content({ id: SHARE_ID })).rejects.toMatchObject({
525
+ code: 'NOT_FOUND',
526
+ });
527
+ });
528
+
529
+ it('returns the validated envelope, contentVersion and an opaque receipt only', async () => {
530
+ const dependencies = makeDependencies({
531
+ policyResolver: eligiblePolicy,
532
+ getShareLink: async () =>
533
+ shareRecord({
534
+ minorPolicyIsMinor: false,
535
+ minorPolicyResolved: true,
536
+ minorPolicyViewCountingEnabled: true,
537
+ }),
538
+ });
539
+ const result = await makeCaller(dependencies).content({ id: SHARE_ID });
540
+
541
+ expect(result).toEqual({
542
+ id: SHARE_ID,
543
+ contentVersion: 2,
544
+ envelope,
545
+ contentUrl: `/share-links/${SHARE_ID}/content`,
546
+ receipt: RECEIPT,
547
+ });
548
+ expect(result).not.toHaveProperty('objectId');
549
+ expect(result).not.toHaveProperty('operationId');
550
+ expect(result).not.toHaveProperty('contentHash');
551
+ expect(dependencies.receipts.persist).toHaveBeenCalledTimes(1);
552
+ });
553
+
554
+ it('returns identical-shape padding and persists nothing for an ineligible owner', async () => {
555
+ const dependencies = makeDependencies({ policyResolver: ineligiblePolicy });
556
+ const result = await makeCaller(dependencies).content({ id: SHARE_ID });
557
+
558
+ expect(result.receipt).toBe(RECEIPT);
559
+ expect(result.receipt).toHaveLength(43);
560
+ expect(dependencies.receipts.persist).not.toHaveBeenCalled();
561
+ });
562
+
563
+ it('fails closed when the committed policy is disabled even if policy now permits', async () => {
564
+ const dependencies = makeDependencies({
565
+ policyResolver: eligiblePolicy,
566
+ getShareLink: async () => shareRecord({ minorPolicyViewCountingEnabled: false }),
567
+ });
568
+
569
+ await makeCaller(dependencies).content({ id: SHARE_ID });
570
+
571
+ expect(dependencies.receipts.persist).not.toHaveBeenCalled();
572
+ });
573
+
574
+ it('withholds bytes when the share is revoked between fetch and the final recheck', async () => {
575
+ let reads = 0;
576
+ const dependencies = makeDependencies({
577
+ policyResolver: eligiblePolicy,
578
+ getShareLink: async () => {
579
+ reads += 1;
580
+ return reads === 1
581
+ ? shareRecord()
582
+ : shareRecord({ status: 'stopped', stoppedAt: '2026-09-21T00:00:01.000Z' });
583
+ },
584
+ });
585
+
586
+ await expect(makeCaller(dependencies).content({ id: SHARE_ID })).rejects.toMatchObject({
587
+ code: 'NOT_FOUND',
588
+ });
589
+ expect(dependencies.receipts.persist).not.toHaveBeenCalled();
590
+ });
591
+
592
+ it('withholds bytes when the content version is replaced during the remote read', async () => {
593
+ let reads = 0;
594
+ const dependencies = makeDependencies({
595
+ policyResolver: eligiblePolicy,
596
+ getShareLink: async () => {
597
+ reads += 1;
598
+ return reads === 1
599
+ ? shareRecord()
600
+ : shareRecord({
601
+ version: 4,
602
+ contentVersion: 3,
603
+ activeObjectRef: 'new-object-ref',
604
+ activeObjectOperationId: 'new-operation-id',
605
+ });
606
+ },
607
+ });
608
+
609
+ await expect(makeCaller(dependencies).content({ id: SHARE_ID })).rejects.toMatchObject({
610
+ code: 'NOT_FOUND',
611
+ });
612
+ });
613
+
614
+ it('answers not_found when disabled, without repository calls', async () => {
615
+ const dependencies = makeDependencies();
616
+ await expect(makeCaller(null).content({ id: SHARE_ID })).rejects.toMatchObject({
617
+ code: 'NOT_FOUND',
618
+ });
619
+ expect(dependencies.repository.fetchContent).not.toHaveBeenCalled();
620
+ });
621
+
622
+ it('maps an unavailable remote read to a fixed service-unavailable error', async () => {
623
+ const dependencies = makeDependencies({
624
+ fetchContent: async () => ({ ok: false as const, error: 'UNAVAILABLE' }),
625
+ });
626
+
627
+ await expect(makeCaller(dependencies).content({ id: SHARE_ID })).rejects.toMatchObject({
628
+ code: 'SERVICE_UNAVAILABLE',
629
+ });
630
+ });
631
+
632
+ it('withholds content when revoked during receipt persistence, after all awaited work', async () => {
633
+ let revoked = false;
634
+ const enabled = {
635
+ minorPolicyIsMinor: false,
636
+ minorPolicyResolved: true,
637
+ minorPolicyViewCountingEnabled: true,
638
+ };
639
+ const dependencies = makeDependencies({
640
+ policyResolver: eligiblePolicy,
641
+ getShareLink: async () =>
642
+ revoked
643
+ ? shareRecord({ status: 'stopped', stoppedAt: '2026-09-21T00:00:01.000Z' })
644
+ : shareRecord(enabled),
645
+ persist: vi.fn(async () => {
646
+ revoked = true;
647
+ return true;
648
+ }),
649
+ });
650
+
651
+ await expect(makeCaller(dependencies).content({ id: SHARE_ID })).rejects.toMatchObject({
652
+ code: 'NOT_FOUND',
653
+ });
654
+ });
655
+
656
+ it('mounts the returned content URL under the deployed /api base path', () => {
657
+ expect(publicShareContentUrl(SHARE_ID)).toBe(`/api/public/share-links/${SHARE_ID}/content`);
658
+ expect(publicShareContentUrl('a/b?c')).toBe('/api/public/share-links/a%2Fb%3Fc/content');
659
+ });
660
+ });
661
+
662
+ describe('public share-link acknowledgeView', () => {
663
+ it('sends an opted-in adult notification with count and time only', async () => {
664
+ const notifyView = vi.fn(async () => undefined);
665
+ const viewedAt = '2026-09-21T00:00:02.000Z';
666
+ const dependencies = makeDependencies({
667
+ policyResolver: eligiblePolicy,
668
+ lookupContext: vi.fn(async () => ({ ownerProfileId: 'owner-1', shareId: SHARE_ID })),
669
+ getShareLink: async () =>
670
+ shareRecord({
671
+ notifyOnView: true,
672
+ viewCount: 4,
673
+ lastViewedAt: viewedAt,
674
+ minorPolicyIsMinor: false,
675
+ minorPolicyResolved: true,
676
+ minorPolicyViewCountingEnabled: true,
677
+ }),
678
+ notifyView,
679
+ });
680
+
681
+ await makeCaller(dependencies).acknowledgeView({ receipt: RECEIPT });
682
+
683
+ expect(notifyView).toHaveBeenCalledWith({
684
+ shareId: SHARE_ID,
685
+ ownerProfileId: 'owner-1',
686
+ title: 'Shared credentials',
687
+ selectedCount: 2,
688
+ viewCount: 4,
689
+ viewedAt,
690
+ });
691
+ expect(notifyView.mock.calls[0][0]).not.toHaveProperty('ip');
692
+ expect(notifyView.mock.calls[0][0]).not.toHaveProperty('device');
693
+ expect(notifyView.mock.calls[0][0]).not.toHaveProperty('location');
694
+ });
695
+ it('always returns the uniform { ok: true } and never exposes eligibility', async () => {
696
+ const consumed = makeDependencies({ consume: vi.fn(async () => 'consumed') });
697
+ await expect(makeCaller(consumed).acknowledgeView({ receipt: RECEIPT })).resolves.toEqual({
698
+ ok: true,
699
+ });
700
+
701
+ const ineligible = makeDependencies({ consume: vi.fn(async () => 'ineligible') });
702
+ await expect(makeCaller(ineligible).acknowledgeView({ receipt: RECEIPT })).resolves.toEqual(
703
+ { ok: true }
704
+ );
705
+
706
+ const unknown = makeDependencies({ lookupOwner: vi.fn(async () => null) });
707
+ await expect(makeCaller(unknown).acknowledgeView({ receipt: RECEIPT })).resolves.toEqual({
708
+ ok: true,
709
+ });
710
+ expect(unknown.receipts.consume).not.toHaveBeenCalled();
711
+ });
712
+
713
+ it('stays uniform when disabled, rate-limited or when the receipt dependency fails', async () => {
714
+ await expect(makeCaller(null).acknowledgeView({ receipt: RECEIPT })).resolves.toEqual({
715
+ ok: true,
716
+ });
717
+
718
+ const limited = makeDependencies({
719
+ enforceRateLimit: vi.fn(async () => {
720
+ throw new Error('cache down');
721
+ }),
722
+ });
723
+ await expect(makeCaller(limited).acknowledgeView({ receipt: RECEIPT })).resolves.toEqual({
724
+ ok: true,
725
+ });
726
+ expect(limited.receipts.consume).not.toHaveBeenCalled();
727
+
728
+ const failing = makeDependencies({
729
+ consume: vi.fn(async () => {
730
+ throw new Error('graph down');
731
+ }),
732
+ });
733
+ await expect(makeCaller(failing).acknowledgeView({ receipt: RECEIPT })).resolves.toEqual({
734
+ ok: true,
735
+ });
736
+ });
737
+
738
+ it('resolves authoritative policy for the receipt owner before consuming', async () => {
739
+ const dependencies = makeDependencies({
740
+ policyResolver: eligiblePolicy,
741
+ lookupOwner: vi.fn(async () => 'owner-1'),
742
+ });
743
+
744
+ await makeCaller(dependencies).acknowledgeView({ receipt: RECEIPT });
745
+
746
+ expect(dependencies.policyResolver.resolve).toHaveBeenCalledWith('owner-1');
747
+ expect(dependencies.receipts.consume).toHaveBeenCalledWith({
748
+ receipt: RECEIPT,
749
+ namespace: NAME,
750
+ eligibilitySource: undefined,
751
+ });
752
+ });
753
+
754
+ it('passes the transaction-compatible eligibility source through to consume', async () => {
755
+ const eligibilitySource = { isEligible: vi.fn(async () => true) };
756
+ const dependencies = makeDependencies({
757
+ policyResolver: eligiblePolicy,
758
+ lookupOwner: vi.fn(async () => 'owner-1'),
759
+ });
760
+ const withSource = { ...dependencies, eligibilitySource };
761
+
762
+ await makeCaller(withSource).acknowledgeView({ receipt: RECEIPT });
763
+
764
+ expect(dependencies.receipts.consume).toHaveBeenCalledWith({
765
+ receipt: RECEIPT,
766
+ namespace: NAME,
767
+ eligibilitySource,
768
+ });
769
+ });
770
+
771
+ it('does not open a consume transaction when the pre-lock policy forbids counting', async () => {
772
+ const dependencies = makeDependencies({
773
+ policyResolver: ineligiblePolicy,
774
+ lookupOwner: vi.fn(async () => 'owner-1'),
775
+ });
776
+
777
+ await makeCaller(dependencies).acknowledgeView({ receipt: RECEIPT });
778
+
779
+ expect(dependencies.receipts.consume).not.toHaveBeenCalled();
780
+ });
781
+
782
+ it('rejects malformed receipts as a generic validation error', async () => {
783
+ const dependencies = makeDependencies();
784
+ await expect(
785
+ makeCaller(dependencies).acknowledgeView({ receipt: 'short' } as never)
786
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
787
+ expect(dependencies.receipts.consume).not.toHaveBeenCalled();
788
+ });
789
+
790
+ it('rate-limits ack with a share-id-free key', async () => {
791
+ const dependencies = makeDependencies();
792
+ await makeCaller(dependencies).acknowledgeView({ receipt: RECEIPT });
793
+
794
+ expect(dependencies.receipts.lookupOwner).toHaveBeenCalledWith(RECEIPT, NAME);
795
+
796
+ const [window] = (dependencies.enforceRateLimit as ReturnType<typeof vi.fn>).mock.calls[0];
797
+ expect(window.key).toBe(`share-link-public-ack:${NAME}:203.0.113.7`);
798
+ expect(window.key).not.toContain(RECEIPT);
799
+ });
800
+ });