@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,814 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ import { TRPCError } from '@trpc/server';
4
+
5
+ import {
6
+ AcknowledgeViewInputValidator,
7
+ AcknowledgeViewOutputValidator,
8
+ ResolveShareLinkInputValidator,
9
+ SHARE_VIEW_RECEIPT_TTL_SECONDS,
10
+ ShareEnvelopeValidator,
11
+ ShareLinkPublicContentViewValidator,
12
+ ShareLinkPublicStateValidator,
13
+ } from '@learncard/types';
14
+ import { LCNNotificationTypeEnumValidator } from '@learncard/types';
15
+
16
+ import { openRouteWithoutInputCapture, t } from '@routes';
17
+ import type { ShareLinkPolicyResolver } from '@helpers/share-link-policy/types';
18
+ import { createShareLinkPolicyResolver } from '@helpers/share-link-policy/resolver';
19
+ import type { ShareContentContentProjection } from '@helpers/share-content-client/types';
20
+ import { createRetryableLazyInitializer } from '@helpers/share-link-owner/lazy-initializer';
21
+ import {
22
+ createBoundedShareLinkDiagnosticReporter,
23
+ createShareLinkDependencyResolver,
24
+ SHARE_LINK_COMPOSITION_DIAGNOSTIC,
25
+ } from '@helpers/share-link-owner/diagnostics';
26
+ import { resolveShareLinkPublicApiConfig } from '@helpers/share-link-public/config';
27
+ import type { ShareLinkPublicApiConfigResolution } from '@helpers/share-link-public/config';
28
+ import type {
29
+ ConsumeShareViewReceiptInput,
30
+ ConsumeShareViewReceiptOutcome,
31
+ PersistShareViewReceiptInput,
32
+ ShareViewEligibilitySource,
33
+ } from '@accesslayer/share-link';
34
+ import type { ShareLinkRecord } from '../models/ShareLink';
35
+
36
+ /**
37
+ * LC-2187 public (anonymous) share-link API: resolve, guarded content and
38
+ * privacy-uniform view acknowledgement.
39
+ *
40
+ * Disabled by default and fail-closed. There is no caller authority of any kind:
41
+ * namespace comes only from trusted server configuration, the owner only from
42
+ * the committed share, and there is never a caller-supplied object ref, URL,
43
+ * key or policy field. Resolve returns metadata only; resolve/previews never
44
+ * create a receipt or a count. The content route proxies the reviewed client
45
+ * transport and only releases bytes from one committed tuple after a final
46
+ * current-state recheck.
47
+ */
48
+
49
+ export type PublicShareLinkSharer = { displayName: string; avatar?: string };
50
+
51
+ export type PublicShareLinkRouterDependencies = {
52
+ readonly namespace: string;
53
+ readonly repository: {
54
+ getShareLink: (input: {
55
+ shareId: string;
56
+ namespace?: string;
57
+ }) => Promise<ShareLinkRecord | null>;
58
+ /**
59
+ * Namespace/owner-scoped read of the reviewed committted content tuple.
60
+ * Production delegates to `coordinator.fetchShareContent`, which applies
61
+ * the final committed-tuple recheck after the awaited remote read.
62
+ */
63
+ fetchContent: (input: {
64
+ shareId: string;
65
+ namespace: string;
66
+ ownerProfileId: string;
67
+ }) => Promise<
68
+ { ok: true; value: ShareContentContentProjection } | { ok: false; error: string }
69
+ >;
70
+ };
71
+ readonly receipts: {
72
+ persist: (input: PersistShareViewReceiptInput) => Promise<boolean>;
73
+ /** Owner for a receipt that belongs to `namespace`; null otherwise. */
74
+ lookupOwner: (receipt: string, namespace: string) => Promise<string | null>;
75
+ lookupContext?: (
76
+ receipt: string,
77
+ namespace: string
78
+ ) => Promise<{ ownerProfileId: string; shareId: string } | null>;
79
+ consume: (input: ConsumeShareViewReceiptInput) => Promise<ConsumeShareViewReceiptOutcome>;
80
+ };
81
+ readonly policyResolver: ShareLinkPolicyResolver;
82
+ /**
83
+ * Optional transaction-compatible trusted eligibility source. Production
84
+ * rechecks persisted profile state under the share lock. An absent source
85
+ * remains fail-closed for tests and other compositions.
86
+ */
87
+ readonly eligibilitySource?: ShareViewEligibilitySource;
88
+ readonly getSharer: (ownerProfileId: string) => Promise<PublicShareLinkSharer | null>;
89
+ readonly verifyPasscode?: (passcodeHash: string, passcode: string) => Promise<boolean>;
90
+ readonly passcodeAttempts?: {
91
+ reserve: (shareId: string, sourceIp?: string) => Promise<boolean>;
92
+ };
93
+ readonly notifyView?: (input: {
94
+ shareId: string;
95
+ ownerProfileId: string;
96
+ title: string;
97
+ selectedCount: number;
98
+ viewCount: number;
99
+ viewedAt: string;
100
+ }) => Promise<void>;
101
+ readonly enforceRateLimit: (window: {
102
+ key: string;
103
+ limit: number;
104
+ windowSeconds: number;
105
+ description: string;
106
+ }) => Promise<void>;
107
+ readonly contentUrlFor: (shareId: string) => string;
108
+ readonly newReceipt: () => string;
109
+ readonly now: () => Date;
110
+ /** Aggregate-only dependency-failure telemetry; never includes input. */
111
+ readonly reportFailure: (category: string) => void;
112
+ };
113
+
114
+ type PublicShareClassification =
115
+ | { state: 'active'; record: ShareLinkRecord }
116
+ | { state: 'expired'; expiresAt: string }
117
+ | { state: 'stopped'; stoppedAt: string }
118
+ | { state: 'not_found' };
119
+
120
+ /**
121
+ * Strict public classification. Stopped wins over expired; `staging`,
122
+ * `content_missing`, `pending` and any missing object binding fail closed to
123
+ * `not_found` so a half-committed revision is never advertised.
124
+ */
125
+ export const classifyPublicShare = (
126
+ record: ShareLinkRecord | null,
127
+ now: Date
128
+ ): PublicShareClassification => {
129
+ if (!record) return { state: 'not_found' };
130
+
131
+ if (record.status === 'stopped') {
132
+ return { state: 'stopped', stoppedAt: record.stoppedAt ?? record.updatedAt };
133
+ }
134
+
135
+ if (record.expiresAt && Date.parse(record.expiresAt) <= now.getTime()) {
136
+ return { state: 'expired', expiresAt: record.expiresAt };
137
+ }
138
+
139
+ if (
140
+ record.status !== 'active' ||
141
+ record.contentState !== 'finalized' ||
142
+ !record.activeObjectRef ||
143
+ !record.activeObjectOperationId
144
+ ) {
145
+ return { state: 'not_found' };
146
+ }
147
+
148
+ return { state: 'active', record };
149
+ };
150
+
151
+ function notFound(): never {
152
+ throw new TRPCError({ code: 'NOT_FOUND' });
153
+ }
154
+
155
+ const openapi = (method: 'GET' | 'POST', path: `/${string}`, summary: string) => ({
156
+ protect: false as const,
157
+ method,
158
+ path,
159
+ tags: ['ShareLinks'],
160
+ summary,
161
+ });
162
+
163
+ const PUBLIC_RESOLVE_PATH = '/public/share-links/{id}' as const;
164
+ const PUBLIC_CONTENT_PATH = '/public/share-links/{id}/content' as const;
165
+ const PUBLIC_ACK_PATH = '/public/share-links/acknowledge-view' as const;
166
+
167
+ const passcodeAccepted = async (
168
+ record: ShareLinkRecord,
169
+ passcode: string | undefined,
170
+ dependencies: PublicShareLinkRouterDependencies,
171
+ sourceIp?: string
172
+ ): Promise<'accepted' | 'rejected' | 'try_later'> => {
173
+ if (record.passcodeHash == null) return 'accepted';
174
+ if (!passcode) return 'rejected';
175
+ if (!dependencies.verifyPasscode || !dependencies.passcodeAttempts) return 'try_later';
176
+ try {
177
+ if (!(await dependencies.passcodeAttempts.reserve(record.id, sourceIp))) return 'try_later';
178
+ const accepted = await dependencies.verifyPasscode(record.passcodeHash, passcode);
179
+ return accepted ? 'accepted' : 'rejected';
180
+ } catch (error) {
181
+ dependencies.reportFailure(
182
+ error instanceof Error && error.name === 'SharePasscodeCapacityError'
183
+ ? 'passcode_capacity'
184
+ : 'passcode_guard'
185
+ );
186
+ return 'try_later';
187
+ }
188
+ };
189
+
190
+ /**
191
+ * Trusted relative content route mounted under the OpenAPI adapter's `/api`
192
+ * base path. Never a LearnCloud object URL or a Host; a returned URL can be
193
+ * fetched directly from the supported local adapters.
194
+ */
195
+ export const publicShareContentUrl = (shareId: string): string =>
196
+ `/api/public/share-links/${encodeURIComponent(shareId)}/content`;
197
+
198
+ const rateLimitKey = (kind: string, namespace: string, sourceIp?: string): string =>
199
+ `share-link-public-${kind}:${namespace}:${sourceIp ?? 'unknown'}`;
200
+
201
+ export const createPublicShareLinksRouter = (
202
+ getDependencies: () => Promise<PublicShareLinkRouterDependencies | null>
203
+ ) => {
204
+ const resolve = async (): Promise<PublicShareLinkRouterDependencies> => {
205
+ let dependencies: PublicShareLinkRouterDependencies | null;
206
+
207
+ try {
208
+ dependencies = await getDependencies();
209
+ } catch {
210
+ throw new TRPCError({
211
+ code: 'INTERNAL_SERVER_ERROR',
212
+ message: 'share-link service is unavailable',
213
+ });
214
+ }
215
+
216
+ if (!dependencies) notFound();
217
+
218
+ return dependencies;
219
+ };
220
+
221
+ return t.router({
222
+ resolve: openRouteWithoutInputCapture
223
+ .meta({
224
+ openapi: openapi('POST', PUBLIC_RESOLVE_PATH, 'Resolve public share metadata'),
225
+ })
226
+ .input(ResolveShareLinkInputValidator)
227
+ .output(ShareLinkPublicStateValidator)
228
+ .mutation(async ({ ctx, input }) => {
229
+ let dependencies: PublicShareLinkRouterDependencies | null;
230
+ try {
231
+ dependencies = await getDependencies();
232
+ } catch {
233
+ return { state: 'not_found' as const, id: input.id };
234
+ }
235
+
236
+ // Disabled/invalid config: no repository, signer or remote call.
237
+ if (!dependencies) return { state: 'not_found' as const, id: input.id };
238
+
239
+ try {
240
+ await dependencies.enforceRateLimit({
241
+ key: rateLimitKey('resolve', dependencies.namespace, ctx.sourceIp),
242
+ limit: 120,
243
+ windowSeconds: 60,
244
+ description: 'public share-link resolve',
245
+ });
246
+ } catch {
247
+ dependencies.reportFailure('resolve_rate_limit');
248
+ throw new TRPCError({
249
+ code: 'TOO_MANY_REQUESTS',
250
+ message: 'rate limit exceeded',
251
+ });
252
+ }
253
+
254
+ let record: ShareLinkRecord | null;
255
+ try {
256
+ record = await dependencies.repository.getShareLink({
257
+ shareId: input.id,
258
+ namespace: dependencies.namespace,
259
+ });
260
+ } catch {
261
+ // Raw repository failures must not escape with a message.
262
+ dependencies.reportFailure('resolve_repository');
263
+ return { state: 'not_found' as const, id: input.id };
264
+ }
265
+ const classification = classifyPublicShare(record, dependencies.now());
266
+
267
+ if (classification.state === 'not_found') {
268
+ return { state: 'not_found' as const, id: input.id };
269
+ }
270
+ // Even lifecycle timestamps are private until a protected link
271
+ // has been unlocked. Missing records never enter Argon2.
272
+ if (record) {
273
+ const access = await passcodeAccepted(
274
+ record,
275
+ input.passcode,
276
+ dependencies,
277
+ ctx.sourceIp
278
+ );
279
+ if (access === 'rejected')
280
+ return { state: 'passcode_required' as const, id: input.id };
281
+ if (access === 'try_later')
282
+ return { state: 'try_later' as const, id: input.id };
283
+ }
284
+
285
+ if (classification.state === 'expired') {
286
+ return {
287
+ state: 'expired' as const,
288
+ id: input.id,
289
+ expiresAt: classification.expiresAt,
290
+ };
291
+ }
292
+ if (classification.state === 'stopped') {
293
+ return {
294
+ state: 'stopped' as const,
295
+ id: input.id,
296
+ stoppedAt: classification.stoppedAt,
297
+ };
298
+ }
299
+ let sharer: PublicShareLinkSharer | null;
300
+ try {
301
+ sharer = await dependencies.getSharer(classification.record.ownerProfileId);
302
+ } catch {
303
+ dependencies.reportFailure('resolve_sharer');
304
+ return { state: 'not_found' as const, id: input.id };
305
+ }
306
+ if (!sharer) return { state: 'not_found' as const, id: input.id };
307
+
308
+ // Final current-state guard AFTER the awaited sharer lookup: if the
309
+ // metadata revision changed while the sharer was being read, the
310
+ // projection is stale and must not be returned.
311
+ let latest: ShareLinkRecord | null;
312
+ try {
313
+ latest = await dependencies.repository.getShareLink({
314
+ shareId: input.id,
315
+ namespace: dependencies.namespace,
316
+ });
317
+ } catch {
318
+ dependencies.reportFailure('resolve_repository');
319
+ return { state: 'not_found' as const, id: input.id };
320
+ }
321
+ const latestClassification = classifyPublicShare(latest, dependencies.now());
322
+ if (
323
+ latestClassification.state !== 'active' ||
324
+ latestClassification.record.version !== classification.record.version
325
+ ) {
326
+ return { state: 'not_found' as const, id: input.id };
327
+ }
328
+
329
+ return {
330
+ state: 'active' as const,
331
+ id: classification.record.id,
332
+ title: classification.record.title,
333
+ ...(classification.record.note === null
334
+ ? {}
335
+ : { note: classification.record.note }),
336
+ selectedCount: classification.record.selectedCount,
337
+ contentVersion: classification.record.contentVersion,
338
+ contentUrl: dependencies.contentUrlFor(classification.record.id),
339
+ sharer: {
340
+ displayName: sharer.displayName.slice(0, 120),
341
+ ...(sharer.avatar ? { avatar: sharer.avatar.slice(0, 2048) } : {}),
342
+ },
343
+ createdAt: classification.record.createdAt,
344
+ updatedAt: classification.record.updatedAt,
345
+ expiresAt: classification.record.expiresAt,
346
+ };
347
+ }),
348
+
349
+ content: openRouteWithoutInputCapture
350
+ .meta({
351
+ openapi: openapi('POST', PUBLIC_CONTENT_PATH, 'Fetch guarded share content'),
352
+ })
353
+ .input(ResolveShareLinkInputValidator)
354
+ .output(ShareLinkPublicContentViewValidator)
355
+ .mutation(async ({ ctx, input }) => {
356
+ const dependencies = await resolve();
357
+
358
+ // The content response is large; a rate-limit/dependency failure
359
+ // fails closed with a fixed safe error (no raw message escapes).
360
+ try {
361
+ await dependencies.enforceRateLimit({
362
+ key: rateLimitKey('content', dependencies.namespace, ctx.sourceIp),
363
+ limit: 60,
364
+ windowSeconds: 60,
365
+ description: 'public share-link content',
366
+ });
367
+ } catch {
368
+ dependencies.reportFailure('content_rate_limit');
369
+ throw new TRPCError({
370
+ code: 'SERVICE_UNAVAILABLE',
371
+ message: 'share-link content is unavailable',
372
+ });
373
+ }
374
+
375
+ const readActiveShare = async (): Promise<ShareLinkRecord> => {
376
+ let candidate: ShareLinkRecord | null;
377
+ try {
378
+ candidate = await dependencies.repository.getShareLink({
379
+ shareId: input.id,
380
+ namespace: dependencies.namespace,
381
+ });
382
+ } catch {
383
+ dependencies.reportFailure('content_repository');
384
+ throw new TRPCError({
385
+ code: 'SERVICE_UNAVAILABLE',
386
+ message: 'share-link content is unavailable',
387
+ });
388
+ }
389
+
390
+ const classification = classifyPublicShare(candidate, dependencies.now());
391
+ if (classification.state !== 'active') notFound();
392
+
393
+ return classification.record;
394
+ };
395
+
396
+ const tupleMatches = (
397
+ projection: {
398
+ objectId: string;
399
+ operationId: string;
400
+ contentVersion: number;
401
+ payloadHash: string;
402
+ },
403
+ candidate: ShareLinkRecord
404
+ ): boolean =>
405
+ projection.objectId === candidate.activeObjectRef &&
406
+ projection.operationId === candidate.activeObjectOperationId &&
407
+ projection.contentVersion === candidate.contentVersion &&
408
+ // Brain's committed `activeContentHash` is the payload hash
409
+ // (envelope + recovery, before IDs): exactly what the
410
+ // reviewed client verifies on stat. Never compare the
411
+ // tuple-inclusive LearnCloud `contentHash` here.
412
+ (candidate.activeContentHash === null ||
413
+ projection.payloadHash === candidate.activeContentHash);
414
+
415
+ const first = await readActiveShare();
416
+
417
+ const access = await passcodeAccepted(
418
+ first,
419
+ input.passcode,
420
+ dependencies,
421
+ ctx.sourceIp
422
+ );
423
+ if (access === 'try_later') {
424
+ throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'try again later' });
425
+ }
426
+ if (access === 'rejected') {
427
+ throw new TRPCError({
428
+ code: 'UNAUTHORIZED',
429
+ message: 'share-link passcode required',
430
+ });
431
+ }
432
+
433
+ let fetched: Awaited<
434
+ ReturnType<PublicShareLinkRouterDependencies['repository']['fetchContent']>
435
+ >;
436
+ try {
437
+ fetched = await dependencies.repository.fetchContent({
438
+ shareId: input.id,
439
+ namespace: dependencies.namespace,
440
+ ownerProfileId: first.ownerProfileId,
441
+ });
442
+ } catch {
443
+ dependencies.reportFailure('content_fetch');
444
+ throw new TRPCError({
445
+ code: 'SERVICE_UNAVAILABLE',
446
+ message: 'share-link content is unavailable',
447
+ });
448
+ }
449
+
450
+ if (!fetched.ok) {
451
+ if (
452
+ fetched.error === 'UNAVAILABLE' ||
453
+ fetched.error === 'TIMEOUT' ||
454
+ fetched.error === 'NETWORK_ERROR' ||
455
+ fetched.error === 'MALFORMED_RESPONSE' ||
456
+ fetched.error === 'RESPONSE_TOO_LARGE' ||
457
+ fetched.error === 'UNEXPECTED_STATUS'
458
+ ) {
459
+ throw new TRPCError({
460
+ code: 'SERVICE_UNAVAILABLE',
461
+ message: 'share-link content is unavailable',
462
+ });
463
+ }
464
+ notFound();
465
+ }
466
+
467
+ // Fresh authoritative policy (never an earlier owner snapshot and
468
+ // never a caller field). A policy resolver failure is a fixed safe
469
+ // error, not a raw leak.
470
+ let policy: Awaited<ReturnType<ShareLinkPolicyResolver['resolve']>>;
471
+ try {
472
+ policy = await dependencies.policyResolver.resolve(first.ownerProfileId);
473
+ } catch {
474
+ dependencies.reportFailure('content_policy');
475
+ throw new TRPCError({
476
+ code: 'SERVICE_UNAVAILABLE',
477
+ message: 'share-link content is unavailable',
478
+ });
479
+ }
480
+
481
+ // Re-read AFTER the awaited policy resolve: a revoke/replacement/
482
+ // expiry during policy resolution must withhold the bytes.
483
+ const persistRecord = await readActiveShare();
484
+ if (
485
+ persistRecord.version !== first.version ||
486
+ !tupleMatches(fetched.value, persistRecord)
487
+ ) {
488
+ notFound();
489
+ }
490
+
491
+ const committedEligible =
492
+ persistRecord.minorPolicyViewCountingEnabled === true &&
493
+ persistRecord.minorPolicyResolved === true &&
494
+ persistRecord.minorPolicyIsMinor === false;
495
+ const eligible =
496
+ policy.viewCountingEnabled &&
497
+ policy.policyResolved &&
498
+ policy.isMinor === false &&
499
+ committedEligible;
500
+
501
+ const receipt = dependencies.newReceipt();
502
+
503
+ if (eligible) {
504
+ try {
505
+ await dependencies.receipts.persist({
506
+ receipt,
507
+ namespace: dependencies.namespace,
508
+ ownerProfileId: persistRecord.ownerProfileId,
509
+ shareId: persistRecord.id,
510
+ shareVersion: persistRecord.version,
511
+ contentVersion: persistRecord.contentVersion,
512
+ objectRef: fetched.value.objectId,
513
+ operationId: fetched.value.operationId,
514
+ ttlSeconds: SHARE_VIEW_RECEIPT_TTL_SECONDS,
515
+ eligibilitySource: dependencies.eligibilitySource,
516
+ now: dependencies.now,
517
+ });
518
+ } catch {
519
+ // Fail closed on counting; the response shape is unchanged.
520
+ dependencies.reportFailure('receipt_persist');
521
+ }
522
+ }
523
+
524
+ // FINAL current-state check after ALL awaited work (policy resolve,
525
+ // receipt persistence). No bytes are released unless the complete
526
+ // committed tuple/status/expiry/version is still current, so a
527
+ // revoke, replacement or expiry during persistence cannot return
528
+ // content authorized against an earlier state.
529
+ const finalRecord = await readActiveShare();
530
+ if (
531
+ finalRecord.version !== persistRecord.version ||
532
+ !tupleMatches(fetched.value, finalRecord)
533
+ ) {
534
+ notFound();
535
+ }
536
+
537
+ const envelope = ShareEnvelopeValidator.safeParse(fetched.value.envelope);
538
+ if (!envelope.success) notFound();
539
+
540
+ return {
541
+ id: finalRecord.id,
542
+ contentVersion: finalRecord.contentVersion,
543
+ envelope: envelope.data,
544
+ contentUrl: dependencies.contentUrlFor(finalRecord.id),
545
+ receipt,
546
+ };
547
+ }),
548
+
549
+ acknowledgeView: openRouteWithoutInputCapture
550
+ .meta({
551
+ openapi: openapi('POST', PUBLIC_ACK_PATH, 'Acknowledge a share view receipt'),
552
+ })
553
+ .input(AcknowledgeViewInputValidator)
554
+ .output(AcknowledgeViewOutputValidator)
555
+ .mutation(async ({ ctx, input }) => {
556
+ let dependencies: PublicShareLinkRouterDependencies | null;
557
+ try {
558
+ dependencies = await getDependencies();
559
+ } catch {
560
+ return { ok: true as const };
561
+ }
562
+
563
+ if (!dependencies) return { ok: true as const };
564
+
565
+ // Rate limiting and dependency failures must never change the
566
+ // uniform acknowledgement shape. Fail closed on counting only.
567
+ try {
568
+ await dependencies.enforceRateLimit({
569
+ key: rateLimitKey('ack', dependencies.namespace, ctx.sourceIp),
570
+ limit: 120,
571
+ windowSeconds: 60,
572
+ description: 'public share-link acknowledgement',
573
+ });
574
+ } catch {
575
+ dependencies.reportFailure('ack_rate_limit');
576
+ return { ok: true as const };
577
+ }
578
+
579
+ try {
580
+ // Owner is discovered from persisted receipt state, never from
581
+ // the caller. Unknown/padding tokens and tokens minted in
582
+ // another namespace simply return early.
583
+ const context = dependencies.receipts.lookupContext
584
+ ? await dependencies.receipts.lookupContext(
585
+ input.receipt,
586
+ dependencies.namespace
587
+ )
588
+ : null;
589
+ const ownerProfileId =
590
+ context?.ownerProfileId ??
591
+ (await dependencies.receipts.lookupOwner(
592
+ input.receipt,
593
+ dependencies.namespace
594
+ ));
595
+ if (ownerProfileId === null) return { ok: true as const };
596
+
597
+ const policy = await dependencies.policyResolver.resolve(ownerProfileId);
598
+
599
+ // The pre-lock resolve is a cheap fail-closed short-circuit,
600
+ // never authority: consume re-checks the committed snapshot
601
+ // and the transaction-compatible source under both locks.
602
+ if (!policy.viewCountingEnabled) return { ok: true as const };
603
+
604
+ const outcome = await dependencies.receipts.consume({
605
+ receipt: input.receipt,
606
+ namespace: dependencies.namespace,
607
+ eligibilitySource: dependencies.eligibilitySource,
608
+ });
609
+
610
+ if (outcome === 'consumed' && context && dependencies.notifyView) {
611
+ const share = await dependencies.repository.getShareLink({
612
+ shareId: context.shareId,
613
+ namespace: dependencies.namespace,
614
+ });
615
+ if (
616
+ share &&
617
+ share.ownerProfileId === context.ownerProfileId &&
618
+ share.notifyOnView === true &&
619
+ share.minorPolicyResolved === true &&
620
+ share.minorPolicyIsMinor === false &&
621
+ share.minorPolicyViewCountingEnabled === true &&
622
+ share.lastViewedAt
623
+ ) {
624
+ await dependencies.notifyView({
625
+ shareId: share.id,
626
+ ownerProfileId: share.ownerProfileId,
627
+ title: share.title,
628
+ selectedCount: share.selectedCount,
629
+ viewCount: share.viewCount,
630
+ viewedAt: share.lastViewedAt,
631
+ });
632
+ }
633
+ }
634
+ } catch {
635
+ dependencies.reportFailure('ack_consume');
636
+ }
637
+
638
+ return { ok: true as const };
639
+ }),
640
+ });
641
+ };
642
+
643
+ export type PublicShareLinksRouter = ReturnType<typeof createPublicShareLinksRouter>;
644
+
645
+ /**
646
+ * Lazy production composition. Config is resolved before any signer/graph/remote
647
+ * import; a disabled configuration is inert and silent, while malformed wiring
648
+ * emits a single sanitized diagnostic and still fails closed. A failed build is
649
+ * not cached, so a later request retries.
650
+ */
651
+ const initializeProductionDependencies =
652
+ createRetryableLazyInitializer<PublicShareLinkRouterDependencies>(() =>
653
+ buildProductionDependencies(
654
+ resolveShareLinkPublicApiConfig(process.env as Record<string, unknown>) as Extract<
655
+ ShareLinkPublicApiConfigResolution,
656
+ { status: 'enabled' }
657
+ >
658
+ )
659
+ );
660
+
661
+ export const getProductionDependencies =
662
+ createShareLinkDependencyResolver<PublicShareLinkRouterDependencies>({
663
+ resolveConfig: () =>
664
+ resolveShareLinkPublicApiConfig(process.env as Record<string, unknown>),
665
+ initializeDependencies: initializeProductionDependencies,
666
+ reportDiagnostic: createBoundedShareLinkDiagnosticReporter(),
667
+ configurationInvalidCategory:
668
+ SHARE_LINK_COMPOSITION_DIAGNOSTIC.PUBLIC_CONFIGURATION_INVALID,
669
+ initializationFailedCategory:
670
+ SHARE_LINK_COMPOSITION_DIAGNOSTIC.PUBLIC_INITIALIZATION_FAILED,
671
+ });
672
+
673
+ const buildProductionDependencies = async (
674
+ config: Extract<ShareLinkPublicApiConfigResolution, { status: 'enabled' }>
675
+ ): Promise<PublicShareLinkRouterDependencies> => {
676
+ const [
677
+ { getServerDidWebDID },
678
+ { createDidWebLearnCardTokenSigner },
679
+ clientModule,
680
+ runtimeModule,
681
+ { ensureShareLinkConstraints },
682
+ { createProductionShareLinkPolicySource, productionShareViewEligibilitySource },
683
+ { getProfileByProfileId },
684
+ { verifySharePasscode },
685
+ passcodeAbuse,
686
+ { addNotificationToQueue },
687
+ receiptModule,
688
+ { getShareLink },
689
+ { claimShareViewNotification },
690
+ { getNotificationMessage },
691
+ { resolveRecipientLocale },
692
+ ] = await Promise.all([
693
+ import('@helpers/learnCard.helpers'),
694
+ import('@helpers/share-content-client/adapters'),
695
+ import('@helpers/share-content-client'),
696
+ import('@helpers/share-link-coordinator/runtime'),
697
+ import('../models/share-link-constraints'),
698
+ import('@helpers/share-link-policy/production'),
699
+ import('@accesslayer/profile/read'),
700
+ import('@helpers/share-link-passcode'),
701
+ import('@helpers/share-link-passcode-abuse'),
702
+ import('@helpers/notifications.helpers'),
703
+ import('@accesslayer/share-link/receipt'),
704
+ import('@accesslayer/share-link/read'),
705
+ import('@helpers/share-link-view-notification'),
706
+ import('@helpers/notificationMessages'),
707
+ import('@helpers/getRecipientLocale.helpers'),
708
+ ]);
709
+
710
+ const clientConfig = clientModule.resolveShareContentClientConfig({
711
+ enabled: true,
712
+ origin: config.origin,
713
+ namespace: config.namespace,
714
+ audience: config.audience,
715
+ signerDid: getServerDidWebDID(),
716
+ allowInsecureLoopback: config.allowInsecureLoopback,
717
+ signer: createDidWebLearnCardTokenSigner(),
718
+ });
719
+
720
+ if (!clientConfig.enabled) {
721
+ // Fail the build so the retryable lazy initializer does not cache it.
722
+ throw new Error('share-content client configuration is invalid');
723
+ }
724
+
725
+ const client = clientModule.createShareContentClient(clientConfig);
726
+ const policyResolver = createShareLinkPolicyResolver(createProductionShareLinkPolicySource());
727
+ const coordinator = runtimeModule.createRuntimeShareLinkCoordinator({
728
+ client,
729
+ leaseOwner: 'brain-share-link-public-api',
730
+ policyResolver,
731
+ });
732
+
733
+ await ensureShareLinkConstraints();
734
+
735
+ return {
736
+ namespace: config.namespace,
737
+ repository: {
738
+ getShareLink: input => getShareLink(input),
739
+ fetchContent: async input => {
740
+ const result = await coordinator.fetchShareContent(input.shareId, {
741
+ namespace: input.namespace,
742
+ ownerProfileId: input.ownerProfileId,
743
+ });
744
+
745
+ return result.ok ? { ok: true, value: result.value } : result;
746
+ },
747
+ },
748
+ receipts: {
749
+ persist: receiptModule.persistShareViewReceipt,
750
+ lookupOwner: async (receipt, namespace) => {
751
+ const owner = await receiptModule.readShareViewReceiptOwner(receipt, namespace);
752
+ return owner;
753
+ },
754
+ lookupContext: receiptModule.readShareViewReceiptContext,
755
+ consume: receiptModule.consumeShareViewReceipt,
756
+ },
757
+ policyResolver,
758
+ eligibilitySource: productionShareViewEligibilitySource,
759
+ verifyPasscode: verifySharePasscode,
760
+ passcodeAttempts: {
761
+ reserve: (shareId, sourceIp) =>
762
+ passcodeAbuse.reserveSharePasscodeAttempt(config.namespace, shareId, sourceIp),
763
+ },
764
+ notifyView: async notification => {
765
+ // Best effort: claim before enqueue to avoid duplicate alerts under
766
+ // concurrent views. A queue outage may suppress one window.
767
+ if (!(await claimShareViewNotification(config.namespace, notification.shareId))) return;
768
+ const profile = await getProfileByProfileId(notification.ownerProfileId);
769
+ if (!profile) return;
770
+ await addNotificationToQueue({
771
+ type: LCNNotificationTypeEnumValidator.enum.APP_NOTIFICATION,
772
+ to: profile,
773
+ from: {
774
+ did: getServerDidWebDID(),
775
+ displayName: 'LearnCard',
776
+ },
777
+ message: getNotificationMessage('shareViewed', resolveRecipientLocale(profile), {
778
+ title: notification.title,
779
+ count: String(notification.selectedCount),
780
+ }),
781
+ data: {
782
+ metadata: {
783
+ shareView: {
784
+ count: notification.viewCount,
785
+ viewedAt: notification.viewedAt,
786
+ },
787
+ },
788
+ },
789
+ });
790
+ },
791
+ getSharer: async ownerProfileId => {
792
+ const profile = await getProfileByProfileId(ownerProfileId);
793
+ if (!profile) return null;
794
+
795
+ return {
796
+ displayName: profile.displayName,
797
+ ...(profile.image ? { avatar: profile.image } : {}),
798
+ };
799
+ },
800
+ enforceRateLimit: async window => {
801
+ const { enforceRateLimits } = await import('@helpers/rateLimit.helpers');
802
+ await enforceRateLimits([window]);
803
+ },
804
+ contentUrlFor: publicShareContentUrl,
805
+ newReceipt: () => randomBytes(32).toString('base64url'),
806
+ now: () => new Date(),
807
+ reportFailure: category => {
808
+ // Aggregate-only: a fixed category string, no receipt/share/user data.
809
+ console.warn('share_link_public_dependency_failure', { category });
810
+ },
811
+ };
812
+ };
813
+
814
+ export const publicShareLinksRouter = createPublicShareLinksRouter(getProductionDependencies);