@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
@@ -1 +1 @@
1
- {"version":3,"file":"settings-tests.mjs","names":[],"sources":["../../src/pages/settings-tests.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport {\n Alert,\n Button,\n Card,\n CheckField,\n Field,\n FormActions,\n Meta,\n SectionTitle,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\n\n/** Interaction-tests settings tab: play execution toggle and timeout. */\nexport function renderSettingsTests(\n project: Project,\n isAdmin: boolean,\n formState?: { globalError?: string },\n): unknown {\n return (\n <div class=\"grid max-w-form\">\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n <Card>\n <SectionTitle>Interaction tests</SectionTitle>\n <Meta>\n When enabled, Storybook <code>play</code> functions run before each screenshot. Failures\n block the build unless the story is marked\n <code>flaky-test</code> via <code>tags: ['flaky-test']</code> or\n <code>parameters: &#123; flakyTest: true &#125;</code> (supports both{\" \"}\n <code>chromatic</code> and <code>storyshelf</code> keys, story-level, case-insensitive).\n Use <code>disableSnapshot</code> to skip a story entirely.\n </Meta>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/tests`}\n hx-post={`/projects/${project.slug}/settings/tests`}\n hx-target=\"body\"\n hx-swap=\"outerHTML\"\n >\n {csrfField()}\n <CheckField\n label=\"Enable interaction tests (play)\"\n name=\"executePlay\"\n value=\"true\"\n checked={project.executePlay ? true : undefined}\n disabled={!isAdmin}\n hint=\"When enabled, failing play blocks the build (failed). Flaky stories show warnings but status stays successful.\"\n />\n <Field\n label=\"Play timeout (ms)\"\n name=\"playTimeoutMs\"\n type=\"number\"\n min=\"1000\"\n max=\"30000\"\n step=\"1000\"\n value={String(project.playTimeoutMs)}\n disabled={!isAdmin}\n hint=\"Timeout for each play function (1000–30000 ms, default 10000).\"\n />\n {isAdmin ? (\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"check\">\n Save changes\n </Button>\n </FormActions>\n ) : (\n <Meta>You need admin access to edit settings.</Meta>\n )}\n </form>\n </Card>\n <Card>\n <SectionTitle level={3}>How to mark stories</SectionTitle>\n <Meta as=\"pre\">\n {`// Disable snapshot entirely (skip capture + play)\nexport const Hidden: Story = {\n parameters: { storyshelf: { disableSnapshot: true } }\n};\n// Mark as flaky (non-blocking, shows warning)\nexport const Flaky: Story = {\n tags: ['flaky-test'],\n play: async ({canvasElement}) => { /* ... */ }\n};\n// or via parameters (both keys work, storyshelf wins)\nexport const Flaky2: Story = {\n parameters: { chromatic: { flakyTest: true } }\n};`}\n </Meta>\n <Meta>\n Tags are story-level and case-insensitive (<code>flaky-test</code>). Whole story is\n non-blocking when flaky. GitHub status stays\n <code>success</code> with a warning comment.\n </Meta>\n </Card>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;AAcA,SAAgB,oBACd,SACA,SACA,WACS;CACT,OACE,qBAAC,OAAD;EAAK,OAAM;EAAX,UAAA;GACG,WAAW,cAAc,oBAAC,OAAD;IAAO,MAAK;IAAU,UAAA,UAAU;GAAmB,CAAA,IAAI;GACjF,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD,EAAA,UAAc,oBAA+B,CAAA;IAC7C,qBAAC,MAAD,EAAA,UAAA;KAAM;KACoB,oBAAC,QAAD,EAAA,UAAM,OAAU,CAAA;KAAC;KAEzC,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAAK,oBAAC,QAAD,EAAA,UAAM,uBAA0B,CAAA;KAAC;KAC7D,oBAAC,QAAD,EAAA,UAAM,kCAA+C,CAAA;KAAC;KAAgB;KACtE,oBAAC,QAAD,EAAA,UAAM,YAAe,CAAA;KAAC;KAAK,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAC9C,oBAAC,QAAD,EAAA,UAAM,kBAAqB,CAAA;KAAC;IAC5B,EAAA,CAAA;IACN,qBAAC,QAAD;KACE,QAAO;KACP,QAAQ,aAAa,QAAQ,KAAK;KAClC,WAAS,aAAa,QAAQ,KAAK;KACnC,aAAU;KACV,WAAQ;KALV,UAAA;MAOG,UAAU;MACX,oBAAC,YAAD;OACE,OAAM;OACN,MAAK;OACL,OAAM;OACN,SAAS,QAAQ,cAAc,OAAO,KAAA;OACtC,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACD,oBAAC,OAAD;OACE,OAAM;OACN,MAAK;OACL,MAAK;OACL,KAAI;OACJ,KAAI;OACJ,MAAK;OACL,OAAO,OAAO,QAAQ,aAAa;OACnC,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACA,UACC,oBAAC,aAAD,EAAA,UACE,oBAAC,QAAD;OAAQ,SAAQ;OAAU,MAAK;OAAS,MAAK;OAAQ,UAAA;MAE7C,CAAA,EACG,CAAA,IAEb,oBAAC,MAAD,EAAA,UAAM,0CAA6C,CAAA;KAEjD;;GACF,EAAA,CAAA;GACN,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD;KAAc,OAAO;KAAG,UAAA;IAAiC,CAAA;IACzD,oBAAC,MAAD;KAAM,IAAG;KACN,UAAA;;;;;;;;;;;;;IAaG,CAAA;IACN,qBAAC,MAAD,EAAA,UAAA;KAAM;KACuC,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAElE,oBAAC,QAAD,EAAA,UAAM,UAAa,CAAA;KAAC;IAChB,EAAA,CAAA;GACF,EAAA,CAAA;EACH;;AAET"}
1
+ {"version":3,"file":"settings-tests.mjs","names":[],"sources":["../../src/pages/settings-tests.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport {\n Alert,\n Button,\n Card,\n CheckField,\n Field,\n FormActions,\n Meta,\n SectionTitle,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\n\n/** Tests settings tab: play execution, Storybook 10 test runs, accessibility checks, play timeout. */\nexport function renderSettingsTests(\n project: Project,\n isAdmin: boolean,\n formState?: { globalError?: string },\n): unknown {\n return (\n <div class=\"grid max-w-form\">\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n <Card>\n <SectionTitle>Interaction and accessibility tests</SectionTitle>\n <Meta>\n When enabled, Storybook <code>play</code> functions run before each screenshot. Failures\n block the build unless the story is marked\n <code>flaky-test</code> via <code>tags: ['flaky-test']</code> or\n <code>parameters: &#123; flakyTest: true &#125;</code> (supports both{\" \"}\n <code>chromatic</code> and <code>storyshelf</code> keys, story-level, case-insensitive).\n Use <code>disableSnapshot</code> to skip a story entirely.\n </Meta>\n <Meta>\n Accessibility checks run axe on every captured story. Violations appear with the snapshot\n in review and never block a build; a story opts out with{\" \"}\n <code>parameters: &#123; a11y: &#123; disable: true &#125; &#125;</code>.\n </Meta>\n <Meta>\n Storybook 10 <code>.test()</code> entries (<code>story.test(\"name\", async ...)</code>) can\n run as interaction tests too. They take no screenshot: each runs in the Storybook iframe\n and is reported as passed or failed on the build. A failing test fails the build like a\n failing <code>play</code> function. Storybook 8 and 9 builds have none.\n </Meta>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/tests`}\n hx-post={`/projects/${project.slug}/settings/tests`}\n hx-target=\"body\"\n hx-swap=\"outerHTML\"\n >\n {csrfField()}\n <CheckField\n label=\"Enable interaction tests (play)\"\n name=\"executePlay\"\n value=\"true\"\n checked={project.executePlay ? true : undefined}\n disabled={!isAdmin}\n hint=\"When enabled, failing play blocks the build (failed). Flaky stories show warnings but status stays successful.\"\n />\n <CheckField\n label=\"Run Storybook tests (.test())\"\n name=\"runTests\"\n value=\"true\"\n checked={project.runTests === true ? true : undefined}\n disabled={!isAdmin}\n hint=\"Off by default, because it changes what a build contains. When enabled, each .test() entry runs once on the primary browser and a failing test fails the build (flaky stories excepted).\"\n />\n <CheckField\n label=\"Accessibility checks (axe)\"\n name=\"runA11y\"\n value=\"true\"\n checked={project.runA11y === false ? undefined : true}\n disabled={!isAdmin}\n hint=\"On by default. Each story is scanned with axe-core (WCAG A/AA and best-practice rules). Violations are shown on the snapshot in review and never block the build.\"\n />\n <Field\n label=\"Play timeout (ms)\"\n name=\"playTimeoutMs\"\n type=\"number\"\n min=\"1000\"\n max=\"30000\"\n step=\"1000\"\n value={String(project.playTimeoutMs)}\n disabled={!isAdmin}\n hint=\"Timeout for each play function (1000–30000 ms, default 10000).\"\n />\n {isAdmin ? (\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"check\">\n Save changes\n </Button>\n </FormActions>\n ) : (\n <Meta>You need admin access to edit settings.</Meta>\n )}\n </form>\n </Card>\n <Card>\n <SectionTitle level={3}>How to mark stories</SectionTitle>\n <Meta as=\"pre\">\n {`// Disable snapshot entirely (skip capture + play)\nexport const Hidden: Story = {\n parameters: { storyshelf: { disableSnapshot: true } }\n};\n// Mark as flaky (non-blocking, shows warning)\nexport const Flaky: Story = {\n tags: ['flaky-test'],\n play: async ({canvasElement}) => { /* ... */ }\n};\n// or via parameters (both keys work, storyshelf wins)\nexport const Flaky2: Story = {\n parameters: { chromatic: { flakyTest: true } }\n};`}\n </Meta>\n <Meta>\n Tags are story-level and case-insensitive (<code>flaky-test</code>). Whole story is\n non-blocking when flaky. GitHub status stays\n <code>success</code> with a warning comment.\n </Meta>\n </Card>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;AAcA,SAAgB,oBACd,SACA,SACA,WACS;CACT,OACE,qBAAC,OAAD;EAAK,OAAM;EAAX,UAAA;GACG,WAAW,cAAc,oBAAC,OAAD;IAAO,MAAK;IAAU,UAAA,UAAU;GAAmB,CAAA,IAAI;GACjF,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD,EAAA,UAAc,sCAAiD,CAAA;IAC/D,qBAAC,MAAD,EAAA,UAAA;KAAM;KACoB,oBAAC,QAAD,EAAA,UAAM,OAAU,CAAA;KAAC;KAEzC,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAAK,oBAAC,QAAD,EAAA,UAAM,uBAA0B,CAAA;KAAC;KAC7D,oBAAC,QAAD,EAAA,UAAM,kCAA+C,CAAA;KAAC;KAAgB;KACtE,oBAAC,QAAD,EAAA,UAAM,YAAe,CAAA;KAAC;KAAK,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAC9C,oBAAC,QAAD,EAAA,UAAM,kBAAqB,CAAA;KAAC;IAC5B,EAAA,CAAA;IACN,qBAAC,MAAD,EAAA,UAAA;KAAM;KAEqD;KACzD,oBAAC,QAAD,EAAA,UAAM,0CAAiE,CAAA;KAAC;IACpE,EAAA,CAAA;IACN,qBAAC,MAAD,EAAA,UAAA;KAAM;KACS,oBAAC,QAAD,EAAA,UAAM,UAAa,CAAA;KAAC;KAAU,oBAAC,QAAD,EAAA,UAAM,kCAAmC,CAAA;KAAC;KAG7E,oBAAC,QAAD,EAAA,UAAM,OAAU,CAAA;KAAC;IACrB,EAAA,CAAA;IACN,qBAAC,QAAD;KACE,QAAO;KACP,QAAQ,aAAa,QAAQ,KAAK;KAClC,WAAS,aAAa,QAAQ,KAAK;KACnC,aAAU;KACV,WAAQ;KALV,UAAA;MAOG,UAAU;MACX,oBAAC,YAAD;OACE,OAAM;OACN,MAAK;OACL,OAAM;OACN,SAAS,QAAQ,cAAc,OAAO,KAAA;OACtC,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACD,oBAAC,YAAD;OACE,OAAM;OACN,MAAK;OACL,OAAM;OACN,SAAS,QAAQ,aAAa,OAAO,OAAO,KAAA;OAC5C,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACD,oBAAC,YAAD;OACE,OAAM;OACN,MAAK;OACL,OAAM;OACN,SAAS,QAAQ,YAAY,QAAQ,KAAA,IAAY;OACjD,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACD,oBAAC,OAAD;OACE,OAAM;OACN,MAAK;OACL,MAAK;OACL,KAAI;OACJ,KAAI;OACJ,MAAK;OACL,OAAO,OAAO,QAAQ,aAAa;OACnC,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACA,UACC,oBAAC,aAAD,EAAA,UACE,oBAAC,QAAD;OAAQ,SAAQ;OAAU,MAAK;OAAS,MAAK;OAAQ,UAAA;MAE7C,CAAA,EACG,CAAA,IAEb,oBAAC,MAAD,EAAA,UAAM,0CAA6C,CAAA;KAEjD;;GACF,EAAA,CAAA;GACN,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD;KAAc,OAAO;KAAG,UAAA;IAAiC,CAAA;IACzD,oBAAC,MAAD;KAAM,IAAG;KACN,UAAA;;;;;;;;;;;;;IAaG,CAAA;IACN,qBAAC,MAAD,EAAA,UAAA;KAAM;KACuC,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAElE,oBAAC,QAAD,EAAA,UAAM,UAAa,CAAA;KAAC;IAChB,EAAA,CAAA;GACF,EAAA,CAAA;EACH;;AAET"}
@@ -0,0 +1,39 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { targetLabel } from "../targets.mjs";
5
+ import { BROWSER_NAMES, unavailableReason } from "@storyshelf/core/adapter/capture-runner";
6
+ //#region src/pages/target-choices.ts
7
+ /** The capture targets a project can pick, from what the runner offers (ADR 0007, decision 7). */
8
+ /**
9
+ * Selectable targets: every ready one; a project's current target that is no longer ready stays,
10
+ * marked, so the form round-trips it. Without a runner list every engine is offered.
11
+ */
12
+ function targetChoices(offered, current) {
13
+ if (offered === void 0) return {
14
+ choices: BROWSER_NAMES.map((name) => ({
15
+ value: name,
16
+ label: targetLabel(name)
17
+ })),
18
+ unavailable: []
19
+ };
20
+ const choices = offered.filter((entry) => unavailableReason(entry.status) === null).map((entry) => ({
21
+ value: entry.target,
22
+ label: targetLabel(entry.target)
23
+ }));
24
+ for (const target of current) if (!choices.some((choice) => choice.value === target)) choices.push({
25
+ value: target,
26
+ label: `${targetLabel(target)} (not available)`
27
+ });
28
+ return {
29
+ choices,
30
+ unavailable: offered.flatMap((entry) => {
31
+ const reason = unavailableReason(entry.status);
32
+ return reason === null ? [] : [`${entry.target}: ${reason}`];
33
+ })
34
+ };
35
+ }
36
+ //#endregion
37
+ export { targetChoices };
38
+
39
+ //# sourceMappingURL=target-choices.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"target-choices.mjs","names":[],"sources":["../../src/pages/target-choices.ts"],"sourcesContent":["/** The capture targets a project can pick, from what the runner offers (ADR 0007, decision 7). */\nimport {\n BROWSER_NAMES,\n unavailableReason,\n type CaptureTarget,\n type RunnerTarget,\n} from \"@storyshelf/core/adapter/capture-runner\";\nimport { targetLabel } from \"../targets.ts\";\n\n/** One selectable target. */\nexport interface TargetChoice {\n value: string;\n label: string;\n}\n\n/** What the capture settings offer. */\nexport interface TargetChoices {\n /** Targets that can be picked now, plus the project's own (so saving never silently drops one). */\n choices: TargetChoice[];\n /** Offered but not selectable, with why (`firefox: declared, not installed`). */\n unavailable: string[];\n}\n\n/**\n * Selectable targets: every ready one; a project's current target that is no longer ready stays,\n * marked, so the form round-trips it. Without a runner list every engine is offered.\n */\nexport function targetChoices(\n offered: readonly RunnerTarget[] | undefined,\n current: readonly CaptureTarget[],\n): TargetChoices {\n if (offered === undefined) {\n return {\n choices: BROWSER_NAMES.map((name) => ({ value: name, label: targetLabel(name) })),\n unavailable: [],\n };\n }\n const ready = offered.filter((entry) => unavailableReason(entry.status) === null);\n const choices = ready.map((entry) => ({\n value: entry.target,\n label: targetLabel(entry.target),\n }));\n for (const target of current) {\n if (!choices.some((choice) => choice.value === target)) {\n choices.push({ value: target, label: `${targetLabel(target)} (not available)` });\n }\n }\n const unavailable = offered.flatMap((entry) => {\n const reason = unavailableReason(entry.status);\n return reason === null ? [] : [`${entry.target}: ${reason}`];\n });\n return { choices, unavailable };\n}\n"],"mappings":";;;;;;;;;;;AA2BA,SAAgB,cACd,SACA,SACe;CACf,IAAI,YAAY,KAAA,GACd,OAAO;EACL,SAAS,cAAc,KAAK,UAAU;GAAE,OAAO;GAAM,OAAO,YAAY,IAAI;EAAE,EAAE;EAChF,aAAa,CAAC;CAChB;CAGF,MAAM,UADQ,QAAQ,QAAQ,UAAU,kBAAkB,MAAM,MAAM,MAAM,IACxD,CAAC,CAAC,KAAK,WAAW;EACpC,OAAO,MAAM;EACb,OAAO,YAAY,MAAM,MAAM;CACjC,EAAE;CACF,KAAK,MAAM,UAAU,SACnB,IAAI,CAAC,QAAQ,MAAM,WAAW,OAAO,UAAU,MAAM,GACnD,QAAQ,KAAK;EAAE,OAAO;EAAQ,OAAO,GAAG,YAAY,MAAM,EAAE;CAAkB,CAAC;CAOnF,OAAO;EAAE;EAAS,aAJE,QAAQ,SAAS,UAAU;GAC7C,MAAM,SAAS,kBAAkB,MAAM,MAAM;GAC7C,OAAO,WAAW,OAAO,CAAC,IAAI,CAAC,GAAG,MAAM,OAAO,IAAI,QAAQ;EAC7D,CAC4B;CAAE;AAChC"}
@@ -0,0 +1,89 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { DEFAULT_SCRATCH_DIR } from "@storyshelf/core/config";
5
+ import { createCatalogHook, createCatalogLoader, createPublishCompleter, createPublishHandler, extractionLimitsOf, resolvePublishLimits } from "@storyshelf/core/capture";
6
+ //#region src/publish-setup.ts
7
+ /**
8
+ * Publishing wiring (ADR 0006, decision 10): the completer every upload route ends in, and the
9
+ * `publish` job kind on the shared database queue. Its handler runs on every instance with a
10
+ * database, so a hosting-only instance can publish large zips; it is absent on serverless hosts,
11
+ * where an over-limit zip is refused instead. The consumer that calls the handler is built in
12
+ * `jobs-setup.ts`, together with the capture handler.
13
+ */
14
+ /**
15
+ * Wire publishing for `options` (the instrumented options; `config` is the resolved config).
16
+ * `captureWillRun` is whether uploads get a capture (`enqueueCapture` exists): capture owns the
17
+ * build status then, and publishing leaves it alone. `delivery.jobs` is the shared queue; a host
18
+ * that polls no `publish` jobs (serverless) has none of the job side. When publishing ends,
19
+ * `delivery.buildState` shows the build's state on its git channels (the `storybook` status) and
20
+ * `delivery.reviewRequested` asks the reviewers for a verdict on a build that waits for one.
21
+ */
22
+ function setupPublishing(options, config, logger, captureWillRun, delivery) {
23
+ const { jobs } = delivery;
24
+ const catalog = createCatalogLoader({
25
+ db: options.database,
26
+ storage: options.storage,
27
+ logger: logger.child({ component: "catalog" })
28
+ });
29
+ const afterPublish = publishHooks(catalog, captureWillRun, delivery);
30
+ const completer = createPublishCompleter({
31
+ db: options.database,
32
+ captureWillRun,
33
+ afterPublish,
34
+ logger
35
+ });
36
+ if (!jobs.kinds.includes("publish")) return {
37
+ completer,
38
+ afterPublish,
39
+ catalog,
40
+ enqueuePublish: void 0,
41
+ handler: null
42
+ };
43
+ jobs.exhausted.publish = async (job, error) => {
44
+ await completer.complete(job.buildId, {
45
+ ok: false,
46
+ error
47
+ });
48
+ };
49
+ return {
50
+ completer,
51
+ afterPublish,
52
+ catalog,
53
+ enqueuePublish: async (buildId, reqId) => {
54
+ await jobs.get().enqueueJob({
55
+ kind: "publish",
56
+ buildId,
57
+ ...reqId ? { reqId } : {}
58
+ });
59
+ },
60
+ handler: createPublishHandler({
61
+ db: options.database,
62
+ storage: options.storage,
63
+ scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,
64
+ limits: extractionLimitsOf(resolvePublishLimits(config)),
65
+ completer,
66
+ logger
67
+ })
68
+ };
69
+ }
70
+ /**
71
+ * Hooks run after every publish: the catalog, the build's state on its git channels (the Storybook
72
+ * status shows the end of publishing, a transition no build status change marks) and, without
73
+ * `testing`, the request for a verdict on a build left `pending` (ADR 0013, decision 4).
74
+ */
75
+ function publishHooks(catalog, captureWillRun, delivery) {
76
+ return [
77
+ createCatalogHook(catalog),
78
+ async ({ build }) => {
79
+ await delivery.buildState.request(build.id);
80
+ },
81
+ async ({ build, project, outcome }) => {
82
+ if (outcome.ok && !captureWillRun && build.status === "pending") await delivery.reviewRequested(project, build);
83
+ }
84
+ ];
85
+ }
86
+ //#endregion
87
+ export { setupPublishing };
88
+
89
+ //# sourceMappingURL=publish-setup.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"publish-setup.mjs","names":[],"sources":["../src/publish-setup.ts"],"sourcesContent":["/**\n * Publishing wiring (ADR 0006, decision 10): the completer every upload route ends in, and the\n * `publish` job kind on the shared database queue. Its handler runs on every instance with a\n * database, so a hosting-only instance can publish large zips; it is absent on serverless hosts,\n * where an over-limit zip is refused instead. The consumer that calls the handler is built in\n * `jobs-setup.ts`, together with the capture handler.\n */\nimport {\n createCatalogHook,\n createCatalogLoader,\n createPublishCompleter,\n createPublishHandler,\n extractionLimitsOf,\n resolvePublishLimits,\n type AfterPublishHook,\n type BuildStatePublisher,\n type CatalogLoader,\n type KindHandler,\n type PublishCompleter,\n} from \"@storyshelf/core/capture\";\nimport type { ShelfConfig, ShelfOptions } from \"@storyshelf/core/config\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport type { JobQueue } from \"./job-queue.ts\";\n\n/** What request handlers and the consumer use of publishing. */\nexport interface PublishWiring {\n /** Applies the acceptance rule and the hooks when a publish ends. */\n completer: PublishCompleter;\n /** Hooks run after every publish; the catalog registers here. */\n afterPublish: AfterPublishHook[];\n /** The Storybook catalog of published builds (cached in memory; catalogues old builds lazily). */\n catalog: CatalogLoader;\n /** Puts a `publish` job on the queue; undefined when this host runs no consumer. */\n enqueuePublish: ((buildId: string, reqId?: string) => Promise<void>) | undefined;\n /** Runs `publish` jobs; null when this host runs none. */\n handler: KindHandler | null;\n}\n\n/** The request-scoped part of {@link PublishWiring}. */\nexport type PublishService = Pick<\n PublishWiring,\n \"completer\" | \"afterPublish\" | \"catalog\" | \"enqueuePublish\"\n>;\n\n/**\n * Wire publishing for `options` (the instrumented options; `config` is the resolved config).\n * `captureWillRun` is whether uploads get a capture (`enqueueCapture` exists): capture owns the\n * build status then, and publishing leaves it alone. `delivery.jobs` is the shared queue; a host\n * that polls no `publish` jobs (serverless) has none of the job side. When publishing ends,\n * `delivery.buildState` shows the build's state on its git channels (the `storybook` status) and\n * `delivery.reviewRequested` asks the reviewers for a verdict on a build that waits for one.\n */\nexport function setupPublishing(\n options: ShelfOptions,\n config: ShelfConfig,\n logger: Logger,\n captureWillRun: boolean,\n delivery: {\n jobs: JobQueue;\n buildState: BuildStatePublisher;\n /** Tells the project's reviewers a build waits for a verdict (`review:requested`). */\n reviewRequested: (project: Project, build: Build) => Promise<void>;\n },\n): PublishWiring {\n const { jobs } = delivery;\n const catalog = createCatalogLoader({\n db: options.database,\n storage: options.storage,\n logger: logger.child({ component: \"catalog\" }),\n });\n const afterPublish = publishHooks(catalog, captureWillRun, delivery);\n const completer = createPublishCompleter({\n db: options.database,\n captureWillRun,\n afterPublish,\n logger,\n });\n if (!jobs.kinds.includes(\"publish\")) {\n return { completer, afterPublish, catalog, enqueuePublish: undefined, handler: null };\n }\n // A publish whose last attempt lost its lease has no running handler to fail the build.\n jobs.exhausted.publish = async (job, error): Promise<void> => {\n await completer.complete(job.buildId, { ok: false, error });\n };\n return {\n completer,\n afterPublish,\n catalog,\n enqueuePublish: async (buildId, reqId): Promise<void> => {\n await jobs.get().enqueueJob({ kind: \"publish\", buildId, ...(reqId ? { reqId } : {}) });\n },\n handler: createPublishHandler({\n db: options.database,\n storage: options.storage,\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n limits: extractionLimitsOf(resolvePublishLimits(config)),\n completer,\n logger,\n }),\n };\n}\n\n/**\n * Hooks run after every publish: the catalog, the build's state on its git channels (the Storybook\n * status shows the end of publishing, a transition no build status change marks) and, without\n * `testing`, the request for a verdict on a build left `pending` (ADR 0013, decision 4).\n */\nfunction publishHooks(\n catalog: CatalogLoader,\n captureWillRun: boolean,\n delivery: Pick<Parameters<typeof setupPublishing>[4], \"buildState\" | \"reviewRequested\">,\n): AfterPublishHook[] {\n return [\n createCatalogHook(catalog),\n async ({ build }) => {\n await delivery.buildState.request(build.id);\n },\n async ({ build, project, outcome }) => {\n if (outcome.ok && !captureWillRun && build.status === \"pending\") {\n await delivery.reviewRequested(project, build);\n }\n },\n ];\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAsDA,SAAgB,gBACd,SACA,QACA,QACA,gBACA,UAMe;CACf,MAAM,EAAE,SAAS;CACjB,MAAM,UAAU,oBAAoB;EAClC,IAAI,QAAQ;EACZ,SAAS,QAAQ;EACjB,QAAQ,OAAO,MAAM,EAAE,WAAW,UAAU,CAAC;CAC/C,CAAC;CACD,MAAM,eAAe,aAAa,SAAS,gBAAgB,QAAQ;CACnE,MAAM,YAAY,uBAAuB;EACvC,IAAI,QAAQ;EACZ;EACA;EACA;CACF,CAAC;CACD,IAAI,CAAC,KAAK,MAAM,SAAS,SAAS,GAChC,OAAO;EAAE;EAAW;EAAc;EAAS,gBAAgB,KAAA;EAAW,SAAS;CAAK;CAGtF,KAAK,UAAU,UAAU,OAAO,KAAK,UAAyB;EAC5D,MAAM,UAAU,SAAS,IAAI,SAAS;GAAE,IAAI;GAAO;EAAM,CAAC;CAC5D;CACA,OAAO;EACL;EACA;EACA;EACA,gBAAgB,OAAO,SAAS,UAAyB;GACvD,MAAM,KAAK,IAAI,CAAC,CAAC,WAAW;IAAE,MAAM;IAAW;IAAS,GAAI,QAAQ,EAAE,MAAM,IAAI,CAAC;GAAG,CAAC;EACvF;EACA,SAAS,qBAAqB;GAC5B,IAAI,QAAQ;GACZ,SAAS,QAAQ;GACjB,YAAY,OAAO,cAAc;GACjC,QAAQ,mBAAmB,qBAAqB,MAAM,CAAC;GACvD;GACA;EACF,CAAC;CACH;AACF;;;;;;AAOA,SAAS,aACP,SACA,gBACA,UACoB;CACpB,OAAO;EACL,kBAAkB,OAAO;EACzB,OAAO,EAAE,YAAY;GACnB,MAAM,SAAS,WAAW,QAAQ,MAAM,EAAE;EAC5C;EACA,OAAO,EAAE,OAAO,SAAS,cAAc;GACrC,IAAI,QAAQ,MAAM,CAAC,kBAAkB,MAAM,WAAW,WACpD,MAAM,SAAS,gBAAgB,SAAS,KAAK;EAEjD;CACF;AACF"}
@@ -2,7 +2,7 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { ProjectModel } from "@storyshelf/core/models";
5
- import { Retention } from "@storyshelf/core/retention";
5
+ import { Retention, purgeAiData } from "@storyshelf/core/retention";
6
6
  //#region src/retention-timer.ts
7
7
  const DEFAULT_BRANCH_TTL_DAYS = 30;
8
8
  const DEFAULT_BRANCH_GC_INTERVAL_MS = 864e5;
@@ -36,6 +36,7 @@ function createBranchGcRunner(db, storage, logger, ttlDays) {
36
36
  const results = await Promise.all(projects.map((project) => retention.purgeStaleBranches(project, ttlDays)));
37
37
  const totalBranches = results.reduce((sum, r) => sum + r.removedBranches, 0);
38
38
  const totalBaselines = results.reduce((sum, r) => sum + r.removedBaselines, 0);
39
+ await purgeAiData(db, /* @__PURE__ */ new Date(), logger);
39
40
  if (totalBranches > 0 || totalBaselines > 0) logger.info({
40
41
  totalBranches,
41
42
  totalBaselines
@@ -1 +1 @@
1
- {"version":3,"file":"retention-timer.mjs","names":[],"sources":["../src/retention-timer.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { ShelfConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\n/* oxlint-disable typescript/promise-function-async -- interval helpers return promise factories */\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { Retention } from \"@storyshelf/core/retention\";\n\nconst DEFAULT_BRANCH_TTL_DAYS = 30;\nconst DEFAULT_BRANCH_GC_INTERVAL_MS = 86_400_000;\n\n/** Handle for the background branch-GC interval; call `stop()` on server shutdown. */\nexport interface BranchGcTimer {\n stop(): void;\n}\n\n/**\n * Start a daily sweep that removes baselines for branches that have not\n * been updated within `branchTtlDays`. Returns a timer handle or `null`\n * when GC is disabled (`branchTtlDays: null` or non-positive interval).\n *\n * @param db - Database adapter to list projects\n * @param storage - Storage adapter for baseline cleanup\n * @param config - Shelf config supplying `branchTtlDays` and `branchGcIntervalMs`\n * @param logger - Structured logger for sweep diagnostics\n * @returns Timer handle with `stop()`, or `null` if GC is disabled\n */\nexport function startBranchGcTimer(\n db: DatabaseAdapter,\n storage: StorageAdapter,\n config: ShelfConfig,\n logger: Logger,\n): BranchGcTimer | null {\n const ttlDays = config.branchTtlDays ?? DEFAULT_BRANCH_TTL_DAYS;\n if (ttlDays === null) {\n return null;\n }\n const intervalMs = config.branchGcIntervalMs ?? DEFAULT_BRANCH_GC_INTERVAL_MS;\n if (intervalMs <= 0) {\n return null;\n }\n const run = createBranchGcRunner(db, storage, logger, ttlDays);\n return scheduleBranchGc(run, intervalMs);\n}\n\n// oxlint-disable-next-line typescript/promise-function-async\nfunction createBranchGcRunner(\n db: DatabaseAdapter,\n storage: StorageAdapter,\n logger: Logger,\n ttlDays: number,\n): () => Promise<void> {\n return async (): Promise<void> => {\n try {\n const projects = await new ProjectModel(db).list();\n const retention = new Retention(\n db,\n storage,\n {\n builds: db.tables.builds,\n buildLabels: db.tables.buildLabels,\n baselines: db.tables.baselines,\n },\n logger,\n );\n const results = await Promise.all(\n projects.map((project) => retention.purgeStaleBranches(project, ttlDays)),\n );\n const totalBranches = results.reduce(\n (sum: number, r: { removedBranches: number }) => sum + r.removedBranches,\n 0,\n );\n const totalBaselines = results.reduce(\n (sum: number, r: { removedBaselines: number }) => sum + r.removedBaselines,\n 0,\n );\n if (totalBranches > 0 || totalBaselines > 0) {\n logger.info({ totalBranches, totalBaselines }, \"branch GC daily sweep complete\");\n }\n } catch (error) {\n logger.error({ err: error }, \"branch GC sweep failed\");\n }\n };\n}\n\nfunction scheduleBranchGc(run: () => Promise<void>, intervalMs: number): BranchGcTimer {\n const firstDelay = Math.min(3_600_000, intervalMs);\n const timeout = setTimeout(() => {\n // oxlint-disable-next-line no-void -- fire-and-forget interval task\n void run();\n }, firstDelay);\n timeout.unref?.();\n\n const interval = setInterval(() => {\n // oxlint-disable-next-line no-void -- fire-and-forget interval task\n void run();\n }, intervalMs);\n interval.unref?.();\n\n return {\n stop: () => {\n clearTimeout(timeout);\n clearInterval(interval);\n },\n };\n}\n"],"mappings":";;;;;;AAQA,MAAM,0BAA0B;AAChC,MAAM,gCAAgC;;;;;;;;;;;;AAkBtC,SAAgB,mBACd,IACA,SACA,QACA,QACsB;CACtB,MAAM,UAAU,OAAO,iBAAiB;CACxC,IAAI,YAAY,MACd,OAAO;CAET,MAAM,aAAa,OAAO,sBAAsB;CAChD,IAAI,cAAc,GAChB,OAAO;CAGT,OAAO,iBADK,qBAAqB,IAAI,SAAS,QAAQ,OAC5B,GAAG,UAAU;AACzC;AAGA,SAAS,qBACP,IACA,SACA,QACA,SACqB;CACrB,OAAO,YAA2B;EAChC,IAAI;GACF,MAAM,WAAW,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,KAAK;GACjD,MAAM,YAAY,IAAI,UACpB,IACA,SACA;IACE,QAAQ,GAAG,OAAO;IAClB,aAAa,GAAG,OAAO;IACvB,WAAW,GAAG,OAAO;GACvB,GACA,MACF;GACA,MAAM,UAAU,MAAM,QAAQ,IAC5B,SAAS,KAAK,YAAY,UAAU,mBAAmB,SAAS,OAAO,CAAC,CAC1E;GACA,MAAM,gBAAgB,QAAQ,QAC3B,KAAa,MAAmC,MAAM,EAAE,iBACzD,CACF;GACA,MAAM,iBAAiB,QAAQ,QAC5B,KAAa,MAAoC,MAAM,EAAE,kBAC1D,CACF;GACA,IAAI,gBAAgB,KAAK,iBAAiB,GACxC,OAAO,KAAK;IAAE;IAAe;GAAe,GAAG,gCAAgC;EAEnF,SAAS,OAAO;GACd,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,wBAAwB;EACvD;CACF;AACF;AAEA,SAAS,iBAAiB,KAA0B,YAAmC;CAErF,MAAM,UAAU,iBAAiB;EAE/B,IAAS;CACX,GAJmB,KAAK,IAAI,MAAW,UAI3B,CAAC;CACb,QAAQ,QAAQ;CAEhB,MAAM,WAAW,kBAAkB;EAEjC,IAAS;CACX,GAAG,UAAU;CACb,SAAS,QAAQ;CAEjB,OAAO,EACL,YAAY;EACV,aAAa,OAAO;EACpB,cAAc,QAAQ;CACxB,EACF;AACF"}
1
+ {"version":3,"file":"retention-timer.mjs","names":[],"sources":["../src/retention-timer.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { ShelfConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\n/* oxlint-disable typescript/promise-function-async -- interval helpers return promise factories */\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { Retention, purgeAiData } from \"@storyshelf/core/retention\";\n\nconst DEFAULT_BRANCH_TTL_DAYS = 30;\nconst DEFAULT_BRANCH_GC_INTERVAL_MS = 86_400_000;\n\n/** Handle for the background branch-GC interval; call `stop()` on server shutdown. */\nexport interface BranchGcTimer {\n stop(): void;\n}\n\n/**\n * Start a daily sweep that removes baselines for branches that have not\n * been updated within `branchTtlDays`. Returns a timer handle or `null`\n * when GC is disabled (`branchTtlDays: null` or non-positive interval).\n *\n * @param db - Database adapter to list projects\n * @param storage - Storage adapter for baseline cleanup\n * @param config - Shelf config supplying `branchTtlDays` and `branchGcIntervalMs`\n * @param logger - Structured logger for sweep diagnostics\n * @returns Timer handle with `stop()`, or `null` if GC is disabled\n */\nexport function startBranchGcTimer(\n db: DatabaseAdapter,\n storage: StorageAdapter,\n config: ShelfConfig,\n logger: Logger,\n): BranchGcTimer | null {\n const ttlDays = config.branchTtlDays ?? DEFAULT_BRANCH_TTL_DAYS;\n if (ttlDays === null) {\n return null;\n }\n const intervalMs = config.branchGcIntervalMs ?? DEFAULT_BRANCH_GC_INTERVAL_MS;\n if (intervalMs <= 0) {\n return null;\n }\n const run = createBranchGcRunner(db, storage, logger, ttlDays);\n return scheduleBranchGc(run, intervalMs);\n}\n\n// oxlint-disable-next-line typescript/promise-function-async\nfunction createBranchGcRunner(\n db: DatabaseAdapter,\n storage: StorageAdapter,\n logger: Logger,\n ttlDays: number,\n): () => Promise<void> {\n return async (): Promise<void> => {\n try {\n const projects = await new ProjectModel(db).list();\n const retention = new Retention(\n db,\n storage,\n {\n builds: db.tables.builds,\n buildLabels: db.tables.buildLabels,\n baselines: db.tables.baselines,\n },\n logger,\n );\n const results = await Promise.all(\n projects.map((project) => retention.purgeStaleBranches(project, ttlDays)),\n );\n const totalBranches = results.reduce(\n (sum: number, r: { removedBranches: number }) => sum + r.removedBranches,\n 0,\n );\n const totalBaselines = results.reduce(\n (sum: number, r: { removedBaselines: number }) => sum + r.removedBaselines,\n 0,\n );\n await purgeAiData(db, new Date(), logger);\n if (totalBranches > 0 || totalBaselines > 0) {\n logger.info({ totalBranches, totalBaselines }, \"branch GC daily sweep complete\");\n }\n } catch (error) {\n logger.error({ err: error }, \"branch GC sweep failed\");\n }\n };\n}\n\nfunction scheduleBranchGc(run: () => Promise<void>, intervalMs: number): BranchGcTimer {\n const firstDelay = Math.min(3_600_000, intervalMs);\n const timeout = setTimeout(() => {\n // oxlint-disable-next-line no-void -- fire-and-forget interval task\n void run();\n }, firstDelay);\n timeout.unref?.();\n\n const interval = setInterval(() => {\n // oxlint-disable-next-line no-void -- fire-and-forget interval task\n void run();\n }, intervalMs);\n interval.unref?.();\n\n return {\n stop: () => {\n clearTimeout(timeout);\n clearInterval(interval);\n },\n };\n}\n"],"mappings":";;;;;;AAQA,MAAM,0BAA0B;AAChC,MAAM,gCAAgC;;;;;;;;;;;;AAkBtC,SAAgB,mBACd,IACA,SACA,QACA,QACsB;CACtB,MAAM,UAAU,OAAO,iBAAiB;CACxC,IAAI,YAAY,MACd,OAAO;CAET,MAAM,aAAa,OAAO,sBAAsB;CAChD,IAAI,cAAc,GAChB,OAAO;CAGT,OAAO,iBADK,qBAAqB,IAAI,SAAS,QAAQ,OAC5B,GAAG,UAAU;AACzC;AAGA,SAAS,qBACP,IACA,SACA,QACA,SACqB;CACrB,OAAO,YAA2B;EAChC,IAAI;GACF,MAAM,WAAW,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,KAAK;GACjD,MAAM,YAAY,IAAI,UACpB,IACA,SACA;IACE,QAAQ,GAAG,OAAO;IAClB,aAAa,GAAG,OAAO;IACvB,WAAW,GAAG,OAAO;GACvB,GACA,MACF;GACA,MAAM,UAAU,MAAM,QAAQ,IAC5B,SAAS,KAAK,YAAY,UAAU,mBAAmB,SAAS,OAAO,CAAC,CAC1E;GACA,MAAM,gBAAgB,QAAQ,QAC3B,KAAa,MAAmC,MAAM,EAAE,iBACzD,CACF;GACA,MAAM,iBAAiB,QAAQ,QAC5B,KAAa,MAAoC,MAAM,EAAE,kBAC1D,CACF;GACA,MAAM,YAAY,oBAAI,IAAI,KAAK,GAAG,MAAM;GACxC,IAAI,gBAAgB,KAAK,iBAAiB,GACxC,OAAO,KAAK;IAAE;IAAe;GAAe,GAAG,gCAAgC;EAEnF,SAAS,OAAO;GACd,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,wBAAwB;EACvD;CACF;AACF;AAEA,SAAS,iBAAiB,KAA0B,YAAmC;CAErF,MAAM,UAAU,iBAAiB;EAE/B,IAAS;CACX,GAJmB,KAAK,IAAI,MAAW,UAI3B,CAAC;CACb,QAAQ,QAAQ;CAEhB,MAAM,WAAW,kBAAkB;EAEjC,IAAS;CACX,GAAG,UAAU;CACb,SAAS,QAAQ;CAEjB,OAAO,EACL,YAAY;EACV,aAAa,OAAO;EACpB,cAAc,QAAQ;CACxB,EACF;AACF"}
@@ -0,0 +1,48 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "./credential-keys.mjs";
5
+ import { getStore } from "./store.mjs";
6
+ import { notifyProjectWith } from "./notify.mjs";
7
+ import { emitWebhookEvent } from "@storyshelf/core/adapter/webhook-events";
8
+ //#region src/review-events.ts
9
+ /**
10
+ * Review events (ADR 0013, decisions 4 and 10; ADR 0014, decision 10). They ride the existing
11
+ * paths: signed webhooks to the project's subscribers and the notification fan-out (stored
12
+ * channels plus per-user subscriptions). There is no new transport.
13
+ *
14
+ * - `review:requested`: a build is waiting for a verdict. Default-on for the project's approvers
15
+ * and admins (see `notify.ts`).
16
+ * - `review:comment`: a build or story comment (snapshot comments keep `comment:created`).
17
+ * - `review:verdict`: a reviewer approved or rejected a build.
18
+ */
19
+ /** Event names of the review topics. */
20
+ const REVIEW_EVENTS = {
21
+ requested: "review:requested",
22
+ comment: "review:comment",
23
+ verdict: "review:verdict"
24
+ };
25
+ /** Webhook and notification fan-out for one review event, from explicit dependencies. */
26
+ async function announceReviewWith(deps, project, event, data, buildId) {
27
+ await emitWebhookEvent(deps.db, { webhooks: deps.db.tables.webhooks }, project.id, event, data, credentialKeys(deps.config), deps.logger);
28
+ await notifyProjectWith(deps, project, event, data, `/builds/${buildId}`);
29
+ }
30
+ /** {@link announceReviewWith} inside a request. */
31
+ async function announceReview(project, event, data, buildId) {
32
+ await announceReviewWith(getStore(), project, event, data, buildId);
33
+ }
34
+ /** What `review:requested` carries: the build to look at. */
35
+ function reviewRequestedData(build) {
36
+ return {
37
+ buildId: build.id,
38
+ gitSha: build.gitSha,
39
+ gitBranch: build.gitBranch,
40
+ authorName: build.authorName,
41
+ authorEmail: build.authorEmail,
42
+ message: build.message
43
+ };
44
+ }
45
+ //#endregion
46
+ export { REVIEW_EVENTS, announceReview, announceReviewWith, reviewRequestedData };
47
+
48
+ //# sourceMappingURL=review-events.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review-events.mjs","names":[],"sources":["../src/review-events.ts"],"sourcesContent":["/**\n * Review events (ADR 0013, decisions 4 and 10; ADR 0014, decision 10). They ride the existing\n * paths: signed webhooks to the project's subscribers and the notification fan-out (stored\n * channels plus per-user subscriptions). There is no new transport.\n *\n * - `review:requested`: a build is waiting for a verdict. Default-on for the project's approvers\n * and admins (see `notify.ts`).\n * - `review:comment`: a build or story comment (snapshot comments keep `comment:created`).\n * - `review:verdict`: a reviewer approved or rejected a build.\n */\nimport { emitWebhookEvent } from \"@storyshelf/core/adapter/webhook-events\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { notifyProjectWith, type NotifyDeps } from \"./notify.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Event names of the review topics. */\nexport const REVIEW_EVENTS = {\n requested: \"review:requested\",\n comment: \"review:comment\",\n verdict: \"review:verdict\",\n} as const;\n\n/** Webhook and notification fan-out for one review event, from explicit dependencies. */\nexport async function announceReviewWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: (typeof REVIEW_EVENTS)[keyof typeof REVIEW_EVENTS],\n data: Record<string, unknown>,\n buildId: string,\n): Promise<void> {\n await emitWebhookEvent(\n deps.db,\n { webhooks: deps.db.tables.webhooks },\n project.id,\n event,\n data,\n credentialKeys(deps.config),\n deps.logger,\n );\n await notifyProjectWith(deps, project, event, data, `/builds/${buildId}`);\n}\n\n/** {@link announceReviewWith} inside a request. */\nexport async function announceReview(\n project: Pick<Project, \"id\" | \"slug\">,\n event: (typeof REVIEW_EVENTS)[keyof typeof REVIEW_EVENTS],\n data: Record<string, unknown>,\n buildId: string,\n): Promise<void> {\n await announceReviewWith(getStore(), project, event, data, buildId);\n}\n\n/** What `review:requested` carries: the build to look at. */\nexport function reviewRequestedData(build: Build): Record<string, unknown> {\n return {\n buildId: build.id,\n gitSha: build.gitSha,\n gitBranch: build.gitBranch,\n authorName: build.authorName,\n authorEmail: build.authorEmail,\n message: build.message,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAiBA,MAAa,gBAAgB;CAC3B,WAAW;CACX,SAAS;CACT,SAAS;AACX;;AAGA,eAAsB,mBACpB,MACA,SACA,OACA,MACA,SACe;CACf,MAAM,iBACJ,KAAK,IACL,EAAE,UAAU,KAAK,GAAG,OAAO,SAAS,GACpC,QAAQ,IACR,OACA,MACA,eAAe,KAAK,MAAM,GAC1B,KAAK,MACP;CACA,MAAM,kBAAkB,MAAM,SAAS,OAAO,MAAM,WAAW,SAAS;AAC1E;;AAGA,eAAsB,eACpB,SACA,OACA,MACA,SACe;CACf,MAAM,mBAAmB,SAAS,GAAG,SAAS,OAAO,MAAM,OAAO;AACpE;;AAGA,SAAgB,oBAAoB,OAAuC;CACzE,OAAO;EACL,SAAS,MAAM;EACf,QAAQ,MAAM;EACd,WAAW,MAAM;EACjB,YAAY,MAAM;EAClB,aAAa,MAAM;EACnB,SAAS,MAAM;CACjB;AACF"}
@@ -2,18 +2,25 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
+ import { notificationsPaused } from "../notifications-pause.mjs";
5
6
  import { loadCredentialProbe, reencryptWithCurrent } from "../credentials.mjs";
7
+ import { countHiddenSnapshots } from "../hidden-data.mjs";
8
+ import { requireSiteAdmin } from "./helpers.mjs";
9
+ import { insightDepsFromStore } from "../insights/deps.mjs";
10
+ import { buildUsageReport } from "../insights/usage-report.mjs";
11
+ import { renderAdminAiPage } from "../pages/admin-ai.mjs";
12
+ import { runnerTargets } from "../targets.mjs";
6
13
  import { renderAdminSystemPage } from "../pages/admin-system.mjs";
7
14
  import { hxRedirect } from "./htmx.mjs";
8
15
  import { flash } from "./flash.mjs";
9
16
  import { collectHealthReport } from "./health-report.mjs";
10
- import { requireSiteAdmin } from "./helpers.mjs";
17
+ import { describeCaptureHost } from "@storyshelf/core/utils";
11
18
  //#region src/routers/admin-pages.ts
12
19
  /** Register the site-admin System page (adapter inventory + in-depth health). */
13
20
  function registerAdminPages(app, deps, auth) {
14
21
  app.get("/admin", async (c) => {
15
22
  requireSiteAdmin(c);
16
- const { authEnabled, config, db } = getStore();
23
+ const { authEnabled, config, db, capturesHere, captureConcurrency, testing } = getStore();
17
24
  const credentials = await loadCredentialProbe(db, config);
18
25
  const report = await collectHealthReport(deps.sources, deps.getSettled(), deps.bootTimeMs, deps.version, credentials);
19
26
  const authMethods = auth?.loginMethods().map((method) => method.label);
@@ -23,9 +30,21 @@ function registerAdminPages(app, deps, auth) {
23
30
  authEnabled,
24
31
  config,
25
32
  authMethods,
26
- credentials
33
+ credentials,
34
+ aiEnabled: getStore().ai !== void 0,
35
+ testing,
36
+ hiddenSnapshots: testing ? 0 : await countHiddenSnapshots(db),
37
+ ...await captureView(),
38
+ capture: capturesHere === true ? describeCaptureHost(captureConcurrency) : null
27
39
  }));
28
40
  });
41
+ app.get("/admin/ai", async (c) => {
42
+ requireSiteAdmin(c);
43
+ const aiDeps = insightDepsFromStore();
44
+ if (!aiDeps) return c.notFound();
45
+ c.header("Cache-Control", "no-store");
46
+ return c.html(renderAdminAiPage(await buildUsageReport(aiDeps)));
47
+ });
29
48
  app.post("/admin/credentials/reencrypt", async (c) => {
30
49
  requireSiteAdmin(c);
31
50
  const { db, config } = getStore();
@@ -42,6 +61,29 @@ function reencryptMessage(result) {
42
61
  const base = `Re-encrypted ${result.reencrypted} credential(s)`;
43
62
  return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;
44
63
  }
64
+ /** Whether the System page warns that notifications are paused. */
65
+ async function pausedView() {
66
+ const store = getStore();
67
+ return store.notifiers.length > 0 && await notificationsPaused(store);
68
+ }
69
+ /** The System page's capture browsers and public origin (ADR 0007). */
70
+ async function captureView() {
71
+ return {
72
+ targets: await runnerTargets(),
73
+ publicOrigin: await publicOriginView(),
74
+ notificationsPaused: await pausedView()
75
+ };
76
+ }
77
+ /** What the System page shows about the public origin: what resolves (confirmed), and what a browser reported. */
78
+ async function publicOriginView() {
79
+ const { publicOrigin, authEnabled } = getStore();
80
+ if (publicOrigin === void 0) return;
81
+ return {
82
+ resolved: await publicOrigin.resolve(),
83
+ stored: await publicOrigin.stored(),
84
+ learns: authEnabled
85
+ };
86
+ }
45
87
  //#endregion
46
88
  export { registerAdminPages };
47
89
 
@@ -1 +1 @@
1
- {"version":3,"file":"admin-pages.mjs","names":[],"sources":["../../src/routers/admin-pages.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe, reencryptWithCurrent } from \"../credentials.ts\";\nimport { renderAdminSystemPage } from \"../pages/admin-system.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { collectHealthReport } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Dependencies for the admin System page (same probing inputs as deep health). */\nexport interface AdminPageDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Register the site-admin System page (adapter inventory + in-depth health). */\nexport function registerAdminPages(app: ShelfRouter, deps: AdminPageDeps, auth?: Auth): void {\n app.get(\"/admin\", async (c) => {\n requireSiteAdmin(c);\n const { authEnabled, config, db } = getStore();\n const credentials = await loadCredentialProbe(db, config);\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n credentials,\n );\n const authMethods = auth?.loginMethods().map((method) => method.label);\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(renderAdminSystemPage({ report, authEnabled, config, authMethods, credentials }));\n });\n\n app.post(\"/admin/credentials/reencrypt\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n if (!config.previousSecret) {\n flash(c, \"No previous secret is configured\", \"warning\");\n return hxRedirect(c, \"/admin\");\n }\n const result = await reencryptWithCurrent(db, config);\n flash(c, reencryptMessage(result), result.failed > 0 ? \"warning\" : \"success\");\n return hxRedirect(c, \"/admin\");\n });\n}\n\nfunction reencryptMessage(result: { reencrypted: number; failed: number }): string {\n const base = `Re-encrypted ${result.reencrypted} credential(s)`;\n return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,KAAkB,MAAqB,MAAmB;CAC3F,IAAI,IAAI,UAAU,OAAO,MAAM;EAC7B,iBAAiB,CAAC;EAClB,MAAM,EAAE,aAAa,QAAQ,OAAO,SAAS;EAC7C,MAAM,cAAc,MAAM,oBAAoB,IAAI,MAAM;EACxD,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,WACF;EACA,MAAM,cAAc,MAAM,aAAa,CAAC,CAAC,KAAK,WAAW,OAAO,KAAK;EACrE,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,sBAAsB;GAAE;GAAQ;GAAa;GAAQ;GAAa;EAAY,CAAC,CAAC;CAChG,CAAC;CAED,IAAI,KAAK,gCAAgC,OAAO,MAAM;EACpD,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,IAAI,CAAC,OAAO,gBAAgB;GAC1B,MAAM,GAAG,oCAAoC,SAAS;GACtD,OAAO,WAAW,GAAG,QAAQ;EAC/B;EACA,MAAM,SAAS,MAAM,qBAAqB,IAAI,MAAM;EACpD,MAAM,GAAG,iBAAiB,MAAM,GAAG,OAAO,SAAS,IAAI,YAAY,SAAS;EAC5E,OAAO,WAAW,GAAG,QAAQ;CAC/B,CAAC;AACH;AAEA,SAAS,iBAAiB,QAAyD;CACjF,MAAM,OAAO,gBAAgB,OAAO,YAAY;CAChD,OAAO,OAAO,SAAS,IAAI,GAAG,KAAK,IAAI,OAAO,OAAO,6BAA6B;AACpF"}
1
+ {"version":3,"file":"admin-pages.mjs","names":[],"sources":["../../src/routers/admin-pages.ts"],"sourcesContent":["import type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { Auth } from \"@storyshelf/core/auth\";\nimport { describeCaptureHost } from \"@storyshelf/core/utils\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe, reencryptWithCurrent } from \"../credentials.ts\";\nimport { countHiddenSnapshots } from \"../hidden-data.ts\";\nimport { insightDepsFromStore } from \"../insights/deps.ts\";\nimport { buildUsageReport } from \"../insights/usage-report.ts\";\nimport { notificationsPaused } from \"../notifications-pause.ts\";\nimport { renderAdminAiPage } from \"../pages/admin-ai.tsx\";\nimport { renderAdminSystemPage, type AdminSystemData } from \"../pages/admin-system.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { runnerTargets } from \"../targets.ts\";\nimport { flash } from \"./flash.ts\";\nimport { collectHealthReport } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Dependencies for the admin System page (same probing inputs as deep health). */\nexport interface AdminPageDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Register the site-admin System page (adapter inventory + in-depth health). */\nexport function registerAdminPages(app: ShelfRouter, deps: AdminPageDeps, auth?: Auth): void {\n app.get(\"/admin\", async (c) => {\n requireSiteAdmin(c);\n const { authEnabled, config, db, capturesHere, captureConcurrency, testing } = getStore();\n const credentials = await loadCredentialProbe(db, config);\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n credentials,\n );\n const authMethods = auth?.loginMethods().map((method) => method.label);\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(\n renderAdminSystemPage({\n report,\n authEnabled,\n config,\n authMethods,\n credentials,\n aiEnabled: getStore().ai !== undefined,\n testing,\n hiddenSnapshots: testing ? 0 : await countHiddenSnapshots(db),\n ...(await captureView()),\n // Only a host that runs capture jobs (one with `testing`) is sized here.\n capture: capturesHere === true ? describeCaptureHost(captureConcurrency) : null,\n }),\n );\n });\n\n app.get(\"/admin/ai\", async (c) => {\n requireSiteAdmin(c);\n const aiDeps = insightDepsFromStore();\n if (!aiDeps) {\n return c.notFound();\n }\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(renderAdminAiPage(await buildUsageReport(aiDeps)));\n });\n\n app.post(\"/admin/credentials/reencrypt\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n if (!config.previousSecret) {\n flash(c, \"No previous secret is configured\", \"warning\");\n return hxRedirect(c, \"/admin\");\n }\n const result = await reencryptWithCurrent(db, config);\n flash(c, reencryptMessage(result), result.failed > 0 ? \"warning\" : \"success\");\n return hxRedirect(c, \"/admin\");\n });\n}\n\nfunction reencryptMessage(result: { reencrypted: number; failed: number }): string {\n const base = `Re-encrypted ${result.reencrypted} credential(s)`;\n return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;\n}\n\n/** Whether the System page warns that notifications are paused. */\nasync function pausedView(): Promise<boolean> {\n const store = getStore();\n return store.notifiers.length > 0 && (await notificationsPaused(store));\n}\n\n/** The System page's capture browsers and public origin (ADR 0007). */\nasync function captureView(): Promise<\n Pick<AdminSystemData, \"targets\" | \"publicOrigin\" | \"notificationsPaused\">\n> {\n return {\n targets: await runnerTargets(),\n publicOrigin: await publicOriginView(),\n notificationsPaused: await pausedView(),\n };\n}\n\n/** What the System page shows about the public origin: what resolves (confirmed), and what a browser reported. */\nasync function publicOriginView(): Promise<AdminSystemData[\"publicOrigin\"]> {\n const { publicOrigin, authEnabled } = getStore();\n if (publicOrigin === undefined) {\n return undefined;\n }\n return {\n resolved: await publicOrigin.resolve(),\n stored: await publicOrigin.stored(),\n // Only a deployment with admins can learn an origin from one.\n learns: authEnabled,\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,mBAAmB,KAAkB,MAAqB,MAAmB;CAC3F,IAAI,IAAI,UAAU,OAAO,MAAM;EAC7B,iBAAiB,CAAC;EAClB,MAAM,EAAE,aAAa,QAAQ,IAAI,cAAc,oBAAoB,YAAY,SAAS;EACxF,MAAM,cAAc,MAAM,oBAAoB,IAAI,MAAM;EACxD,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,WACF;EACA,MAAM,cAAc,MAAM,aAAa,CAAC,CAAC,KAAK,WAAW,OAAO,KAAK;EACrE,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KACP,sBAAsB;GACpB;GACA;GACA;GACA;GACA;GACA,WAAW,SAAS,CAAC,CAAC,OAAO,KAAA;GAC7B;GACA,iBAAiB,UAAU,IAAI,MAAM,qBAAqB,EAAE;GAC5D,GAAI,MAAM,YAAY;GAEtB,SAAS,iBAAiB,OAAO,oBAAoB,kBAAkB,IAAI;EAC7E,CAAC,CACH;CACF,CAAC;CAED,IAAI,IAAI,aAAa,OAAO,MAAM;EAChC,iBAAiB,CAAC;EAClB,MAAM,SAAS,qBAAqB;EACpC,IAAI,CAAC,QACH,OAAO,EAAE,SAAS;EAEpB,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,kBAAkB,MAAM,iBAAiB,MAAM,CAAC,CAAC;CACjE,CAAC;CAED,IAAI,KAAK,gCAAgC,OAAO,MAAM;EACpD,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,IAAI,CAAC,OAAO,gBAAgB;GAC1B,MAAM,GAAG,oCAAoC,SAAS;GACtD,OAAO,WAAW,GAAG,QAAQ;EAC/B;EACA,MAAM,SAAS,MAAM,qBAAqB,IAAI,MAAM;EACpD,MAAM,GAAG,iBAAiB,MAAM,GAAG,OAAO,SAAS,IAAI,YAAY,SAAS;EAC5E,OAAO,WAAW,GAAG,QAAQ;CAC/B,CAAC;AACH;AAEA,SAAS,iBAAiB,QAAyD;CACjF,MAAM,OAAO,gBAAgB,OAAO,YAAY;CAChD,OAAO,OAAO,SAAS,IAAI,GAAG,KAAK,IAAI,OAAO,OAAO,6BAA6B;AACpF;;AAGA,eAAe,aAA+B;CAC5C,MAAM,QAAQ,SAAS;CACvB,OAAO,MAAM,UAAU,SAAS,KAAM,MAAM,oBAAoB,KAAK;AACvE;;AAGA,eAAe,cAEb;CACA,OAAO;EACL,SAAS,MAAM,cAAc;EAC7B,cAAc,MAAM,iBAAiB;EACrC,qBAAqB,MAAM,WAAW;CACxC;AACF;;AAGA,eAAe,mBAA6D;CAC1E,MAAM,EAAE,cAAc,gBAAgB,SAAS;CAC/C,IAAI,iBAAiB,KAAA,GACnB;CAEF,OAAO;EACL,UAAU,MAAM,aAAa,QAAQ;EACrC,QAAQ,MAAM,aAAa,OAAO;EAElC,QAAQ;CACV;AACF"}
@@ -0,0 +1,85 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { requireSiteAdmin } from "./helpers.mjs";
6
+ import { hxRedirect } from "./htmx.mjs";
7
+ import { flash } from "./flash.mjs";
8
+ import { requestOpenBuildStatesNow } from "../git-status.mjs";
9
+ import { normalizeOrigin } from "@storyshelf/core/utils";
10
+ import { HTTPException } from "hono/http-exception";
11
+ //#region src/routers/admin-public-origin.ts
12
+ /**
13
+ * The public origin, learnt from an admin's browser (ADR 0007, decision 6): the System page reports
14
+ * `location.origin`, an admin confirms or replaces it, and background jobs read it from the database.
15
+ * Site-admin and CSRF protected; with authentication off there is no admin, so nothing is learnt.
16
+ */
17
+ /** Register the public-origin routes of the System page. */
18
+ function registerPublicOriginRoutes(app) {
19
+ app.post("/admin/public-origin/report", handleReport);
20
+ app.post("/admin/public-origin", handleSet);
21
+ app.post("/admin/public-origin/clear", handleClear);
22
+ }
23
+ /** The origin a report names, once it matches what the browser says it used; throws 400 otherwise. */
24
+ async function reportedOrigin(c) {
25
+ const body = await c.req.json().catch(() => null);
26
+ const reported = typeof body?.origin === "string" ? normalizeOrigin(body.origin) : void 0;
27
+ if (reported === void 0 || c.req.header("origin") !== reported) throw new HTTPException(400, { message: "Origin does not match the request" });
28
+ return reported;
29
+ }
30
+ /** The page reports the origin it was loaded from; the first report is kept. */
31
+ async function handleReport(c) {
32
+ requireSiteAdmin(c);
33
+ const { authEnabled, publicOrigin, user } = getStore();
34
+ if (!authEnabled || publicOrigin === void 0) return c.body(null, 204);
35
+ const reported = await reportedOrigin(c);
36
+ const before = await publicOrigin.stored();
37
+ const wasResolved = await publicOrigin.url() !== void 0;
38
+ const stored = await publicOrigin.report(reported, user?.id);
39
+ await resumeIfResolved(wasResolved);
40
+ return c.json(stored, before === null ? 201 : 200);
41
+ }
42
+ /**
43
+ * Notifications were paused and an origin now resolves (an admin confirmed one; a mere report
44
+ * resolves nothing): re-request the state of open builds.
45
+ */
46
+ async function resumeIfResolved(wasResolved) {
47
+ if (!wasResolved && await getStore().publicOrigin?.url() !== void 0) requestOpenBuildStatesNow().catch(() => 0);
48
+ }
49
+ /** Store what an admin typed; the message says why not when it is no origin. */
50
+ async function storeTyped(c) {
51
+ const { publicOrigin, user } = getStore();
52
+ const origin = (await c.req.formData()).get("origin");
53
+ try {
54
+ const wasResolved = await publicOrigin?.url() !== void 0;
55
+ await publicOrigin?.set(typeof origin === "string" ? origin : "", user?.id);
56
+ await resumeIfResolved(wasResolved);
57
+ return "Public origin saved";
58
+ } catch (error) {
59
+ return error instanceof Error ? error.message : "Invalid origin";
60
+ }
61
+ }
62
+ /** An admin confirmed the detected origin or entered another. */
63
+ async function handleSet(c) {
64
+ requireSiteAdmin(c);
65
+ const { authEnabled, publicOrigin } = getStore();
66
+ if (!authEnabled || publicOrigin === void 0) {
67
+ flash(c, "The public origin is learnt from an admin's browser, which needs authentication", "warning");
68
+ return hxRedirect(c, "/admin");
69
+ }
70
+ const message = await storeTyped(c);
71
+ flash(c, message, message === "Public origin saved" ? "success" : "warning");
72
+ return hxRedirect(c, "/admin");
73
+ }
74
+ /** An admin dropped the stored origin; the next admin visit reports a fresh one. */
75
+ async function handleClear(c) {
76
+ requireSiteAdmin(c);
77
+ const { publicOrigin } = getStore();
78
+ await publicOrigin?.clear();
79
+ flash(c, "Stored public origin removed");
80
+ return hxRedirect(c, "/admin");
81
+ }
82
+ //#endregion
83
+ export { registerPublicOriginRoutes };
84
+
85
+ //# sourceMappingURL=admin-public-origin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-public-origin.mjs","names":[],"sources":["../../src/routers/admin-public-origin.ts"],"sourcesContent":["/**\n * The public origin, learnt from an admin's browser (ADR 0007, decision 6): the System page reports\n * `location.origin`, an admin confirms or replaces it, and background jobs read it from the database.\n * Site-admin and CSRF protected; with authentication off there is no admin, so nothing is learnt.\n */\nimport { normalizeOrigin } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { requestOpenBuildStatesNow } from \"../git-status.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Register the public-origin routes of the System page. */\nexport function registerPublicOriginRoutes(app: ShelfRouter): void {\n app.post(\"/admin/public-origin/report\", handleReport);\n app.post(\"/admin/public-origin\", handleSet);\n app.post(\"/admin/public-origin/clear\", handleClear);\n}\n\n/** The origin a report names, once it matches what the browser says it used; throws 400 otherwise. */\nasync function reportedOrigin(c: Context): Promise<string> {\n const body = (await c.req.json().catch(() => null)) as { origin?: unknown } | null;\n const reported = typeof body?.origin === \"string\" ? normalizeOrigin(body.origin) : undefined;\n // The browser states the origin it used itself; a report that disagrees is not one.\n if (reported === undefined || c.req.header(\"origin\") !== reported) {\n throw new HTTPException(400, { message: \"Origin does not match the request\" });\n }\n return reported;\n}\n\n/** The page reports the origin it was loaded from; the first report is kept. */\nasync function handleReport(c: Context): Promise<Response> {\n requireSiteAdmin(c);\n const { authEnabled, publicOrigin, user } = getStore();\n if (!authEnabled || publicOrigin === undefined) {\n return c.body(null, 204);\n }\n const reported = await reportedOrigin(c);\n const before = await publicOrigin.stored();\n const wasResolved = (await publicOrigin.url()) !== undefined;\n const stored = await publicOrigin.report(reported, user?.id);\n await resumeIfResolved(wasResolved);\n return c.json(stored, before === null ? 201 : 200);\n}\n\n/**\n * Notifications were paused and an origin now resolves (an admin confirmed one; a mere report\n * resolves nothing): re-request the state of open builds.\n */\nasync function resumeIfResolved(wasResolved: boolean): Promise<void> {\n if (!wasResolved && (await getStore().publicOrigin?.url()) !== undefined) {\n requestOpenBuildStatesNow().catch(() => 0);\n }\n}\n\n/** Store what an admin typed; the message says why not when it is no origin. */\nasync function storeTyped(c: Context): Promise<string> {\n const { publicOrigin, user } = getStore();\n const origin = (await c.req.formData()).get(\"origin\");\n try {\n const wasResolved = (await publicOrigin?.url()) !== undefined;\n await publicOrigin?.set(typeof origin === \"string\" ? origin : \"\", user?.id);\n await resumeIfResolved(wasResolved);\n return \"Public origin saved\";\n } catch (error) {\n return error instanceof Error ? error.message : \"Invalid origin\";\n }\n}\n\n/** An admin confirmed the detected origin or entered another. */\nasync function handleSet(c: Context): Promise<Response> {\n requireSiteAdmin(c);\n const { authEnabled, publicOrigin } = getStore();\n if (!authEnabled || publicOrigin === undefined) {\n flash(\n c,\n \"The public origin is learnt from an admin's browser, which needs authentication\",\n \"warning\",\n );\n return hxRedirect(c, \"/admin\");\n }\n const message = await storeTyped(c);\n flash(c, message, message === \"Public origin saved\" ? \"success\" : \"warning\");\n return hxRedirect(c, \"/admin\");\n}\n\n/** An admin dropped the stored origin; the next admin visit reports a fresh one. */\nasync function handleClear(c: Context): Promise<Response> {\n requireSiteAdmin(c);\n const { publicOrigin } = getStore();\n await publicOrigin?.clear();\n flash(c, \"Stored public origin removed\");\n return hxRedirect(c, \"/admin\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAgBA,SAAgB,2BAA2B,KAAwB;CACjE,IAAI,KAAK,+BAA+B,YAAY;CACpD,IAAI,KAAK,wBAAwB,SAAS;CAC1C,IAAI,KAAK,8BAA8B,WAAW;AACpD;;AAGA,eAAe,eAAe,GAA6B;CACzD,MAAM,OAAQ,MAAM,EAAE,IAAI,KAAK,CAAC,CAAC,YAAY,IAAI;CACjD,MAAM,WAAW,OAAO,MAAM,WAAW,WAAW,gBAAgB,KAAK,MAAM,IAAI,KAAA;CAEnF,IAAI,aAAa,KAAA,KAAa,EAAE,IAAI,OAAO,QAAQ,MAAM,UACvD,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,oCAAoC,CAAC;CAE/E,OAAO;AACT;;AAGA,eAAe,aAAa,GAA+B;CACzD,iBAAiB,CAAC;CAClB,MAAM,EAAE,aAAa,cAAc,SAAS,SAAS;CACrD,IAAI,CAAC,eAAe,iBAAiB,KAAA,GACnC,OAAO,EAAE,KAAK,MAAM,GAAG;CAEzB,MAAM,WAAW,MAAM,eAAe,CAAC;CACvC,MAAM,SAAS,MAAM,aAAa,OAAO;CACzC,MAAM,cAAe,MAAM,aAAa,IAAI,MAAO,KAAA;CACnD,MAAM,SAAS,MAAM,aAAa,OAAO,UAAU,MAAM,EAAE;CAC3D,MAAM,iBAAiB,WAAW;CAClC,OAAO,EAAE,KAAK,QAAQ,WAAW,OAAO,MAAM,GAAG;AACnD;;;;;AAMA,eAAe,iBAAiB,aAAqC;CACnE,IAAI,CAAC,eAAgB,MAAM,SAAS,CAAC,CAAC,cAAc,IAAI,MAAO,KAAA,GAC7D,0BAA0B,CAAC,CAAC,YAAY,CAAC;AAE7C;;AAGA,eAAe,WAAW,GAA6B;CACrD,MAAM,EAAE,cAAc,SAAS,SAAS;CACxC,MAAM,UAAU,MAAM,EAAE,IAAI,SAAS,EAAA,CAAG,IAAI,QAAQ;CACpD,IAAI;EACF,MAAM,cAAe,MAAM,cAAc,IAAI,MAAO,KAAA;EACpD,MAAM,cAAc,IAAI,OAAO,WAAW,WAAW,SAAS,IAAI,MAAM,EAAE;EAC1E,MAAM,iBAAiB,WAAW;EAClC,OAAO;CACT,SAAS,OAAO;EACd,OAAO,iBAAiB,QAAQ,MAAM,UAAU;CAClD;AACF;;AAGA,eAAe,UAAU,GAA+B;CACtD,iBAAiB,CAAC;CAClB,MAAM,EAAE,aAAa,iBAAiB,SAAS;CAC/C,IAAI,CAAC,eAAe,iBAAiB,KAAA,GAAW;EAC9C,MACE,GACA,mFACA,SACF;EACA,OAAO,WAAW,GAAG,QAAQ;CAC/B;CACA,MAAM,UAAU,MAAM,WAAW,CAAC;CAClC,MAAM,GAAG,SAAS,YAAY,wBAAwB,YAAY,SAAS;CAC3E,OAAO,WAAW,GAAG,QAAQ;AAC/B;;AAGA,eAAe,YAAY,GAA+B;CACxD,iBAAiB,CAAC;CAClB,MAAM,EAAE,iBAAiB,SAAS;CAClC,MAAM,cAAc,MAAM;CAC1B,MAAM,GAAG,8BAA8B;CACvC,OAAO,WAAW,GAAG,QAAQ;AAC/B"}
@@ -26,9 +26,9 @@ function serveHashed(c, asset, requestedHash) {
26
26
  etag
27
27
  });
28
28
  }
29
- /** Register the static-asset routes (HTMX bundle and icon sprite). */
29
+ /** Register the static-asset routes (HTMX bundle, shell script, icon sprite and mark). */
30
30
  function registerAssets(app) {
31
- const { htmx, icons, mark } = assetManifest;
31
+ const { client, htmx, icons, mark } = assetManifest;
32
32
  app.get(String.raw`/assets/:file{htmx-[a-f0-9]+\.js}`, (c) => {
33
33
  const hash = (c.req.param("file") ?? "").replaceAll(/^htmx-|\.js$/gu, "");
34
34
  return serveHashed(c, {
@@ -36,6 +36,13 @@ function registerAssets(app) {
36
36
  contentType: "application/javascript"
37
37
  }, hash);
38
38
  });
39
+ app.get(String.raw`/assets/:file{client-[a-f0-9]+\.js}`, (c) => {
40
+ const hash = (c.req.param("file") ?? "").replaceAll(/^client-|\.js$/gu, "");
41
+ return serveHashed(c, {
42
+ ...client,
43
+ contentType: "application/javascript"
44
+ }, hash);
45
+ });
39
46
  app.get(String.raw`/assets/:file{icons-[a-f0-9]+\.svg}`, (c) => {
40
47
  const hash = (c.req.param("file") ?? "").replaceAll(/^icons-|\.svg$/gu, "");
41
48
  return serveHashed(c, {
@@ -1 +1 @@
1
- {"version":3,"file":"assets.mjs","names":[],"sources":["../../src/routers/assets.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { assetManifest } from \"../asset-manifest.ts\";\n\nconst IMMUTABLE = \"public, max-age=31536000, immutable\";\n\ninterface Asset {\n source: string;\n hash: string;\n contentType: string;\n}\n\n/**\n * Serve a content-addressed asset. A matching hash is cacheable forever and\n * answers conditional requests with 304; a stale hash (an old page still\n * open after an upgrade) gets the current content but is never cached under\n * the old URL.\n */\nfunction serveHashed(c: Context, asset: Asset, requestedHash: string): Response {\n const etag = `\"${asset.hash}\"`;\n if (requestedHash !== asset.hash) {\n return c.body(asset.source, 200, {\n \"content-type\": asset.contentType,\n \"cache-control\": \"no-store\",\n });\n }\n if (c.req.header(\"if-none-match\") === etag) {\n return c.body(null, 304, { etag, \"cache-control\": IMMUTABLE });\n }\n return c.body(asset.source, 200, {\n \"content-type\": asset.contentType,\n \"cache-control\": IMMUTABLE,\n etag,\n });\n}\n\n/** Register the static-asset routes (HTMX bundle and icon sprite). */\nexport function registerAssets(app: ShelfRouter): void {\n const { htmx, icons, mark } = assetManifest;\n app.get(String.raw`/assets/:file{htmx-[a-f0-9]+\\.js}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^htmx-|\\.js$/gu, \"\");\n return serveHashed(c, { ...htmx, contentType: \"application/javascript\" }, hash);\n });\n app.get(String.raw`/assets/:file{icons-[a-f0-9]+\\.svg}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^icons-|\\.svg$/gu, \"\");\n return serveHashed(c, { ...icons, contentType: \"image/svg+xml\" }, hash);\n });\n app.get(String.raw`/assets/:file{mark-[a-f0-9]+\\.svg}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^mark-|\\.svg$/gu, \"\");\n return serveHashed(c, { ...mark, contentType: \"image/svg+xml\" }, hash);\n });\n // Legacy unversioned URL: kept for old pages and external embeds, but\n // revalidated (never immutable) because its content can change.\n app.get(\"/assets/htmx.js\", (c) => {\n return c.body(htmx.source, 200, {\n \"content-type\": \"application/javascript\",\n \"cache-control\": \"no-cache\",\n etag: `\"${htmx.hash}\"`,\n });\n });\n}\n"],"mappings":";;;;;AAIA,MAAM,YAAY;;;;;;;AAclB,SAAS,YAAY,GAAY,OAAc,eAAiC;CAC9E,MAAM,OAAO,IAAI,MAAM,KAAK;CAC5B,IAAI,kBAAkB,MAAM,MAC1B,OAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;EAC/B,gBAAgB,MAAM;EACtB,iBAAiB;CACnB,CAAC;CAEH,IAAI,EAAE,IAAI,OAAO,eAAe,MAAM,MACpC,OAAO,EAAE,KAAK,MAAM,KAAK;EAAE;EAAM,iBAAiB;CAAU,CAAC;CAE/D,OAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;EAC/B,gBAAgB,MAAM;EACtB,iBAAiB;EACjB;CACF,CAAC;AACH;;AAGA,SAAgB,eAAe,KAAwB;CACrD,MAAM,EAAE,MAAM,OAAO,SAAS;CAC9B,IAAI,IAAI,OAAO,GAAG,sCAAsC,MAAM;EAC5D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,kBAAkB,EAAE;EACxE,OAAO,YAAY,GAAG;GAAE,GAAG;GAAM,aAAa;EAAyB,GAAG,IAAI;CAChF,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,wCAAwC,MAAM;EAC9D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,oBAAoB,EAAE;EAC1E,OAAO,YAAY,GAAG;GAAE,GAAG;GAAO,aAAa;EAAgB,GAAG,IAAI;CACxE,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,uCAAuC,MAAM;EAC7D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,mBAAmB,EAAE;EACzE,OAAO,YAAY,GAAG;GAAE,GAAG;GAAM,aAAa;EAAgB,GAAG,IAAI;CACvE,CAAC;CAGD,IAAI,IAAI,oBAAoB,MAAM;EAChC,OAAO,EAAE,KAAK,KAAK,QAAQ,KAAK;GAC9B,gBAAgB;GAChB,iBAAiB;GACjB,MAAM,IAAI,KAAK,KAAK;EACtB,CAAC;CACH,CAAC;AACH"}
1
+ {"version":3,"file":"assets.mjs","names":[],"sources":["../../src/routers/assets.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { assetManifest } from \"../asset-manifest.ts\";\n\nconst IMMUTABLE = \"public, max-age=31536000, immutable\";\n\ninterface Asset {\n source: string;\n hash: string;\n contentType: string;\n}\n\n/**\n * Serve a content-addressed asset. A matching hash is cacheable forever and\n * answers conditional requests with 304; a stale hash (an old page still\n * open after an upgrade) gets the current content but is never cached under\n * the old URL.\n */\nfunction serveHashed(c: Context, asset: Asset, requestedHash: string): Response {\n const etag = `\"${asset.hash}\"`;\n if (requestedHash !== asset.hash) {\n return c.body(asset.source, 200, {\n \"content-type\": asset.contentType,\n \"cache-control\": \"no-store\",\n });\n }\n if (c.req.header(\"if-none-match\") === etag) {\n return c.body(null, 304, { etag, \"cache-control\": IMMUTABLE });\n }\n return c.body(asset.source, 200, {\n \"content-type\": asset.contentType,\n \"cache-control\": IMMUTABLE,\n etag,\n });\n}\n\n/** Register the static-asset routes (HTMX bundle, shell script, icon sprite and mark). */\nexport function registerAssets(app: ShelfRouter): void {\n const { client, htmx, icons, mark } = assetManifest;\n app.get(String.raw`/assets/:file{htmx-[a-f0-9]+\\.js}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^htmx-|\\.js$/gu, \"\");\n return serveHashed(c, { ...htmx, contentType: \"application/javascript\" }, hash);\n });\n app.get(String.raw`/assets/:file{client-[a-f0-9]+\\.js}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^client-|\\.js$/gu, \"\");\n return serveHashed(c, { ...client, contentType: \"application/javascript\" }, hash);\n });\n app.get(String.raw`/assets/:file{icons-[a-f0-9]+\\.svg}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^icons-|\\.svg$/gu, \"\");\n return serveHashed(c, { ...icons, contentType: \"image/svg+xml\" }, hash);\n });\n app.get(String.raw`/assets/:file{mark-[a-f0-9]+\\.svg}`, (c) => {\n const hash = (c.req.param(\"file\") ?? \"\").replaceAll(/^mark-|\\.svg$/gu, \"\");\n return serveHashed(c, { ...mark, contentType: \"image/svg+xml\" }, hash);\n });\n // Legacy unversioned URL: kept for old pages and external embeds, but\n // revalidated (never immutable) because its content can change.\n app.get(\"/assets/htmx.js\", (c) => {\n return c.body(htmx.source, 200, {\n \"content-type\": \"application/javascript\",\n \"cache-control\": \"no-cache\",\n etag: `\"${htmx.hash}\"`,\n });\n });\n}\n"],"mappings":";;;;;AAIA,MAAM,YAAY;;;;;;;AAclB,SAAS,YAAY,GAAY,OAAc,eAAiC;CAC9E,MAAM,OAAO,IAAI,MAAM,KAAK;CAC5B,IAAI,kBAAkB,MAAM,MAC1B,OAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;EAC/B,gBAAgB,MAAM;EACtB,iBAAiB;CACnB,CAAC;CAEH,IAAI,EAAE,IAAI,OAAO,eAAe,MAAM,MACpC,OAAO,EAAE,KAAK,MAAM,KAAK;EAAE;EAAM,iBAAiB;CAAU,CAAC;CAE/D,OAAO,EAAE,KAAK,MAAM,QAAQ,KAAK;EAC/B,gBAAgB,MAAM;EACtB,iBAAiB;EACjB;CACF,CAAC;AACH;;AAGA,SAAgB,eAAe,KAAwB;CACrD,MAAM,EAAE,QAAQ,MAAM,OAAO,SAAS;CACtC,IAAI,IAAI,OAAO,GAAG,sCAAsC,MAAM;EAC5D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,kBAAkB,EAAE;EACxE,OAAO,YAAY,GAAG;GAAE,GAAG;GAAM,aAAa;EAAyB,GAAG,IAAI;CAChF,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,wCAAwC,MAAM;EAC9D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,oBAAoB,EAAE;EAC1E,OAAO,YAAY,GAAG;GAAE,GAAG;GAAQ,aAAa;EAAyB,GAAG,IAAI;CAClF,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,wCAAwC,MAAM;EAC9D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,oBAAoB,EAAE;EAC1E,OAAO,YAAY,GAAG;GAAE,GAAG;GAAO,aAAa;EAAgB,GAAG,IAAI;CACxE,CAAC;CACD,IAAI,IAAI,OAAO,GAAG,uCAAuC,MAAM;EAC7D,MAAM,QAAQ,EAAE,IAAI,MAAM,MAAM,KAAK,GAAA,CAAI,WAAW,mBAAmB,EAAE;EACzE,OAAO,YAAY,GAAG;GAAE,GAAG;GAAM,aAAa;EAAgB,GAAG,IAAI;CACvE,CAAC;CAGD,IAAI,IAAI,oBAAoB,MAAM;EAChC,OAAO,EAAE,KAAK,KAAK,QAAQ,KAAK;GAC9B,gBAAgB;GAChB,iBAAiB;GACjB,MAAM,IAAI,KAAK,KAAK;EACtB,CAAC;CACH,CAAC;AACH"}
@@ -8,8 +8,8 @@ import { renderInviteInvalidPage, renderInvitePage } from "../pages/invite.mjs";
8
8
  import { renderLoginPage } from "../pages/login.mjs";
9
9
  import { syncLoginMemberships } from "./auth-sync.mjs";
10
10
  import { UserModel } from "@storyshelf/core/models";
11
- import { SESSION_COOKIE } from "@storyshelf/core/types";
12
11
  import { HTTPException } from "hono/http-exception";
12
+ import { SESSION_COOKIE } from "@storyshelf/core/types";
13
13
  import { MIN_PASSWORD_LENGTH } from "@storyshelf/core/auth";
14
14
  //#region src/routers/auth.ts
15
15
  /** Provider ids are URL path segments: strict charset, bounded length. */
@@ -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"}