@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
@@ -2,10 +2,20 @@ import { TRPCError } from '@trpc/server';
2
2
  import { v4 as uuid } from 'uuid';
3
3
  import { z } from 'zod';
4
4
  import {
5
+ PendingListingUpdateValidator,
6
+ AppManifestDiffValidator,
7
+ AppManifestValidator,
8
+ AppManifestVersionValidator,
5
9
  AppEventValidator,
6
10
  LCNNotificationTypeEnumValidator,
7
11
  SendNotificationEventValidator,
8
12
  } from '@learncard/types';
13
+ import { mergeCapturedManifests } from '@learncard/partner-connect-core';
14
+ import type {
15
+ ConsentRequest,
16
+ InlineCredentialTemplate,
17
+ NormalizedConsentScopes,
18
+ } from '@learncard/partner-connect-core';
9
19
  import type { UnsignedVC } from '@learncard/types';
10
20
  import { isVC2Format, checkAppInstallEligibility, calculateAgeFromDob } from '@learncard/helpers';
11
21
  import type { ProfileType } from 'types/profile';
@@ -18,18 +28,28 @@ import { addNotificationToQueue } from '@helpers/notifications.helpers';
18
28
  import { getNotificationMessage } from '@helpers/notificationMessages';
19
29
  import { resolveRecipientLocale } from '@helpers/getRecipientLocale.helpers';
20
30
  import { PerfTracker } from '@helpers/perf';
31
+ import {
32
+ buildConsentFlowContractFromScopes,
33
+ buildScopedConsentContractName,
34
+ computeConsentScopeHash,
35
+ normalizeConsentScopes,
36
+ } from '@helpers/consent-scopes.helpers';
37
+ import { diffManifests, hashManifest } from '@helpers/app-manifest.helpers';
21
38
  import cache from '@cache';
22
39
  import {
23
40
  getBoostForListingCached,
41
+ getConsentContractForListingByScopeHashCached,
24
42
  readAppStoreListingByIdCached,
25
43
  getIntegrationForListingCached,
26
44
  getOwnerProfileForIntegrationCached,
27
45
  getPrimarySigningAuthorityForListingCached,
28
46
  getPrimarySigningAuthorityForIntegrationCached,
47
+ invalidateBoostForListingCache,
48
+ invalidateConsentContractForListingByScopeHashCache,
29
49
  } from '@cache/app-store.caches';
30
50
  import { logCredentialSent } from '@helpers/activity.helpers';
31
51
  import { getCredentialUri } from '@helpers/credential.helpers';
32
- import { resolveUri } from '@helpers/uri.helpers';
52
+ import { constructUri, resolveUri } from '@helpers/uri.helpers';
33
53
  import { inflateObject } from '@helpers/objects.helpers';
34
54
  import { getAvailableAppSlug } from '@helpers/slug.helpers';
35
55
  import {
@@ -52,13 +72,17 @@ import {
52
72
  countInstalledAppsForProfile,
53
73
  checkIfProfileInstalledApp,
54
74
  } from '@accesslayer/app-store-listing/read';
55
- import { updateAppStoreListing } from '@accesslayer/app-store-listing/update';
75
+ import {
76
+ clearPendingListingUpdate,
77
+ updateAppStoreListing,
78
+ } from '@accesslayer/app-store-listing/update';
56
79
  import { deleteAppStoreListing } from '@accesslayer/app-store-listing/delete';
57
80
  import {
58
81
  associateListingWithIntegration,
59
82
  associateListingWithSubmitter,
60
83
  installAppForProfile,
61
84
  associateBoostWithListing,
85
+ associateConsentContractWithListing,
62
86
  } from '@accesslayer/app-store-listing/relationships/create';
63
87
  import {
64
88
  uninstallAppForProfile,
@@ -91,11 +115,13 @@ import {
91
115
  PromotionLevel,
92
116
  AppStoreListingValidator,
93
117
  AgeRating,
118
+ StoredPendingListingUpdateValidator,
94
119
  } from 'types/app-store-listing';
95
120
  import type {
96
121
  AppStoreListingCreateType,
97
122
  AppStoreListingType,
98
123
  AppStoreListingUpdateType,
124
+ StoredPendingListingUpdate,
99
125
  } from 'types/app-store-listing';
100
126
  import { getBoostByUri } from '@accesslayer/boost/read';
101
127
  import {
@@ -106,9 +132,30 @@ import {
106
132
  } from '@helpers/boost.helpers';
107
133
  import { setCredentialSubjectIds } from '@helpers/credentialSubject.helpers';
108
134
  import { createBoostForListing } from '@accesslayer/boost/create';
135
+ import { createConsentFlowContract } from '@accesslayer/consentflowcontract/create';
136
+ import { createAppManifestVersion } from '@accesslayer/app-manifest-version/create';
137
+ import {
138
+ getActiveManifestVersionForIntegration,
139
+ getLatestManifestVersionForIntegration,
140
+ getManifestVersionForIntegration,
141
+ getManifestVersionsForIntegration,
142
+ } from '@accesslayer/app-manifest-version/read';
143
+ import {
144
+ associateListingWithManifestVersion,
145
+ markManifestVersionsSuperseded,
146
+ updateAppManifestVersion,
147
+ } from '@accesslayer/app-manifest-version/update';
109
148
  import { setBoostAsParent } from '@accesslayer/boost/relationships/create';
110
149
  import { issueCredentialWithSigningAuthority } from '@helpers/signingAuthority.helpers';
111
150
  import type { IssuedCredential } from '../types/credential';
151
+ import {
152
+ computeInlineTemplateHash,
153
+ ensureManagedSigningAuthorityForListing,
154
+ getInlineTemplateVersion,
155
+ readInlineTemplateBoostMeta,
156
+ upsertInlineTemplateBoostForListing,
157
+ validateInlineTemplateDataOrThrow,
158
+ } from '@helpers/inline-template.helpers';
112
159
  import {
113
160
  renderBoostTemplate,
114
161
  parseRenderedTemplate,
@@ -179,8 +226,14 @@ const isAllowedImageUrl = (url: string): boolean => {
179
226
  const isValidIframeUrl = (url: string): boolean => {
180
227
  try {
181
228
  const parsed = new URL(url);
182
- // Only allow https URLs for iframes
183
- if (parsed.protocol !== 'https:') return false;
229
+ // Only allow https URLs for iframes (except for local preview of draft listings)
230
+ if (parsed.protocol !== 'https:') {
231
+ const isLocalhost =
232
+ parsed.hostname === 'localhost' ||
233
+ parsed.hostname === '127.0.0.1' ||
234
+ parsed.hostname === '[::1]';
235
+ if (!isLocalhost) return false;
236
+ }
184
237
  // Block javascript:, data:, and vbscript: schemes (already handled by URL parsing, but be explicit)
185
238
  const lowerUrl = url.toLowerCase();
186
239
  if (
@@ -292,7 +345,14 @@ const iframeUrlRefinement = (
292
345
  if (config.iframeUrl && !isValidIframeUrl(config.iframeUrl)) {
293
346
  ctx.addIssue({
294
347
  code: z.ZodIssueCode.custom,
295
- message: 'Iframe URL must be a valid HTTPS URL',
348
+ message: 'Iframe URL must be a valid HTTPS URL (or localhost for testing)',
349
+ path: ['launch_config_json'],
350
+ });
351
+ }
352
+ if (config.url && !isValidIframeUrl(config.url)) {
353
+ ctx.addIssue({
354
+ code: z.ZodIssueCode.custom,
355
+ message: 'URL must be a valid HTTPS URL (or localhost for testing)',
296
356
  path: ['launch_config_json'],
297
357
  });
298
358
  }
@@ -302,12 +362,16 @@ const iframeUrlRefinement = (
302
362
  }
303
363
  };
304
364
 
365
+ type PendingStorageField =
366
+ 'pending_update_json' | 'pending_update_status' | 'pending_update_submitted_at';
367
+
305
368
  type AppStoreListingResponse<T extends AppStoreListingType> = Omit<
306
369
  T,
307
- 'highlights_json' | 'screenshots_json'
370
+ 'highlights_json' | 'screenshots_json' | PendingStorageField
308
371
  > & {
309
372
  highlights?: string[];
310
373
  screenshots?: string[];
374
+ pending_update?: z.infer<typeof PendingListingUpdateValidator>;
311
375
  };
312
376
 
313
377
  type ListingStorageInput<T extends Record<string, unknown>> = Omit<
@@ -321,18 +385,72 @@ type ListingStorageInput<T extends Record<string, unknown>> = Omit<
321
385
  // Helper to strip sensitive fields (contact_email) from listings for public responses
322
386
  const stripSensitiveFields = <T extends Record<string, unknown>>(
323
387
  listing: T
324
- ): Omit<T, 'contact_email'> => {
325
- const { contact_email, ...rest } = listing;
326
- return rest as Omit<T, 'contact_email'>;
388
+ ): Omit<T, 'contact_email' | 'pending_update'> => {
389
+ const { contact_email, pending_update, ...rest } = listing;
390
+ return rest as Omit<T, 'contact_email' | 'pending_update'>;
391
+ };
392
+
393
+ const parseJsonArray = (value: unknown): string[] | undefined => {
394
+ if (typeof value !== 'string' || !value) return undefined;
395
+ try {
396
+ const parsed: unknown = JSON.parse(value);
397
+ return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
398
+ } catch {
399
+ return [];
400
+ }
401
+ };
402
+
403
+ const readPendingUpdate = (listing: AppStoreListingType): StoredPendingListingUpdate | null => {
404
+ if (!listing.pending_update_json) return null;
405
+ try {
406
+ const parsed = StoredPendingListingUpdateValidator.safeParse(
407
+ JSON.parse(listing.pending_update_json)
408
+ );
409
+ return parsed.success ? parsed.data : null;
410
+ } catch {
411
+ return null;
412
+ }
413
+ };
414
+
415
+ const toPendingUpdateResponse = (
416
+ listing: AppStoreListingType
417
+ ): z.infer<typeof PendingListingUpdateValidator> | undefined => {
418
+ const pending = readPendingUpdate(listing);
419
+ if (!pending || !listing.pending_update_status) return undefined;
420
+
421
+ const { highlights_json, screenshots_json, ...changes } = pending.changes;
422
+ const highlights = parseJsonArray(highlights_json);
423
+ const screenshots = parseJsonArray(screenshots_json);
424
+
425
+ return PendingListingUpdateValidator.parse({
426
+ status: listing.pending_update_status,
427
+ submitted_at: listing.pending_update_submitted_at,
428
+ changes: {
429
+ ...changes,
430
+ ...(highlights ? { highlights } : {}),
431
+ ...(screenshots ? { screenshots } : {}),
432
+ },
433
+ manifest_version: pending.manifestVersion,
434
+ });
327
435
  };
328
436
 
329
437
  // Helper to transform listing for API response (JSON strings -> arrays)
330
438
  const transformListingForResponse = <T extends AppStoreListingType & Record<string, unknown>>(
331
439
  listing: T
332
440
  ): AppStoreListingResponse<T> => {
333
- const { highlights_json, screenshots_json, ...rest } = listing;
441
+ const {
442
+ highlights_json,
443
+ screenshots_json,
444
+ pending_update_json,
445
+ pending_update_status,
446
+ pending_update_submitted_at,
447
+ ...rest
448
+ } = listing;
334
449
  const result = { ...rest } as AppStoreListingResponse<T>;
335
450
 
451
+ const pendingUpdate = toPendingUpdateResponse(listing);
452
+ if (pendingUpdate) result.pending_update = pendingUpdate;
453
+
336
454
  if (highlights_json) {
337
455
  try {
338
456
  result.highlights = JSON.parse(highlights_json);
@@ -442,9 +560,13 @@ const AppStoreListingResponseValidator = AppStoreListingValidator.extend({
442
560
  highlights: z.array(z.string()).optional(),
443
561
  screenshots: z.array(z.string()).optional(),
444
562
  submitter: AppStoreListingSubmitterValidator.optional(),
563
+ pending_update: PendingListingUpdateValidator.optional(),
445
564
  }).omit({
446
565
  highlights_json: true,
447
566
  screenshots_json: true,
567
+ pending_update_json: true,
568
+ pending_update_status: true,
569
+ pending_update_submitted_at: true,
448
570
  });
449
571
 
450
572
  const PaginatedAppStoreListingsValidator = z.object({
@@ -480,6 +602,45 @@ const PaginatedAppCredentialsValidator = z.object({
480
602
  totalCount: z.number(),
481
603
  });
482
604
 
605
+ const AppManifestVersionSummaryValidator = AppManifestVersionValidator.omit({ manifest: true });
606
+
607
+ const PaginatedAppManifestVersionsValidator = z.object({
608
+ hasMore: z.boolean(),
609
+ cursor: z.string().optional(),
610
+ records: z.array(AppManifestVersionSummaryValidator),
611
+ });
612
+
613
+ const SubmitAppManifestResponseValidator = z.object({
614
+ version: z.number().int().min(1),
615
+ manifestHash: z.string(),
616
+ diff: AppManifestDiffValidator.nullable(),
617
+ noop: z.boolean(),
618
+ });
619
+
620
+ const ApplyManifestVersionResponseValidator = z.object({
621
+ applied: z.boolean(),
622
+ pendingReview: z.boolean().optional(),
623
+ version: z.number().int().min(1),
624
+ reconciled: z.object({
625
+ templatesUpserted: z.number().int().min(0),
626
+ templatesSkipped: z.number().int().min(0),
627
+ contractsUpserted: z.number().int().min(0),
628
+ contractsSkipped: z.number().int().min(0),
629
+ signingAuthorityEnsured: z.boolean(),
630
+ }),
631
+ });
632
+
633
+ const toManifestVersionSummary = (
634
+ manifestVersion: z.infer<typeof AppManifestVersionValidator>
635
+ ): z.infer<typeof AppManifestVersionSummaryValidator> => ({
636
+ id: manifestVersion.id,
637
+ version: manifestVersion.version,
638
+ manifestHash: manifestVersion.manifestHash,
639
+ status: manifestVersion.status,
640
+ createdAt: manifestVersion.createdAt,
641
+ activatedAt: manifestVersion.activatedAt,
642
+ });
643
+
483
644
  // Helper to verify integration ownership
484
645
  const verifyIntegrationOwnership = async (integrationId: string, profileId: string) => {
485
646
  const associated = await isIntegrationAssociatedWithProfile(integrationId, profileId);
@@ -558,12 +719,17 @@ export const handleSendCredentialEvent = async (
558
719
  ): Promise<Record<string, unknown>> => {
559
720
  const perf = perfTracker ?? new PerfTracker('handleSendCredentialEvent');
560
721
 
561
- const templateAlias = event.templateAlias as string | undefined;
722
+ const templateAlias =
723
+ (event.templateAlias as string | undefined) ?? (event.alias as string | undefined);
724
+ const inlineTemplate = event.template as
725
+ import('@learncard/partner-connect-core').InlineCredentialTemplate | undefined;
562
726
  const templateData = event.templateData as Record<string, unknown> | undefined;
563
727
  const preventDuplicateClaim = Boolean(event.preventDuplicateClaim);
728
+ const isInlineTemplateEvent = Boolean(inlineTemplate);
729
+ let templateVersion: number | undefined = undefined;
564
730
 
565
731
  if (!templateAlias) {
566
- throw new TRPCError({ code: 'BAD_REQUEST', message: 'templateAlias required' });
732
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'templateAlias or alias required' });
567
733
  }
568
734
 
569
735
  // LC-1644 Phase 1b: Fire the four input-only lookups in parallel. All depend only on
@@ -572,17 +738,16 @@ export const handleSendCredentialEvent = async (
572
738
  // parallel batching + 30s LRU cache on the first three below compounds to ~50-150ms
573
739
  // saved on warm lambda invocations. targetProfile is keyed by the requester's
574
740
  // profileId (varies per user) so it's not cached.
575
- const [boostResult, listing, integration, targetProfile] = await Promise.all([
576
- getBoostForListingCached(listingId, templateAlias, ctx.domain),
741
+ const [cachedBoostResult, listing, integration, targetProfile] = await Promise.all([
742
+ isInlineTemplateEvent
743
+ ? Promise.resolve(null)
744
+ : getBoostForListingCached(listingId, templateAlias, ctx.domain),
577
745
  readAppStoreListingByIdCached(listingId),
578
746
  getIntegrationForListingCached(listingId),
579
747
  getProfilesByProfileIds([profile.profileId]),
580
748
  ]);
581
749
  perf.mark('parallelReads');
582
750
 
583
- if (!boostResult) {
584
- throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found for this app' });
585
- }
586
751
  if (!listing) {
587
752
  throw new TRPCError({ code: 'NOT_FOUND', message: 'App Store Listing not found' });
588
753
  }
@@ -594,7 +759,30 @@ export const handleSendCredentialEvent = async (
594
759
  throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found' });
595
760
  }
596
761
 
762
+ const boostResult = isInlineTemplateEvent
763
+ ? await upsertInlineTemplateBoostForListing({
764
+ listing,
765
+ integration,
766
+ templateAlias,
767
+ template: inlineTemplate!,
768
+ domain: ctx.domain,
769
+ })
770
+ : cachedBoostResult;
771
+
772
+ if (!boostResult) {
773
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Boost not found for this app' });
774
+ }
775
+
776
+ if (isInlineTemplateEvent) {
777
+ const inlineBoostResult = boostResult as Awaited<
778
+ ReturnType<typeof upsertInlineTemplateBoostForListing>
779
+ >;
780
+ validateInlineTemplateDataOrThrow(inlineBoostResult.manifest, templateData);
781
+ templateVersion = inlineBoostResult.templateVersion;
782
+ }
783
+
597
784
  const { boost, boostUri } = boostResult;
785
+ templateVersion ??= getInlineTemplateVersion(boost);
598
786
 
599
787
  // NOTE: This is best-effort duplicate prevention, not a guarantee.
600
788
  // There is a race condition window between checking and creating the credential
@@ -618,6 +806,7 @@ export const handleSendCredentialEvent = async (
618
806
  receivedDate: existingCredential.receivedDate ?? existingCredential.sentDate,
619
807
  status: existingCredential.status,
620
808
  boostUri,
809
+ ...(templateVersion !== undefined ? { templateVersion } : {}),
621
810
  // credential not included — frontend skips modal when alreadyClaimed=true
622
811
  };
623
812
  }
@@ -652,17 +841,19 @@ export const handleSendCredentialEvent = async (
652
841
  listingSa || integrationSa
653
842
  ? undefined
654
843
  : await getPrimarySigningAuthorityForUser(integrationOwner);
655
- const sa = listingSa ?? integrationSa ?? ownerSa;
844
+ const sa =
845
+ listingSa ??
846
+ integrationSa ??
847
+ ownerSa ??
848
+ (await ensureManagedSigningAuthorityForListing(
849
+ listing,
850
+ integration,
851
+ integrationOwner,
852
+ ctx.domain
853
+ ));
656
854
 
657
855
  perf.mark('saResolve');
658
856
 
659
- if (!sa) {
660
- throw new TRPCError({
661
- code: 'NOT_FOUND',
662
- message: 'No signing authority configured for this app',
663
- });
664
- }
665
-
666
857
  // Build unsigned credential from boost template
667
858
  let unsignedVc: UnsignedVC;
668
859
 
@@ -826,6 +1017,7 @@ export const handleSendCredentialEvent = async (
826
1017
  return {
827
1018
  credentialUri,
828
1019
  boostUri,
1020
+ ...(templateVersion !== undefined ? { templateVersion } : {}),
829
1021
  // LC-1644: pre-resolved (enriched) credential so the frontend modal
830
1022
  // can render the proper title + issuer info without shimmer or
831
1023
  // background re-fetch. See enrichment block above for details.
@@ -833,6 +1025,330 @@ export const handleSendCredentialEvent = async (
833
1025
  };
834
1026
  };
835
1027
 
1028
+ const normalizeConsentRequestOrThrow = (scopes: ConsentRequest): NormalizedConsentScopes => {
1029
+ try {
1030
+ return normalizeConsentScopes(scopes);
1031
+ } catch (error) {
1032
+ const message = error instanceof Error ? error.message : 'Invalid consent scopes';
1033
+
1034
+ throw new TRPCError({
1035
+ code: 'BAD_REQUEST',
1036
+ message: `CONSENT_SCOPES_INVALID: ${message}`,
1037
+ });
1038
+ }
1039
+ };
1040
+
1041
+ const handleUpsertConsentContractEvent = async (
1042
+ ctx: { domain: string },
1043
+ listing: AppStoreListingType,
1044
+ listingId: string,
1045
+ event: Record<string, unknown>
1046
+ ): Promise<{ contractUri: string; created: boolean }> => {
1047
+ const rawScopes = event.scopes as ConsentRequest;
1048
+ const normalizedScopes = normalizeConsentRequestOrThrow(rawScopes);
1049
+ return upsertConsentContractForListingScopes({
1050
+ ctx,
1051
+ listing,
1052
+ listingId,
1053
+ normalizedScopes,
1054
+ reason:
1055
+ typeof rawScopes.reason === 'string' && rawScopes.reason.trim().length > 0
1056
+ ? rawScopes.reason
1057
+ : undefined,
1058
+ });
1059
+ };
1060
+
1061
+ const upsertConsentContractForListingScopes = async ({
1062
+ ctx,
1063
+ listing,
1064
+ listingId,
1065
+ normalizedScopes,
1066
+ reason,
1067
+ }: {
1068
+ ctx: { domain: string };
1069
+ listing: AppStoreListingType;
1070
+ listingId: string;
1071
+ normalizedScopes: NormalizedConsentScopes;
1072
+ reason?: string;
1073
+ }): Promise<{ contractUri: string; created: boolean; scopeHash: string }> => {
1074
+ const scopeHash = computeConsentScopeHash(normalizedScopes);
1075
+ const existingContract = await getConsentContractForListingByScopeHashCached(
1076
+ listingId,
1077
+ scopeHash
1078
+ );
1079
+
1080
+ if (existingContract) {
1081
+ return {
1082
+ contractUri: constructUri('contract', existingContract.id, ctx.domain),
1083
+ created: false,
1084
+ scopeHash,
1085
+ };
1086
+ }
1087
+
1088
+ const integration = await getIntegrationForListingCached(listingId);
1089
+
1090
+ if (!integration) {
1091
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration not found' });
1092
+ }
1093
+
1094
+ const ownerProfile = await getOwnerProfileForIntegrationCached(integration.id);
1095
+
1096
+ if (!ownerProfile) {
1097
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration owner not found' });
1098
+ }
1099
+
1100
+ const createdContract = await createConsentFlowContract({
1101
+ contract: buildConsentFlowContractFromScopes(normalizedScopes),
1102
+ ownerProfileId: ownerProfile.profileId,
1103
+ name: buildScopedConsentContractName(listing.display_name),
1104
+ description: listing.tagline,
1105
+ reasonForAccessing: reason,
1106
+ });
1107
+
1108
+ await associateConsentContractWithListing(listingId, createdContract.id, scopeHash);
1109
+ invalidateConsentContractForListingByScopeHashCache(listingId, scopeHash);
1110
+
1111
+ return {
1112
+ contractUri: constructUri('contract', createdContract.id, ctx.domain),
1113
+ created: true,
1114
+ scopeHash,
1115
+ };
1116
+ };
1117
+
1118
+ const resolveManifestApplyListing = async ({
1119
+ integrationId,
1120
+ listingId,
1121
+ profileId,
1122
+ }: {
1123
+ integrationId: string;
1124
+ listingId: string | undefined;
1125
+ profileId: string;
1126
+ }): Promise<AppStoreListingType> => {
1127
+ if (listingId) {
1128
+ const { listing, integration } = await verifyListingOwnership(listingId, profileId);
1129
+
1130
+ if (integration.id !== integrationId) {
1131
+ throw new TRPCError({
1132
+ code: 'BAD_REQUEST',
1133
+ message: 'Listing does not belong to the provided Integration',
1134
+ });
1135
+ }
1136
+
1137
+ return listing;
1138
+ }
1139
+
1140
+ const listings = await getListingsForIntegration(integrationId, { limit: 2 });
1141
+
1142
+ if (listings.length === 0) {
1143
+ throw new TRPCError({
1144
+ code: 'NOT_FOUND',
1145
+ message: 'No App Store Listing found for this Integration',
1146
+ });
1147
+ }
1148
+
1149
+ if (listings.length > 1) {
1150
+ throw new TRPCError({
1151
+ code: 'BAD_REQUEST',
1152
+ message: 'listingId is required when an Integration has multiple listings',
1153
+ });
1154
+ }
1155
+
1156
+ return listings[0]!;
1157
+ };
1158
+
1159
+ type ManifestVersionRecord = NonNullable<
1160
+ Awaited<ReturnType<typeof getManifestVersionForIntegration>>
1161
+ >;
1162
+ type IntegrationRecord = Awaited<ReturnType<typeof verifyIntegrationOwnership>>;
1163
+ type OwnerProfileRecord = NonNullable<Awaited<ReturnType<typeof getOwnerProfileForIntegration>>>;
1164
+
1165
+ const reconcileManifestVersionForListing = async ({
1166
+ ctx,
1167
+ integration,
1168
+ integrationOwner,
1169
+ listing,
1170
+ manifestVersion,
1171
+ }: {
1172
+ ctx: { domain: string };
1173
+ integration: IntegrationRecord;
1174
+ integrationOwner: OwnerProfileRecord;
1175
+ listing: AppStoreListingType;
1176
+ manifestVersion: ManifestVersionRecord;
1177
+ }) => {
1178
+ let templatesUpserted = 0;
1179
+ let templatesSkipped = 0;
1180
+ let contractsUpserted = 0;
1181
+ let contractsSkipped = 0;
1182
+
1183
+ for (const templateRecord of manifestVersion.manifest.templates) {
1184
+ const inlineTemplate = templateRecord.template as InlineCredentialTemplate;
1185
+ const existingBoost = await getBoostForListingByTemplateAlias(
1186
+ listing.listing_id,
1187
+ templateRecord.alias,
1188
+ ctx.domain
1189
+ );
1190
+ const desiredTemplateHash = computeInlineTemplateHash(inlineTemplate).contentHash;
1191
+
1192
+ const existingBoostMeta = existingBoost
1193
+ ? await readInlineTemplateBoostMeta(existingBoost.boost)
1194
+ : undefined;
1195
+
1196
+ if (
1197
+ existingBoostMeta?.inlineTemplateHash === desiredTemplateHash &&
1198
+ existingBoostMeta.inlineTemplateVersion === templateRecord.version
1199
+ ) {
1200
+ templatesSkipped += 1;
1201
+ continue;
1202
+ }
1203
+
1204
+ await upsertInlineTemplateBoostForListing({
1205
+ listing,
1206
+ integration,
1207
+ templateAlias: templateRecord.alias,
1208
+ template: inlineTemplate,
1209
+ domain: ctx.domain,
1210
+ desiredVersion: templateRecord.version,
1211
+ });
1212
+
1213
+ templatesUpserted += 1;
1214
+
1215
+ invalidateBoostForListingCache(listing.listing_id, templateRecord.alias, ctx.domain);
1216
+ }
1217
+
1218
+ for (const consentRecord of manifestVersion.manifest.consentRequests) {
1219
+ const upsertedConsent = await upsertConsentContractForListingScopes({
1220
+ ctx,
1221
+ listing,
1222
+ listingId: listing.listing_id,
1223
+ normalizedScopes: consentRecord.scopes,
1224
+ reason: consentRecord.reason,
1225
+ });
1226
+
1227
+ if (upsertedConsent.created) {
1228
+ contractsUpserted += 1;
1229
+ } else {
1230
+ contractsSkipped += 1;
1231
+ }
1232
+
1233
+ invalidateConsentContractForListingByScopeHashCache(
1234
+ listing.listing_id,
1235
+ upsertedConsent.scopeHash
1236
+ );
1237
+ }
1238
+
1239
+ await markManifestVersionsSuperseded(integration.id, manifestVersion.id);
1240
+
1241
+ if (manifestVersion.status !== 'active' || !manifestVersion.activatedAt) {
1242
+ await updateAppManifestVersion(manifestVersion.id, {
1243
+ status: 'active',
1244
+ activatedAt: manifestVersion.activatedAt ?? new Date().toISOString(),
1245
+ });
1246
+ }
1247
+
1248
+ await associateListingWithManifestVersion(listing.listing_id, manifestVersion.id);
1249
+
1250
+ await ensureManagedSigningAuthorityForListing(
1251
+ listing,
1252
+ integration,
1253
+ integrationOwner,
1254
+ ctx.domain
1255
+ );
1256
+
1257
+ return {
1258
+ templatesUpserted,
1259
+ templatesSkipped,
1260
+ contractsUpserted,
1261
+ contractsSkipped,
1262
+ signingAuthorityEnsured: true,
1263
+ };
1264
+ };
1265
+
1266
+ const PENDING_UPDATE_IN_REVIEW_MESSAGE =
1267
+ 'An update for this app is already in review. Withdraw it to make more changes.';
1268
+
1269
+ /**
1270
+ * Records changes to a LISTED app as a pending update instead of applying them, so
1271
+ * nothing about a live app changes until an admin approves it.
1272
+ */
1273
+ const holdChangesForReview = async (
1274
+ listing: AppStoreListingType,
1275
+ {
1276
+ changes = {},
1277
+ manifestVersion,
1278
+ }: { changes?: Record<string, unknown>; manifestVersion?: number }
1279
+ ): Promise<boolean> => {
1280
+ if (listing.pending_update_status === 'PENDING_REVIEW') {
1281
+ throw new TRPCError({ code: 'BAD_REQUEST', message: PENDING_UPDATE_IN_REVIEW_MESSAGE });
1282
+ }
1283
+
1284
+ const existing = readPendingUpdate(listing);
1285
+ const next: StoredPendingListingUpdate = {
1286
+ changes: { ...existing?.changes, ...changes },
1287
+ manifestVersion: manifestVersion ?? existing?.manifestVersion,
1288
+ };
1289
+
1290
+ return updateAppStoreListing(listing, {
1291
+ pending_update_json: JSON.stringify(next),
1292
+ pending_update_status: 'DRAFT',
1293
+ });
1294
+ };
1295
+
1296
+ const assertLaunchConfigSubmittable = (launchType: string, launchConfigJson: string): void => {
1297
+ if (launchType !== 'EMBEDDED_IFRAME' || !launchConfigJson) return;
1298
+
1299
+ try {
1300
+ const config = JSON.parse(launchConfigJson);
1301
+ const urlToCheck = config.url || config.iframeUrl;
1302
+ if (!urlToCheck) return;
1303
+
1304
+ const parsed = new URL(urlToCheck);
1305
+ const isLocalhost =
1306
+ parsed.hostname === 'localhost' ||
1307
+ parsed.hostname === '127.0.0.1' ||
1308
+ parsed.hostname === '[::1]';
1309
+ if (isLocalhost || parsed.protocol !== 'https:') {
1310
+ throw new TRPCError({
1311
+ code: 'BAD_REQUEST',
1312
+ message:
1313
+ 'Cannot submit an app for review with a localhost or non-HTTPS URL. Please update the launch configuration with your production URL.',
1314
+ });
1315
+ }
1316
+ } catch (e) {
1317
+ if (e instanceof TRPCError) throw e;
1318
+ // Unparseable config is rejected by input validation.
1319
+ }
1320
+ };
1321
+
1322
+ const notifyAppStoreAdmins = async (
1323
+ from: Parameters<typeof addNotificationToQueue>[0]['from'],
1324
+ type: 'APP_LISTING_SUBMITTED' | 'APP_LISTING_WITHDRAWN',
1325
+ listing: AppStoreListingType,
1326
+ metadata: Record<string, unknown> = {}
1327
+ ): Promise<void> => {
1328
+ if (APP_STORE_ADMIN_PROFILE_IDS.length === 0) return;
1329
+
1330
+ const adminProfiles = await getProfilesByProfileIds(APP_STORE_ADMIN_PROFILE_IDS);
1331
+ for (const adminProfile of adminProfiles) {
1332
+ await addNotificationToQueue({
1333
+ type,
1334
+ to: adminProfile,
1335
+ from,
1336
+ message: getNotificationMessage(
1337
+ type === 'APP_LISTING_SUBMITTED' ? 'appListingSubmitted' : 'appListingWithdrawn',
1338
+ resolveRecipientLocale(adminProfile),
1339
+ { displayName: listing.display_name }
1340
+ ),
1341
+ data: {
1342
+ metadata: {
1343
+ listingId: listing.listing_id,
1344
+ listingName: listing.display_name,
1345
+ ...metadata,
1346
+ },
1347
+ },
1348
+ });
1349
+ }
1350
+ };
1351
+
836
1352
  const handleCheckCredentialEvent = async (
837
1353
  ctx: { domain: string },
838
1354
  profile: { profileId: string },
@@ -1830,6 +2346,274 @@ export const appStoreRouter = t.router({
1830
2346
  return countListingsForIntegration(input.integrationId);
1831
2347
  }),
1832
2348
 
2349
+ submitAppManifest: profileRoute
2350
+ .meta({
2351
+ openapi: {
2352
+ protect: true,
2353
+ method: 'POST',
2354
+ path: '/app-store/integration/{integrationId}/manifest/submit',
2355
+ tags: ['App Store'],
2356
+ summary: 'Submit App Manifest',
2357
+ description:
2358
+ 'Create a versioned app manifest draft for an integration, idempotently by manifest hash.',
2359
+ },
2360
+ requiredScope: 'app-store:write',
2361
+ })
2362
+ .input(
2363
+ z.object({
2364
+ integrationId: z.string(),
2365
+ manifest: AppManifestValidator,
2366
+ })
2367
+ )
2368
+ .output(SubmitAppManifestResponseValidator)
2369
+ .mutation(async ({ input, ctx }) => {
2370
+ await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
2371
+
2372
+ const latestManifestVersion = await getLatestManifestVersionForIntegration(
2373
+ input.integrationId
2374
+ );
2375
+ const activeManifestVersion = await getActiveManifestVersionForIntegration(
2376
+ input.integrationId
2377
+ );
2378
+ // Each capture is one run of the app; merging keeps what earlier runs saw
2379
+ // so an unexercised feature never shows up as removed.
2380
+ const manifest = latestManifestVersion
2381
+ ? mergeCapturedManifests(latestManifestVersion.manifest, input.manifest)
2382
+ : input.manifest;
2383
+ const manifestHash = hashManifest(manifest);
2384
+
2385
+ if (latestManifestVersion?.manifestHash === manifestHash) {
2386
+ return {
2387
+ version: latestManifestVersion.version,
2388
+ manifestHash,
2389
+ diff: null,
2390
+ noop: true,
2391
+ };
2392
+ }
2393
+
2394
+ const createdManifestVersion = await createAppManifestVersion({
2395
+ integrationId: input.integrationId,
2396
+ version: (latestManifestVersion?.version ?? 0) + 1,
2397
+ manifestHash,
2398
+ manifest,
2399
+ status: 'draft',
2400
+ });
2401
+
2402
+ return {
2403
+ version: createdManifestVersion.version,
2404
+ manifestHash,
2405
+ diff: activeManifestVersion
2406
+ ? diffManifests(activeManifestVersion.manifest, manifest)
2407
+ : null,
2408
+ noop: false,
2409
+ };
2410
+ }),
2411
+
2412
+ getManifestVersions: profileRoute
2413
+ .meta({
2414
+ openapi: {
2415
+ protect: true,
2416
+ method: 'GET',
2417
+ path: '/app-store/integration/{integrationId}/manifest/versions',
2418
+ tags: ['App Store'],
2419
+ summary: 'Get App Manifest Versions',
2420
+ description: 'List paginated app manifest versions for an integration.',
2421
+ },
2422
+ requiredScope: 'app-store:read',
2423
+ })
2424
+ .input(
2425
+ z.object({
2426
+ integrationId: z.string(),
2427
+ limit: z.number().int().min(1).max(100).optional(),
2428
+ cursor: z.string().optional(),
2429
+ })
2430
+ )
2431
+ .output(PaginatedAppManifestVersionsValidator)
2432
+ .query(async ({ input, ctx }) => {
2433
+ await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
2434
+
2435
+ const limit = input.limit ?? 25;
2436
+ const versions = await getManifestVersionsForIntegration(input.integrationId, {
2437
+ limit: limit + 1,
2438
+ cursor: input.cursor,
2439
+ });
2440
+
2441
+ const hasMore = versions.length > limit;
2442
+ const records = (hasMore ? versions.slice(0, limit) : versions).map(
2443
+ toManifestVersionSummary
2444
+ );
2445
+
2446
+ return {
2447
+ hasMore,
2448
+ cursor: hasMore ? String(records[records.length - 1]?.version) : undefined,
2449
+ records,
2450
+ };
2451
+ }),
2452
+
2453
+ getManifestVersion: profileRoute
2454
+ .meta({
2455
+ openapi: {
2456
+ protect: true,
2457
+ method: 'GET',
2458
+ path: '/app-store/integration/{integrationId}/manifest/{version}',
2459
+ tags: ['App Store'],
2460
+ summary: 'Get App Manifest Version',
2461
+ description: 'Get a full app manifest version for an integration.',
2462
+ },
2463
+ requiredScope: 'app-store:read',
2464
+ })
2465
+ .input(
2466
+ z.object({
2467
+ integrationId: z.string(),
2468
+ version: z.number().int().min(1),
2469
+ })
2470
+ )
2471
+ .output(AppManifestVersionValidator)
2472
+ .query(async ({ input, ctx }) => {
2473
+ await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
2474
+
2475
+ const manifestVersion = await getManifestVersionForIntegration(
2476
+ input.integrationId,
2477
+ input.version
2478
+ );
2479
+
2480
+ if (!manifestVersion) {
2481
+ throw new TRPCError({
2482
+ code: 'NOT_FOUND',
2483
+ message: 'App manifest version not found',
2484
+ });
2485
+ }
2486
+
2487
+ return manifestVersion;
2488
+ }),
2489
+
2490
+ getManifestDiff: profileRoute
2491
+ .meta({
2492
+ openapi: {
2493
+ protect: true,
2494
+ method: 'GET',
2495
+ path: '/app-store/integration/{integrationId}/manifest/diff/{toVersion}',
2496
+ tags: ['App Store'],
2497
+ summary: 'Get App Manifest Diff',
2498
+ description: 'Diff one app manifest version against another for an integration.',
2499
+ },
2500
+ requiredScope: 'app-store:read',
2501
+ })
2502
+ .input(
2503
+ z.object({
2504
+ integrationId: z.string(),
2505
+ fromVersion: z.number().int().min(1).optional(),
2506
+ toVersion: z.number().int().min(1),
2507
+ })
2508
+ )
2509
+ .output(AppManifestDiffValidator)
2510
+ .query(async ({ input, ctx }) => {
2511
+ await verifyIntegrationOwnership(input.integrationId, ctx.user.profile.profileId);
2512
+
2513
+ const toManifestVersion = await getManifestVersionForIntegration(
2514
+ input.integrationId,
2515
+ input.toVersion
2516
+ );
2517
+
2518
+ if (!toManifestVersion) {
2519
+ throw new TRPCError({
2520
+ code: 'NOT_FOUND',
2521
+ message: 'Target app manifest version not found',
2522
+ });
2523
+ }
2524
+
2525
+ const fromManifestVersion = input.fromVersion
2526
+ ? await getManifestVersionForIntegration(input.integrationId, input.fromVersion)
2527
+ : await getActiveManifestVersionForIntegration(input.integrationId);
2528
+
2529
+ if (!fromManifestVersion) {
2530
+ throw new TRPCError({
2531
+ code: 'NOT_FOUND',
2532
+ message: 'Source app manifest version not found',
2533
+ });
2534
+ }
2535
+
2536
+ return diffManifests(fromManifestVersion.manifest, toManifestVersion.manifest);
2537
+ }),
2538
+
2539
+ applyManifestVersion: profileRoute
2540
+ .meta({
2541
+ openapi: {
2542
+ protect: true,
2543
+ method: 'POST',
2544
+ path: '/app-store/integration/{integrationId}/manifest/{version}/apply',
2545
+ tags: ['App Store'],
2546
+ summary: 'Apply App Manifest Version',
2547
+ description:
2548
+ 'Mark an app manifest version active and idempotently reconcile derived listing entities.',
2549
+ },
2550
+ requiredScope: 'app-store:write',
2551
+ })
2552
+ .input(
2553
+ z.object({
2554
+ integrationId: z.string(),
2555
+ version: z.number().int().min(1),
2556
+ listingId: z.string().optional(),
2557
+ })
2558
+ )
2559
+ .output(ApplyManifestVersionResponseValidator)
2560
+ .mutation(async ({ input, ctx }) => {
2561
+ const integration = await verifyIntegrationOwnership(
2562
+ input.integrationId,
2563
+ ctx.user.profile.profileId
2564
+ );
2565
+ const manifestVersion = await getManifestVersionForIntegration(
2566
+ input.integrationId,
2567
+ input.version
2568
+ );
2569
+
2570
+ if (!manifestVersion) {
2571
+ throw new TRPCError({
2572
+ code: 'NOT_FOUND',
2573
+ message: 'App manifest version not found',
2574
+ });
2575
+ }
2576
+
2577
+ const listing = await resolveManifestApplyListing({
2578
+ integrationId: input.integrationId,
2579
+ listingId: input.listingId,
2580
+ profileId: ctx.user.profile.profileId,
2581
+ });
2582
+ const integrationOwner = await getOwnerProfileForIntegration(input.integrationId);
2583
+
2584
+ if (!integrationOwner) {
2585
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Integration owner not found' });
2586
+ }
2587
+
2588
+ if (listing.app_listing_status === 'LISTED' && manifestVersion.status !== 'active') {
2589
+ // A live app's new capabilities wait for review instead of taking effect.
2590
+ await holdChangesForReview(listing, { manifestVersion: manifestVersion.version });
2591
+
2592
+ return {
2593
+ applied: false,
2594
+ pendingReview: true,
2595
+ version: manifestVersion.version,
2596
+ reconciled: {
2597
+ templatesUpserted: 0,
2598
+ templatesSkipped: 0,
2599
+ contractsUpserted: 0,
2600
+ contractsSkipped: 0,
2601
+ signingAuthorityEnsured: false,
2602
+ },
2603
+ };
2604
+ }
2605
+
2606
+ const reconciled = await reconcileManifestVersionForListing({
2607
+ ctx,
2608
+ integration,
2609
+ integrationOwner,
2610
+ listing,
2611
+ manifestVersion,
2612
+ });
2613
+
2614
+ return { applied: true, version: manifestVersion.version, reconciled };
2615
+ }),
2616
+
1833
2617
  updateListing: profileRoute
1834
2618
  .meta({
1835
2619
  openapi: {
@@ -1861,6 +2645,10 @@ export const appStoreRouter = t.router({
1861
2645
  ...input.updates,
1862
2646
  });
1863
2647
 
2648
+ if (listing.app_listing_status === 'LISTED') {
2649
+ return holdChangesForReview(listing, { changes: storageUpdates });
2650
+ }
2651
+
1864
2652
  if (!listing.slug) {
1865
2653
  const displayName = storageUpdates.display_name ?? listing.display_name;
1866
2654
  storageUpdates.slug = await getAvailableAppSlug(displayName, listing.listing_id);
@@ -2016,6 +2804,8 @@ export const appStoreRouter = t.router({
2016
2804
  });
2017
2805
  }
2018
2806
 
2807
+ assertLaunchConfigSubmittable(listing.launch_type, listing.launch_config_json);
2808
+
2019
2809
  const submittedAt = new Date().toISOString();
2020
2810
 
2021
2811
  // Update status on the listing node
@@ -2116,6 +2906,125 @@ export const appStoreRouter = t.router({
2116
2906
  return result;
2117
2907
  }),
2118
2908
 
2909
+ submitListingUpdate: profileRoute
2910
+ .meta({
2911
+ openapi: {
2912
+ protect: true,
2913
+ method: 'POST',
2914
+ path: '/app-store/listing/{listingId}/update/submit',
2915
+ tags: ['App Store'],
2916
+ summary: 'Submit Listing Update for Review',
2917
+ description:
2918
+ 'Send pending changes to a LISTED app for admin review. The live listing is unchanged until approval.',
2919
+ },
2920
+ requiredScope: 'app-store:write',
2921
+ })
2922
+ .input(z.object({ listingId: z.string() }))
2923
+ .output(z.boolean())
2924
+ .mutation(async ({ input, ctx }) => {
2925
+ const { listing } = await verifyListingOwnership(
2926
+ input.listingId,
2927
+ ctx.user.profile.profileId
2928
+ );
2929
+ const pending = readPendingUpdate(listing);
2930
+
2931
+ if (listing.app_listing_status !== 'LISTED' || !pending) {
2932
+ throw new TRPCError({
2933
+ code: 'BAD_REQUEST',
2934
+ message: 'There are no changes to submit for this app.',
2935
+ });
2936
+ }
2937
+ if (listing.pending_update_status === 'PENDING_REVIEW') {
2938
+ throw new TRPCError({
2939
+ code: 'BAD_REQUEST',
2940
+ message: PENDING_UPDATE_IN_REVIEW_MESSAGE,
2941
+ });
2942
+ }
2943
+
2944
+ const nextLaunchType = pending.changes.launch_type ?? listing.launch_type;
2945
+ const nextLaunchConfig =
2946
+ pending.changes.launch_config_json ?? listing.launch_config_json;
2947
+ assertLaunchConfigSubmittable(String(nextLaunchType), String(nextLaunchConfig));
2948
+
2949
+ const result = await updateAppStoreListing(listing, {
2950
+ pending_update_status: 'PENDING_REVIEW',
2951
+ pending_update_submitted_at: new Date().toISOString(),
2952
+ });
2953
+
2954
+ await notifyAppStoreAdmins(ctx.user.profile, 'APP_LISTING_SUBMITTED', listing, {
2955
+ isUpdate: true,
2956
+ });
2957
+
2958
+ return result;
2959
+ }),
2960
+
2961
+ withdrawListingUpdate: profileRoute
2962
+ .meta({
2963
+ openapi: {
2964
+ protect: true,
2965
+ method: 'POST',
2966
+ path: '/app-store/listing/{listingId}/update/withdraw',
2967
+ tags: ['App Store'],
2968
+ summary: 'Withdraw Listing Update from Review',
2969
+ description: 'Return a submitted update to a LISTED app back to editable changes.',
2970
+ },
2971
+ requiredScope: 'app-store:write',
2972
+ })
2973
+ .input(z.object({ listingId: z.string() }))
2974
+ .output(z.boolean())
2975
+ .mutation(async ({ input, ctx }) => {
2976
+ const { listing } = await verifyListingOwnership(
2977
+ input.listingId,
2978
+ ctx.user.profile.profileId
2979
+ );
2980
+
2981
+ if (listing.pending_update_status !== 'PENDING_REVIEW') {
2982
+ throw new TRPCError({
2983
+ code: 'BAD_REQUEST',
2984
+ message: 'There is no update in review for this app.',
2985
+ });
2986
+ }
2987
+
2988
+ const result = await updateAppStoreListing(listing, { pending_update_status: 'DRAFT' });
2989
+
2990
+ await notifyAppStoreAdmins(ctx.user.profile, 'APP_LISTING_WITHDRAWN', listing, {
2991
+ isUpdate: true,
2992
+ });
2993
+
2994
+ return result;
2995
+ }),
2996
+
2997
+ discardListingUpdate: profileRoute
2998
+ .meta({
2999
+ openapi: {
3000
+ protect: true,
3001
+ method: 'POST',
3002
+ path: '/app-store/listing/{listingId}/update/discard',
3003
+ tags: ['App Store'],
3004
+ summary: 'Discard Listing Update',
3005
+ description: 'Throw away unsubmitted changes to a LISTED app.',
3006
+ },
3007
+ requiredScope: 'app-store:write',
3008
+ })
3009
+ .input(z.object({ listingId: z.string() }))
3010
+ .output(z.boolean())
3011
+ .mutation(async ({ input, ctx }) => {
3012
+ const { listing } = await verifyListingOwnership(
3013
+ input.listingId,
3014
+ ctx.user.profile.profileId
3015
+ );
3016
+
3017
+ if (listing.pending_update_status === 'PENDING_REVIEW') {
3018
+ throw new TRPCError({
3019
+ code: 'BAD_REQUEST',
3020
+ message: PENDING_UPDATE_IN_REVIEW_MESSAGE,
3021
+ });
3022
+ }
3023
+
3024
+ await clearPendingListingUpdate(listing.listing_id);
3025
+ return true;
3026
+ }),
3027
+
2119
3028
  deleteListing: profileRoute
2120
3029
  .meta({
2121
3030
  openapi: {
@@ -2124,14 +3033,34 @@ export const appStoreRouter = t.router({
2124
3033
  path: '/app-store/listing/{listingId}',
2125
3034
  tags: ['App Store'],
2126
3035
  summary: 'Delete App Store Listing',
2127
- description: 'Delete an App Store Listing',
3036
+ description:
3037
+ 'Delete an App Store Listing. Only draft or rejected listings may be deleted; in-review and live listings must be withdrawn or removed from the store first.',
2128
3038
  },
2129
3039
  requiredScope: 'app-store:delete',
2130
3040
  })
2131
3041
  .input(z.object({ listingId: z.string() }))
2132
3042
  .output(z.boolean())
2133
3043
  .mutation(async ({ input, ctx }) => {
2134
- await verifyListingOwnership(input.listingId, ctx.user.profile.profileId);
3044
+ const { listing } = await verifyListingOwnership(
3045
+ input.listingId,
3046
+ ctx.user.profile.profileId
3047
+ );
3048
+
3049
+ // ARCHIVED is how a rejected listing is represented (there is no
3050
+ // separate REJECTED status), so DRAFT and ARCHIVED are deletable.
3051
+ if (listing.app_listing_status === 'PENDING_REVIEW') {
3052
+ throw new TRPCError({
3053
+ code: 'PRECONDITION_FAILED',
3054
+ message: 'Only draft apps can be deleted. Withdraw it from review first.',
3055
+ });
3056
+ }
3057
+
3058
+ if (listing.app_listing_status === 'LISTED') {
3059
+ throw new TRPCError({
3060
+ code: 'PRECONDITION_FAILED',
3061
+ message: "Live apps can't be deleted. Remove it from the store first.",
3062
+ });
3063
+ }
2135
3064
 
2136
3065
  await deleteAppStoreListing(input.listingId);
2137
3066
 
@@ -2829,6 +3758,10 @@ export const appStoreRouter = t.router({
2829
3758
  );
2830
3759
  }
2831
3760
 
3761
+ if (eventType === 'upsert-consent-contract') {
3762
+ return handleUpsertConsentContractEvent(ctx, listing, resolvedListingId, event);
3763
+ }
3764
+
2832
3765
  if (eventType === 'send-ai-session-credential') {
2833
3766
  return handleSendAiSessionCredentialEvent(ctx, profile, resolvedListingId, event);
2834
3767
  }
@@ -2938,6 +3871,98 @@ export const appStoreRouter = t.router({
2938
3871
  return result;
2939
3872
  }),
2940
3873
 
3874
+ adminReviewListingUpdate: profileRoute
3875
+ .meta({
3876
+ openapi: {
3877
+ protect: true,
3878
+ method: 'POST',
3879
+ path: '/app-store/admin/listing/{listingId}/update/review',
3880
+ tags: ['App Store Admin'],
3881
+ summary: 'Review Listing Update (Admin)',
3882
+ description:
3883
+ 'Approve (apply to the live listing) or reject a submitted update to a LISTED app.',
3884
+ },
3885
+ requiredScope: 'app-store:admin',
3886
+ })
3887
+ .input(z.object({ listingId: z.string(), approve: z.boolean() }))
3888
+ .output(z.boolean())
3889
+ .mutation(async ({ input, ctx }) => {
3890
+ verifyAppStoreAdmin(ctx.user.profile.profileId);
3891
+
3892
+ const listing = await getListingOrThrow(input.listingId);
3893
+ const pending = readPendingUpdate(listing);
3894
+
3895
+ if (listing.pending_update_status !== 'PENDING_REVIEW' || !pending) {
3896
+ throw new TRPCError({
3897
+ code: 'BAD_REQUEST',
3898
+ message: 'This app has no update waiting for review.',
3899
+ });
3900
+ }
3901
+
3902
+ const integration = await getIntegrationForListing(input.listingId);
3903
+ const ownerProfile = integration
3904
+ ? await getOwnerProfileForIntegration(integration.id)
3905
+ : null;
3906
+
3907
+ if (input.approve) {
3908
+ if (pending.manifestVersion !== undefined) {
3909
+ if (!integration || !ownerProfile) {
3910
+ throw new TRPCError({
3911
+ code: 'NOT_FOUND',
3912
+ message: 'Integration for this listing was not found',
3913
+ });
3914
+ }
3915
+ const manifestVersion = await getManifestVersionForIntegration(
3916
+ integration.id,
3917
+ pending.manifestVersion
3918
+ );
3919
+ if (manifestVersion) {
3920
+ await reconcileManifestVersionForListing({
3921
+ ctx,
3922
+ integration,
3923
+ integrationOwner: ownerProfile,
3924
+ listing,
3925
+ manifestVersion,
3926
+ });
3927
+ }
3928
+ }
3929
+
3930
+ await updateAppStoreListing(listing, pending.changes as AppStoreListingUpdateType);
3931
+ await clearPendingListingUpdate(listing.listing_id);
3932
+ } else {
3933
+ await updateAppStoreListing(listing, { pending_update_status: 'DRAFT' });
3934
+ }
3935
+
3936
+ if (ownerProfile) {
3937
+ const approvedName =
3938
+ typeof pending.changes.display_name === 'string' && input.approve
3939
+ ? pending.changes.display_name
3940
+ : listing.display_name;
3941
+
3942
+ await addNotificationToQueue({
3943
+ type: input.approve
3944
+ ? LCNNotificationTypeEnumValidator.enum.APP_LISTING_APPROVED
3945
+ : LCNNotificationTypeEnumValidator.enum.APP_LISTING_REJECTED,
3946
+ to: ownerProfile,
3947
+ from: ctx.user.profile,
3948
+ message: getNotificationMessage(
3949
+ input.approve ? 'appListingApproved' : 'appListingRejected',
3950
+ resolveRecipientLocale(ownerProfile),
3951
+ { displayName: approvedName }
3952
+ ),
3953
+ data: {
3954
+ metadata: {
3955
+ listingId: listing.listing_id,
3956
+ listingName: approvedName,
3957
+ isUpdate: true,
3958
+ },
3959
+ },
3960
+ });
3961
+ }
3962
+
3963
+ return true;
3964
+ }),
3965
+
2941
3966
  adminUpdatePromotionLevel: profileRoute
2942
3967
  .meta({
2943
3968
  openapi: {
@@ -2983,6 +4008,7 @@ export const appStoreRouter = t.router({
2983
4008
  limit: z.number().optional(),
2984
4009
  cursor: z.string().optional(),
2985
4010
  status: AppListingStatus.optional(),
4011
+ pendingUpdatesOnly: z.boolean().optional(),
2986
4012
  })
2987
4013
  .optional()
2988
4014
  )
@@ -3000,6 +4026,7 @@ export const appStoreRouter = t.router({
3000
4026
  status: input?.status,
3001
4027
  includeAllStatuses: !input?.status, // Include all if no specific status filter
3002
4028
  excludeDemoted: false, // Admin can see demoted listings
4029
+ pendingUpdatesOnly: input?.pendingUpdatesOnly,
3003
4030
  });
3004
4031
 
3005
4032
  const hasMore = results.length > limit;