@learncard/network-brain-service 3.19.1 → 4.0.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 (315) hide show
  1. package/.env.example +44 -111
  2. package/AGENTS.md +52 -0
  3. package/CHANGELOG.md +120 -0
  4. package/README.md +9 -2
  5. package/bootstrapLambda.test.ts +72 -0
  6. package/bundle.mjs +9 -1
  7. package/config/config.dev.json +29 -0
  8. package/config/config.example.json +29 -0
  9. package/config/config.json +1 -0
  10. package/config/config.production.json +29 -0
  11. package/config/config.scouts.dev.json +24 -0
  12. package/config/config.scouts.production.json +24 -0
  13. package/contractEventsLambda.ts +18 -0
  14. package/contractEventsLambdaApp.ts +19 -0
  15. package/didWebLambda.ts +13 -18
  16. package/didWebLambdaApp.ts +22 -0
  17. package/dist/accesslayer/app-manifest-version/create.d.ts +10 -0
  18. package/dist/accesslayer/app-manifest-version/create.d.ts.map +1 -0
  19. package/dist/accesslayer/app-manifest-version/read.d.ts +11 -0
  20. package/dist/accesslayer/app-manifest-version/read.d.ts.map +1 -0
  21. package/dist/accesslayer/app-manifest-version/update.d.ts +7 -0
  22. package/dist/accesslayer/app-manifest-version/update.d.ts.map +1 -0
  23. package/dist/accesslayer/app-store-listing/read.d.ts +2 -1
  24. package/dist/accesslayer/app-store-listing/read.d.ts.map +1 -1
  25. package/dist/accesslayer/app-store-listing/relationships/create.d.ts +1 -0
  26. package/dist/accesslayer/app-store-listing/relationships/create.d.ts.map +1 -1
  27. package/dist/accesslayer/app-store-listing/relationships/read.d.ts +2 -0
  28. package/dist/accesslayer/app-store-listing/relationships/read.d.ts.map +1 -1
  29. package/dist/accesslayer/app-store-listing/update.d.ts +1 -0
  30. package/dist/accesslayer/app-store-listing/update.d.ts.map +1 -1
  31. package/dist/accesslayer/boost/relationships/create.d.ts +2 -1
  32. package/dist/accesslayer/boost/relationships/create.d.ts.map +1 -1
  33. package/dist/accesslayer/consentflowcontract/create.d.ts +9 -1
  34. package/dist/accesslayer/consentflowcontract/create.d.ts.map +1 -1
  35. package/dist/accesslayer/consentflowcontract/read.d.ts +34 -3
  36. package/dist/accesslayer/consentflowcontract/read.d.ts.map +1 -1
  37. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts +4 -1
  38. package/dist/accesslayer/consentflowcontract/relationships/create.d.ts.map +1 -1
  39. package/dist/accesslayer/consentflowcontract/relationships/delete.d.ts.map +1 -1
  40. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts +3 -2
  41. package/dist/accesslayer/consentflowcontract/relationships/read.d.ts.map +1 -1
  42. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts +12 -0
  43. package/dist/accesslayer/consentflowcontract/relationships/recipients.d.ts.map +1 -0
  44. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts +12 -0
  45. package/dist/accesslayer/consentflowcontract/relationships/smartResume.d.ts.map +1 -0
  46. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts +8 -5
  47. package/dist/accesslayer/consentflowcontract/relationships/update.d.ts.map +1 -1
  48. package/dist/accesslayer/credential/create.d.ts +2 -1
  49. package/dist/accesslayer/credential/create.d.ts.map +1 -1
  50. package/dist/accesslayer/credential/relationships/create.d.ts +4 -3
  51. package/dist/accesslayer/credential/relationships/create.d.ts.map +1 -1
  52. package/dist/accesslayer/inbox-credential/relationships/create.d.ts +2 -1
  53. package/dist/accesslayer/inbox-credential/relationships/create.d.ts.map +1 -1
  54. package/dist/accesslayer/inbox-credential/update.d.ts +2 -1
  55. package/dist/accesslayer/inbox-credential/update.d.ts.map +1 -1
  56. package/dist/accesslayer/share-link/abandon.d.ts.map +1 -1
  57. package/dist/accesslayer/share-link/attachment.d.ts +37 -0
  58. package/dist/accesslayer/share-link/attachment.d.ts.map +1 -0
  59. package/dist/accesslayer/share-link/finalize.d.ts.map +1 -1
  60. package/dist/accesslayer/share-link/helpers.d.ts +3 -0
  61. package/dist/accesslayer/share-link/helpers.d.ts.map +1 -1
  62. package/dist/accesslayer/share-link/recovery.d.ts.map +1 -1
  63. package/dist/accesslayer/share-link/reserve.d.ts.map +1 -1
  64. package/dist/accesslayer/share-link/revoke.d.ts.map +1 -1
  65. package/dist/accesslayer/share-link/types.d.ts +8 -3
  66. package/dist/accesslayer/share-link/types.d.ts.map +1 -1
  67. package/dist/cache/app-store.caches.d.ts +49 -0
  68. package/dist/cache/app-store.caches.d.ts.map +1 -1
  69. package/dist/config/applyDockerStageConfig.d.ts +2 -0
  70. package/dist/config/applyDockerStageConfig.d.ts.map +1 -0
  71. package/dist/config/environment.d.ts +5 -0
  72. package/dist/config/environment.d.ts.map +1 -1
  73. package/dist/config/serverlessFunctionEnv.test.d.ts +2 -0
  74. package/dist/config/serverlessFunctionEnv.test.d.ts.map +1 -0
  75. package/dist/config/stageConfig.d.ts +112 -0
  76. package/dist/config/stageConfig.d.ts.map +1 -0
  77. package/dist/config/stageConfig.test.d.ts +2 -0
  78. package/dist/config/stageConfig.test.d.ts.map +1 -0
  79. package/dist/didkit_wasm_bg.wasm +0 -0
  80. package/dist/docker-entry.d.ts +1 -1
  81. package/dist/docker-entry.d.ts.map +1 -1
  82. package/dist/helpers/app-manifest.helpers.d.ts +5 -0
  83. package/dist/helpers/app-manifest.helpers.d.ts.map +1 -0
  84. package/dist/helpers/claim-hooks.helpers.d.ts +2 -1
  85. package/dist/helpers/claim-hooks.helpers.d.ts.map +1 -1
  86. package/dist/helpers/consent-referral.helpers.d.ts +2 -0
  87. package/dist/helpers/consent-referral.helpers.d.ts.map +1 -0
  88. package/dist/helpers/consent-scopes.helpers.d.ts +7 -0
  89. package/dist/helpers/consent-scopes.helpers.d.ts.map +1 -0
  90. package/dist/helpers/contract-event-maintenance.helpers.d.ts +3 -0
  91. package/dist/helpers/contract-event-maintenance.helpers.d.ts.map +1 -0
  92. package/dist/helpers/contract-events.helpers.d.ts +25 -0
  93. package/dist/helpers/contract-events.helpers.d.ts.map +1 -0
  94. package/dist/helpers/contract-requests.helpers.d.ts +19 -0
  95. package/dist/helpers/contract-requests.helpers.d.ts.map +1 -0
  96. package/dist/helpers/finalize-inbox.helpers.d.ts +3 -3
  97. package/dist/helpers/finalize-inbox.helpers.d.ts.map +1 -1
  98. package/dist/helpers/inbox.helpers.d.ts.map +1 -1
  99. package/dist/helpers/inline-template.helpers.d.ts +45 -0
  100. package/dist/helpers/inline-template.helpers.d.ts.map +1 -0
  101. package/dist/helpers/notificationMessages.d.ts +1 -1
  102. package/dist/helpers/notificationMessages.d.ts.map +1 -1
  103. package/dist/helpers/notificationQueue.helpers.d.ts.map +1 -1
  104. package/dist/helpers/notifications.helpers.d.ts +6 -0
  105. package/dist/helpers/notifications.helpers.d.ts.map +1 -1
  106. package/dist/helpers/profile.helpers.d.ts +1 -0
  107. package/dist/helpers/profile.helpers.d.ts.map +1 -1
  108. package/dist/helpers/share-link-attachment-access.d.ts +4 -0
  109. package/dist/helpers/share-link-attachment-access.d.ts.map +1 -0
  110. package/dist/helpers/share-link-attachment.d.ts +18 -0
  111. package/dist/helpers/share-link-attachment.d.ts.map +1 -0
  112. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts +5 -0
  113. package/dist/helpers/share-link-coordinator/attachment-stat.d.ts.map +1 -0
  114. package/dist/helpers/share-link-coordinator/coordinator.d.ts.map +1 -1
  115. package/dist/helpers/share-link-coordinator/recovery-runner.d.ts.map +1 -1
  116. package/dist/helpers/share-link-maintenance/runner.d.ts +1 -0
  117. package/dist/helpers/share-link-maintenance/runner.d.ts.map +1 -1
  118. package/dist/helpers/share-link-maintenance/runtime.d.ts +2 -0
  119. package/dist/helpers/share-link-maintenance/runtime.d.ts.map +1 -1
  120. package/dist/helpers/share-link-maintenance/telemetry.d.ts.map +1 -1
  121. package/dist/helpers/share-link-maintenance/types.d.ts +4 -1
  122. package/dist/helpers/share-link-maintenance/types.d.ts.map +1 -1
  123. package/dist/helpers/share-link-owner-projection.d.ts.map +1 -1
  124. package/dist/helpers/share-link-policy/production.d.ts +3 -5
  125. package/dist/helpers/share-link-policy/production.d.ts.map +1 -1
  126. package/dist/helpers/share-link-policy/resolver.d.ts +1 -1
  127. package/dist/helpers/share-link-policy/resolver.d.ts.map +1 -1
  128. package/dist/helpers/share-link-policy/types.d.ts +8 -2
  129. package/dist/helpers/share-link-policy/types.d.ts.map +1 -1
  130. package/dist/helpers/signingAuthority.helpers.d.ts.map +1 -1
  131. package/dist/helpers/smartResume.helpers.d.ts +3 -0
  132. package/dist/helpers/smartResume.helpers.d.ts.map +1 -0
  133. package/dist/index.js +2 -2
  134. package/dist/models/AppManifestVersion.d.ts +20 -0
  135. package/dist/models/AppManifestVersion.d.ts.map +1 -0
  136. package/dist/models/AppStoreListing.d.ts +13 -0
  137. package/dist/models/AppStoreListing.d.ts.map +1 -1
  138. package/dist/models/ConsentFlowContract.d.ts +13 -2
  139. package/dist/models/ConsentFlowContract.d.ts.map +1 -1
  140. package/dist/models/ConsentFlowTerms.d.ts.map +1 -1
  141. package/dist/models/ConsentFlowTransaction.d.ts.map +1 -1
  142. package/dist/models/Integration.d.ts +2 -0
  143. package/dist/models/Integration.d.ts.map +1 -1
  144. package/dist/models/ShareLink.d.ts +2 -0
  145. package/dist/models/ShareLink.d.ts.map +1 -1
  146. package/dist/models/index.d.ts +1 -0
  147. package/dist/models/index.d.ts.map +1 -1
  148. package/dist/models/share-link-constraints.d.ts.map +1 -1
  149. package/dist/openapi.d.ts +1 -1
  150. package/dist/openapi.d.ts.map +1 -1
  151. package/dist/publish-metafile.json +1 -0
  152. package/dist/routes/app-store.d.ts +619 -0
  153. package/dist/routes/app-store.d.ts.map +1 -1
  154. package/dist/routes/boosts.d.ts +2 -2
  155. package/dist/routes/boosts.d.ts.map +1 -1
  156. package/dist/routes/contracts.d.ts +146 -3
  157. package/dist/routes/contracts.d.ts.map +1 -1
  158. package/dist/routes/credentials.d.ts +34 -0
  159. package/dist/routes/credentials.d.ts.map +1 -1
  160. package/dist/routes/inbox.d.ts +52 -2
  161. package/dist/routes/inbox.d.ts.map +1 -1
  162. package/dist/routes/index.d.ts.map +1 -1
  163. package/dist/routes/profile-manager.d.ts.map +1 -1
  164. package/dist/routes/profiles.d.ts.map +1 -1
  165. package/dist/routes/public-share-links.d.ts +55 -0
  166. package/dist/routes/public-share-links.d.ts.map +1 -1
  167. package/dist/routes/share-links.d.ts +175 -14
  168. package/dist/routes/share-links.d.ts.map +1 -1
  169. package/dist/routes/test.d.ts +5 -0
  170. package/dist/routes/test.d.ts.map +1 -1
  171. package/dist/routes/workflows.d.ts.map +1 -1
  172. package/dist/types/app-manifest.d.ts +339 -0
  173. package/dist/types/app-manifest.d.ts.map +1 -0
  174. package/dist/types/app-store-listing.d.ts +34 -0
  175. package/dist/types/app-store-listing.d.ts.map +1 -1
  176. package/dist/types/consentflowcontract.d.ts +30 -3
  177. package/dist/types/consentflowcontract.d.ts.map +1 -1
  178. package/dist/types/credential-refresh.d.ts +1 -1
  179. package/esbuildPlugins.cjs +1 -0
  180. package/lambda.ts +49 -195
  181. package/lambdaApp.ts +203 -0
  182. package/package.json +20 -19
  183. package/project.json +8 -1
  184. package/scripts/export-openapi.ts +20 -0
  185. package/scripts/offline-schema-environment.ts +18 -0
  186. package/scripts/verify-python-contracts.ts +72 -0
  187. package/serverless-local.yml +3 -6
  188. package/serverless.function-env.cjs +50 -0
  189. package/serverless.local.yml +0 -1
  190. package/serverless.yml +29 -74
  191. package/shareLinkMaintenanceLambda.ts +13 -44
  192. package/shareLinkMaintenanceLambdaApp.ts +50 -0
  193. package/src/accesslayer/app-manifest-version/create.ts +57 -0
  194. package/src/accesslayer/app-manifest-version/read.ts +129 -0
  195. package/src/accesslayer/app-manifest-version/update.ts +61 -0
  196. package/src/accesslayer/app-store-listing/read.ts +6 -0
  197. package/src/accesslayer/app-store-listing/relationships/create.ts +20 -0
  198. package/src/accesslayer/app-store-listing/relationships/read.ts +24 -1
  199. package/src/accesslayer/app-store-listing/update.ts +15 -0
  200. package/src/accesslayer/boost/relationships/create.ts +8 -2
  201. package/src/accesslayer/consentflowcontract/create.ts +81 -8
  202. package/src/accesslayer/consentflowcontract/read.ts +84 -10
  203. package/src/accesslayer/consentflowcontract/relationships/create.ts +116 -93
  204. package/src/accesslayer/consentflowcontract/relationships/delete.ts +1 -0
  205. package/src/accesslayer/consentflowcontract/relationships/read.ts +184 -108
  206. package/src/accesslayer/consentflowcontract/relationships/recipients.ts +125 -0
  207. package/src/accesslayer/consentflowcontract/relationships/smartResume.ts +125 -0
  208. package/src/accesslayer/consentflowcontract/relationships/update.ts +240 -189
  209. package/src/accesslayer/credential/create.test.ts +2 -115
  210. package/src/accesslayer/credential/create.ts +11 -6
  211. package/src/accesslayer/credential/relationships/create.ts +17 -10
  212. package/src/accesslayer/inbox-credential/relationships/create.ts +70 -48
  213. package/src/accesslayer/inbox-credential/update.ts +4 -2
  214. package/src/accesslayer/share-link/abandon.ts +20 -0
  215. package/src/accesslayer/share-link/attachment.ts +360 -0
  216. package/src/accesslayer/share-link/finalize.ts +55 -7
  217. package/src/accesslayer/share-link/helpers.ts +33 -2
  218. package/src/accesslayer/share-link/recovery.ts +20 -0
  219. package/src/accesslayer/share-link/reserve.ts +85 -6
  220. package/src/accesslayer/share-link/revoke.ts +35 -0
  221. package/src/accesslayer/share-link/types.ts +9 -4
  222. package/src/cache/app-store.caches.ts +35 -0
  223. package/src/config/applyDockerStageConfig.ts +6 -0
  224. package/src/config/environment.test.ts +17 -12
  225. package/src/config/environment.ts +1 -0
  226. package/src/config/serverlessFunctionEnv.test.ts +105 -0
  227. package/src/config/stageConfig.test.ts +25 -0
  228. package/src/config/stageConfig.ts +24 -0
  229. package/src/docker-entry.ts +24 -0
  230. package/src/helpers/app-manifest.helpers.ts +185 -0
  231. package/src/helpers/claim-hooks.helpers.ts +20 -7
  232. package/src/helpers/consent-referral.helpers.ts +6 -0
  233. package/src/helpers/consent-scopes.helpers.ts +58 -0
  234. package/src/helpers/contract-event-maintenance.helpers.ts +43 -0
  235. package/src/helpers/contract-events.helpers.ts +540 -0
  236. package/src/helpers/contract-requests.helpers.ts +211 -0
  237. package/src/helpers/finalize-inbox.helpers.ts +163 -44
  238. package/src/helpers/inbox.helpers.ts +51 -66
  239. package/src/helpers/inline-template.helpers.ts +560 -0
  240. package/src/helpers/notificationMessages.ts +59 -0
  241. package/src/helpers/notificationQueue.helpers.ts +23 -5
  242. package/src/helpers/notifications.helpers.ts +25 -1
  243. package/src/helpers/profile.helpers.ts +6 -0
  244. package/src/helpers/share-link-attachment-access.ts +87 -0
  245. package/src/helpers/share-link-attachment.ts +115 -0
  246. package/src/helpers/share-link-coordinator/attachment-stat.ts +41 -0
  247. package/src/helpers/share-link-coordinator/coordinator.ts +15 -2
  248. package/src/helpers/share-link-coordinator/recovery-runner.ts +15 -0
  249. package/src/helpers/share-link-maintenance/runner.ts +28 -0
  250. package/src/helpers/share-link-maintenance/runtime.ts +6 -0
  251. package/src/helpers/share-link-maintenance/telemetry.ts +3 -0
  252. package/src/helpers/share-link-maintenance/types.ts +2 -0
  253. package/src/helpers/share-link-owner-projection.ts +2 -0
  254. package/src/helpers/share-link-policy/production.ts +37 -17
  255. package/src/helpers/share-link-policy/resolver.ts +16 -11
  256. package/src/helpers/share-link-policy/types.ts +15 -7
  257. package/src/helpers/signingAuthority.helpers.ts +23 -14
  258. package/src/helpers/smartResume.helpers.ts +123 -0
  259. package/src/models/AppManifestVersion.ts +43 -0
  260. package/src/models/AppStoreListing.ts +32 -0
  261. package/src/models/ConsentFlowContract.ts +34 -3
  262. package/src/models/ConsentFlowTerms.ts +10 -0
  263. package/src/models/ConsentFlowTransaction.ts +3 -0
  264. package/src/models/Integration.ts +2 -0
  265. package/src/models/ShareLink.ts +4 -0
  266. package/src/models/index.ts +23 -0
  267. package/src/models/share-link-constraints.ts +5 -0
  268. package/src/openapi.ts +44 -8
  269. package/src/routes/app-store.ts +1054 -27
  270. package/src/routes/boosts.ts +12 -14
  271. package/src/routes/contracts.ts +382 -262
  272. package/src/routes/inbox.ts +4 -2
  273. package/src/routes/index.ts +4 -1
  274. package/src/routes/profile-manager.ts +19 -0
  275. package/src/routes/profiles.ts +12 -0
  276. package/src/routes/public-share-links.ts +169 -0
  277. package/src/routes/share-links.ts +69 -0
  278. package/src/routes/workflows.ts +3 -2
  279. package/src/types/app-manifest.ts +28 -0
  280. package/src/types/app-store-listing.ts +13 -0
  281. package/src/types/consentflowcontract.ts +25 -4
  282. package/test/app-manifest.helpers.spec.ts +170 -0
  283. package/test/app-manifest.routes.spec.ts +299 -0
  284. package/test/app-store-listing-updates.spec.ts +236 -0
  285. package/test/app-store-listing.spec.ts +65 -3
  286. package/test/consent-data-boundaries.spec.ts +352 -0
  287. package/test/consent-recipients.spec.ts +513 -0
  288. package/test/consent-scopes.helpers.spec.ts +67 -0
  289. package/test/consentflow.spec.ts +63 -2
  290. package/test/contract-requests.spec.ts +1588 -0
  291. package/test/credential-storage.neo4j.spec.ts +122 -0
  292. package/test/guardian-gated-routes.spec.ts +36 -0
  293. package/test/inbox-security.e2e.ts +26 -10
  294. package/test/inbox.spec.ts +184 -8
  295. package/test/inline-template.helpers.spec.ts +49 -0
  296. package/test/managed-recipient-inbox.spec.ts +24 -2
  297. package/test/openapi.spec.ts +14 -73
  298. package/test/profiles.spec.ts +30 -0
  299. package/test/share-link-attachment.neo4j.spec.ts +262 -0
  300. package/test/share-link-attachment.unit.spec.ts +244 -0
  301. package/test/share-link-lifecycle.neo4j.spec.ts +145 -2
  302. package/test/share-link-maintenance.unit.spec.ts +32 -5
  303. package/test/share-link-owner-route.unit.spec.ts +70 -2
  304. package/test/share-link-policy.unit.spec.ts +18 -4
  305. package/test/share-link-production-policy.unit.spec.ts +58 -9
  306. package/test/share-link-public-route.unit.spec.ts +279 -0
  307. package/test/share-link-service-policy.neo4j.spec.ts +403 -0
  308. package/test/signing-authority-encryption.spec.ts +75 -0
  309. package/test/smart-resume-consent.spec.ts +233 -0
  310. package/test/smart-resume-upload.spec.ts +65 -0
  311. package/tsconfig.build.json +1 -1
  312. package/tsconfig.json +1 -0
  313. package/vitest.config.ts +13 -0
  314. package/vitest.inbox-e2e.config.ts +2 -0
  315. package/workerLambda.test.ts +100 -0
@@ -7,10 +7,12 @@ import {
7
7
  DEFAULT_SHARE_LINK_POLICY,
8
8
  type ShareLinkPolicySnapshot,
9
9
  } from '@helpers/share-link-policy/types';
10
+ import { mergeShareLinkPolicyConservatively } from '@helpers/share-link-policy/resolver';
10
11
 
11
12
  import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
12
13
  import type { ShareLinkRecord } from '../../models/ShareLink';
13
14
  import { failShareLink } from './errors';
15
+ import { pinShareAttachment, queueShareAttachmentCleanup } from './attachment';
14
16
  import {
15
17
  deleteReservation,
16
18
  enqueueCleanupJob,
@@ -21,6 +23,7 @@ import {
21
23
  readOperation,
22
24
  readReservation,
23
25
  readShareById,
26
+ toShareLinkOperationPolicy,
24
27
  toShareLinkOperationRecord,
25
28
  toShareLinkRecord,
26
29
  toShareLinkReservationRecord,
@@ -60,6 +63,8 @@ const parseRecordedResult = (
60
63
  };
61
64
 
62
65
  type ReservationProps = {
66
+ attachmentId?: string | null;
67
+ attachmentChunkCount?: number | null;
63
68
  shareId: string;
64
69
  namespace: string;
65
70
  ownerProfileId: string;
@@ -96,6 +101,20 @@ const writeReservation = async (
96
101
  tx: ShareLinkTransaction,
97
102
  props: ReservationProps
98
103
  ): Promise<void> => {
104
+ if (props.contentVersion !== null && props.attachmentId && props.attachmentChunkCount) {
105
+ await pinShareAttachment(
106
+ tx,
107
+ {
108
+ namespace: props.namespace,
109
+ ownerProfileId: props.ownerProfileId,
110
+ shareId: props.shareId,
111
+ contentVersion: props.contentVersion,
112
+ attachmentId: props.attachmentId,
113
+ chunkCount: props.attachmentChunkCount,
114
+ },
115
+ props.requestHash
116
+ );
117
+ }
99
118
  await tx.run('CREATE (r:ShareLinkReservation) SET r = $props', { props });
100
119
  };
101
120
 
@@ -120,9 +139,30 @@ const bumpShareGeneration = async (
120
139
  const supersedeReservation = async (
121
140
  tx: ShareLinkTransaction,
122
141
  reservationProps: Record<string, unknown>,
123
- now: string
142
+ now: string,
143
+ retainAttachment = false
124
144
  ): Promise<ShareLinkReservationRecord> => {
125
145
  const reservation = toShareLinkReservationRecord(reservationProps);
146
+ if (
147
+ !retainAttachment &&
148
+ reservation.attachmentId &&
149
+ reservation.attachmentChunkCount &&
150
+ reservation.contentVersion
151
+ ) {
152
+ await queueShareAttachmentCleanup(
153
+ tx,
154
+ {
155
+ namespace: reservation.namespace,
156
+ ownerProfileId: reservation.ownerProfileId,
157
+ shareId: reservation.shareId,
158
+ contentVersion: reservation.contentVersion,
159
+ attachmentId: reservation.attachmentId,
160
+ chunkCount: reservation.attachmentChunkCount,
161
+ },
162
+ 'abandoned',
163
+ now
164
+ );
165
+ }
126
166
 
127
167
  if (reservation.objectRef) {
128
168
  await enqueueCleanupJob(tx, {
@@ -214,6 +254,7 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
214
254
  };
215
255
  }
216
256
 
257
+ let retryPolicy = toShareLinkOperationPolicy(existingOperation);
217
258
  const existingReservationProps = await readReservation(tx, input.shareId);
218
259
 
219
260
  if (existingReservationProps) {
@@ -230,6 +271,10 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
230
271
  );
231
272
  }
232
273
 
274
+ retryPolicy = retryPolicy
275
+ ? mergeShareLinkPolicyConservatively(retryPolicy, existingReservation.policy)
276
+ : existingReservation.policy;
277
+
233
278
  if (isLeaseActive(existingReservation.leaseExpiresAt, now)) {
234
279
  if (!share) {
235
280
  failShareLink('CONFLICT', 'in-flight reservation exists without a share');
@@ -245,7 +290,7 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
245
290
 
246
291
  // Same logical operation, expired lease: retain its expiry.
247
292
  supersededExpiresAt = { value: existingReservation.expiresAt };
248
- await supersedeReservation(tx, existingReservationProps, nowIso);
293
+ await supersedeReservation(tx, existingReservationProps, nowIso, true);
249
294
  }
250
295
 
251
296
  if (!share) {
@@ -255,6 +300,10 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
255
300
  failShareLink('CONFLICT', 'cannot re-drive create for a stopped share');
256
301
  }
257
302
 
303
+ const policy = mergeShareLinkPolicyConservatively(
304
+ retryPolicy ?? DEFAULT_SHARE_LINK_POLICY,
305
+ input.policy ?? retryPolicy ?? DEFAULT_SHARE_LINK_POLICY
306
+ );
258
307
  return createReservationForShare(tx, {
259
308
  share,
260
309
  opKey,
@@ -264,14 +313,17 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
264
313
  contentVersion: 1,
265
314
  baseContentVersion: share.contentVersion,
266
315
  content: input.content,
316
+ attachment: input.attachment,
267
317
  selectedCount: input.selectedCount,
268
318
  title: input.title,
269
319
  note: input.note ?? null,
270
320
  expiresAt:
271
321
  supersededExpiresAt !== null ? supersededExpiresAt.value : share.expiresAt,
272
322
  passcodeHash: share.passcodeHash ?? input.passcodeHash ?? null,
273
- notifyOnView: share.notifyOnView ?? input.notifyOnView ?? false,
274
- policy: input.policy ?? DEFAULT_SHARE_LINK_POLICY,
323
+ notifyOnView:
324
+ (share.notifyOnView ?? input.notifyOnView ?? false) &&
325
+ policy.viewCountingEnabled,
326
+ policy,
275
327
  leaseOwner: input.leaseOwner,
276
328
  leaseExpiresAt,
277
329
  nowIso,
@@ -335,6 +387,7 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
335
387
  operationId,
336
388
  shareId: input.shareId,
337
389
  requestHash: input.requestHash,
390
+ policy,
338
391
  now: nowIso,
339
392
  pruneAfter,
340
393
  });
@@ -349,6 +402,8 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
349
402
  objectRef,
350
403
  contentVersion: 1,
351
404
  baseVersion: 1,
405
+ attachmentId: input.attachment?.id ?? null,
406
+ attachmentChunkCount: input.attachment?.chunkCount ?? null,
352
407
  baseContentVersion: 1,
353
408
  contentHash: input.content.contentHash,
354
409
  contentBytes: input.content.contentBytes,
@@ -388,6 +443,7 @@ export const reserveCreate = async (input: ReserveCreateInput): Promise<ReserveS
388
443
  };
389
444
 
390
445
  type CreateReservationForShareInput = {
446
+ attachment?: import('@learncard/types').ShareLinkAttachment;
391
447
  share: ShareLinkRecord;
392
448
  opKey: ShareLinkOperationKey;
393
449
  operationId: string;
@@ -420,6 +476,7 @@ const createReservationForShare = async (
420
476
  operationId: input.operationId,
421
477
  shareId: input.share.id,
422
478
  requestHash: input.requestHash,
479
+ policy: input.policy,
423
480
  now: input.nowIso,
424
481
  pruneAfter: input.pruneAfter,
425
482
  });
@@ -434,6 +491,14 @@ const createReservationForShare = async (
434
491
  operationId: input.operationId,
435
492
  objectRef: input.objectRef,
436
493
  contentVersion: input.contentVersion,
494
+ attachmentId:
495
+ input.contentVersion !== null
496
+ ? (input.attachment?.id ?? null)
497
+ : (input.share.attachmentId ?? null),
498
+ attachmentChunkCount:
499
+ input.contentVersion !== null
500
+ ? (input.attachment?.chunkCount ?? null)
501
+ : (input.share.attachmentChunkCount ?? null),
437
502
  baseVersion: input.share.version,
438
503
  baseContentVersion: input.baseContentVersion,
439
504
  contentHash: input.content ? input.content.contentHash : null,
@@ -539,6 +604,7 @@ export const reserveReplacement = async (
539
604
  };
540
605
  }
541
606
 
607
+ let retryPolicy = toShareLinkOperationPolicy(existingOperation);
542
608
  const existingReservationProps = await readReservation(tx, input.shareId);
543
609
 
544
610
  if (existingReservationProps) {
@@ -555,6 +621,10 @@ export const reserveReplacement = async (
555
621
  );
556
622
  }
557
623
 
624
+ retryPolicy = retryPolicy
625
+ ? mergeShareLinkPolicyConservatively(retryPolicy, existingReservation.policy)
626
+ : existingReservation.policy;
627
+
558
628
  if (isLeaseActive(existingReservation.leaseExpiresAt, now)) {
559
629
  return {
560
630
  outcome: 'reserved',
@@ -564,12 +634,20 @@ export const reserveReplacement = async (
564
634
  };
565
635
  }
566
636
 
567
- await supersedeReservation(tx, existingReservationProps, nowIso);
637
+ await supersedeReservation(tx, existingReservationProps, nowIso, true);
568
638
  }
569
639
 
570
640
  return reserveReplacementForLockedShare(tx, {
571
641
  share,
572
- input,
642
+ input: {
643
+ ...input,
644
+ // The ceiling belongs to the logical request, not its lease.
645
+ // Missing legacy state fails closed after reservation cleanup.
646
+ policy: mergeShareLinkPolicyConservatively(
647
+ retryPolicy ?? DEFAULT_SHARE_LINK_POLICY,
648
+ input.policy ?? retryPolicy ?? DEFAULT_SHARE_LINK_POLICY
649
+ ),
650
+ },
573
651
  opKey,
574
652
  leaseExpiresAt,
575
653
  nowIso,
@@ -659,6 +737,7 @@ const reserveReplacementForLockedShare = async (
659
737
  contentVersion: input.content ? input.content.contentVersion : null,
660
738
  baseContentVersion: share.contentVersion,
661
739
  content: input.content,
740
+ attachment: input.content?.attachment,
662
741
  selectedCount: input.content ? input.content.selectedCount : share.selectedCount,
663
742
  title: input.title ?? share.title,
664
743
  note: input.note !== undefined ? input.note : share.note,
@@ -2,6 +2,7 @@ import { SHARE_LINK_OPERATION_RETENTION_MS } from '@helpers/share-link-lifecycle
2
2
 
3
3
  import { ensureShareLinkConstraints } from '../../models/share-link-constraints';
4
4
  import { failShareLink } from './errors';
5
+ import { queueShareAttachmentCleanup } from './attachment';
5
6
  import {
6
7
  deleteReservation,
7
8
  enqueueCleanupJob,
@@ -105,6 +106,25 @@ export const revokeShareLink = async (
105
106
 
106
107
  if (reservationProps) {
107
108
  const reservation = toShareLinkReservationRecord(reservationProps);
109
+ if (
110
+ reservation.attachmentId &&
111
+ reservation.attachmentChunkCount &&
112
+ reservation.contentVersion
113
+ ) {
114
+ await queueShareAttachmentCleanup(
115
+ tx,
116
+ {
117
+ namespace: reservation.namespace,
118
+ ownerProfileId: reservation.ownerProfileId,
119
+ shareId: reservation.shareId,
120
+ contentVersion: reservation.contentVersion,
121
+ attachmentId: reservation.attachmentId,
122
+ chunkCount: reservation.attachmentChunkCount,
123
+ },
124
+ 'abandoned',
125
+ nowIso
126
+ );
127
+ }
108
128
 
109
129
  if (reservation.objectRef) {
110
130
  await enqueueCleanupJob(tx, {
@@ -131,6 +151,21 @@ export const revokeShareLink = async (
131
151
  }
132
152
 
133
153
  if (share.activeObjectRef) {
154
+ if (share.attachmentId && share.attachmentChunkCount) {
155
+ await queueShareAttachmentCleanup(
156
+ tx,
157
+ {
158
+ namespace: share.namespace,
159
+ ownerProfileId: share.ownerProfileId,
160
+ shareId: share.id,
161
+ contentVersion: share.contentVersion,
162
+ attachmentId: share.attachmentId,
163
+ chunkCount: share.attachmentChunkCount,
164
+ },
165
+ 'stopped',
166
+ nowIso
167
+ );
168
+ }
134
169
  await enqueueCleanupJob(tx, {
135
170
  objectRef: share.activeObjectRef,
136
171
  operationId: share.activeObjectOperationId!,
@@ -3,10 +3,10 @@ import type {
3
3
  ShareContentCleanupStatus,
4
4
  ShareLinkOperationKind,
5
5
  } from '@helpers/share-link-lifecycle';
6
- import type { ShareLinkPolicySnapshot } from '@helpers/share-link-policy';
6
+ import type { CurrentShareLinkPolicy, ShareLinkPolicySnapshot } from '@helpers/share-link-policy';
7
7
 
8
8
  import type { ShareContentState, ShareLinkRecord, ShareLinkStatus } from '../../models/ShareLink';
9
- import type { ShareLinkTransactionOptions } from './transaction';
9
+ import type { ShareLinkTransaction, ShareLinkTransactionOptions } from './transaction';
10
10
 
11
11
  /** Namespace + owner + share id binding used by every lifecycle operation. */
12
12
  export type ShareLinkKey = {
@@ -55,6 +55,8 @@ export type ShareLinkOperationRecord = {
55
55
  * a bounded lease so a restart can either resume or supersede it.
56
56
  */
57
57
  export type ShareLinkReservationRecord = {
58
+ attachmentId?: string | null;
59
+ attachmentChunkCount?: number | null;
58
60
  shareId: string;
59
61
  namespace: string;
60
62
  ownerProfileId: string;
@@ -122,6 +124,7 @@ export type ShareContentBinding = {
122
124
  };
123
125
 
124
126
  export type ReserveCreateInput = {
127
+ attachment?: import('@learncard/types').ShareLinkAttachment;
125
128
  namespace: string;
126
129
  ownerProfileId: string;
127
130
  clientRequestId: string;
@@ -143,6 +146,7 @@ export type ReserveCreateInput = {
143
146
  };
144
147
 
145
148
  export type ReserveReplacementContent = ShareContentBinding & {
149
+ attachment?: import('@learncard/types').ShareLinkAttachment;
146
150
  /** Must equal the current visible content version + 1. */
147
151
  contentVersion: number;
148
152
  selectedCount: number;
@@ -194,6 +198,7 @@ export type ReserveShareLinkResult =
194
198
  };
195
199
 
196
200
  export type FinalizeReservationInput = {
201
+ verifiedAttachmentId?: string;
197
202
  namespace: string;
198
203
  ownerProfileId: string;
199
204
  shareId: string;
@@ -210,10 +215,10 @@ export type FinalizeReservationInput = {
210
215
  verifiedContentHash?: string;
211
216
  /** Production-only graph-local policy recheck under the share write lock. */
212
217
  resolveCurrentPolicy?: (
213
- tx: import('./transaction').ShareLinkTransaction,
218
+ tx: ShareLinkTransaction,
214
219
  ownerProfileId: string,
215
220
  now: Date
216
- ) => Promise<import('@helpers/share-link-policy/types').ShareLinkPolicySnapshot>;
221
+ ) => Promise<CurrentShareLinkPolicy>;
217
222
  now?: Date;
218
223
  /** Maintenance-only: bounded transaction timeout for this unit. */
219
224
  transactionTimeoutMs?: number;
@@ -3,6 +3,7 @@ import { readAppStoreListingById } from '@accesslayer/app-store-listing/read';
3
3
  import {
4
4
  getIntegrationForListing,
5
5
  getBoostForListingByTemplateAlias,
6
+ getConsentContractForListingByScopeHash,
6
7
  } from '@accesslayer/app-store-listing/relationships/read';
7
8
  import { getOwnerProfileForIntegration } from '@accesslayer/integration/relationships/read';
8
9
  import {
@@ -28,6 +29,7 @@ const LISTING_CACHE_TTL_MS = 30_000;
28
29
  const INTEGRATION_CACHE_TTL_MS = 30_000;
29
30
  const OWNER_CACHE_TTL_MS = 30_000;
30
31
  const SA_CACHE_TTL_MS = 30_000;
32
+ const CONTRACT_CACHE_TTL_MS = 30_000;
31
33
 
32
34
  const boostByAliasCache =
33
35
  getLRUCache<Awaited<ReturnType<typeof getBoostForListingByTemplateAlias>>>(200);
@@ -40,6 +42,8 @@ const listingSaCache =
40
42
  getLRUCache<Awaited<ReturnType<typeof getPrimarySigningAuthorityForListing>>>(200);
41
43
  const integrationSaCache =
42
44
  getLRUCache<Awaited<ReturnType<typeof getPrimarySigningAuthorityForIntegration>>>(200);
45
+ const consentContractByScopeCache =
46
+ getLRUCache<Awaited<ReturnType<typeof getConsentContractForListingByScopeHash>>>(200);
43
47
 
44
48
  export const getBoostForListingCached = async (
45
49
  listingId: string,
@@ -54,6 +58,33 @@ export const getBoostForListingCached = async (
54
58
  return value;
55
59
  };
56
60
 
61
+ export const invalidateBoostForListingCache = (
62
+ listingId: string,
63
+ templateAlias: string,
64
+ domain: string
65
+ ) => {
66
+ boostByAliasCache.invalidate(`${listingId}|${templateAlias}|${domain}`);
67
+ };
68
+
69
+ export const getConsentContractForListingByScopeHashCached = async (
70
+ listingId: string,
71
+ scopeHash: string
72
+ ) => {
73
+ const key = `${listingId}|${scopeHash}`;
74
+ const hit = consentContractByScopeCache.get(key);
75
+ if (hit !== undefined) return hit;
76
+ const value = await getConsentContractForListingByScopeHash(listingId, scopeHash);
77
+ consentContractByScopeCache.add(key, value, CONTRACT_CACHE_TTL_MS);
78
+ return value;
79
+ };
80
+
81
+ export const invalidateConsentContractForListingByScopeHashCache = (
82
+ listingId: string,
83
+ scopeHash: string
84
+ ) => {
85
+ consentContractByScopeCache.invalidate(`${listingId}|${scopeHash}`);
86
+ };
87
+
57
88
  export const readAppStoreListingByIdCached = async (listingId: string) => {
58
89
  const hit = listingByIdCache.get(listingId);
59
90
  if (hit !== undefined) return hit;
@@ -87,6 +118,10 @@ export const getPrimarySigningAuthorityForListingCached = async (listing: AppSto
87
118
  return value;
88
119
  };
89
120
 
121
+ export const invalidateListingSigningAuthorityCache = (listingId: string) => {
122
+ listingSaCache.invalidate(listingId);
123
+ };
124
+
90
125
  export const getPrimarySigningAuthorityForIntegrationCached = async (integrationId: string) => {
91
126
  const hit = integrationSaCache.get(integrationId);
92
127
  if (hit !== undefined) return hit;
@@ -0,0 +1,6 @@
1
+ import { applyBrainServiceStageConfig } from './stageConfig';
2
+
3
+ // Side-effect import: runs before @environment is evaluated so Docker/local processes
4
+ // pick up checked-in per-stage config. Keyed on CONFIG_STAGE (Lambda uses LAMBDA_STAGE);
5
+ // explicit non-empty env values from the developer's .env still win.
6
+ applyBrainServiceStageConfig(process.env.CONFIG_STAGE);
@@ -83,18 +83,23 @@ describe('credential refresh configuration', () => {
83
83
  }
84
84
  );
85
85
 
86
- it.each(['serverless.yml', 'serverless-local.yml'])(
87
- 'passes all refresh settings into functions in %s',
88
- manifestName => {
89
- const manifestPath = fileURLToPath(new URL(`../../${manifestName}`, import.meta.url));
90
- const manifest = parse(readFileSync(manifestPath, 'utf8'));
91
-
92
- expect(manifest.provider.environment).toMatchObject({
93
- CREDENTIAL_REFRESH_ENABLED: '${env:CREDENTIAL_REFRESH_ENABLED, "false"}',
94
- CREDENTIAL_REFRESH_DIGEST_SECRET: '${env:CREDENTIAL_REFRESH_DIGEST_SECRET, ""}',
95
- CREDENTIAL_REFRESH_NOTIFICATION_WINDOW_HOURS:
96
- '${env:CREDENTIAL_REFRESH_NOTIFICATION_WINDOW_HOURS, "24"}',
97
- });
86
+ it('resolves the deployed function environment through the shared provider file', () => {
87
+ const manifestPath = fileURLToPath(new URL('../../serverless.yml', import.meta.url));
88
+ const manifest = parse(readFileSync(manifestPath, 'utf8'));
89
+
90
+ expect(manifest.provider.environment).toBe(
91
+ '${file(./serverless.function-env.cjs):provider}'
92
+ );
93
+ });
94
+
95
+ it.each(['config.dev.json', 'config.production.json'])(
96
+ 'delivers non-secret refresh settings through checked-in stage config in %s',
97
+ stageFile => {
98
+ const configPath = fileURLToPath(new URL(`../../config/${stageFile}`, import.meta.url));
99
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
100
+
101
+ expect(config.CREDENTIAL_REFRESH_ENABLED).toBeDefined();
102
+ expect(config.CREDENTIAL_REFRESH_NOTIFICATION_WINDOW_HOURS).toBeDefined();
98
103
  }
99
104
  );
100
105
  });
@@ -27,6 +27,7 @@ export const brainServiceEnvironmentShape = {
27
27
  CLIENT_APP_DOMAIN_NAME: optionalEnvironmentString,
28
28
  CLIENT_APP_PORT: optionalEnvironmentPort,
29
29
  LOGIN_PROVIDER_DID: optionalEnvironmentString,
30
+ SIGNING_AUTHORITY_ENDPOINT: optionalEnvironmentUrl,
30
31
  APP_STORE_ADMIN_PROFILE_IDS: optionalEnvironmentString,
31
32
  SKILL_FRAMEWORK_ADMIN_PROFILE_IDS: optionalEnvironmentString,
32
33
  SKILL_FRAMEWORK_SEED_OWNER_PROFILE_ID: optionalEnvironmentString,
@@ -0,0 +1,105 @@
1
+ import { createRequire } from 'node:module';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const functions: {
6
+ provider: (input?: {
7
+ options?: Record<string, unknown>;
8
+ env?: NodeJS.ProcessEnv;
9
+ }) => Record<string, unknown>;
10
+ } = require('../../serverless.function-env.cjs');
11
+
12
+ const INFRA_KEYS = {
13
+ LAMBDA_STAGE: 'dev',
14
+ PORT: '3000',
15
+ REDIS_HOST: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Address'] },
16
+ REDIS_PORT: { 'Fn::GetAtt': ['ElasticCacheCluster', 'RedisEndpoint.Port'] },
17
+ NOTIFICATIONS_QUEUE_URL: '${construct:notifications-queue.queueUrl}',
18
+ INBOX_QUEUE_URL: '${construct:inbox-queue.queueUrl}',
19
+ };
20
+
21
+ beforeEach(() => {
22
+ vi.stubEnv('RUNTIME_SECRETS_ID', '');
23
+ vi.stubEnv('GIT_SHA', '');
24
+ vi.stubEnv('SEED', 'seed');
25
+ vi.stubEnv('NEO4J_PASSWORD', 'neo4j-password');
26
+ vi.stubEnv('SHARE_LINK_REQUEST_HASH_SECRET', 'hash-secret');
27
+ vi.stubEnv('SENTRY_AUTH_TOKEN', 'build-token');
28
+ vi.stubEnv('TRUSTED_ISSUERS_WHITELIST', 'did:web:issuer');
29
+ });
30
+ afterEach(() => vi.unstubAllEnvs());
31
+
32
+ describe('brain-service serverless provider environment', () => {
33
+ it('always emits Redis and queue infrastructure as unresolved references', () => {
34
+ expect(functions.provider({ options: { stage: 'dev' } })).toMatchObject(INFRA_KEYS);
35
+ });
36
+
37
+ it('derives LAMBDA_STAGE and PORT from the CLI options, never a stage passthrough', () => {
38
+ const result = functions.provider({ options: { stage: 'production', httpPort: 4000 } });
39
+ expect(result.LAMBDA_STAGE).toBe('production');
40
+ expect(result.PORT).toBe('4000');
41
+ });
42
+
43
+ it('defaults LAMBDA_STAGE and PORT when no CLI options are supplied', () => {
44
+ const result = functions.provider();
45
+ expect(result.LAMBDA_STAGE).toBe('dev');
46
+ expect(result.PORT).toBe('3000');
47
+ });
48
+
49
+ it('emits GIT_SHA only when it is set to a non-empty value', () => {
50
+ expect(functions.provider()).not.toHaveProperty('GIT_SHA');
51
+ vi.stubEnv('GIT_SHA', 'deadbeef');
52
+ expect(functions.provider()).toMatchObject({ GIT_SHA: 'deadbeef' });
53
+ });
54
+
55
+ it('omits every fallback credential and the pointer stays set in bundle mode', () => {
56
+ vi.stubEnv('RUNTIME_SECRETS_ID', 'brain-service/dev/runtime-secrets');
57
+ const result = functions.provider({ options: { stage: 'dev' } });
58
+ expect(result).toMatchObject({
59
+ ...INFRA_KEYS,
60
+ RUNTIME_SECRETS_ID: 'brain-service/dev/runtime-secrets',
61
+ });
62
+ expect(result).not.toHaveProperty('SEED');
63
+ expect(result).not.toHaveProperty('NEO4J_PASSWORD');
64
+ expect(result).not.toHaveProperty('SHARE_LINK_REQUEST_HASH_SECRET');
65
+ });
66
+
67
+ it('retains only the approved fallback credentials when no bundle is configured', () => {
68
+ const result = functions.provider();
69
+ expect(result.SEED).toBe('seed');
70
+ expect(result.NEO4J_PASSWORD).toBe('neo4j-password');
71
+ expect(result.SHARE_LINK_REQUEST_HASH_SECRET).toBe('hash-secret');
72
+ expect(result.RUNTIME_SECRETS_ID).toBeUndefined();
73
+ });
74
+
75
+ it('never forwards Sentry build credentials or non-secret allowlists as fallbacks', () => {
76
+ const result = functions.provider();
77
+ expect(result).not.toHaveProperty('SENTRY_AUTH_TOKEN');
78
+ expect(result).not.toHaveProperty('SENTRY_ORG');
79
+ expect(result).not.toHaveProperty('SENTRY_PROJECT');
80
+ expect(result).not.toHaveProperty('TRUSTED_ISSUERS_WHITELIST');
81
+ expect(result).not.toHaveProperty('TRUSTED_BRAIN_SERVICES');
82
+ expect(result).not.toHaveProperty('BRAIN_SERVICE_REGISTRY_URL');
83
+ expect(result).not.toHaveProperty('DCC_KNOWN_REGISTRIES_URL');
84
+ });
85
+
86
+ it('resolves the provider through Serverless v3, preserving nested references in both modes', async () => {
87
+ const source = require('serverless/lib/configuration/variables/sources/file');
88
+ for (const bundle of ['', 'bundle']) {
89
+ vi.stubEnv('RUNTIME_SECRETS_ID', bundle);
90
+ const result = await source.resolve({
91
+ serviceDir: new URL('../../', import.meta.url).pathname,
92
+ params: [new URL('../../serverless.function-env.cjs', import.meta.url).pathname],
93
+ address: 'provider',
94
+ options: { stage: 'dev' },
95
+ resolveConfigurationProperty: vi.fn(),
96
+ resolveVariable: vi.fn(),
97
+ });
98
+ expect(result.value).toEqual(functions.provider({ options: { stage: 'dev' } }));
99
+ expect(result.value.NOTIFICATIONS_QUEUE_URL).toBe(
100
+ '${construct:notifications-queue.queueUrl}'
101
+ );
102
+ expect(result.value.INBOX_QUEUE_URL).toBe('${construct:inbox-queue.queueUrl}');
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,25 @@
1
+ import { afterEach, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('dotenv/config', () => {
4
+ vi.stubEnv('SENTRY_ENV', 'self-hosted');
5
+ return {};
6
+ });
7
+
8
+ afterEach(() => vi.unstubAllEnvs());
9
+
10
+ it('loads local env before applying statically imported stage defaults', async () => {
11
+ const { applyBrainServiceStageConfig, stages } = await import('./stageConfig');
12
+ for (const key of Object.keys(stages.dev)) {
13
+ if (key !== 'SENTRY_ENV') vi.stubEnv(key, '');
14
+ }
15
+ applyBrainServiceStageConfig('dev');
16
+ expect(process.env.SENTRY_ENV).toBe('self-hosted');
17
+ expect(process.env.DOMAIN_NAME).toBe(stages.dev.DOMAIN_NAME);
18
+ });
19
+
20
+ it('does not select a deployed stage for an unknown local stage', async () => {
21
+ const { applyBrainServiceStageConfig } = await import('./stageConfig');
22
+ vi.stubEnv('DOMAIN_NAME', '');
23
+ applyBrainServiceStageConfig('unregistered-self-hosted-stage');
24
+ expect(process.env.DOMAIN_NAME).toBe('');
25
+ });
@@ -0,0 +1,24 @@
1
+ import 'dotenv/config';
2
+ import { applyStageConfig } from '@learncard/service-config';
3
+
4
+ import baseConfig from '../../config/config.json';
5
+ import devConfig from '../../config/config.dev.json';
6
+ import productionConfig from '../../config/config.production.json';
7
+ import scoutsDevConfig from '../../config/config.scouts.dev.json';
8
+ import scoutsProductionConfig from '../../config/config.scouts.production.json';
9
+
10
+ export const base: Record<string, string> = baseConfig;
11
+
12
+ export const stages = {
13
+ dev: devConfig,
14
+ production: productionConfig,
15
+ // ScoutPass deploys the same services and stage names; CONFIG_TENANT=scouts selects these.
16
+ 'scouts.dev': scoutsDevConfig,
17
+ 'scouts.production': scoutsProductionConfig,
18
+ } satisfies Record<string, Record<string, string>>;
19
+
20
+ // Must run before any module that reads configuration on import (e.g. @environment):
21
+ // merges base + the selected stage into process.env; explicit non-empty env still wins.
22
+ export const applyBrainServiceStageConfig = (stage: string | undefined): void => {
23
+ applyStageConfig({ base, stages, stage });
24
+ };
@@ -1,3 +1,6 @@
1
+ import './config/applyDockerStageConfig';
2
+
3
+ import { dispatchContractEvents } from '@helpers/contract-events.helpers';
1
4
  import { configureInboxBatchBodyLimit } from '@helpers/inbox-batch-http.helpers';
2
5
  import { environment } from '@environment';
3
6
  import Fastify from 'fastify';
@@ -41,8 +44,27 @@ const server = Fastify({ routerOptions: { maxParamLength: 5000 } });
41
44
  * graph/remote/signing dependency is initialized at all.
42
45
  */
43
46
  let shareLinkMaintenanceScheduler: ShareLinkMaintenanceScheduler | null = null;
47
+ let contractEventTimer: ReturnType<typeof setTimeout> | null = null;
48
+ let contractEventPass: Promise<unknown> | null = null;
49
+ let contractEventsStopped = false;
50
+
51
+ const runContractEventPass = (): void => {
52
+ if (contractEventsStopped) return;
53
+ contractEventPass = dispatchContractEvents()
54
+ .catch(() => {
55
+ console.warn('contract_events: recovery_pending');
56
+ })
57
+ .finally(() => {
58
+ contractEventPass = null;
59
+ if (!contractEventsStopped)
60
+ contractEventTimer = setTimeout(runContractEventPass, 60_000);
61
+ });
62
+ };
44
63
 
45
64
  server.addHook('onClose', async () => {
65
+ contractEventsStopped = true;
66
+ if (contractEventTimer) clearTimeout(contractEventTimer);
67
+ await contractEventPass;
46
68
  await shareLinkMaintenanceScheduler?.stop();
47
69
  });
48
70
  // Register before either OpenAPI or tRPC registers its wildcard route. The hook raises the
@@ -167,6 +189,8 @@ server.register(credentialRefreshFastifyPlugin);
167
189
  console.error('Skill embedding backfill failed', error);
168
190
  }
169
191
 
192
+ contractEventTimer = setTimeout(runContractEventPass, 60_000);
193
+
170
194
  // Explicit startup after readiness. Disabled/invalid config stays inert.
171
195
  const maintenanceRuntime = createShareLinkMaintenanceRuntime();
172
196