@storyshelf/app 0.6.2 → 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 (361) 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/git-status.mjs +46 -0
  26. package/dist/git-status.mjs.map +1 -0
  27. package/dist/hidden-data.mjs +24 -0
  28. package/dist/hidden-data.mjs.map +1 -0
  29. package/dist/index.d.mts +3 -3
  30. package/dist/index.mjs +203 -125
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/insights/budget.mjs +49 -0
  33. package/dist/insights/budget.mjs.map +1 -0
  34. package/dist/insights/deps.mjs +21 -0
  35. package/dist/insights/deps.mjs.map +1 -0
  36. package/dist/insights/errors.mjs +22 -0
  37. package/dist/insights/errors.mjs.map +1 -0
  38. package/dist/insights/health-panel.mjs +40 -0
  39. package/dist/insights/health-panel.mjs.map +1 -0
  40. package/dist/insights/job.mjs +103 -0
  41. package/dist/insights/job.mjs.map +1 -0
  42. package/dist/insights/load-health.mjs +59 -0
  43. package/dist/insights/load-health.mjs.map +1 -0
  44. package/dist/insights/load-triage.mjs +98 -0
  45. package/dist/insights/load-triage.mjs.map +1 -0
  46. package/dist/insights/panel.mjs +28 -0
  47. package/dist/insights/panel.mjs.map +1 -0
  48. package/dist/insights/request.mjs +113 -0
  49. package/dist/insights/request.mjs.map +1 -0
  50. package/dist/insights/stale.mjs +21 -0
  51. package/dist/insights/stale.mjs.map +1 -0
  52. package/dist/insights/usage-report.mjs +60 -0
  53. package/dist/insights/usage-report.mjs.map +1 -0
  54. package/dist/insights/view.mjs +48 -0
  55. package/dist/insights/view.mjs.map +1 -0
  56. package/dist/job-canceller.mjs +14 -0
  57. package/dist/job-canceller.mjs.map +1 -0
  58. package/dist/job-queue.mjs +51 -0
  59. package/dist/job-queue.mjs.map +1 -0
  60. package/dist/jobs-setup.mjs +132 -0
  61. package/dist/jobs-setup.mjs.map +1 -0
  62. package/dist/lifecycle.mjs +91 -19
  63. package/dist/lifecycle.mjs.map +1 -1
  64. package/dist/middleware/auth-gate.mjs +4 -2
  65. package/dist/middleware/auth-gate.mjs.map +1 -1
  66. package/dist/middleware/rate-limit.mjs +41 -33
  67. package/dist/middleware/rate-limit.mjs.map +1 -1
  68. package/dist/middleware/request-log.mjs +3 -2
  69. package/dist/middleware/request-log.mjs.map +1 -1
  70. package/dist/middleware/require-review.mjs +51 -0
  71. package/dist/middleware/require-review.mjs.map +1 -0
  72. package/dist/middleware/require-testing.mjs +41 -0
  73. package/dist/middleware/require-testing.mjs.map +1 -0
  74. package/dist/middleware/setup-gate.mjs.map +1 -1
  75. package/dist/middleware/store-scope.mjs +12 -2
  76. package/dist/middleware/store-scope.mjs.map +1 -1
  77. package/dist/notifications-pause.mjs +33 -0
  78. package/dist/notifications-pause.mjs.map +1 -0
  79. package/dist/notify.d.mts +2 -1
  80. package/dist/notify.mjs +78 -39
  81. package/dist/notify.mjs.map +1 -1
  82. package/dist/pages/a11y-violations.mjs +116 -0
  83. package/dist/pages/a11y-violations.mjs.map +1 -0
  84. package/dist/pages/admin-ai.mjs +78 -0
  85. package/dist/pages/admin-ai.mjs.map +1 -0
  86. package/dist/pages/admin-system-origin.mjs +123 -0
  87. package/dist/pages/admin-system-origin.mjs.map +1 -0
  88. package/dist/pages/admin-system-targets.mjs +53 -0
  89. package/dist/pages/admin-system-targets.mjs.map +1 -0
  90. package/dist/pages/admin-system.mjs +63 -13
  91. package/dist/pages/admin-system.mjs.map +1 -1
  92. package/dist/pages/build-detail.mjs +104 -76
  93. package/dist/pages/build-detail.mjs.map +1 -1
  94. package/dist/pages/build-diff-actions.mjs +87 -33
  95. package/dist/pages/build-diff-actions.mjs.map +1 -1
  96. package/dist/pages/build-diff-comments.mjs +16 -14
  97. package/dist/pages/build-diff-comments.mjs.map +1 -1
  98. package/dist/pages/build-diff-environment.mjs +82 -0
  99. package/dist/pages/build-diff-environment.mjs.map +1 -0
  100. package/dist/pages/build-diff-header.mjs +22 -4
  101. package/dist/pages/build-diff-header.mjs.map +1 -1
  102. package/dist/pages/build-diff-insight.mjs +113 -0
  103. package/dist/pages/build-diff-insight.mjs.map +1 -0
  104. package/dist/pages/build-diff-model.mjs +55 -10
  105. package/dist/pages/build-diff-model.mjs.map +1 -1
  106. package/dist/pages/build-diff-nav.mjs +28 -12
  107. package/dist/pages/build-diff-nav.mjs.map +1 -1
  108. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  109. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  110. package/dist/pages/build-diff-usage.mjs +63 -0
  111. package/dist/pages/build-diff-usage.mjs.map +1 -0
  112. package/dist/pages/build-diff-viewer.mjs +62 -5
  113. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  114. package/dist/pages/build-diff.mjs +34 -13
  115. package/dist/pages/build-diff.mjs.map +1 -1
  116. package/dist/pages/build-tests.mjs +74 -0
  117. package/dist/pages/build-tests.mjs.map +1 -0
  118. package/dist/pages/component-detail.mjs +249 -0
  119. package/dist/pages/component-detail.mjs.map +1 -0
  120. package/dist/pages/component-stories.mjs +168 -0
  121. package/dist/pages/component-stories.mjs.map +1 -0
  122. package/dist/pages/compute-jobs.mjs +13 -26
  123. package/dist/pages/compute-jobs.mjs.map +1 -1
  124. package/dist/pages/design-links.mjs +182 -0
  125. package/dist/pages/design-links.mjs.map +1 -0
  126. package/dist/pages/environment-change.mjs +53 -0
  127. package/dist/pages/environment-change.mjs.map +1 -0
  128. package/dist/pages/health-panel.mjs +126 -0
  129. package/dist/pages/health-panel.mjs.map +1 -0
  130. package/dist/pages/library-grid.mjs +105 -0
  131. package/dist/pages/library-grid.mjs.map +1 -0
  132. package/dist/pages/library-model.mjs +47 -0
  133. package/dist/pages/library-model.mjs.map +1 -0
  134. package/dist/pages/library-snapshots.mjs +130 -0
  135. package/dist/pages/library-snapshots.mjs.map +1 -0
  136. package/dist/pages/library.mjs +112 -218
  137. package/dist/pages/library.mjs.map +1 -1
  138. package/dist/pages/notifications-paused.mjs +22 -0
  139. package/dist/pages/notifications-paused.mjs.map +1 -0
  140. package/dist/pages/profile.mjs +53 -48
  141. package/dist/pages/profile.mjs.map +1 -1
  142. package/dist/pages/project-builds-table.mjs +2 -2
  143. package/dist/pages/project-builds-table.mjs.map +1 -1
  144. package/dist/pages/project-builds.mjs +51 -21
  145. package/dist/pages/project-builds.mjs.map +1 -1
  146. package/dist/pages/project-settings.mjs +51 -12
  147. package/dist/pages/project-settings.mjs.map +1 -1
  148. package/dist/pages/projects-card.mjs +14 -1
  149. package/dist/pages/projects-card.mjs.map +1 -1
  150. package/dist/pages/projects.mjs +6 -2
  151. package/dist/pages/projects.mjs.map +1 -1
  152. package/dist/pages/review-changeset.mjs +116 -0
  153. package/dist/pages/review-changeset.mjs.map +1 -0
  154. package/dist/pages/review-comments.mjs +103 -0
  155. package/dist/pages/review-comments.mjs.map +1 -0
  156. package/dist/pages/review-verdict.mjs +102 -0
  157. package/dist/pages/review-verdict.mjs.map +1 -0
  158. package/dist/pages/settings-ai.mjs +69 -0
  159. package/dist/pages/settings-ai.mjs.map +1 -0
  160. package/dist/pages/settings-capture.mjs +124 -0
  161. package/dist/pages/settings-capture.mjs.map +1 -0
  162. package/dist/pages/settings-design-credential.mjs +99 -0
  163. package/dist/pages/settings-design-credential.mjs.map +1 -0
  164. package/dist/pages/settings-design-links.mjs +179 -0
  165. package/dist/pages/settings-design-links.mjs.map +1 -0
  166. package/dist/pages/settings-design.mjs +48 -0
  167. package/dist/pages/settings-design.mjs.map +1 -0
  168. package/dist/pages/settings-general.mjs +46 -21
  169. package/dist/pages/settings-general.mjs.map +1 -1
  170. package/dist/pages/settings-labels.mjs +43 -2
  171. package/dist/pages/settings-labels.mjs.map +1 -1
  172. package/dist/pages/settings-links.mjs +112 -0
  173. package/dist/pages/settings-links.mjs.map +1 -0
  174. package/dist/pages/settings-notifications-git.mjs +128 -0
  175. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  176. package/dist/pages/settings-notifications.mjs +79 -42
  177. package/dist/pages/settings-notifications.mjs.map +1 -1
  178. package/dist/pages/settings-tests.mjs +33 -2
  179. package/dist/pages/settings-tests.mjs.map +1 -1
  180. package/dist/pages/target-choices.mjs +39 -0
  181. package/dist/pages/target-choices.mjs.map +1 -0
  182. package/dist/publish-setup.mjs +89 -0
  183. package/dist/publish-setup.mjs.map +1 -0
  184. package/dist/retention-timer.mjs +2 -1
  185. package/dist/retention-timer.mjs.map +1 -1
  186. package/dist/review-events.mjs +48 -0
  187. package/dist/review-events.mjs.map +1 -0
  188. package/dist/routers/admin-pages.mjs +45 -3
  189. package/dist/routers/admin-pages.mjs.map +1 -1
  190. package/dist/routers/admin-public-origin.mjs +85 -0
  191. package/dist/routers/admin-public-origin.mjs.map +1 -0
  192. package/dist/routers/assets.mjs +9 -2
  193. package/dist/routers/assets.mjs.map +1 -1
  194. package/dist/routers/auth.mjs +1 -1
  195. package/dist/routers/baseline-guard.mjs +2 -2
  196. package/dist/routers/baseline-guard.mjs.map +1 -1
  197. package/dist/routers/baselines.handlers.mjs +64 -0
  198. package/dist/routers/baselines.handlers.mjs.map +1 -0
  199. package/dist/routers/baselines.mjs +125 -0
  200. package/dist/routers/baselines.mjs.map +1 -0
  201. package/dist/routers/build-cancel.mjs +68 -0
  202. package/dist/routers/build-cancel.mjs.map +1 -0
  203. package/dist/routers/build-changeset.mjs +70 -0
  204. package/dist/routers/build-changeset.mjs.map +1 -0
  205. package/dist/routers/build-reviews.handlers.mjs +86 -0
  206. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  207. package/dist/routers/build-reviews.mjs +74 -0
  208. package/dist/routers/build-reviews.mjs.map +1 -0
  209. package/dist/routers/builds.handlers.mjs +46 -55
  210. package/dist/routers/builds.handlers.mjs.map +1 -1
  211. package/dist/routers/builds.mjs +71 -17
  212. package/dist/routers/builds.mjs.map +1 -1
  213. package/dist/routers/capture-config.mjs +35 -0
  214. package/dist/routers/capture-config.mjs.map +1 -0
  215. package/dist/routers/comments.mjs +41 -15
  216. package/dist/routers/comments.mjs.map +1 -1
  217. package/dist/routers/component-catalog.handlers.mjs +71 -0
  218. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  219. package/dist/routers/component.handlers.mjs +121 -0
  220. package/dist/routers/component.handlers.mjs.map +1 -0
  221. package/dist/routers/connections.mjs +118 -0
  222. package/dist/routers/connections.mjs.map +1 -0
  223. package/dist/routers/design-connect.handlers.mjs +44 -0
  224. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  225. package/dist/routers/design.handlers.mjs +114 -0
  226. package/dist/routers/design.handlers.mjs.map +1 -0
  227. package/dist/routers/health-report.mjs +2 -3
  228. package/dist/routers/health-report.mjs.map +1 -1
  229. package/dist/routers/health.mjs +4 -3
  230. package/dist/routers/health.mjs.map +1 -1
  231. package/dist/routers/helpers.mjs +19 -3
  232. package/dist/routers/helpers.mjs.map +1 -1
  233. package/dist/routers/insights-ui.mjs +64 -0
  234. package/dist/routers/insights-ui.mjs.map +1 -0
  235. package/dist/routers/insights.handlers.mjs +45 -0
  236. package/dist/routers/insights.handlers.mjs.map +1 -0
  237. package/dist/routers/insights.mjs +189 -0
  238. package/dist/routers/insights.mjs.map +1 -0
  239. package/dist/routers/links.handlers.mjs +80 -0
  240. package/dist/routers/links.handlers.mjs.map +1 -0
  241. package/dist/routers/links.mjs +139 -0
  242. package/dist/routers/links.mjs.map +1 -0
  243. package/dist/routers/media.mjs +18 -4
  244. package/dist/routers/media.mjs.map +1 -1
  245. package/dist/routers/members.mjs +2 -1
  246. package/dist/routers/members.mjs.map +1 -1
  247. package/dist/routers/notification-channels.mjs +17 -23
  248. package/dist/routers/notification-channels.mjs.map +1 -1
  249. package/dist/routers/oauth-state.mjs +45 -0
  250. package/dist/routers/oauth-state.mjs.map +1 -0
  251. package/dist/routers/preview-base.mjs +38 -0
  252. package/dist/routers/preview-base.mjs.map +1 -0
  253. package/dist/routers/preview-token.mjs +51 -0
  254. package/dist/routers/preview-token.mjs.map +1 -0
  255. package/dist/routers/profile.mjs +3 -1
  256. package/dist/routers/profile.mjs.map +1 -1
  257. package/dist/routers/projects.mjs +17 -1
  258. package/dist/routers/projects.mjs.map +1 -1
  259. package/dist/routers/publish-upload.mjs +78 -0
  260. package/dist/routers/publish-upload.mjs.map +1 -0
  261. package/dist/routers/published-access.mjs +52 -0
  262. package/dist/routers/published-access.mjs.map +1 -0
  263. package/dist/routers/published-files.mjs +52 -0
  264. package/dist/routers/published-files.mjs.map +1 -0
  265. package/dist/routers/review-forms.mjs +72 -0
  266. package/dist/routers/review-forms.mjs.map +1 -0
  267. package/dist/routers/review-section.handlers.mjs +85 -0
  268. package/dist/routers/review-section.handlers.mjs.map +1 -0
  269. package/dist/routers/review-switch-off.mjs +29 -0
  270. package/dist/routers/review-switch-off.mjs.map +1 -0
  271. package/dist/routers/schemas.mjs +140 -22
  272. package/dist/routers/schemas.mjs.map +1 -1
  273. package/dist/routers/scoped-models.mjs +22 -0
  274. package/dist/routers/scoped-models.mjs.map +1 -0
  275. package/dist/routers/settings-ai.mjs +35 -0
  276. package/dist/routers/settings-ai.mjs.map +1 -0
  277. package/dist/routers/settings-capture.mjs +77 -0
  278. package/dist/routers/settings-capture.mjs.map +1 -0
  279. package/dist/routers/settings-design.mjs +103 -0
  280. package/dist/routers/settings-design.mjs.map +1 -0
  281. package/dist/routers/settings-general.mjs +55 -10
  282. package/dist/routers/settings-general.mjs.map +1 -1
  283. package/dist/routers/settings-git-channel.mjs +77 -0
  284. package/dist/routers/settings-git-channel.mjs.map +1 -0
  285. package/dist/routers/settings-labels.mjs +27 -1
  286. package/dist/routers/settings-labels.mjs.map +1 -1
  287. package/dist/routers/settings-links.mjs +63 -0
  288. package/dist/routers/settings-links.mjs.map +1 -0
  289. package/dist/routers/settings-members.mjs +4 -7
  290. package/dist/routers/settings-members.mjs.map +1 -1
  291. package/dist/routers/settings-notifications.mjs +15 -10
  292. package/dist/routers/settings-notifications.mjs.map +1 -1
  293. package/dist/routers/settings-tokens.mjs +2 -5
  294. package/dist/routers/settings-tokens.mjs.map +1 -1
  295. package/dist/routers/settings-webhooks.mjs +7 -24
  296. package/dist/routers/settings-webhooks.mjs.map +1 -1
  297. package/dist/routers/settings.handlers.mjs +107 -26
  298. package/dist/routers/settings.handlers.mjs.map +1 -1
  299. package/dist/routers/settings.mjs +16 -4
  300. package/dist/routers/settings.mjs.map +1 -1
  301. package/dist/routers/signed-storybook.mjs +53 -0
  302. package/dist/routers/signed-storybook.mjs.map +1 -0
  303. package/dist/routers/snapshot-groups.mjs +63 -0
  304. package/dist/routers/snapshot-groups.mjs.map +1 -0
  305. package/dist/routers/snapshots.mjs +103 -5
  306. package/dist/routers/snapshots.mjs.map +1 -1
  307. package/dist/routers/storybook.mjs +37 -88
  308. package/dist/routers/storybook.mjs.map +1 -1
  309. package/dist/routers/tokens.mjs +2 -5
  310. package/dist/routers/tokens.mjs.map +1 -1
  311. package/dist/routers/ui.mjs +34 -5
  312. package/dist/routers/ui.mjs.map +1 -1
  313. package/dist/routers/webhooks.mjs +5 -11
  314. package/dist/routers/webhooks.mjs.map +1 -1
  315. package/dist/routers/well-known.mjs +11 -4
  316. package/dist/routers/well-known.mjs.map +1 -1
  317. package/dist/routes/api-routes.mjs +93 -0
  318. package/dist/routes/api-routes.mjs.map +1 -0
  319. package/dist/routes/classification.mjs +38 -0
  320. package/dist/routes/classification.mjs.map +1 -0
  321. package/dist/routes/openapi-classes.mjs +22 -0
  322. package/dist/routes/openapi-classes.mjs.map +1 -0
  323. package/dist/routes/page-routes.mjs +104 -0
  324. package/dist/routes/page-routes.mjs.map +1 -0
  325. package/dist/runtime.mjs +18 -13
  326. package/dist/runtime.mjs.map +1 -1
  327. package/dist/serverless.mjs +5 -0
  328. package/dist/store.mjs +19 -1
  329. package/dist/store.mjs.map +1 -1
  330. package/dist/targets.mjs +46 -0
  331. package/dist/targets.mjs.map +1 -0
  332. package/dist/ui/buttons.mjs +16 -3
  333. package/dist/ui/buttons.mjs.map +1 -1
  334. package/dist/ui/design-reference.mjs +97 -0
  335. package/dist/ui/design-reference.mjs.map +1 -0
  336. package/dist/ui/document.mjs +1 -2
  337. package/dist/ui/document.mjs.map +1 -1
  338. package/dist/ui/forms.mjs +6 -5
  339. package/dist/ui/forms.mjs.map +1 -1
  340. package/dist/ui/icons/paths.mjs +1 -0
  341. package/dist/ui/icons/paths.mjs.map +1 -1
  342. package/dist/ui/preview-embed.mjs +82 -0
  343. package/dist/ui/preview-embed.mjs.map +1 -0
  344. package/dist/ui/shell/client-script.mjs +85 -0
  345. package/dist/ui/shell/client-script.mjs.map +1 -1
  346. package/dist/ui/shell/nav.mjs +12 -8
  347. package/dist/ui/shell/nav.mjs.map +1 -1
  348. package/dist/ui/shell/review-script.mjs +8 -0
  349. package/dist/ui/shell/review-script.mjs.map +1 -1
  350. package/dist/ui/shell/sidebar.mjs +2 -2
  351. package/dist/ui/shell/sidebar.mjs.map +1 -1
  352. package/package.json +11 -9
  353. package/dist/asset-paths.mjs +0 -23
  354. package/dist/asset-paths.mjs.map +0 -1
  355. package/dist/assets/mark.svg +0 -1
  356. package/dist/pages/settings-status.mjs +0 -137
  357. package/dist/pages/settings-status.mjs.map +0 -1
  358. package/dist/routers/settings-status.mjs +0 -77
  359. package/dist/routers/settings-status.mjs.map +0 -1
  360. package/dist/routers/status-configs.mjs +0 -108
  361. package/dist/routers/status-configs.mjs.map +0 -1
@@ -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":";;;;;;;;;;;;;;;;;AAqBA,MAAM,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AACtF,MAAM,iBAAyC,CAAC,YAAY,OAAO;AAEnE,MAAM,YAAY,EAAE,OAAO,CAAC,CAAC,QAAQ;CAAE,MAAM;CAAU,QAAQ;AAAS,CAAC;AACzE,MAAM,qBAAqB,EACxB,OAAO;CACN,UAAU,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,aAAa,yCAAyC,CAAC;CAC5F,SAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,aAAa,sCAAsC,CAAC;CACxF,SAAS,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,aAAa,iCAAiC,CAAC;AACrF,CAAC,CAAC,CACD,QAAQ,sBAAsB;AAEjC,MAAM,cAAc,YAAY;CAC9B,QAAQ;CACR,MAAM,CAAC,WAAW;CAClB,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EACrC,OAAO,EAAE,OAAO,EACd,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,aAAa,0BAA0B,CAAC,EACzF,CAAC;CACH;CACA,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,mBAAmB,EAAE,QAAQ,UAAU,EAAE;GAAG,aAAa;EAAmB;EAC9F,GAAG;EACH,GAAG;EACH,GAAG;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,cAAc,YAAY;CAC9B,QAAQ;CACR,MAAM,CAAC,WAAW;CAClB,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EACrC,MAAM;GAAE,SAAS,EAAE,mBAAmB,EAAE,QAAQ,UAAU,EAAE;GAAG,UAAU;EAAK;CAChF;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,mBAAmB,EAAE;GAC9D,aAAa;EACf;EACA,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,GAAG;EACH,GAAG;EACH,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAS,aAAa,OAAuB;CAC3C,IAAI,iBAAiB,sBACnB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,MAAM,QAAQ,CAAC;CAEzD,MAAM;AACR;;AAGA,SAAgB,kBAAkB,KAAwB;CACxD,IAAI,QAAQ,aAAa,OAAO,MAAM;EACpC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,UAAU;EAC1F,MAAM,EAAE,WAAW,EAAE,IAAI,MAAM,OAAO;EACtC,MAAM,EAAE,IAAI,SAAS,WAAW,SAAS;EACzC,MAAM,SAAS,MAAM,IAAI,sBAAsB,IAAI,OAAO,CAAC,CACxD,OAAO,QAAQ,IAAI;GAClB;GACA,SAAS,QAAQ;GACjB,YAAY,aAAmB;IAC7B,OAAO,KAAK;KAAE,WAAW,QAAQ;KAAI,GAAG;IAAS,GAAG,kCAAkC;GACxF;EACF,CAAC,CAAC,CACD,MAAM,YAAY;EACrB,OAAO,IAAI,SAAS,YAAY,MAAM,GAAG,EACvC,SAAS;GACP,gBAAgB;GAChB,uBAAuB,yBAAyB,gBAAgB,QAAQ,MAAM,MAAM,EAAE;GACtF,iBAAiB;EACnB,EACF,CAAC;CACH,CAAC;CAED,IAAI,QAAQ,aAAa,OAAO,MAAM;EACpC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,cAAc;EAC9F,MAAM,UAAU,MAAM,eAAe,EAAE,IAAI,KAAK,gBAAgB,SAAS,CAAC,CAAC,MAAM,CAAC;EAClF,MAAM,EAAE,IAAI,YAAY,SAAS;EACjC,MAAM,SAAS,MAAM,IAAI,sBAAsB,IAAI,OAAO,CAAC,CACxD,OAAO,QAAQ,IAAI,OAAO,CAAC,CAC3B,MAAM,YAAY;EACrB,SAAS,CAAC,CAAC,OAAO,KAAK;GAAE,WAAW,QAAQ;GAAI,GAAG;EAAO,GAAG,oBAAoB;EACjF,OAAO,EAAE,KAAK,QAAQ,GAAG;CAC3B,CAAC;AACH"}
@@ -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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-reviews.handlers.mjs","names":[],"sources":["../../src/routers/build-reviews.handlers.ts"],"sourcesContent":["/**\n * Build-level verdicts (ADR 0013, decision 3): giving one and reading where a build stands. The\n * routes are classed `review`, so `requireReview` answers 404 for a project with review off.\n */\nimport { publishingOf } from \"@storyshelf/core/capture\";\nimport {\n BuildModel,\n BuildReviewModel,\n deriveReviewStatus,\n latestVerdicts,\n} from \"@storyshelf/core/models\";\nimport type { Build, BuildReview, Project } from \"@storyshelf/core/schema\";\nimport type { BuildStatus, ReviewVerdict } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { requestBuildState } from \"../git-status.ts\";\nimport { REVIEW_EVENTS, announceReview } from \"../review-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { announceBuildStatus } from \"./builds.handlers.ts\";\n\n/** Statuses the verdicts may move a build between; a failed, cancelled or capturing build is not theirs to decide. */\nconst VERDICT_STATUSES: ReadonlySet<BuildStatus> = new Set([\"pending\", \"approved\", \"rejected\"]);\n\n/** Where a build stands in review. */\nexport interface ReviewState {\n status: BuildStatus;\n approvalsRequired: number;\n approvals: number;\n latest: BuildReview[];\n history: BuildReview[];\n}\n\n/** The verdicts of a build and what they make of it. */\nexport async function reviewStateOf(project: Project, build: Build): Promise<ReviewState> {\n const history = await new BuildReviewModel(getStore().db).listByBuild(build.id);\n const latest = latestVerdicts(history);\n return {\n status: build.status,\n approvalsRequired: approvalsRequired(project),\n approvals: latest.filter((review) => review.verdict === \"approved\").length,\n latest,\n history,\n };\n}\n\n/**\n * Record `userId`'s verdict and let the verdicts decide the build. With `testing` on the verdict is\n * stored as a sign-off and the snapshot rollup keeps deciding. Every write asks for the build's\n * state to be shown on its git channels; a status the verdicts moved to `approved` or `rejected`\n * is announced like any other (webhook and notifications). Every verdict raises `review:verdict`.\n */\nexport async function giveVerdict(\n project: Project,\n build: Build,\n userId: string | null,\n input: { verdict: ReviewVerdict; body?: string | undefined },\n): Promise<BuildReview> {\n await assertReviewable(project, build, userId);\n const review = await new BuildReviewModel(getStore().db).add(\n build.id,\n userId,\n input.verdict,\n input.body,\n );\n const status = await decideBuild(project, build);\n await announceReview(\n project,\n REVIEW_EVENTS.verdict,\n {\n buildId: build.id,\n gitBranch: build.gitBranch,\n verdict: review.verdict,\n userId,\n body: review.body?.slice(0, 500) ?? null,\n status: status ?? build.status,\n },\n build.id,\n );\n // Every move is news: `approved` and `rejected` as ever, and `pending` when a rewritten verdict\n // sends the build back; a verdict that moves nothing only refreshes the git channels.\n if (status === null) {\n await requestBuildState(build.id);\n } else {\n await announceBuildStatus(build.id, status);\n }\n return review;\n}\n\n/**\n * A verdict needs a build that is neither `failed` nor `cancelled`, whose publishing has ended (a publish that later\n * failed would otherwise leave an approved build without statics), and, when it has no user (an\n * instance without authentication), a project that needs one approval: the anonymous reviewer is\n * one person, however many verdicts they give.\n */\nasync function assertReviewable(\n project: Project,\n build: Build,\n userId: string | null,\n): Promise<void> {\n if (build.status === \"failed\" || build.status === \"cancelled\") {\n throw new HTTPException(409, {\n message: `The build ${build.status === \"failed\" ? \"failed\" : \"was cancelled\"}; there is nothing to review`,\n });\n }\n if ((await publishingOf(getStore().storage, build)) !== \"success\") {\n throw new HTTPException(409, {\n message: \"The build is still publishing; give a verdict once its Storybook is published\",\n });\n }\n if (userId === null && approvalsRequired(project) > 1) {\n throw new HTTPException(409, {\n message: `Verdicts without authentication count as one reviewer, but this project needs ${String(approvalsRequired(project))} approvals; enable authentication to review`,\n });\n }\n}\n\n/** Move the build to the status its verdicts give it; the new status, or null when it did not move. */\nasync function decideBuild(\n project: Project,\n build: Build,\n): Promise<\"approved\" | \"rejected\" | \"pending\" | null> {\n const { db, testing } = getStore();\n const status = deriveReviewStatus({\n testing,\n review: true,\n approvalsRequired: approvalsRequired(project),\n reviews: await new BuildReviewModel(db).listByBuild(build.id),\n });\n if (status === null || !VERDICT_STATUSES.has(build.status) || status === build.status) {\n return null;\n }\n await new BuildModel(db).setStatus(build.id, status);\n return status;\n}\n\nfunction approvalsRequired(project: Project): number {\n return Math.max(1, project.reviewApprovals ?? 1);\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAoBA,MAAM,mCAA6C,IAAI,IAAI;CAAC;CAAW;CAAY;AAAU,CAAC;;AAY9F,eAAsB,cAAc,SAAkB,OAAoC;CACxF,MAAM,UAAU,MAAM,IAAI,iBAAiB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAC9E,MAAM,SAAS,eAAe,OAAO;CACrC,OAAO;EACL,QAAQ,MAAM;EACd,mBAAmB,kBAAkB,OAAO;EAC5C,WAAW,OAAO,QAAQ,WAAW,OAAO,YAAY,UAAU,CAAC,CAAC;EACpE;EACA;CACF;AACF;;;;;;;AAQA,eAAsB,YACpB,SACA,OACA,QACA,OACsB;CACtB,MAAM,iBAAiB,SAAS,OAAO,MAAM;CAC7C,MAAM,SAAS,MAAM,IAAI,iBAAiB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IACvD,MAAM,IACN,QACA,MAAM,SACN,MAAM,IACR;CACA,MAAM,SAAS,MAAM,YAAY,SAAS,KAAK;CAC/C,MAAM,eACJ,SACA,cAAc,SACd;EACE,SAAS,MAAM;EACf,WAAW,MAAM;EACjB,SAAS,OAAO;EAChB;EACA,MAAM,OAAO,MAAM,MAAM,GAAG,GAAG,KAAK;EACpC,QAAQ,UAAU,MAAM;CAC1B,GACA,MAAM,EACR;CAGA,IAAI,WAAW,MACb,MAAM,kBAAkB,MAAM,EAAE;MAEhC,MAAM,oBAAoB,MAAM,IAAI,MAAM;CAE5C,OAAO;AACT;;;;;;;AAQA,eAAe,iBACb,SACA,OACA,QACe;CACf,IAAI,MAAM,WAAW,YAAY,MAAM,WAAW,aAChD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,aAAa,MAAM,WAAW,WAAW,WAAW,gBAAgB,8BAC/E,CAAC;CAEH,IAAK,MAAM,aAAa,SAAS,CAAC,CAAC,SAAS,KAAK,MAAO,WACtD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,gFACX,CAAC;CAEH,IAAI,WAAW,QAAQ,kBAAkB,OAAO,IAAI,GAClD,MAAM,IAAI,cAAc,KAAK,EAC3B,SAAS,iFAAiF,OAAO,kBAAkB,OAAO,CAAC,EAAE,6CAC/H,CAAC;AAEL;;AAGA,eAAe,YACb,SACA,OACqD;CACrD,MAAM,EAAE,IAAI,YAAY,SAAS;CACjC,MAAM,SAAS,mBAAmB;EAChC;EACA,QAAQ;EACR,mBAAmB,kBAAkB,OAAO;EAC5C,SAAS,MAAM,IAAI,iBAAiB,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAC9D,CAAC;CACD,IAAI,WAAW,QAAQ,CAAC,iBAAiB,IAAI,MAAM,MAAM,KAAK,WAAW,MAAM,QAC7E,OAAO;CAET,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,MAAM,IAAI,MAAM;CACnD,OAAO;AACT;AAEA,SAAS,kBAAkB,SAA0B;CACnD,OAAO,KAAK,IAAI,GAAG,QAAQ,mBAAmB,CAAC;AACjD"}
@@ -0,0 +1,74 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { resolveAuthorizedProject, resolveReviewer } from "./helpers.mjs";
5
+ import { hxRefresh } from "./htmx.mjs";
6
+ import { flashHx } from "./flash.mjs";
7
+ import { buildReviewCreateSchema, buildReviewSchema, buildReviewStateSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
8
+ import { APPROVER_ROLES, VIEW_ROLES, buildForProject } from "./builds.handlers.mjs";
9
+ import { giveVerdict, reviewStateOf } from "./build-reviews.handlers.mjs";
10
+ import { createRoute, z } from "@hono/zod-openapi";
11
+ //#region src/routers/build-reviews.ts
12
+ const params = z.object({
13
+ slug: z.string(),
14
+ buildId: z.string()
15
+ });
16
+ const getReviewsRoute = createRoute({
17
+ method: "get",
18
+ tags: ["Review"],
19
+ summary: "Get a build's review state",
20
+ description: "The verdicts reviewers gave the build and what they make of it: each reviewer's latest verdict, the approvals against the project's requirement, and the full history. 404 when the project has review off.",
21
+ path: "/api/v1/projects/{slug}/builds/{buildId}/reviews",
22
+ request: { params },
23
+ responses: {
24
+ 200: {
25
+ content: { "application/json": { schema: buildReviewStateSchema } },
26
+ description: "Review state of the build"
27
+ },
28
+ ...notFound,
29
+ ...unauthorized
30
+ }
31
+ });
32
+ const createReviewRoute = createRoute({
33
+ method: "post",
34
+ tags: ["Review"],
35
+ summary: "Approve or reject a build",
36
+ description: "Give the build a verdict (approver and admin). A reviewer rewrites their verdict by posting again; the newest counts. With `testing` off the verdicts decide the build: `approved` once the project's required approvals are present and no reviewer's latest verdict is `rejected`, `rejected` when any is. With `testing` on the verdict is a sign-off and the snapshot rollup keeps deciding. The verdict is attributed to the signed-in user, or to the user who owns the API token; a caller that is no user (the bootstrap admin token) gets 403, and only an instance with no authentication at all records an anonymous verdict, which counts when the project needs one approval (409 otherwise). 404 when the project has review off; 409 for a failed or cancelled build, while the build is still publishing, or for an anonymous verdict the project cannot count.",
37
+ path: "/api/v1/projects/{slug}/builds/{buildId}/reviews",
38
+ request: {
39
+ params,
40
+ body: { content: { "application/json": { schema: buildReviewCreateSchema } } }
41
+ },
42
+ responses: {
43
+ 201: {
44
+ content: { "application/json": { schema: buildReviewSchema } },
45
+ description: "Verdict recorded"
46
+ },
47
+ ...forbidden,
48
+ ...notFound,
49
+ ...unauthorized
50
+ }
51
+ });
52
+ /** Register the build-level verdict endpoints (`review` class). */
53
+ function registerBuildReviews(app) {
54
+ app.openapi(getReviewsRoute, async (c) => {
55
+ const { slug, buildId } = c.req.valid("param");
56
+ const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
57
+ const build = await buildForProject(project.id, buildId);
58
+ return c.json(await reviewStateOf(project, build));
59
+ });
60
+ app.openapi(createReviewRoute, async (c) => {
61
+ const { slug, buildId } = c.req.valid("param");
62
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
63
+ const build = await buildForProject(project.id, buildId);
64
+ const userId = await resolveReviewer(c);
65
+ const review = await giveVerdict(project, build, userId, c.req.valid("json"));
66
+ hxRefresh(c);
67
+ flashHx(c, "Verdict recorded");
68
+ return c.json(review, 201);
69
+ });
70
+ }
71
+ //#endregion
72
+ export { registerBuildReviews };
73
+
74
+ //# sourceMappingURL=build-reviews.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-reviews.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/build-reviews.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { giveVerdict, reviewStateOf } from \"./build-reviews.handlers.ts\";\nimport { APPROVER_ROLES, VIEW_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject, resolveReviewer } from \"./helpers.ts\";\nimport { hxRefresh } from \"./htmx.ts\";\nimport {\n buildReviewCreateSchema,\n buildReviewSchema,\n buildReviewStateSchema,\n forbidden as forbiddenResponse,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\n\nconst params = z.object({ slug: z.string(), buildId: z.string() });\n\nconst getReviewsRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"Get a build's review state\",\n description:\n \"The verdicts reviewers gave the build and what they make of it: each reviewer's latest verdict, the approvals against the project's requirement, and the full history. 404 when the project has review off.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reviews\",\n request: { params },\n responses: {\n 200: {\n content: { \"application/json\": { schema: buildReviewStateSchema } },\n description: \"Review state of the build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createReviewRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve or reject a build\",\n description:\n \"Give the build a verdict (approver and admin). A reviewer rewrites their verdict by posting again; the newest counts. With `testing` off the verdicts decide the build: `approved` once the project's required approvals are present and no reviewer's latest verdict is `rejected`, `rejected` when any is. With `testing` on the verdict is a sign-off and the snapshot rollup keeps deciding. The verdict is attributed to the signed-in user, or to the user who owns the API token; a caller that is no user (the bootstrap admin token) gets 403, and only an instance with no authentication at all records an anonymous verdict, which counts when the project needs one approval (409 otherwise). 404 when the project has review off; 409 for a failed or cancelled build, while the build is still publishing, or for an anonymous verdict the project cannot count.\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reviews\",\n request: {\n params,\n body: { content: { \"application/json\": { schema: buildReviewCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: buildReviewSchema } },\n description: \"Verdict recorded\",\n },\n ...forbiddenResponse,\n ...notFound,\n ...unauthorized,\n },\n});\n\n/** Register the build-level verdict endpoints (`review` class). */\nexport function registerBuildReviews(app: ShelfRouter): void {\n app.openapi(getReviewsRoute, 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 return c.json(await reviewStateOf(project, build));\n });\n\n app.openapi(createReviewRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const userId = await resolveReviewer(c);\n const review = await giveVerdict(project, build, userId, c.req.valid(\"json\"));\n hxRefresh(c);\n flashHx(c, \"Verdict recorded\");\n return c.json(review, 201);\n });\n}\n"],"mappings":";;;;;;;;;;;AAgBA,MAAM,SAAS,EAAE,OAAO;CAAE,MAAM,EAAE,OAAO;CAAG,SAAS,EAAE,OAAO;AAAE,CAAC;AAEjE,MAAM,kBAAkB,YAAY;CAClC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS,EAAE,OAAO;CAClB,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,uBAAuB,EAAE;GAClE,aAAa;EACf;EACA,GAAG;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,aACE;CACF,MAAM;CACN,SAAS;EACP;EACA,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,wBAAwB,EAAE,EAAE;CAC/E;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,kBAAkB,EAAE;GAC7D,aAAa;EACf;EACA,GAAGA;EACH,GAAG;EACH,GAAG;CACL;AACF,CAAC;;AAGD,SAAgB,qBAAqB,KAAwB;CAC3D,IAAI,QAAQ,iBAAiB,OAAO,MAAM;EACxC,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,OAAO,EAAE,KAAK,MAAM,cAAc,SAAS,KAAK,CAAC;CACnD,CAAC;CAED,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,cAAc;EACzE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,SAAS,MAAM,gBAAgB,CAAC;EACtC,MAAM,SAAS,MAAM,YAAY,SAAS,OAAO,QAAQ,EAAE,IAAI,MAAM,MAAM,CAAC;EAC5E,UAAU,CAAC;EACX,QAAQ,GAAG,kBAAkB;EAC7B,OAAO,EAAE,KAAK,QAAQ,GAAG;CAC3B,CAAC;AACH"}