@learncard/network-brain-service 3.19.1 → 4.0.0

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 (312) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +109 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/config/config.dev.json +29 -0
  7. package/config/config.example.json +29 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +29 -0
  10. package/config/config.scouts.dev.json +24 -0
  11. package/config/config.scouts.production.json +24 -0
  12. package/contractEventsLambda.ts +18 -0
  13. package/contractEventsLambdaApp.ts +19 -0
  14. package/didWebLambda.ts +13 -18
  15. package/didWebLambdaApp.ts +22 -0
  16. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  18. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  20. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  22. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  23. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  24. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  26. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  28. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  29. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  30. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  32. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  34. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  35. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  36. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  41. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  47. package/dist/accesslayer/credential/create.d.ts +2 -1
  48. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  49. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  50. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  51. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  53. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  55. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  57. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  58. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  59. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  60. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  61. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/types.d.ts +8 -3
  65. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  66. package/dist/cache/app-store.caches.d.ts +49 -0
  67. package/dist/cache/app-store.caches.d.ts.map +1 -1
  68. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  69. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  70. package/dist/config/environment.d.ts +5 -0
  71. package/dist/config/environment.d.ts.map +1 -1
  72. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  73. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  74. package/dist/config/stageConfig.d.ts +112 -0
  75. package/dist/config/stageConfig.d.ts.map +1 -0
  76. package/dist/config/stageConfig.test.d.ts +2 -0
  77. package/dist/config/stageConfig.test.d.ts.map +1 -0
  78. package/dist/didkit_wasm_bg.wasm +0 -0
  79. package/dist/docker-entry.d.ts +1 -1
  80. package/dist/docker-entry.d.ts.map +1 -1
  81. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  82. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  83. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  84. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  85. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  86. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  87. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  89. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  91. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  92. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  93. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  95. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  96. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  97. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  99. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  100. package/dist/helpers/notificationMessages.d.ts +1 -1
  101. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  102. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  103. package/dist/helpers/notifications.helpers.d.ts +6 -0
  104. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  105. package/dist/helpers/profile.helpers.d.ts +1 -0
  106. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  107. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  108. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  109. package/dist/helpers/share-link-attachment.d.ts +18 -0
  110. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  111. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  113. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  114. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  115. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  116. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  117. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  119. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  122. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  123. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  124. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  125. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  127. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  128. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  129. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  130. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  131. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  132. package/dist/index.js +2 -2
  133. package/dist/models/AppManifestVersion.d.ts +20 -0
  134. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  135. package/dist/models/AppStoreListing.d.ts +13 -0
  136. package/dist/models/AppStoreListing.d.ts.map +1 -1
  137. package/dist/models/ConsentFlowContract.d.ts +13 -2
  138. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  139. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  141. package/dist/models/Integration.d.ts +2 -0
  142. package/dist/models/Integration.d.ts.map +1 -1
  143. package/dist/models/ShareLink.d.ts +2 -0
  144. package/dist/models/ShareLink.d.ts.map +1 -1
  145. package/dist/models/index.d.ts +1 -0
  146. package/dist/models/index.d.ts.map +1 -1
  147. package/dist/models/share-link-constraints.d.ts.map +1 -1
  148. package/dist/openapi.d.ts +1 -1
  149. package/dist/openapi.d.ts.map +1 -1
  150. package/dist/routes/app-store.d.ts +619 -0
  151. package/dist/routes/app-store.d.ts.map +1 -1
  152. package/dist/routes/boosts.d.ts +2 -2
  153. package/dist/routes/boosts.d.ts.map +1 -1
  154. package/dist/routes/contracts.d.ts +146 -3
  155. package/dist/routes/contracts.d.ts.map +1 -1
  156. package/dist/routes/credentials.d.ts +34 -0
  157. package/dist/routes/credentials.d.ts.map +1 -1
  158. package/dist/routes/inbox.d.ts +52 -2
  159. package/dist/routes/inbox.d.ts.map +1 -1
  160. package/dist/routes/index.d.ts.map +1 -1
  161. package/dist/routes/profile-manager.d.ts.map +1 -1
  162. package/dist/routes/profiles.d.ts.map +1 -1
  163. package/dist/routes/public-share-links.d.ts +55 -0
  164. package/dist/routes/public-share-links.d.ts.map +1 -1
  165. package/dist/routes/share-links.d.ts +175 -14
  166. package/dist/routes/share-links.d.ts.map +1 -1
  167. package/dist/routes/test.d.ts +5 -0
  168. package/dist/routes/test.d.ts.map +1 -1
  169. package/dist/routes/workflows.d.ts.map +1 -1
  170. package/dist/types/app-manifest.d.ts +339 -0
  171. package/dist/types/app-manifest.d.ts.map +1 -0
  172. package/dist/types/app-store-listing.d.ts +34 -0
  173. package/dist/types/app-store-listing.d.ts.map +1 -1
  174. package/dist/types/consentflowcontract.d.ts +30 -3
  175. package/dist/types/consentflowcontract.d.ts.map +1 -1
  176. package/dist/types/credential-refresh.d.ts +1 -1
  177. package/esbuildPlugins.cjs +1 -0
  178. package/lambda.ts +49 -195
  179. package/lambdaApp.ts +203 -0
  180. package/package.json +20 -18
  181. package/scripts/export-openapi.ts +20 -0
  182. package/scripts/offline-schema-environment.ts +18 -0
  183. package/scripts/verify-python-contracts.ts +72 -0
  184. package/serverless-local.yml +3 -6
  185. package/serverless.function-env.cjs +50 -0
  186. package/serverless.local.yml +0 -1
  187. package/serverless.yml +27 -74
  188. package/shareLinkMaintenanceLambda.ts +13 -44
  189. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  190. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  191. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  192. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  193. package/src/accesslayer/app-store-listing/read.ts +6 -0
  194. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  195. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  196. package/src/accesslayer/app-store-listing/update.ts +15 -0
  197. package/src/accesslayer/boost/relationships/create.ts +8 -2
  198. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  199. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  200. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  201. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  202. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  203. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  204. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  205. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  206. package/src/accesslayer/credential/create.test.ts +2 -115
  207. package/src/accesslayer/credential/create.ts +11 -6
  208. package/src/accesslayer/credential/relationships/create.ts +17 -10
  209. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  210. package/src/accesslayer/inbox-credential/update.ts +4 -2
  211. package/src/accesslayer/share-link/abandon.ts +20 -0
  212. package/src/accesslayer/share-link/attachment.ts +360 -0
  213. package/src/accesslayer/share-link/finalize.ts +55 -7
  214. package/src/accesslayer/share-link/helpers.ts +33 -2
  215. package/src/accesslayer/share-link/recovery.ts +20 -0
  216. package/src/accesslayer/share-link/reserve.ts +85 -6
  217. package/src/accesslayer/share-link/revoke.ts +35 -0
  218. package/src/accesslayer/share-link/types.ts +9 -4
  219. package/src/cache/app-store.caches.ts +35 -0
  220. package/src/config/applyDockerStageConfig.ts +6 -0
  221. package/src/config/environment.test.ts +17 -12
  222. package/src/config/environment.ts +1 -0
  223. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  224. package/src/config/stageConfig.test.ts +25 -0
  225. package/src/config/stageConfig.ts +24 -0
  226. package/src/docker-entry.ts +24 -0
  227. package/src/helpers/app-manifest.helpers.ts +185 -0
  228. package/src/helpers/claim-hooks.helpers.ts +20 -7
  229. package/src/helpers/consent-referral.helpers.ts +6 -0
  230. package/src/helpers/consent-scopes.helpers.ts +58 -0
  231. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  232. package/src/helpers/contract-events.helpers.ts +540 -0
  233. package/src/helpers/contract-requests.helpers.ts +211 -0
  234. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  235. package/src/helpers/inbox.helpers.ts +51 -66
  236. package/src/helpers/inline-template.helpers.ts +560 -0
  237. package/src/helpers/notificationMessages.ts +59 -0
  238. package/src/helpers/notificationQueue.helpers.ts +23 -5
  239. package/src/helpers/notifications.helpers.ts +25 -1
  240. package/src/helpers/profile.helpers.ts +6 -0
  241. package/src/helpers/share-link-attachment-access.ts +87 -0
  242. package/src/helpers/share-link-attachment.ts +115 -0
  243. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  244. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  245. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  246. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  247. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  248. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  249. package/src/helpers/share-link-maintenance/types.ts +2 -0
  250. package/src/helpers/share-link-owner-projection.ts +2 -0
  251. package/src/helpers/share-link-policy/production.ts +37 -17
  252. package/src/helpers/share-link-policy/resolver.ts +16 -11
  253. package/src/helpers/share-link-policy/types.ts +15 -7
  254. package/src/helpers/signingAuthority.helpers.ts +23 -14
  255. package/src/helpers/smartResume.helpers.ts +123 -0
  256. package/src/models/AppManifestVersion.ts +43 -0
  257. package/src/models/AppStoreListing.ts +32 -0
  258. package/src/models/ConsentFlowContract.ts +34 -3
  259. package/src/models/ConsentFlowTerms.ts +10 -0
  260. package/src/models/ConsentFlowTransaction.ts +3 -0
  261. package/src/models/Integration.ts +2 -0
  262. package/src/models/ShareLink.ts +4 -0
  263. package/src/models/index.ts +23 -0
  264. package/src/models/share-link-constraints.ts +5 -0
  265. package/src/openapi.ts +44 -8
  266. package/src/routes/app-store.ts +1054 -27
  267. package/src/routes/boosts.ts +12 -14
  268. package/src/routes/contracts.ts +382 -262
  269. package/src/routes/inbox.ts +4 -2
  270. package/src/routes/index.ts +4 -1
  271. package/src/routes/profile-manager.ts +19 -0
  272. package/src/routes/profiles.ts +12 -0
  273. package/src/routes/public-share-links.ts +169 -0
  274. package/src/routes/share-links.ts +69 -0
  275. package/src/routes/workflows.ts +3 -2
  276. package/src/types/app-manifest.ts +28 -0
  277. package/src/types/app-store-listing.ts +13 -0
  278. package/src/types/consentflowcontract.ts +25 -4
  279. package/test/app-manifest.helpers.spec.ts +170 -0
  280. package/test/app-manifest.routes.spec.ts +299 -0
  281. package/test/app-store-listing-updates.spec.ts +236 -0
  282. package/test/app-store-listing.spec.ts +65 -3
  283. package/test/consent-data-boundaries.spec.ts +352 -0
  284. package/test/consent-recipients.spec.ts +513 -0
  285. package/test/consent-scopes.helpers.spec.ts +67 -0
  286. package/test/consentflow.spec.ts +63 -2
  287. package/test/contract-requests.spec.ts +1588 -0
  288. package/test/credential-storage.neo4j.spec.ts +122 -0
  289. package/test/guardian-gated-routes.spec.ts +36 -0
  290. package/test/inbox-security.e2e.ts +26 -10
  291. package/test/inbox.spec.ts +184 -8
  292. package/test/inline-template.helpers.spec.ts +49 -0
  293. package/test/managed-recipient-inbox.spec.ts +24 -2
  294. package/test/openapi.spec.ts +14 -73
  295. package/test/profiles.spec.ts +30 -0
  296. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  297. package/test/share-link-attachment.unit.spec.ts +244 -0
  298. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  299. package/test/share-link-maintenance.unit.spec.ts +32 -5
  300. package/test/share-link-owner-route.unit.spec.ts +70 -2
  301. package/test/share-link-policy.unit.spec.ts +18 -4
  302. package/test/share-link-production-policy.unit.spec.ts +58 -9
  303. package/test/share-link-public-route.unit.spec.ts +279 -0
  304. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  305. package/test/signing-authority-encryption.spec.ts +75 -0
  306. package/test/smart-resume-consent.spec.ts +233 -0
  307. package/test/smart-resume-upload.spec.ts +65 -0
  308. package/tsconfig.build.json +1 -1
  309. package/tsconfig.json +1 -0
  310. package/vitest.config.ts +13 -0
  311. package/vitest.inbox-e2e.config.ts +2 -0
  312. package/workerLambda.test.ts +100 -0
@@ -26,8 +26,20 @@ const TIMEOUT = 6000;
26
26
 
27
27
  type NotificationDeliveryOptions = {
28
28
  propagateDirectWebhookTransportErrors?: boolean;
29
+ /** Opt in to terminal rejection errors; legacy callers continue to receive false. */
30
+ throwOnPermanentFailure?: boolean;
29
31
  };
30
32
 
33
+ export class PermanentNotificationDeliveryError extends Error {
34
+ constructor(
35
+ readonly reason: 'no_webhook' | 'invalid_webhook' | 'webhook_rejected',
36
+ readonly statusCode?: number
37
+ ) {
38
+ super(`Notification delivery rejected: ${reason}`);
39
+ this.name = 'PermanentNotificationDeliveryError';
40
+ }
41
+ }
42
+
31
43
  type NotificationWebhookResponseRecord = Record<string, unknown>;
32
44
 
33
45
  const isDefinitiveWebhookRejection = (statusCode: number): boolean => {
@@ -308,6 +320,8 @@ export async function sendNotification(
308
320
  );
309
321
 
310
322
  if (!notificationsWebhook) {
323
+ if (options.throwOnPermanentFailure)
324
+ throw new PermanentNotificationDeliveryError('no_webhook');
311
325
  return false;
312
326
  }
313
327
 
@@ -360,7 +374,14 @@ export async function sendNotification(
360
374
  }
361
375
 
362
376
  if (!response.ok) {
363
- if (isDefinitiveWebhookRejection(response.status)) return false;
377
+ if (isDefinitiveWebhookRejection(response.status)) {
378
+ if (options.throwOnPermanentFailure)
379
+ throw new PermanentNotificationDeliveryError(
380
+ 'webhook_rejected',
381
+ response.status
382
+ );
383
+ return false;
384
+ }
364
385
 
365
386
  throw createWebhookTransportError(response.status);
366
387
  }
@@ -411,7 +432,10 @@ export async function sendNotification(
411
432
 
412
433
  return notificationDelivered;
413
434
  }
435
+ if (options.throwOnPermanentFailure)
436
+ throw new PermanentNotificationDeliveryError('invalid_webhook');
414
437
  } catch (error) {
438
+ if (error instanceof PermanentNotificationDeliveryError) throw error;
415
439
  if (runtimeEnvironment.NODE_ENV !== 'test') {
416
440
  console.error('Notifications Helpers - Error While Sending:', error);
417
441
  }
@@ -13,6 +13,12 @@ export const PublicProfileIdValidator = LCNProfileValidator.shape.profileId.refi
13
13
  { message: 'Profile IDs beginning with "sample-" are reserved.' }
14
14
  );
15
15
 
16
+ /** Legacy service flags never override an explicit child classification. */
17
+ export const isServiceProfileExemptFromGuardianship = (
18
+ isServiceProfile: unknown,
19
+ profileType: unknown
20
+ ): boolean => isServiceProfile === true && profileType !== 'child';
21
+
16
22
  /**
17
23
  * Gets or creates a federated profile for cross-instance credential sending.
18
24
  * Federated profiles use email-like format: profileId@service-domain to avoid collisions.
@@ -0,0 +1,87 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { getShareLinkRequestHashSecret } from './share-link-lifecycle';
3
+ import type { ShareLinkRecord } from '../models/ShareLink';
4
+
5
+ const TTL_MS = 60 * 1000;
6
+ const sign = (text: string): string =>
7
+ createHmac('sha256', getShareLinkRequestHashSecret()).update(text).digest('base64url');
8
+ const binding = (record: ShareLinkRecord, namespace: string, sourceIp?: string): string =>
9
+ sign(
10
+ JSON.stringify([
11
+ 'attachment-access-v1',
12
+ namespace,
13
+ record.ownerProfileId,
14
+ record.id,
15
+ record.contentVersion,
16
+ record.attachmentId,
17
+ record.attachmentChunkCount,
18
+ record.passcodeHash ?? null,
19
+ sourceIp ?? null,
20
+ ])
21
+ );
22
+
23
+ /** Opaque keyed binding never exposes the password verifier or internal owner/namespace. */
24
+ export const mintShareAttachmentAccessToken = (
25
+ record: ShareLinkRecord,
26
+ namespace: string,
27
+ sourceIp: string | undefined,
28
+ now: Date
29
+ ): string => {
30
+ const body = Buffer.from(
31
+ JSON.stringify({
32
+ v: 1,
33
+ expiresAt: now.getTime() + TTL_MS,
34
+ nonce: randomUUID(),
35
+ binding: binding(record, namespace, sourceIp),
36
+ })
37
+ ).toString('base64url');
38
+ return `${body}.${sign(`attachment-grant-v1:${body}`)}`;
39
+ };
40
+
41
+ /** A grant only amortizes a successful password check; active/current authorization remains mandatory. */
42
+ export const verifyShareAttachmentAccessToken = (
43
+ token: string,
44
+ record: ShareLinkRecord,
45
+ namespace: string,
46
+ sourceIp: string | undefined,
47
+ now: Date
48
+ ): boolean => {
49
+ try {
50
+ const parts = token.split('.');
51
+ const bodyPart = parts[0];
52
+ const signature = parts[1];
53
+ if (
54
+ parts.length !== 2 ||
55
+ !bodyPart ||
56
+ !signature ||
57
+ signature.length !== 43 ||
58
+ token.length > 2048
59
+ )
60
+ return false;
61
+ const bytes = Buffer.from(bodyPart, 'base64url');
62
+ if (bytes.toString('base64url') !== bodyPart) return false;
63
+ const actual = Buffer.from(signature, 'base64url');
64
+ const expected = Buffer.from(sign(`attachment-grant-v1:${bodyPart}`), 'base64url');
65
+ if (
66
+ actual.length !== expected.length ||
67
+ actual.toString('base64url') !== signature ||
68
+ !timingSafeEqual(actual, expected)
69
+ )
70
+ return false;
71
+ const body: unknown = JSON.parse(bytes.toString('utf8'));
72
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
73
+ const value = body as Record<string, unknown>;
74
+ if (
75
+ value.v !== 1 ||
76
+ typeof value.expiresAt !== 'number' ||
77
+ !Number.isSafeInteger(value.expiresAt) ||
78
+ value.expiresAt <= now.getTime() ||
79
+ value.expiresAt > now.getTime() + TTL_MS ||
80
+ value.binding !== binding(record, namespace, sourceIp)
81
+ )
82
+ return false;
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ };
@@ -0,0 +1,115 @@
1
+ import {
2
+ PutShareLinkAttachmentChunkInputValidator,
3
+ DeleteShareLinkAttachmentChunksInputValidator,
4
+ } from '@learncard/types';
5
+ import type {
6
+ PutShareLinkAttachmentChunkInput,
7
+ DeleteShareLinkAttachmentChunksInput,
8
+ } from '@learncard/types';
9
+ import type { ShareContentClient } from './share-content-client';
10
+ import {
11
+ beginShareAttachmentChunk,
12
+ completeShareAttachmentChunk,
13
+ deleteUnreferencedShareAttachment,
14
+ shareAttachmentTuple,
15
+ type ShareAttachmentBinding,
16
+ } from '../accesslayer/share-link/attachment';
17
+ import { ShareLinkCoordinatorError } from './share-link-coordinator/types';
18
+ import type { ShareOwnerContext } from './share-link-coordinator/types';
19
+ import { isShareLinkRepositoryError } from '../accesslayer/share-link/errors';
20
+
21
+ export type ShareLinkAttachmentOwnerApi = {
22
+ put: (
23
+ input: PutShareLinkAttachmentChunkInput,
24
+ owner: ShareOwnerContext
25
+ ) => Promise<{ ok: true }>;
26
+ delete: (
27
+ input: DeleteShareLinkAttachmentChunksInput,
28
+ owner: ShareOwnerContext
29
+ ) => Promise<{ ok: boolean }>;
30
+ };
31
+
32
+ export const createShareLinkAttachmentOwnerApi = (
33
+ client: Pick<ShareContentClient, 'put' | 'delete'>,
34
+ repository = {
35
+ begin: beginShareAttachmentChunk,
36
+ complete: completeShareAttachmentChunk,
37
+ deleteUnreferenced: deleteUnreferencedShareAttachment,
38
+ }
39
+ ): ShareLinkAttachmentOwnerApi => {
40
+ const bindingFor = (
41
+ input: DeleteShareLinkAttachmentChunksInput,
42
+ owner: ShareOwnerContext
43
+ ): ShareAttachmentBinding => ({
44
+ namespace: owner.namespace,
45
+ ownerProfileId: owner.ownerProfileId,
46
+ shareId: input.id,
47
+ contentVersion: input.contentVersion,
48
+ attachmentId: input.attachmentId,
49
+ chunkCount: input.chunkCount,
50
+ });
51
+ return {
52
+ put: async (input, owner) => {
53
+ const parsed = PutShareLinkAttachmentChunkInputValidator.safeParse(input);
54
+ if (!parsed.success)
55
+ throw new ShareLinkCoordinatorError('INVALID_INPUT', 'invalid attachment');
56
+ const value = parsed.data;
57
+ const binding = bindingFor(value, owner);
58
+ try {
59
+ await repository.begin(binding, value.chunkIndex);
60
+ const result = await client.put({
61
+ ...shareAttachmentTuple(binding, value.chunkIndex),
62
+ envelope: value.envelope,
63
+ ownerEncryptedRecovery: value.ownerEncryptedRecovery,
64
+ });
65
+ if (!result.ok)
66
+ throw new ShareLinkCoordinatorError(
67
+ result.error === 'CONFLICT' ? 'CONFLICT' : 'UNAVAILABLE',
68
+ 'attachment upload unavailable'
69
+ );
70
+ await repository.complete(binding, value.chunkIndex);
71
+ return { ok: true };
72
+ } catch (error) {
73
+ if (error instanceof ShareLinkCoordinatorError) throw error;
74
+ const code = isShareLinkRepositoryError(error) ? error.code : undefined;
75
+ throw new ShareLinkCoordinatorError(
76
+ code === 'CONFLICT'
77
+ ? 'CONFLICT'
78
+ : code === 'NOT_FOUND'
79
+ ? 'NOT_FOUND'
80
+ : code === 'PRECONDITION_FAILED' || code === 'INVALID_INPUT'
81
+ ? code
82
+ : 'UNAVAILABLE',
83
+ 'attachment upload unavailable'
84
+ );
85
+ }
86
+ },
87
+ delete: async (input, owner) => {
88
+ const parsed = DeleteShareLinkAttachmentChunksInputValidator.safeParse(input);
89
+ if (!parsed.success)
90
+ throw new ShareLinkCoordinatorError('INVALID_INPUT', 'invalid attachment');
91
+ const binding = bindingFor(parsed.data, owner);
92
+ let allowed: boolean;
93
+ try {
94
+ allowed = await repository.deleteUnreferenced(binding);
95
+ } catch {
96
+ throw new ShareLinkCoordinatorError(
97
+ 'UNAVAILABLE',
98
+ 'attachment cleanup unavailable'
99
+ );
100
+ }
101
+ if (!allowed) return { ok: false };
102
+ // Durable jobs were queued before I/O; interrupted/failed deletes are retried by maintenance.
103
+ let ok = true;
104
+ for (let index = 0; index < binding.chunkCount; index++) {
105
+ try {
106
+ const result = await client.delete(shareAttachmentTuple(binding, index));
107
+ if (!result.ok) ok = false;
108
+ } catch {
109
+ ok = false;
110
+ }
111
+ }
112
+ return { ok };
113
+ },
114
+ };
115
+ };
@@ -0,0 +1,41 @@
1
+ import { shareAttachmentTuple } from '../../accesslayer/share-link/attachment';
2
+ import type { ShareLinkReservationRecord } from '../../accesslayer/share-link/types';
3
+ import type { ShareContentClient } from '../share-content-client';
4
+ import type { MaintenanceBudget } from './types';
5
+ import { budgetAllows } from './budget-helpers';
6
+
7
+ /** Verify the complete immutable set before releasing the atomic commit fence. */
8
+ export const verifyShareAttachmentStats = async (
9
+ client: Pick<ShareContentClient, 'stat'>,
10
+ reservation: ShareLinkReservationRecord,
11
+ budget?: MaintenanceBudget,
12
+ remoteAndFinalizeCostMs = 0
13
+ ): Promise<boolean> => {
14
+ if (reservation.contentVersion === null || !reservation.attachmentId) return true;
15
+ const count = reservation.attachmentChunkCount;
16
+ if (!count || count < 1 || count > 16) return false;
17
+ const binding = {
18
+ namespace: reservation.namespace,
19
+ ownerProfileId: reservation.ownerProfileId,
20
+ shareId: reservation.shareId,
21
+ contentVersion: reservation.contentVersion,
22
+ attachmentId: reservation.attachmentId,
23
+ chunkCount: count,
24
+ };
25
+ for (let index = 0; index < count; index++) {
26
+ // Each bounded HTTP call must leave enough time for finalization and the
27
+ // pass reserve. A many-chunk asset cannot overrun the maintenance budget.
28
+ if (!budgetAllows(budget, remoteAndFinalizeCostMs)) return false;
29
+ const tuple = shareAttachmentTuple(binding, index);
30
+ const result = await client.stat(tuple);
31
+ if (!result.ok || result.value.kind !== 'active') return false;
32
+ const active = result.value;
33
+ if (
34
+ Object.entries(tuple).some(
35
+ ([key, value]) => active[key as keyof typeof active] !== value
36
+ )
37
+ )
38
+ return false;
39
+ }
40
+ return true;
41
+ };
@@ -28,6 +28,7 @@ import type {
28
28
  ShareContentEnvelope,
29
29
  } from '../share-content-client/types';
30
30
  import { verifyShareContentActiveStat as verifyActiveStat } from './stat';
31
+ import { verifyShareAttachmentStats } from './attachment-stat';
31
32
  import {
32
33
  ShareLinkCoordinatorError,
33
34
  type PendingShareContentOperation,
@@ -162,8 +163,11 @@ export const createShareLinkCoordinator = (
162
163
  const policyResolver: ShareLinkPolicyResolver =
163
164
  dependencies.policyResolver ??
164
165
  createShareLinkPolicyResolver({
165
- resolveOwnerAge: async () => 'unknown',
166
- isManaged: async () => false,
166
+ resolveOwner: async () => ({
167
+ age: 'unknown',
168
+ isManaged: false,
169
+ isServiceProfile: false,
170
+ }),
167
171
  });
168
172
 
169
173
  const finalizeOrPending = async (
@@ -171,7 +175,14 @@ export const createShareLinkCoordinator = (
171
175
  objectRef: string | null
172
176
  ): Promise<ShareLinkCommitResult> => {
173
177
  try {
178
+ if (!(await verifyShareAttachmentStats(client, reservation))) {
179
+ return { status: 'pending', reservation, reason: 'stat_missing' };
180
+ }
174
181
  const finalized = await repository.finalizeReservation({
182
+ verifiedAttachmentId:
183
+ reservation.contentVersion !== null
184
+ ? (reservation.attachmentId ?? undefined)
185
+ : undefined,
175
186
  namespace: reservation.namespace,
176
187
  ownerProfileId: reservation.ownerProfileId,
177
188
  shareId: reservation.shareId,
@@ -336,6 +347,7 @@ export const createShareLinkCoordinator = (
336
347
  // policy that disables view counting for minors/unknown age.
337
348
  notifyOnView: value.notifyOnView && policy.viewCountingEnabled,
338
349
  content,
350
+ attachment: value.attachment,
339
351
  requestHash,
340
352
  policy,
341
353
  leaseOwner: dependencies.leaseOwner,
@@ -397,6 +409,7 @@ export const createShareLinkCoordinator = (
397
409
  ),
398
410
  contentVersion: value.contentVersion!,
399
411
  selectedCount: value.selectedCount!,
412
+ attachment: value.attachment,
400
413
  },
401
414
  }
402
415
  : {}),
@@ -7,6 +7,7 @@ import type { ShareLinkRecord } from '../../models/ShareLink';
7
7
  import { isTransientShareContentError } from '../share-content-client/types';
8
8
  import { budgetAllows, budgetHasUnitReserve, boundTransactionTimeout } from './budget-helpers';
9
9
  import { verifyShareContentActiveStat } from './stat';
10
+ import { verifyShareAttachmentStats } from './attachment-stat';
10
11
  import {
11
12
  ShareLinkCoordinatorError,
12
13
  type RecoveryRunnerDependencies,
@@ -102,7 +103,21 @@ const finalizeClaimedReservation = async (
102
103
  dependencies: RecoveryRunnerDependencies
103
104
  ): Promise<RecoveryDriveOutcome> => {
104
105
  try {
106
+ if (
107
+ !(await verifyShareAttachmentStats(
108
+ dependencies.client,
109
+ reservation,
110
+ dependencies.budget,
111
+ recoveryRemoteCostMs(dependencies)
112
+ ))
113
+ ) {
114
+ return { kind: 'deferred', category: 'deferred_stat_mismatch' };
115
+ }
105
116
  const finalized = await dependencies.repository.finalizeReservation({
117
+ verifiedAttachmentId:
118
+ reservation.contentVersion !== null
119
+ ? (reservation.attachmentId ?? undefined)
120
+ : undefined,
106
121
  namespace: reservation.namespace,
107
122
  ownerProfileId: reservation.ownerProfileId,
108
123
  shareId: reservation.shareId,
@@ -29,6 +29,7 @@ export type ShareLinkMaintenanceRunnerDependencies = {
29
29
  * never load the Neo4j `@instance`; production passes the real repository.
30
30
  */
31
31
  pruneReceipts: (input: PruneShareViewReceiptsInput) => Promise<number>;
32
+ expireAttachmentStages?: typeof import('../../accesslayer/share-link/attachment').expireShareAttachmentStages;
32
33
  client: Pick<ShareContentClient, 'stat' | 'delete'>;
33
34
  logger: ShareLinkMaintenanceLogger;
34
35
  /** Monotonic clock for the cooperative budget; injectable for tests. */
@@ -124,6 +125,8 @@ export const runShareLinkMaintenancePass = async (
124
125
  let budgetStoppedRecovery = false;
125
126
  let budgetStoppedCleanup = false;
126
127
  let budgetStoppedReceiptPrune = false;
128
+ let attachmentStages: ShareLinkMaintenanceRunSummary['attachmentStages'];
129
+ let stageFailed = false;
127
130
 
128
131
  if (budgetAllows(budget, recoveryDiscoveryCostMs(config))) {
129
132
  recoveryAttempted = true;
@@ -149,6 +152,27 @@ export const runShareLinkMaintenancePass = async (
149
152
  budgetStoppedRecovery = true;
150
153
  }
151
154
 
155
+ if (dependencies.expireAttachmentStages) {
156
+ if (budgetAllows(budget, config.graphTimeoutMs)) {
157
+ try {
158
+ attachmentStages = {
159
+ queued: await dependencies.expireAttachmentStages({
160
+ namespace: config.namespace,
161
+ limit: 10,
162
+ now: dependencies.now?.(),
163
+ transaction: { timeoutMs: config.graphTimeoutMs, noInlineRetry: true },
164
+ }),
165
+ };
166
+ } catch {
167
+ attachmentStages = null;
168
+ stageFailed = true;
169
+ categories.share_link_maintenance_attachment_stage_failed = 1;
170
+ }
171
+ } else {
172
+ budgetStoppedCleanup = true;
173
+ }
174
+ }
175
+
152
176
  if (budgetAllows(budget, cleanupUnitCostMs(config))) {
153
177
  cleanupAttempted = true;
154
178
 
@@ -196,6 +220,7 @@ export const runShareLinkMaintenancePass = async (
196
220
  }
197
221
 
198
222
  const failed =
223
+ stageFailed ||
199
224
  (recoveryAttempted && recovery === null) ||
200
225
  (cleanupAttempted && cleanup === null) ||
201
226
  (receiptPruneAttempted && receiptPrune === null);
@@ -214,6 +239,9 @@ export const runShareLinkMaintenancePass = async (
214
239
  recovery,
215
240
  cleanup,
216
241
  receiptPrune,
242
+ ...(dependencies.expireAttachmentStages
243
+ ? { attachmentStages: attachmentStages ?? null }
244
+ : {}),
217
245
  categories,
218
246
  };
219
247
 
@@ -17,6 +17,7 @@ import type {
17
17
  type MaintenanceClient = Pick<ShareContentClient, 'stat' | 'delete'>;
18
18
 
19
19
  type ResolvedRunnerDependencies = {
20
+ expireAttachmentStages?: typeof import('../../accesslayer/share-link/attachment').expireShareAttachmentStages;
20
21
  recoveryRepository: RecoveryRunnerDependencies['repository'];
21
22
  cleanupRepository: CleanupRunnerDependencies['repository'];
22
23
  pruneReceipts: (input: PruneShareViewReceiptsInput) => Promise<number>;
@@ -58,6 +59,7 @@ export type CreateShareLinkMaintenanceRuntimeOptions = {
58
59
  recoveryRepository: RecoveryRunnerDependencies['repository'];
59
60
  cleanupRepository: CleanupRunnerDependencies['repository'];
60
61
  pruneReceipts: ResolvedRunnerDependencies['pruneReceipts'];
62
+ expireAttachmentStages?: ResolvedRunnerDependencies['expireAttachmentStages'];
61
63
  }>;
62
64
  };
63
65
 
@@ -245,6 +247,7 @@ export const createShareLinkMaintenanceRuntime = (
245
247
  recoveryRepository: setup.dependencies.recoveryRepository,
246
248
  cleanupRepository: setup.dependencies.cleanupRepository,
247
249
  pruneReceipts: setup.dependencies.pruneReceipts,
250
+ expireAttachmentStages: setup.dependencies.expireAttachmentStages,
248
251
  client: setup.dependencies.client,
249
252
  logger,
250
253
  monotonicNow: options.monotonicNow,
@@ -297,6 +300,7 @@ const buildRuntimeRepositories = async (
297
300
  recoveryRepository: RecoveryRunnerDependencies['repository'];
298
301
  cleanupRepository: CleanupRunnerDependencies['repository'];
299
302
  pruneReceipts: ResolvedRunnerDependencies['pruneReceipts'];
303
+ expireAttachmentStages?: ResolvedRunnerDependencies['expireAttachmentStages'];
300
304
  }> => {
301
305
  const runtimeModule = await import('../share-link-coordinator/runtime');
302
306
 
@@ -325,6 +329,8 @@ const buildRuntimeRepositories = async (
325
329
  recoveryRepository: recoveryDependencies.repository,
326
330
  cleanupRepository: cleanupDependencies.repository,
327
331
  pruneReceipts: pruneShareViewReceipts,
332
+ expireAttachmentStages: (await import('../../accesslayer/share-link/attachment'))
333
+ .expireShareAttachmentStages,
328
334
  };
329
335
  };
330
336
 
@@ -39,6 +39,9 @@ export const toMaintenanceLogEvent = (
39
39
  ...recoveryCounts(summary.recovery),
40
40
  ...cleanupCounts(summary.cleanup),
41
41
  receiptsPruned: summary.receiptPrune?.pruned ?? 0,
42
+ ...(summary.attachmentStages
43
+ ? { attachmentStagesQueued: summary.attachmentStages.queued }
44
+ : {}),
42
45
  },
43
46
  categories: {
44
47
  ...(summary.recovery?.categories ?? {}),
@@ -50,6 +50,7 @@ export type ShareLinkMaintenanceFailureCategory =
50
50
  | 'share_link_maintenance_pass_failed'
51
51
  | 'share_link_maintenance_recovery_failed'
52
52
  | 'share_link_maintenance_cleanup_failed'
53
+ | 'share_link_maintenance_attachment_stage_failed'
53
54
  | 'share_link_maintenance_receipt_prune_failed'
54
55
  | 'share_link_maintenance_initialization_failed'
55
56
  | 'share_link_maintenance_configuration_invalid';
@@ -67,6 +68,7 @@ export type ShareLinkMaintenanceRunSummary = {
67
68
  cleanup: CleanupRunSummary | null;
68
69
  /** Namespace-scoped expired/consumed receipt nodes deleted this pass. */
69
70
  receiptPrune: { pruned: number } | null;
71
+ attachmentStages?: { queued: number } | null;
70
72
  categories: Partial<Record<ShareLinkMaintenanceFailureCategory, number>>;
71
73
  };
72
74
 
@@ -31,6 +31,8 @@ export const toOwnerShareLink = (
31
31
  selectedCount: record.selectedCount,
32
32
  version: record.version,
33
33
  contentVersion: record.contentVersion,
34
+ attachmentId: record.attachmentId ?? null,
35
+ attachmentChunkCount: record.attachmentChunkCount ?? null,
34
36
  status: record.status,
35
37
  contentState: record.contentState,
36
38
  createdAt: record.createdAt,
@@ -2,14 +2,17 @@ import { getProfileByProfileId } from '@accesslayer/profile/read';
2
2
  import { isProfileManaged } from '@accesslayer/profile/relationships/read';
3
3
  import type { ShareViewEligibilitySource } from '@accesslayer/share-link/types';
4
4
  import type { ShareLinkTransaction } from '@accesslayer/share-link/transaction';
5
- import { transformProfileId } from '@helpers/profile.helpers';
5
+ import {
6
+ isServiceProfileExemptFromGuardianship,
7
+ transformProfileId,
8
+ } from '@helpers/profile.helpers';
6
9
 
7
10
  import { composeShareLinkPolicy } from './resolver';
8
- import type { ShareLinkOwnerAge, ShareLinkPolicySnapshot, ShareLinkPolicySource } from './types';
11
+ import type { CurrentShareLinkPolicy, ShareLinkOwnerAge, ShareLinkPolicySource } from './types';
9
12
 
10
13
  const ADULT_AGE = 18;
11
14
 
12
- /** A missing or malformed birthdate never grants view tracking. */
15
+ /** Classify persisted human age without inventing a birthdate for service profiles. */
13
16
  export const ageFromPersistedProfile = (
14
17
  profile: { dob?: unknown; type?: unknown } | null,
15
18
  now: Date = new Date()
@@ -45,16 +48,23 @@ export const ageFromPersistedProfile = (
45
48
  };
46
49
 
47
50
  /**
48
- * Profile birthdate is persisted server-side, but self-reported rather than
49
- * independently verified. Unknown dates and child profiles stay restricted.
51
+ * Personal profiles require a valid persisted birthdate for tracking. Service
52
+ * profiles are age-exempt, but an explicit child type always keeps protections.
50
53
  */
51
- export const createProductionShareLinkPolicySource = (options?: {
52
- resolveOwnerAge?: (profileId: string) => Promise<ShareLinkOwnerAge>;
53
- }): ShareLinkPolicySource => ({
54
- resolveOwnerAge:
55
- options?.resolveOwnerAge ??
56
- (async profileId => ageFromPersistedProfile(await getProfileByProfileId(profileId))),
57
- isManaged: isProfileManaged,
54
+ export const createProductionShareLinkPolicySource = (): ShareLinkPolicySource => ({
55
+ resolveOwner: async profileId => {
56
+ const profile = await getProfileByProfileId(profileId);
57
+ const isServiceProfile = isServiceProfileExemptFromGuardianship(
58
+ profile?.isServiceProfile,
59
+ profile?.type
60
+ );
61
+ const isManaged = await isProfileManaged(profileId);
62
+ return {
63
+ age: ageFromPersistedProfile(profile),
64
+ isServiceProfile,
65
+ isManaged,
66
+ };
67
+ },
58
68
  });
59
69
 
60
70
  /** Read current eligibility within the share lock; no request values or network I/O. */
@@ -62,23 +72,33 @@ export const resolveCurrentShareLinkPolicy = async (
62
72
  tx: ShareLinkTransaction,
63
73
  ownerProfileId: string,
64
74
  now: Date
65
- ): Promise<ShareLinkPolicySnapshot> => {
75
+ ): Promise<CurrentShareLinkPolicy> => {
66
76
  const result = await tx.run(
67
77
  `MATCH (p:Profile {profileId: $profileId})
68
78
  OPTIONAL MATCH (p)-[:MANAGED_BY]->(directManager:Profile)
69
79
  OPTIONAL MATCH (manager:ProfileManager)-[:MANAGES]->(p)
70
80
  RETURN p.dob AS dob, p.type AS profileType,
81
+ p.isServiceProfile AS isServiceProfile,
71
82
  (directManager IS NOT NULL OR manager IS NOT NULL) AS isManaged
72
83
  LIMIT 1`,
73
84
  { profileId: transformProfileId(ownerProfileId) }
74
85
  );
75
86
  const record = result.records[0];
76
- if (!record) return composeShareLinkPolicy('unknown', true);
87
+ if (!record) return { ...composeShareLinkPolicy('unknown', true), isServiceProfile: false };
77
88
 
78
- return composeShareLinkPolicy(
79
- ageFromPersistedProfile({ dob: record.get('dob'), type: record.get('profileType') }, now),
80
- record.get('isManaged') !== false
89
+ const profileType = record.get('profileType');
90
+ const isServiceProfile = isServiceProfileExemptFromGuardianship(
91
+ record.get('isServiceProfile'),
92
+ profileType
81
93
  );
94
+ return {
95
+ ...composeShareLinkPolicy(
96
+ ageFromPersistedProfile({ dob: record.get('dob'), type: profileType }, now),
97
+ record.get('isManaged') !== false,
98
+ isServiceProfile
99
+ ),
100
+ isServiceProfile,
101
+ };
82
102
  };
83
103
 
84
104
  export const productionShareViewEligibilitySource: ShareViewEligibilitySource = {