@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,130 @@
1
+ import { ModelFactory, NeogmaInstance } from 'neogma';
2
+
3
+ import { neogma } from '@instance';
4
+
5
+ /** Lifecycle status of a share. Only `active` is ever served to a recipient. */
6
+ export type ShareLinkStatus = 'pending' | 'active' | 'stopped';
7
+
8
+ /**
9
+ * Whether the visible content pointer for a share is committed.
10
+ *
11
+ * `staging` means a reservation exists but the object is not visible yet;
12
+ * `content_missing` means Brain believes content should be visible but LearnCloud
13
+ * does not have it, so the share fails closed and is never served.
14
+ */
15
+ export type ShareContentState = 'staging' | 'finalized' | 'content_missing';
16
+
17
+ /**
18
+ * Persisted ShareLink metadata (LC-2187).
19
+ *
20
+ * This node is the only Brain-side source of truth for the *visible* content
21
+ * pointer. It stores an opaque immutable `activeObjectRef` plus content/recovery
22
+ * hashes and byte counts — never ciphertext, a raw private source URI or a key.
23
+ * The concrete encrypted envelope and owner recovery JWE live together in the
24
+ * dedicated LearnCloud collection keyed by the immutable object reference.
25
+ *
26
+ * `version` is the committed record revision used for optimistic concurrency; a
27
+ * reservation (`staging`) does not change it. `contentVersion` is the visible
28
+ * content revision and changes only when a replacement is finalized.
29
+ */
30
+ export type ShareLinkRecord = {
31
+ id: string;
32
+ namespace: string;
33
+ ownerProfileId: string;
34
+ version: number;
35
+ contentVersion: number;
36
+ /**
37
+ * Monotonic fence. Bumped on every reservation and on revoke; a worker may
38
+ * only finalize the reservation whose generation still equals this value.
39
+ */
40
+ generation: number;
41
+ status: ShareLinkStatus;
42
+ contentState: ShareContentState;
43
+ activeObjectRef: string | null;
44
+ activeObjectOperationId: string | null;
45
+ activeContentHash: string | null;
46
+ activeContentBytes: number | null;
47
+ activeRecoveryHash: string | null;
48
+ /** Operation binding: the operation id that produced the visible revision. */
49
+ lastOperationId: string | null;
50
+ /** Operation binding: the create request that first bound this share id. */
51
+ createdByClientRequestId: string | null;
52
+ title: string;
53
+ note: string | null;
54
+ selectedCount: number;
55
+ expiresAt: string | null;
56
+ stoppedAt: string | null;
57
+ viewCount: number;
58
+ lastViewedAt: string | null;
59
+ /** Argon2id PHC string. Plaintext passcodes are never persisted. */
60
+ passcodeHash?: string | null;
61
+ notifyOnView?: boolean;
62
+ minorPolicyIsMinor: boolean | null;
63
+ minorPolicyResolved: boolean;
64
+ minorPolicyDefaultExpiryDays: number;
65
+ minorPolicyViewCountingEnabled: boolean;
66
+ createdAt: string;
67
+ updatedAt: string;
68
+ };
69
+
70
+ /** Neo4j omits null properties; the repository maps missing values back to null. */
71
+ type ShareLinkProperties = { lockTick?: number } & {
72
+ [Key in keyof ShareLinkRecord]:
73
+ | Exclude<ShareLinkRecord[Key], null>
74
+ | (null extends ShareLinkRecord[Key] ? undefined : never);
75
+ };
76
+ export type ShareLinkInstance = NeogmaInstance<ShareLinkProperties, Record<string, never>>;
77
+
78
+ /**
79
+ * `:ShareLink` model registration. The repository writes this label with raw
80
+ * parameterized Cypher so it can hold the share write lock across read + write
81
+ * within one transaction; the model exists for schema/typed instance support and
82
+ * constraint registration.
83
+ */
84
+ export const ShareLink = ModelFactory<ShareLinkProperties, Record<string, never>>(
85
+ {
86
+ label: 'ShareLink',
87
+ schema: {
88
+ id: { type: 'string', required: true },
89
+ namespace: { type: 'string', required: true },
90
+ ownerProfileId: { type: 'string', required: true },
91
+ version: { type: 'number', required: true },
92
+ contentVersion: { type: 'number', required: true },
93
+ generation: { type: 'number', required: true },
94
+ status: { type: 'string', required: true, enum: ['pending', 'active', 'stopped'] },
95
+ contentState: {
96
+ type: 'string',
97
+ required: true,
98
+ enum: ['staging', 'finalized', 'content_missing'],
99
+ },
100
+ activeObjectRef: { type: 'string', required: false },
101
+ activeObjectOperationId: { type: 'string', required: false },
102
+ activeContentHash: { type: 'string', required: false },
103
+ activeContentBytes: { type: 'number', required: false },
104
+ activeRecoveryHash: { type: 'string', required: false },
105
+ lastOperationId: { type: 'string', required: false },
106
+ createdByClientRequestId: { type: 'string', required: false },
107
+ title: { type: 'string', required: true },
108
+ note: { type: 'string', required: false },
109
+ selectedCount: { type: 'number', required: true },
110
+ expiresAt: { type: 'string', required: false },
111
+ stoppedAt: { type: 'string', required: false },
112
+ viewCount: { type: 'number', required: true },
113
+ lastViewedAt: { type: 'string', required: false },
114
+ passcodeHash: { type: 'string', required: false },
115
+ // LC-2187 records created before opt-in alerts may omit this field.
116
+ notifyOnView: { type: 'boolean', required: false },
117
+ minorPolicyIsMinor: { type: 'boolean', required: false },
118
+ minorPolicyResolved: { type: 'boolean', required: true },
119
+ minorPolicyDefaultExpiryDays: { type: 'number', required: true },
120
+ minorPolicyViewCountingEnabled: { type: 'boolean', required: true },
121
+ createdAt: { type: 'string', required: true },
122
+ updatedAt: { type: 'string', required: true },
123
+ lockTick: { type: 'number', required: false },
124
+ },
125
+ primaryKeyField: 'id',
126
+ },
127
+ neogma
128
+ );
129
+
130
+ export default ShareLink;
@@ -0,0 +1,69 @@
1
+ import { ModelFactory, NeogmaInstance } from 'neogma';
2
+
3
+ import { neogma } from '@instance';
4
+
5
+ /**
6
+ * Persisted, short-lived view receipt (LC-2187 ADR-10).
7
+ *
8
+ * Only the hash of the opaque CSPRNG token is stored — never the token, a viewer
9
+ * identity, an IP, a fingerprint, a credential, a key or any request body. The
10
+ * node is bound to one immutable committed tuple
11
+ * `(namespace, ownerProfileId, shareId, contentVersion, objectRef, operationId)`
12
+ * so a receipt for a superseded revision can never count toward the current one.
13
+ *
14
+ * `consumedAt` is the single-use marker; `lockTick` exists only so a transaction
15
+ * can take a write lock on the receipt AFTER it holds the share write lock.
16
+ * `expiresAt` bounds retention and is indexed for one-shot pruning.
17
+ */
18
+ export type ShareViewReceiptRecord = {
19
+ receiptHash: string;
20
+ namespace: string;
21
+ ownerProfileId: string;
22
+ shareId: string;
23
+ contentVersion: number;
24
+ objectRef: string;
25
+ operationId: string;
26
+ createdAt: string;
27
+ expiresAt: string;
28
+ consumedAt: string | null;
29
+ };
30
+
31
+ type ShareViewReceiptProperties = { lockTick?: number } & {
32
+ [Key in keyof ShareViewReceiptRecord]:
33
+ | Exclude<ShareViewReceiptRecord[Key], null>
34
+ | (null extends ShareViewReceiptRecord[Key] ? undefined : never);
35
+ };
36
+
37
+ export type ShareViewReceiptInstance = NeogmaInstance<
38
+ ShareViewReceiptProperties,
39
+ Record<string, never>
40
+ >;
41
+
42
+ /**
43
+ * `:ShareViewReceipt` model registration. The repository writes this label with
44
+ * raw parameterized Cypher so consume + increment happen in one transaction with
45
+ * explicit share-then-receipt lock ordering; the model exists for schema and
46
+ * constraint registration.
47
+ */
48
+ export const ShareViewReceipt = ModelFactory<ShareViewReceiptProperties, Record<string, never>>(
49
+ {
50
+ label: 'ShareViewReceipt',
51
+ schema: {
52
+ receiptHash: { type: 'string', required: true },
53
+ namespace: { type: 'string', required: true },
54
+ ownerProfileId: { type: 'string', required: true },
55
+ shareId: { type: 'string', required: true },
56
+ contentVersion: { type: 'number', required: true },
57
+ objectRef: { type: 'string', required: true },
58
+ operationId: { type: 'string', required: true },
59
+ createdAt: { type: 'string', required: true },
60
+ expiresAt: { type: 'string', required: true },
61
+ consumedAt: { type: 'string', required: false },
62
+ lockTick: { type: 'number', required: false },
63
+ },
64
+ primaryKeyField: 'receiptHash',
65
+ },
66
+ neogma
67
+ );
68
+
69
+ export default ShareViewReceipt;
@@ -69,6 +69,7 @@ Presentation.addRelationships({
69
69
  properties: {
70
70
  from: { property: 'from', schema: { type: 'string', required: true } },
71
71
  date: { property: 'date', schema: { type: 'string', required: true } },
72
+ metadata: { property: 'metadata', schema: { type: 'object', required: false } },
72
73
  },
73
74
  },
74
75
  });
@@ -256,4 +257,5 @@ export * from './Integration';
256
257
  export * from './AppStoreListing';
257
258
  export * from './CredentialActivity';
258
259
  export * from './CredentialRefresh';
260
+ export * from './ShareLink';
259
261
  export * from './StatusList';
@@ -0,0 +1,93 @@
1
+ import { neogma } from '@instance';
2
+
3
+ /**
4
+ * Uniqueness rules required for the LC-2187 share lifecycle.
5
+ *
6
+ * A uniqueness constraint alone does not serialize two transactions consuming one
7
+ * reservation; the repository additionally holds a `:ShareLink` write lock before
8
+ * reading status/version/reservation state. These rules are the hard backstop that
9
+ * makes the resulting race fail closed instead of forking state.
10
+ */
11
+ const SHARE_LINK_CONSTRAINT_QUERIES = [
12
+ // A share id is globally unique across namespaces.
13
+ 'CREATE CONSTRAINT share_link_id_unique IF NOT EXISTS FOR (s:ShareLink) REQUIRE (s.id) IS UNIQUE',
14
+ // One idempotency record per (namespace, owner, operation kind, client request).
15
+ 'CREATE CONSTRAINT share_link_operation_key_unique IF NOT EXISTS FOR (o:ShareLinkOperation) REQUIRE (o.namespace, o.ownerProfileId, o.opKind, o.clientRequestId) IS UNIQUE',
16
+ // At most one in-flight reservation per share.
17
+ 'CREATE CONSTRAINT share_link_reservation_share_unique IF NOT EXISTS FOR (r:ShareLinkReservation) REQUIRE (r.shareId) IS UNIQUE',
18
+ // Cleanup is bound to exactly one immutable object reference.
19
+ 'CREATE CONSTRAINT share_content_cleanup_object_unique IF NOT EXISTS FOR (c:ShareContentCleanupJob) REQUIRE (c.objectRef) IS UNIQUE',
20
+ // One persisted receipt node per opaque token hash (the token itself is never stored).
21
+ 'CREATE CONSTRAINT share_view_receipt_hash_unique IF NOT EXISTS FOR (v:ShareViewReceipt) REQUIRE (v.receiptHash) IS UNIQUE',
22
+ ];
23
+
24
+ /**
25
+ * Non-unique indexes that keep the lifecycle queries bounded. Index creation is
26
+ * idempotent and shares the same readiness gate as the constraints.
27
+ */
28
+ const SHARE_LINK_INDEX_QUERIES = [
29
+ 'CREATE INDEX share_link_ns_owner_idx IF NOT EXISTS FOR (s:ShareLink) ON (s.namespace, s.ownerProfileId)',
30
+ // Bounded owner-list keyset pagination: filters on the leading
31
+ // (namespace, ownerProfileId) prefix and orders by the immutable
32
+ // (createdAt, id) key without a full scan/sort.
33
+ 'CREATE INDEX share_link_ns_owner_created_idx IF NOT EXISTS FOR (s:ShareLink) ON (s.namespace, s.ownerProfileId, s.createdAt, s.id)',
34
+ 'CREATE INDEX share_link_status_idx IF NOT EXISTS FOR (s:ShareLink) ON (s.status)',
35
+ 'CREATE INDEX share_link_operation_operation_id_idx IF NOT EXISTS FOR (o:ShareLinkOperation) ON (o.operationId)',
36
+ 'CREATE INDEX share_link_operation_share_id_idx IF NOT EXISTS FOR (o:ShareLinkOperation) ON (o.shareId)',
37
+ 'CREATE INDEX share_link_reservation_lease_idx IF NOT EXISTS FOR (r:ShareLinkReservation) ON (r.leaseExpiresAt)',
38
+ // Bounded recovery discovery enumerates expired reservations for one namespace.
39
+ 'CREATE INDEX share_link_reservation_ns_lease_idx IF NOT EXISTS FOR (r:ShareLinkReservation) ON (r.namespace, r.leaseExpiresAt)',
40
+ 'CREATE INDEX share_content_cleanup_due_idx IF NOT EXISTS FOR (c:ShareContentCleanupJob) ON (c.status, c.nextAttemptAt)',
41
+ // One-shot receipt pruning is bounded by expiry/consumed state.
42
+ 'CREATE INDEX share_view_receipt_expiry_idx IF NOT EXISTS FOR (v:ShareViewReceipt) ON (v.expiresAt)',
43
+ 'CREATE INDEX share_view_receipt_share_idx IF NOT EXISTS FOR (v:ShareViewReceipt) ON (v.namespace, v.shareId)',
44
+ ];
45
+
46
+ type Neo4jSchemaError = {
47
+ code?: string;
48
+ };
49
+
50
+ const isEquivalentSchemaRuleRace = (error: unknown): boolean =>
51
+ (error as Neo4jSchemaError | undefined)?.code ===
52
+ 'Neo.ClientError.Schema.EquivalentSchemaRuleAlreadyExists';
53
+
54
+ const createShareLinkSchema = async (): Promise<void> => {
55
+ for (const query of [...SHARE_LINK_CONSTRAINT_QUERIES, ...SHARE_LINK_INDEX_QUERIES]) {
56
+ try {
57
+ await neogma.queryRunner.run(query);
58
+ } catch (error) {
59
+ // Concurrent service starts can both observe a missing rule before one
60
+ // wins creation. The losing CREATE is equivalent to the desired ready state.
61
+ if (!isEquivalentSchemaRuleRace(error)) throw error;
62
+ }
63
+ }
64
+ };
65
+
66
+ let constraintReadiness: Promise<void> | undefined;
67
+
68
+ /**
69
+ * Ensures the uniqueness rules and indexes required for the share lifecycle.
70
+ *
71
+ * Every public repository method awaits this before its transaction, so a caller
72
+ * can never observe the "constraints not initialized" window. Concurrent callers
73
+ * share one attempt; a failed attempt is cleared so a later request can retry
74
+ * instead of leaving the repository permanently unavailable.
75
+ */
76
+ export const ensureShareLinkConstraints = (): Promise<void> => {
77
+ if (!constraintReadiness) {
78
+ const pendingReadiness = createShareLinkSchema();
79
+ constraintReadiness = pendingReadiness;
80
+
81
+ void pendingReadiness.catch(() => {
82
+ if (constraintReadiness === pendingReadiness) constraintReadiness = undefined;
83
+ });
84
+ }
85
+
86
+ return constraintReadiness;
87
+ };
88
+
89
+ /** Exposed for focused tests that assert the exact schema statements. */
90
+ export const SHARE_LINK_SCHEMA_QUERIES = [
91
+ ...SHARE_LINK_CONSTRAINT_QUERIES,
92
+ ...SHARE_LINK_INDEX_QUERIES,
93
+ ] as const;
@@ -0,0 +1,49 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+
3
+ /**
4
+ * LC-2187 public share-link HTTP adapter seam.
5
+ *
6
+ * The Fastify OpenAPI adapter has no `responseMeta` hook, so the no-store
7
+ * contract for the anonymous public share-link routes is applied at the adapter
8
+ * boundary itself. This module is deliberately free of the server bootstrap
9
+ * (which listens on import) so a real Fastify instance can be exercised in
10
+ * tests without starting the application.
11
+ */
12
+
13
+ /**
14
+ * Prefixes that identify a mounted public share-link OpenAPI request.
15
+ *
16
+ * The trpc-to-openapi Fastify adapter rewrites `request.raw.url` by stripping
17
+ * its `/api` base path before the handler runs, so the `onSend` hook sees
18
+ * `/public/share-links/...` even though the client requested
19
+ * `/api/public/share-links/...`. Both forms are matched (with a path-boundary
20
+ * check) so a sibling route such as `/public/share-links-other` is not treated
21
+ * as public.
22
+ */
23
+ const PUBLIC_SHARE_LINK_PREFIXES = ['/api/public/share-links', '/public/share-links'] as const;
24
+
25
+ /** True for the mounted public share-link OpenAPI route prefix only. */
26
+ export const isPublicShareLinkOpenApiRequestUrl = (url: string | undefined): boolean => {
27
+ if (!url) return false;
28
+
29
+ const path = url.split('?')[0] ?? '';
30
+
31
+ return PUBLIC_SHARE_LINK_PREFIXES.some(
32
+ prefix => path === prefix || path.startsWith(`${prefix}/`)
33
+ );
34
+ };
35
+
36
+ /**
37
+ * Installs the adapter-boundary `Cache-Control: private, no-store` on every
38
+ * public share-link OpenAPI response, independent of any upstream cache layer.
39
+ * It is safe to register on any Fastify instance (tRPC-only test apps included).
40
+ */
41
+ export const registerPublicShareLinkNoStore = (app: FastifyInstance): void => {
42
+ app.addHook('onSend', async (request, reply, payload) => {
43
+ if (isPublicShareLinkOpenApiRequestUrl(request.raw.url)) {
44
+ reply.header('Cache-Control', 'private, no-store');
45
+ }
46
+
47
+ return payload;
48
+ });
49
+ };
@@ -104,6 +104,7 @@ import {
104
104
  isDraftBoost,
105
105
  appendTemplateEvidenceToCredential,
106
106
  } from '@helpers/boost.helpers';
107
+ import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
107
108
  import { createBoostForListing } from '@accesslayer/boost/create';
108
109
  import { setBoostAsParent } from '@accesslayer/boost/relationships/create';
109
110
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
@@ -692,17 +693,7 @@ export const handleSendCredentialEvent = async (
692
693
 
693
694
  const targetDid = getDidWeb(ctx.domain, target.profileId);
694
695
 
695
- if (Array.isArray(unsignedVc.credentialSubject)) {
696
- unsignedVc.credentialSubject = unsignedVc.credentialSubject.map(subject => ({
697
- ...subject,
698
- id: targetDid,
699
- }));
700
- } else {
701
- unsignedVc.credentialSubject = {
702
- ...unsignedVc.credentialSubject,
703
- id: targetDid,
704
- };
705
- }
696
+ setCredentialSubjectIds(unsignedVc, targetDid);
706
697
 
707
698
  if (unsignedVc?.type?.includes('BoostCredential')) {
708
699
  unsignedVc.boostId = boostUri;
@@ -115,6 +115,7 @@ import {
115
115
  prepareCredentialFromBoost,
116
116
  appendTemplateEvidenceToCredential,
117
117
  } from '@helpers/boost.helpers';
118
+ import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
118
119
  import {
119
120
  BoostValidator,
120
121
  BoostGenerateClaimLinkInput,
@@ -4549,17 +4550,7 @@ export const boostsRouter = t.router({
4549
4550
  }
4550
4551
  unsignedVc.issuer = { id: getDidWeb(ctx.domain, profile.profileId) };
4551
4552
 
4552
- if (Array.isArray(unsignedVc.credentialSubject)) {
4553
- unsignedVc.credentialSubject = unsignedVc.credentialSubject.map(subject => ({
4554
- ...subject,
4555
- id: getDidWeb(ctx.domain, targetProfile.profileId),
4556
- }));
4557
- } else {
4558
- unsignedVc.credentialSubject = {
4559
- ...unsignedVc.credentialSubject,
4560
- id: getDidWeb(ctx.domain, targetProfile.profileId),
4561
- };
4562
- }
4553
+ setCredentialSubjectIds(unsignedVc, getDidWeb(ctx.domain, targetProfile.profileId));
4563
4554
  if (unsignedVc?.type?.includes('BoostCredential')) unsignedVc.boostId = boostUri;
4564
4555
  // Inject OBv3 skill alignments based on boost's framework/skills
4565
4556
  await injectObv3AlignmentsIntoCredentialForBoost(unsignedVc, boost, ctx.domain);
@@ -77,6 +77,7 @@ import {
77
77
  } from '@accesslayer/consentflowcontract/relationships/create';
78
78
  import { getProfileByDid, getProfileByProfileId } from '@accesslayer/profile/read';
79
79
  import { sendBoost, isDraftBoost } from '@helpers/boost.helpers';
80
+ import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
80
81
  import { isRelationshipBlocked } from '@helpers/connection.helpers';
81
82
  import { getBoostByUri } from '@accesslayer/boost/read';
82
83
  import { canProfileIssueBoost } from '@accesslayer/boost/relationships/read';
@@ -818,17 +819,7 @@ export const contractsRouter = t.router({
818
819
  unsignedVc.issuanceDate = new Date().toISOString();
819
820
  }
820
821
  unsignedVc.issuer = { id: getDidWeb(ctx.domain, profile.profileId) };
821
- if (Array.isArray(unsignedVc.credentialSubject)) {
822
- unsignedVc.credentialSubject = unsignedVc.credentialSubject.map(subject => ({
823
- ...subject,
824
- id: getDidWeb(ctx.domain, otherProfile.profileId),
825
- }));
826
- } else {
827
- unsignedVc.credentialSubject = {
828
- ...unsignedVc.credentialSubject,
829
- id: getDidWeb(ctx.domain, otherProfile.profileId),
830
- };
831
- }
822
+ setCredentialSubjectIds(unsignedVc, getDidWeb(ctx.domain, otherProfile.profileId));
832
823
  if (unsignedVc?.type?.includes('BoostCredential')) unsignedVc.boostId = boostUri;
833
824
  // Inject OBv3 skill alignments based on boost's framework/skills
834
825
  await injectObv3AlignmentsIntoCredentialForBoost(unsignedVc, boost, ctx.domain);
@@ -11,6 +11,7 @@ import jwtDecode from 'jwt-decode';
11
11
  import * as Sentry from '@sentry/serverless';
12
12
  import { ACT_AS_HEADER, AUTH_GRANT_AUDIENCE_DOMAIN_PREFIX } from '@learncard/types';
13
13
  import { ContactMethodType } from '@learncard/types';
14
+ import { MAX_SHARE_LINK_REQUEST_BYTES, utf8ByteLength } from '@learncard/types';
14
15
 
15
16
  import { RegExpTransformer } from '@learncard/helpers';
16
17
  import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
@@ -217,14 +218,93 @@ export const createContext = async (
217
218
  return { domain, tenant, _guardianApprovalToken, sourceIp, actAs };
218
219
  };
219
220
 
220
- export const openRoute = t.procedure
221
- .use(t.middleware(Sentry.Handlers.trpcMiddleware({ attachRpcInput: true }) as any))
222
- .use(({ ctx, next, path }) => {
223
- Sentry.configureScope(scope => {
224
- scope.setTransactionName(`trpc-${path}`);
225
- });
226
- return next({ ctx });
221
+ const sentryTransactionNameMiddleware = t.middleware(({ ctx, next, path }) => {
222
+ Sentry.configureScope(scope => {
223
+ scope.setTransactionName(`trpc-${path}`);
227
224
  });
225
+ return next({ ctx });
226
+ });
227
+
228
+ // Sentry's tRPC middleware type predates this repo's generic context. Cast
229
+ // through the tRPC builder's expected parameter type instead of `any`.
230
+ type SentryTrpcMiddleware = Parameters<typeof t.middleware>[0];
231
+
232
+ const sentryInputCaptureMiddleware = Sentry.Handlers.trpcMiddleware({
233
+ attachRpcInput: true,
234
+ }) as unknown as SentryTrpcMiddleware;
235
+
236
+ const sentryNoInputCaptureMiddleware = Sentry.Handlers.trpcMiddleware({
237
+ attachRpcInput: false,
238
+ }) as unknown as SentryTrpcMiddleware;
239
+
240
+ /**
241
+ * Complete-request byte bound for owner share-link routes, enforced on the RAW
242
+ * input before Zod parsing/stripping. Individual field validators cannot bound a
243
+ * request whose unknown or duplicate fields are stripped first, so this measures
244
+ * the whole serialized body (UTF-8) and fails closed over the shared 1 MiB cap.
245
+ *
246
+ * It is composed into the base so it runs before authentication and before any
247
+ * input parser, and can never be skipped by a malformed or unauthenticated call.
248
+ */
249
+ const enforceShareLinkRequestByteBound = t.middleware(async ({ ctx, next, getRawInput }) => {
250
+ const raw = await getRawInput();
251
+
252
+ let bytes: number;
253
+ if (raw === undefined || raw === null) {
254
+ bytes = 0;
255
+ } else if (typeof raw === 'string') {
256
+ bytes = utf8ByteLength(raw);
257
+ } else {
258
+ let serialized: string;
259
+ try {
260
+ serialized = JSON.stringify(raw);
261
+ } catch {
262
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'invalid share-link request' });
263
+ }
264
+ bytes = utf8ByteLength(serialized);
265
+ }
266
+
267
+ if (bytes > MAX_SHARE_LINK_REQUEST_BYTES) {
268
+ throw new TRPCError({
269
+ code: 'PAYLOAD_TOO_LARGE',
270
+ message: 'share-link request exceeds the 1 MiB limit',
271
+ });
272
+ }
273
+
274
+ return next({ ctx });
275
+ });
276
+
277
+ export const openRoute = t.procedure
278
+ .use(t.middleware(sentryInputCaptureMiddleware))
279
+ .use(sentryTransactionNameMiddleware);
280
+
281
+ /**
282
+ * LC-2187 public share-link paths must never be cached by an intermediary or a
283
+ * browser. tRPC has no per-procedure header API, so the actual HTTP adapters
284
+ * (Fastify and Lambda, both tRPC and OpenAPI) apply `Cache-Control` from the
285
+ * resolved procedure path through this single helper.
286
+ */
287
+ export const PUBLIC_SHARE_LINK_ROUTE_PREFIX = 'publicShareLinks.';
288
+
289
+ export const publicShareLinkCacheControlHeaders = (
290
+ paths: readonly string[] | undefined
291
+ ): Record<string, string> =>
292
+ (paths ?? []).some(path => path.startsWith(PUBLIC_SHARE_LINK_ROUTE_PREFIX))
293
+ ? { 'Cache-Control': 'private, no-store' }
294
+ : {};
295
+
296
+ /**
297
+ * Route base for procedures whose input carries owner-private material (titles,
298
+ * notes, ciphertext envelopes, recovery JWEs) and whose error paths must not
299
+ * attach raw request bodies to Sentry. It runs the same Sentry error/transaction
300
+ * handling but with input attachment disabled, so no branch — malformed,
301
+ * unauthenticated or failing — can capture the payload, and it enforces the
302
+ * complete-request byte bound before input parsing.
303
+ */
304
+ export const openRouteWithoutInputCapture = t.procedure
305
+ .use(t.middleware(sentryNoInputCaptureMiddleware))
306
+ .use(sentryTransactionNameMiddleware)
307
+ .use(enforceShareLinkRequestByteBound);
228
308
 
229
309
  export const resolveProfileFromContextDid = async (
230
310
  didFromContext: string | undefined,
@@ -316,27 +396,33 @@ const resolveActAs = async (
316
396
  return { ...user, profile: target, did: target.did, onBehalfOf: profile.profileId };
317
397
  };
318
398
 
319
- export const didRoute = openRoute.use(async ({ ctx, next }) => {
320
- if (!ctx.user?.did) {
321
- throw new TRPCError({ code: 'UNAUTHORIZED' });
322
- }
399
+ const withDid = (base: typeof openRoute) =>
400
+ base.use(async ({ ctx, next }) => {
401
+ if (!ctx.user?.did) {
402
+ throw new TRPCError({ code: 'UNAUTHORIZED' });
403
+ }
323
404
 
324
- const profile = await resolveProfileFromContextDid(ctx.user.did, ctx.domain);
405
+ const profile = await resolveProfileFromContextDid(ctx.user.did, ctx.domain);
325
406
 
326
- if (profile) Sentry.setUser({ id: profile.profileId, username: profile.displayName });
407
+ if (profile) Sentry.setUser({ id: profile.profileId, username: profile.displayName });
327
408
 
328
- const user = ctx.user.isChallengeValid
329
- ? await resolveActAs({ ...ctx.user, profile }, ctx.actAs, ctx.domain)
330
- : { ...ctx.user, profile };
409
+ const user = ctx.user.isChallengeValid
410
+ ? await resolveActAs({ ...ctx.user, profile }, ctx.actAs, ctx.domain)
411
+ : { ...ctx.user, profile };
331
412
 
332
- return next({ ctx: { ...ctx, user } });
333
- });
413
+ return next({ ctx: { ...ctx, user } });
414
+ });
334
415
 
335
- export const didAndChallengeRoute = didRoute.use(({ ctx, next }) => {
336
- if (!ctx.user?.isChallengeValid) throw new TRPCError({ code: 'UNAUTHORIZED' });
416
+ export const didRoute = withDid(openRoute);
337
417
 
338
- return next({ ctx: { ...ctx, user: ctx.user } });
339
- });
418
+ const withChallenge = (base: typeof didRoute) =>
419
+ base.use(({ ctx, next }) => {
420
+ if (!ctx.user?.isChallengeValid) throw new TRPCError({ code: 'UNAUTHORIZED' });
421
+
422
+ return next({ ctx: { ...ctx, user: ctx.user } });
423
+ });
424
+
425
+ export const didAndChallengeRoute = withChallenge(didRoute);
340
426
 
341
427
  export const scopedRoute = didAndChallengeRoute.use(({ ctx, next, meta }) => {
342
428
  if (!meta?.requiredScope) {
@@ -370,33 +456,41 @@ export const openProfileRoute = didRoute.use(async ({ ctx, next }) => {
370
456
  return next({ ctx: { ...ctx, user: { ...ctx.user, profile } } });
371
457
  });
372
458
 
373
- export const profileRoute = didAndChallengeRoute.use(async ({ ctx, next, meta }) => {
374
- const { profile } = ctx.user;
459
+ const withProfile = (base: typeof didAndChallengeRoute) =>
460
+ base.use(async ({ ctx, next, meta }) => {
461
+ const { profile } = ctx.user;
375
462
 
376
- if (!profile) {
377
- throw new TRPCError({
378
- code: 'NOT_FOUND',
379
- message: 'Profile not found. Please make a profile!',
380
- });
381
- }
463
+ if (!profile) {
464
+ throw new TRPCError({
465
+ code: 'NOT_FOUND',
466
+ message: 'Profile not found. Please make a profile!',
467
+ });
468
+ }
382
469
 
383
- if (!meta?.requiredScope) {
384
- return next({ ctx: { ...ctx, user: { ...ctx.user, profile } } });
385
- }
470
+ if (!meta?.requiredScope) {
471
+ return next({ ctx: { ...ctx, user: { ...ctx.user, profile } } });
472
+ }
386
473
 
387
- const userScope = ctx.user?.scope || AUTH_GRANT_NO_ACCESS_SCOPE;
474
+ const userScope = ctx.user?.scope || AUTH_GRANT_NO_ACCESS_SCOPE;
388
475
 
389
- const hasRequiredScope = userHasRequiredScopes(userScope, meta.requiredScope);
476
+ const hasRequiredScope = userHasRequiredScopes(userScope, meta.requiredScope);
390
477
 
391
- if (!hasRequiredScope) {
392
- throw new TRPCError({
393
- code: 'UNAUTHORIZED',
394
- message: `This operation requires ${meta.requiredScope} scope`,
395
- });
396
- }
478
+ if (!hasRequiredScope) {
479
+ throw new TRPCError({
480
+ code: 'UNAUTHORIZED',
481
+ message: `This operation requires ${meta.requiredScope} scope`,
482
+ });
483
+ }
397
484
 
398
- return next({ ctx: { ...ctx, user: { ...ctx.user, profile } } });
399
- });
485
+ return next({ ctx: { ...ctx, user: { ...ctx.user, profile } } });
486
+ });
487
+
488
+ export const profileRoute = withProfile(didAndChallengeRoute);
489
+
490
+ /** Same auth chain, composed on a base that never attaches private RPC input to Sentry. */
491
+ export const didRouteWithoutInputCapture = withDid(openRouteWithoutInputCapture);
492
+ export const didAndChallengeRouteWithoutInputCapture = withChallenge(didRouteWithoutInputCapture);
493
+ export const profileRouteWithoutInputCapture = withProfile(didAndChallengeRouteWithoutInputCapture);
400
494
 
401
495
  export const openProfileManagerRoute = openRoute.use(async ({ ctx, next }) => {
402
496
  if (!ctx.user?.did) throw new TRPCError({ code: 'UNAUTHORIZED' });
@@ -460,7 +554,8 @@ const GUARDIAN_APPROVAL_CLOCK_SKEW_SECONDS = 60;
460
554
  export const guardianGatedRoute = profileRoute.use(async ({ ctx, next }) => {
461
555
  const { profile } = ctx.user;
462
556
  const guardianApprovalToken = ctx._guardianApprovalToken;
463
- const isChildAccount = await isProfileManaged(profile.profileId);
557
+ // Service profiles (orgs) are managed for admin purposes, not guardianship.
558
+ const isChildAccount = !profile.isServiceProfile && (await isProfileManaged(profile.profileId));
464
559
  let guardianIdentity: { profileId: string; did: string } | undefined;
465
560
 
466
561
  if (isChildAccount && guardianApprovalToken) {