@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,752 @@
1
+ import { t, profileRoute, profileRouteWithoutInputCapture } from '../src/routes';
2
+ import { getProfileByDid } from '@accesslayer/profile/read';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ import { AUTH_GRANT_FULL_ACCESS_SCOPE } from 'src/constants/auth-grant';
8
+ import {
9
+ AUTH_GRANT_SHARE_LINKS_READ_SCOPE,
10
+ AUTH_GRANT_SHARE_LINKS_WRITE_SCOPE,
11
+ } from 'src/constants/auth-grant';
12
+ import { ShareLinkCoordinatorError } from '@helpers/share-link-coordinator';
13
+ import type {
14
+ RecoveryRunnerDependencies,
15
+ ShareLinkCoordinator,
16
+ } from '@helpers/share-link-coordinator';
17
+ import type { ShareLinkRecord } from '../src/models/ShareLink';
18
+
19
+ /**
20
+ * LC-2187 owner API route-boundary tests.
21
+ *
22
+ * These exercise the REAL tRPC procedure chain: the same did/challenge/profile
23
+ * and scope middleware as production (only the Sentry transport and the backing
24
+ * repository/coordinator are fakes). No unprotected procedure replaces
25
+ * `profileRoute`.
26
+ */
27
+
28
+ const OWNER_PROFILE = {
29
+ profileId: 'owner-1',
30
+ did: 'did:key:owner-1',
31
+ displayName: 'Owner One',
32
+ };
33
+
34
+ const sentry = vi.hoisted(() => ({
35
+ trpcMiddleware: vi.fn(() => async (opts: { next: () => unknown }) => opts.next()),
36
+ setUser: vi.fn(),
37
+ configureScope: vi.fn(),
38
+ }));
39
+
40
+ vi.mock('@sentry/serverless', () => ({
41
+ Handlers: { trpcMiddleware: sentry.trpcMiddleware },
42
+ setUser: sentry.setUser,
43
+ configureScope: sentry.configureScope,
44
+ }));
45
+
46
+ vi.mock('@accesslayer/profile/read', () => ({
47
+ getProfileByDid: vi.fn(async () => OWNER_PROFILE),
48
+ getProfileByProfileId: vi.fn(async () => OWNER_PROFILE),
49
+ }));
50
+
51
+ const rateLimits = vi.hoisted(() => ({ enforceRateLimits: vi.fn(async () => undefined) }));
52
+
53
+ vi.mock('@helpers/rateLimit.helpers', () => ({
54
+ enforceRateLimits: rateLimits.enforceRateLimits,
55
+ }));
56
+
57
+ const recoveryRunner = vi.hoisted(() => ({
58
+ recoverShareLinkOperation: vi.fn(),
59
+ runShareLinkRecoveryOnce: vi.fn(),
60
+ }));
61
+
62
+ vi.mock('@helpers/share-link-coordinator/recovery-runner', () => ({
63
+ recoverShareLinkOperation: recoveryRunner.recoverShareLinkOperation,
64
+ runShareLinkRecoveryOnce: recoveryRunner.runShareLinkRecoveryOnce,
65
+ }));
66
+
67
+ import { createShareLinksRouter, enforceOwnerShareWriteRateLimit } from '../src/routes/share-links';
68
+ import type { ShareLinkRouterDependencies } from '../src/routes/share-links';
69
+ import { encodeShareLinkListCursor } from '../src/accesslayer/share-link/list';
70
+
71
+ const NAME = 'deployment-ns';
72
+ const SHARE_ID = Buffer.alloc(16, 7).toString('base64url');
73
+ const OPERATION_ID = randomUUID();
74
+
75
+ const envelope = {
76
+ v: 1 as const,
77
+ alg: 'A256GCM' as const,
78
+ iv: Buffer.alloc(12, 1).toString('base64url'),
79
+ ct: Buffer.alloc(32, 2).toString('base64url'),
80
+ };
81
+ const recovery = { protected: 'p', iv: 'i', ciphertext: 'c', tag: 't' };
82
+ const contentProjection = {
83
+ kind: 'active' as const,
84
+ namespace: NAME,
85
+ ownerProfileId: OWNER_PROFILE.profileId,
86
+ shareId: SHARE_ID,
87
+ contentVersion: 1,
88
+ objectId: 'internal-object-ref',
89
+ operationId: OPERATION_ID,
90
+ contentHash: 'a'.repeat(64),
91
+ payloadHash: 'b'.repeat(64),
92
+ envelope,
93
+ ciphertextBytes: 64,
94
+ createdAt: '2026-09-20T00:00:00.000Z',
95
+ };
96
+
97
+ const createInput = () => ({
98
+ id: SHARE_ID,
99
+ clientRequestId: randomUUID(),
100
+ title: 'Shared credentials',
101
+ selectedCount: 1,
102
+ contentVersion: 1 as const,
103
+ envelope,
104
+ ownerEncryptedRecovery: recovery,
105
+ });
106
+
107
+ const shareRecord = (overrides: Partial<ShareLinkRecord> = {}): ShareLinkRecord => ({
108
+ id: SHARE_ID,
109
+ namespace: NAME,
110
+ ownerProfileId: OWNER_PROFILE.profileId,
111
+ version: 1,
112
+ contentVersion: 1,
113
+ generation: 1,
114
+ status: 'active',
115
+ contentState: 'finalized',
116
+ // Internal fields that must never leak through the projection.
117
+ activeObjectRef: 'internal-object-ref',
118
+ activeObjectOperationId: OPERATION_ID,
119
+ activeContentHash: 'internal-content-hash',
120
+ activeContentBytes: 123,
121
+ activeRecoveryHash: 'internal-recovery-hash',
122
+ lastOperationId: OPERATION_ID,
123
+ createdByClientRequestId: 'client-request-id',
124
+ title: 'Shared credentials',
125
+ note: null,
126
+ selectedCount: 1,
127
+ expiresAt: null,
128
+ stoppedAt: null,
129
+ viewCount: 0,
130
+ lastViewedAt: null,
131
+ minorPolicyIsMinor: null,
132
+ minorPolicyResolved: false,
133
+ minorPolicyDefaultExpiryDays: 30,
134
+ minorPolicyViewCountingEnabled: false,
135
+ createdAt: '2026-09-20T00:00:00.000Z',
136
+ updatedAt: '2026-09-20T00:00:00.000Z',
137
+ ...overrides,
138
+ });
139
+
140
+ const baseCoordinator = (): ShareLinkCoordinator => ({
141
+ createShareLink: vi.fn(async () => ({ status: 'committed', share: shareRecord() })),
142
+ updateShareLink: vi.fn(async () => ({ status: 'committed', share: shareRecord() })),
143
+ revokeShareLink: vi.fn(async () => ({
144
+ status: 'revoked',
145
+ share: shareRecord({ status: 'stopped', stoppedAt: '2026-09-21T00:00:00.000Z' }),
146
+ cleanupQueuedFor: [],
147
+ })),
148
+ resumePendingOperation: vi.fn(),
149
+ abandonPendingOperation: vi.fn(),
150
+ getShareLink: vi.fn(async () => shareRecord()),
151
+ getActiveShareContent: vi.fn(),
152
+ fetchShareContent: vi.fn(async () => ({ ok: true as const, value: contentProjection })),
153
+ readOwnerRecovery: vi.fn(async () => ({
154
+ ok: true as const,
155
+ value: { ownerEncryptedRecovery: recovery },
156
+ })),
157
+ });
158
+
159
+ const baseRecovery = (): RecoveryRunnerDependencies =>
160
+ ({
161
+ repository: {
162
+ readShareLinkRecoveryTarget: vi.fn(async () => ({
163
+ state: 'absent' as const,
164
+ })),
165
+ },
166
+ client: {},
167
+ claimant: 'route-test',
168
+ namespace: NAME,
169
+ }) as unknown as RecoveryRunnerDependencies;
170
+
171
+ const makeDependencies = (
172
+ overrides: {
173
+ coordinator?: Partial<ShareLinkCoordinator>;
174
+ recovery?: RecoveryRunnerDependencies;
175
+ listShareLinks?: ShareLinkRouterDependencies['listShareLinks'];
176
+ enforceOwnerWriteRateLimit?: (ownerProfileId: string) => Promise<void>;
177
+ resolveViewCountingEligibility?: (ownerProfileId: string) => Promise<boolean>;
178
+ } = {}
179
+ ) => ({
180
+ namespace: NAME,
181
+ coordinator: { ...baseCoordinator(), ...overrides.coordinator } as ShareLinkCoordinator,
182
+ recovery: overrides.recovery ?? baseRecovery(),
183
+ listShareLinks:
184
+ overrides.listShareLinks ??
185
+ vi.fn(async () => ({ records: [], hasMore: false, nextCursor: null })),
186
+ enforceOwnerWriteRateLimit:
187
+ overrides.enforceOwnerWriteRateLimit ?? vi.fn(async () => undefined),
188
+ resolveViewCountingEligibility:
189
+ overrides.resolveViewCountingEligibility ?? vi.fn(async () => true),
190
+ });
191
+
192
+ const makeCaller = (
193
+ dependencies: ReturnType<typeof makeDependencies> | null,
194
+ options: {
195
+ user?: { did: string; isChallengeValid: boolean; scope: string } | undefined;
196
+ getDependenciesError?: Error;
197
+ } = {}
198
+ ) => {
199
+ const router = createShareLinksRouter(async () => {
200
+ if (options.getDependenciesError) throw options.getDependenciesError;
201
+ return dependencies;
202
+ });
203
+
204
+ return router.createCaller({
205
+ domain: 'network.example.com',
206
+ tenant: { id: 'default' },
207
+ user: 'user' in options ? options.user : authenticatedUser,
208
+ } as never);
209
+ };
210
+
211
+ const authenticatedUser = {
212
+ did: OWNER_PROFILE.did,
213
+ isChallengeValid: true,
214
+ scope: AUTH_GRANT_FULL_ACCESS_SCOPE,
215
+ };
216
+
217
+ beforeEach(() => {
218
+ rateLimits.enforceRateLimits.mockReset();
219
+ rateLimits.enforceRateLimits.mockResolvedValue(undefined);
220
+ recoveryRunner.recoverShareLinkOperation.mockReset();
221
+ });
222
+
223
+ describe.each([
224
+ ['standard', profileRoute],
225
+ ['private input', profileRouteWithoutInputCapture],
226
+ ])('%s profile authentication chain', (_name, procedure) => {
227
+ const endpoint = t.router({
228
+ read: procedure
229
+ .meta({ requiredScope: AUTH_GRANT_SHARE_LINKS_READ_SCOPE })
230
+ .query(({ ctx }) => ctx.user.profile.profileId),
231
+ });
232
+ it.each([
233
+ [undefined, 'UNAUTHORIZED'],
234
+ [{ ...authenticatedUser, did: '' }, 'UNAUTHORIZED'],
235
+ [{ ...authenticatedUser, isChallengeValid: false }, 'UNAUTHORIZED'],
236
+ [{ ...authenticatedUser, scope: AUTH_GRANT_SHARE_LINKS_WRITE_SCOPE }, 'UNAUTHORIZED'],
237
+ [{ ...authenticatedUser, scope: AUTH_GRANT_SHARE_LINKS_READ_SCOPE }, null],
238
+ [authenticatedUser, null],
239
+ ])('enforces caller %#', async (user, code) => {
240
+ const call = endpoint
241
+ .createCaller({
242
+ domain: 'network.example.com',
243
+ tenant: { id: 'default' },
244
+ user,
245
+ } as never)
246
+ .read();
247
+ if (code) await expect(call).rejects.toMatchObject({ code });
248
+ else await expect(call).resolves.toBe(OWNER_PROFILE.profileId);
249
+ });
250
+ it('rejects a DID with no profile', async () => {
251
+ vi.mocked(getProfileByDid).mockResolvedValueOnce(null);
252
+ await expect(
253
+ endpoint
254
+ .createCaller({
255
+ domain: 'network.example.com',
256
+ tenant: { id: 'default' },
257
+ user: authenticatedUser,
258
+ } as never)
259
+ .read()
260
+ ).rejects.toMatchObject({ code: 'NOT_FOUND' });
261
+ });
262
+ });
263
+
264
+ describe('share-link owner route boundary', () => {
265
+ it('captures transaction names but never attaches share-link RPC input', async () => {
266
+ await makeCaller(makeDependencies()).get({ id: SHARE_ID });
267
+
268
+ expect(sentry.trpcMiddleware).toHaveBeenCalledWith({ attachRpcInput: false });
269
+ expect(sentry.configureScope).toHaveBeenCalled();
270
+ });
271
+
272
+ it('answers NOT_FOUND and touches nothing when configuration is disabled', async () => {
273
+ const dependencies = makeDependencies();
274
+ const caller = makeCaller(null);
275
+
276
+ await expect(caller.create(createInput())).rejects.toMatchObject({ code: 'NOT_FOUND' });
277
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
278
+ });
279
+
280
+ it('rejects an unauthenticated caller before any dependency work', async () => {
281
+ const dependencies = makeDependencies();
282
+ const caller = makeCaller(dependencies, { user: undefined as never });
283
+
284
+ await expect(caller.create(createInput())).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
285
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it('rejects a caller without a valid challenge', async () => {
289
+ const dependencies = makeDependencies();
290
+ const caller = makeCaller(dependencies, {
291
+ user: { ...authenticatedUser, isChallengeValid: false },
292
+ });
293
+
294
+ await expect(caller.create(createInput())).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
295
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
296
+ });
297
+
298
+ it('rejects insufficient scopes', async () => {
299
+ const dependencies = makeDependencies();
300
+ const caller = makeCaller(dependencies, {
301
+ user: { ...authenticatedUser, scope: AUTH_GRANT_SHARE_LINKS_READ_SCOPE },
302
+ });
303
+
304
+ await expect(caller.create(createInput())).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
305
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it('derives owner and namespace from the authenticated context, not the body', async () => {
309
+ const dependencies = makeDependencies();
310
+ const caller = makeCaller(dependencies);
311
+
312
+ await caller.create(createInput());
313
+
314
+ expect(dependencies.coordinator.createShareLink).toHaveBeenCalledWith(expect.anything(), {
315
+ namespace: NAME,
316
+ ownerProfileId: OWNER_PROFILE.profileId,
317
+ });
318
+ });
319
+
320
+ it('answers not_found for a cross-owner read without leaking the target owner', async () => {
321
+ const dependencies = makeDependencies({
322
+ coordinator: { getShareLink: vi.fn(async () => null) },
323
+ });
324
+ const caller = makeCaller(dependencies);
325
+
326
+ await expect(caller.get({ id: SHARE_ID })).resolves.toEqual({
327
+ status: 'not_found',
328
+ id: SHARE_ID,
329
+ });
330
+ expect(dependencies.coordinator.getShareLink).toHaveBeenCalledWith(SHARE_ID, {
331
+ namespace: NAME,
332
+ ownerProfileId: OWNER_PROFILE.profileId,
333
+ });
334
+ });
335
+
336
+ it('returns only the safe owner projection', async () => {
337
+ const caller = makeCaller(makeDependencies());
338
+
339
+ const result = await caller.get({ id: SHARE_ID });
340
+
341
+ expect(result.status).toBe('found');
342
+ if (result.status !== 'found') throw new Error('unreachable');
343
+ expect(Object.keys(result.share).sort()).toEqual(
344
+ expect.arrayContaining(['id', 'title', 'minorPolicy', 'status'])
345
+ );
346
+ expect(result.share).not.toHaveProperty('activeObjectRef');
347
+ expect(result.share).not.toHaveProperty('activeContentHash');
348
+ expect(result.share).not.toHaveProperty('lastOperationId');
349
+ expect(result.share).not.toHaveProperty('createdByClientRequestId');
350
+ });
351
+
352
+ it('suppresses viewCount and lastViewedAt for a currently ineligible owner', async () => {
353
+ // Formerly eligible snapshot: committed counting enabled, count present.
354
+ const dependencies = makeDependencies({
355
+ coordinator: {
356
+ getShareLink: vi.fn(async () =>
357
+ shareRecord({
358
+ viewCount: 42,
359
+ lastViewedAt: '2026-09-21T01:00:00.000Z',
360
+ minorPolicyIsMinor: false,
361
+ minorPolicyResolved: true,
362
+ minorPolicyViewCountingEnabled: true,
363
+ })
364
+ ),
365
+ },
366
+ // Authoritative re-resolve now says the owner is ineligible.
367
+ resolveViewCountingEligibility: vi.fn(async () => false),
368
+ });
369
+
370
+ const result = await makeCaller(dependencies).get({ id: SHARE_ID });
371
+
372
+ expect(result.status).toBe('found');
373
+ if (result.status !== 'found') throw new Error('unreachable');
374
+ expect(result.share).not.toHaveProperty('viewCount');
375
+ expect(result.share.lastViewedAt).toBeNull();
376
+ });
377
+
378
+ it('reports completed with the authoritative stopped share status on revoke', async () => {
379
+ const caller = makeCaller(makeDependencies());
380
+
381
+ const result = await caller.revoke({ id: SHARE_ID });
382
+
383
+ expect(result.status).toBe('completed');
384
+ if (result.status !== 'completed') throw new Error('unreachable');
385
+ expect(result.share.status).toBe('stopped');
386
+ expect(result.share.stoppedAt).toBe('2026-09-21T00:00:00.000Z');
387
+ });
388
+
389
+ it('rejects unknown fields strictly', async () => {
390
+ const dependencies = makeDependencies();
391
+ const caller = makeCaller(dependencies);
392
+
393
+ await expect(
394
+ caller.create({ ...createInput(), unexpected: true } as never)
395
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
396
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
397
+ });
398
+
399
+ it('rejects a complete request over 1 MiB on the raw input', async () => {
400
+ const dependencies = makeDependencies();
401
+ const caller = makeCaller(dependencies);
402
+
403
+ await expect(
404
+ caller.create({ ...createInput(), padding: 'x'.repeat(1_100_000) } as never)
405
+ ).rejects.toMatchObject({
406
+ code: 'PAYLOAD_TOO_LARGE',
407
+ message: expect.stringContaining('1 MiB'),
408
+ });
409
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
410
+ });
411
+
412
+ it('maps coordinator failures to fixed sanitized codes and text', async () => {
413
+ const dependencies = makeDependencies({
414
+ coordinator: {
415
+ createShareLink: vi.fn(async () => {
416
+ throw new ShareLinkCoordinatorError('LEASE_EXPIRED', 'raw secret lease detail');
417
+ }),
418
+ },
419
+ });
420
+ const caller = makeCaller(dependencies);
421
+
422
+ await expect(caller.create(createInput())).rejects.toMatchObject({
423
+ code: 'CONFLICT',
424
+ message: 'share-link state conflict',
425
+ });
426
+
427
+ try {
428
+ await caller.create(createInput());
429
+ } catch (error) {
430
+ expect(JSON.stringify(error)).not.toContain('raw secret lease detail');
431
+ }
432
+ });
433
+
434
+ it('propagates an exhausted rate limit without calling the coordinator', async () => {
435
+ const dependencies = makeDependencies({
436
+ enforceOwnerWriteRateLimit: vi.fn(async () => {
437
+ throw new Error('rate limit exceeded');
438
+ }),
439
+ });
440
+ const caller = makeCaller(dependencies);
441
+
442
+ await expect(caller.create(createInput())).rejects.toThrow('rate limit exceeded');
443
+ expect(dependencies.coordinator.createShareLink).not.toHaveBeenCalled();
444
+ });
445
+
446
+ it('retries a failed lazy initialization on the next request', async () => {
447
+ const dependencies = makeDependencies();
448
+ let initialized = false;
449
+ const router = createShareLinksRouter(async () => {
450
+ if (!initialized) {
451
+ initialized = true;
452
+ throw new Error('raw initialization secret');
453
+ }
454
+ return dependencies;
455
+ });
456
+ const caller = router.createCaller({
457
+ domain: 'network.example.com',
458
+ tenant: { id: 'default' },
459
+ user: authenticatedUser,
460
+ } as never);
461
+
462
+ await expect(caller.create(createInput())).rejects.toMatchObject({
463
+ code: 'INTERNAL_SERVER_ERROR',
464
+ message: 'share-link service is unavailable',
465
+ });
466
+
467
+ await expect(caller.create(createInput())).resolves.toMatchObject({
468
+ status: 'completed',
469
+ });
470
+ });
471
+
472
+ it('returns the owner recovery JWE and nothing else', async () => {
473
+ const caller = makeCaller(makeDependencies());
474
+
475
+ await expect(caller.getRecovery({ id: SHARE_ID })).resolves.toEqual({ recovery });
476
+ });
477
+
478
+ it('returns only owner ciphertext fields and allows retained expired content', async () => {
479
+ const dependencies = makeDependencies();
480
+ const caller = makeCaller(dependencies);
481
+
482
+ await expect(caller.getContent({ id: SHARE_ID })).resolves.toEqual({
483
+ id: SHARE_ID,
484
+ contentVersion: 1,
485
+ envelope,
486
+ });
487
+ expect(dependencies.coordinator.fetchShareContent).toHaveBeenCalledWith(
488
+ SHARE_ID,
489
+ { namespace: NAME, ownerProfileId: OWNER_PROFILE.profileId },
490
+ { allowExpired: true }
491
+ );
492
+ });
493
+
494
+ it('fails closed when owner content is stopped or missing', async () => {
495
+ const caller = makeCaller(
496
+ makeDependencies({
497
+ coordinator: {
498
+ fetchShareContent: vi.fn(async () => ({
499
+ ok: false as const,
500
+ error: 'NOT_FOUND' as const,
501
+ })),
502
+ },
503
+ })
504
+ );
505
+
506
+ await expect(caller.getContent({ id: SHARE_ID })).rejects.toMatchObject({
507
+ code: 'NOT_FOUND',
508
+ });
509
+ });
510
+ it('fails closed on a malformed stored recovery', async () => {
511
+ const dependencies = makeDependencies({
512
+ coordinator: {
513
+ readOwnerRecovery: vi.fn(async () => ({
514
+ ok: true as const,
515
+ value: { ownerEncryptedRecovery: {} },
516
+ })),
517
+ },
518
+ });
519
+ const caller = makeCaller(dependencies);
520
+
521
+ await expect(caller.getRecovery({ id: SHARE_ID })).rejects.toMatchObject({
522
+ code: 'NOT_FOUND',
523
+ });
524
+ });
525
+
526
+ it('retries only persisted-key recovery and maps its outcomes', async () => {
527
+ const dependencies = makeDependencies();
528
+ const caller = makeCaller(dependencies);
529
+
530
+ recoveryRunner.recoverShareLinkOperation.mockResolvedValueOnce({
531
+ status: 'committed',
532
+ share: shareRecord(),
533
+ });
534
+ await expect(
535
+ caller.retry({ id: SHARE_ID, operationId: OPERATION_ID })
536
+ ).resolves.toMatchObject({ status: 'found' });
537
+
538
+ recoveryRunner.recoverShareLinkOperation.mockResolvedValueOnce({ status: 'abandoned' });
539
+ await expect(
540
+ caller.retry({ id: SHARE_ID, operationId: OPERATION_ID })
541
+ ).resolves.toMatchObject({ status: 'not_found' });
542
+
543
+ expect(recoveryRunner.recoverShareLinkOperation).toHaveBeenCalledWith(
544
+ dependencies.recovery,
545
+ {
546
+ namespace: NAME,
547
+ ownerProfileId: OWNER_PROFILE.profileId,
548
+ shareId: SHARE_ID,
549
+ operationId: OPERATION_ID,
550
+ }
551
+ );
552
+ });
553
+
554
+ describe('list', () => {
555
+ const emptyPage = { records: [], hasMore: false, nextCursor: null };
556
+
557
+ it('answers NOT_FOUND when configuration is disabled', async () => {
558
+ const caller = makeCaller(null);
559
+
560
+ await expect(caller.list({ limit: 25 })).rejects.toMatchObject({ code: 'NOT_FOUND' });
561
+ });
562
+
563
+ it('rejects a write-scope-only caller before listing', async () => {
564
+ const listShareLinks = vi.fn(async () => emptyPage);
565
+ const caller = makeCaller(makeDependencies({ listShareLinks }), {
566
+ user: { ...authenticatedUser, scope: AUTH_GRANT_SHARE_LINKS_WRITE_SCOPE },
567
+ });
568
+
569
+ await expect(caller.list({ limit: 25 })).rejects.toMatchObject({
570
+ code: 'UNAUTHORIZED',
571
+ });
572
+ expect(listShareLinks).not.toHaveBeenCalled();
573
+ });
574
+
575
+ it('passes only trusted namespace/owner and the default limit', async () => {
576
+ const listShareLinks = vi.fn(async () => ({
577
+ records: [shareRecord()],
578
+ hasMore: false,
579
+ nextCursor: null,
580
+ }));
581
+ const caller = makeCaller(makeDependencies({ listShareLinks }));
582
+
583
+ const result = await caller.list({ limit: 10 });
584
+
585
+ expect(listShareLinks).toHaveBeenCalledWith({
586
+ namespace: NAME,
587
+ ownerProfileId: OWNER_PROFILE.profileId,
588
+ limit: 10,
589
+ cursor: null,
590
+ });
591
+ expect(result.records).toHaveLength(1);
592
+ });
593
+
594
+ it('decodes a canonical cursor into exactly the ordering fields', async () => {
595
+ const listShareLinks = vi.fn(async () => emptyPage);
596
+ const caller = makeCaller(makeDependencies({ listShareLinks }));
597
+ const cursor = encodeShareLinkListCursor({
598
+ createdAt: '2026-09-20T00:00:00.000Z',
599
+ id: SHARE_ID,
600
+ });
601
+
602
+ await caller.list({ limit: 25, cursor });
603
+
604
+ expect(listShareLinks).toHaveBeenCalledWith({
605
+ namespace: NAME,
606
+ ownerProfileId: OWNER_PROFILE.profileId,
607
+ limit: 25,
608
+ cursor: { createdAt: '2026-09-20T00:00:00.000Z', id: SHARE_ID },
609
+ });
610
+ });
611
+
612
+ it('rejects malformed/foreign-version/invalid-field cursors with one fixed safe error', async () => {
613
+ const listShareLinks = vi.fn(async () => emptyPage);
614
+ const caller = makeCaller(makeDependencies({ listShareLinks }));
615
+
616
+ const malformed = [
617
+ 'not-a-cursor',
618
+ 'v2.abc',
619
+ 'v1.@@@',
620
+ encodeShareLinkListCursor({ createdAt: 'not-a-date', id: SHARE_ID }),
621
+ encodeShareLinkListCursor({ createdAt: '2026-09-20T00:00:00.000Z', id: 'short' }),
622
+ ];
623
+
624
+ for (const cursor of malformed) {
625
+ await expect(caller.list({ limit: 25, cursor })).rejects.toMatchObject({
626
+ code: 'BAD_REQUEST',
627
+ message: 'invalid share-link cursor',
628
+ });
629
+ }
630
+
631
+ expect(listShareLinks).not.toHaveBeenCalled();
632
+ });
633
+
634
+ it('rejects an oversized raw cursor at the input validator', async () => {
635
+ const listShareLinks = vi.fn(async () => emptyPage);
636
+ const caller = makeCaller(makeDependencies({ listShareLinks }));
637
+
638
+ await expect(
639
+ caller.list({ limit: 25, cursor: `v1.${'A'.repeat(600)}` })
640
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
641
+ expect(listShareLinks).not.toHaveBeenCalled();
642
+ });
643
+
644
+ it('returns the opaque next cursor and hasMore unchanged', async () => {
645
+ const nextCursor = encodeShareLinkListCursor({
646
+ createdAt: '2026-09-19T00:00:00.000Z',
647
+ id: SHARE_ID,
648
+ });
649
+ const listShareLinks = vi.fn(async () => ({
650
+ records: [shareRecord()],
651
+ hasMore: true,
652
+ nextCursor,
653
+ }));
654
+ const caller = makeCaller(makeDependencies({ listShareLinks }));
655
+
656
+ await expect(caller.list({ limit: 1 })).resolves.toMatchObject({
657
+ hasMore: true,
658
+ cursor: nextCursor,
659
+ });
660
+ });
661
+
662
+ it('projects only safe owner fields and suppresses counts for an ineligible owner', async () => {
663
+ const eligibleButNowDenied = shareRecord({
664
+ viewCount: 42,
665
+ lastViewedAt: '2026-09-21T01:00:00.000Z',
666
+ minorPolicyIsMinor: false,
667
+ minorPolicyResolved: true,
668
+ minorPolicyViewCountingEnabled: true,
669
+ });
670
+ const listShareLinks = vi.fn(async () => ({
671
+ records: [eligibleButNowDenied],
672
+ hasMore: false,
673
+ nextCursor: null,
674
+ }));
675
+ const resolveViewCountingEligibility = vi.fn(async () => false);
676
+ const caller = makeCaller(
677
+ makeDependencies({ listShareLinks, resolveViewCountingEligibility })
678
+ );
679
+
680
+ const result = await caller.list({ limit: 25 });
681
+ const record = result.records[0];
682
+
683
+ expect(resolveViewCountingEligibility).toHaveBeenCalledTimes(1);
684
+ expect(record).not.toHaveProperty('activeObjectRef');
685
+ expect(record).not.toHaveProperty('activeContentHash');
686
+ expect(record).not.toHaveProperty('lastOperationId');
687
+ expect(record).not.toHaveProperty('viewCount');
688
+ expect(record.lastViewedAt).toBeNull();
689
+ });
690
+
691
+ it('suppresses counts fail-closed when the eligibility resolver throws', async () => {
692
+ const listShareLinks = vi.fn(async () => ({
693
+ records: [
694
+ shareRecord({
695
+ viewCount: 7,
696
+ lastViewedAt: '2026-09-21T01:00:00.000Z',
697
+ }),
698
+ ],
699
+ hasMore: false,
700
+ nextCursor: null,
701
+ }));
702
+ const resolveViewCountingEligibility = vi.fn(async () => {
703
+ throw new Error('raw policy secret');
704
+ });
705
+ const caller = makeCaller(
706
+ makeDependencies({ listShareLinks, resolveViewCountingEligibility })
707
+ );
708
+
709
+ const result = await caller.list({ limit: 25 });
710
+
711
+ expect(result.records[0]).not.toHaveProperty('viewCount');
712
+ expect(result.records[0].lastViewedAt).toBeNull();
713
+ });
714
+
715
+ it('maps a repository failure to a fixed safe error without leaking the cause', async () => {
716
+ const listShareLinks = vi.fn(async () => {
717
+ throw new Error('raw secret graph detail');
718
+ });
719
+ const caller = makeCaller(makeDependencies({ listShareLinks }));
720
+
721
+ await expect(caller.list({ limit: 25 })).rejects.toMatchObject({
722
+ code: 'INTERNAL_SERVER_ERROR',
723
+ message: 'share-link list is unavailable',
724
+ });
725
+
726
+ try {
727
+ await caller.list({ limit: 25 });
728
+ } catch (error) {
729
+ expect(JSON.stringify(error)).not.toContain('raw secret graph detail');
730
+ }
731
+ });
732
+ });
733
+ });
734
+
735
+ describe('enforceOwnerShareWriteRateLimit', () => {
736
+ it('uses a 60-per-hour per-owner/namespace key and fails closed on dependency errors', async () => {
737
+ await enforceOwnerShareWriteRateLimit(NAME, OWNER_PROFILE.profileId);
738
+
739
+ expect(rateLimits.enforceRateLimits).toHaveBeenCalledWith([
740
+ expect.objectContaining({
741
+ key: `share-link-owner-write:${NAME}:${OWNER_PROFILE.profileId}`,
742
+ limit: 60,
743
+ windowSeconds: 3600,
744
+ }),
745
+ ]);
746
+
747
+ rateLimits.enforceRateLimits.mockRejectedValueOnce(new Error('cache unavailable'));
748
+ await expect(
749
+ enforceOwnerShareWriteRateLimit(NAME, OWNER_PROFILE.profileId)
750
+ ).rejects.toThrow('cache unavailable');
751
+ });
752
+ });