@storyshelf/app 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/credentials.mjs +3 -2
  26. package/dist/credentials.mjs.map +1 -1
  27. package/dist/git-status.mjs +46 -0
  28. package/dist/git-status.mjs.map +1 -0
  29. package/dist/hidden-data.mjs +24 -0
  30. package/dist/hidden-data.mjs.map +1 -0
  31. package/dist/index.d.mts +3 -3
  32. package/dist/index.mjs +202 -124
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/insights/budget.mjs +49 -0
  35. package/dist/insights/budget.mjs.map +1 -0
  36. package/dist/insights/deps.mjs +21 -0
  37. package/dist/insights/deps.mjs.map +1 -0
  38. package/dist/insights/errors.mjs +22 -0
  39. package/dist/insights/errors.mjs.map +1 -0
  40. package/dist/insights/health-panel.mjs +40 -0
  41. package/dist/insights/health-panel.mjs.map +1 -0
  42. package/dist/insights/job.mjs +103 -0
  43. package/dist/insights/job.mjs.map +1 -0
  44. package/dist/insights/load-health.mjs +59 -0
  45. package/dist/insights/load-health.mjs.map +1 -0
  46. package/dist/insights/load-triage.mjs +98 -0
  47. package/dist/insights/load-triage.mjs.map +1 -0
  48. package/dist/insights/panel.mjs +28 -0
  49. package/dist/insights/panel.mjs.map +1 -0
  50. package/dist/insights/request.mjs +113 -0
  51. package/dist/insights/request.mjs.map +1 -0
  52. package/dist/insights/stale.mjs +21 -0
  53. package/dist/insights/stale.mjs.map +1 -0
  54. package/dist/insights/usage-report.mjs +60 -0
  55. package/dist/insights/usage-report.mjs.map +1 -0
  56. package/dist/insights/view.mjs +48 -0
  57. package/dist/insights/view.mjs.map +1 -0
  58. package/dist/job-canceller.mjs +14 -0
  59. package/dist/job-canceller.mjs.map +1 -0
  60. package/dist/job-queue.mjs +51 -0
  61. package/dist/job-queue.mjs.map +1 -0
  62. package/dist/jobs-setup.mjs +132 -0
  63. package/dist/jobs-setup.mjs.map +1 -0
  64. package/dist/lifecycle.mjs +109 -19
  65. package/dist/lifecycle.mjs.map +1 -1
  66. package/dist/middleware/auth-gate.mjs +4 -2
  67. package/dist/middleware/auth-gate.mjs.map +1 -1
  68. package/dist/middleware/rate-limit.mjs +41 -33
  69. package/dist/middleware/rate-limit.mjs.map +1 -1
  70. package/dist/middleware/request-log.mjs +3 -2
  71. package/dist/middleware/request-log.mjs.map +1 -1
  72. package/dist/middleware/require-review.mjs +51 -0
  73. package/dist/middleware/require-review.mjs.map +1 -0
  74. package/dist/middleware/require-testing.mjs +41 -0
  75. package/dist/middleware/require-testing.mjs.map +1 -0
  76. package/dist/middleware/setup-gate.mjs.map +1 -1
  77. package/dist/middleware/store-scope.mjs +12 -2
  78. package/dist/middleware/store-scope.mjs.map +1 -1
  79. package/dist/notifications-pause.mjs +33 -0
  80. package/dist/notifications-pause.mjs.map +1 -0
  81. package/dist/notify.d.mts +2 -1
  82. package/dist/notify.mjs +78 -39
  83. package/dist/notify.mjs.map +1 -1
  84. package/dist/pages/a11y-violations.mjs +116 -0
  85. package/dist/pages/a11y-violations.mjs.map +1 -0
  86. package/dist/pages/admin-ai.mjs +78 -0
  87. package/dist/pages/admin-ai.mjs.map +1 -0
  88. package/dist/pages/admin-system-origin.mjs +123 -0
  89. package/dist/pages/admin-system-origin.mjs.map +1 -0
  90. package/dist/pages/admin-system-targets.mjs +53 -0
  91. package/dist/pages/admin-system-targets.mjs.map +1 -0
  92. package/dist/pages/admin-system.mjs +63 -13
  93. package/dist/pages/admin-system.mjs.map +1 -1
  94. package/dist/pages/build-detail.mjs +104 -76
  95. package/dist/pages/build-detail.mjs.map +1 -1
  96. package/dist/pages/build-diff-actions.mjs +87 -33
  97. package/dist/pages/build-diff-actions.mjs.map +1 -1
  98. package/dist/pages/build-diff-comments.mjs +16 -14
  99. package/dist/pages/build-diff-comments.mjs.map +1 -1
  100. package/dist/pages/build-diff-environment.mjs +82 -0
  101. package/dist/pages/build-diff-environment.mjs.map +1 -0
  102. package/dist/pages/build-diff-header.mjs +22 -4
  103. package/dist/pages/build-diff-header.mjs.map +1 -1
  104. package/dist/pages/build-diff-insight.mjs +113 -0
  105. package/dist/pages/build-diff-insight.mjs.map +1 -0
  106. package/dist/pages/build-diff-model.mjs +55 -10
  107. package/dist/pages/build-diff-model.mjs.map +1 -1
  108. package/dist/pages/build-diff-nav.mjs +28 -12
  109. package/dist/pages/build-diff-nav.mjs.map +1 -1
  110. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  111. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  112. package/dist/pages/build-diff-usage.mjs +63 -0
  113. package/dist/pages/build-diff-usage.mjs.map +1 -0
  114. package/dist/pages/build-diff-viewer.mjs +62 -5
  115. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  116. package/dist/pages/build-diff.mjs +34 -13
  117. package/dist/pages/build-diff.mjs.map +1 -1
  118. package/dist/pages/build-tests.mjs +74 -0
  119. package/dist/pages/build-tests.mjs.map +1 -0
  120. package/dist/pages/component-detail.mjs +249 -0
  121. package/dist/pages/component-detail.mjs.map +1 -0
  122. package/dist/pages/component-stories.mjs +168 -0
  123. package/dist/pages/component-stories.mjs.map +1 -0
  124. package/dist/pages/compute-jobs.mjs +13 -26
  125. package/dist/pages/compute-jobs.mjs.map +1 -1
  126. package/dist/pages/design-links.mjs +182 -0
  127. package/dist/pages/design-links.mjs.map +1 -0
  128. package/dist/pages/environment-change.mjs +53 -0
  129. package/dist/pages/environment-change.mjs.map +1 -0
  130. package/dist/pages/health-panel.mjs +126 -0
  131. package/dist/pages/health-panel.mjs.map +1 -0
  132. package/dist/pages/library-grid.mjs +105 -0
  133. package/dist/pages/library-grid.mjs.map +1 -0
  134. package/dist/pages/library-model.mjs +47 -0
  135. package/dist/pages/library-model.mjs.map +1 -0
  136. package/dist/pages/library-snapshots.mjs +130 -0
  137. package/dist/pages/library-snapshots.mjs.map +1 -0
  138. package/dist/pages/library.mjs +112 -218
  139. package/dist/pages/library.mjs.map +1 -1
  140. package/dist/pages/notifications-paused.mjs +22 -0
  141. package/dist/pages/notifications-paused.mjs.map +1 -0
  142. package/dist/pages/profile.mjs +53 -48
  143. package/dist/pages/profile.mjs.map +1 -1
  144. package/dist/pages/project-builds-table.mjs +2 -2
  145. package/dist/pages/project-builds-table.mjs.map +1 -1
  146. package/dist/pages/project-builds.mjs +51 -21
  147. package/dist/pages/project-builds.mjs.map +1 -1
  148. package/dist/pages/project-settings.mjs +51 -12
  149. package/dist/pages/project-settings.mjs.map +1 -1
  150. package/dist/pages/projects-card.mjs +14 -1
  151. package/dist/pages/projects-card.mjs.map +1 -1
  152. package/dist/pages/projects.mjs +6 -2
  153. package/dist/pages/projects.mjs.map +1 -1
  154. package/dist/pages/review-changeset.mjs +116 -0
  155. package/dist/pages/review-changeset.mjs.map +1 -0
  156. package/dist/pages/review-comments.mjs +103 -0
  157. package/dist/pages/review-comments.mjs.map +1 -0
  158. package/dist/pages/review-verdict.mjs +102 -0
  159. package/dist/pages/review-verdict.mjs.map +1 -0
  160. package/dist/pages/settings-ai.mjs +69 -0
  161. package/dist/pages/settings-ai.mjs.map +1 -0
  162. package/dist/pages/settings-capture.mjs +124 -0
  163. package/dist/pages/settings-capture.mjs.map +1 -0
  164. package/dist/pages/settings-design-credential.mjs +99 -0
  165. package/dist/pages/settings-design-credential.mjs.map +1 -0
  166. package/dist/pages/settings-design-links.mjs +179 -0
  167. package/dist/pages/settings-design-links.mjs.map +1 -0
  168. package/dist/pages/settings-design.mjs +48 -0
  169. package/dist/pages/settings-design.mjs.map +1 -0
  170. package/dist/pages/settings-general.mjs +46 -21
  171. package/dist/pages/settings-general.mjs.map +1 -1
  172. package/dist/pages/settings-labels.mjs +43 -2
  173. package/dist/pages/settings-labels.mjs.map +1 -1
  174. package/dist/pages/settings-links.mjs +112 -0
  175. package/dist/pages/settings-links.mjs.map +1 -0
  176. package/dist/pages/settings-notifications-git.mjs +128 -0
  177. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  178. package/dist/pages/settings-notifications.mjs +79 -42
  179. package/dist/pages/settings-notifications.mjs.map +1 -1
  180. package/dist/pages/settings-tests.mjs +33 -2
  181. package/dist/pages/settings-tests.mjs.map +1 -1
  182. package/dist/pages/target-choices.mjs +39 -0
  183. package/dist/pages/target-choices.mjs.map +1 -0
  184. package/dist/publish-setup.mjs +89 -0
  185. package/dist/publish-setup.mjs.map +1 -0
  186. package/dist/retention-timer.mjs +2 -1
  187. package/dist/retention-timer.mjs.map +1 -1
  188. package/dist/review-events.mjs +48 -0
  189. package/dist/review-events.mjs.map +1 -0
  190. package/dist/routers/admin-pages.mjs +45 -3
  191. package/dist/routers/admin-pages.mjs.map +1 -1
  192. package/dist/routers/admin-public-origin.mjs +85 -0
  193. package/dist/routers/admin-public-origin.mjs.map +1 -0
  194. package/dist/routers/assets.mjs +9 -2
  195. package/dist/routers/assets.mjs.map +1 -1
  196. package/dist/routers/auth.mjs +2 -2
  197. package/dist/routers/auth.mjs.map +1 -1
  198. package/dist/routers/baseline-guard.mjs +2 -2
  199. package/dist/routers/baseline-guard.mjs.map +1 -1
  200. package/dist/routers/baselines.handlers.mjs +64 -0
  201. package/dist/routers/baselines.handlers.mjs.map +1 -0
  202. package/dist/routers/baselines.mjs +125 -0
  203. package/dist/routers/baselines.mjs.map +1 -0
  204. package/dist/routers/build-cancel.mjs +68 -0
  205. package/dist/routers/build-cancel.mjs.map +1 -0
  206. package/dist/routers/build-changeset.mjs +70 -0
  207. package/dist/routers/build-changeset.mjs.map +1 -0
  208. package/dist/routers/build-reviews.handlers.mjs +86 -0
  209. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  210. package/dist/routers/build-reviews.mjs +74 -0
  211. package/dist/routers/build-reviews.mjs.map +1 -0
  212. package/dist/routers/builds.handlers.mjs +46 -55
  213. package/dist/routers/builds.handlers.mjs.map +1 -1
  214. package/dist/routers/builds.mjs +71 -17
  215. package/dist/routers/builds.mjs.map +1 -1
  216. package/dist/routers/capture-config.mjs +35 -0
  217. package/dist/routers/capture-config.mjs.map +1 -0
  218. package/dist/routers/comments.mjs +41 -15
  219. package/dist/routers/comments.mjs.map +1 -1
  220. package/dist/routers/component-catalog.handlers.mjs +71 -0
  221. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  222. package/dist/routers/component.handlers.mjs +121 -0
  223. package/dist/routers/component.handlers.mjs.map +1 -0
  224. package/dist/routers/connections.mjs +118 -0
  225. package/dist/routers/connections.mjs.map +1 -0
  226. package/dist/routers/design-connect.handlers.mjs +44 -0
  227. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  228. package/dist/routers/design.handlers.mjs +114 -0
  229. package/dist/routers/design.handlers.mjs.map +1 -0
  230. package/dist/routers/health-report.mjs +2 -3
  231. package/dist/routers/health-report.mjs.map +1 -1
  232. package/dist/routers/health.mjs +4 -3
  233. package/dist/routers/health.mjs.map +1 -1
  234. package/dist/routers/helpers.mjs +19 -3
  235. package/dist/routers/helpers.mjs.map +1 -1
  236. package/dist/routers/insights-ui.mjs +64 -0
  237. package/dist/routers/insights-ui.mjs.map +1 -0
  238. package/dist/routers/insights.handlers.mjs +45 -0
  239. package/dist/routers/insights.handlers.mjs.map +1 -0
  240. package/dist/routers/insights.mjs +189 -0
  241. package/dist/routers/insights.mjs.map +1 -0
  242. package/dist/routers/links.handlers.mjs +80 -0
  243. package/dist/routers/links.handlers.mjs.map +1 -0
  244. package/dist/routers/links.mjs +139 -0
  245. package/dist/routers/links.mjs.map +1 -0
  246. package/dist/routers/media.mjs +18 -4
  247. package/dist/routers/media.mjs.map +1 -1
  248. package/dist/routers/members.mjs +2 -1
  249. package/dist/routers/members.mjs.map +1 -1
  250. package/dist/routers/notification-channels.mjs +17 -23
  251. package/dist/routers/notification-channels.mjs.map +1 -1
  252. package/dist/routers/oauth-state.mjs +45 -0
  253. package/dist/routers/oauth-state.mjs.map +1 -0
  254. package/dist/routers/preview-base.mjs +38 -0
  255. package/dist/routers/preview-base.mjs.map +1 -0
  256. package/dist/routers/preview-token.mjs +51 -0
  257. package/dist/routers/preview-token.mjs.map +1 -0
  258. package/dist/routers/profile.mjs +4 -2
  259. package/dist/routers/profile.mjs.map +1 -1
  260. package/dist/routers/projects.mjs +17 -1
  261. package/dist/routers/projects.mjs.map +1 -1
  262. package/dist/routers/publish-upload.mjs +78 -0
  263. package/dist/routers/publish-upload.mjs.map +1 -0
  264. package/dist/routers/published-access.mjs +52 -0
  265. package/dist/routers/published-access.mjs.map +1 -0
  266. package/dist/routers/published-files.mjs +52 -0
  267. package/dist/routers/published-files.mjs.map +1 -0
  268. package/dist/routers/review-forms.mjs +72 -0
  269. package/dist/routers/review-forms.mjs.map +1 -0
  270. package/dist/routers/review-section.handlers.mjs +85 -0
  271. package/dist/routers/review-section.handlers.mjs.map +1 -0
  272. package/dist/routers/review-switch-off.mjs +29 -0
  273. package/dist/routers/review-switch-off.mjs.map +1 -0
  274. package/dist/routers/schemas.mjs +140 -22
  275. package/dist/routers/schemas.mjs.map +1 -1
  276. package/dist/routers/scoped-models.mjs +22 -0
  277. package/dist/routers/scoped-models.mjs.map +1 -0
  278. package/dist/routers/settings-ai.mjs +35 -0
  279. package/dist/routers/settings-ai.mjs.map +1 -0
  280. package/dist/routers/settings-capture.mjs +77 -0
  281. package/dist/routers/settings-capture.mjs.map +1 -0
  282. package/dist/routers/settings-design.mjs +103 -0
  283. package/dist/routers/settings-design.mjs.map +1 -0
  284. package/dist/routers/settings-general.mjs +55 -10
  285. package/dist/routers/settings-general.mjs.map +1 -1
  286. package/dist/routers/settings-git-channel.mjs +77 -0
  287. package/dist/routers/settings-git-channel.mjs.map +1 -0
  288. package/dist/routers/settings-labels.mjs +27 -1
  289. package/dist/routers/settings-labels.mjs.map +1 -1
  290. package/dist/routers/settings-links.mjs +63 -0
  291. package/dist/routers/settings-links.mjs.map +1 -0
  292. package/dist/routers/settings-members.mjs +4 -7
  293. package/dist/routers/settings-members.mjs.map +1 -1
  294. package/dist/routers/settings-notifications.mjs +15 -10
  295. package/dist/routers/settings-notifications.mjs.map +1 -1
  296. package/dist/routers/settings-tokens.mjs +2 -5
  297. package/dist/routers/settings-tokens.mjs.map +1 -1
  298. package/dist/routers/settings-webhooks.mjs +7 -24
  299. package/dist/routers/settings-webhooks.mjs.map +1 -1
  300. package/dist/routers/settings.handlers.mjs +107 -26
  301. package/dist/routers/settings.handlers.mjs.map +1 -1
  302. package/dist/routers/settings.mjs +16 -4
  303. package/dist/routers/settings.mjs.map +1 -1
  304. package/dist/routers/signed-storybook.mjs +53 -0
  305. package/dist/routers/signed-storybook.mjs.map +1 -0
  306. package/dist/routers/snapshot-groups.mjs +63 -0
  307. package/dist/routers/snapshot-groups.mjs.map +1 -0
  308. package/dist/routers/snapshots.mjs +103 -5
  309. package/dist/routers/snapshots.mjs.map +1 -1
  310. package/dist/routers/storybook.mjs +37 -88
  311. package/dist/routers/storybook.mjs.map +1 -1
  312. package/dist/routers/tokens.mjs +2 -5
  313. package/dist/routers/tokens.mjs.map +1 -1
  314. package/dist/routers/ui.mjs +34 -5
  315. package/dist/routers/ui.mjs.map +1 -1
  316. package/dist/routers/webhooks.mjs +5 -11
  317. package/dist/routers/webhooks.mjs.map +1 -1
  318. package/dist/routers/well-known.mjs +11 -4
  319. package/dist/routers/well-known.mjs.map +1 -1
  320. package/dist/routes/api-routes.mjs +93 -0
  321. package/dist/routes/api-routes.mjs.map +1 -0
  322. package/dist/routes/classification.mjs +38 -0
  323. package/dist/routes/classification.mjs.map +1 -0
  324. package/dist/routes/openapi-classes.mjs +22 -0
  325. package/dist/routes/openapi-classes.mjs.map +1 -0
  326. package/dist/routes/page-routes.mjs +104 -0
  327. package/dist/routes/page-routes.mjs.map +1 -0
  328. package/dist/runtime.mjs +18 -13
  329. package/dist/runtime.mjs.map +1 -1
  330. package/dist/serverless.mjs +5 -0
  331. package/dist/store.mjs +19 -1
  332. package/dist/store.mjs.map +1 -1
  333. package/dist/targets.mjs +46 -0
  334. package/dist/targets.mjs.map +1 -0
  335. package/dist/ui/buttons.mjs +16 -3
  336. package/dist/ui/buttons.mjs.map +1 -1
  337. package/dist/ui/design-reference.mjs +97 -0
  338. package/dist/ui/design-reference.mjs.map +1 -0
  339. package/dist/ui/document.mjs +1 -2
  340. package/dist/ui/document.mjs.map +1 -1
  341. package/dist/ui/forms.mjs +6 -5
  342. package/dist/ui/forms.mjs.map +1 -1
  343. package/dist/ui/icons/paths.mjs +1 -0
  344. package/dist/ui/icons/paths.mjs.map +1 -1
  345. package/dist/ui/preview-embed.mjs +82 -0
  346. package/dist/ui/preview-embed.mjs.map +1 -0
  347. package/dist/ui/shell/client-script.mjs +85 -0
  348. package/dist/ui/shell/client-script.mjs.map +1 -1
  349. package/dist/ui/shell/nav.mjs +12 -8
  350. package/dist/ui/shell/nav.mjs.map +1 -1
  351. package/dist/ui/shell/review-script.mjs +8 -0
  352. package/dist/ui/shell/review-script.mjs.map +1 -1
  353. package/dist/ui/shell/sidebar.mjs +2 -2
  354. package/dist/ui/shell/sidebar.mjs.map +1 -1
  355. package/package.json +11 -6
  356. package/dist/asset-paths.mjs +0 -23
  357. package/dist/asset-paths.mjs.map +0 -1
  358. package/dist/assets/mark.svg +0 -1
  359. package/dist/pages/settings-status.mjs +0 -137
  360. package/dist/pages/settings-status.mjs.map +0 -1
  361. package/dist/routers/settings-status.mjs +0 -77
  362. package/dist/routers/settings-status.mjs.map +0 -1
  363. package/dist/routers/status-configs.mjs +0 -108
  364. package/dist/routers/status-configs.mjs.map +0 -1
@@ -0,0 +1,53 @@
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 { publishedAccess } from "./published-access.mjs";
7
+ import { SIGNED_URL_HEADERS, publishedRelativePath, servePublishedFile } from "./published-files.mjs";
8
+ import { STORYBOOK_TOKEN_PREFIX, storybookTokenPath, verifyStorybookToken } from "@storyshelf/core/utils";
9
+ import { BuildModel, ProjectModel } from "@storyshelf/core/models";
10
+ //#region src/routers/signed-storybook.ts
11
+ /**
12
+ * Signed Storybook URLs (ADR 0007, decision 5): `GET /_s/<token>/<file>` serves one build's static
13
+ * Storybook files to a remote browser, with no session. The HMAC token is the only credential; it
14
+ * names one project and one build and expires. The file itself is served by the same handler and
15
+ * access policy as the other published routes (`published-files.ts`, `published-access.ts`).
16
+ * Nothing else lives under `/_s/`: no wrapper page, no API, no other build, no listing.
17
+ */
18
+ /** Register the signed-URL file route. */
19
+ function registerSignedStorybook(app) {
20
+ app.get(`${STORYBOOK_TOKEN_PREFIX}:token/*`, async (c) => await serveSigned(c));
21
+ }
22
+ async function serveSigned(c) {
23
+ const token = c.req.param("token") ?? "";
24
+ const rel = publishedRelativePath(c.req.path, storybookTokenPath(token));
25
+ if (!rel) return refuse(c, "bad path");
26
+ return await serveGranted(c, token, rel) ?? refuse(c, "no such file");
27
+ }
28
+ /** The file the token grants as a response, or null (for the debug log only: why not). */
29
+ async function serveGranted(c, token, rel) {
30
+ const { config, db } = getStore();
31
+ const verified = verifyStorybookToken(token, credentialKeys(config));
32
+ if (!verified.ok) return refuse(c, verified.reason);
33
+ const { projectId, buildId } = verified.claims;
34
+ const [project, build] = await Promise.all([new ProjectModel(db).get(projectId), new BuildModel(db).get(buildId)]);
35
+ const access = project && build ? await publishedAccess(build, project, verified.claims) : null;
36
+ if (access !== "token") return refuse(c, "unknown build");
37
+ return await servePublishedFile(c, {
38
+ projectId,
39
+ buildId,
40
+ rel,
41
+ access,
42
+ cacheControl: ""
43
+ });
44
+ }
45
+ /** The one answer for every refusal, so a caller cannot tell a bad signature from an expired token. */
46
+ function refuse(c, reason) {
47
+ getStore().logger.debug({ reason }, "signed storybook request refused");
48
+ return c.text("Not found", 404, SIGNED_URL_HEADERS);
49
+ }
50
+ //#endregion
51
+ export { registerSignedStorybook };
52
+
53
+ //# sourceMappingURL=signed-storybook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signed-storybook.mjs","names":[],"sources":["../../src/routers/signed-storybook.ts"],"sourcesContent":["/**\n * Signed Storybook URLs (ADR 0007, decision 5): `GET /_s/<token>/<file>` serves one build's static\n * Storybook files to a remote browser, with no session. The HMAC token is the only credential; it\n * names one project and one build and expires. The file itself is served by the same handler and\n * access policy as the other published routes (`published-files.ts`, `published-access.ts`).\n * Nothing else lives under `/_s/`: no wrapper page, no API, no other build, no listing.\n */\nimport { BuildModel, ProjectModel } from \"@storyshelf/core/models\";\nimport {\n STORYBOOK_TOKEN_PREFIX,\n storybookTokenPath,\n verifyStorybookToken,\n} from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { publishedAccess } from \"./published-access.ts\";\nimport {\n publishedRelativePath,\n servePublishedFile,\n SIGNED_URL_HEADERS,\n} from \"./published-files.ts\";\n\n/** Register the signed-URL file route. */\nexport function registerSignedStorybook(app: ShelfRouter): void {\n app.get(`${STORYBOOK_TOKEN_PREFIX}:token/*`, async (c) => await serveSigned(c));\n}\n\nasync function serveSigned(c: Context): Promise<Response> {\n const token = c.req.param(\"token\") ?? \"\";\n const rel = publishedRelativePath(c.req.path, storybookTokenPath(token));\n if (!rel) {\n return refuse(c, \"bad path\");\n }\n return (await serveGranted(c, token, rel)) ?? refuse(c, \"no such file\");\n}\n\n/** The file the token grants as a response, or null (for the debug log only: why not). */\nasync function serveGranted(c: Context, token: string, rel: string): Promise<Response | null> {\n const { config, db } = getStore();\n const verified = verifyStorybookToken(token, credentialKeys(config));\n if (!verified.ok) {\n return refuse(c, verified.reason);\n }\n const { projectId, buildId } = verified.claims;\n const [project, build] = await Promise.all([\n new ProjectModel(db).get(projectId),\n new BuildModel(db).get(buildId),\n ]);\n const access = project && build ? await publishedAccess(build, project, verified.claims) : null;\n if (access !== \"token\") {\n return refuse(c, \"unknown build\");\n }\n return await servePublishedFile(c, { projectId, buildId, rel, access, cacheControl: \"\" });\n}\n\n/** The one answer for every refusal, so a caller cannot tell a bad signature from an expired token. */\nfunction refuse(c: Context, reason: string): Response {\n getStore().logger.debug({ reason }, \"signed storybook request refused\");\n return c.text(\"Not found\", 404, SIGNED_URL_HEADERS);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAyBA,SAAgB,wBAAwB,KAAwB;CAC9D,IAAI,IAAI,GAAG,uBAAuB,WAAW,OAAO,MAAM,MAAM,YAAY,CAAC,CAAC;AAChF;AAEA,eAAe,YAAY,GAA+B;CACxD,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO,KAAK;CACtC,MAAM,MAAM,sBAAsB,EAAE,IAAI,MAAM,mBAAmB,KAAK,CAAC;CACvE,IAAI,CAAC,KACH,OAAO,OAAO,GAAG,UAAU;CAE7B,OAAQ,MAAM,aAAa,GAAG,OAAO,GAAG,KAAM,OAAO,GAAG,cAAc;AACxE;;AAGA,eAAe,aAAa,GAAY,OAAe,KAAuC;CAC5F,MAAM,EAAE,QAAQ,OAAO,SAAS;CAChC,MAAM,WAAW,qBAAqB,OAAO,eAAe,MAAM,CAAC;CACnE,IAAI,CAAC,SAAS,IACZ,OAAO,OAAO,GAAG,SAAS,MAAM;CAElC,MAAM,EAAE,WAAW,YAAY,SAAS;CACxC,MAAM,CAAC,SAAS,SAAS,MAAM,QAAQ,IAAI,CACzC,IAAI,aAAa,EAAE,CAAC,CAAC,IAAI,SAAS,GAClC,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,OAAO,CAChC,CAAC;CACD,MAAM,SAAS,WAAW,QAAQ,MAAM,gBAAgB,OAAO,SAAS,SAAS,MAAM,IAAI;CAC3F,IAAI,WAAW,SACb,OAAO,OAAO,GAAG,eAAe;CAElC,OAAO,MAAM,mBAAmB,GAAG;EAAE;EAAW;EAAS;EAAK;EAAQ,cAAc;CAAG,CAAC;AAC1F;;AAGA,SAAS,OAAO,GAAY,QAA0B;CACpD,SAAS,CAAC,CAAC,OAAO,MAAM,EAAE,OAAO,GAAG,kCAAkC;CACtE,OAAO,EAAE,KAAK,aAAa,KAAK,kBAAkB;AACpD"}
@@ -0,0 +1,63 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { notFound } from "./helpers.mjs";
6
+ import { baselineChanged, findDriftedSnapshots, isOpenSnapshot } from "./baseline-guard.mjs";
7
+ import { approveSnapshot, refreshBuild } from "./builds.handlers.mjs";
8
+ import { SnapshotModel } from "@storyshelf/core/models";
9
+ //#region src/routers/snapshot-groups.ts
10
+ /** Shared (story, mode) review: one decision covers every browser's snapshot. */
11
+ /** Identity of a review group: the same story and mode across engines. */
12
+ function groupKey(snapshot) {
13
+ return `${snapshot.storyId}\u0000${snapshot.viewportName}`;
14
+ }
15
+ /** Open snapshots of one story/mode; 404 when the group has nothing left to decide. */
16
+ async function openMembers(build, storyId, modeKey) {
17
+ const members = (await new SnapshotModel(getStore().db).listByBuild(build.id)).filter((snapshot) => snapshot.storyId === storyId && snapshot.viewportName === modeKey && isOpenSnapshot(snapshot));
18
+ if (members.length === 0) notFound("No open snapshots for this story and mode");
19
+ return members;
20
+ }
21
+ /**
22
+ * Approve every open snapshot of a story/mode, promoting each engine's
23
+ * baseline. All-or-nothing on drift: when any member's baseline moved and
24
+ * `force` is off, nothing is written and the 409 names that snapshot.
25
+ *
26
+ * @returns The approved snapshot ids, primary engine first.
27
+ */
28
+ async function approveGroup(project, build, target, userId, options = {}) {
29
+ const members = await openMembers(build, target.storyId, target.modeKey);
30
+ if (!options.force) {
31
+ const first = [...(await findDriftedSnapshots(project, build, members)).entries()][0];
32
+ if (first) throw baselineChanged(first[0], first[1]);
33
+ }
34
+ for (const member of members) await approveSnapshot(member.id, userId, {
35
+ force: options.force ?? false,
36
+ deferRefresh: true
37
+ });
38
+ await refreshBuild(build.id);
39
+ return members.map((member) => member.id);
40
+ }
41
+ /** Reject every open snapshot of a story/mode and refresh the build once. */
42
+ async function rejectGroup(build, target, userId) {
43
+ const members = await openMembers(build, target.storyId, target.modeKey);
44
+ const snapshots = new SnapshotModel(getStore().db);
45
+ await Promise.all(members.map(async (member) => await snapshots.review(member.id, "rejected", userId)));
46
+ await refreshBuild(build.id);
47
+ return members.map((member) => member.id);
48
+ }
49
+ /**
50
+ * Split open snapshots into approvable groups and groups skipped because some
51
+ * member drifted (the whole group is skipped, never half-approved).
52
+ */
53
+ function splitByDrift(open, drifted) {
54
+ const blocked = new Set(open.filter((s) => drifted.has(s.id)).map((s) => groupKey(s)));
55
+ return {
56
+ approvable: open.filter((s) => !blocked.has(groupKey(s))),
57
+ skipped: open.filter((s) => blocked.has(groupKey(s)))
58
+ };
59
+ }
60
+ //#endregion
61
+ export { approveGroup, groupKey, rejectGroup, splitByDrift };
62
+
63
+ //# sourceMappingURL=snapshot-groups.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshot-groups.mjs","names":[],"sources":["../../src/routers/snapshot-groups.ts"],"sourcesContent":["/** Shared (story, mode) review: one decision covers every browser's snapshot. */\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { getStore } from \"../store.ts\";\nimport { baselineChanged, findDriftedSnapshots, isOpenSnapshot } from \"./baseline-guard.ts\";\nimport { approveSnapshot, refreshBuild } from \"./builds.handlers.ts\";\nimport { notFound } from \"./helpers.ts\";\n\n/** Identity of a review group: the same story and mode across engines. */\nexport function groupKey(snapshot: Pick<Snapshot, \"storyId\" | \"viewportName\">): string {\n return `${snapshot.storyId}\\u0000${snapshot.viewportName}`;\n}\n\n/** Open snapshots of one story/mode; 404 when the group has nothing left to decide. */\nasync function openMembers(build: Build, storyId: string, modeKey: string): Promise<Snapshot[]> {\n const all = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const members = all.filter(\n (snapshot) =>\n snapshot.storyId === storyId && snapshot.viewportName === modeKey && isOpenSnapshot(snapshot),\n );\n if (members.length === 0) {\n notFound(\"No open snapshots for this story and mode\");\n }\n return members;\n}\n\n/**\n * Approve every open snapshot of a story/mode, promoting each engine's\n * baseline. All-or-nothing on drift: when any member's baseline moved and\n * `force` is off, nothing is written and the 409 names that snapshot.\n *\n * @returns The approved snapshot ids, primary engine first.\n */\nexport async function approveGroup(\n project: Project,\n build: Build,\n target: { storyId: string; modeKey: string },\n userId: string | null,\n options: { force?: boolean } = {},\n): Promise<string[]> {\n const members = await openMembers(build, target.storyId, target.modeKey);\n if (!options.force) {\n const drifted = await findDriftedSnapshots(project, build, members);\n const first = [...drifted.entries()][0];\n if (first) {\n throw baselineChanged(first[0], first[1]);\n }\n }\n for (const member of members) {\n // oxlint-disable-next-line no-await-in-loop -- baselines are promoted engine by engine\n await approveSnapshot(member.id, userId, {\n force: options.force ?? false,\n deferRefresh: true,\n });\n }\n await refreshBuild(build.id);\n return members.map((member) => member.id);\n}\n\n/** Reject every open snapshot of a story/mode and refresh the build once. */\nexport async function rejectGroup(\n build: Build,\n target: { storyId: string; modeKey: string },\n userId: string | null,\n): Promise<string[]> {\n const members = await openMembers(build, target.storyId, target.modeKey);\n const snapshots = new SnapshotModel(getStore().db);\n await Promise.all(\n members.map(async (member) => await snapshots.review(member.id, \"rejected\", userId)),\n );\n await refreshBuild(build.id);\n return members.map((member) => member.id);\n}\n\n/**\n * Split open snapshots into approvable groups and groups skipped because some\n * member drifted (the whole group is skipped, never half-approved).\n */\nexport function splitByDrift(\n open: Snapshot[],\n drifted: ReadonlyMap<string, unknown>,\n): { approvable: Snapshot[]; skipped: Snapshot[] } {\n const blocked = new Set(open.filter((s) => drifted.has(s.id)).map((s) => groupKey(s)));\n return {\n approvable: open.filter((s) => !blocked.has(groupKey(s))),\n skipped: open.filter((s) => blocked.has(groupKey(s))),\n };\n}\n"],"mappings":";;;;;;;;;;;AASA,SAAgB,SAAS,UAA8D;CACrF,OAAO,GAAG,SAAS,QAAQ,QAAQ,SAAS;AAC9C;;AAGA,eAAe,YAAY,OAAc,SAAiB,SAAsC;CAE9F,MAAM,WAAU,MADE,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,EAAA,CACnD,QACjB,aACC,SAAS,YAAY,WAAW,SAAS,iBAAiB,WAAW,eAAe,QAAQ,CAChG;CACA,IAAI,QAAQ,WAAW,GACrB,SAAS,2CAA2C;CAEtD,OAAO;AACT;;;;;;;;AASA,eAAsB,aACpB,SACA,OACA,QACA,QACA,UAA+B,CAAC,GACb;CACnB,MAAM,UAAU,MAAM,YAAY,OAAO,OAAO,SAAS,OAAO,OAAO;CACvE,IAAI,CAAC,QAAQ,OAAO;EAElB,MAAM,QAAQ,CAAC,IAAG,MADI,qBAAqB,SAAS,OAAO,OAAO,EAAA,CACxC,QAAQ,CAAC,CAAC,CAAC;EACrC,IAAI,OACF,MAAM,gBAAgB,MAAM,IAAI,MAAM,EAAE;CAE5C;CACA,KAAK,MAAM,UAAU,SAEnB,MAAM,gBAAgB,OAAO,IAAI,QAAQ;EACvC,OAAO,QAAQ,SAAS;EACxB,cAAc;CAChB,CAAC;CAEH,MAAM,aAAa,MAAM,EAAE;CAC3B,OAAO,QAAQ,KAAK,WAAW,OAAO,EAAE;AAC1C;;AAGA,eAAsB,YACpB,OACA,QACA,QACmB;CACnB,MAAM,UAAU,MAAM,YAAY,OAAO,OAAO,SAAS,OAAO,OAAO;CACvE,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,WAAW,MAAM,UAAU,OAAO,OAAO,IAAI,YAAY,MAAM,CAAC,CACrF;CACA,MAAM,aAAa,MAAM,EAAE;CAC3B,OAAO,QAAQ,KAAK,WAAW,OAAO,EAAE;AAC1C;;;;;AAMA,SAAgB,aACd,MACA,SACiD;CACjD,MAAM,UAAU,IAAI,IAAI,KAAK,QAAQ,MAAM,QAAQ,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,MAAM,SAAS,CAAC,CAAC,CAAC;CACrF,OAAO;EACL,YAAY,KAAK,QAAQ,MAAM,CAAC,QAAQ,IAAI,SAAS,CAAC,CAAC,CAAC;EACxD,SAAS,KAAK,QAAQ,MAAM,QAAQ,IAAI,SAAS,CAAC,CAAC,CAAC;CACtD;AACF"}
@@ -2,13 +2,14 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
+ import { resolveAuthorizedProject } from "./helpers.mjs";
5
6
  import { hxRefresh, isHxRequest } from "./htmx.mjs";
6
7
  import { flashHx } from "./flash.mjs";
7
- import { resolveAuthorizedProject } from "./helpers.mjs";
8
- import { approveAllResultSchema, baselineChangedSchema, errorSchema, notFound, okSchema, rediffResultSchema, snapshotSchema, unauthorized } from "./schemas.mjs";
8
+ import { approveAllResultSchema, baselineChangedSchema, errorSchema, groupDecisionResultSchema, notFound, okSchema, rediffResultSchema, snapshotSchema, unauthorized } from "./schemas.mjs";
9
9
  import { findDriftedSnapshots, isBaselineChanged, isOpenSnapshot } from "./baseline-guard.mjs";
10
10
  import { APPROVER_ROLES, VIEW_ROLES, approveSnapshot, buildForProject, refreshBuild, snapshotForBuild } from "./builds.handlers.mjs";
11
11
  import { hxAdvance } from "./review-next.mjs";
12
+ import { approveGroup, rejectGroup, splitByDrift } from "./snapshot-groups.mjs";
12
13
  import { createRoute, z } from "@hono/zod-openapi";
13
14
  import { REDIFFABLE_BUILD_STATUSES, rediffBuild } from "@storyshelf/core/capture";
14
15
  import { SnapshotModel } from "@storyshelf/core/models";
@@ -95,6 +96,47 @@ const approveAllRoute = createRoute({
95
96
  ...notFound
96
97
  }
97
98
  });
99
+ const groupParams = z.object({
100
+ slug: z.string(),
101
+ buildId: z.string(),
102
+ storyId: z.string(),
103
+ modeKey: z.string()
104
+ });
105
+ const approveGroupRoute = createRoute({
106
+ method: "post",
107
+ tags: ["Review"],
108
+ summary: "Approve a story/mode across all browsers",
109
+ path: "/api/v1/projects/{slug}/builds/{buildId}/stories/{storyId}/modes/{modeKey}/approve",
110
+ request: {
111
+ params: groupParams,
112
+ query: z.object({ force: z.enum(["true", "false"]).optional() })
113
+ },
114
+ responses: {
115
+ 200: {
116
+ content: { "application/json": { schema: groupDecisionResultSchema } },
117
+ description: "Every open snapshot of the story and mode approved and each engine's baseline promoted"
118
+ },
119
+ 409: {
120
+ content: { "application/json": { schema: baselineChangedSchema } },
121
+ description: "A member's baseline changed after its diff; nothing was approved. Re-diff or pass force=true"
122
+ },
123
+ ...notFound
124
+ }
125
+ });
126
+ const rejectGroupRoute = createRoute({
127
+ method: "post",
128
+ tags: ["Review"],
129
+ summary: "Reject a story/mode across all browsers",
130
+ path: "/api/v1/projects/{slug}/builds/{buildId}/stories/{storyId}/modes/{modeKey}/reject",
131
+ request: { params: groupParams },
132
+ responses: {
133
+ 200: {
134
+ content: { "application/json": { schema: groupDecisionResultSchema } },
135
+ description: "Every open snapshot of the story and mode rejected"
136
+ },
137
+ ...notFound
138
+ }
139
+ });
98
140
  const rediffRoute = createRoute({
99
141
  method: "post",
100
142
  tags: ["Review"],
@@ -191,15 +233,71 @@ function registerSnapshots(app) {
191
233
  const userId = getStore().user?.id ?? null;
192
234
  const forced = force === "true";
193
235
  const drifted = forced ? /* @__PURE__ */ new Map() : await findDriftedSnapshots(project, build, snapshots);
194
- await Promise.all(snapshots.filter((snapshot) => isOpenSnapshot(snapshot) && !drifted.has(snapshot.id)).map(async (snapshot) => {
195
- await approveSnapshot(snapshot.id, userId, { force: forced });
236
+ const { approvable, skipped } = splitByDrift(snapshots.filter((snapshot) => isOpenSnapshot(snapshot)), drifted);
237
+ await Promise.all(approvable.map(async (snapshot) => {
238
+ await approveSnapshot(snapshot.id, userId, {
239
+ force: forced,
240
+ deferRefresh: true
241
+ });
196
242
  }));
243
+ await refreshBuild(build.id);
197
244
  hxRefresh(c);
198
245
  return c.json({
199
246
  ok: true,
200
- skipped: [...drifted.keys()]
247
+ skipped: skipped.map((snapshot) => snapshot.id)
201
248
  });
202
249
  });
250
+ app.openapi(approveGroupRoute, async (c) => {
251
+ const { slug, buildId, storyId, modeKey } = c.req.valid("param");
252
+ const { force } = c.req.valid("query");
253
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
254
+ const build = await buildForProject(project.id, buildId);
255
+ const userId = getStore().user?.id ?? null;
256
+ let snapshotIds;
257
+ try {
258
+ snapshotIds = await approveGroup(project, build, {
259
+ storyId,
260
+ modeKey
261
+ }, userId, { force: force === "true" });
262
+ } catch (error) {
263
+ if (!isHxRequest(c) || !isBaselineChanged(error)) throw error;
264
+ c.header("HX-Redirect", `/projects/${slug}/builds/${buildId}/diff`);
265
+ return c.json({
266
+ ok: true,
267
+ snapshotIds: []
268
+ }, 200);
269
+ }
270
+ const first = snapshotIds[0];
271
+ if (first) await hxAdvance(c, {
272
+ slug,
273
+ buildId,
274
+ snapshotId: first
275
+ });
276
+ return c.json({
277
+ ok: true,
278
+ snapshotIds
279
+ }, 200);
280
+ });
281
+ app.openapi(rejectGroupRoute, async (c) => {
282
+ const { slug, buildId, storyId, modeKey } = c.req.valid("param");
283
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
284
+ const build = await buildForProject(project.id, buildId);
285
+ const userId = getStore().user?.id ?? null;
286
+ const snapshotIds = await rejectGroup(build, {
287
+ storyId,
288
+ modeKey
289
+ }, userId);
290
+ const first = snapshotIds[0];
291
+ if (first) await hxAdvance(c, {
292
+ slug,
293
+ buildId,
294
+ snapshotId: first
295
+ });
296
+ return c.json({
297
+ ok: true,
298
+ snapshotIds
299
+ }, 200);
300
+ });
203
301
  app.openapi(rediffRoute, async (c) => {
204
302
  const { slug, buildId } = c.req.valid("param");
205
303
  const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
@@ -1 +1 @@
1
- {"version":3,"file":"snapshots.mjs","names":[],"sources":["../../src/routers/snapshots.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { REDIFFABLE_BUILD_STATUSES, rediffBuild } from \"@storyshelf/core/capture\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { findDriftedSnapshots, isBaselineChanged, isOpenSnapshot } from \"./baseline-guard.ts\";\nimport {\n VIEW_ROLES,\n APPROVER_ROLES,\n snapshotForBuild,\n refreshBuild,\n approveSnapshot,\n buildForProject,\n} from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRefresh, isHxRequest } from \"./htmx.ts\";\nimport { hxAdvance } from \"./review-next.ts\";\nimport {\n snapshotSchema,\n okSchema,\n approveAllResultSchema,\n rediffResultSchema,\n baselineChangedSchema,\n errorSchema,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\nconst listSnapshotsRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"List snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: snapshotSchema.array() } },\n description: \"List snapshots for a build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst approveSnapshotRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve a snapshot\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/approve\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string(), snapshotId: z.string() }),\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"Snapshot approved\",\n },\n 409: {\n content: { \"application/json\": { schema: baselineChangedSchema } },\n description:\n \"The baseline changed after this diff was computed; re-diff the build or pass force=true\",\n },\n ...notFound,\n },\n});\n\nconst rejectSnapshotRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject a snapshot\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/reject\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), snapshotId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"Snapshot rejected\",\n },\n ...notFound,\n },\n});\n\nconst approveAllRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve all open snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/approve-all\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: approveAllResultSchema } },\n description: \"Open snapshots approved; snapshots with a changed baseline are skipped\",\n },\n ...notFound,\n },\n});\n\nconst rediffRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Re-diff against current baselines\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/rediff\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: rediffResultSchema } },\n description:\n \"Undecided snapshots re-diffed against the current baselines (no re-render). Decided snapshots are untouched.\",\n },\n 400: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Default-branch builds are authoritative and are not re-diffed\",\n },\n 409: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Build has not finished capturing or failed; retry capture instead\",\n },\n ...notFound,\n },\n});\n\nconst rejectAllRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject all open snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reject-all\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"All snapshots rejected\",\n },\n ...notFound,\n },\n});\n\n/** Register the snapshot list, approve, reject, and bulk-review endpoints. */\nexport function registerSnapshots(app: ShelfRouter): void {\n app.openapi(listSnapshotsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = new SnapshotModel(getStore().db).listByBuild(build.id);\n return c.json(await snapshots);\n });\n\n app.openapi(approveSnapshotRoute, async (c) => {\n const { slug, buildId, snapshotId } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await snapshotForBuild(build, snapshotId);\n const userId = getStore().user?.id ?? null;\n try {\n await approveSnapshot(snapshotId, userId, { force: force === \"true\" });\n } catch (error) {\n if (!isHxRequest(c) || !isBaselineChanged(error)) {\n throw error;\n }\n // The review page recomputes staleness on load and offers re-diff / approve anyway.\n c.header(\"HX-Redirect\", `/projects/${slug}/builds/${buildId}/diff?snapshot=${snapshotId}`);\n return c.json({ ok: true }, 200);\n }\n await hxAdvance(c, { slug, buildId, snapshotId });\n return c.json({ ok: true }, 200);\n });\n\n app.openapi(rejectSnapshotRoute, async (c) => {\n const { slug, buildId, snapshotId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshot = await snapshotForBuild(build, snapshotId);\n const userId = getStore().user?.id ?? null;\n await new SnapshotModel(getStore().db).review(snapshot.id, \"rejected\", userId);\n await refreshBuild(build.id);\n await hxAdvance(c, { slug, buildId, snapshotId });\n return c.json({ ok: true });\n });\n\n app.openapi(approveAllRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const userId = getStore().user?.id ?? null;\n const forced = force === \"true\";\n const drifted = forced ? new Map() : await findDriftedSnapshots(project, build, snapshots);\n await Promise.all(\n snapshots\n .filter((snapshot) => isOpenSnapshot(snapshot) && !drifted.has(snapshot.id))\n .map(async (snapshot) => {\n await approveSnapshot(snapshot.id, userId, { force: forced });\n }),\n );\n hxRefresh(c);\n return c.json({ ok: true, skipped: [...drifted.keys()] });\n });\n\n app.openapi(rediffRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n if (build.isDefault) {\n throw new HTTPException(400, { message: \"Default-branch builds are not re-diffed\" });\n }\n if (!REDIFFABLE_BUILD_STATUSES.has(build.status)) {\n throw new HTTPException(409, {\n message: `Build is ${build.status}; wait for capture to finish or retry capture`,\n });\n }\n const { db, storage, logger } = getStore();\n const result = await rediffBuild({ db, storage, logger }, project, build);\n await refreshBuild(build.id);\n hxRefresh(c);\n flashHx(c, \"Re-diffed against current baselines\");\n return c.json({ ok: true, ...result }, 200);\n });\n\n app.openapi(rejectAllRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const userId = getStore().user?.id ?? null;\n await Promise.all(\n snapshots\n .filter((snapshot) => snapshot.status === \"new\" || snapshot.status === \"changed\")\n .map(async (snapshot) => {\n await new SnapshotModel(getStore().db).review(snapshot.id, \"rejected\", userId);\n }),\n );\n await refreshBuild(build.id);\n hxRefresh(c);\n return c.json({ ok: true });\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"snapshots.mjs","names":[],"sources":["../../src/routers/snapshots.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { REDIFFABLE_BUILD_STATUSES, rediffBuild } from \"@storyshelf/core/capture\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { findDriftedSnapshots, isBaselineChanged, isOpenSnapshot } from \"./baseline-guard.ts\";\nimport {\n VIEW_ROLES,\n APPROVER_ROLES,\n snapshotForBuild,\n refreshBuild,\n approveSnapshot,\n buildForProject,\n} from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRefresh, isHxRequest } from \"./htmx.ts\";\nimport { hxAdvance } from \"./review-next.ts\";\nimport {\n snapshotSchema,\n okSchema,\n approveAllResultSchema,\n groupDecisionResultSchema,\n rediffResultSchema,\n baselineChangedSchema,\n errorSchema,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\nimport { approveGroup, rejectGroup, splitByDrift } from \"./snapshot-groups.ts\";\nconst listSnapshotsRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"List snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: snapshotSchema.array() } },\n description: \"List snapshots for a build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst approveSnapshotRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve a snapshot\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/approve\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string(), snapshotId: z.string() }),\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"Snapshot approved\",\n },\n 409: {\n content: { \"application/json\": { schema: baselineChangedSchema } },\n description:\n \"The baseline changed after this diff was computed; re-diff the build or pass force=true\",\n },\n ...notFound,\n },\n});\n\nconst rejectSnapshotRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject a snapshot\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/reject\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), snapshotId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"Snapshot rejected\",\n },\n ...notFound,\n },\n});\n\nconst approveAllRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve all open snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/approve-all\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: approveAllResultSchema } },\n description: \"Open snapshots approved; snapshots with a changed baseline are skipped\",\n },\n ...notFound,\n },\n});\n\nconst groupParams = z.object({\n slug: z.string(),\n buildId: z.string(),\n storyId: z.string(),\n modeKey: z.string(),\n});\n\nconst approveGroupRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve a story/mode across all browsers\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/stories/{storyId}/modes/{modeKey}/approve\",\n request: {\n params: groupParams,\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: groupDecisionResultSchema } },\n description:\n \"Every open snapshot of the story and mode approved and each engine's baseline promoted\",\n },\n 409: {\n content: { \"application/json\": { schema: baselineChangedSchema } },\n description:\n \"A member's baseline changed after its diff; nothing was approved. Re-diff or pass force=true\",\n },\n ...notFound,\n },\n});\n\nconst rejectGroupRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject a story/mode across all browsers\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/stories/{storyId}/modes/{modeKey}/reject\",\n request: { params: groupParams },\n responses: {\n 200: {\n content: { \"application/json\": { schema: groupDecisionResultSchema } },\n description: \"Every open snapshot of the story and mode rejected\",\n },\n ...notFound,\n },\n});\n\nconst rediffRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Re-diff against current baselines\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/rediff\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: rediffResultSchema } },\n description:\n \"Undecided snapshots re-diffed against the current baselines (no re-render). Decided snapshots are untouched.\",\n },\n 400: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Default-branch builds are authoritative and are not re-diffed\",\n },\n 409: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Build has not finished capturing or failed; retry capture instead\",\n },\n ...notFound,\n },\n});\n\nconst rejectAllRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject all open snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reject-all\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"All snapshots rejected\",\n },\n ...notFound,\n },\n});\n\n/** Register the snapshot list, approve, reject, and bulk-review endpoints. */\nexport function registerSnapshots(app: ShelfRouter): void {\n app.openapi(listSnapshotsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = new SnapshotModel(getStore().db).listByBuild(build.id);\n return c.json(await snapshots);\n });\n\n app.openapi(approveSnapshotRoute, async (c) => {\n const { slug, buildId, snapshotId } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await snapshotForBuild(build, snapshotId);\n const userId = getStore().user?.id ?? null;\n try {\n await approveSnapshot(snapshotId, userId, { force: force === \"true\" });\n } catch (error) {\n if (!isHxRequest(c) || !isBaselineChanged(error)) {\n throw error;\n }\n // The review page recomputes staleness on load and offers re-diff / approve anyway.\n c.header(\"HX-Redirect\", `/projects/${slug}/builds/${buildId}/diff?snapshot=${snapshotId}`);\n return c.json({ ok: true }, 200);\n }\n await hxAdvance(c, { slug, buildId, snapshotId });\n return c.json({ ok: true }, 200);\n });\n\n app.openapi(rejectSnapshotRoute, async (c) => {\n const { slug, buildId, snapshotId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshot = await snapshotForBuild(build, snapshotId);\n const userId = getStore().user?.id ?? null;\n await new SnapshotModel(getStore().db).review(snapshot.id, \"rejected\", userId);\n await refreshBuild(build.id);\n await hxAdvance(c, { slug, buildId, snapshotId });\n return c.json({ ok: true });\n });\n\n app.openapi(approveAllRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const userId = getStore().user?.id ?? null;\n const forced = force === \"true\";\n const drifted = forced ? new Map() : await findDriftedSnapshots(project, build, snapshots);\n // A story/mode is approved on every engine or on none: one drifted member skips its group.\n const { approvable, skipped } = splitByDrift(\n snapshots.filter((snapshot) => isOpenSnapshot(snapshot)),\n drifted,\n );\n await Promise.all(\n approvable.map(async (snapshot) => {\n await approveSnapshot(snapshot.id, userId, { force: forced, deferRefresh: true });\n }),\n );\n await refreshBuild(build.id);\n hxRefresh(c);\n return c.json({ ok: true, skipped: skipped.map((snapshot) => snapshot.id) });\n });\n\n app.openapi(approveGroupRoute, async (c) => {\n const { slug, buildId, storyId, modeKey } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const userId = getStore().user?.id ?? null;\n let snapshotIds: string[];\n try {\n snapshotIds = await approveGroup(project, build, { storyId, modeKey }, userId, {\n force: force === \"true\",\n });\n } catch (error) {\n if (!isHxRequest(c) || !isBaselineChanged(error)) {\n throw error;\n }\n c.header(\"HX-Redirect\", `/projects/${slug}/builds/${buildId}/diff`);\n return c.json({ ok: true, snapshotIds: [] }, 200);\n }\n const first = snapshotIds[0];\n if (first) await hxAdvance(c, { slug, buildId, snapshotId: first });\n return c.json({ ok: true, snapshotIds }, 200);\n });\n\n app.openapi(rejectGroupRoute, async (c) => {\n const { slug, buildId, storyId, modeKey } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const userId = getStore().user?.id ?? null;\n const snapshotIds = await rejectGroup(build, { storyId, modeKey }, userId);\n const first = snapshotIds[0];\n if (first) await hxAdvance(c, { slug, buildId, snapshotId: first });\n return c.json({ ok: true, snapshotIds }, 200);\n });\n\n app.openapi(rediffRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n if (build.isDefault) {\n throw new HTTPException(400, { message: \"Default-branch builds are not re-diffed\" });\n }\n if (!REDIFFABLE_BUILD_STATUSES.has(build.status)) {\n throw new HTTPException(409, {\n message: `Build is ${build.status}; wait for capture to finish or retry capture`,\n });\n }\n const { db, storage, logger } = getStore();\n const result = await rediffBuild({ db, storage, logger }, project, build);\n await refreshBuild(build.id);\n hxRefresh(c);\n flashHx(c, \"Re-diffed against current baselines\");\n return c.json({ ok: true, ...result }, 200);\n });\n\n app.openapi(rejectAllRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const userId = getStore().user?.id ?? null;\n await Promise.all(\n snapshots\n .filter((snapshot) => snapshot.status === \"new\" || snapshot.status === \"changed\")\n .map(async (snapshot) => {\n await new SnapshotModel(getStore().db).review(snapshot.id, \"rejected\", userId);\n }),\n );\n await refreshBuild(build.id);\n hxRefresh(c);\n return c.json({ ok: true });\n });\n}\n"],"mappings":"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"}
@@ -2,55 +2,19 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
- import { currentProjectRole, notFound } from "./helpers.mjs";
5
+ import { notFound } from "./helpers.mjs";
6
+ import { publishedAccess } from "./published-access.mjs";
7
+ import { publishedRelativePath, servePublishedFile } from "./published-files.mjs";
6
8
  import { renderStorybookPage, renderStorybookPreparingPage } from "../pages/storybook.mjs";
7
- import { BuildModel, LabelModel, ProjectModel, isPublicBuild } from "@storyshelf/core/models";
8
- import { isSafeSegment, mimeFor, storybookDir } from "@storyshelf/core/utils";
9
+ import { storybookDir } from "@storyshelf/core/utils";
10
+ import { BuildModel, LabelModel, ProjectModel } from "@storyshelf/core/models";
9
11
  import { posix } from "node:path";
10
12
  //#region src/routers/storybook.ts
11
- const VIEW_ROLES = /* @__PURE__ */ new Set([
12
- "viewer",
13
- "developer",
14
- "approver",
15
- "admin"
16
- ]);
17
- function contentTypeFor(path) {
18
- return mimeFor(path);
19
- }
20
- /**
21
- * Decide whether the current request may view `build`'s published Storybook.
22
- *
23
- * Public builds (ADR 0011) are viewable without auth; every other build requires
24
- * a logged-in session with at least `viewer` membership on the project.
25
- */
26
- async function canViewBuild(build, project) {
27
- if (isPublicBuild(project, build)) return true;
28
- const { authEnabled } = getStore();
29
- if (!authEnabled) return true;
30
- const role = await currentProjectRole(project.id);
31
- return Boolean(role && VIEW_ROLES.has(role));
32
- }
33
13
  /** Check whether a build's extracted statics are available for serving. */
34
14
  async function staticsReady(projectId, buildId) {
35
15
  if (await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), "manifest.json"))) return true;
36
16
  return await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), "iframe.html"));
37
17
  }
38
- async function readWithManifestFallback(projectId, buildId, rel) {
39
- try {
40
- const manifestPath = posix.join(storybookDir(projectId, buildId), "manifest.json");
41
- if (await getStore().storage.exists(manifestPath)) {
42
- const manifestRaw = await getStore().storage.read(manifestPath);
43
- const hash = JSON.parse(manifestRaw.toString("utf8"))[rel];
44
- if (hash) {
45
- const contentPath = `content/${hash}`;
46
- if (await getStore().storage.exists(contentPath)) return await getStore().storage.read(contentPath);
47
- }
48
- }
49
- } catch {}
50
- const legacyPath = posix.join(storybookDir(projectId, buildId), rel);
51
- if (await getStore().storage.exists(legacyPath)) return await getStore().storage.read(legacyPath);
52
- return null;
53
- }
54
18
  /** Register the published Storybook resolver and static-asset routes. */
55
19
  function registerStorybook(app) {
56
20
  app.get("/_", (c) => c.redirect("/", 302));
@@ -61,7 +25,7 @@ function registerStorybook(app) {
61
25
  if (!project) notFound("Project not found");
62
26
  const build = await new BuildModel(getStore().db).latestApprovedDefault(project);
63
27
  if (!build) notFound("No approved build on the default branch yet");
64
- if (!await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
28
+ if (!await publishedAccess(build, project)) return c.redirect("/auth/login", 302);
65
29
  return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);
66
30
  });
67
31
  app.get("/projects/:slug/storybook/:key/:value", async (c) => {
@@ -74,7 +38,7 @@ function registerStorybook(app) {
74
38
  const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);
75
39
  if (!buildId) notFound("No build carries that label");
76
40
  const build = await new BuildModel(getStore().db).get(buildId);
77
- if (!build || !await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
41
+ if (!build || !await publishedAccess(build, project)) return c.redirect("/auth/login", 302);
78
42
  return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);
79
43
  });
80
44
  app.get("/_/:id", async (c) => {
@@ -87,14 +51,8 @@ function registerStorybook(app) {
87
51
  app.get("/_/:id/*", async (c) => {
88
52
  const id = c.req.param("id");
89
53
  const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);
90
- const base = `/_/${id}/`;
91
- if (!c.req.path.startsWith(base)) return c.notFound();
92
- let rest;
93
- try {
94
- rest = decodeURIComponent(c.req.path.slice(base.length));
95
- } catch {
96
- return c.notFound();
97
- }
54
+ const rest = publishedRelativePath(c.req.path, `/_/${id}/`);
55
+ if (rest === null) return c.notFound();
98
56
  let project = null;
99
57
  let build = null;
100
58
  if (isUlid26) {
@@ -108,45 +66,31 @@ function registerStorybook(app) {
108
66
  build = await new BuildModel(getStore().db).latestApprovedDefault(project);
109
67
  if (!build) return c.notFound();
110
68
  }
111
- if (!await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
112
- if (rest === "") {
113
- if (!await staticsReady(project.id, build.id)) return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);
114
- const buffer = await readWithManifestFallback(project.id, build.id, "index.html");
115
- if (!buffer) return c.redirect(isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`, 302);
116
- const cacheControl = isUlid26 ? "public, max-age=31536000, immutable" : "public, max-age=60";
117
- return c.body(new Uint8Array(buffer), 200, {
118
- "content-type": "text/html; charset=utf-8",
119
- "cache-control": cacheControl
120
- });
121
- }
122
- if (rest.split("/").some((segment) => !isSafeSegment(segment))) return c.notFound();
123
- const buffer = await readWithManifestFallback(project.id, build.id, rest);
124
- if (!buffer) {
125
- if (rest === "index.html") return c.redirect(isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`, 302);
126
- return c.notFound();
127
- }
128
- const cacheControl = isUlid26 ? "public, max-age=31536000, immutable" : "public, max-age=3600";
129
- return c.body(new Uint8Array(buffer), 200, {
130
- "content-type": contentTypeFor(rest),
131
- "cache-control": cacheControl
69
+ const access = await publishedAccess(build, project);
70
+ if (!access) return c.redirect("/auth/login", 302);
71
+ if (rest === "" && !await staticsReady(project.id, build.id)) return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);
72
+ const fallback = isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`;
73
+ const served = await servePublishedFile(c, {
74
+ projectId: project.id,
75
+ buildId: build.id,
76
+ rel: rest === "" ? "index.html" : rest,
77
+ access,
78
+ cacheControl: shortLinkCache(isUlid26, rest === "")
132
79
  });
80
+ if (served) return served;
81
+ return rest === "" || rest === "index.html" ? c.redirect(fallback, 302) : c.notFound();
133
82
  });
134
83
  app.get("/projects/:slug/storybook/build/:buildId/*", async (c) => {
135
84
  const slug = c.req.param("slug");
136
85
  const buildId = c.req.param("buildId");
137
- const base = `/projects/${slug}/storybook/build/${buildId}/`;
138
- if (!c.req.path.startsWith(base)) return c.notFound();
139
- let rest;
140
- try {
141
- rest = decodeURIComponent(c.req.path.slice(base.length));
142
- } catch {
143
- return c.notFound();
144
- }
86
+ const rest = publishedRelativePath(c.req.path, `/projects/${slug}/storybook/build/${buildId}/`);
87
+ if (rest === null) return c.notFound();
145
88
  const project = await new ProjectModel(getStore().db).getBySlug(slug);
146
89
  if (!project) notFound("Project not found");
147
90
  const build = await new BuildModel(getStore().db).get(buildId);
148
91
  if (!build || build.projectId !== project.id) notFound("Build not found");
149
- if (!await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
92
+ const access = await publishedAccess(build, project);
93
+ if (!access) return c.redirect("/auth/login", 302);
150
94
  if (rest === "") {
151
95
  if (!await staticsReady(project.id, build.id)) return c.html(renderStorybookPreparingPage(project, build, slug), 200);
152
96
  const storyId = c.req.query("storyId") ?? c.req.query("id") ?? void 0;
@@ -156,15 +100,20 @@ function registerStorybook(app) {
156
100
  viewMode
157
101
  }), 200);
158
102
  }
159
- if (rest.split("/").some((segment) => !isSafeSegment(segment))) return c.notFound();
160
- const buffer = await readWithManifestFallback(project.id, build.id, rest);
161
- if (!buffer) return c.notFound();
162
- return c.body(new Uint8Array(buffer), 200, {
163
- "content-type": contentTypeFor(rest),
164
- "cache-control": "public, max-age=31536000, immutable"
165
- });
103
+ return await servePublishedFile(c, {
104
+ projectId: project.id,
105
+ buildId: build.id,
106
+ rel: rest,
107
+ access,
108
+ cacheControl: "public, max-age=31536000, immutable"
109
+ }) ?? c.notFound();
166
110
  });
167
111
  }
112
+ /** Cache policy of the short link: a ULID names one build forever; a slug follows the latest. */
113
+ function shortLinkCache(isUlid26, isRoot) {
114
+ if (isUlid26) return "public, max-age=31536000, immutable";
115
+ return isRoot ? "public, max-age=60" : "public, max-age=3600";
116
+ }
168
117
  //#endregion
169
118
  export { registerStorybook };
170
119
 
@@ -1 +1 @@
1
- {"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/routers/storybook.ts"],"sourcesContent":["import { isPublicBuild, BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { isSafeSegment, mimeFor, storybookDir } from \"@storyshelf/core/utils\";\nimport { posix } from \"node:path\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderStorybookPage, renderStorybookPreparingPage } from \"../pages/storybook.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { currentProjectRole, notFound } from \"./helpers.ts\";\nconst VIEW_ROLES: ReadonlySet<string> = new Set([\"viewer\", \"developer\", \"approver\", \"admin\"]);\n\nfunction contentTypeFor(path: string): string {\n return mimeFor(path);\n}\n\n/**\n * Decide whether the current request may view `build`'s published Storybook.\n *\n * Public builds (ADR 0011) are viewable without auth; every other build requires\n * a logged-in session with at least `viewer` membership on the project.\n */\nasync function canViewBuild(\n build: Pick<Build, \"public\" | \"gitBranch\">,\n project: Project,\n): Promise<boolean> {\n if (isPublicBuild(project, build)) {\n return true;\n }\n const { authEnabled } = getStore();\n if (!authEnabled) {\n return true;\n }\n const role = await currentProjectRole(project.id);\n return Boolean(role && VIEW_ROLES.has(role));\n}\n\n/** Check whether a build's extracted statics are available for serving. */\nasync function staticsReady(projectId: string, buildId: string): Promise<boolean> {\n // New path: manifest.json indicates content-addressed storage; fallback to legacy iframe.html\n if (\n await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), \"manifest.json\"))\n ) {\n return true;\n }\n return await getStore().storage.exists(\n posix.join(storybookDir(projectId, buildId), \"iframe.html\"),\n );\n}\n\nasync function readWithManifestFallback(\n projectId: string,\n buildId: string,\n rel: string,\n): Promise<Buffer | null> {\n // Try manifest indirection first (content-addressed)\n try {\n const manifestPath = posix.join(storybookDir(projectId, buildId), \"manifest.json\");\n if (await getStore().storage.exists(manifestPath)) {\n const manifestRaw = await getStore().storage.read(manifestPath);\n const manifest = JSON.parse(manifestRaw.toString(\"utf8\")) as Record<string, string>;\n const hash = manifest[rel];\n if (hash) {\n const contentPath = `content/${hash}`;\n if (await getStore().storage.exists(contentPath)) {\n return await getStore().storage.read(contentPath);\n }\n }\n }\n } catch {\n // ignore — manifest may not exist for old builds\n }\n // Fallback to legacy per-build path\n const legacyPath = posix.join(storybookDir(projectId, buildId), rel);\n if (await getStore().storage.exists(legacyPath)) {\n return await getStore().storage.read(legacyPath);\n }\n return null;\n}\n\n/** Register the published Storybook resolver and static-asset routes. */\nexport function registerStorybook(app: ShelfRouter): void {\n app.get(\"/_\", (c) => c.redirect(\"/\", 302));\n app.get(\"/_/\", (c) => c.redirect(\"/\", 302));\n\n // Resolver: latest approved build on the default branch (never feature branches).\n app.get(\"/projects/:slug/storybook\", async (c) => {\n const slug = c.req.param(\"slug\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) {\n notFound(\"No approved build on the default branch yet\");\n }\n if (!(await canViewBuild(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);\n });\n\n // Resolver: latest build bearing a label (`build` is a reserved label key).\n app.get(\"/projects/:slug/storybook/:key/:value\", async (c) => {\n const slug = c.req.param(\"slug\");\n const key = c.req.param(\"key\");\n if (key === \"build\") {\n return c.notFound();\n }\n const value = c.req.param(\"value\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);\n if (!buildId) {\n notFound(\"No build carries that label\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || !(await canViewBuild(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);\n });\n\n // Short link canonical is /_/:id/ with trailing slash (so relative ./sb-manager/... resolves).\n // 26-char ULID → that exact build, whatever its status (visibility check only).\n // Any other id → project slug → latest approved build on the default branch, else 404\n // (no fallback to feature-branch or unreviewed builds). No fall-through on ULID.\n app.get(\"/_/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n if (isUlid26) {\n const build = await new BuildModel(getStore().db).get(id);\n if (!build) notFound();\n } else {\n const project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) notFound();\n }\n return c.redirect(`/_/${id}/`, 302);\n });\n\n app.get(\"/_/:id/*\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n const base = `/_/${id}/`;\n if (!c.req.path.startsWith(base)) return c.notFound();\n let rest: string;\n try {\n rest = decodeURIComponent(c.req.path.slice(base.length));\n } catch {\n return c.notFound();\n }\n let project: Project | null = null;\n let build: Build | null = null;\n if (isUlid26) {\n build = await new BuildModel(getStore().db).get(id);\n if (!build) return c.notFound();\n project = await new ProjectModel(getStore().db).get(build.projectId);\n if (!project) return c.notFound();\n } else {\n project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) return c.notFound();\n build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) return c.notFound();\n }\n if (!(await canViewBuild(build, project))) return c.redirect(\"/auth/login\", 302);\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);\n }\n const buffer = await readWithManifestFallback(project.id, build.id, \"index.html\");\n if (!buffer) {\n return c.redirect(\n isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`,\n 302,\n );\n }\n const cacheControl = isUlid26 ? \"public, max-age=31536000, immutable\" : \"public, max-age=60\";\n return c.body(new Uint8Array(buffer), 200, {\n \"content-type\": \"text/html; charset=utf-8\",\n \"cache-control\": cacheControl,\n });\n }\n const segments = rest.split(\"/\");\n if (segments.some((segment) => !isSafeSegment(segment))) return c.notFound();\n const buffer = await readWithManifestFallback(project.id, build.id, rest);\n if (!buffer) {\n if (rest === \"index.html\") {\n return c.redirect(\n isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`,\n 302,\n );\n }\n return c.notFound();\n }\n const cacheControl = isUlid26 ? \"public, max-age=31536000, immutable\" : \"public, max-age=3600\";\n return c.body(new Uint8Array(buffer), 200, {\n \"content-type\": contentTypeFor(rest),\n \"cache-control\": cacheControl,\n });\n });\n\n // Canonical build route: serves the static Storybook and the landing page.\n app.get(\"/projects/:slug/storybook/build/:buildId/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const buildId = c.req.param(\"buildId\");\n // Hono's bare `/*` wildcard is not exposed through `param()`, so derive the\n // remainder from the raw request path (un-encoded) and decode it ourselves.\n const base = `/projects/${slug}/storybook/build/${buildId}/`;\n if (!c.req.path.startsWith(base)) {\n return c.notFound();\n }\n let rest: string;\n try {\n rest = decodeURIComponent(c.req.path.slice(base.length));\n } catch {\n return c.notFound();\n }\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== project.id) {\n notFound(\"Build not found\");\n }\n if (!(await canViewBuild(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, slug), 200);\n }\n const storyId = c.req.query(\"storyId\") ?? c.req.query(\"id\") ?? undefined;\n const viewMode = c.req.query(\"viewMode\") === \"docs\" ? \"docs\" : undefined;\n return c.html(renderStorybookPage(project, build, slug, { storyId, viewMode }), 200);\n }\n\n const segments = rest.split(\"/\");\n if (segments.some((segment) => !isSafeSegment(segment))) {\n return c.notFound();\n }\n const buffer = await readWithManifestFallback(project.id, build.id, rest);\n if (!buffer) return c.notFound();\n return c.body(new Uint8Array(buffer), 200, {\n \"content-type\": contentTypeFor(rest),\n \"cache-control\": \"public, max-age=31536000, immutable\",\n });\n });\n}\n"],"mappings":";;;;;;;;;;AAWA,MAAM,6BAAkC,IAAI,IAAI;CAAC;CAAU;CAAa;CAAY;AAAO,CAAC;AAE5F,SAAS,eAAe,MAAsB;CAC5C,OAAO,QAAQ,IAAI;AACrB;;;;;;;AAQA,eAAe,aACb,OACA,SACkB;CAClB,IAAI,cAAc,SAAS,KAAK,GAC9B,OAAO;CAET,MAAM,EAAE,gBAAgB,SAAS;CACjC,IAAI,CAAC,aACH,OAAO;CAET,MAAM,OAAO,MAAM,mBAAmB,QAAQ,EAAE;CAChD,OAAO,QAAQ,QAAQ,WAAW,IAAI,IAAI,CAAC;AAC7C;;AAGA,eAAe,aAAa,WAAmB,SAAmC;CAEhF,IACE,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,MAAM,KAAK,aAAa,WAAW,OAAO,GAAG,eAAe,CAAC,GAE7F,OAAO;CAET,OAAO,MAAM,SAAS,CAAC,CAAC,QAAQ,OAC9B,MAAM,KAAK,aAAa,WAAW,OAAO,GAAG,aAAa,CAC5D;AACF;AAEA,eAAe,yBACb,WACA,SACA,KACwB;CAExB,IAAI;EACF,MAAM,eAAe,MAAM,KAAK,aAAa,WAAW,OAAO,GAAG,eAAe;EACjF,IAAI,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,YAAY,GAAG;GACjD,MAAM,cAAc,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,YAAY;GAE9D,MAAM,OADW,KAAK,MAAM,YAAY,SAAS,MAAM,CACnC,CAAC,CAAC;GACtB,IAAI,MAAM;IACR,MAAM,cAAc,WAAW;IAC/B,IAAI,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,WAAW,GAC7C,OAAO,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,WAAW;GAEpD;EACF;CACF,QAAQ,CAER;CAEA,MAAM,aAAa,MAAM,KAAK,aAAa,WAAW,OAAO,GAAG,GAAG;CACnE,IAAI,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,UAAU,GAC5C,OAAO,MAAM,SAAS,CAAC,CAAC,QAAQ,KAAK,UAAU;CAEjD,OAAO;AACT;;AAGA,SAAgB,kBAAkB,KAAwB;CACxD,IAAI,IAAI,OAAO,MAAM,EAAE,SAAS,KAAK,GAAG,CAAC;CACzC,IAAI,IAAI,QAAQ,MAAM,EAAE,SAAS,KAAK,GAAG,CAAC;CAG1C,IAAI,IAAI,6BAA6B,OAAO,MAAM;EAChD,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,IAAI;EACpE,IAAI,CAAC,SACH,SAAS,mBAAmB;EAE9B,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,sBAAsB,OAAO;EAC/E,IAAI,CAAC,OACH,SAAS,6CAA6C;EAExD,IAAI,CAAE,MAAM,aAAa,OAAO,OAAO,GACrC,OAAO,EAAE,SAAS,eAAe,GAAG;EAEtC,OAAO,EAAE,SAAS,aAAa,KAAK,mBAAmB,MAAM,GAAG,IAAI,GAAG;CACzE,CAAC;CAGD,IAAI,IAAI,yCAAyC,OAAO,MAAM;EAC5D,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,MAAM,EAAE,IAAI,MAAM,KAAK;EAC7B,IAAI,QAAQ,SACV,OAAO,EAAE,SAAS;EAEpB,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO;EACjC,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,IAAI;EACpE,IAAI,CAAC,SACH,SAAS,mBAAmB;EAE9B,MAAM,UAAU,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,cAAc,QAAQ,IAAI,KAAK,KAAK;EACxF,IAAI,CAAC,SACH,SAAS,6BAA6B;EAExC,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,OAAO;EAC7D,IAAI,CAAC,SAAS,CAAE,MAAM,aAAa,OAAO,OAAO,GAC/C,OAAO,EAAE,SAAS,eAAe,GAAG;EAEtC,OAAO,EAAE,SAAS,aAAa,KAAK,mBAAmB,QAAQ,IAAI,GAAG;CACxE,CAAC;CAMD,IAAI,IAAI,UAAU,OAAO,MAAM;EAC7B,MAAM,KAAK,EAAE,IAAI,MAAM,IAAI;EAE3B,IADiB,GAAG,WAAW,MAAM,iCAAiC,KAAK,EAAE,GAGvE;OAAA,CAAC,MADe,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,GAC5C,SAAS;EAAA,OAGrB,IAAI,CAAC,MADiB,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,EAAE,GACpD,SAAS;EAEzB,OAAO,EAAE,SAAS,MAAM,GAAG,IAAI,GAAG;CACpC,CAAC;CAED,IAAI,IAAI,YAAY,OAAO,MAAM;EAC/B,MAAM,KAAK,EAAE,IAAI,MAAM,IAAI;EAC3B,MAAM,WAAW,GAAG,WAAW,MAAM,iCAAiC,KAAK,EAAE;EAC7E,MAAM,OAAO,MAAM,GAAG;EACtB,IAAI,CAAC,EAAE,IAAI,KAAK,WAAW,IAAI,GAAG,OAAO,EAAE,SAAS;EACpD,IAAI;EACJ,IAAI;GACF,OAAO,mBAAmB,EAAE,IAAI,KAAK,MAAM,KAAK,MAAM,CAAC;EACzD,QAAQ;GACN,OAAO,EAAE,SAAS;EACpB;EACA,IAAI,UAA0B;EAC9B,IAAI,QAAsB;EAC1B,IAAI,UAAU;GACZ,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE;GAClD,IAAI,CAAC,OAAO,OAAO,EAAE,SAAS;GAC9B,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,SAAS;GACnE,IAAI,CAAC,SAAS,OAAO,EAAE,SAAS;EAClC,OAAO;GACL,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,EAAE;GAC5D,IAAI,CAAC,SAAS,OAAO,EAAE,SAAS;GAChC,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,sBAAsB,OAAO;GACzE,IAAI,CAAC,OAAO,OAAO,EAAE,SAAS;EAChC;EACA,IAAI,CAAE,MAAM,aAAa,OAAO,OAAO,GAAI,OAAO,EAAE,SAAS,eAAe,GAAG;EAC/E,IAAI,SAAS,IAAI;GACf,IAAI,CAAE,MAAM,aAAa,QAAQ,IAAI,MAAM,EAAE,GAC3C,OAAO,EAAE,KAAK,6BAA6B,SAAS,OAAO,QAAQ,IAAI,GAAG,GAAG;GAE/E,MAAM,SAAS,MAAM,yBAAyB,QAAQ,IAAI,MAAM,IAAI,YAAY;GAChF,IAAI,CAAC,QACH,OAAO,EAAE,SACP,WAAW,aAAa,QAAQ,KAAK,UAAU,MAAM,OAAO,aAAa,QAAQ,QACjF,GACF;GAEF,MAAM,eAAe,WAAW,wCAAwC;GACxE,OAAO,EAAE,KAAK,IAAI,WAAW,MAAM,GAAG,KAAK;IACzC,gBAAgB;IAChB,iBAAiB;GACnB,CAAC;EACH;EAEA,IADiB,KAAK,MAAM,GACjB,CAAC,CAAC,MAAM,YAAY,CAAC,cAAc,OAAO,CAAC,GAAG,OAAO,EAAE,SAAS;EAC3E,MAAM,SAAS,MAAM,yBAAyB,QAAQ,IAAI,MAAM,IAAI,IAAI;EACxE,IAAI,CAAC,QAAQ;GACX,IAAI,SAAS,cACX,OAAO,EAAE,SACP,WAAW,aAAa,QAAQ,KAAK,UAAU,MAAM,OAAO,aAAa,QAAQ,QACjF,GACF;GAEF,OAAO,EAAE,SAAS;EACpB;EACA,MAAM,eAAe,WAAW,wCAAwC;EACxE,OAAO,EAAE,KAAK,IAAI,WAAW,MAAM,GAAG,KAAK;GACzC,gBAAgB,eAAe,IAAI;GACnC,iBAAiB;EACnB,CAAC;CACH,CAAC;CAGD,IAAI,IAAI,8CAA8C,OAAO,MAAM;EACjE,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,UAAU,EAAE,IAAI,MAAM,SAAS;EAGrC,MAAM,OAAO,aAAa,KAAK,mBAAmB,QAAQ;EAC1D,IAAI,CAAC,EAAE,IAAI,KAAK,WAAW,IAAI,GAC7B,OAAO,EAAE,SAAS;EAEpB,IAAI;EACJ,IAAI;GACF,OAAO,mBAAmB,EAAE,IAAI,KAAK,MAAM,KAAK,MAAM,CAAC;EACzD,QAAQ;GACN,OAAO,EAAE,SAAS;EACpB;EACA,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,IAAI;EACpE,IAAI,CAAC,SACH,SAAS,mBAAmB;EAE9B,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,OAAO;EAC7D,IAAI,CAAC,SAAS,MAAM,cAAc,QAAQ,IACxC,SAAS,iBAAiB;EAE5B,IAAI,CAAE,MAAM,aAAa,OAAO,OAAO,GACrC,OAAO,EAAE,SAAS,eAAe,GAAG;EAGtC,IAAI,SAAS,IAAI;GACf,IAAI,CAAE,MAAM,aAAa,QAAQ,IAAI,MAAM,EAAE,GAC3C,OAAO,EAAE,KAAK,6BAA6B,SAAS,OAAO,IAAI,GAAG,GAAG;GAEvE,MAAM,UAAU,EAAE,IAAI,MAAM,SAAS,KAAK,EAAE,IAAI,MAAM,IAAI,KAAK,KAAA;GAC/D,MAAM,WAAW,EAAE,IAAI,MAAM,UAAU,MAAM,SAAS,SAAS,KAAA;GAC/D,OAAO,EAAE,KAAK,oBAAoB,SAAS,OAAO,MAAM;IAAE;IAAS;GAAS,CAAC,GAAG,GAAG;EACrF;EAGA,IADiB,KAAK,MAAM,GACjB,CAAC,CAAC,MAAM,YAAY,CAAC,cAAc,OAAO,CAAC,GACpD,OAAO,EAAE,SAAS;EAEpB,MAAM,SAAS,MAAM,yBAAyB,QAAQ,IAAI,MAAM,IAAI,IAAI;EACxE,IAAI,CAAC,QAAQ,OAAO,EAAE,SAAS;EAC/B,OAAO,EAAE,KAAK,IAAI,WAAW,MAAM,GAAG,KAAK;GACzC,gBAAgB,eAAe,IAAI;GACnC,iBAAiB;EACnB,CAAC;CACH,CAAC;AACH"}
1
+ {"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/routers/storybook.ts"],"sourcesContent":["import { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { storybookDir } from \"@storyshelf/core/utils\";\nimport { posix } from \"node:path\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderStorybookPage, renderStorybookPreparingPage } from \"../pages/storybook.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { publishedAccess } from \"./published-access.ts\";\nimport { publishedRelativePath, servePublishedFile } from \"./published-files.ts\";\n\n/** Check whether a build's extracted statics are available for serving. */\nasync function staticsReady(projectId: string, buildId: string): Promise<boolean> {\n // New path: manifest.json indicates content-addressed storage; fallback to legacy iframe.html\n if (\n await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), \"manifest.json\"))\n ) {\n return true;\n }\n return await getStore().storage.exists(\n posix.join(storybookDir(projectId, buildId), \"iframe.html\"),\n );\n}\n\n/** Register the published Storybook resolver and static-asset routes. */\nexport function registerStorybook(app: ShelfRouter): void {\n app.get(\"/_\", (c) => c.redirect(\"/\", 302));\n app.get(\"/_/\", (c) => c.redirect(\"/\", 302));\n\n // Resolver: latest approved build on the default branch (never feature branches).\n app.get(\"/projects/:slug/storybook\", async (c) => {\n const slug = c.req.param(\"slug\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) {\n notFound(\"No approved build on the default branch yet\");\n }\n if (!(await publishedAccess(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);\n });\n\n // Resolver: latest build bearing a label (`build` is a reserved label key).\n app.get(\"/projects/:slug/storybook/:key/:value\", async (c) => {\n const slug = c.req.param(\"slug\");\n const key = c.req.param(\"key\");\n if (key === \"build\") {\n return c.notFound();\n }\n const value = c.req.param(\"value\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);\n if (!buildId) {\n notFound(\"No build carries that label\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || !(await publishedAccess(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);\n });\n\n // Short link canonical is /_/:id/ with trailing slash (so relative ./sb-manager/... resolves).\n // 26-char ULID → that exact build, whatever its status (visibility check only).\n // Any other id → project slug → latest approved build on the default branch, else 404\n // (no fallback to feature-branch or unreviewed builds). No fall-through on ULID.\n app.get(\"/_/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n if (isUlid26) {\n const build = await new BuildModel(getStore().db).get(id);\n if (!build) notFound();\n } else {\n const project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) notFound();\n }\n return c.redirect(`/_/${id}/`, 302);\n });\n\n app.get(\"/_/:id/*\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n const rest = publishedRelativePath(c.req.path, `/_/${id}/`);\n if (rest === null) return c.notFound();\n let project: Project | null = null;\n let build: Build | null = null;\n if (isUlid26) {\n build = await new BuildModel(getStore().db).get(id);\n if (!build) return c.notFound();\n project = await new ProjectModel(getStore().db).get(build.projectId);\n if (!project) return c.notFound();\n } else {\n project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) return c.notFound();\n build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) return c.notFound();\n }\n const access = await publishedAccess(build, project);\n if (!access) return c.redirect(\"/auth/login\", 302);\n if (rest === \"\" && !(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);\n }\n const fallback = isUlid26\n ? `/projects/${project.slug}/builds/${build.id}`\n : `/projects/${project.slug}`;\n const served = await servePublishedFile(c, {\n projectId: project.id,\n buildId: build.id,\n rel: rest === \"\" ? \"index.html\" : rest,\n access,\n cacheControl: shortLinkCache(isUlid26, rest === \"\"),\n });\n if (served) return served;\n return rest === \"\" || rest === \"index.html\" ? c.redirect(fallback, 302) : c.notFound();\n });\n\n // Canonical build route: serves the static Storybook and the landing page.\n app.get(\"/projects/:slug/storybook/build/:buildId/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const buildId = c.req.param(\"buildId\");\n // Hono's bare `/*` wildcard is not exposed through `param()`, so derive the\n // remainder from the raw request path (un-encoded) and decode it ourselves.\n const rest = publishedRelativePath(c.req.path, `/projects/${slug}/storybook/build/${buildId}/`);\n if (rest === null) {\n return c.notFound();\n }\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== project.id) {\n notFound(\"Build not found\");\n }\n const access = await publishedAccess(build, project);\n if (!access) {\n return c.redirect(\"/auth/login\", 302);\n }\n\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, slug), 200);\n }\n const storyId = c.req.query(\"storyId\") ?? c.req.query(\"id\") ?? undefined;\n const viewMode = c.req.query(\"viewMode\") === \"docs\" ? \"docs\" : undefined;\n return c.html(renderStorybookPage(project, build, slug, { storyId, viewMode }), 200);\n }\n\n return (\n (await servePublishedFile(c, {\n projectId: project.id,\n buildId: build.id,\n rel: rest,\n access,\n cacheControl: \"public, max-age=31536000, immutable\",\n })) ?? c.notFound()\n );\n });\n}\n\n/** Cache policy of the short link: a ULID names one build forever; a slug follows the latest. */\nfunction shortLinkCache(isUlid26: boolean, isRoot: boolean): string {\n if (isUlid26) {\n return \"public, max-age=31536000, immutable\";\n }\n return isRoot ? \"public, max-age=60\" : \"public, max-age=3600\";\n}\n"],"mappings":"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"}