@storyshelf/app 0.6.1 → 0.7.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 (364) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/credentials.mjs +3 -2
  26. package/dist/credentials.mjs.map +1 -1
  27. package/dist/git-status.mjs +46 -0
  28. package/dist/git-status.mjs.map +1 -0
  29. package/dist/hidden-data.mjs +24 -0
  30. package/dist/hidden-data.mjs.map +1 -0
  31. package/dist/index.d.mts +3 -3
  32. package/dist/index.mjs +202 -124
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/insights/budget.mjs +49 -0
  35. package/dist/insights/budget.mjs.map +1 -0
  36. package/dist/insights/deps.mjs +21 -0
  37. package/dist/insights/deps.mjs.map +1 -0
  38. package/dist/insights/errors.mjs +22 -0
  39. package/dist/insights/errors.mjs.map +1 -0
  40. package/dist/insights/health-panel.mjs +40 -0
  41. package/dist/insights/health-panel.mjs.map +1 -0
  42. package/dist/insights/job.mjs +103 -0
  43. package/dist/insights/job.mjs.map +1 -0
  44. package/dist/insights/load-health.mjs +59 -0
  45. package/dist/insights/load-health.mjs.map +1 -0
  46. package/dist/insights/load-triage.mjs +98 -0
  47. package/dist/insights/load-triage.mjs.map +1 -0
  48. package/dist/insights/panel.mjs +28 -0
  49. package/dist/insights/panel.mjs.map +1 -0
  50. package/dist/insights/request.mjs +113 -0
  51. package/dist/insights/request.mjs.map +1 -0
  52. package/dist/insights/stale.mjs +21 -0
  53. package/dist/insights/stale.mjs.map +1 -0
  54. package/dist/insights/usage-report.mjs +60 -0
  55. package/dist/insights/usage-report.mjs.map +1 -0
  56. package/dist/insights/view.mjs +48 -0
  57. package/dist/insights/view.mjs.map +1 -0
  58. package/dist/job-canceller.mjs +14 -0
  59. package/dist/job-canceller.mjs.map +1 -0
  60. package/dist/job-queue.mjs +51 -0
  61. package/dist/job-queue.mjs.map +1 -0
  62. package/dist/jobs-setup.mjs +132 -0
  63. package/dist/jobs-setup.mjs.map +1 -0
  64. package/dist/lifecycle.mjs +109 -19
  65. package/dist/lifecycle.mjs.map +1 -1
  66. package/dist/middleware/auth-gate.mjs +4 -2
  67. package/dist/middleware/auth-gate.mjs.map +1 -1
  68. package/dist/middleware/rate-limit.mjs +41 -33
  69. package/dist/middleware/rate-limit.mjs.map +1 -1
  70. package/dist/middleware/request-log.mjs +3 -2
  71. package/dist/middleware/request-log.mjs.map +1 -1
  72. package/dist/middleware/require-review.mjs +51 -0
  73. package/dist/middleware/require-review.mjs.map +1 -0
  74. package/dist/middleware/require-testing.mjs +41 -0
  75. package/dist/middleware/require-testing.mjs.map +1 -0
  76. package/dist/middleware/setup-gate.mjs.map +1 -1
  77. package/dist/middleware/store-scope.mjs +12 -2
  78. package/dist/middleware/store-scope.mjs.map +1 -1
  79. package/dist/notifications-pause.mjs +33 -0
  80. package/dist/notifications-pause.mjs.map +1 -0
  81. package/dist/notify.d.mts +2 -1
  82. package/dist/notify.mjs +78 -39
  83. package/dist/notify.mjs.map +1 -1
  84. package/dist/pages/a11y-violations.mjs +116 -0
  85. package/dist/pages/a11y-violations.mjs.map +1 -0
  86. package/dist/pages/admin-ai.mjs +78 -0
  87. package/dist/pages/admin-ai.mjs.map +1 -0
  88. package/dist/pages/admin-system-origin.mjs +123 -0
  89. package/dist/pages/admin-system-origin.mjs.map +1 -0
  90. package/dist/pages/admin-system-targets.mjs +53 -0
  91. package/dist/pages/admin-system-targets.mjs.map +1 -0
  92. package/dist/pages/admin-system.mjs +63 -13
  93. package/dist/pages/admin-system.mjs.map +1 -1
  94. package/dist/pages/build-detail.mjs +104 -76
  95. package/dist/pages/build-detail.mjs.map +1 -1
  96. package/dist/pages/build-diff-actions.mjs +87 -33
  97. package/dist/pages/build-diff-actions.mjs.map +1 -1
  98. package/dist/pages/build-diff-comments.mjs +16 -14
  99. package/dist/pages/build-diff-comments.mjs.map +1 -1
  100. package/dist/pages/build-diff-environment.mjs +82 -0
  101. package/dist/pages/build-diff-environment.mjs.map +1 -0
  102. package/dist/pages/build-diff-header.mjs +22 -4
  103. package/dist/pages/build-diff-header.mjs.map +1 -1
  104. package/dist/pages/build-diff-insight.mjs +113 -0
  105. package/dist/pages/build-diff-insight.mjs.map +1 -0
  106. package/dist/pages/build-diff-model.mjs +55 -10
  107. package/dist/pages/build-diff-model.mjs.map +1 -1
  108. package/dist/pages/build-diff-nav.mjs +28 -12
  109. package/dist/pages/build-diff-nav.mjs.map +1 -1
  110. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  111. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  112. package/dist/pages/build-diff-usage.mjs +63 -0
  113. package/dist/pages/build-diff-usage.mjs.map +1 -0
  114. package/dist/pages/build-diff-viewer.mjs +62 -5
  115. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  116. package/dist/pages/build-diff.mjs +34 -13
  117. package/dist/pages/build-diff.mjs.map +1 -1
  118. package/dist/pages/build-tests.mjs +74 -0
  119. package/dist/pages/build-tests.mjs.map +1 -0
  120. package/dist/pages/component-detail.mjs +249 -0
  121. package/dist/pages/component-detail.mjs.map +1 -0
  122. package/dist/pages/component-stories.mjs +168 -0
  123. package/dist/pages/component-stories.mjs.map +1 -0
  124. package/dist/pages/compute-jobs.mjs +13 -26
  125. package/dist/pages/compute-jobs.mjs.map +1 -1
  126. package/dist/pages/design-links.mjs +182 -0
  127. package/dist/pages/design-links.mjs.map +1 -0
  128. package/dist/pages/environment-change.mjs +53 -0
  129. package/dist/pages/environment-change.mjs.map +1 -0
  130. package/dist/pages/health-panel.mjs +126 -0
  131. package/dist/pages/health-panel.mjs.map +1 -0
  132. package/dist/pages/library-grid.mjs +105 -0
  133. package/dist/pages/library-grid.mjs.map +1 -0
  134. package/dist/pages/library-model.mjs +47 -0
  135. package/dist/pages/library-model.mjs.map +1 -0
  136. package/dist/pages/library-snapshots.mjs +130 -0
  137. package/dist/pages/library-snapshots.mjs.map +1 -0
  138. package/dist/pages/library.mjs +112 -218
  139. package/dist/pages/library.mjs.map +1 -1
  140. package/dist/pages/notifications-paused.mjs +22 -0
  141. package/dist/pages/notifications-paused.mjs.map +1 -0
  142. package/dist/pages/profile.mjs +53 -48
  143. package/dist/pages/profile.mjs.map +1 -1
  144. package/dist/pages/project-builds-table.mjs +2 -2
  145. package/dist/pages/project-builds-table.mjs.map +1 -1
  146. package/dist/pages/project-builds.mjs +51 -21
  147. package/dist/pages/project-builds.mjs.map +1 -1
  148. package/dist/pages/project-settings.mjs +51 -12
  149. package/dist/pages/project-settings.mjs.map +1 -1
  150. package/dist/pages/projects-card.mjs +14 -1
  151. package/dist/pages/projects-card.mjs.map +1 -1
  152. package/dist/pages/projects.mjs +6 -2
  153. package/dist/pages/projects.mjs.map +1 -1
  154. package/dist/pages/review-changeset.mjs +116 -0
  155. package/dist/pages/review-changeset.mjs.map +1 -0
  156. package/dist/pages/review-comments.mjs +103 -0
  157. package/dist/pages/review-comments.mjs.map +1 -0
  158. package/dist/pages/review-verdict.mjs +102 -0
  159. package/dist/pages/review-verdict.mjs.map +1 -0
  160. package/dist/pages/settings-ai.mjs +69 -0
  161. package/dist/pages/settings-ai.mjs.map +1 -0
  162. package/dist/pages/settings-capture.mjs +124 -0
  163. package/dist/pages/settings-capture.mjs.map +1 -0
  164. package/dist/pages/settings-design-credential.mjs +99 -0
  165. package/dist/pages/settings-design-credential.mjs.map +1 -0
  166. package/dist/pages/settings-design-links.mjs +179 -0
  167. package/dist/pages/settings-design-links.mjs.map +1 -0
  168. package/dist/pages/settings-design.mjs +48 -0
  169. package/dist/pages/settings-design.mjs.map +1 -0
  170. package/dist/pages/settings-general.mjs +46 -21
  171. package/dist/pages/settings-general.mjs.map +1 -1
  172. package/dist/pages/settings-labels.mjs +43 -2
  173. package/dist/pages/settings-labels.mjs.map +1 -1
  174. package/dist/pages/settings-links.mjs +112 -0
  175. package/dist/pages/settings-links.mjs.map +1 -0
  176. package/dist/pages/settings-notifications-git.mjs +128 -0
  177. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  178. package/dist/pages/settings-notifications.mjs +79 -42
  179. package/dist/pages/settings-notifications.mjs.map +1 -1
  180. package/dist/pages/settings-tests.mjs +33 -2
  181. package/dist/pages/settings-tests.mjs.map +1 -1
  182. package/dist/pages/target-choices.mjs +39 -0
  183. package/dist/pages/target-choices.mjs.map +1 -0
  184. package/dist/publish-setup.mjs +89 -0
  185. package/dist/publish-setup.mjs.map +1 -0
  186. package/dist/retention-timer.mjs +2 -1
  187. package/dist/retention-timer.mjs.map +1 -1
  188. package/dist/review-events.mjs +48 -0
  189. package/dist/review-events.mjs.map +1 -0
  190. package/dist/routers/admin-pages.mjs +45 -3
  191. package/dist/routers/admin-pages.mjs.map +1 -1
  192. package/dist/routers/admin-public-origin.mjs +85 -0
  193. package/dist/routers/admin-public-origin.mjs.map +1 -0
  194. package/dist/routers/assets.mjs +9 -2
  195. package/dist/routers/assets.mjs.map +1 -1
  196. package/dist/routers/auth.mjs +2 -2
  197. package/dist/routers/auth.mjs.map +1 -1
  198. package/dist/routers/baseline-guard.mjs +2 -2
  199. package/dist/routers/baseline-guard.mjs.map +1 -1
  200. package/dist/routers/baselines.handlers.mjs +64 -0
  201. package/dist/routers/baselines.handlers.mjs.map +1 -0
  202. package/dist/routers/baselines.mjs +125 -0
  203. package/dist/routers/baselines.mjs.map +1 -0
  204. package/dist/routers/build-cancel.mjs +68 -0
  205. package/dist/routers/build-cancel.mjs.map +1 -0
  206. package/dist/routers/build-changeset.mjs +70 -0
  207. package/dist/routers/build-changeset.mjs.map +1 -0
  208. package/dist/routers/build-reviews.handlers.mjs +86 -0
  209. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  210. package/dist/routers/build-reviews.mjs +74 -0
  211. package/dist/routers/build-reviews.mjs.map +1 -0
  212. package/dist/routers/builds.handlers.mjs +46 -55
  213. package/dist/routers/builds.handlers.mjs.map +1 -1
  214. package/dist/routers/builds.mjs +71 -17
  215. package/dist/routers/builds.mjs.map +1 -1
  216. package/dist/routers/capture-config.mjs +35 -0
  217. package/dist/routers/capture-config.mjs.map +1 -0
  218. package/dist/routers/comments.mjs +41 -15
  219. package/dist/routers/comments.mjs.map +1 -1
  220. package/dist/routers/component-catalog.handlers.mjs +71 -0
  221. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  222. package/dist/routers/component.handlers.mjs +121 -0
  223. package/dist/routers/component.handlers.mjs.map +1 -0
  224. package/dist/routers/connections.mjs +118 -0
  225. package/dist/routers/connections.mjs.map +1 -0
  226. package/dist/routers/design-connect.handlers.mjs +44 -0
  227. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  228. package/dist/routers/design.handlers.mjs +114 -0
  229. package/dist/routers/design.handlers.mjs.map +1 -0
  230. package/dist/routers/health-report.mjs +2 -3
  231. package/dist/routers/health-report.mjs.map +1 -1
  232. package/dist/routers/health.mjs +4 -3
  233. package/dist/routers/health.mjs.map +1 -1
  234. package/dist/routers/helpers.mjs +19 -3
  235. package/dist/routers/helpers.mjs.map +1 -1
  236. package/dist/routers/insights-ui.mjs +64 -0
  237. package/dist/routers/insights-ui.mjs.map +1 -0
  238. package/dist/routers/insights.handlers.mjs +45 -0
  239. package/dist/routers/insights.handlers.mjs.map +1 -0
  240. package/dist/routers/insights.mjs +189 -0
  241. package/dist/routers/insights.mjs.map +1 -0
  242. package/dist/routers/links.handlers.mjs +80 -0
  243. package/dist/routers/links.handlers.mjs.map +1 -0
  244. package/dist/routers/links.mjs +139 -0
  245. package/dist/routers/links.mjs.map +1 -0
  246. package/dist/routers/media.mjs +18 -4
  247. package/dist/routers/media.mjs.map +1 -1
  248. package/dist/routers/members.mjs +2 -1
  249. package/dist/routers/members.mjs.map +1 -1
  250. package/dist/routers/notification-channels.mjs +17 -23
  251. package/dist/routers/notification-channels.mjs.map +1 -1
  252. package/dist/routers/oauth-state.mjs +45 -0
  253. package/dist/routers/oauth-state.mjs.map +1 -0
  254. package/dist/routers/preview-base.mjs +38 -0
  255. package/dist/routers/preview-base.mjs.map +1 -0
  256. package/dist/routers/preview-token.mjs +51 -0
  257. package/dist/routers/preview-token.mjs.map +1 -0
  258. package/dist/routers/profile.mjs +4 -2
  259. package/dist/routers/profile.mjs.map +1 -1
  260. package/dist/routers/projects.mjs +17 -1
  261. package/dist/routers/projects.mjs.map +1 -1
  262. package/dist/routers/publish-upload.mjs +78 -0
  263. package/dist/routers/publish-upload.mjs.map +1 -0
  264. package/dist/routers/published-access.mjs +52 -0
  265. package/dist/routers/published-access.mjs.map +1 -0
  266. package/dist/routers/published-files.mjs +52 -0
  267. package/dist/routers/published-files.mjs.map +1 -0
  268. package/dist/routers/review-forms.mjs +72 -0
  269. package/dist/routers/review-forms.mjs.map +1 -0
  270. package/dist/routers/review-section.handlers.mjs +85 -0
  271. package/dist/routers/review-section.handlers.mjs.map +1 -0
  272. package/dist/routers/review-switch-off.mjs +29 -0
  273. package/dist/routers/review-switch-off.mjs.map +1 -0
  274. package/dist/routers/schemas.mjs +140 -22
  275. package/dist/routers/schemas.mjs.map +1 -1
  276. package/dist/routers/scoped-models.mjs +22 -0
  277. package/dist/routers/scoped-models.mjs.map +1 -0
  278. package/dist/routers/settings-ai.mjs +35 -0
  279. package/dist/routers/settings-ai.mjs.map +1 -0
  280. package/dist/routers/settings-capture.mjs +77 -0
  281. package/dist/routers/settings-capture.mjs.map +1 -0
  282. package/dist/routers/settings-design.mjs +103 -0
  283. package/dist/routers/settings-design.mjs.map +1 -0
  284. package/dist/routers/settings-general.mjs +55 -10
  285. package/dist/routers/settings-general.mjs.map +1 -1
  286. package/dist/routers/settings-git-channel.mjs +77 -0
  287. package/dist/routers/settings-git-channel.mjs.map +1 -0
  288. package/dist/routers/settings-labels.mjs +27 -1
  289. package/dist/routers/settings-labels.mjs.map +1 -1
  290. package/dist/routers/settings-links.mjs +63 -0
  291. package/dist/routers/settings-links.mjs.map +1 -0
  292. package/dist/routers/settings-members.mjs +4 -7
  293. package/dist/routers/settings-members.mjs.map +1 -1
  294. package/dist/routers/settings-notifications.mjs +15 -10
  295. package/dist/routers/settings-notifications.mjs.map +1 -1
  296. package/dist/routers/settings-tokens.mjs +2 -5
  297. package/dist/routers/settings-tokens.mjs.map +1 -1
  298. package/dist/routers/settings-webhooks.mjs +7 -24
  299. package/dist/routers/settings-webhooks.mjs.map +1 -1
  300. package/dist/routers/settings.handlers.mjs +107 -26
  301. package/dist/routers/settings.handlers.mjs.map +1 -1
  302. package/dist/routers/settings.mjs +16 -4
  303. package/dist/routers/settings.mjs.map +1 -1
  304. package/dist/routers/signed-storybook.mjs +53 -0
  305. package/dist/routers/signed-storybook.mjs.map +1 -0
  306. package/dist/routers/snapshot-groups.mjs +63 -0
  307. package/dist/routers/snapshot-groups.mjs.map +1 -0
  308. package/dist/routers/snapshots.mjs +103 -5
  309. package/dist/routers/snapshots.mjs.map +1 -1
  310. package/dist/routers/storybook.mjs +37 -88
  311. package/dist/routers/storybook.mjs.map +1 -1
  312. package/dist/routers/tokens.mjs +2 -5
  313. package/dist/routers/tokens.mjs.map +1 -1
  314. package/dist/routers/ui.mjs +34 -5
  315. package/dist/routers/ui.mjs.map +1 -1
  316. package/dist/routers/webhooks.mjs +5 -11
  317. package/dist/routers/webhooks.mjs.map +1 -1
  318. package/dist/routers/well-known.mjs +11 -4
  319. package/dist/routers/well-known.mjs.map +1 -1
  320. package/dist/routes/api-routes.mjs +93 -0
  321. package/dist/routes/api-routes.mjs.map +1 -0
  322. package/dist/routes/classification.mjs +38 -0
  323. package/dist/routes/classification.mjs.map +1 -0
  324. package/dist/routes/openapi-classes.mjs +22 -0
  325. package/dist/routes/openapi-classes.mjs.map +1 -0
  326. package/dist/routes/page-routes.mjs +104 -0
  327. package/dist/routes/page-routes.mjs.map +1 -0
  328. package/dist/runtime.mjs +18 -13
  329. package/dist/runtime.mjs.map +1 -1
  330. package/dist/serverless.mjs +5 -0
  331. package/dist/store.mjs +19 -1
  332. package/dist/store.mjs.map +1 -1
  333. package/dist/targets.mjs +46 -0
  334. package/dist/targets.mjs.map +1 -0
  335. package/dist/ui/buttons.mjs +16 -3
  336. package/dist/ui/buttons.mjs.map +1 -1
  337. package/dist/ui/design-reference.mjs +97 -0
  338. package/dist/ui/design-reference.mjs.map +1 -0
  339. package/dist/ui/document.mjs +1 -2
  340. package/dist/ui/document.mjs.map +1 -1
  341. package/dist/ui/forms.mjs +6 -5
  342. package/dist/ui/forms.mjs.map +1 -1
  343. package/dist/ui/icons/paths.mjs +1 -0
  344. package/dist/ui/icons/paths.mjs.map +1 -1
  345. package/dist/ui/preview-embed.mjs +82 -0
  346. package/dist/ui/preview-embed.mjs.map +1 -0
  347. package/dist/ui/shell/client-script.mjs +85 -0
  348. package/dist/ui/shell/client-script.mjs.map +1 -1
  349. package/dist/ui/shell/nav.mjs +12 -8
  350. package/dist/ui/shell/nav.mjs.map +1 -1
  351. package/dist/ui/shell/review-script.mjs +8 -0
  352. package/dist/ui/shell/review-script.mjs.map +1 -1
  353. package/dist/ui/shell/sidebar.mjs +2 -2
  354. package/dist/ui/shell/sidebar.mjs.map +1 -1
  355. package/package.json +11 -6
  356. package/dist/asset-paths.mjs +0 -23
  357. package/dist/asset-paths.mjs.map +0 -1
  358. package/dist/assets/mark.svg +0 -1
  359. package/dist/pages/settings-status.mjs +0 -137
  360. package/dist/pages/settings-status.mjs.map +0 -1
  361. package/dist/routers/settings-status.mjs +0 -77
  362. package/dist/routers/settings-status.mjs.map +0 -1
  363. package/dist/routers/status-configs.mjs +0 -108
  364. package/dist/routers/status-configs.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Auth, EngineLoginMethod } from \"@storyshelf/auth\";\nimport { MIN_PASSWORD_LENGTH } from \"@storyshelf/auth\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport { SESSION_COOKIE } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notifySystem } from \"../notify.ts\";\nimport { renderInviteInvalidPage, renderInvitePage } from \"../pages/invite.tsx\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { syncLoginMemberships } from \"./auth-sync.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Provider ids are URL path segments: strict charset, bounded length. */\nfunction validProviderId(value: string): boolean {\n return /^[a-zA-Z0-9_-]{1,64}$/u.test(value);\n}\n\nconst ENGINE_PASSWORD_ACTION = \"/auth/engine/login\";\n\n/** Forward a request to the mounted engine, preserving origin and cookies. */\nexport async function forwardToEngine(\n c: Context,\n engine: Auth,\n path: string,\n body?: unknown,\n): Promise<Response> {\n return await forwardEngineRequest(c, engine, path, \"POST\", body);\n}\n\nasync function forwardEngineRequest(\n c: Context,\n engine: Auth,\n path: string,\n method: \"GET\" | \"POST\",\n body?: unknown,\n): Promise<Response> {\n const headers = new Headers();\n for (const name of [\"cookie\", \"origin\", \"referer\", \"user-agent\"]) {\n const value = c.req.header(name);\n if (value) {\n headers.set(name, value);\n }\n }\n if (method === \"POST\") {\n headers.set(\"content-type\", \"application/json\");\n }\n return await engine.handler(\n new Request(new URL(path, c.req.url), {\n method,\n headers,\n ...(method === \"POST\" ? { body: JSON.stringify(body ?? {}) } : {}),\n }),\n );\n}\n\nfunction copySetCookies(c: Context, res: Response): void {\n const cookies =\n typeof res.headers.getSetCookie === \"function\"\n ? res.headers.getSetCookie()\n : splitSetCookie(res.headers.get(\"set-cookie\"));\n for (const cookie of cookies) {\n c.header(\"set-cookie\", cookie, { append: true });\n }\n}\n\n/** Split a joined Set-Cookie header (runtimes without getSetCookie). */\nfunction splitSetCookie(header: string | null): string[] {\n if (!header) {\n return [];\n }\n // Split on commas starting a new `name=` pair; Expires dates (\"Wed, 21\")\n // never match (no `=` after the token). Fallback only — Node has getSetCookie.\n return header\n .split(/,\\s*(?=[^;\\s=]+=)/u)\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n}\n\nfunction loginLinks(\n methods: EngineLoginMethod[],\n): { kind: \"password\" | \"oauth\" | \"passkey\" | \"sso\"; id: string; label: string; url: string }[] {\n return methods.map((method) => ({\n ...method,\n // Passkey sign-in is JS-driven on the page; the url is an unused fallback.\n url:\n method.kind === \"oauth\" || method.kind === \"sso\"\n ? `/auth/engine/${method.id}`\n : ENGINE_PASSWORD_ACTION,\n }));\n}\n\nasync function renderEngineLogin(\n c: Context,\n engine: Auth,\n error?: string,\n email?: string,\n status?: 200 | 400 | 401 | 429 | 500 | 502,\n): Promise<Response> {\n return c.html(\n await renderLoginPage({\n error,\n email,\n engineMethods: loginLinks(engine.loginMethods()),\n }),\n status ?? 200,\n );\n}\n\n/** Membership sync for first-seen engine users only (never demote existing roles). */\nasync function syncEngineUser(user: { id: string; email: string; name: string }): Promise<void> {\n const existing = await new UserModel(getStore().db).get(user.id);\n if (existing) {\n return;\n }\n await syncLoginMemberships(\n getStore().db,\n {\n users: getStore().db.tables.users,\n projects: getStore().db.tables.projects,\n projectMembers: getStore().db.tables.projectMembers,\n projectGroupMappings: getStore().db.tables.projectGroupMappings,\n },\n { ...user, role: \"member\" },\n );\n}\n\nasync function readCredentials(c: Context): Promise<{ email: string; password: string }> {\n const form = await c.req.formData();\n const rawEmail = form.get(\"email\");\n const rawPassword = form.get(\"password\");\n return {\n email: typeof rawEmail === \"string\" ? rawEmail : \"\",\n password: typeof rawPassword === \"string\" ? rawPassword : \"\",\n };\n}\n\nasync function finishCredentialLogin(\n c: Context,\n engine: Auth,\n email: string,\n password: string,\n): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", { email, password });\n if (res.status !== 200) {\n // Generic message either way (no user enumeration), but propagate\n // rate-limit/upstream statuses instead of masking them as 401.\n if (res.status === 429) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 429);\n }\n if (res.status >= 500) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 502);\n }\n if (res.status === 400) {\n return renderEngineLogin(c, engine, \"Enter a valid email address\", email, 400);\n }\n // Credential failures only (never rate-limit/upstream noise, never\n // secrets): admins opted into sys:* see a login-attempt signal. Spray\n // attempts can fan out here; the /auth/* rate-limit bucket bounds it.\n await notifySystem(\"sys:auth-failed\", { email });\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 401);\n }\n copySetCookies(c, res);\n const body = (await res.json()) as { user: { id: string; email: string; name: string } };\n await syncEngineUser(body.user);\n return hxRedirect(c, \"/\");\n}\n\nasync function handleEngineLogin(c: Context, engine: Auth): Promise<Response> {\n const { email, password } = await readCredentials(c);\n return finishCredentialLogin(c, engine, email, password);\n}\n\nfunction resolveEngineMethod(c: Context, engine: Auth): EngineLoginMethod | null {\n const id = c.req.param(\"providerId\") ?? \"\";\n if (!validProviderId(id)) {\n throw new HTTPException(400, { message: \"Unknown provider\" });\n }\n return (\n engine.loginMethods().find((entry) => entry.id === id && entry.kind !== \"password\") ?? null\n );\n}\n\nasync function finishOAuthStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/social\", {\n provider: id,\n callbackURL: \"/\",\n disableRedirect: true,\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function finishSSOStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/sso\", {\n providerId: id,\n callbackURL: \"/\",\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function handleEngineStart(c: Context, engine: Auth): Promise<Response> {\n const method = resolveEngineMethod(c, engine);\n if (!method || method.kind === \"passkey\") {\n return c.notFound();\n }\n if (method.kind === \"sso\") {\n return await finishSSOStart(c, engine, method.id);\n }\n return await finishOAuthStart(c, engine, method.id);\n}\n\nasync function handleInvitePage(c: Context, engine: Auth): Promise<Response> {\n const inviteId = c.req.param(\"inviteId\") ?? \"\";\n const token = c.req.query(\"token\") ?? \"\";\n // The invite token renders into the page: never store.\n c.header(\"Cache-Control\", \"no-store\");\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n try {\n const { email, name } = await engine.verifyInvite({ inviteId, token });\n return c.html(await renderInvitePage({ inviteId, token, email, name }));\n } catch {\n return c.html(renderInviteInvalidPage(\"Invalid or expired invite\"), 400);\n }\n}\n\nasync function readInviteForm(c: Context): Promise<{\n inviteId: string;\n token: string;\n password: string;\n confirm: string;\n}> {\n const form = await c.req.formData();\n return {\n inviteId: c.req.param(\"inviteId\") ?? \"\",\n token: formText(form.get(\"token\")),\n password: formText(form.get(\"password\")),\n confirm: formText(form.get(\"confirm\")),\n };\n}\n\nfunction formText(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nasync function finishInviteAccept(\n c: Context,\n engine: Auth,\n inviteId: string,\n token: string,\n password: string,\n): Promise<Response> {\n const user = await engine.acceptInvite({ inviteId, token, password });\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", {\n email: user.email,\n password,\n });\n if (res.status !== 200) {\n throw new Error(\"Sign in failed\");\n }\n copySetCookies(c, res);\n await syncEngineUser({ id: user.id, email: user.email, name: user.name });\n return c.redirect(\"/profile\");\n}\n\nasync function inviteAcceptError(\n c: Context,\n inviteId: string,\n token: string,\n error: unknown,\n): Promise<Response> {\n const message = error instanceof Error ? error.message : \"Invalid or expired invite\";\n if (message.includes(`at least ${MIN_PASSWORD_LENGTH}`)) {\n return c.html(await renderInvitePage({ inviteId, token, error: message }), 400);\n }\n return c.html(renderInviteInvalidPage(message), 400);\n}\n\nasync function handleInviteAccept(c: Context, engine: Auth): Promise<Response> {\n const { inviteId, token, password, confirm } = await readInviteForm(c);\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n if (password !== confirm) {\n return c.html(\n await renderInvitePage({ inviteId, token, error: \"Passwords do not match\" }),\n 400,\n );\n }\n try {\n return await finishInviteAccept(c, engine, inviteId, token, password);\n } catch (error) {\n return await inviteAcceptError(c, inviteId, token, error);\n }\n}\n\n/** Register the engine mount and descriptor-driven login routes. */\nexport function registerEngineAuth(app: ShelfRouter, engine: Auth): void {\n app.all(\"/api/auth/*\", async (c) => await engine.handler(c.req.raw));\n\n app.get(\"/auth/login\", async (c) => {\n const methods = engine.loginMethods();\n const sole = methods.length === 1 ? methods.at(0) : undefined;\n if (sole?.kind === \"oauth\" || sole?.kind === \"sso\") {\n return c.redirect(`/auth/engine/${sole.id}`);\n }\n return await renderEngineLogin(c, engine);\n });\n\n // A failed POST leaves the browser on this URL; a reload must not 404.\n app.get(\"/auth/engine/login\", (c) => c.redirect(\"/auth/login\"));\n\n app.post(\"/auth/engine/login\", async (c) => await handleEngineLogin(c, engine));\n\n app.get(\"/auth/engine/:providerId\", async (c) => await handleEngineStart(c, engine));\n\n app.get(\"/auth/invites/:inviteId\", async (c) => await handleInvitePage(c, engine));\n\n app.post(\"/auth/invites/:inviteId\", async (c) => await handleInviteAccept(c, engine));\n\n app.post(\"/auth/logout\", async (c) => {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-out\", {});\n copySetCookies(c, res);\n // Mirror the Secure attribute or the clear-cookie misses Secure session\n // cookies over HTTPS (behind TLS-terminating proxies check forwarded proto).\n const secure =\n new URL(c.req.url).protocol === \"https:\" || c.req.header(\"x-forwarded-proto\") === \"https\";\n c.header(\n \"set-cookie\",\n `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? \"; Secure\" : \"\"}`,\n { append: true },\n );\n return hxRedirect(c, \"/auth/login\");\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Auth, EngineLoginMethod } from \"@storyshelf/core/auth\";\nimport { MIN_PASSWORD_LENGTH } from \"@storyshelf/core/auth\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport { SESSION_COOKIE } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notifySystem } from \"../notify.ts\";\nimport { renderInviteInvalidPage, renderInvitePage } from \"../pages/invite.tsx\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { syncLoginMemberships } from \"./auth-sync.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Provider ids are URL path segments: strict charset, bounded length. */\nfunction validProviderId(value: string): boolean {\n return /^[a-zA-Z0-9_-]{1,64}$/u.test(value);\n}\n\nconst ENGINE_PASSWORD_ACTION = \"/auth/engine/login\";\n\n/** Forward a request to the mounted engine, preserving origin and cookies. */\nexport async function forwardToEngine(\n c: Context,\n engine: Auth,\n path: string,\n body?: unknown,\n): Promise<Response> {\n return await forwardEngineRequest(c, engine, path, \"POST\", body);\n}\n\nasync function forwardEngineRequest(\n c: Context,\n engine: Auth,\n path: string,\n method: \"GET\" | \"POST\",\n body?: unknown,\n): Promise<Response> {\n const headers = new Headers();\n for (const name of [\"cookie\", \"origin\", \"referer\", \"user-agent\"]) {\n const value = c.req.header(name);\n if (value) {\n headers.set(name, value);\n }\n }\n if (method === \"POST\") {\n headers.set(\"content-type\", \"application/json\");\n }\n return await engine.handler(\n new Request(new URL(path, c.req.url), {\n method,\n headers,\n ...(method === \"POST\" ? { body: JSON.stringify(body ?? {}) } : {}),\n }),\n );\n}\n\nfunction copySetCookies(c: Context, res: Response): void {\n const cookies =\n typeof res.headers.getSetCookie === \"function\"\n ? res.headers.getSetCookie()\n : splitSetCookie(res.headers.get(\"set-cookie\"));\n for (const cookie of cookies) {\n c.header(\"set-cookie\", cookie, { append: true });\n }\n}\n\n/** Split a joined Set-Cookie header (runtimes without getSetCookie). */\nfunction splitSetCookie(header: string | null): string[] {\n if (!header) {\n return [];\n }\n // Split on commas starting a new `name=` pair; Expires dates (\"Wed, 21\")\n // never match (no `=` after the token). Fallback only — Node has getSetCookie.\n return header\n .split(/,\\s*(?=[^;\\s=]+=)/u)\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n}\n\nfunction loginLinks(\n methods: EngineLoginMethod[],\n): { kind: \"password\" | \"oauth\" | \"passkey\" | \"sso\"; id: string; label: string; url: string }[] {\n return methods.map((method) => ({\n ...method,\n // Passkey sign-in is JS-driven on the page; the url is an unused fallback.\n url:\n method.kind === \"oauth\" || method.kind === \"sso\"\n ? `/auth/engine/${method.id}`\n : ENGINE_PASSWORD_ACTION,\n }));\n}\n\nasync function renderEngineLogin(\n c: Context,\n engine: Auth,\n error?: string,\n email?: string,\n status?: 200 | 400 | 401 | 429 | 500 | 502,\n): Promise<Response> {\n return c.html(\n await renderLoginPage({\n error,\n email,\n engineMethods: loginLinks(engine.loginMethods()),\n }),\n status ?? 200,\n );\n}\n\n/** Membership sync for first-seen engine users only (never demote existing roles). */\nasync function syncEngineUser(user: { id: string; email: string; name: string }): Promise<void> {\n const existing = await new UserModel(getStore().db).get(user.id);\n if (existing) {\n return;\n }\n await syncLoginMemberships(\n getStore().db,\n {\n users: getStore().db.tables.users,\n projects: getStore().db.tables.projects,\n projectMembers: getStore().db.tables.projectMembers,\n projectGroupMappings: getStore().db.tables.projectGroupMappings,\n },\n { ...user, role: \"member\" },\n );\n}\n\nasync function readCredentials(c: Context): Promise<{ email: string; password: string }> {\n const form = await c.req.formData();\n const rawEmail = form.get(\"email\");\n const rawPassword = form.get(\"password\");\n return {\n email: typeof rawEmail === \"string\" ? rawEmail : \"\",\n password: typeof rawPassword === \"string\" ? rawPassword : \"\",\n };\n}\n\nasync function finishCredentialLogin(\n c: Context,\n engine: Auth,\n email: string,\n password: string,\n): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", { email, password });\n if (res.status !== 200) {\n // Generic message either way (no user enumeration), but propagate\n // rate-limit/upstream statuses instead of masking them as 401.\n if (res.status === 429) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 429);\n }\n if (res.status >= 500) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 502);\n }\n if (res.status === 400) {\n return renderEngineLogin(c, engine, \"Enter a valid email address\", email, 400);\n }\n // Credential failures only (never rate-limit/upstream noise, never\n // secrets): admins opted into sys:* see a login-attempt signal. Spray\n // attempts can fan out here; the /auth/* rate-limit bucket bounds it.\n await notifySystem(\"sys:auth-failed\", { email });\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 401);\n }\n copySetCookies(c, res);\n const body = (await res.json()) as { user: { id: string; email: string; name: string } };\n await syncEngineUser(body.user);\n return hxRedirect(c, \"/\");\n}\n\nasync function handleEngineLogin(c: Context, engine: Auth): Promise<Response> {\n const { email, password } = await readCredentials(c);\n return finishCredentialLogin(c, engine, email, password);\n}\n\nfunction resolveEngineMethod(c: Context, engine: Auth): EngineLoginMethod | null {\n const id = c.req.param(\"providerId\") ?? \"\";\n if (!validProviderId(id)) {\n throw new HTTPException(400, { message: \"Unknown provider\" });\n }\n return (\n engine.loginMethods().find((entry) => entry.id === id && entry.kind !== \"password\") ?? null\n );\n}\n\nasync function finishOAuthStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/social\", {\n provider: id,\n callbackURL: \"/\",\n disableRedirect: true,\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function finishSSOStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/sso\", {\n providerId: id,\n callbackURL: \"/\",\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function handleEngineStart(c: Context, engine: Auth): Promise<Response> {\n const method = resolveEngineMethod(c, engine);\n if (!method || method.kind === \"passkey\") {\n return c.notFound();\n }\n if (method.kind === \"sso\") {\n return await finishSSOStart(c, engine, method.id);\n }\n return await finishOAuthStart(c, engine, method.id);\n}\n\nasync function handleInvitePage(c: Context, engine: Auth): Promise<Response> {\n const inviteId = c.req.param(\"inviteId\") ?? \"\";\n const token = c.req.query(\"token\") ?? \"\";\n // The invite token renders into the page: never store.\n c.header(\"Cache-Control\", \"no-store\");\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n try {\n const { email, name } = await engine.verifyInvite({ inviteId, token });\n return c.html(await renderInvitePage({ inviteId, token, email, name }));\n } catch {\n return c.html(renderInviteInvalidPage(\"Invalid or expired invite\"), 400);\n }\n}\n\nasync function readInviteForm(c: Context): Promise<{\n inviteId: string;\n token: string;\n password: string;\n confirm: string;\n}> {\n const form = await c.req.formData();\n return {\n inviteId: c.req.param(\"inviteId\") ?? \"\",\n token: formText(form.get(\"token\")),\n password: formText(form.get(\"password\")),\n confirm: formText(form.get(\"confirm\")),\n };\n}\n\nfunction formText(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nasync function finishInviteAccept(\n c: Context,\n engine: Auth,\n inviteId: string,\n token: string,\n password: string,\n): Promise<Response> {\n const user = await engine.acceptInvite({ inviteId, token, password });\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", {\n email: user.email,\n password,\n });\n if (res.status !== 200) {\n throw new Error(\"Sign in failed\");\n }\n copySetCookies(c, res);\n await syncEngineUser({ id: user.id, email: user.email, name: user.name });\n return c.redirect(\"/profile\");\n}\n\nasync function inviteAcceptError(\n c: Context,\n inviteId: string,\n token: string,\n error: unknown,\n): Promise<Response> {\n const message = error instanceof Error ? error.message : \"Invalid or expired invite\";\n if (message.includes(`at least ${MIN_PASSWORD_LENGTH}`)) {\n return c.html(await renderInvitePage({ inviteId, token, error: message }), 400);\n }\n return c.html(renderInviteInvalidPage(message), 400);\n}\n\nasync function handleInviteAccept(c: Context, engine: Auth): Promise<Response> {\n const { inviteId, token, password, confirm } = await readInviteForm(c);\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n if (password !== confirm) {\n return c.html(\n await renderInvitePage({ inviteId, token, error: \"Passwords do not match\" }),\n 400,\n );\n }\n try {\n return await finishInviteAccept(c, engine, inviteId, token, password);\n } catch (error) {\n return await inviteAcceptError(c, inviteId, token, error);\n }\n}\n\n/** Register the engine mount and descriptor-driven login routes. */\nexport function registerEngineAuth(app: ShelfRouter, engine: Auth): void {\n app.all(\"/api/auth/*\", async (c) => await engine.handler(c.req.raw));\n\n app.get(\"/auth/login\", async (c) => {\n const methods = engine.loginMethods();\n const sole = methods.length === 1 ? methods.at(0) : undefined;\n if (sole?.kind === \"oauth\" || sole?.kind === \"sso\") {\n return c.redirect(`/auth/engine/${sole.id}`);\n }\n return await renderEngineLogin(c, engine);\n });\n\n // A failed POST leaves the browser on this URL; a reload must not 404.\n app.get(\"/auth/engine/login\", (c) => c.redirect(\"/auth/login\"));\n\n app.post(\"/auth/engine/login\", async (c) => await handleEngineLogin(c, engine));\n\n app.get(\"/auth/engine/:providerId\", async (c) => await handleEngineStart(c, engine));\n\n app.get(\"/auth/invites/:inviteId\", async (c) => await handleInvitePage(c, engine));\n\n app.post(\"/auth/invites/:inviteId\", async (c) => await handleInviteAccept(c, engine));\n\n app.post(\"/auth/logout\", async (c) => {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-out\", {});\n copySetCookies(c, res);\n // Mirror the Secure attribute or the clear-cookie misses Secure session\n // cookies over HTTPS (behind TLS-terminating proxies check forwarded proto).\n const secure =\n new URL(c.req.url).protocol === \"https:\" || c.req.header(\"x-forwarded-proto\") === \"https\";\n c.header(\n \"set-cookie\",\n `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? \"; Secure\" : \"\"}`,\n { append: true },\n );\n return hxRedirect(c, \"/auth/login\");\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;AAeA,SAAS,gBAAgB,OAAwB;CAC/C,OAAO,yBAAyB,KAAK,KAAK;AAC5C;AAEA,MAAM,yBAAyB;;AAG/B,eAAsB,gBACpB,GACA,QACA,MACA,MACmB;CACnB,OAAO,MAAM,qBAAqB,GAAG,QAAQ,MAAM,QAAQ,IAAI;AACjE;AAEA,eAAe,qBACb,GACA,QACA,MACA,QACA,MACmB;CACnB,MAAM,UAAU,IAAI,QAAQ;CAC5B,KAAK,MAAM,QAAQ;EAAC;EAAU;EAAU;EAAW;CAAY,GAAG;EAChE,MAAM,QAAQ,EAAE,IAAI,OAAO,IAAI;EAC/B,IAAI,OACF,QAAQ,IAAI,MAAM,KAAK;CAE3B;CACA,IAAI,WAAW,QACb,QAAQ,IAAI,gBAAgB,kBAAkB;CAEhD,OAAO,MAAM,OAAO,QAClB,IAAI,QAAQ,IAAI,IAAI,MAAM,EAAE,IAAI,GAAG,GAAG;EACpC;EACA;EACA,GAAI,WAAW,SAAS,EAAE,MAAM,KAAK,UAAU,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC;CAClE,CAAC,CACH;AACF;AAEA,SAAS,eAAe,GAAY,KAAqB;CACvD,MAAM,UACJ,OAAO,IAAI,QAAQ,iBAAiB,aAChC,IAAI,QAAQ,aAAa,IACzB,eAAe,IAAI,QAAQ,IAAI,YAAY,CAAC;CAClD,KAAK,MAAM,UAAU,SACnB,EAAE,OAAO,cAAc,QAAQ,EAAE,QAAQ,KAAK,CAAC;AAEnD;;AAGA,SAAS,eAAe,QAAiC;CACvD,IAAI,CAAC,QACH,OAAO,CAAC;CAIV,OAAO,OACJ,MAAM,oBAAoB,CAAC,CAC3B,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,CAC1B,QAAQ,SAAS,SAAS,EAAE;AACjC;AAEA,SAAS,WACP,SAC8F;CAC9F,OAAO,QAAQ,KAAK,YAAY;EAC9B,GAAG;EAEH,KACE,OAAO,SAAS,WAAW,OAAO,SAAS,QACvC,gBAAgB,OAAO,OACvB;CACR,EAAE;AACJ;AAEA,eAAe,kBACb,GACA,QACA,OACA,OACA,QACmB;CACnB,OAAO,EAAE,KACP,MAAM,gBAAgB;EACpB;EACA;EACA,eAAe,WAAW,OAAO,aAAa,CAAC;CACjD,CAAC,GACD,UAAU,GACZ;AACF;;AAGA,eAAe,eAAe,MAAkE;CAE9F,IAAI,MADmB,IAAI,UAAU,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,KAAK,EAAE,GAE7D;CAEF,MAAM,qBACJ,SAAS,CAAC,CAAC,IACX;EACE,OAAO,SAAS,CAAC,CAAC,GAAG,OAAO;EAC5B,UAAU,SAAS,CAAC,CAAC,GAAG,OAAO;EAC/B,gBAAgB,SAAS,CAAC,CAAC,GAAG,OAAO;EACrC,sBAAsB,SAAS,CAAC,CAAC,GAAG,OAAO;CAC7C,GACA;EAAE,GAAG;EAAM,MAAM;CAAS,CAC5B;AACF;AAEA,eAAe,gBAAgB,GAA0D;CACvF,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,MAAM,WAAW,KAAK,IAAI,OAAO;CACjC,MAAM,cAAc,KAAK,IAAI,UAAU;CACvC,OAAO;EACL,OAAO,OAAO,aAAa,WAAW,WAAW;EACjD,UAAU,OAAO,gBAAgB,WAAW,cAAc;CAC5D;AACF;AAEA,eAAe,sBACb,GACA,QACA,OACA,UACmB;CACnB,MAAM,MAAM,MAAM,gBAAgB,GAAG,QAAQ,2BAA2B;EAAE;EAAO;CAAS,CAAC;CAC3F,IAAI,IAAI,WAAW,KAAK;EAGtB,IAAI,IAAI,WAAW,KACjB,OAAO,kBAAkB,GAAG,QAAQ,uBAAuB,OAAO,GAAG;EAEvE,IAAI,IAAI,UAAU,KAChB,OAAO,kBAAkB,GAAG,QAAQ,uBAAuB,OAAO,GAAG;EAEvE,IAAI,IAAI,WAAW,KACjB,OAAO,kBAAkB,GAAG,QAAQ,+BAA+B,OAAO,GAAG;EAK/E,MAAM,aAAa,mBAAmB,EAAE,MAAM,CAAC;EAC/C,OAAO,kBAAkB,GAAG,QAAQ,uBAAuB,OAAO,GAAG;CACvE;CACA,eAAe,GAAG,GAAG;CAErB,MAAM,gBAAe,MADD,IAAI,KAAK,EAAA,CACH,IAAI;CAC9B,OAAO,WAAW,GAAG,GAAG;AAC1B;AAEA,eAAe,kBAAkB,GAAY,QAAiC;CAC5E,MAAM,EAAE,OAAO,aAAa,MAAM,gBAAgB,CAAC;CACnD,OAAO,sBAAsB,GAAG,QAAQ,OAAO,QAAQ;AACzD;AAEA,SAAS,oBAAoB,GAAY,QAAwC;CAC/E,MAAM,KAAK,EAAE,IAAI,MAAM,YAAY,KAAK;CACxC,IAAI,CAAC,gBAAgB,EAAE,GACrB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,mBAAmB,CAAC;CAE9D,OACE,OAAO,aAAa,CAAC,CAAC,MAAM,UAAU,MAAM,OAAO,MAAM,MAAM,SAAS,UAAU,KAAK;AAE3F;AAEA,eAAe,iBAAiB,GAAY,QAAc,IAA+B;CACvF,MAAM,MAAM,MAAM,gBAAgB,GAAG,QAAQ,4BAA4B;EACvE,UAAU;EACV,aAAa;EACb,iBAAiB;CACnB,CAAC;CACD,IAAI,IAAI,WAAW,KACjB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,iBAAiB,CAAC;CAE5D,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,KAAK,KACR,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,iBAAiB,CAAC;CAE5D,eAAe,GAAG,GAAG;CACrB,OAAO,EAAE,SAAS,KAAK,GAAG;AAC5B;AAEA,eAAe,eAAe,GAAY,QAAc,IAA+B;CACrF,MAAM,MAAM,MAAM,gBAAgB,GAAG,QAAQ,yBAAyB;EACpE,YAAY;EACZ,aAAa;CACf,CAAC;CACD,IAAI,IAAI,WAAW,KACjB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,iBAAiB,CAAC;CAE5D,MAAM,OAAQ,MAAM,IAAI,KAAK;CAC7B,IAAI,CAAC,KAAK,KACR,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,iBAAiB,CAAC;CAE5D,eAAe,GAAG,GAAG;CACrB,OAAO,EAAE,SAAS,KAAK,GAAG;AAC5B;AAEA,eAAe,kBAAkB,GAAY,QAAiC;CAC5E,MAAM,SAAS,oBAAoB,GAAG,MAAM;CAC5C,IAAI,CAAC,UAAU,OAAO,SAAS,WAC7B,OAAO,EAAE,SAAS;CAEpB,IAAI,OAAO,SAAS,OAClB,OAAO,MAAM,eAAe,GAAG,QAAQ,OAAO,EAAE;CAElD,OAAO,MAAM,iBAAiB,GAAG,QAAQ,OAAO,EAAE;AACpD;AAEA,eAAe,iBAAiB,GAAY,QAAiC;CAC3E,MAAM,WAAW,EAAE,IAAI,MAAM,UAAU,KAAK;CAC5C,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO,KAAK;CAEtC,EAAE,OAAO,iBAAiB,UAAU;CACpC,IAAI,CAAC,YAAY,CAAC,OAChB,OAAO,EAAE,KAAK,wBAAwB,gCAAgC,GAAG,GAAG;CAE9E,IAAI;EACF,MAAM,EAAE,OAAO,SAAS,MAAM,OAAO,aAAa;GAAE;GAAU;EAAM,CAAC;EACrE,OAAO,EAAE,KAAK,MAAM,iBAAiB;GAAE;GAAU;GAAO;GAAO;EAAK,CAAC,CAAC;CACxE,QAAQ;EACN,OAAO,EAAE,KAAK,wBAAwB,2BAA2B,GAAG,GAAG;CACzE;AACF;AAEA,eAAe,eAAe,GAK3B;CACD,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,OAAO;EACL,UAAU,EAAE,IAAI,MAAM,UAAU,KAAK;EACrC,OAAO,SAAS,KAAK,IAAI,OAAO,CAAC;EACjC,UAAU,SAAS,KAAK,IAAI,UAAU,CAAC;EACvC,SAAS,SAAS,KAAK,IAAI,SAAS,CAAC;CACvC;AACF;AAEA,SAAS,SAAS,OAAwB;CACxC,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;AAEA,eAAe,mBACb,GACA,QACA,UACA,OACA,UACmB;CACnB,MAAM,OAAO,MAAM,OAAO,aAAa;EAAE;EAAU;EAAO;CAAS,CAAC;CACpE,MAAM,MAAM,MAAM,gBAAgB,GAAG,QAAQ,2BAA2B;EACtE,OAAO,KAAK;EACZ;CACF,CAAC;CACD,IAAI,IAAI,WAAW,KACjB,MAAM,IAAI,MAAM,gBAAgB;CAElC,eAAe,GAAG,GAAG;CACrB,MAAM,eAAe;EAAE,IAAI,KAAK;EAAI,OAAO,KAAK;EAAO,MAAM,KAAK;CAAK,CAAC;CACxE,OAAO,EAAE,SAAS,UAAU;AAC9B;AAEA,eAAe,kBACb,GACA,UACA,OACA,OACmB;CACnB,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;CACzD,IAAI,QAAQ,SAAS,YAAY,qBAAqB,GACpD,OAAO,EAAE,KAAK,MAAM,iBAAiB;EAAE;EAAU;EAAO,OAAO;CAAQ,CAAC,GAAG,GAAG;CAEhF,OAAO,EAAE,KAAK,wBAAwB,OAAO,GAAG,GAAG;AACrD;AAEA,eAAe,mBAAmB,GAAY,QAAiC;CAC7E,MAAM,EAAE,UAAU,OAAO,UAAU,YAAY,MAAM,eAAe,CAAC;CACrE,IAAI,CAAC,YAAY,CAAC,OAChB,OAAO,EAAE,KAAK,wBAAwB,gCAAgC,GAAG,GAAG;CAE9E,IAAI,aAAa,SACf,OAAO,EAAE,KACP,MAAM,iBAAiB;EAAE;EAAU;EAAO,OAAO;CAAyB,CAAC,GAC3E,GACF;CAEF,IAAI;EACF,OAAO,MAAM,mBAAmB,GAAG,QAAQ,UAAU,OAAO,QAAQ;CACtE,SAAS,OAAO;EACd,OAAO,MAAM,kBAAkB,GAAG,UAAU,OAAO,KAAK;CAC1D;AACF;;AAGA,SAAgB,mBAAmB,KAAkB,QAAoB;CACvE,IAAI,IAAI,eAAe,OAAO,MAAM,MAAM,OAAO,QAAQ,EAAE,IAAI,GAAG,CAAC;CAEnE,IAAI,IAAI,eAAe,OAAO,MAAM;EAClC,MAAM,UAAU,OAAO,aAAa;EACpC,MAAM,OAAO,QAAQ,WAAW,IAAI,QAAQ,GAAG,CAAC,IAAI,KAAA;EACpD,IAAI,MAAM,SAAS,WAAW,MAAM,SAAS,OAC3C,OAAO,EAAE,SAAS,gBAAgB,KAAK,IAAI;EAE7C,OAAO,MAAM,kBAAkB,GAAG,MAAM;CAC1C,CAAC;CAGD,IAAI,IAAI,uBAAuB,MAAM,EAAE,SAAS,aAAa,CAAC;CAE9D,IAAI,KAAK,sBAAsB,OAAO,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC;CAE9E,IAAI,IAAI,4BAA4B,OAAO,MAAM,MAAM,kBAAkB,GAAG,MAAM,CAAC;CAEnF,IAAI,IAAI,2BAA2B,OAAO,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC;CAEjF,IAAI,KAAK,2BAA2B,OAAO,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC;CAEpF,IAAI,KAAK,gBAAgB,OAAO,MAAM;EAEpC,eAAe,GAAG,MADA,gBAAgB,GAAG,QAAQ,sBAAsB,CAAC,CAAC,CAChD;EAGrB,MAAM,SACJ,IAAI,IAAI,EAAE,IAAI,GAAG,CAAC,CAAC,aAAa,YAAY,EAAE,IAAI,OAAO,mBAAmB,MAAM;EACpF,EAAE,OACA,cACA,GAAG,eAAe,8CAA8C,SAAS,aAAa,MACtF,EAAE,QAAQ,KAAK,CACjB;EACA,OAAO,WAAW,GAAG,aAAa;CACpC,CAAC;AACH"}
@@ -12,7 +12,7 @@ function isOpenSnapshot(snapshot) {
12
12
  }
13
13
  /** Compare a snapshot's recorded baseline with the baseline that applies to it now. */
14
14
  async function snapshotBaselineStatus(project, build, snapshot) {
15
- const current = await new BaselineModel(getStore().db).resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch);
15
+ const current = await new BaselineModel(getStore().db).resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch, snapshot.browser);
16
16
  return baselineStatus(snapshot, current);
17
17
  }
18
18
  /** Open snapshots whose diff no longer matches the current baseline. */
@@ -63,7 +63,7 @@ async function loadBaselineView(project, build, snapshots) {
63
63
  const entries = await Promise.all(snapshots.map(async (snapshot) => {
64
64
  return {
65
65
  snapshot,
66
- current: await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch)
66
+ current: await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch, snapshot.browser)
67
67
  };
68
68
  }));
69
69
  const view = {
@@ -1 +1 @@
1
- {"version":3,"file":"baseline-guard.mjs","names":[],"sources":["../../src/routers/baseline-guard.ts"],"sourcesContent":["/** Refuse to promote a screenshot over a baseline the reviewer has not seen. */\nimport {\n BaselineModel,\n baselineStatus,\n isBaselineDrifted,\n type BaselineStatus,\n} from \"@storyshelf/core/models\";\nimport type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** Whether a snapshot is awaiting a review decision. */\nexport function isOpenSnapshot(snapshot: Pick<Snapshot, \"status\">): boolean {\n return snapshot.status === \"new\" || snapshot.status === \"changed\";\n}\n\n/** Compare a snapshot's recorded baseline with the baseline that applies to it now. */\nexport async function snapshotBaselineStatus(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n): Promise<BaselineStatus> {\n const baselines = new BaselineModel(getStore().db);\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n return baselineStatus(snapshot, current);\n}\n\n/** Open snapshots whose diff no longer matches the current baseline. */\nexport async function findDriftedSnapshots(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshots: Snapshot[],\n): Promise<Map<string, BaselineStatus>> {\n const open = snapshots.filter((snapshot) => isOpenSnapshot(snapshot));\n const statuses = await Promise.all(\n open.map(async (snapshot) => await snapshotBaselineStatus(project, build, snapshot)),\n );\n const drifted = new Map<string, BaselineStatus>();\n for (const [index, snapshot] of open.entries()) {\n const status = statuses[index];\n if (status && isBaselineDrifted(status)) {\n drifted.set(snapshot.id, status);\n }\n }\n return drifted;\n}\n\n/** 409 telling the client the diff is out of date; recompute it or force the approval. */\nexport function baselineChanged(snapshotId: string, status: BaselineStatus): HTTPException {\n const body = {\n code: \"baseline_changed\",\n message:\n \"The baseline changed after this diff was computed. Re-diff the build, or approve with force.\",\n snapshotId,\n baselineStatus: status,\n };\n return new HTTPException(409, {\n res: new Response(JSON.stringify(body), {\n status: 409,\n headers: { \"content-type\": \"application/json\" },\n }),\n });\n}\n\n/** Throw {@link baselineChanged} when an open snapshot's baseline drifted and `force` is off. */\nexport async function assertBaselineCurrent(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n force: boolean,\n): Promise<void> {\n if (!isOpenSnapshot(snapshot)) {\n return;\n }\n const status = await snapshotBaselineStatus(project, build, snapshot);\n if (!isBaselineDrifted(status)) {\n return;\n }\n if (force) {\n getStore().logger.warn(\n { snapshotId: snapshot.id, baselineStatus: status, userId: getStore().user?.id ?? null },\n \"approved snapshot despite changed baseline (forced)\",\n );\n return;\n }\n throw baselineChanged(snapshot.id, status);\n}\n\n/** True when `error` is the 409 raised by {@link baselineChanged}. */\nexport function isBaselineChanged(error: unknown): boolean {\n return error instanceof HTTPException && error.status === 409;\n}\n\n/** Per-snapshot baseline facts the review pages need. */\nexport interface BaselineView {\n /** Snapshot ids that have a baseline to compare against. */\n hasBaseline: Record<string, boolean>;\n /** Open snapshots whose recorded baseline no longer matches the current one. */\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/** Resolve each snapshot's current baseline once and derive presence plus drift. */\nexport async function loadBaselineView(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\" | \"isDefault\">,\n snapshots: Snapshot[],\n): Promise<BaselineView> {\n const baselines = new BaselineModel(getStore().db);\n const entries = await Promise.all(\n snapshots.map(async (snapshot) => {\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n return { snapshot, current };\n }),\n );\n const view: BaselineView = { hasBaseline: {}, drifted: {} };\n for (const { snapshot, current } of entries) {\n view.hasBaseline[snapshot.id] = Boolean(current);\n const status = baselineStatus(snapshot, current);\n if (\n !build.isDefault &&\n isOpenSnapshot(snapshot) &&\n (status === \"stale\" || status === \"removed\")\n ) {\n view.drifted[snapshot.id] = status;\n }\n }\n return view;\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,eAAe,UAA6C;CAC1E,OAAO,SAAS,WAAW,SAAS,SAAS,WAAW;AAC1D;;AAGA,eAAsB,uBACpB,SACA,OACA,UACyB;CAEzB,MAAM,UAAU,MAAM,IADA,cAAc,SAAS,CAAC,CAAC,EACjB,CAAC,CAAC,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;CACA,OAAO,eAAe,UAAU,OAAO;AACzC;;AAGA,eAAsB,qBACpB,SACA,OACA,WACsC;CACtC,MAAM,OAAO,UAAU,QAAQ,aAAa,eAAe,QAAQ,CAAC;CACpE,MAAM,WAAW,MAAM,QAAQ,IAC7B,KAAK,IAAI,OAAO,aAAa,MAAM,uBAAuB,SAAS,OAAO,QAAQ,CAAC,CACrF;CACA,MAAM,0BAAU,IAAI,IAA4B;CAChD,KAAK,MAAM,CAAC,OAAO,aAAa,KAAK,QAAQ,GAAG;EAC9C,MAAM,SAAS,SAAS;EACxB,IAAI,UAAU,kBAAkB,MAAM,GACpC,QAAQ,IAAI,SAAS,IAAI,MAAM;CAEnC;CACA,OAAO;AACT;;AAGA,SAAgB,gBAAgB,YAAoB,QAAuC;CAQzF,OAAO,IAAI,cAAc,KAAK,EAC5B,KAAK,IAAI,SAAS,KAAK,UAAU;EAPjC,MAAM;EACN,SACE;EACF;EACA,gBAAgB;CAGoB,CAAC,GAAG;EACtC,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC,EACH,CAAC;AACH;;AAGA,eAAsB,sBACpB,SACA,OACA,UACA,OACe;CACf,IAAI,CAAC,eAAe,QAAQ,GAC1B;CAEF,MAAM,SAAS,MAAM,uBAAuB,SAAS,OAAO,QAAQ;CACpE,IAAI,CAAC,kBAAkB,MAAM,GAC3B;CAEF,IAAI,OAAO;EACT,SAAS,CAAC,CAAC,OAAO,KAChB;GAAE,YAAY,SAAS;GAAI,gBAAgB;GAAQ,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;EAAK,GACvF,qDACF;EACA;CACF;CACA,MAAM,gBAAgB,SAAS,IAAI,MAAM;AAC3C;;AAGA,SAAgB,kBAAkB,OAAyB;CACzD,OAAO,iBAAiB,iBAAiB,MAAM,WAAW;AAC5D;;AAWA,eAAsB,iBACpB,SACA,OACA,WACuB;CACvB,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,UAAU,MAAM,QAAQ,IAC5B,UAAU,IAAI,OAAO,aAAa;EAQhC,OAAO;GAAE;GAAU,SAAA,MAPG,UAAU,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;EAC2B;CAC7B,CAAC,CACH;CACA,MAAM,OAAqB;EAAE,aAAa,CAAC;EAAG,SAAS,CAAC;CAAE;CAC1D,KAAK,MAAM,EAAE,UAAU,aAAa,SAAS;EAC3C,KAAK,YAAY,SAAS,MAAM,QAAQ,OAAO;EAC/C,MAAM,SAAS,eAAe,UAAU,OAAO;EAC/C,IACE,CAAC,MAAM,aACP,eAAe,QAAQ,MACtB,WAAW,WAAW,WAAW,YAElC,KAAK,QAAQ,SAAS,MAAM;CAEhC;CACA,OAAO;AACT"}
1
+ {"version":3,"file":"baseline-guard.mjs","names":[],"sources":["../../src/routers/baseline-guard.ts"],"sourcesContent":["/** Refuse to promote a screenshot over a baseline the reviewer has not seen. */\nimport {\n BaselineModel,\n baselineStatus,\n isBaselineDrifted,\n type BaselineStatus,\n} from \"@storyshelf/core/models\";\nimport type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** Whether a snapshot is awaiting a review decision. */\nexport function isOpenSnapshot(snapshot: Pick<Snapshot, \"status\">): boolean {\n return snapshot.status === \"new\" || snapshot.status === \"changed\";\n}\n\n/** Compare a snapshot's recorded baseline with the baseline that applies to it now. */\nexport async function snapshotBaselineStatus(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n): Promise<BaselineStatus> {\n const baselines = new BaselineModel(getStore().db);\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n return baselineStatus(snapshot, current);\n}\n\n/** Open snapshots whose diff no longer matches the current baseline. */\nexport async function findDriftedSnapshots(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshots: Snapshot[],\n): Promise<Map<string, BaselineStatus>> {\n const open = snapshots.filter((snapshot) => isOpenSnapshot(snapshot));\n const statuses = await Promise.all(\n open.map(async (snapshot) => await snapshotBaselineStatus(project, build, snapshot)),\n );\n const drifted = new Map<string, BaselineStatus>();\n for (const [index, snapshot] of open.entries()) {\n const status = statuses[index];\n if (status && isBaselineDrifted(status)) {\n drifted.set(snapshot.id, status);\n }\n }\n return drifted;\n}\n\n/** 409 telling the client the diff is out of date; recompute it or force the approval. */\nexport function baselineChanged(snapshotId: string, status: BaselineStatus): HTTPException {\n const body = {\n code: \"baseline_changed\",\n message:\n \"The baseline changed after this diff was computed. Re-diff the build, or approve with force.\",\n snapshotId,\n baselineStatus: status,\n };\n return new HTTPException(409, {\n res: new Response(JSON.stringify(body), {\n status: 409,\n headers: { \"content-type\": \"application/json\" },\n }),\n });\n}\n\n/** Throw {@link baselineChanged} when an open snapshot's baseline drifted and `force` is off. */\nexport async function assertBaselineCurrent(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n force: boolean,\n): Promise<void> {\n if (!isOpenSnapshot(snapshot)) {\n return;\n }\n const status = await snapshotBaselineStatus(project, build, snapshot);\n if (!isBaselineDrifted(status)) {\n return;\n }\n if (force) {\n getStore().logger.warn(\n { snapshotId: snapshot.id, baselineStatus: status, userId: getStore().user?.id ?? null },\n \"approved snapshot despite changed baseline (forced)\",\n );\n return;\n }\n throw baselineChanged(snapshot.id, status);\n}\n\n/** True when `error` is the 409 raised by {@link baselineChanged}. */\nexport function isBaselineChanged(error: unknown): boolean {\n return error instanceof HTTPException && error.status === 409;\n}\n\n/** Per-snapshot baseline facts the review pages need. */\nexport interface BaselineView {\n /** Snapshot ids that have a baseline to compare against. */\n hasBaseline: Record<string, boolean>;\n /** Open snapshots whose recorded baseline no longer matches the current one. */\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/** Resolve each snapshot's current baseline once and derive presence plus drift. */\nexport async function loadBaselineView(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\" | \"isDefault\">,\n snapshots: Snapshot[],\n): Promise<BaselineView> {\n const baselines = new BaselineModel(getStore().db);\n const entries = await Promise.all(\n snapshots.map(async (snapshot) => {\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n snapshot.browser,\n );\n return { snapshot, current };\n }),\n );\n const view: BaselineView = { hasBaseline: {}, drifted: {} };\n for (const { snapshot, current } of entries) {\n view.hasBaseline[snapshot.id] = Boolean(current);\n const status = baselineStatus(snapshot, current);\n if (\n !build.isDefault &&\n isOpenSnapshot(snapshot) &&\n (status === \"stale\" || status === \"removed\")\n ) {\n view.drifted[snapshot.id] = status;\n }\n }\n return view;\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,eAAe,UAA6C;CAC1E,OAAO,SAAS,WAAW,SAAS,SAAS,WAAW;AAC1D;;AAGA,eAAsB,uBACpB,SACA,OACA,UACyB;CAEzB,MAAM,UAAU,MAAM,IADA,cAAc,SAAS,CAAC,CAAC,EACjB,CAAC,CAAC,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,kBACR,SAAS,OACX;CACA,OAAO,eAAe,UAAU,OAAO;AACzC;;AAGA,eAAsB,qBACpB,SACA,OACA,WACsC;CACtC,MAAM,OAAO,UAAU,QAAQ,aAAa,eAAe,QAAQ,CAAC;CACpE,MAAM,WAAW,MAAM,QAAQ,IAC7B,KAAK,IAAI,OAAO,aAAa,MAAM,uBAAuB,SAAS,OAAO,QAAQ,CAAC,CACrF;CACA,MAAM,0BAAU,IAAI,IAA4B;CAChD,KAAK,MAAM,CAAC,OAAO,aAAa,KAAK,QAAQ,GAAG;EAC9C,MAAM,SAAS,SAAS;EACxB,IAAI,UAAU,kBAAkB,MAAM,GACpC,QAAQ,IAAI,SAAS,IAAI,MAAM;CAEnC;CACA,OAAO;AACT;;AAGA,SAAgB,gBAAgB,YAAoB,QAAuC;CAQzF,OAAO,IAAI,cAAc,KAAK,EAC5B,KAAK,IAAI,SAAS,KAAK,UAAU;EAPjC,MAAM;EACN,SACE;EACF;EACA,gBAAgB;CAGoB,CAAC,GAAG;EACtC,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC,EACH,CAAC;AACH;;AAGA,eAAsB,sBACpB,SACA,OACA,UACA,OACe;CACf,IAAI,CAAC,eAAe,QAAQ,GAC1B;CAEF,MAAM,SAAS,MAAM,uBAAuB,SAAS,OAAO,QAAQ;CACpE,IAAI,CAAC,kBAAkB,MAAM,GAC3B;CAEF,IAAI,OAAO;EACT,SAAS,CAAC,CAAC,OAAO,KAChB;GAAE,YAAY,SAAS;GAAI,gBAAgB;GAAQ,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;EAAK,GACvF,qDACF;EACA;CACF;CACA,MAAM,gBAAgB,SAAS,IAAI,MAAM;AAC3C;;AAGA,SAAgB,kBAAkB,OAAyB;CACzD,OAAO,iBAAiB,iBAAiB,MAAM,WAAW;AAC5D;;AAWA,eAAsB,iBACpB,SACA,OACA,WACuB;CACvB,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,UAAU,MAAM,QAAQ,IAC5B,UAAU,IAAI,OAAO,aAAa;EAShC,OAAO;GAAE;GAAU,SAAA,MARG,UAAU,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,kBACR,SAAS,OACX;EAC2B;CAC7B,CAAC,CACH;CACA,MAAM,OAAqB;EAAE,aAAa,CAAC;EAAG,SAAS,CAAC;CAAE;CAC1D,KAAK,MAAM,EAAE,UAAU,aAAa,SAAS;EAC3C,KAAK,YAAY,SAAS,MAAM,QAAQ,OAAO;EAC/C,MAAM,SAAS,eAAe,UAAU,OAAO;EAC/C,IACE,CAAC,MAAM,aACP,eAAe,QAAQ,MACtB,WAAW,WAAW,WAAW,YAElC,KAAK,QAAQ,SAAS,MAAM;CAEhC;CACA,OAAO;AACT"}
@@ -0,0 +1,64 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { DEFAULT_MAX_UPLOAD_BYTES } from "@storyshelf/core/config";
5
+ import { BASELINE_ARCHIVE_LIMITS } from "@storyshelf/core/models";
6
+ import { HTTPException } from "hono/http-exception";
7
+ //#region src/routers/baselines.handlers.ts
8
+ /** HTTP helpers for the baseline export/import router. */
9
+ /** Ceiling for a buffered import archive, whatever `maxUploadBytes` allows. */
10
+ const MAX_BASELINE_IMPORT_BYTES = BASELINE_ARCHIVE_LIMITS.maxArchiveBytes;
11
+ /** Byte limit for an import body: `maxUploadBytes`, capped because the archive is buffered. */
12
+ function importByteLimit(config) {
13
+ return Math.min(config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES, MAX_BASELINE_IMPORT_BYTES);
14
+ }
15
+ /**
16
+ * Buffer a request body, failing fast with 413 once it passes `maxBytes`
17
+ * (checked against `content-length` up front and while streaming).
18
+ */
19
+ async function readCappedBody(request, maxBytes) {
20
+ if (Number(request.headers.get("content-length") ?? 0) > maxBytes) throw tooLarge(maxBytes);
21
+ if (!request.body) throw new HTTPException(400, { message: "Missing request body" });
22
+ return await collectCapped(request.body.getReader(), maxBytes);
23
+ }
24
+ async function collectCapped(reader, maxBytes) {
25
+ const chunks = [];
26
+ let seen = 0;
27
+ let next = await reader.read();
28
+ while (!next.done && seen <= maxBytes) {
29
+ seen += next.value.length;
30
+ chunks.push(next.value);
31
+ next = await reader.read();
32
+ }
33
+ if (seen > maxBytes) await abortTooLarge(reader, maxBytes);
34
+ return Buffer.concat(chunks);
35
+ }
36
+ async function abortTooLarge(reader, maxBytes) {
37
+ await reader.cancel();
38
+ throw tooLarge(maxBytes);
39
+ }
40
+ function tooLarge(maxBytes) {
41
+ return new HTTPException(413, { message: `Archive exceeds ${maxBytes} bytes` });
42
+ }
43
+ /** Adapt a lazily produced chunk sequence to a pull-based web stream. */
44
+ function toWebStream(chunks) {
45
+ const iterator = chunks[Symbol.asyncIterator]();
46
+ return new ReadableStream({
47
+ async pull(controller) {
48
+ const next = await iterator.next();
49
+ if (next.done) controller.close();
50
+ else controller.enqueue(new Uint8Array(next.value));
51
+ },
52
+ async cancel() {
53
+ await iterator.return?.();
54
+ }
55
+ });
56
+ }
57
+ /** Download file name: `<slug>-baselines[-<branch>].zip`, header-safe. */
58
+ function archiveFileName(slug, branch) {
59
+ return `${(branch ? `${slug}-baselines-${branch}` : `${slug}-baselines`).replaceAll(/[^\w.-]+/gu, "_")}.zip`;
60
+ }
61
+ //#endregion
62
+ export { MAX_BASELINE_IMPORT_BYTES, archiveFileName, importByteLimit, readCappedBody, toWebStream };
63
+
64
+ //# sourceMappingURL=baselines.handlers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baselines.handlers.mjs","names":[],"sources":["../../src/routers/baselines.handlers.ts"],"sourcesContent":["/** HTTP helpers for the baseline export/import router. */\nimport { DEFAULT_MAX_UPLOAD_BYTES, type ShelfConfig } from \"@storyshelf/core/config\";\nimport { BASELINE_ARCHIVE_LIMITS } from \"@storyshelf/core/models\";\nimport { HTTPException } from \"hono/http-exception\";\n\n/** Ceiling for a buffered import archive, whatever `maxUploadBytes` allows. */\nexport const MAX_BASELINE_IMPORT_BYTES = BASELINE_ARCHIVE_LIMITS.maxArchiveBytes;\n\n/** Byte limit for an import body: `maxUploadBytes`, capped because the archive is buffered. */\nexport function importByteLimit(config: Pick<ShelfConfig, \"maxUploadBytes\">): number {\n return Math.min(config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES, MAX_BASELINE_IMPORT_BYTES);\n}\n\n/**\n * Buffer a request body, failing fast with 413 once it passes `maxBytes`\n * (checked against `content-length` up front and while streaming).\n */\nexport async function readCappedBody(request: Request, maxBytes: number): Promise<Buffer> {\n if (Number(request.headers.get(\"content-length\") ?? 0) > maxBytes) {\n throw tooLarge(maxBytes);\n }\n if (!request.body) {\n throw new HTTPException(400, { message: \"Missing request body\" });\n }\n return await collectCapped(request.body.getReader(), maxBytes);\n}\n\nasync function collectCapped(\n reader: ReadableStreamDefaultReader<Uint8Array>,\n maxBytes: number,\n): Promise<Buffer> {\n const chunks: Uint8Array[] = [];\n let seen = 0;\n let next = await reader.read();\n while (!next.done && seen <= maxBytes) {\n seen += next.value.length;\n chunks.push(next.value);\n // oxlint-disable-next-line eslint/no-await-in-loop -- reading a stream is inherently sequential\n next = await reader.read();\n }\n if (seen > maxBytes) {\n await abortTooLarge(reader, maxBytes);\n }\n return Buffer.concat(chunks);\n}\n\nasync function abortTooLarge(\n reader: ReadableStreamDefaultReader<Uint8Array>,\n maxBytes: number,\n): Promise<never> {\n await reader.cancel();\n throw tooLarge(maxBytes);\n}\n\nfunction tooLarge(maxBytes: number): HTTPException {\n return new HTTPException(413, { message: `Archive exceeds ${maxBytes} bytes` });\n}\n\n/** Adapt a lazily produced chunk sequence to a pull-based web stream. */\nexport function toWebStream(chunks: AsyncIterable<Buffer>): ReadableStream<Uint8Array> {\n const iterator = chunks[Symbol.asyncIterator]();\n return new ReadableStream<Uint8Array>({\n async pull(controller): Promise<void> {\n const next = await iterator.next();\n if (next.done) {\n controller.close();\n } else {\n controller.enqueue(new Uint8Array(next.value));\n }\n },\n async cancel(): Promise<void> {\n await iterator.return?.();\n },\n });\n}\n\n/** Download file name: `<slug>-baselines[-<branch>].zip`, header-safe. */\nexport function archiveFileName(slug: string, branch?: string): string {\n const base = branch ? `${slug}-baselines-${branch}` : `${slug}-baselines`;\n return `${base.replaceAll(/[^\\w.-]+/gu, \"_\")}.zip`;\n}\n"],"mappings":";;;;;;;;;AAMA,MAAa,4BAA4B,wBAAwB;;AAGjE,SAAgB,gBAAgB,QAAqD;CACnF,OAAO,KAAK,IAAI,OAAO,kBAAkB,0BAA0B,yBAAyB;AAC9F;;;;;AAMA,eAAsB,eAAe,SAAkB,UAAmC;CACxF,IAAI,OAAO,QAAQ,QAAQ,IAAI,gBAAgB,KAAK,CAAC,IAAI,UACvD,MAAM,SAAS,QAAQ;CAEzB,IAAI,CAAC,QAAQ,MACX,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,uBAAuB,CAAC;CAElE,OAAO,MAAM,cAAc,QAAQ,KAAK,UAAU,GAAG,QAAQ;AAC/D;AAEA,eAAe,cACb,QACA,UACiB;CACjB,MAAM,SAAuB,CAAC;CAC9B,IAAI,OAAO;CACX,IAAI,OAAO,MAAM,OAAO,KAAK;CAC7B,OAAO,CAAC,KAAK,QAAQ,QAAQ,UAAU;EACrC,QAAQ,KAAK,MAAM;EACnB,OAAO,KAAK,KAAK,KAAK;EAEtB,OAAO,MAAM,OAAO,KAAK;CAC3B;CACA,IAAI,OAAO,UACT,MAAM,cAAc,QAAQ,QAAQ;CAEtC,OAAO,OAAO,OAAO,MAAM;AAC7B;AAEA,eAAe,cACb,QACA,UACgB;CAChB,MAAM,OAAO,OAAO;CACpB,MAAM,SAAS,QAAQ;AACzB;AAEA,SAAS,SAAS,UAAiC;CACjD,OAAO,IAAI,cAAc,KAAK,EAAE,SAAS,mBAAmB,SAAS,QAAQ,CAAC;AAChF;;AAGA,SAAgB,YAAY,QAA2D;CACrF,MAAM,WAAW,OAAO,OAAO,cAAc,CAAC;CAC9C,OAAO,IAAI,eAA2B;EACpC,MAAM,KAAK,YAA2B;GACpC,MAAM,OAAO,MAAM,SAAS,KAAK;GACjC,IAAI,KAAK,MACP,WAAW,MAAM;QAEjB,WAAW,QAAQ,IAAI,WAAW,KAAK,KAAK,CAAC;EAEjD;EACA,MAAM,SAAwB;GAC5B,MAAM,SAAS,SAAS;EAC1B;CACF,CAAC;AACH;;AAGA,SAAgB,gBAAgB,MAAc,QAAyB;CAErE,OAAO,IADM,SAAS,GAAG,KAAK,aAAa,WAAW,GAAG,KAAK,YAAA,CAC/C,WAAW,cAAc,GAAG,EAAE;AAC/C"}
@@ -0,0 +1,125 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { badRequest, errorSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
7
+ import { archiveFileName, importByteLimit, readCappedBody, toWebStream } from "./baselines.handlers.mjs";
8
+ import { createRoute, z } from "@hono/zod-openapi";
9
+ import { BaselineArchiveError, BaselineTransferModel } from "@storyshelf/core/models";
10
+ import { HTTPException } from "hono/http-exception";
11
+ //#region src/routers/baselines.ts
12
+ /**
13
+ * Baseline portability endpoints: export a project's baselines as a zip
14
+ * (`manifest.json` + PNGs, format v1) and import (upsert) the same format.
15
+ * Format, limits, and validation live in `@storyshelf/core/models`
16
+ * (`BaselineTransferModel`); this router owns HTTP concerns only.
17
+ */
18
+ const VIEW_ROLES = [
19
+ "viewer",
20
+ "developer",
21
+ "approver",
22
+ "admin"
23
+ ];
24
+ const APPROVER_ROLES = ["approver", "admin"];
25
+ const zipSchema = z.string().openapi({
26
+ type: "string",
27
+ format: "binary"
28
+ });
29
+ const importResultSchema = z.object({
30
+ imported: z.number().int().openapi({ description: "Baselines in the archive (all applied)" }),
31
+ created: z.number().int().openapi({ description: "Baselines that did not exist before" }),
32
+ updated: z.number().int().openapi({ description: "Existing baselines overwritten" })
33
+ }).openapi("BaselineImportResult");
34
+ const exportRoute = createRoute({
35
+ method: "get",
36
+ tags: ["Baselines"],
37
+ summary: "Export baselines as a zip archive",
38
+ description: "Streams `manifest.json` (story/mode/browser/branch → file, SHA-256, timestamps) plus one PNG per baseline at `baselines/<branch>/<storyId>/<mode>.png`. Baselines whose image cannot be read are listed under `skipped`.",
39
+ path: "/api/v1/projects/{slug}/baselines/export",
40
+ request: {
41
+ params: z.object({ slug: z.string() }),
42
+ query: z.object({ branch: z.string().min(1).optional().openapi({ description: "Export only this branch" }) })
43
+ },
44
+ responses: {
45
+ 200: {
46
+ content: { "application/zip": { schema: zipSchema } },
47
+ description: "Baseline archive"
48
+ },
49
+ ...badRequest,
50
+ ...unauthorized,
51
+ ...forbidden,
52
+ ...notFound
53
+ }
54
+ });
55
+ const importRoute = createRoute({
56
+ method: "post",
57
+ tags: ["Baselines"],
58
+ summary: "Import (upsert) baselines from a zip archive",
59
+ description: "Accepts an archive produced by the export endpoint. The whole archive is validated (manifest schema, path safety, PNG signature, SHA-256) before anything is written; baselines with the same story/mode/browser/branch are overwritten. Requires the approver role.",
60
+ path: "/api/v1/projects/{slug}/baselines/import",
61
+ request: {
62
+ params: z.object({ slug: z.string() }),
63
+ body: {
64
+ content: { "application/zip": { schema: zipSchema } },
65
+ required: true
66
+ }
67
+ },
68
+ responses: {
69
+ 200: {
70
+ content: { "application/json": { schema: importResultSchema } },
71
+ description: "Baselines imported"
72
+ },
73
+ 413: {
74
+ content: { "application/json": { schema: errorSchema } },
75
+ description: "Archive exceeds the size limit"
76
+ },
77
+ ...badRequest,
78
+ ...unauthorized,
79
+ ...forbidden,
80
+ ...notFound
81
+ }
82
+ });
83
+ /** Map archive validation failures to 400s; rethrow everything else. */
84
+ function asBadRequest(error) {
85
+ if (error instanceof BaselineArchiveError) throw new HTTPException(400, { message: error.message });
86
+ throw error;
87
+ }
88
+ /** Register the baseline export/import endpoints. */
89
+ function registerBaselines(app) {
90
+ app.openapi(exportRoute, async (c) => {
91
+ const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
92
+ const { branch } = c.req.valid("query");
93
+ const { db, storage, logger } = getStore();
94
+ const chunks = await new BaselineTransferModel(db, storage).export(project.id, {
95
+ branch,
96
+ project: project.slug,
97
+ onSkipped: (baseline) => {
98
+ logger.warn({
99
+ projectId: project.id,
100
+ ...baseline
101
+ }, "baseline image skipped in export");
102
+ }
103
+ }).catch(asBadRequest);
104
+ return new Response(toWebStream(chunks), { headers: {
105
+ "content-type": "application/zip",
106
+ "content-disposition": `attachment; filename="${archiveFileName(project.slug, branch)}"`,
107
+ "cache-control": "no-store"
108
+ } });
109
+ });
110
+ app.openapi(importRoute, async (c) => {
111
+ const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...APPROVER_ROLES);
112
+ const archive = await readCappedBody(c.req.raw, importByteLimit(getStore().config));
113
+ const { db, storage } = getStore();
114
+ const result = await new BaselineTransferModel(db, storage).import(project.id, archive).catch(asBadRequest);
115
+ getStore().logger.info({
116
+ projectId: project.id,
117
+ ...result
118
+ }, "baselines imported");
119
+ return c.json(result, 200);
120
+ });
121
+ }
122
+ //#endregion
123
+ export { registerBaselines };
124
+
125
+ //# sourceMappingURL=baselines.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"baselines.mjs","names":[],"sources":["../../src/routers/baselines.ts"],"sourcesContent":["/**\n * Baseline portability endpoints: export a project's baselines as a zip\n * (`manifest.json` + PNGs, format v1) and import (upsert) the same format.\n * Format, limits, and validation live in `@storyshelf/core/models`\n * (`BaselineTransferModel`); this router owns HTTP concerns only.\n */\nimport { createRoute, z } from \"@hono/zod-openapi\";\nimport { BaselineArchiveError, BaselineTransferModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport {\n archiveFileName,\n importByteLimit,\n readCappedBody,\n toWebStream,\n} from \"./baselines.handlers.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { badRequest, errorSchema, forbidden, notFound, unauthorized } from \"./schemas.ts\";\n\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\nconst zipSchema = z.string().openapi({ type: \"string\", format: \"binary\" });\nconst importResultSchema = z\n .object({\n imported: z.number().int().openapi({ description: \"Baselines in the archive (all applied)\" }),\n created: z.number().int().openapi({ description: \"Baselines that did not exist before\" }),\n updated: z.number().int().openapi({ description: \"Existing baselines overwritten\" }),\n })\n .openapi(\"BaselineImportResult\");\n\nconst exportRoute = createRoute({\n method: \"get\",\n tags: [\"Baselines\"],\n summary: \"Export baselines as a zip archive\",\n description:\n \"Streams `manifest.json` (story/mode/browser/branch → file, SHA-256, timestamps) plus one PNG per baseline at `baselines/<branch>/<storyId>/<mode>.png`. Baselines whose image cannot be read are listed under `skipped`.\",\n path: \"/api/v1/projects/{slug}/baselines/export\",\n request: {\n params: z.object({ slug: z.string() }),\n query: z.object({\n branch: z.string().min(1).optional().openapi({ description: \"Export only this branch\" }),\n }),\n },\n responses: {\n 200: { content: { \"application/zip\": { schema: zipSchema } }, description: \"Baseline archive\" },\n ...badRequest,\n ...unauthorized,\n ...forbidden,\n ...notFound,\n },\n});\n\nconst importRoute = createRoute({\n method: \"post\",\n tags: [\"Baselines\"],\n summary: \"Import (upsert) baselines from a zip archive\",\n description:\n \"Accepts an archive produced by the export endpoint. The whole archive is validated (manifest schema, path safety, PNG signature, SHA-256) before anything is written; baselines with the same story/mode/browser/branch are overwritten. Requires the approver role.\",\n path: \"/api/v1/projects/{slug}/baselines/import\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/zip\": { schema: zipSchema } }, required: true },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: importResultSchema } },\n description: \"Baselines imported\",\n },\n 413: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Archive exceeds the size limit\",\n },\n ...badRequest,\n ...unauthorized,\n ...forbidden,\n ...notFound,\n },\n});\n\n/** Map archive validation failures to 400s; rethrow everything else. */\nfunction asBadRequest(error: unknown): never {\n if (error instanceof BaselineArchiveError) {\n throw new HTTPException(400, { message: error.message });\n }\n throw error;\n}\n\n/** Register the baseline export/import endpoints. */\nexport function registerBaselines(app: ShelfRouter): void {\n app.openapi(exportRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { branch } = c.req.valid(\"query\");\n const { db, storage, logger } = getStore();\n const chunks = await new BaselineTransferModel(db, storage)\n .export(project.id, {\n branch,\n project: project.slug,\n onSkipped: (baseline): void => {\n logger.warn({ projectId: project.id, ...baseline }, \"baseline image skipped in export\");\n },\n })\n .catch(asBadRequest);\n return new Response(toWebStream(chunks), {\n headers: {\n \"content-type\": \"application/zip\",\n \"content-disposition\": `attachment; filename=\"${archiveFileName(project.slug, branch)}\"`,\n \"cache-control\": \"no-store\",\n },\n });\n });\n\n app.openapi(importRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...APPROVER_ROLES);\n const archive = await readCappedBody(c.req.raw, importByteLimit(getStore().config));\n const { db, storage } = getStore();\n const result = await new BaselineTransferModel(db, storage)\n .import(project.id, archive)\n .catch(asBadRequest);\n getStore().logger.info({ projectId: project.id, ...result }, \"baselines imported\");\n return c.json(result, 200);\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { hxRefresh } from "./htmx.mjs";
7
+ import { flashHx } from "./flash.mjs";
8
+ import { buildSchema, errorSchema, notFound, unauthorized } from "./schemas.mjs";
9
+ import { DEVELOPER_ROLES, announceBuildStatus, buildForProject } from "./builds.handlers.mjs";
10
+ import { createRoute, z } from "@hono/zod-openapi";
11
+ import { BuildModel, CaptureAttemptModel } from "@storyshelf/core/models";
12
+ import { HTTPException } from "hono/http-exception";
13
+ import { CANCELLABLE_BUILD_STATUSES } from "@storyshelf/core/types";
14
+ //#region src/routers/build-cancel.ts
15
+ const cancelBuildRoute = createRoute({
16
+ method: "post",
17
+ tags: ["Builds"],
18
+ summary: "Cancel a build",
19
+ description: "Stops the work of a build that has not finished capturing: its queued jobs (capture, publish and notification jobs alike) end cancelled, running ones are signalled and stop within seconds, and the build ends `cancelled`, a terminal status that a capture finishing late cannot move (retry resets it like a failed build). It raises `build:cancelled` (webhook and notification topic), never `build:failed` or a capture-failed alert. `testing` class. 409 when the build is not pending, capturing or comparing.",
20
+ path: "/api/v1/projects/{slug}/builds/{buildId}/cancel",
21
+ request: { params: z.object({
22
+ slug: z.string(),
23
+ buildId: z.string()
24
+ }) },
25
+ responses: {
26
+ 200: {
27
+ content: { "application/json": { schema: buildSchema } },
28
+ description: "The build's jobs were cancelled and the build marked cancelled"
29
+ },
30
+ 409: {
31
+ content: { "application/json": { schema: errorSchema } },
32
+ description: "The build is not in a cancellable state"
33
+ },
34
+ ...notFound,
35
+ ...unauthorized
36
+ }
37
+ });
38
+ /** Register `POST …/builds/:buildId/cancel` (`testing` class). */
39
+ function registerBuildCancel(app) {
40
+ app.openapi(cancelBuildRoute, async (c) => {
41
+ const { slug, buildId } = c.req.valid("param");
42
+ const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
43
+ const build = await buildForProject(project.id, buildId);
44
+ if (!CANCELLABLE_BUILD_STATUSES.includes(build.status)) throw new HTTPException(409, { message: `A ${build.status} build cannot be cancelled` });
45
+ const updated = await cancelBuild(build.id);
46
+ hxRefresh(c);
47
+ flashHx(c, "Build cancelled");
48
+ return c.json(updated, 200);
49
+ });
50
+ }
51
+ /**
52
+ * Cancel the build's jobs of every kind, close its open attempts, then end the build `cancelled` and
53
+ * announce it. A capture still running notices the cancel on its next check (within seconds) and
54
+ * stops; whatever it still writes cannot move the build off `cancelled`. The jobs end first so the
55
+ * cancel's own notification job is not cancelled.
56
+ */
57
+ async function cancelBuild(buildId) {
58
+ const { db, cancelBuildJobs } = getStore();
59
+ await cancelBuildJobs?.(buildId);
60
+ await new CaptureAttemptModel(db).closeOthers(buildId, null, "Cancelled by a user");
61
+ const updated = await new BuildModel(db).setStatus(buildId, "cancelled");
62
+ await announceBuildStatus(buildId, "cancelled");
63
+ return updated;
64
+ }
65
+ //#endregion
66
+ export { registerBuildCancel };
67
+
68
+ //# sourceMappingURL=build-cancel.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-cancel.mjs","names":[],"sources":["../../src/routers/build-cancel.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { BuildModel, CaptureAttemptModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport { CANCELLABLE_BUILD_STATUSES } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { DEVELOPER_ROLES, announceBuildStatus, buildForProject } from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRefresh } from \"./htmx.ts\";\nimport { buildSchema, errorSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst cancelBuildRoute = createRoute({\n method: \"post\",\n tags: [\"Builds\"],\n summary: \"Cancel a build\",\n description:\n \"Stops the work of a build that has not finished capturing: its queued jobs (capture, publish and notification jobs alike) end cancelled, running ones are signalled and stop within seconds, and the build ends `cancelled`, a terminal status that a capture finishing late cannot move (retry resets it like a failed build). It raises `build:cancelled` (webhook and notification topic), never `build:failed` or a capture-failed alert. `testing` class. 409 when the build is not pending, capturing or comparing.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/cancel\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: buildSchema } },\n description: \"The build's jobs were cancelled and the build marked cancelled\",\n },\n 409: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"The build is not in a cancellable state\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\n/** Register `POST …/builds/:buildId/cancel` (`testing` class). */\nexport function registerBuildCancel(app: ShelfRouter): void {\n app.openapi(cancelBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n if (!CANCELLABLE_BUILD_STATUSES.includes(build.status)) {\n throw new HTTPException(409, { message: `A ${build.status} build cannot be cancelled` });\n }\n const updated = await cancelBuild(build.id);\n hxRefresh(c);\n flashHx(c, \"Build cancelled\");\n return c.json(updated, 200);\n });\n}\n\n/**\n * Cancel the build's jobs of every kind, close its open attempts, then end the build `cancelled` and\n * announce it. A capture still running notices the cancel on its next check (within seconds) and\n * stops; whatever it still writes cannot move the build off `cancelled`. The jobs end first so the\n * cancel's own notification job is not cancelled.\n */\nasync function cancelBuild(buildId: string): Promise<Build> {\n const { db, cancelBuildJobs } = getStore();\n await cancelBuildJobs?.(buildId);\n await new CaptureAttemptModel(db).closeOthers(buildId, null, \"Cancelled by a user\");\n const updated = await new BuildModel(db).setStatus(buildId, \"cancelled\");\n await announceBuildStatus(buildId, \"cancelled\");\n return updated;\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,MAAM,mBAAmB,YAAY;CACnC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAgB,oBAAoB,KAAwB;CAC1D,IAAI,QAAQ,kBAAkB,OAAO,MAAM;EACzC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,IAAI,CAAC,2BAA2B,SAAS,MAAM,MAAM,GACnD,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,KAAK,MAAM,OAAO,4BAA4B,CAAC;EAEzF,MAAM,UAAU,MAAM,YAAY,MAAM,EAAE;EAC1C,UAAU,CAAC;EACX,QAAQ,GAAG,iBAAiB;EAC5B,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;AACH;;;;;;;AAQA,eAAe,YAAY,SAAiC;CAC1D,MAAM,EAAE,IAAI,oBAAoB,SAAS;CACzC,MAAM,kBAAkB,OAAO;CAC/B,MAAM,IAAI,oBAAoB,EAAE,CAAC,CAAC,YAAY,SAAS,MAAM,qBAAqB;CAClF,MAAM,UAAU,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,SAAS,WAAW;CACvE,MAAM,oBAAoB,SAAS,WAAW;CAC9C,OAAO;AACT"}
@@ -0,0 +1,70 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { notFound, unauthorized } from "./schemas.mjs";
7
+ import { VIEW_ROLES, buildForProject } from "./builds.handlers.mjs";
8
+ import { createRoute, z } from "@hono/zod-openapi";
9
+ import { changesetOf } from "@storyshelf/core/models";
10
+ //#region src/routers/build-changeset.ts
11
+ const component = z.object({
12
+ importPath: z.string(),
13
+ title: z.string(),
14
+ storyCount: z.number()
15
+ });
16
+ const changesetSchema = z.object({
17
+ baseline: z.object({
18
+ id: z.string(),
19
+ gitBranch: z.string(),
20
+ gitSha: z.string()
21
+ }).nullable().describe("The build compared with; null for the first build"),
22
+ added: component.array(),
23
+ removed: component.array(),
24
+ changed: component.extend({ previousStoryCount: z.number() }).array(),
25
+ unchanged: z.number().describe("Components with the same hash in both builds")
26
+ }).openapi("BuildChangeset");
27
+ const getChangesetRoute = createRoute({
28
+ method: "get",
29
+ tags: ["Review"],
30
+ summary: "Get a build's changeset",
31
+ description: "Components the build added, removed or changed against its baseline build (the latest accepted build of its branch, else of the default branch), from the Storybook catalog. A changed hash means the component's stories changed, not that anything looks different. 404 when the project has review off.",
32
+ path: "/api/v1/projects/{slug}/builds/{buildId}/changeset",
33
+ request: { params: z.object({
34
+ slug: z.string(),
35
+ buildId: z.string()
36
+ }) },
37
+ responses: {
38
+ 200: {
39
+ content: { "application/json": { schema: changesetSchema } },
40
+ description: "The build's changeset"
41
+ },
42
+ ...notFound,
43
+ ...unauthorized
44
+ }
45
+ });
46
+ /** Register the changeset endpoint (`review` class). */
47
+ function registerBuildChangeset(app) {
48
+ app.openapi(getChangesetRoute, async (c) => {
49
+ const { slug, buildId } = c.req.valid("param");
50
+ const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
51
+ const build = await buildForProject(project.id, buildId);
52
+ const { baseline, changeset } = await changesetOf(getStore().db, project, build);
53
+ const { added, removed, changed, unchanged } = changeset;
54
+ return c.json({
55
+ baseline: baseline ? {
56
+ id: baseline.id,
57
+ gitBranch: baseline.gitBranch,
58
+ gitSha: baseline.gitSha
59
+ } : null,
60
+ added,
61
+ removed,
62
+ changed,
63
+ unchanged
64
+ });
65
+ });
66
+ }
67
+ //#endregion
68
+ export { registerBuildChangeset };
69
+
70
+ //# sourceMappingURL=build-changeset.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-changeset.mjs","names":[],"sources":["../../src/routers/build-changeset.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { changesetOf } from \"@storyshelf/core/models\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { VIEW_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { notFound, unauthorized } from \"./schemas.ts\";\n\nconst component = z.object({\n importPath: z.string(),\n title: z.string(),\n storyCount: z.number(),\n});\n\nconst changesetSchema = z\n .object({\n baseline: z\n .object({ id: z.string(), gitBranch: z.string(), gitSha: z.string() })\n .nullable()\n .describe(\"The build compared with; null for the first build\"),\n added: component.array(),\n removed: component.array(),\n changed: component.extend({ previousStoryCount: z.number() }).array(),\n unchanged: z.number().describe(\"Components with the same hash in both builds\"),\n })\n .openapi(\"BuildChangeset\");\n\nconst getChangesetRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"Get a build's changeset\",\n description:\n \"Components the build added, removed or changed against its baseline build (the latest accepted build of its branch, else of the default branch), from the Storybook catalog. A changed hash means the component's stories changed, not that anything looks different. 404 when the project has review off.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/changeset\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: changesetSchema } },\n description: \"The build's changeset\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\n/** Register the changeset endpoint (`review` class). */\nexport function registerBuildChangeset(app: ShelfRouter): void {\n app.openapi(getChangesetRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n const { baseline, changeset } = await changesetOf(getStore().db, project, build);\n const { added, removed, changed, unchanged } = changeset;\n return c.json({\n baseline: baseline\n ? { id: baseline.id, gitBranch: baseline.gitBranch, gitSha: baseline.gitSha }\n : null,\n added,\n removed,\n changed,\n unchanged,\n });\n });\n}\n"],"mappings":";;;;;;;;;;AAQA,MAAM,YAAY,EAAE,OAAO;CACzB,YAAY,EAAE,OAAO;CACrB,OAAO,EAAE,OAAO;CAChB,YAAY,EAAE,OAAO;AACvB,CAAC;AAED,MAAM,kBAAkB,EACrB,OAAO;CACN,UAAU,EACP,OAAO;EAAE,IAAI,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;EAAG,QAAQ,EAAE,OAAO;CAAE,CAAC,CAAC,CACrE,SAAS,CAAC,CACV,SAAS,mDAAmD;CAC/D,OAAO,UAAU,MAAM;CACvB,SAAS,UAAU,MAAM;CACzB,SAAS,UAAU,OAAO,EAAE,oBAAoB,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,MAAM;CACpE,WAAW,EAAE,OAAO,CAAC,CAAC,SAAS,8CAA8C;AAC/E,CAAC,CAAC,CACD,QAAQ,gBAAgB;AAE3B,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,gBAAgB,EAAE;GAC3D,aAAa;EACf;EACA,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAgB,uBAAuB,KAAwB;CAC7D,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,EAAE,UAAU,cAAc,MAAM,YAAY,SAAS,CAAC,CAAC,IAAI,SAAS,KAAK;EAC/E,MAAM,EAAE,OAAO,SAAS,SAAS,cAAc;EAC/C,OAAO,EAAE,KAAK;GACZ,UAAU,WACN;IAAE,IAAI,SAAS;IAAI,WAAW,SAAS;IAAW,QAAQ,SAAS;GAAO,IAC1E;GACJ;GACA;GACA;GACA;EACF,CAAC;CACH,CAAC;AACH"}
@@ -0,0 +1,86 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { REVIEW_EVENTS, announceReview } from "../review-events.mjs";
6
+ import { requestBuildState } from "../git-status.mjs";
7
+ import { announceBuildStatus } from "./builds.handlers.mjs";
8
+ import { publishingOf } from "@storyshelf/core/capture";
9
+ import { BuildModel, BuildReviewModel, deriveReviewStatus, latestVerdicts } from "@storyshelf/core/models";
10
+ import { HTTPException } from "hono/http-exception";
11
+ //#region src/routers/build-reviews.handlers.ts
12
+ /**
13
+ * Build-level verdicts (ADR 0013, decision 3): giving one and reading where a build stands. The
14
+ * routes are classed `review`, so `requireReview` answers 404 for a project with review off.
15
+ */
16
+ /** Statuses the verdicts may move a build between; a failed, cancelled or capturing build is not theirs to decide. */
17
+ const VERDICT_STATUSES = /* @__PURE__ */ new Set([
18
+ "pending",
19
+ "approved",
20
+ "rejected"
21
+ ]);
22
+ /** The verdicts of a build and what they make of it. */
23
+ async function reviewStateOf(project, build) {
24
+ const history = await new BuildReviewModel(getStore().db).listByBuild(build.id);
25
+ const latest = latestVerdicts(history);
26
+ return {
27
+ status: build.status,
28
+ approvalsRequired: approvalsRequired(project),
29
+ approvals: latest.filter((review) => review.verdict === "approved").length,
30
+ latest,
31
+ history
32
+ };
33
+ }
34
+ /**
35
+ * Record `userId`'s verdict and let the verdicts decide the build. With `testing` on the verdict is
36
+ * stored as a sign-off and the snapshot rollup keeps deciding. Every write asks for the build's
37
+ * state to be shown on its git channels; a status the verdicts moved to `approved` or `rejected`
38
+ * is announced like any other (webhook and notifications). Every verdict raises `review:verdict`.
39
+ */
40
+ async function giveVerdict(project, build, userId, input) {
41
+ await assertReviewable(project, build, userId);
42
+ const review = await new BuildReviewModel(getStore().db).add(build.id, userId, input.verdict, input.body);
43
+ const status = await decideBuild(project, build);
44
+ await announceReview(project, REVIEW_EVENTS.verdict, {
45
+ buildId: build.id,
46
+ gitBranch: build.gitBranch,
47
+ verdict: review.verdict,
48
+ userId,
49
+ body: review.body?.slice(0, 500) ?? null,
50
+ status: status ?? build.status
51
+ }, build.id);
52
+ if (status === null) await requestBuildState(build.id);
53
+ else await announceBuildStatus(build.id, status);
54
+ return review;
55
+ }
56
+ /**
57
+ * A verdict needs a build that is neither `failed` nor `cancelled`, whose publishing has ended (a publish that later
58
+ * failed would otherwise leave an approved build without statics), and, when it has no user (an
59
+ * instance without authentication), a project that needs one approval: the anonymous reviewer is
60
+ * one person, however many verdicts they give.
61
+ */
62
+ async function assertReviewable(project, build, userId) {
63
+ if (build.status === "failed" || build.status === "cancelled") throw new HTTPException(409, { message: `The build ${build.status === "failed" ? "failed" : "was cancelled"}; there is nothing to review` });
64
+ if (await publishingOf(getStore().storage, build) !== "success") throw new HTTPException(409, { message: "The build is still publishing; give a verdict once its Storybook is published" });
65
+ if (userId === null && approvalsRequired(project) > 1) throw new HTTPException(409, { message: `Verdicts without authentication count as one reviewer, but this project needs ${String(approvalsRequired(project))} approvals; enable authentication to review` });
66
+ }
67
+ /** Move the build to the status its verdicts give it; the new status, or null when it did not move. */
68
+ async function decideBuild(project, build) {
69
+ const { db, testing } = getStore();
70
+ const status = deriveReviewStatus({
71
+ testing,
72
+ review: true,
73
+ approvalsRequired: approvalsRequired(project),
74
+ reviews: await new BuildReviewModel(db).listByBuild(build.id)
75
+ });
76
+ if (status === null || !VERDICT_STATUSES.has(build.status) || status === build.status) return null;
77
+ await new BuildModel(db).setStatus(build.id, status);
78
+ return status;
79
+ }
80
+ function approvalsRequired(project) {
81
+ return Math.max(1, project.reviewApprovals ?? 1);
82
+ }
83
+ //#endregion
84
+ export { giveVerdict, reviewStateOf };
85
+
86
+ //# sourceMappingURL=build-reviews.handlers.mjs.map