@storyshelf/app 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/git-status.mjs +46 -0
  26. package/dist/git-status.mjs.map +1 -0
  27. package/dist/hidden-data.mjs +24 -0
  28. package/dist/hidden-data.mjs.map +1 -0
  29. package/dist/index.d.mts +3 -3
  30. package/dist/index.mjs +203 -125
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/insights/budget.mjs +49 -0
  33. package/dist/insights/budget.mjs.map +1 -0
  34. package/dist/insights/deps.mjs +21 -0
  35. package/dist/insights/deps.mjs.map +1 -0
  36. package/dist/insights/errors.mjs +22 -0
  37. package/dist/insights/errors.mjs.map +1 -0
  38. package/dist/insights/health-panel.mjs +40 -0
  39. package/dist/insights/health-panel.mjs.map +1 -0
  40. package/dist/insights/job.mjs +103 -0
  41. package/dist/insights/job.mjs.map +1 -0
  42. package/dist/insights/load-health.mjs +59 -0
  43. package/dist/insights/load-health.mjs.map +1 -0
  44. package/dist/insights/load-triage.mjs +98 -0
  45. package/dist/insights/load-triage.mjs.map +1 -0
  46. package/dist/insights/panel.mjs +28 -0
  47. package/dist/insights/panel.mjs.map +1 -0
  48. package/dist/insights/request.mjs +113 -0
  49. package/dist/insights/request.mjs.map +1 -0
  50. package/dist/insights/stale.mjs +21 -0
  51. package/dist/insights/stale.mjs.map +1 -0
  52. package/dist/insights/usage-report.mjs +60 -0
  53. package/dist/insights/usage-report.mjs.map +1 -0
  54. package/dist/insights/view.mjs +48 -0
  55. package/dist/insights/view.mjs.map +1 -0
  56. package/dist/job-canceller.mjs +14 -0
  57. package/dist/job-canceller.mjs.map +1 -0
  58. package/dist/job-queue.mjs +51 -0
  59. package/dist/job-queue.mjs.map +1 -0
  60. package/dist/jobs-setup.mjs +132 -0
  61. package/dist/jobs-setup.mjs.map +1 -0
  62. package/dist/lifecycle.mjs +91 -19
  63. package/dist/lifecycle.mjs.map +1 -1
  64. package/dist/middleware/auth-gate.mjs +4 -2
  65. package/dist/middleware/auth-gate.mjs.map +1 -1
  66. package/dist/middleware/rate-limit.mjs +41 -33
  67. package/dist/middleware/rate-limit.mjs.map +1 -1
  68. package/dist/middleware/request-log.mjs +3 -2
  69. package/dist/middleware/request-log.mjs.map +1 -1
  70. package/dist/middleware/require-review.mjs +51 -0
  71. package/dist/middleware/require-review.mjs.map +1 -0
  72. package/dist/middleware/require-testing.mjs +41 -0
  73. package/dist/middleware/require-testing.mjs.map +1 -0
  74. package/dist/middleware/setup-gate.mjs.map +1 -1
  75. package/dist/middleware/store-scope.mjs +12 -2
  76. package/dist/middleware/store-scope.mjs.map +1 -1
  77. package/dist/notifications-pause.mjs +33 -0
  78. package/dist/notifications-pause.mjs.map +1 -0
  79. package/dist/notify.d.mts +2 -1
  80. package/dist/notify.mjs +78 -39
  81. package/dist/notify.mjs.map +1 -1
  82. package/dist/pages/a11y-violations.mjs +116 -0
  83. package/dist/pages/a11y-violations.mjs.map +1 -0
  84. package/dist/pages/admin-ai.mjs +78 -0
  85. package/dist/pages/admin-ai.mjs.map +1 -0
  86. package/dist/pages/admin-system-origin.mjs +123 -0
  87. package/dist/pages/admin-system-origin.mjs.map +1 -0
  88. package/dist/pages/admin-system-targets.mjs +53 -0
  89. package/dist/pages/admin-system-targets.mjs.map +1 -0
  90. package/dist/pages/admin-system.mjs +63 -13
  91. package/dist/pages/admin-system.mjs.map +1 -1
  92. package/dist/pages/build-detail.mjs +104 -76
  93. package/dist/pages/build-detail.mjs.map +1 -1
  94. package/dist/pages/build-diff-actions.mjs +87 -33
  95. package/dist/pages/build-diff-actions.mjs.map +1 -1
  96. package/dist/pages/build-diff-comments.mjs +16 -14
  97. package/dist/pages/build-diff-comments.mjs.map +1 -1
  98. package/dist/pages/build-diff-environment.mjs +82 -0
  99. package/dist/pages/build-diff-environment.mjs.map +1 -0
  100. package/dist/pages/build-diff-header.mjs +22 -4
  101. package/dist/pages/build-diff-header.mjs.map +1 -1
  102. package/dist/pages/build-diff-insight.mjs +113 -0
  103. package/dist/pages/build-diff-insight.mjs.map +1 -0
  104. package/dist/pages/build-diff-model.mjs +55 -10
  105. package/dist/pages/build-diff-model.mjs.map +1 -1
  106. package/dist/pages/build-diff-nav.mjs +28 -12
  107. package/dist/pages/build-diff-nav.mjs.map +1 -1
  108. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  109. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  110. package/dist/pages/build-diff-usage.mjs +63 -0
  111. package/dist/pages/build-diff-usage.mjs.map +1 -0
  112. package/dist/pages/build-diff-viewer.mjs +62 -5
  113. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  114. package/dist/pages/build-diff.mjs +34 -13
  115. package/dist/pages/build-diff.mjs.map +1 -1
  116. package/dist/pages/build-tests.mjs +74 -0
  117. package/dist/pages/build-tests.mjs.map +1 -0
  118. package/dist/pages/component-detail.mjs +249 -0
  119. package/dist/pages/component-detail.mjs.map +1 -0
  120. package/dist/pages/component-stories.mjs +168 -0
  121. package/dist/pages/component-stories.mjs.map +1 -0
  122. package/dist/pages/compute-jobs.mjs +13 -26
  123. package/dist/pages/compute-jobs.mjs.map +1 -1
  124. package/dist/pages/design-links.mjs +182 -0
  125. package/dist/pages/design-links.mjs.map +1 -0
  126. package/dist/pages/environment-change.mjs +53 -0
  127. package/dist/pages/environment-change.mjs.map +1 -0
  128. package/dist/pages/health-panel.mjs +126 -0
  129. package/dist/pages/health-panel.mjs.map +1 -0
  130. package/dist/pages/library-grid.mjs +105 -0
  131. package/dist/pages/library-grid.mjs.map +1 -0
  132. package/dist/pages/library-model.mjs +47 -0
  133. package/dist/pages/library-model.mjs.map +1 -0
  134. package/dist/pages/library-snapshots.mjs +130 -0
  135. package/dist/pages/library-snapshots.mjs.map +1 -0
  136. package/dist/pages/library.mjs +112 -218
  137. package/dist/pages/library.mjs.map +1 -1
  138. package/dist/pages/notifications-paused.mjs +22 -0
  139. package/dist/pages/notifications-paused.mjs.map +1 -0
  140. package/dist/pages/profile.mjs +53 -48
  141. package/dist/pages/profile.mjs.map +1 -1
  142. package/dist/pages/project-builds-table.mjs +2 -2
  143. package/dist/pages/project-builds-table.mjs.map +1 -1
  144. package/dist/pages/project-builds.mjs +51 -21
  145. package/dist/pages/project-builds.mjs.map +1 -1
  146. package/dist/pages/project-settings.mjs +51 -12
  147. package/dist/pages/project-settings.mjs.map +1 -1
  148. package/dist/pages/projects-card.mjs +14 -1
  149. package/dist/pages/projects-card.mjs.map +1 -1
  150. package/dist/pages/projects.mjs +6 -2
  151. package/dist/pages/projects.mjs.map +1 -1
  152. package/dist/pages/review-changeset.mjs +116 -0
  153. package/dist/pages/review-changeset.mjs.map +1 -0
  154. package/dist/pages/review-comments.mjs +103 -0
  155. package/dist/pages/review-comments.mjs.map +1 -0
  156. package/dist/pages/review-verdict.mjs +102 -0
  157. package/dist/pages/review-verdict.mjs.map +1 -0
  158. package/dist/pages/settings-ai.mjs +69 -0
  159. package/dist/pages/settings-ai.mjs.map +1 -0
  160. package/dist/pages/settings-capture.mjs +124 -0
  161. package/dist/pages/settings-capture.mjs.map +1 -0
  162. package/dist/pages/settings-design-credential.mjs +99 -0
  163. package/dist/pages/settings-design-credential.mjs.map +1 -0
  164. package/dist/pages/settings-design-links.mjs +179 -0
  165. package/dist/pages/settings-design-links.mjs.map +1 -0
  166. package/dist/pages/settings-design.mjs +48 -0
  167. package/dist/pages/settings-design.mjs.map +1 -0
  168. package/dist/pages/settings-general.mjs +46 -21
  169. package/dist/pages/settings-general.mjs.map +1 -1
  170. package/dist/pages/settings-labels.mjs +43 -2
  171. package/dist/pages/settings-labels.mjs.map +1 -1
  172. package/dist/pages/settings-links.mjs +112 -0
  173. package/dist/pages/settings-links.mjs.map +1 -0
  174. package/dist/pages/settings-notifications-git.mjs +128 -0
  175. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  176. package/dist/pages/settings-notifications.mjs +79 -42
  177. package/dist/pages/settings-notifications.mjs.map +1 -1
  178. package/dist/pages/settings-tests.mjs +33 -2
  179. package/dist/pages/settings-tests.mjs.map +1 -1
  180. package/dist/pages/target-choices.mjs +39 -0
  181. package/dist/pages/target-choices.mjs.map +1 -0
  182. package/dist/publish-setup.mjs +89 -0
  183. package/dist/publish-setup.mjs.map +1 -0
  184. package/dist/retention-timer.mjs +2 -1
  185. package/dist/retention-timer.mjs.map +1 -1
  186. package/dist/review-events.mjs +48 -0
  187. package/dist/review-events.mjs.map +1 -0
  188. package/dist/routers/admin-pages.mjs +45 -3
  189. package/dist/routers/admin-pages.mjs.map +1 -1
  190. package/dist/routers/admin-public-origin.mjs +85 -0
  191. package/dist/routers/admin-public-origin.mjs.map +1 -0
  192. package/dist/routers/assets.mjs +9 -2
  193. package/dist/routers/assets.mjs.map +1 -1
  194. package/dist/routers/auth.mjs +1 -1
  195. package/dist/routers/baseline-guard.mjs +2 -2
  196. package/dist/routers/baseline-guard.mjs.map +1 -1
  197. package/dist/routers/baselines.handlers.mjs +64 -0
  198. package/dist/routers/baselines.handlers.mjs.map +1 -0
  199. package/dist/routers/baselines.mjs +125 -0
  200. package/dist/routers/baselines.mjs.map +1 -0
  201. package/dist/routers/build-cancel.mjs +68 -0
  202. package/dist/routers/build-cancel.mjs.map +1 -0
  203. package/dist/routers/build-changeset.mjs +70 -0
  204. package/dist/routers/build-changeset.mjs.map +1 -0
  205. package/dist/routers/build-reviews.handlers.mjs +86 -0
  206. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  207. package/dist/routers/build-reviews.mjs +74 -0
  208. package/dist/routers/build-reviews.mjs.map +1 -0
  209. package/dist/routers/builds.handlers.mjs +46 -55
  210. package/dist/routers/builds.handlers.mjs.map +1 -1
  211. package/dist/routers/builds.mjs +71 -17
  212. package/dist/routers/builds.mjs.map +1 -1
  213. package/dist/routers/capture-config.mjs +35 -0
  214. package/dist/routers/capture-config.mjs.map +1 -0
  215. package/dist/routers/comments.mjs +41 -15
  216. package/dist/routers/comments.mjs.map +1 -1
  217. package/dist/routers/component-catalog.handlers.mjs +71 -0
  218. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  219. package/dist/routers/component.handlers.mjs +121 -0
  220. package/dist/routers/component.handlers.mjs.map +1 -0
  221. package/dist/routers/connections.mjs +118 -0
  222. package/dist/routers/connections.mjs.map +1 -0
  223. package/dist/routers/design-connect.handlers.mjs +44 -0
  224. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  225. package/dist/routers/design.handlers.mjs +114 -0
  226. package/dist/routers/design.handlers.mjs.map +1 -0
  227. package/dist/routers/health-report.mjs +2 -3
  228. package/dist/routers/health-report.mjs.map +1 -1
  229. package/dist/routers/health.mjs +4 -3
  230. package/dist/routers/health.mjs.map +1 -1
  231. package/dist/routers/helpers.mjs +19 -3
  232. package/dist/routers/helpers.mjs.map +1 -1
  233. package/dist/routers/insights-ui.mjs +64 -0
  234. package/dist/routers/insights-ui.mjs.map +1 -0
  235. package/dist/routers/insights.handlers.mjs +45 -0
  236. package/dist/routers/insights.handlers.mjs.map +1 -0
  237. package/dist/routers/insights.mjs +189 -0
  238. package/dist/routers/insights.mjs.map +1 -0
  239. package/dist/routers/links.handlers.mjs +80 -0
  240. package/dist/routers/links.handlers.mjs.map +1 -0
  241. package/dist/routers/links.mjs +139 -0
  242. package/dist/routers/links.mjs.map +1 -0
  243. package/dist/routers/media.mjs +18 -4
  244. package/dist/routers/media.mjs.map +1 -1
  245. package/dist/routers/members.mjs +2 -1
  246. package/dist/routers/members.mjs.map +1 -1
  247. package/dist/routers/notification-channels.mjs +17 -23
  248. package/dist/routers/notification-channels.mjs.map +1 -1
  249. package/dist/routers/oauth-state.mjs +45 -0
  250. package/dist/routers/oauth-state.mjs.map +1 -0
  251. package/dist/routers/preview-base.mjs +38 -0
  252. package/dist/routers/preview-base.mjs.map +1 -0
  253. package/dist/routers/preview-token.mjs +51 -0
  254. package/dist/routers/preview-token.mjs.map +1 -0
  255. package/dist/routers/profile.mjs +3 -1
  256. package/dist/routers/profile.mjs.map +1 -1
  257. package/dist/routers/projects.mjs +17 -1
  258. package/dist/routers/projects.mjs.map +1 -1
  259. package/dist/routers/publish-upload.mjs +78 -0
  260. package/dist/routers/publish-upload.mjs.map +1 -0
  261. package/dist/routers/published-access.mjs +52 -0
  262. package/dist/routers/published-access.mjs.map +1 -0
  263. package/dist/routers/published-files.mjs +52 -0
  264. package/dist/routers/published-files.mjs.map +1 -0
  265. package/dist/routers/review-forms.mjs +72 -0
  266. package/dist/routers/review-forms.mjs.map +1 -0
  267. package/dist/routers/review-section.handlers.mjs +85 -0
  268. package/dist/routers/review-section.handlers.mjs.map +1 -0
  269. package/dist/routers/review-switch-off.mjs +29 -0
  270. package/dist/routers/review-switch-off.mjs.map +1 -0
  271. package/dist/routers/schemas.mjs +140 -22
  272. package/dist/routers/schemas.mjs.map +1 -1
  273. package/dist/routers/scoped-models.mjs +22 -0
  274. package/dist/routers/scoped-models.mjs.map +1 -0
  275. package/dist/routers/settings-ai.mjs +35 -0
  276. package/dist/routers/settings-ai.mjs.map +1 -0
  277. package/dist/routers/settings-capture.mjs +77 -0
  278. package/dist/routers/settings-capture.mjs.map +1 -0
  279. package/dist/routers/settings-design.mjs +103 -0
  280. package/dist/routers/settings-design.mjs.map +1 -0
  281. package/dist/routers/settings-general.mjs +55 -10
  282. package/dist/routers/settings-general.mjs.map +1 -1
  283. package/dist/routers/settings-git-channel.mjs +77 -0
  284. package/dist/routers/settings-git-channel.mjs.map +1 -0
  285. package/dist/routers/settings-labels.mjs +27 -1
  286. package/dist/routers/settings-labels.mjs.map +1 -1
  287. package/dist/routers/settings-links.mjs +63 -0
  288. package/dist/routers/settings-links.mjs.map +1 -0
  289. package/dist/routers/settings-members.mjs +4 -7
  290. package/dist/routers/settings-members.mjs.map +1 -1
  291. package/dist/routers/settings-notifications.mjs +15 -10
  292. package/dist/routers/settings-notifications.mjs.map +1 -1
  293. package/dist/routers/settings-tokens.mjs +2 -5
  294. package/dist/routers/settings-tokens.mjs.map +1 -1
  295. package/dist/routers/settings-webhooks.mjs +7 -24
  296. package/dist/routers/settings-webhooks.mjs.map +1 -1
  297. package/dist/routers/settings.handlers.mjs +107 -26
  298. package/dist/routers/settings.handlers.mjs.map +1 -1
  299. package/dist/routers/settings.mjs +16 -4
  300. package/dist/routers/settings.mjs.map +1 -1
  301. package/dist/routers/signed-storybook.mjs +53 -0
  302. package/dist/routers/signed-storybook.mjs.map +1 -0
  303. package/dist/routers/snapshot-groups.mjs +63 -0
  304. package/dist/routers/snapshot-groups.mjs.map +1 -0
  305. package/dist/routers/snapshots.mjs +103 -5
  306. package/dist/routers/snapshots.mjs.map +1 -1
  307. package/dist/routers/storybook.mjs +37 -88
  308. package/dist/routers/storybook.mjs.map +1 -1
  309. package/dist/routers/tokens.mjs +2 -5
  310. package/dist/routers/tokens.mjs.map +1 -1
  311. package/dist/routers/ui.mjs +34 -5
  312. package/dist/routers/ui.mjs.map +1 -1
  313. package/dist/routers/webhooks.mjs +5 -11
  314. package/dist/routers/webhooks.mjs.map +1 -1
  315. package/dist/routers/well-known.mjs +11 -4
  316. package/dist/routers/well-known.mjs.map +1 -1
  317. package/dist/routes/api-routes.mjs +93 -0
  318. package/dist/routes/api-routes.mjs.map +1 -0
  319. package/dist/routes/classification.mjs +38 -0
  320. package/dist/routes/classification.mjs.map +1 -0
  321. package/dist/routes/openapi-classes.mjs +22 -0
  322. package/dist/routes/openapi-classes.mjs.map +1 -0
  323. package/dist/routes/page-routes.mjs +104 -0
  324. package/dist/routes/page-routes.mjs.map +1 -0
  325. package/dist/runtime.mjs +18 -13
  326. package/dist/runtime.mjs.map +1 -1
  327. package/dist/serverless.mjs +5 -0
  328. package/dist/store.mjs +19 -1
  329. package/dist/store.mjs.map +1 -1
  330. package/dist/targets.mjs +46 -0
  331. package/dist/targets.mjs.map +1 -0
  332. package/dist/ui/buttons.mjs +16 -3
  333. package/dist/ui/buttons.mjs.map +1 -1
  334. package/dist/ui/design-reference.mjs +97 -0
  335. package/dist/ui/design-reference.mjs.map +1 -0
  336. package/dist/ui/document.mjs +1 -2
  337. package/dist/ui/document.mjs.map +1 -1
  338. package/dist/ui/forms.mjs +6 -5
  339. package/dist/ui/forms.mjs.map +1 -1
  340. package/dist/ui/icons/paths.mjs +1 -0
  341. package/dist/ui/icons/paths.mjs.map +1 -1
  342. package/dist/ui/preview-embed.mjs +82 -0
  343. package/dist/ui/preview-embed.mjs.map +1 -0
  344. package/dist/ui/shell/client-script.mjs +85 -0
  345. package/dist/ui/shell/client-script.mjs.map +1 -1
  346. package/dist/ui/shell/nav.mjs +12 -8
  347. package/dist/ui/shell/nav.mjs.map +1 -1
  348. package/dist/ui/shell/review-script.mjs +8 -0
  349. package/dist/ui/shell/review-script.mjs.map +1 -1
  350. package/dist/ui/shell/sidebar.mjs +2 -2
  351. package/dist/ui/shell/sidebar.mjs.map +1 -1
  352. package/package.json +11 -9
  353. package/dist/asset-paths.mjs +0 -23
  354. package/dist/asset-paths.mjs.map +0 -1
  355. package/dist/assets/mark.svg +0 -1
  356. package/dist/pages/settings-status.mjs +0 -137
  357. package/dist/pages/settings-status.mjs.map +0 -1
  358. package/dist/routers/settings-status.mjs +0 -77
  359. package/dist/routers/settings-status.mjs.map +0 -1
  360. package/dist/routers/status-configs.mjs +0 -108
  361. package/dist/routers/status-configs.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { Context, Next } from \"hono\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: Auth,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n path.startsWith(\"/.well-known/\") ||\n (path.startsWith(\"/projects/\") && path.includes(\"/storybook\")) ||\n /^\\/projects\\/[^/]+\\/?$/u.test(path)\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;AAMA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;;AAQA,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KAC1B,KAAK,WAAW,eAAe,KAC9B,KAAK,WAAW,YAAY,KAAK,KAAK,SAAS,YAAY,KAC5D,0BAA0B,KAAK,IAAI,GACnC;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
1
+ {"version":3,"file":"auth-gate.mjs","names":[],"sources":["../../src/middleware/auth-gate.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport { STORYBOOK_TOKEN_PREFIX } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { getStore } from \"../store.ts\";\n\n/** Resolve the request user from the auth adapter (null when auth is off or a token is used). */\nexport async function resolveRequestUser(\n c: Pick<Context, \"req\">,\n auth?: Auth,\n): Promise<AuthUser | null> {\n if (!auth) {\n return null;\n }\n if (c.req.header(\"authorization\")) {\n return null;\n }\n return await auth.check(c.req.raw);\n}\n\n/**\n * Gate the server-rendered UI behind auth (ADR 0008): unauthenticated HTML\n * requests redirect to login. API/auth routes answer 401/403 themselves, and\n * published-Storybook routes enforce their own auth (public builds are\n * viewable without a session, ADR 0011), and `/_s/<token>` is answered by its signed token\n * alone (ADR 0007).\n */\nexport function authGate() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const { user, authEnabled: isAuthEnabled } = getStore();\n const { path } = c.req;\n if (\n !isAuthEnabled ||\n user ||\n path.startsWith(\"/api/\") ||\n path.startsWith(\"/auth/\") ||\n path.startsWith(\"/assets/\") ||\n path.startsWith(STORYBOOK_TOKEN_PREFIX) ||\n path.startsWith(\"/.well-known/\") ||\n (path.startsWith(\"/projects/\") && path.includes(\"/storybook\")) ||\n /^\\/projects\\/[^/]+\\/?$/u.test(path)\n ) {\n await next();\n return;\n }\n if (c.req.header(\"HX-Request\") === \"true\") {\n c.header(\"HX-Redirect\", \"/auth/login\");\n return c.body(null, 204);\n }\n return c.redirect(\"/auth/login\", 302);\n };\n}\n"],"mappings":";;;;;;;AAOA,eAAsB,mBACpB,GACA,MAC0B;CAC1B,IAAI,CAAC,MACH,OAAO;CAET,IAAI,EAAE,IAAI,OAAO,eAAe,GAC9B,OAAO;CAET,OAAO,MAAM,KAAK,MAAM,EAAE,IAAI,GAAG;AACnC;;;;;;;;AASA,SAAgB,WAAW;CAEzB,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,EAAE,MAAM,aAAa,kBAAkB,SAAS;EACtD,MAAM,EAAE,SAAS,EAAE;EACnB,IACE,CAAC,iBACD,QACA,KAAK,WAAW,OAAO,KACvB,KAAK,WAAW,QAAQ,KACxB,KAAK,WAAW,UAAU,KAC1B,KAAK,WAAW,sBAAsB,KACtC,KAAK,WAAW,eAAe,KAC9B,KAAK,WAAW,YAAY,KAAK,KAAK,SAAS,YAAY,KAC5D,0BAA0B,KAAK,IAAI,GACnC;GACA,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,OAAO,YAAY,MAAM,QAAQ;GACzC,EAAE,OAAO,eAAe,aAAa;GACrC,OAAO,EAAE,KAAK,MAAM,GAAG;EACzB;EACA,OAAO,EAAE,SAAS,eAAe,GAAG;CACtC;AACF"}
@@ -25,44 +25,52 @@ function rateLimitKey(prefix) {
25
25
  return `${prefix}:anonymous`;
26
26
  };
27
27
  }
28
- const stores = /* @__PURE__ */ new Map();
29
28
  /** Cap tracked buckets (spoofed keys would otherwise grow the map forever). */
30
29
  const MAX_BUCKETS = 1e4;
31
- function cleanup() {
32
- const now = Date.now();
33
- for (const [key, entry] of stores) if (now > entry.resetAt) stores.delete(key);
34
- }
35
- setInterval(cleanup, 6e4).unref();
36
- /** Hono middleware limiting requests per key within a sliding window. */
37
- function rateLimit(options) {
38
- const { windowMs, max, keyGenerator } = options;
39
- return async (c, next) => {
40
- const key = keyGenerator ? keyGenerator(c) : rateLimitKey("api")(c);
41
- const now = Date.now();
42
- let entry = stores.get(key);
43
- if (!entry || now > entry.resetAt) {
44
- entry = {
45
- count: 0,
46
- resetAt: now + windowMs
47
- };
48
- if (stores.size >= MAX_BUCKETS) {
49
- const oldest = stores.keys().next();
50
- if (!oldest.done) stores.delete(oldest.value);
30
+ const SWEEP_INTERVAL_MS = 6e4;
31
+ /**
32
+ * Create a rate limiter with its own bucket store, so each app instance
33
+ * counts independently. Limiters built from the same `rateLimit` factory that
34
+ * resolve to the same key share one counter (e.g. `/api/v1/*` and
35
+ * `/api/v1/tokens/*` both bill the caller's `api:` bucket).
36
+ */
37
+ function createRateLimiter() {
38
+ const stores = /* @__PURE__ */ new Map();
39
+ let lastSweep = Date.now();
40
+ const sweep = (now) => {
41
+ lastSweep = now;
42
+ for (const [key, entry] of stores) if (now > entry.resetAt) stores.delete(key);
43
+ };
44
+ return (options) => {
45
+ const { windowMs, max, keyGenerator } = options;
46
+ return async (c, next) => {
47
+ const key = keyGenerator ? keyGenerator(c) : rateLimitKey("api")(c);
48
+ const now = Date.now();
49
+ if (now - lastSweep > SWEEP_INTERVAL_MS) sweep(now);
50
+ let entry = stores.get(key);
51
+ if (!entry || now > entry.resetAt) {
52
+ entry = {
53
+ count: 0,
54
+ resetAt: now + windowMs
55
+ };
56
+ if (stores.size >= MAX_BUCKETS) {
57
+ const oldest = stores.keys().next();
58
+ if (!oldest.done) stores.delete(oldest.value);
59
+ }
60
+ stores.set(key, entry);
61
+ }
62
+ entry.count += 1;
63
+ if (entry.count > max) {
64
+ c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1e3)));
65
+ return c.json({ error: "Too many requests" }, 429);
51
66
  }
52
- stores.set(key, entry);
53
- }
54
- entry.count += 1;
55
- if (entry.count > max) {
56
- const retryAfter = Math.ceil((entry.resetAt - now) / 1e3);
57
- c.header("Retry-After", String(retryAfter));
58
- return c.json({ error: "Too many requests" }, 429);
59
- }
60
- c.header("X-RateLimit-Limit", String(max));
61
- c.header("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
62
- await next();
67
+ c.header("X-RateLimit-Limit", String(max));
68
+ c.header("X-RateLimit-Remaining", String(Math.max(0, max - entry.count)));
69
+ return await next();
70
+ };
63
71
  };
64
72
  }
65
73
  //#endregion
66
- export { rateLimit, rateLimitKey };
74
+ export { createRateLimiter, rateLimitKey };
67
75
 
68
76
  //# sourceMappingURL=rate-limit.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\n\ninterface RateLimitEntry {\n count: number;\n resetAt: number;\n}\n\ninterface RateLimitOptions {\n windowMs: number;\n max: number;\n keyGenerator?: (c: Context) => string;\n}\n\n/** Direct peer IP from server runtimes exposing the socket (unspoofable TCP source). */\nfunction peerIp(c: Pick<Context, \"env\">): string | undefined {\n const env = (c.env ?? {}) as {\n incoming?: { socket?: { remoteAddress?: unknown } };\n server?: { incoming?: { socket?: { remoteAddress?: unknown } } };\n };\n const address =\n env.incoming?.socket?.remoteAddress ?? env.server?.incoming?.socket?.remoteAddress;\n return typeof address === \"string\" && address ? address : undefined;\n}\n\n/**\n * Rate-limit identity for one bucket prefix: direct peer first, then the\n * first `X-Forwarded-For` entry (client-controlled, so only a fallback),\n * then `X-Real-IP`, then a shared bucket. Deployments behind proxies with\n * many users behind one peer IP share that peer's budget.\n */\nexport function rateLimitKey(prefix: string): (c: Context) => string {\n return (c) => {\n const peer = peerIp(c);\n if (peer) {\n return `${prefix}:${peer}`;\n }\n const forwarded = c.req.header(\"x-forwarded-for\")?.split(\",\")[0]?.trim();\n if (forwarded) {\n return `${prefix}:fwd:${forwarded}`;\n }\n const real = c.req.header(\"x-real-ip\")?.trim();\n if (real) {\n return `${prefix}:fwd:${real}`;\n }\n return `${prefix}:anonymous`;\n };\n}\n\nconst stores = new Map<string, RateLimitEntry>();\n\n/** Cap tracked buckets (spoofed keys would otherwise grow the map forever). */\nconst MAX_BUCKETS = 10_000;\n\nfunction cleanup(): void {\n const now = Date.now();\n for (const [key, entry] of stores) {\n if (now > entry.resetAt) {\n stores.delete(key);\n }\n }\n}\n\nsetInterval(cleanup, 60_000).unref();\n\n/** Hono middleware limiting requests per key within a sliding window. */\nexport function rateLimit(options: RateLimitOptions) {\n const { windowMs, max, keyGenerator } = options;\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const key = keyGenerator ? keyGenerator(c) : rateLimitKey(\"api\")(c);\n const now = Date.now();\n let entry = stores.get(key);\n if (!entry || now > entry.resetAt) {\n entry = { count: 0, resetAt: now + windowMs };\n if (stores.size >= MAX_BUCKETS) {\n const oldest = stores.keys().next();\n if (!oldest.done) {\n stores.delete(oldest.value);\n }\n }\n stores.set(key, entry);\n }\n entry.count += 1;\n if (entry.count > max) {\n const retryAfter = Math.ceil((entry.resetAt - now) / 1000);\n c.header(\"Retry-After\", String(retryAfter));\n return c.json({ error: \"Too many requests\" }, 429);\n }\n c.header(\"X-RateLimit-Limit\", String(max));\n c.header(\"X-RateLimit-Remaining\", String(Math.max(0, max - entry.count)));\n await next();\n };\n}\n"],"mappings":";;;;;AAcA,SAAS,OAAO,GAA6C;CAC3D,MAAM,MAAO,EAAE,OAAO,CAAC;CAIvB,MAAM,UACJ,IAAI,UAAU,QAAQ,iBAAiB,IAAI,QAAQ,UAAU,QAAQ;CACvE,OAAO,OAAO,YAAY,YAAY,UAAU,UAAU,KAAA;AAC5D;;;;;;;AAQA,SAAgB,aAAa,QAAwC;CACnE,QAAQ,MAAM;EACZ,MAAM,OAAO,OAAO,CAAC;EACrB,IAAI,MACF,OAAO,GAAG,OAAO,GAAG;EAEtB,MAAM,YAAY,EAAE,IAAI,OAAO,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK;EACvE,IAAI,WACF,OAAO,GAAG,OAAO,OAAO;EAE1B,MAAM,OAAO,EAAE,IAAI,OAAO,WAAW,CAAC,EAAE,KAAK;EAC7C,IAAI,MACF,OAAO,GAAG,OAAO,OAAO;EAE1B,OAAO,GAAG,OAAO;CACnB;AACF;AAEA,MAAM,yBAAS,IAAI,IAA4B;;AAG/C,MAAM,cAAc;AAEpB,SAAS,UAAgB;CACvB,MAAM,MAAM,KAAK,IAAI;CACrB,KAAK,MAAM,CAAC,KAAK,UAAU,QACzB,IAAI,MAAM,MAAM,SACd,OAAO,OAAO,GAAG;AAGvB;AAEA,YAAY,SAAS,GAAM,CAAC,CAAC,MAAM;;AAGnC,SAAgB,UAAU,SAA2B;CACnD,MAAM,EAAE,UAAU,KAAK,iBAAiB;CAExC,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,MAAM,eAAe,aAAa,CAAC,IAAI,aAAa,KAAK,CAAC,CAAC,CAAC;EAClE,MAAM,MAAM,KAAK,IAAI;EACrB,IAAI,QAAQ,OAAO,IAAI,GAAG;EAC1B,IAAI,CAAC,SAAS,MAAM,MAAM,SAAS;GACjC,QAAQ;IAAE,OAAO;IAAG,SAAS,MAAM;GAAS;GAC5C,IAAI,OAAO,QAAQ,aAAa;IAC9B,MAAM,SAAS,OAAO,KAAK,CAAC,CAAC,KAAK;IAClC,IAAI,CAAC,OAAO,MACV,OAAO,OAAO,OAAO,KAAK;GAE9B;GACA,OAAO,IAAI,KAAK,KAAK;EACvB;EACA,MAAM,SAAS;EACf,IAAI,MAAM,QAAQ,KAAK;GACrB,MAAM,aAAa,KAAK,MAAM,MAAM,UAAU,OAAO,GAAI;GACzD,EAAE,OAAO,eAAe,OAAO,UAAU,CAAC;GAC1C,OAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;EACnD;EACA,EAAE,OAAO,qBAAqB,OAAO,GAAG,CAAC;EACzC,EAAE,OAAO,yBAAyB,OAAO,KAAK,IAAI,GAAG,MAAM,MAAM,KAAK,CAAC,CAAC;EACxE,MAAM,KAAK;CACb;AACF"}
1
+ {"version":3,"file":"rate-limit.mjs","names":[],"sources":["../../src/middleware/rate-limit.ts"],"sourcesContent":["import type { Context, MiddlewareHandler } from \"hono\";\n\ninterface RateLimitEntry {\n count: number;\n resetAt: number;\n}\n\nexport interface RateLimitOptions {\n windowMs: number;\n max: number;\n keyGenerator?: (c: Context) => string;\n}\n\n/** Direct peer IP from server runtimes exposing the socket (unspoofable TCP source). */\nfunction peerIp(c: Pick<Context, \"env\">): string | undefined {\n const env = (c.env ?? {}) as {\n incoming?: { socket?: { remoteAddress?: unknown } };\n server?: { incoming?: { socket?: { remoteAddress?: unknown } } };\n };\n const address =\n env.incoming?.socket?.remoteAddress ?? env.server?.incoming?.socket?.remoteAddress;\n return typeof address === \"string\" && address ? address : undefined;\n}\n\n/**\n * Rate-limit identity for one bucket prefix: direct peer first, then the\n * first `X-Forwarded-For` entry (client-controlled, so only a fallback),\n * then `X-Real-IP`, then a shared bucket. Deployments behind proxies with\n * many users behind one peer IP share that peer's budget.\n */\nexport function rateLimitKey(prefix: string): (c: Context) => string {\n return (c) => {\n const peer = peerIp(c);\n if (peer) {\n return `${prefix}:${peer}`;\n }\n const forwarded = c.req.header(\"x-forwarded-for\")?.split(\",\")[0]?.trim();\n if (forwarded) {\n return `${prefix}:fwd:${forwarded}`;\n }\n const real = c.req.header(\"x-real-ip\")?.trim();\n if (real) {\n return `${prefix}:fwd:${real}`;\n }\n return `${prefix}:anonymous`;\n };\n}\n\n/** Cap tracked buckets (spoofed keys would otherwise grow the map forever). */\nconst MAX_BUCKETS = 10_000;\nconst SWEEP_INTERVAL_MS = 60_000;\n\n/**\n * Create a rate limiter with its own bucket store, so each app instance\n * counts independently. Limiters built from the same `rateLimit` factory that\n * resolve to the same key share one counter (e.g. `/api/v1/*` and\n * `/api/v1/tokens/*` both bill the caller's `api:` bucket).\n */\nexport function createRateLimiter(): (options: RateLimitOptions) => MiddlewareHandler {\n const stores = new Map<string, RateLimitEntry>();\n let lastSweep = Date.now();\n\n const sweep = (now: number): void => {\n lastSweep = now;\n for (const [key, entry] of stores) {\n if (now > entry.resetAt) {\n stores.delete(key);\n }\n }\n };\n\n return (options) => {\n const { windowMs, max, keyGenerator } = options;\n return async (c, next) => {\n const key = keyGenerator ? keyGenerator(c) : rateLimitKey(\"api\")(c);\n const now = Date.now();\n if (now - lastSweep > SWEEP_INTERVAL_MS) {\n sweep(now);\n }\n let entry = stores.get(key);\n if (!entry || now > entry.resetAt) {\n entry = { count: 0, resetAt: now + windowMs };\n if (stores.size >= MAX_BUCKETS) {\n const oldest = stores.keys().next();\n if (!oldest.done) {\n stores.delete(oldest.value);\n }\n }\n stores.set(key, entry);\n }\n entry.count += 1;\n if (entry.count > max) {\n c.header(\"Retry-After\", String(Math.ceil((entry.resetAt - now) / 1000)));\n return c.json({ error: \"Too many requests\" }, 429);\n }\n c.header(\"X-RateLimit-Limit\", String(max));\n c.header(\"X-RateLimit-Remaining\", String(Math.max(0, max - entry.count)));\n return await next();\n };\n };\n}\n"],"mappings":";;;;;AAcA,SAAS,OAAO,GAA6C;CAC3D,MAAM,MAAO,EAAE,OAAO,CAAC;CAIvB,MAAM,UACJ,IAAI,UAAU,QAAQ,iBAAiB,IAAI,QAAQ,UAAU,QAAQ;CACvE,OAAO,OAAO,YAAY,YAAY,UAAU,UAAU,KAAA;AAC5D;;;;;;;AAQA,SAAgB,aAAa,QAAwC;CACnE,QAAQ,MAAM;EACZ,MAAM,OAAO,OAAO,CAAC;EACrB,IAAI,MACF,OAAO,GAAG,OAAO,GAAG;EAEtB,MAAM,YAAY,EAAE,IAAI,OAAO,iBAAiB,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,EAAE,EAAE,KAAK;EACvE,IAAI,WACF,OAAO,GAAG,OAAO,OAAO;EAE1B,MAAM,OAAO,EAAE,IAAI,OAAO,WAAW,CAAC,EAAE,KAAK;EAC7C,IAAI,MACF,OAAO,GAAG,OAAO,OAAO;EAE1B,OAAO,GAAG,OAAO;CACnB;AACF;;AAGA,MAAM,cAAc;AACpB,MAAM,oBAAoB;;;;;;;AAQ1B,SAAgB,oBAAsE;CACpF,MAAM,yBAAS,IAAI,IAA4B;CAC/C,IAAI,YAAY,KAAK,IAAI;CAEzB,MAAM,SAAS,QAAsB;EACnC,YAAY;EACZ,KAAK,MAAM,CAAC,KAAK,UAAU,QACzB,IAAI,MAAM,MAAM,SACd,OAAO,OAAO,GAAG;CAGvB;CAEA,QAAQ,YAAY;EAClB,MAAM,EAAE,UAAU,KAAK,iBAAiB;EACxC,OAAO,OAAO,GAAG,SAAS;GACxB,MAAM,MAAM,eAAe,aAAa,CAAC,IAAI,aAAa,KAAK,CAAC,CAAC,CAAC;GAClE,MAAM,MAAM,KAAK,IAAI;GACrB,IAAI,MAAM,YAAY,mBACpB,MAAM,GAAG;GAEX,IAAI,QAAQ,OAAO,IAAI,GAAG;GAC1B,IAAI,CAAC,SAAS,MAAM,MAAM,SAAS;IACjC,QAAQ;KAAE,OAAO;KAAG,SAAS,MAAM;IAAS;IAC5C,IAAI,OAAO,QAAQ,aAAa;KAC9B,MAAM,SAAS,OAAO,KAAK,CAAC,CAAC,KAAK;KAClC,IAAI,CAAC,OAAO,MACV,OAAO,OAAO,OAAO,KAAK;IAE9B;IACA,OAAO,IAAI,KAAK,KAAK;GACvB;GACA,MAAM,SAAS;GACf,IAAI,MAAM,QAAQ,KAAK;IACrB,EAAE,OAAO,eAAe,OAAO,KAAK,MAAM,MAAM,UAAU,OAAO,GAAI,CAAC,CAAC;IACvE,OAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;GACnD;GACA,EAAE,OAAO,qBAAqB,OAAO,GAAG,CAAC;GACzC,EAAE,OAAO,yBAAyB,OAAO,KAAK,IAAI,GAAG,MAAM,MAAM,KAAK,CAAC,CAAC;GACxE,OAAO,MAAM,KAAK;EACpB;CACF;AACF"}
@@ -1,6 +1,7 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { redactSecrets } from "@storyshelf/core/utils";
4
5
  //#region src/middleware/request-log.ts
5
6
  /** Hono middleware logging request start/end with duration. */
6
7
  function requestLogging(logger) {
@@ -10,14 +11,14 @@ function requestLogging(logger) {
10
11
  logger.info({
11
12
  reqId: id,
12
13
  method: c.req.method,
13
- url: c.req.path
14
+ url: redactSecrets(c.req.path)
14
15
  }, "request start");
15
16
  await next();
16
17
  const userId = c.get("userId");
17
18
  logger.info({
18
19
  reqId: id,
19
20
  method: c.req.method,
20
- url: c.req.path,
21
+ url: redactSecrets(c.req.path),
21
22
  status: c.res.status,
22
23
  durationMs: Math.round(performance.now() - started),
23
24
  ...userId ? { userId } : {}
@@ -1 +1 @@
1
- {"version":3,"file":"request-log.mjs","names":[],"sources":["../../src/middleware/request-log.ts"],"sourcesContent":["import type { Logger } from \"@storyshelf/core/logger\";\nimport type { Context, Next } from \"hono\";\n\n/** Hono middleware logging request start/end with duration. */\nexport function requestLogging(logger: Logger) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const started = performance.now();\n const id = c.get(\"requestId\");\n logger.info({ reqId: id, method: c.req.method, url: c.req.path }, \"request start\");\n await next();\n const userId = c.get(\"userId\") as string | null | undefined;\n logger.info(\n {\n reqId: id,\n method: c.req.method,\n url: c.req.path,\n status: c.res.status,\n durationMs: Math.round(performance.now() - started),\n ...(userId ? { userId } : {}),\n },\n \"request end\",\n );\n };\n}\n"],"mappings":";;;;;AAIA,SAAgB,eAAe,QAAgB;CAE7C,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,UAAU,YAAY,IAAI;EAChC,MAAM,KAAK,EAAE,IAAI,WAAW;EAC5B,OAAO,KAAK;GAAE,OAAO;GAAI,QAAQ,EAAE,IAAI;GAAQ,KAAK,EAAE,IAAI;EAAK,GAAG,eAAe;EACjF,MAAM,KAAK;EACX,MAAM,SAAS,EAAE,IAAI,QAAQ;EAC7B,OAAO,KACL;GACE,OAAO;GACP,QAAQ,EAAE,IAAI;GACd,KAAK,EAAE,IAAI;GACX,QAAQ,EAAE,IAAI;GACd,YAAY,KAAK,MAAM,YAAY,IAAI,IAAI,OAAO;GAClD,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAC7B,GACA,aACF;CACF;AACF"}
1
+ {"version":3,"file":"request-log.mjs","names":[],"sources":["../../src/middleware/request-log.ts"],"sourcesContent":["import type { Logger } from \"@storyshelf/core/logger\";\nimport { redactSecrets } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\n\n/** Hono middleware logging request start/end with duration. */\nexport function requestLogging(logger: Logger) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const started = performance.now();\n const id = c.get(\"requestId\");\n logger.info(\n { reqId: id, method: c.req.method, url: redactSecrets(c.req.path) },\n \"request start\",\n );\n await next();\n const userId = c.get(\"userId\") as string | null | undefined;\n logger.info(\n {\n reqId: id,\n method: c.req.method,\n url: redactSecrets(c.req.path),\n status: c.res.status,\n durationMs: Math.round(performance.now() - started),\n ...(userId ? { userId } : {}),\n },\n \"request end\",\n );\n };\n}\n"],"mappings":";;;;;;AAKA,SAAgB,eAAe,QAAgB;CAE7C,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,UAAU,YAAY,IAAI;EAChC,MAAM,KAAK,EAAE,IAAI,WAAW;EAC5B,OAAO,KACL;GAAE,OAAO;GAAI,QAAQ,EAAE,IAAI;GAAQ,KAAK,cAAc,EAAE,IAAI,IAAI;EAAE,GAClE,eACF;EACA,MAAM,KAAK;EACX,MAAM,SAAS,EAAE,IAAI,QAAQ;EAC7B,OAAO,KACL;GACE,OAAO;GACP,QAAQ,EAAE,IAAI;GACd,KAAK,cAAc,EAAE,IAAI,IAAI;GAC7B,QAAQ,EAAE,IAAI;GACd,YAAY,KAAK,MAAM,YAAY,IAAI,IAAI,OAAO;GAClD,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAC7B,GACA,aACF;CACF;AACF"}
@@ -0,0 +1,51 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { matchedRouteClass } from "./require-testing.mjs";
5
+ import { resolveAuthorizedProject } from "../routers/helpers.mjs";
6
+ //#region src/middleware/require-review.ts
7
+ /** Body of the 404 a `review` route answers for a project with review off. */
8
+ const REVIEW_DISABLED_MESSAGE = "Review is off for this project.";
9
+ /** Roles that may see a project's routes at all; the gate answers only to them. */
10
+ const VIEWER_ROLES = [
11
+ "viewer",
12
+ "developer",
13
+ "approver",
14
+ "admin"
15
+ ];
16
+ /** The project slug of `/api/v1/projects/:slug/...` and `/projects/:slug/...` paths. */
17
+ function projectSlugOf(path) {
18
+ return /^(?:\/api\/v1)?\/projects\/(?<slug>[^/]+)\//u.exec(path)?.groups?.["slug"];
19
+ }
20
+ /**
21
+ * Gate routes classified `review` (ADR 0013, decision 10): they answer 404 for a project that has
22
+ * review off, as if they did not exist. The switch is read from the database on every request, so
23
+ * turning review off or on takes effect at once. Only a caller who may view the project is told
24
+ * (anyone else gets the route's own refusal), so the gate reveals nothing about a project the
25
+ * caller cannot see. It runs after the store scope and the auth gate, which it needs to resolve
26
+ * the caller; routes of other classes, and unknown projects, pass through.
27
+ */
28
+ function requireReview() {
29
+ return async (c, next) => {
30
+ if (matchedRouteClass(c) !== "review" || !await reviewIsOff(c)) {
31
+ await next();
32
+ return;
33
+ }
34
+ if (c.req.path.startsWith("/api/")) return c.json({ error: REVIEW_DISABLED_MESSAGE }, 404);
35
+ return c.text(REVIEW_DISABLED_MESSAGE, 404);
36
+ };
37
+ }
38
+ /** Whether the request names a project the caller may view and whose review switch is off. */
39
+ async function reviewIsOff(c) {
40
+ const slug = projectSlugOf(c.req.path);
41
+ if (slug === void 0) return false;
42
+ try {
43
+ return (await resolveAuthorizedProject(c, slug, ...VIEWER_ROLES)).reviewEnabled === false;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+ //#endregion
49
+ export { REVIEW_DISABLED_MESSAGE, requireReview };
50
+
51
+ //# sourceMappingURL=require-review.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-review.mjs","names":[],"sources":["../../src/middleware/require-review.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\nimport { resolveAuthorizedProject } from \"../routers/helpers.ts\";\nimport { matchedRouteClass } from \"./require-testing.ts\";\n\n/** Body of the 404 a `review` route answers for a project with review off. */\nexport const REVIEW_DISABLED_MESSAGE = \"Review is off for this project.\";\n\n/** Roles that may see a project's routes at all; the gate answers only to them. */\nconst VIEWER_ROLES = [\"viewer\", \"developer\", \"approver\", \"admin\"] as const;\n\n/** The project slug of `/api/v1/projects/:slug/...` and `/projects/:slug/...` paths. */\nfunction projectSlugOf(path: string): string | undefined {\n return /^(?:\\/api\\/v1)?\\/projects\\/(?<slug>[^/]+)\\//u.exec(path)?.groups?.[\"slug\"];\n}\n\n/**\n * Gate routes classified `review` (ADR 0013, decision 10): they answer 404 for a project that has\n * review off, as if they did not exist. The switch is read from the database on every request, so\n * turning review off or on takes effect at once. Only a caller who may view the project is told\n * (anyone else gets the route's own refusal), so the gate reveals nothing about a project the\n * caller cannot see. It runs after the store scope and the auth gate, which it needs to resolve\n * the caller; routes of other classes, and unknown projects, pass through.\n */\nexport function requireReview() {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n if (matchedRouteClass(c) !== \"review\" || !(await reviewIsOff(c))) {\n await next();\n return;\n }\n if (c.req.path.startsWith(\"/api/\")) {\n return c.json({ error: REVIEW_DISABLED_MESSAGE }, 404);\n }\n return c.text(REVIEW_DISABLED_MESSAGE, 404);\n };\n}\n\n/** Whether the request names a project the caller may view and whose review switch is off. */\nasync function reviewIsOff(c: Context): Promise<boolean> {\n const slug = projectSlugOf(c.req.path);\n if (slug === undefined) {\n return false;\n }\n try {\n const project = await resolveAuthorizedProject(c, slug, ...VIEWER_ROLES);\n return project.reviewEnabled === false;\n } catch {\n return false;\n }\n}\n"],"mappings":";;;;;;;AAKA,MAAa,0BAA0B;;AAGvC,MAAM,eAAe;CAAC;CAAU;CAAa;CAAY;AAAO;;AAGhE,SAAS,cAAc,MAAkC;CACvD,OAAO,+CAA+C,KAAK,IAAI,CAAC,EAAE,SAAS;AAC7E;;;;;;;;;AAUA,SAAgB,gBAAgB;CAE9B,OAAO,OAAO,GAAY,SAAyC;EACjE,IAAI,kBAAkB,CAAC,MAAM,YAAY,CAAE,MAAM,YAAY,CAAC,GAAI;GAChE,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,KAAK,WAAW,OAAO,GAC/B,OAAO,EAAE,KAAK,EAAE,OAAO,wBAAwB,GAAG,GAAG;EAEvD,OAAO,EAAE,KAAK,yBAAyB,GAAG;CAC5C;AACF;;AAGA,eAAe,YAAY,GAA8B;CACvD,MAAM,OAAO,cAAc,EAAE,IAAI,IAAI;CACrC,IAAI,SAAS,KAAA,GACX,OAAO;CAET,IAAI;EAEF,QAAO,MADe,yBAAyB,GAAG,MAAM,GAAG,YAAY,EAAA,CACxD,kBAAkB;CACnC,QAAQ;EACN,OAAO;CACT;AACF"}
@@ -0,0 +1,41 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { classOfRoute } from "../routes/classification.mjs";
5
+ import { matchedRoutes } from "hono/route";
6
+ //#region src/middleware/require-testing.ts
7
+ /** Body of the 404 a `testing` route answers on a hosting-only instance. */
8
+ const TESTING_DISABLED_MESSAGE = "Testing is not enabled on this instance.";
9
+ /**
10
+ * Gate routes classified `testing` (ADR 0006, decisions 1 and 8): without `options.testing` they
11
+ * answer 404, as if the feature did not exist. The class comes from the route table through the
12
+ * handler Hono matched, so a new route needs no guard of its own, only a table entry. It is mounted
13
+ * before the CSRF and rate-limit guards so a hidden route answers the same 404 for every request. `hosting`,
14
+ * `both` and `review` routes, and requests that match no classified route, pass through.
15
+ */
16
+ function requireTesting(enabled) {
17
+ return async (c, next) => {
18
+ if (enabled || !isTestingRoute(c)) {
19
+ await next();
20
+ return;
21
+ }
22
+ if (c.req.path.startsWith("/api/")) return c.json({ error: TESTING_DISABLED_MESSAGE }, 404);
23
+ return c.text(TESTING_DISABLED_MESSAGE, 404);
24
+ };
25
+ }
26
+ /** The last matched handler that the table classifies (middleware matches come first). */
27
+ function matchedRouteClass(c) {
28
+ const routes = matchedRoutes(c);
29
+ for (let index = routes.length - 1; index >= 0; index -= 1) {
30
+ const route = routes[index];
31
+ const routeClass = route && classOfRoute(route.method, route.path);
32
+ if (routeClass !== void 0) return routeClass;
33
+ }
34
+ }
35
+ function isTestingRoute(c) {
36
+ return matchedRouteClass(c) === "testing";
37
+ }
38
+ //#endregion
39
+ export { TESTING_DISABLED_MESSAGE, matchedRouteClass, requireTesting };
40
+
41
+ //# sourceMappingURL=require-testing.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"require-testing.mjs","names":[],"sources":["../../src/middleware/require-testing.ts"],"sourcesContent":["import type { Context, Next } from \"hono\";\nimport { matchedRoutes } from \"hono/route\";\nimport { classOfRoute, type RouteClass } from \"../routes/classification.ts\";\n\n/** Body of the 404 a `testing` route answers on a hosting-only instance. */\nexport const TESTING_DISABLED_MESSAGE = \"Testing is not enabled on this instance.\";\n\n/**\n * Gate routes classified `testing` (ADR 0006, decisions 1 and 8): without `options.testing` they\n * answer 404, as if the feature did not exist. The class comes from the route table through the\n * handler Hono matched, so a new route needs no guard of its own, only a table entry. It is mounted\n * before the CSRF and rate-limit guards so a hidden route answers the same 404 for every request. `hosting`,\n * `both` and `review` routes, and requests that match no classified route, pass through.\n */\nexport function requireTesting(enabled: boolean) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n if (enabled || !isTestingRoute(c)) {\n await next();\n return;\n }\n if (c.req.path.startsWith(\"/api/\")) {\n return c.json({ error: TESTING_DISABLED_MESSAGE }, 404);\n }\n return c.text(TESTING_DISABLED_MESSAGE, 404);\n };\n}\n\n/** The last matched handler that the table classifies (middleware matches come first). */\nexport function matchedRouteClass(c: Context): RouteClass | undefined {\n const routes = matchedRoutes(c);\n for (let index = routes.length - 1; index >= 0; index -= 1) {\n const route = routes[index];\n const routeClass = route && classOfRoute(route.method, route.path);\n if (routeClass !== undefined) {\n return routeClass;\n }\n }\n return undefined;\n}\n\nfunction isTestingRoute(c: Context): boolean {\n return matchedRouteClass(c) === \"testing\";\n}\n"],"mappings":";;;;;;;AAKA,MAAa,2BAA2B;;;;;;;;AASxC,SAAgB,eAAe,SAAkB;CAE/C,OAAO,OAAO,GAAY,SAAyC;EACjE,IAAI,WAAW,CAAC,eAAe,CAAC,GAAG;GACjC,MAAM,KAAK;GACX;EACF;EACA,IAAI,EAAE,IAAI,KAAK,WAAW,OAAO,GAC/B,OAAO,EAAE,KAAK,EAAE,OAAO,yBAAyB,GAAG,GAAG;EAExD,OAAO,EAAE,KAAK,0BAA0B,GAAG;CAC7C;AACF;;AAGA,SAAgB,kBAAkB,GAAoC;CACpE,MAAM,SAAS,cAAc,CAAC;CAC9B,KAAK,IAAI,QAAQ,OAAO,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG;EAC1D,MAAM,QAAQ,OAAO;EACrB,MAAM,aAAa,SAAS,aAAa,MAAM,QAAQ,MAAM,IAAI;EACjE,IAAI,eAAe,KAAA,GACjB,OAAO;CAEX;AAEF;AAEA,SAAS,eAAe,GAAqB;CAC3C,OAAO,kBAAkB,CAAC,MAAM;AAClC"}
@@ -1 +1 @@
1
- {"version":3,"file":"setup-gate.mjs","names":[],"sources":["../../src/middleware/setup-gate.ts"],"sourcesContent":["import type { AdapterSetupResult } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport type { QueueWiring } from \"../capture-setup.ts\";\nimport type { ServerRuntime } from \"../runtime.ts\";\n\nexport interface MiddlewareWiring extends ServerRuntime, QueueWiring {\n options: ShelfOptions;\n getReady: () => Promise<AdapterSetupResult>;\n}\n\n/** Failures safe to expose on the setup gate's 503 (redacted, no secrets). */\nfunction publicFailures(\n result: AdapterSetupResult,\n): { category: string; kind: string; error: string }[] {\n return result.failures.map((failure) => ({\n category: failure.category,\n kind: failure.kind,\n error: sanitizeErrorText(failure.error, 500),\n }));\n}\n\n/**\n * Block requests until adapter setup settles; answer 503 with the per-adapter\n * failures when setup failed. Health probes and the admin System page are\n * exempt (they report setup state themselves instead of being masked by\n * the gate — both are site-admin-gated where it matters).\n */\nexport function setupGate(getReady: () => Promise<AdapterSetupResult>) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n if (c.req.path === \"/api/v1/health\" || c.req.path === \"/admin\") {\n await next();\n return;\n }\n const result = await getReady();\n if (!result.ok) {\n return c.json({ error: \"Adapters failed setup\", failures: publicFailures(result) }, 503);\n }\n await next();\n };\n}\n"],"mappings":";;;;;;AAaA,SAAS,eACP,QACqD;CACrD,OAAO,OAAO,SAAS,KAAK,aAAa;EACvC,UAAU,QAAQ;EAClB,MAAM,QAAQ;EACd,OAAO,kBAAkB,QAAQ,OAAO,GAAG;CAC7C,EAAE;AACJ;;;;;;;AAQA,SAAgB,UAAU,UAA6C;CAErE,OAAO,OAAO,GAAY,SAAyC;EACjE,IAAI,EAAE,IAAI,SAAS,oBAAoB,EAAE,IAAI,SAAS,UAAU;GAC9D,MAAM,KAAK;GACX;EACF;EACA,MAAM,SAAS,MAAM,SAAS;EAC9B,IAAI,CAAC,OAAO,IACV,OAAO,EAAE,KAAK;GAAE,OAAO;GAAyB,UAAU,eAAe,MAAM;EAAE,GAAG,GAAG;EAEzF,MAAM,KAAK;CACb;AACF"}
1
+ {"version":3,"file":"setup-gate.mjs","names":[],"sources":["../../src/middleware/setup-gate.ts"],"sourcesContent":["import type { AdapterSetupResult } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport type { JobsWiring } from \"../jobs-setup.ts\";\nimport type { ServerRuntime } from \"../runtime.ts\";\n\nexport interface MiddlewareWiring extends ServerRuntime, Omit<JobsWiring, \"consumer\"> {\n options: ShelfOptions;\n getReady: () => Promise<AdapterSetupResult>;\n}\n\n/** Failures safe to expose on the setup gate's 503 (redacted, no secrets). */\nfunction publicFailures(\n result: AdapterSetupResult,\n): { category: string; kind: string; error: string }[] {\n return result.failures.map((failure) => ({\n category: failure.category,\n kind: failure.kind,\n error: sanitizeErrorText(failure.error, 500),\n }));\n}\n\n/**\n * Block requests until adapter setup settles; answer 503 with the per-adapter\n * failures when setup failed. Health probes and the admin System page are\n * exempt (they report setup state themselves instead of being masked by\n * the gate — both are site-admin-gated where it matters).\n */\nexport function setupGate(getReady: () => Promise<AdapterSetupResult>) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n if (c.req.path === \"/api/v1/health\" || c.req.path === \"/admin\") {\n await next();\n return;\n }\n const result = await getReady();\n if (!result.ok) {\n return c.json({ error: \"Adapters failed setup\", failures: publicFailures(result) }, 503);\n }\n await next();\n };\n}\n"],"mappings":";;;;;;AAaA,SAAS,eACP,QACqD;CACrD,OAAO,OAAO,SAAS,KAAK,aAAa;EACvC,UAAU,QAAQ;EAClB,MAAM,QAAQ;EACd,OAAO,kBAAkB,QAAQ,OAAO,GAAG;CAC7C,EAAE;AACJ;;;;;;;AAQA,SAAgB,UAAU,UAA6C;CAErE,OAAO,OAAO,GAAY,SAAyC;EACjE,IAAI,EAAE,IAAI,SAAS,oBAAoB,EAAE,IAAI,SAAS,UAAU;GAC9D,MAAM,KAAK;GACX;EACF;EACA,MAAM,SAAS,MAAM,SAAS;EAC9B,IAAI,CAAC,OAAO,IACV,OAAO,EAAE,KAAK;GAAE,OAAO;GAAyB,UAAU,eAAe,MAAM;EAAE,GAAG,GAAG;EAEzF,MAAM,KAAK;CACb;AACF"}
@@ -17,11 +17,21 @@ function storeScope(deps) {
17
17
  logger: deps.logger,
18
18
  user,
19
19
  authEnabled: deps.authEnabled,
20
+ testing: deps.testing,
20
21
  sessionId: sessionIdFrom(c),
21
22
  enqueueCapture: deps.enqueueCapture,
22
23
  captureQueue: deps.captureQueue,
23
- gitHosts: deps.gitHosts,
24
- notifiers: deps.notifiers
24
+ capturesHere: deps.capturesHere,
25
+ publish: deps.publish,
26
+ cancelBuildJobs: deps.cancelBuildJobs,
27
+ buildState: deps.buildState,
28
+ captureConcurrency: deps.captureConcurrency,
29
+ runner: deps.runner,
30
+ publicOrigin: deps.publicOrigin,
31
+ design: deps.design,
32
+ notifiers: deps.notifiers,
33
+ notificationsConfig: deps.notificationsConfig,
34
+ ai: deps.ai
25
35
  }, async () => {
26
36
  await next();
27
37
  });
@@ -1 +1 @@
1
- {"version":3,"file":"store-scope.mjs","names":[],"sources":["../../src/middleware/store-scope.ts"],"sourcesContent":["import type { CaptureQueue } from \"@storyshelf/core/adapter/capture-queue\";\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { GitHostProvider } from \"@storyshelf/core/adapter/git-host\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { Context, Next } from \"hono\";\nimport { runWithStore } from \"../store.ts\";\nimport { sessionIdFrom } from \"./csrf.ts\";\n\n/** Dependencies scoped into the request store for router handlers. */\nexport interface StoreScopeDeps {\n db: DatabaseAdapter;\n storage: StorageAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n authEnabled: boolean;\n enqueueCapture?: (buildId: string, reqId?: string) => Promise<void>;\n captureQueue: CaptureQueue | null;\n gitHosts: GitHostProvider[];\n notifiers: NotifierProvider[];\n resolveUser: (c: Context) => Promise<AuthUser | null>;\n}\n\n/** Hono middleware running downstream handlers inside the request store. */\nexport function storeScope(deps: StoreScopeDeps) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const user = await deps.resolveUser(c);\n c.set(\"userId\", user?.id ?? null);\n await runWithStore(\n {\n db: deps.db,\n storage: deps.storage,\n config: deps.config,\n ui: deps.ui,\n logger: deps.logger,\n user,\n authEnabled: deps.authEnabled,\n sessionId: sessionIdFrom(c),\n enqueueCapture: deps.enqueueCapture,\n captureQueue: deps.captureQueue,\n gitHosts: deps.gitHosts,\n notifiers: deps.notifiers,\n },\n async () => {\n await next();\n },\n );\n };\n}\n"],"mappings":";;;;;;;AA4BA,SAAgB,WAAW,MAAsB;CAE/C,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,OAAO,MAAM,KAAK,YAAY,CAAC;EACrC,EAAE,IAAI,UAAU,MAAM,MAAM,IAAI;EAChC,MAAM,aACJ;GACE,IAAI,KAAK;GACT,SAAS,KAAK;GACd,QAAQ,KAAK;GACb,IAAI,KAAK;GACT,QAAQ,KAAK;GACb;GACA,aAAa,KAAK;GAClB,WAAW,cAAc,CAAC;GAC1B,gBAAgB,KAAK;GACrB,cAAc,KAAK;GACnB,UAAU,KAAK;GACf,WAAW,KAAK;EAClB,GACA,YAAY;GACV,MAAM,KAAK;EACb,CACF;CACF;AACF"}
1
+ {"version":3,"file":"store-scope.mjs","names":[],"sources":["../../src/middleware/store-scope.ts"],"sourcesContent":["import type { CaptureQueue } from \"@storyshelf/core/adapter/capture-queue\";\nimport type { CaptureRunner } from \"@storyshelf/core/adapter/capture-runner\";\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { DesignProvider } from \"@storyshelf/core/adapter/design\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { Ai } from \"@storyshelf/core/ai\";\nimport type { BuildStatePublisher } from \"@storyshelf/core/capture\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { PublicOriginResolver } from \"@storyshelf/core/models\";\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport type { Context, Next } from \"hono\";\nimport type { PublishService } from \"../publish-setup.ts\";\nimport { runWithStore } from \"../store.ts\";\nimport { sessionIdFrom } from \"./csrf.ts\";\n\n/** Dependencies scoped into the request store for router handlers. */\nexport interface StoreScopeDeps {\n db: DatabaseAdapter;\n storage: StorageAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n authEnabled: boolean;\n testing: boolean;\n enqueueCapture?: (buildId: string, reqId?: string) => Promise<void>;\n captureQueue: CaptureQueue | null;\n /** This instance runs the capture jobs itself (the retry and compute-jobs UI applies). */\n capturesHere?: boolean;\n publish?: PublishService;\n cancelBuildJobs?: (buildId: string) => Promise<boolean>;\n buildState?: BuildStatePublisher;\n captureConcurrency?: number;\n runner?: Pick<CaptureRunner, \"metadata\"> | undefined;\n publicOrigin?: PublicOriginResolver | undefined;\n design: DesignProvider[];\n notifiers: NotifierProvider[];\n notificationsConfig?: NotificationsConfig;\n ai?: Ai;\n resolveUser: (c: Context) => Promise<AuthUser | null>;\n}\n\n/** Hono middleware running downstream handlers inside the request store. */\nexport function storeScope(deps: StoreScopeDeps) {\n // oxlint-disable-next-line typescript/no-invalid-void-type -- Hono middleware may not return Response\n return async (c: Context, next: Next): Promise<Response | void> => {\n const user = await deps.resolveUser(c);\n c.set(\"userId\", user?.id ?? null);\n await runWithStore(\n {\n db: deps.db,\n storage: deps.storage,\n config: deps.config,\n ui: deps.ui,\n logger: deps.logger,\n user,\n authEnabled: deps.authEnabled,\n testing: deps.testing,\n sessionId: sessionIdFrom(c),\n enqueueCapture: deps.enqueueCapture,\n captureQueue: deps.captureQueue,\n capturesHere: deps.capturesHere,\n publish: deps.publish,\n cancelBuildJobs: deps.cancelBuildJobs,\n buildState: deps.buildState,\n captureConcurrency: deps.captureConcurrency,\n runner: deps.runner,\n publicOrigin: deps.publicOrigin,\n design: deps.design,\n notifiers: deps.notifiers,\n notificationsConfig: deps.notificationsConfig,\n ai: deps.ai,\n },\n async () => {\n await next();\n },\n );\n };\n}\n"],"mappings":";;;;;;;AA4CA,SAAgB,WAAW,MAAsB;CAE/C,OAAO,OAAO,GAAY,SAAyC;EACjE,MAAM,OAAO,MAAM,KAAK,YAAY,CAAC;EACrC,EAAE,IAAI,UAAU,MAAM,MAAM,IAAI;EAChC,MAAM,aACJ;GACE,IAAI,KAAK;GACT,SAAS,KAAK;GACd,QAAQ,KAAK;GACb,IAAI,KAAK;GACT,QAAQ,KAAK;GACb;GACA,aAAa,KAAK;GAClB,SAAS,KAAK;GACd,WAAW,cAAc,CAAC;GAC1B,gBAAgB,KAAK;GACrB,cAAc,KAAK;GACnB,cAAc,KAAK;GACnB,SAAS,KAAK;GACd,iBAAiB,KAAK;GACtB,YAAY,KAAK;GACjB,oBAAoB,KAAK;GACzB,QAAQ,KAAK;GACb,cAAc,KAAK;GACnB,QAAQ,KAAK;GACb,WAAW,KAAK;GAChB,qBAAqB,KAAK;GAC1B,IAAI,KAAK;EACX,GACA,YAAY;GACV,MAAM,KAAK;EACb,CACF;CACF;AACF"}
@@ -0,0 +1,33 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "./store.mjs";
5
+ //#region src/notifications-pause.ts
6
+ /** What the System page and the notification settings tell the viewer. */
7
+ const NOTIFICATIONS_PAUSED_TEXT = "Notifications are paused until a public URL is set; set PUBLIC_BASE_URL or confirm the detected origin.";
8
+ /** The origin notifications link to, or `undefined` while none is resolved. */
9
+ async function notificationOrigin(deps) {
10
+ const trimmed = (deps.publicOrigin ? await deps.publicOrigin.url() : deps.config.publicBaseUrl)?.trim().replace(/\/+$/u, "");
11
+ return trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
12
+ }
13
+ /** Whether the current instance sends no notifications for want of a public origin. */
14
+ async function notificationsPaused(store = getStore()) {
15
+ return await notificationOrigin(store) === void 0;
16
+ }
17
+ /** Loggers that already said so; cleared when an origin resolves, so a later pause logs again. */
18
+ const warned = /* @__PURE__ */ new WeakSet();
19
+ /** Log (once per pause, per logger) that a notification was skipped. */
20
+ function warnPaused(logger, event) {
21
+ if (!warned.has(logger)) {
22
+ warned.add(logger);
23
+ logger.warn({ event }, `${NOTIFICATIONS_PAUSED_TEXT} Skipping notifications.`);
24
+ }
25
+ }
26
+ /** Forget a logged pause: an origin resolved. */
27
+ function clearPaused(logger) {
28
+ warned.delete(logger);
29
+ }
30
+ //#endregion
31
+ export { NOTIFICATIONS_PAUSED_TEXT, clearPaused, notificationOrigin, notificationsPaused, warnPaused };
32
+
33
+ //# sourceMappingURL=notifications-pause.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications-pause.mjs","names":[],"sources":["../src/notifications-pause.ts"],"sourcesContent":["import type { ShelfConfig } from \"@storyshelf/core/config\";\n/**\n * Notifications need a public origin (ADR 0014, decision 11): every message carries a link and\n * every git status targets one, so while the resolver (ADR 0007, decision 6) finds no confirmed\n * origin (a detected one counts only once an admin confirmed it) nothing is sent. Pausing is not a failure: no channel is marked as errored, and the System page\n * and the notification settings say what to set.\n */\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { PublicOriginResolver } from \"@storyshelf/core/models\";\nimport { getStore, type Store } from \"./store.ts\";\n\n/** What the System page and the notification settings tell the viewer. */\nexport const NOTIFICATIONS_PAUSED_TEXT =\n \"Notifications are paused until a public URL is set; set PUBLIC_BASE_URL or confirm the detected origin.\";\n\n/** What a pause check reads. */\ninterface OriginDeps {\n config: Pick<ShelfConfig, \"publicBaseUrl\">;\n /** The resolver; absent (hand-built stores) means `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** The origin notifications link to, or `undefined` while none is resolved. */\nexport async function notificationOrigin(deps: OriginDeps): Promise<string | undefined> {\n const origin = deps.publicOrigin ? await deps.publicOrigin.url() : deps.config.publicBaseUrl;\n const trimmed = origin?.trim().replace(/\\/+$/u, \"\");\n return trimmed === undefined || trimmed === \"\" ? undefined : trimmed;\n}\n\n/** Whether the current instance sends no notifications for want of a public origin. */\nexport async function notificationsPaused(store: Store = getStore()): Promise<boolean> {\n return (await notificationOrigin(store)) === undefined;\n}\n\n/** Loggers that already said so; cleared when an origin resolves, so a later pause logs again. */\nconst warned = new WeakSet<object>();\n\n/** Log (once per pause, per logger) that a notification was skipped. */\nexport function warnPaused(logger: Logger, event: string): void {\n if (!warned.has(logger)) {\n warned.add(logger);\n logger.warn({ event }, `${NOTIFICATIONS_PAUSED_TEXT} Skipping notifications.`);\n }\n}\n\n/** Forget a logged pause: an origin resolved. */\nexport function clearPaused(logger: Logger): void {\n warned.delete(logger);\n}\n"],"mappings":";;;;;;AAYA,MAAa,4BACX;;AAUF,eAAsB,mBAAmB,MAA+C;CAEtF,MAAM,WADS,KAAK,eAAe,MAAM,KAAK,aAAa,IAAI,IAAI,KAAK,OAAO,cAAA,EACvD,KAAK,CAAC,CAAC,QAAQ,SAAS,EAAE;CAClD,OAAO,YAAY,KAAA,KAAa,YAAY,KAAK,KAAA,IAAY;AAC/D;;AAGA,eAAsB,oBAAoB,QAAe,SAAS,GAAqB;CACrF,OAAQ,MAAM,mBAAmB,KAAK,MAAO,KAAA;AAC/C;;AAGA,MAAM,yBAAS,IAAI,QAAgB;;AAGnC,SAAgB,WAAW,QAAgB,OAAqB;CAC9D,IAAI,CAAC,OAAO,IAAI,MAAM,GAAG;EACvB,OAAO,IAAI,MAAM;EACjB,OAAO,KAAK,EAAE,MAAM,GAAG,GAAG,0BAA0B,yBAAyB;CAC/E;AACF;;AAGA,SAAgB,YAAY,QAAsB;CAChD,OAAO,OAAO,MAAM;AACtB"}
package/dist/notify.d.mts CHANGED
@@ -1,7 +1,8 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
- import { NotifierProvider } from "@storyshelf/core/adapter/notifier";
4
3
  import { ShelfConfig, UIConfig } from "@storyshelf/core/config";
4
+ import "@storyshelf/core/models";
5
+ import { NotifierProvider } from "@storyshelf/core/adapter/notifier";
5
6
  import { Logger } from "@storyshelf/core/logger";
6
7
  import { DatabaseAdapter } from "@storyshelf/core/adapter/database";
7
8
  import "@storyshelf/core/schema";
package/dist/notify.mjs CHANGED
@@ -3,48 +3,56 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { credentialKeys } from "./credential-keys.mjs";
5
5
  import { getStore } from "./store.mjs";
6
- import { NotificationChannelModel, NotificationSubscriptionModel, UserModel } from "@storyshelf/core/models";
6
+ import { clearPaused, notificationOrigin, warnPaused } from "./notifications-pause.mjs";
7
+ import { MemberModel, NotificationChannelModel, NotificationSubscriptionModel, UserModel } from "@storyshelf/core/models";
7
8
  import { emitNotifications, emitSystemNotification } from "@storyshelf/core/adapter/notifier";
8
9
  //#region src/notify.ts
9
- /** Review URL from an explicit base URL (background use). */
10
- function reviewUrlForWith(publicBaseUrl, slug, suffix) {
11
- const base = publicBaseUrl?.replace(/\/+$/u, "");
12
- if (!base) return;
13
- return `${base}/projects/${slug}${suffix}`;
14
- }
15
- /** Brand inputs from UI config plus server notification defaults. */
16
- function brandFor(ui, config, reviewUrl) {
10
+ /** Absolute review URL for a project page under the resolved public origin. */
11
+ function reviewUrlFor(origin, slug, suffix) {
12
+ return `${origin}/projects/${slug}${suffix}`;
13
+ }
14
+ /**
15
+ * The origin notifications link to; `undefined` (after one log line per pause) while none is
16
+ * resolved, which pauses every notification (ADR 0014, decision 11).
17
+ */
18
+ async function originOrPause(deps, event) {
19
+ const origin = await notificationOrigin(deps);
20
+ if (origin === void 0) warnPaused(deps.logger, event);
21
+ else clearPaused(deps.logger);
22
+ return origin;
23
+ }
24
+ /** Brand inputs from UI config plus the notification sender settings. */
25
+ function brandFor(ui, settings, reviewUrl) {
17
26
  return {
18
27
  name: ui.name,
19
28
  logo: ui.logo,
20
29
  reviewUrl,
21
- fromEmail: config.notifications?.fromEmail,
22
- fromName: config.notifications?.fromName ?? ui.name,
23
- footerText: config.notifications?.footerText
24
- };
25
- }
26
- /** Convert a stored row to a send-ready view (null when undecryptable). */
27
- function toView(row, model) {
28
- let secret;
29
- try {
30
- secret = row.secretEncrypted ? model.decryptSecret(row) : void 0;
31
- } catch {
32
- return null;
33
- }
34
- return {
35
- id: row.id,
36
- projectId: row.projectId,
37
- provider: row.provider,
38
- config: NotificationChannelModel.configOf(row),
39
- secret,
40
- events: NotificationChannelModel.eventsOf(row),
41
- enabled: row.enabled
30
+ fromEmail: settings?.fromEmail,
31
+ fromName: settings?.fromName ?? ui.name,
32
+ footerText: settings?.footerText
42
33
  };
43
34
  }
44
35
  /** Load stored channels as send-ready views (skips undecryptable secrets). */
45
36
  async function loadChannels(deps, projectId) {
46
- const model = new NotificationChannelModel(deps.db, void 0, credentialKeys(deps.config));
47
- return (projectId === null ? await model.listSystem() : await model.list(projectId)).filter((row) => row.enabled).flatMap((row) => toView(row, model) ?? []);
37
+ const model = channelModel(deps);
38
+ return (projectId === null ? await model.listSystem() : await model.list(projectId)).filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);
39
+ }
40
+ /** The channel model bound to the server secret. */
41
+ function channelModel(deps) {
42
+ return new NotificationChannelModel(deps.db, void 0, credentialKeys(deps.config));
43
+ }
44
+ /** Record each delivery outcome on its channel row (the last error shown in settings). */
45
+ function recordOutcomes(deps) {
46
+ const model = channelModel(deps);
47
+ return async (outcome) => {
48
+ if (outcome.channelId.startsWith("sub:") || outcome.channelId.startsWith("default:")) return;
49
+ await model.recordOutcome(outcome.channelId, outcome.error).catch((error) => {
50
+ deps.logger.warn({
51
+ err: error,
52
+ channelId: outcome.channelId
53
+ }, "failed to record outcome");
54
+ });
55
+ };
48
56
  }
49
57
  /** Subscriptions matching an event (enabled + allowlist). */
50
58
  function matchingSubs(rows, event) {
@@ -68,10 +76,37 @@ async function targetsForSub(db, sub, wired) {
68
76
  enabled: true
69
77
  }));
70
78
  }
79
+ /** Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4). */
80
+ const DEFAULT_ON_EVENTS = /* @__PURE__ */ new Set(["review:requested"]);
81
+ /** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */
82
+ const REVIEWER_ROLES = /* @__PURE__ */ new Set(["approver", "admin"]);
83
+ /**
84
+ * Reviewers with no subscription row for the project: a default-on topic reaches them by email.
85
+ * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts
86
+ * out), so a row never adds a second message here.
87
+ */
88
+ async function defaultTargets(db, projectId, rows, wired) {
89
+ if (!wired.has("email")) return [];
90
+ const subscribed = new Set(rows.map((row) => row.userId));
91
+ const members = (await new MemberModel(db).list(projectId)).filter((member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId));
92
+ return (await Promise.all(members.map(async (member) => {
93
+ const user = await new UserModel(db).get(member.userId);
94
+ return !user || user.disabled ? [] : [{
95
+ id: `default:${projectId}:${member.userId}`,
96
+ projectId,
97
+ provider: "email",
98
+ config: { to: user.email },
99
+ events: [],
100
+ enabled: true
101
+ }];
102
+ }))).flat();
103
+ }
71
104
  /** Per-subscriber targets for an event (only kinds that are wired). */
72
105
  async function subscriberTargets(db, projectId, event, wired) {
73
106
  const rows = await new NotificationSubscriptionModel(db).list(projectId);
74
- return (await Promise.all(matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)))).flat();
107
+ const nested = await Promise.all(matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)));
108
+ const defaults = DEFAULT_ON_EVENTS.has(event) ? await defaultTargets(db, projectId, rows, wired) : [];
109
+ return [...nested.flat(), ...defaults];
75
110
  }
76
111
  /**
77
112
  * Fan out a project event to stored channels plus opted-in subscribers.
@@ -79,7 +114,9 @@ async function subscriberTargets(db, projectId, event, wired) {
79
114
  */
80
115
  async function notifyProjectWith(deps, project, event, data, reviewSuffix) {
81
116
  if (deps.notifiers.length === 0) return;
82
- const reviewUrl = reviewSuffix ? reviewUrlForWith(deps.config.publicBaseUrl, project.slug, reviewSuffix) : void 0;
117
+ const origin = await originOrPause(deps, event);
118
+ if (origin === void 0) return;
119
+ const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : void 0;
83
120
  const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));
84
121
  const channels = await loadChannels(deps, project.id);
85
122
  const targets = await subscriberTargets(deps.db, project.id, event, wired);
@@ -92,8 +129,9 @@ async function notifyProjectWith(deps, project, event, data, reviewSuffix) {
92
129
  }, {
93
130
  notifiers: deps.notifiers,
94
131
  channels: [...channels, ...targets],
95
- brand: brandFor(deps.ui, deps.config, reviewUrl),
96
- logger: deps.logger
132
+ brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),
133
+ logger: deps.logger,
134
+ onOutcome: recordOutcomes(deps)
97
135
  });
98
136
  }
99
137
  /**
@@ -105,7 +143,7 @@ async function notifyProject(project, event, data, reviewSuffix) {
105
143
  }
106
144
  /** Fan out a site-wide admin alert to project-less channels. */
107
145
  async function notifySystemWith(deps, event, data) {
108
- if (deps.notifiers.length === 0) return;
146
+ if (deps.notifiers.length === 0 || await originOrPause(deps, event) === void 0) return;
109
147
  await emitSystemNotification({
110
148
  event,
111
149
  data,
@@ -113,8 +151,9 @@ async function notifySystemWith(deps, event, data) {
113
151
  }, {
114
152
  notifiers: deps.notifiers,
115
153
  channels: await loadChannels(deps, null),
116
- brand: brandFor(deps.ui, deps.config),
117
- logger: deps.logger
154
+ brand: brandFor(deps.ui, deps.notificationsConfig),
155
+ logger: deps.logger,
156
+ onOutcome: recordOutcomes(deps)
118
157
  });
119
158
  }
120
159
  /** Fan out a site-wide admin alert to project-less channels. */