@storyshelf/app 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/git-status.mjs +46 -0
  26. package/dist/git-status.mjs.map +1 -0
  27. package/dist/hidden-data.mjs +24 -0
  28. package/dist/hidden-data.mjs.map +1 -0
  29. package/dist/index.d.mts +3 -3
  30. package/dist/index.mjs +203 -125
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/insights/budget.mjs +49 -0
  33. package/dist/insights/budget.mjs.map +1 -0
  34. package/dist/insights/deps.mjs +21 -0
  35. package/dist/insights/deps.mjs.map +1 -0
  36. package/dist/insights/errors.mjs +22 -0
  37. package/dist/insights/errors.mjs.map +1 -0
  38. package/dist/insights/health-panel.mjs +40 -0
  39. package/dist/insights/health-panel.mjs.map +1 -0
  40. package/dist/insights/job.mjs +103 -0
  41. package/dist/insights/job.mjs.map +1 -0
  42. package/dist/insights/load-health.mjs +59 -0
  43. package/dist/insights/load-health.mjs.map +1 -0
  44. package/dist/insights/load-triage.mjs +98 -0
  45. package/dist/insights/load-triage.mjs.map +1 -0
  46. package/dist/insights/panel.mjs +28 -0
  47. package/dist/insights/panel.mjs.map +1 -0
  48. package/dist/insights/request.mjs +113 -0
  49. package/dist/insights/request.mjs.map +1 -0
  50. package/dist/insights/stale.mjs +21 -0
  51. package/dist/insights/stale.mjs.map +1 -0
  52. package/dist/insights/usage-report.mjs +60 -0
  53. package/dist/insights/usage-report.mjs.map +1 -0
  54. package/dist/insights/view.mjs +48 -0
  55. package/dist/insights/view.mjs.map +1 -0
  56. package/dist/job-canceller.mjs +14 -0
  57. package/dist/job-canceller.mjs.map +1 -0
  58. package/dist/job-queue.mjs +51 -0
  59. package/dist/job-queue.mjs.map +1 -0
  60. package/dist/jobs-setup.mjs +132 -0
  61. package/dist/jobs-setup.mjs.map +1 -0
  62. package/dist/lifecycle.mjs +91 -19
  63. package/dist/lifecycle.mjs.map +1 -1
  64. package/dist/middleware/auth-gate.mjs +4 -2
  65. package/dist/middleware/auth-gate.mjs.map +1 -1
  66. package/dist/middleware/rate-limit.mjs +41 -33
  67. package/dist/middleware/rate-limit.mjs.map +1 -1
  68. package/dist/middleware/request-log.mjs +3 -2
  69. package/dist/middleware/request-log.mjs.map +1 -1
  70. package/dist/middleware/require-review.mjs +51 -0
  71. package/dist/middleware/require-review.mjs.map +1 -0
  72. package/dist/middleware/require-testing.mjs +41 -0
  73. package/dist/middleware/require-testing.mjs.map +1 -0
  74. package/dist/middleware/setup-gate.mjs.map +1 -1
  75. package/dist/middleware/store-scope.mjs +12 -2
  76. package/dist/middleware/store-scope.mjs.map +1 -1
  77. package/dist/notifications-pause.mjs +33 -0
  78. package/dist/notifications-pause.mjs.map +1 -0
  79. package/dist/notify.d.mts +2 -1
  80. package/dist/notify.mjs +78 -39
  81. package/dist/notify.mjs.map +1 -1
  82. package/dist/pages/a11y-violations.mjs +116 -0
  83. package/dist/pages/a11y-violations.mjs.map +1 -0
  84. package/dist/pages/admin-ai.mjs +78 -0
  85. package/dist/pages/admin-ai.mjs.map +1 -0
  86. package/dist/pages/admin-system-origin.mjs +123 -0
  87. package/dist/pages/admin-system-origin.mjs.map +1 -0
  88. package/dist/pages/admin-system-targets.mjs +53 -0
  89. package/dist/pages/admin-system-targets.mjs.map +1 -0
  90. package/dist/pages/admin-system.mjs +63 -13
  91. package/dist/pages/admin-system.mjs.map +1 -1
  92. package/dist/pages/build-detail.mjs +104 -76
  93. package/dist/pages/build-detail.mjs.map +1 -1
  94. package/dist/pages/build-diff-actions.mjs +87 -33
  95. package/dist/pages/build-diff-actions.mjs.map +1 -1
  96. package/dist/pages/build-diff-comments.mjs +16 -14
  97. package/dist/pages/build-diff-comments.mjs.map +1 -1
  98. package/dist/pages/build-diff-environment.mjs +82 -0
  99. package/dist/pages/build-diff-environment.mjs.map +1 -0
  100. package/dist/pages/build-diff-header.mjs +22 -4
  101. package/dist/pages/build-diff-header.mjs.map +1 -1
  102. package/dist/pages/build-diff-insight.mjs +113 -0
  103. package/dist/pages/build-diff-insight.mjs.map +1 -0
  104. package/dist/pages/build-diff-model.mjs +55 -10
  105. package/dist/pages/build-diff-model.mjs.map +1 -1
  106. package/dist/pages/build-diff-nav.mjs +28 -12
  107. package/dist/pages/build-diff-nav.mjs.map +1 -1
  108. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  109. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  110. package/dist/pages/build-diff-usage.mjs +63 -0
  111. package/dist/pages/build-diff-usage.mjs.map +1 -0
  112. package/dist/pages/build-diff-viewer.mjs +62 -5
  113. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  114. package/dist/pages/build-diff.mjs +34 -13
  115. package/dist/pages/build-diff.mjs.map +1 -1
  116. package/dist/pages/build-tests.mjs +74 -0
  117. package/dist/pages/build-tests.mjs.map +1 -0
  118. package/dist/pages/component-detail.mjs +249 -0
  119. package/dist/pages/component-detail.mjs.map +1 -0
  120. package/dist/pages/component-stories.mjs +168 -0
  121. package/dist/pages/component-stories.mjs.map +1 -0
  122. package/dist/pages/compute-jobs.mjs +13 -26
  123. package/dist/pages/compute-jobs.mjs.map +1 -1
  124. package/dist/pages/design-links.mjs +182 -0
  125. package/dist/pages/design-links.mjs.map +1 -0
  126. package/dist/pages/environment-change.mjs +53 -0
  127. package/dist/pages/environment-change.mjs.map +1 -0
  128. package/dist/pages/health-panel.mjs +126 -0
  129. package/dist/pages/health-panel.mjs.map +1 -0
  130. package/dist/pages/library-grid.mjs +105 -0
  131. package/dist/pages/library-grid.mjs.map +1 -0
  132. package/dist/pages/library-model.mjs +47 -0
  133. package/dist/pages/library-model.mjs.map +1 -0
  134. package/dist/pages/library-snapshots.mjs +130 -0
  135. package/dist/pages/library-snapshots.mjs.map +1 -0
  136. package/dist/pages/library.mjs +112 -218
  137. package/dist/pages/library.mjs.map +1 -1
  138. package/dist/pages/notifications-paused.mjs +22 -0
  139. package/dist/pages/notifications-paused.mjs.map +1 -0
  140. package/dist/pages/profile.mjs +53 -48
  141. package/dist/pages/profile.mjs.map +1 -1
  142. package/dist/pages/project-builds-table.mjs +2 -2
  143. package/dist/pages/project-builds-table.mjs.map +1 -1
  144. package/dist/pages/project-builds.mjs +51 -21
  145. package/dist/pages/project-builds.mjs.map +1 -1
  146. package/dist/pages/project-settings.mjs +51 -12
  147. package/dist/pages/project-settings.mjs.map +1 -1
  148. package/dist/pages/projects-card.mjs +14 -1
  149. package/dist/pages/projects-card.mjs.map +1 -1
  150. package/dist/pages/projects.mjs +6 -2
  151. package/dist/pages/projects.mjs.map +1 -1
  152. package/dist/pages/review-changeset.mjs +116 -0
  153. package/dist/pages/review-changeset.mjs.map +1 -0
  154. package/dist/pages/review-comments.mjs +103 -0
  155. package/dist/pages/review-comments.mjs.map +1 -0
  156. package/dist/pages/review-verdict.mjs +102 -0
  157. package/dist/pages/review-verdict.mjs.map +1 -0
  158. package/dist/pages/settings-ai.mjs +69 -0
  159. package/dist/pages/settings-ai.mjs.map +1 -0
  160. package/dist/pages/settings-capture.mjs +124 -0
  161. package/dist/pages/settings-capture.mjs.map +1 -0
  162. package/dist/pages/settings-design-credential.mjs +99 -0
  163. package/dist/pages/settings-design-credential.mjs.map +1 -0
  164. package/dist/pages/settings-design-links.mjs +179 -0
  165. package/dist/pages/settings-design-links.mjs.map +1 -0
  166. package/dist/pages/settings-design.mjs +48 -0
  167. package/dist/pages/settings-design.mjs.map +1 -0
  168. package/dist/pages/settings-general.mjs +46 -21
  169. package/dist/pages/settings-general.mjs.map +1 -1
  170. package/dist/pages/settings-labels.mjs +43 -2
  171. package/dist/pages/settings-labels.mjs.map +1 -1
  172. package/dist/pages/settings-links.mjs +112 -0
  173. package/dist/pages/settings-links.mjs.map +1 -0
  174. package/dist/pages/settings-notifications-git.mjs +128 -0
  175. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  176. package/dist/pages/settings-notifications.mjs +79 -42
  177. package/dist/pages/settings-notifications.mjs.map +1 -1
  178. package/dist/pages/settings-tests.mjs +33 -2
  179. package/dist/pages/settings-tests.mjs.map +1 -1
  180. package/dist/pages/target-choices.mjs +39 -0
  181. package/dist/pages/target-choices.mjs.map +1 -0
  182. package/dist/publish-setup.mjs +89 -0
  183. package/dist/publish-setup.mjs.map +1 -0
  184. package/dist/retention-timer.mjs +2 -1
  185. package/dist/retention-timer.mjs.map +1 -1
  186. package/dist/review-events.mjs +48 -0
  187. package/dist/review-events.mjs.map +1 -0
  188. package/dist/routers/admin-pages.mjs +45 -3
  189. package/dist/routers/admin-pages.mjs.map +1 -1
  190. package/dist/routers/admin-public-origin.mjs +85 -0
  191. package/dist/routers/admin-public-origin.mjs.map +1 -0
  192. package/dist/routers/assets.mjs +9 -2
  193. package/dist/routers/assets.mjs.map +1 -1
  194. package/dist/routers/auth.mjs +1 -1
  195. package/dist/routers/baseline-guard.mjs +2 -2
  196. package/dist/routers/baseline-guard.mjs.map +1 -1
  197. package/dist/routers/baselines.handlers.mjs +64 -0
  198. package/dist/routers/baselines.handlers.mjs.map +1 -0
  199. package/dist/routers/baselines.mjs +125 -0
  200. package/dist/routers/baselines.mjs.map +1 -0
  201. package/dist/routers/build-cancel.mjs +68 -0
  202. package/dist/routers/build-cancel.mjs.map +1 -0
  203. package/dist/routers/build-changeset.mjs +70 -0
  204. package/dist/routers/build-changeset.mjs.map +1 -0
  205. package/dist/routers/build-reviews.handlers.mjs +86 -0
  206. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  207. package/dist/routers/build-reviews.mjs +74 -0
  208. package/dist/routers/build-reviews.mjs.map +1 -0
  209. package/dist/routers/builds.handlers.mjs +46 -55
  210. package/dist/routers/builds.handlers.mjs.map +1 -1
  211. package/dist/routers/builds.mjs +71 -17
  212. package/dist/routers/builds.mjs.map +1 -1
  213. package/dist/routers/capture-config.mjs +35 -0
  214. package/dist/routers/capture-config.mjs.map +1 -0
  215. package/dist/routers/comments.mjs +41 -15
  216. package/dist/routers/comments.mjs.map +1 -1
  217. package/dist/routers/component-catalog.handlers.mjs +71 -0
  218. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  219. package/dist/routers/component.handlers.mjs +121 -0
  220. package/dist/routers/component.handlers.mjs.map +1 -0
  221. package/dist/routers/connections.mjs +118 -0
  222. package/dist/routers/connections.mjs.map +1 -0
  223. package/dist/routers/design-connect.handlers.mjs +44 -0
  224. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  225. package/dist/routers/design.handlers.mjs +114 -0
  226. package/dist/routers/design.handlers.mjs.map +1 -0
  227. package/dist/routers/health-report.mjs +2 -3
  228. package/dist/routers/health-report.mjs.map +1 -1
  229. package/dist/routers/health.mjs +4 -3
  230. package/dist/routers/health.mjs.map +1 -1
  231. package/dist/routers/helpers.mjs +19 -3
  232. package/dist/routers/helpers.mjs.map +1 -1
  233. package/dist/routers/insights-ui.mjs +64 -0
  234. package/dist/routers/insights-ui.mjs.map +1 -0
  235. package/dist/routers/insights.handlers.mjs +45 -0
  236. package/dist/routers/insights.handlers.mjs.map +1 -0
  237. package/dist/routers/insights.mjs +189 -0
  238. package/dist/routers/insights.mjs.map +1 -0
  239. package/dist/routers/links.handlers.mjs +80 -0
  240. package/dist/routers/links.handlers.mjs.map +1 -0
  241. package/dist/routers/links.mjs +139 -0
  242. package/dist/routers/links.mjs.map +1 -0
  243. package/dist/routers/media.mjs +18 -4
  244. package/dist/routers/media.mjs.map +1 -1
  245. package/dist/routers/members.mjs +2 -1
  246. package/dist/routers/members.mjs.map +1 -1
  247. package/dist/routers/notification-channels.mjs +17 -23
  248. package/dist/routers/notification-channels.mjs.map +1 -1
  249. package/dist/routers/oauth-state.mjs +45 -0
  250. package/dist/routers/oauth-state.mjs.map +1 -0
  251. package/dist/routers/preview-base.mjs +38 -0
  252. package/dist/routers/preview-base.mjs.map +1 -0
  253. package/dist/routers/preview-token.mjs +51 -0
  254. package/dist/routers/preview-token.mjs.map +1 -0
  255. package/dist/routers/profile.mjs +3 -1
  256. package/dist/routers/profile.mjs.map +1 -1
  257. package/dist/routers/projects.mjs +17 -1
  258. package/dist/routers/projects.mjs.map +1 -1
  259. package/dist/routers/publish-upload.mjs +78 -0
  260. package/dist/routers/publish-upload.mjs.map +1 -0
  261. package/dist/routers/published-access.mjs +52 -0
  262. package/dist/routers/published-access.mjs.map +1 -0
  263. package/dist/routers/published-files.mjs +52 -0
  264. package/dist/routers/published-files.mjs.map +1 -0
  265. package/dist/routers/review-forms.mjs +72 -0
  266. package/dist/routers/review-forms.mjs.map +1 -0
  267. package/dist/routers/review-section.handlers.mjs +85 -0
  268. package/dist/routers/review-section.handlers.mjs.map +1 -0
  269. package/dist/routers/review-switch-off.mjs +29 -0
  270. package/dist/routers/review-switch-off.mjs.map +1 -0
  271. package/dist/routers/schemas.mjs +140 -22
  272. package/dist/routers/schemas.mjs.map +1 -1
  273. package/dist/routers/scoped-models.mjs +22 -0
  274. package/dist/routers/scoped-models.mjs.map +1 -0
  275. package/dist/routers/settings-ai.mjs +35 -0
  276. package/dist/routers/settings-ai.mjs.map +1 -0
  277. package/dist/routers/settings-capture.mjs +77 -0
  278. package/dist/routers/settings-capture.mjs.map +1 -0
  279. package/dist/routers/settings-design.mjs +103 -0
  280. package/dist/routers/settings-design.mjs.map +1 -0
  281. package/dist/routers/settings-general.mjs +55 -10
  282. package/dist/routers/settings-general.mjs.map +1 -1
  283. package/dist/routers/settings-git-channel.mjs +77 -0
  284. package/dist/routers/settings-git-channel.mjs.map +1 -0
  285. package/dist/routers/settings-labels.mjs +27 -1
  286. package/dist/routers/settings-labels.mjs.map +1 -1
  287. package/dist/routers/settings-links.mjs +63 -0
  288. package/dist/routers/settings-links.mjs.map +1 -0
  289. package/dist/routers/settings-members.mjs +4 -7
  290. package/dist/routers/settings-members.mjs.map +1 -1
  291. package/dist/routers/settings-notifications.mjs +15 -10
  292. package/dist/routers/settings-notifications.mjs.map +1 -1
  293. package/dist/routers/settings-tokens.mjs +2 -5
  294. package/dist/routers/settings-tokens.mjs.map +1 -1
  295. package/dist/routers/settings-webhooks.mjs +7 -24
  296. package/dist/routers/settings-webhooks.mjs.map +1 -1
  297. package/dist/routers/settings.handlers.mjs +107 -26
  298. package/dist/routers/settings.handlers.mjs.map +1 -1
  299. package/dist/routers/settings.mjs +16 -4
  300. package/dist/routers/settings.mjs.map +1 -1
  301. package/dist/routers/signed-storybook.mjs +53 -0
  302. package/dist/routers/signed-storybook.mjs.map +1 -0
  303. package/dist/routers/snapshot-groups.mjs +63 -0
  304. package/dist/routers/snapshot-groups.mjs.map +1 -0
  305. package/dist/routers/snapshots.mjs +103 -5
  306. package/dist/routers/snapshots.mjs.map +1 -1
  307. package/dist/routers/storybook.mjs +37 -88
  308. package/dist/routers/storybook.mjs.map +1 -1
  309. package/dist/routers/tokens.mjs +2 -5
  310. package/dist/routers/tokens.mjs.map +1 -1
  311. package/dist/routers/ui.mjs +34 -5
  312. package/dist/routers/ui.mjs.map +1 -1
  313. package/dist/routers/webhooks.mjs +5 -11
  314. package/dist/routers/webhooks.mjs.map +1 -1
  315. package/dist/routers/well-known.mjs +11 -4
  316. package/dist/routers/well-known.mjs.map +1 -1
  317. package/dist/routes/api-routes.mjs +93 -0
  318. package/dist/routes/api-routes.mjs.map +1 -0
  319. package/dist/routes/classification.mjs +38 -0
  320. package/dist/routes/classification.mjs.map +1 -0
  321. package/dist/routes/openapi-classes.mjs +22 -0
  322. package/dist/routes/openapi-classes.mjs.map +1 -0
  323. package/dist/routes/page-routes.mjs +104 -0
  324. package/dist/routes/page-routes.mjs.map +1 -0
  325. package/dist/runtime.mjs +18 -13
  326. package/dist/runtime.mjs.map +1 -1
  327. package/dist/serverless.mjs +5 -0
  328. package/dist/store.mjs +19 -1
  329. package/dist/store.mjs.map +1 -1
  330. package/dist/targets.mjs +46 -0
  331. package/dist/targets.mjs.map +1 -0
  332. package/dist/ui/buttons.mjs +16 -3
  333. package/dist/ui/buttons.mjs.map +1 -1
  334. package/dist/ui/design-reference.mjs +97 -0
  335. package/dist/ui/design-reference.mjs.map +1 -0
  336. package/dist/ui/document.mjs +1 -2
  337. package/dist/ui/document.mjs.map +1 -1
  338. package/dist/ui/forms.mjs +6 -5
  339. package/dist/ui/forms.mjs.map +1 -1
  340. package/dist/ui/icons/paths.mjs +1 -0
  341. package/dist/ui/icons/paths.mjs.map +1 -1
  342. package/dist/ui/preview-embed.mjs +82 -0
  343. package/dist/ui/preview-embed.mjs.map +1 -0
  344. package/dist/ui/shell/client-script.mjs +85 -0
  345. package/dist/ui/shell/client-script.mjs.map +1 -1
  346. package/dist/ui/shell/nav.mjs +12 -8
  347. package/dist/ui/shell/nav.mjs.map +1 -1
  348. package/dist/ui/shell/review-script.mjs +8 -0
  349. package/dist/ui/shell/review-script.mjs.map +1 -1
  350. package/dist/ui/shell/sidebar.mjs +2 -2
  351. package/dist/ui/shell/sidebar.mjs.map +1 -1
  352. package/package.json +11 -9
  353. package/dist/asset-paths.mjs +0 -23
  354. package/dist/asset-paths.mjs.map +0 -1
  355. package/dist/assets/mark.svg +0 -1
  356. package/dist/pages/settings-status.mjs +0 -137
  357. package/dist/pages/settings-status.mjs.map +0 -1
  358. package/dist/routers/settings-status.mjs +0 -77
  359. package/dist/routers/settings-status.mjs.map +0 -1
  360. package/dist/routers/status-configs.mjs +0 -108
  361. package/dist/routers/status-configs.mjs.map +0 -1
@@ -0,0 +1,114 @@
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 { connectionModel } from "../connections.mjs";
7
+ import { loadConnectStates, viewerIdOf } from "./design-connect.handlers.mjs";
8
+ import { DesignCredentialModel, DesignModel, DesignNodeModel, isDesignStale, normalizeImportPath, resolveDesigns } from "@storyshelf/core/models";
9
+ import { parseDesignUrl } from "@storyshelf/core/adapter/design";
10
+ //#region src/routers/design.handlers.ts
11
+ /** Most a page render waits for fresh design metadata; slower fetches finish into the cache for next time. */
12
+ const REFRESH_BUDGET_MS = 1500;
13
+ /** Path + query of the current request, to return to after a connect round trip. */
14
+ function returnPathOf(c) {
15
+ const url = new URL(c.req.url);
16
+ return `${url.pathname}${url.search}`;
17
+ }
18
+ /** Component (story file) → design link(s) for a build's snapshots; files without a link are absent. */
19
+ async function loadDesignResolutions(project, snapshots) {
20
+ const { db, design } = getStore();
21
+ if (design.length === 0) return /* @__PURE__ */ new Map();
22
+ const model = new DesignModel(db, design);
23
+ return resolveDesigns(await model.componentLinks(project.id), snapshots);
24
+ }
25
+ /** Design URLs of one snapshot's component. */
26
+ function designUrlsFor(resolutions, snapshot) {
27
+ const key = snapshot.storyImportPath ? normalizeImportPath(snapshot.storyImportPath) : "";
28
+ return resolutions.get(key)?.urls ?? [];
29
+ }
30
+ /** Project-level design file links for the library header. */
31
+ async function loadProjectDesignLinks(project) {
32
+ const { db, design } = getStore();
33
+ if (design.length === 0) return [];
34
+ return (await new DesignModel(db, design).projectLinks(project.id)).map((link) => ({
35
+ label: link.label ?? "Design",
36
+ url: link.url
37
+ }));
38
+ }
39
+ /**
40
+ * Design items for the review page: each link with cached name/state and a
41
+ * stale flag against the snapshot's baseline. Refreshes expired metadata
42
+ * within a short budget; failures degrade to plain deep links, never blocking review.
43
+ */
44
+ async function loadReviewDesign(project, snapshot, urls, returnTo = "/") {
45
+ if (urls.length === 0) return [];
46
+ const nodes = nodeModel();
47
+ const viewerId = viewerIdOf();
48
+ await refreshWithinBudget(nodes, project.id, urls, viewerId);
49
+ const cached = await nodes.cachedFor(project.id, urls, viewerId).catch(() => /* @__PURE__ */ new Map());
50
+ const connects = await loadConnectStates(project.id, urls.map((url) => kindOf(url)), returnTo).catch(() => /* @__PURE__ */ new Map());
51
+ return urls.map((url) => {
52
+ const node = cached.get(url);
53
+ const connect = connects.get(kindOf(url));
54
+ return {
55
+ ...connect ? { connect } : {},
56
+ url,
57
+ name: node?.name ?? null,
58
+ providerName: providerNameFor(url),
59
+ renderUrl: renderUrlOf(project, node),
60
+ state: node?.state ?? "unknown",
61
+ stale: isDesignStale(node, snapshot.baselineVersion)
62
+ };
63
+ });
64
+ }
65
+ /** Most distinct design links a library render refreshes (the model bounds file concurrency). */
66
+ const WARM_LIMIT = 10;
67
+ /**
68
+ * Cached name/render per design URL for library popovers. Missing or expired
69
+ * entries are refreshed first, within the page-render budget and with bounded
70
+ * concurrency, so a slow design API never holds the library up or fans out unbounded.
71
+ */
72
+ async function loadDesignPreviews(project, urls) {
73
+ const distinct = [...new Set(urls)];
74
+ if (distinct.length === 0) return /* @__PURE__ */ new Map();
75
+ const nodes = nodeModel();
76
+ const viewerId = viewerIdOf();
77
+ await refreshWithinBudget(nodes, project.id, distinct.slice(0, WARM_LIMIT), viewerId);
78
+ const cached = await nodes.cachedFor(project.id, distinct, viewerId).catch(() => /* @__PURE__ */ new Map());
79
+ return new Map([...cached].map(([url, node]) => [url, {
80
+ name: node.name,
81
+ renderUrl: renderUrlOf(project, node)
82
+ }]));
83
+ }
84
+ function nodeModel() {
85
+ const { db, config, logger, storage, design } = getStore();
86
+ const credentials = new DesignCredentialModel(db, credentialKeys(config));
87
+ return new DesignNodeModel(db, credentials, design, {
88
+ logger,
89
+ storage,
90
+ connections: connectionModel()
91
+ });
92
+ }
93
+ function kindOf(url) {
94
+ return parseDesignUrl(getStore().design, url)?.provider.metadata.kind ?? "";
95
+ }
96
+ /** Display name of the provider that owns a design link ("Figma"), or a generic fallback. */
97
+ function providerNameFor(url) {
98
+ return parseDesignUrl(getStore().design, url)?.provider.metadata.name ?? "Design";
99
+ }
100
+ /** API URL of a node's cached render (version-busted), or null when none is cached. */
101
+ function renderUrlOf(project, node) {
102
+ return node?.renderPath ? `/api/v1/projects/${project.slug}/design/renders/${node.id}?v=${encodeURIComponent(node.renderVersion ?? "")}` : null;
103
+ }
104
+ /** Refresh the links, aborting every provider call once the budget is spent (the cache keeps what landed). */
105
+ async function refreshWithinBudget(nodes, projectId, urls, viewerId) {
106
+ await nodes.refreshMany(projectId, urls, {
107
+ signal: AbortSignal.timeout(REFRESH_BUDGET_MS),
108
+ ...viewerId ? { viewerId } : {}
109
+ }).catch(() => /* @__PURE__ */ new Map());
110
+ }
111
+ //#endregion
112
+ export { designUrlsFor, loadDesignPreviews, loadDesignResolutions, loadProjectDesignLinks, loadReviewDesign, providerNameFor, returnPathOf };
113
+
114
+ //# sourceMappingURL=design.handlers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"design.handlers.mjs","names":[],"sources":["../../src/routers/design.handlers.ts"],"sourcesContent":["import { parseDesignUrl } from \"@storyshelf/core/adapter/design\";\nimport {\n DesignCredentialModel,\n DesignModel,\n DesignNodeModel,\n isDesignStale,\n resolveDesigns,\n normalizeImportPath,\n type DesignResolution,\n} from \"@storyshelf/core/models\";\nimport type { DesignNode, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { connectionModel } from \"../connections.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport type {\n DesignConnect,\n DesignItem,\n DesignPreviewInfo,\n ProjectDesignLink,\n} from \"../pages/design-links.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { loadConnectStates, viewerIdOf } from \"./design-connect.handlers.ts\";\n\n/** Most a page render waits for fresh design metadata; slower fetches finish into the cache for next time. */\nconst REFRESH_BUDGET_MS = 1500;\n\n/** Path + query of the current request, to return to after a connect round trip. */\nexport function returnPathOf(c: { req: { url: string } }): string {\n const url = new URL(c.req.url);\n return `${url.pathname}${url.search}`;\n}\n\n/** Component (story file) → design link(s) for a build's snapshots; files without a link are absent. */\nexport async function loadDesignResolutions(\n project: Project,\n snapshots: readonly Snapshot[],\n): Promise<Map<string, DesignResolution>> {\n const { db, design } = getStore();\n if (design.length === 0) return new Map();\n const model = new DesignModel(db, design);\n return resolveDesigns(await model.componentLinks(project.id), snapshots);\n}\n\n/** Design URLs of one snapshot's component. */\nexport function designUrlsFor(\n resolutions: Map<string, DesignResolution>,\n snapshot: Pick<Snapshot, \"storyImportPath\">,\n): string[] {\n const key = snapshot.storyImportPath ? normalizeImportPath(snapshot.storyImportPath) : \"\";\n return resolutions.get(key)?.urls ?? [];\n}\n\n/** Project-level design file links for the library header. */\nexport async function loadProjectDesignLinks(project: Project): Promise<ProjectDesignLink[]> {\n const { db, design } = getStore();\n if (design.length === 0) return [];\n const links = await new DesignModel(db, design).projectLinks(project.id);\n return links.map((link) => ({ label: link.label ?? \"Design\", url: link.url }));\n}\n\n/**\n * Design items for the review page: each link with cached name/state and a\n * stale flag against the snapshot's baseline. Refreshes expired metadata\n * within a short budget; failures degrade to plain deep links, never blocking review.\n */\nexport async function loadReviewDesign(\n project: Project,\n snapshot: Snapshot,\n urls: readonly string[],\n returnTo = \"/\",\n): Promise<DesignItem[]> {\n if (urls.length === 0) return [];\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, urls, viewerId);\n const cached = await nodes\n .cachedFor(project.id, urls, viewerId)\n .catch(() => new Map<string, DesignNode>());\n const connects = await loadConnectStates(\n project.id,\n urls.map((url) => kindOf(url)),\n returnTo,\n ).catch(() => new Map<string, DesignConnect>());\n return urls.map((url) => {\n const node = cached.get(url);\n const connect = connects.get(kindOf(url));\n return {\n ...(connect ? { connect } : {}),\n url,\n name: node?.name ?? null,\n providerName: providerNameFor(url),\n renderUrl: renderUrlOf(project, node),\n state: node?.state ?? \"unknown\",\n stale: isDesignStale(node, snapshot.baselineVersion),\n };\n });\n}\n\n/** Most distinct design links a library render refreshes (the model bounds file concurrency). */\nconst WARM_LIMIT = 10;\n\n/**\n * Cached name/render per design URL for library popovers. Missing or expired\n * entries are refreshed first, within the page-render budget and with bounded\n * concurrency, so a slow design API never holds the library up or fans out unbounded.\n */\nexport async function loadDesignPreviews(\n project: Project,\n urls: readonly string[],\n): Promise<Map<string, DesignPreviewInfo>> {\n const distinct = [...new Set(urls)];\n if (distinct.length === 0) return new Map();\n const nodes = nodeModel();\n const viewerId = viewerIdOf();\n await refreshWithinBudget(nodes, project.id, distinct.slice(0, WARM_LIMIT), viewerId);\n const cached = await nodes\n .cachedFor(project.id, distinct, viewerId)\n .catch(() => new Map<string, DesignNode>());\n return new Map(\n [...cached].map(([url, node]) => [\n url,\n { name: node.name, renderUrl: renderUrlOf(project, node) },\n ]),\n );\n}\n\nfunction nodeModel(): DesignNodeModel {\n const { db, config, logger, storage, design } = getStore();\n const credentials = new DesignCredentialModel(db, credentialKeys(config));\n return new DesignNodeModel(db, credentials, design, {\n logger,\n storage,\n connections: connectionModel(),\n });\n}\n\nfunction kindOf(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.kind ?? \"\";\n}\n\n/** Display name of the provider that owns a design link (\"Figma\"), or a generic fallback. */\nexport function providerNameFor(url: string): string {\n return parseDesignUrl(getStore().design, url)?.provider.metadata.name ?? \"Design\";\n}\n\n/** API URL of a node's cached render (version-busted), or null when none is cached. */\nfunction renderUrlOf(project: Project, node: DesignNode | undefined): string | null {\n return node?.renderPath\n ? `/api/v1/projects/${project.slug}/design/renders/${node.id}?v=${encodeURIComponent(node.renderVersion ?? \"\")}`\n : null;\n}\n\n/** Refresh the links, aborting every provider call once the budget is spent (the cache keeps what landed). */\nasync function refreshWithinBudget(\n nodes: DesignNodeModel,\n projectId: string,\n urls: readonly string[],\n viewerId?: string,\n): Promise<void> {\n await nodes\n .refreshMany(projectId, urls, {\n signal: AbortSignal.timeout(REFRESH_BUDGET_MS),\n ...(viewerId ? { viewerId } : {}),\n })\n .catch(() => new Map());\n}\n"],"mappings":"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"}
@@ -16,10 +16,9 @@ function targetsOf(sources) {
16
16
  pushAll(targets, [
17
17
  sources.database,
18
18
  sources.storage,
19
- sources.captureRunner,
20
- sources.captureQueue
19
+ sources.captureRunner
21
20
  ]);
22
- pushAll(targets, sources.gitHosts ?? []);
21
+ pushAll(targets, sources.design ?? []);
23
22
  pushAll(targets, sources.notifiers ?? []);
24
23
  pushTarget(targets, sources.emailSender);
25
24
  return targets;
@@ -1 +1 @@
1
- {"version":3,"file":"health-report.mjs","names":[],"sources":["../../src/routers/health-report.ts"],"sourcesContent":["import type {\n AdapterHealth,\n AdapterLifecycle,\n AdapterMetadata,\n} from \"@storyshelf/core/adapter/metadata\";\n/**\n * Shared adapter health probing for the JSON API and the admin System page.\n *\n * `POST /api/v1/health` and `GET /admin` both consume\n * {@link collectHealthReport}; the API strips the page-only metadata fields\n * so its response shape never changes. Probes stay parallel, time-bounded,\n * and secret-free (details are truncated, never raw config).\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { CredentialProbe } from \"@storyshelf/core/models\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\n\nexport type AdapterState = \"ok\" | \"starting\" | \"failed\";\n\n/** Per-adapter readiness entry with identity for the admin page. */\nexport interface AdapterReportFull {\n category: string;\n kind: string;\n name: string;\n version: string;\n description?: string;\n hasLifecycle: boolean;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Collected health report: overall status plus one entry per adapter. */\nexport interface HealthReport {\n status: \"ok\" | \"starting\" | \"degraded\";\n uptimeSecs: number;\n version: string;\n adapters: AdapterReportFull[];\n}\n\ninterface ProbeTarget {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\nconst PROBE_TIMEOUT_MS = 2000;\nconst DETAIL_MAX_LENGTH = 500;\n\nfunction pushTarget(targets: ProbeTarget[], adapter: ProbeTarget | undefined): void {\n if (adapter) {\n targets.push(adapter);\n }\n}\n\nfunction pushAll(targets: ProbeTarget[], adapters: ReadonlyArray<ProbeTarget | undefined>): void {\n for (const adapter of adapters) {\n pushTarget(targets, adapter);\n }\n}\n\nfunction targetsOf(sources: AdapterSetupSources): ProbeTarget[] {\n const targets: ProbeTarget[] = [];\n pushAll(targets, [\n sources.database,\n sources.storage,\n sources.captureRunner,\n sources.captureQueue,\n ]);\n pushAll(targets, sources.gitHosts ?? []);\n pushAll(targets, sources.notifiers ?? []);\n pushTarget(targets, sources.emailSender);\n return targets;\n}\n\nfunction sanitize(detail: string): string {\n return sanitizeErrorText(detail, DETAIL_MAX_LENGTH);\n}\n\nfunction messageOf(reason: unknown): string {\n const message = reason instanceof Error ? reason.message : String(reason);\n return sanitize(message);\n}\n\nasync function timeoutError(): Promise<never> {\n await new Promise<void>((resolve) => {\n setTimeout(resolve, PROBE_TIMEOUT_MS);\n });\n throw new Error(\"health probe timed out\");\n}\n\nfunction baseReport(\n target: ProbeTarget,\n): Omit<AdapterReportFull, \"state\" | \"latencyMs\" | \"detail\"> {\n const { category, kind, name, version, description } = target.metadata;\n return {\n category,\n kind,\n name,\n version,\n ...(description === undefined ? {} : { description }),\n hasLifecycle: target.lifecycle?.health !== undefined,\n };\n}\n\nfunction okReport(\n target: ProbeTarget,\n latencyMs: number,\n detail: string | undefined,\n): AdapterReportFull {\n return {\n ...baseReport(target),\n state: \"ok\",\n latencyMs,\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\nfunction failedReport(target: ProbeTarget, latencyMs: number, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", latencyMs, detail };\n}\n\nfunction setupFailedReport(target: ProbeTarget, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", detail };\n}\n\nfunction startingReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"starting\" };\n}\n\nfunction noLifecycleReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"ok\" };\n}\n\nfunction detailOf(result: AdapterHealth, fallback: string): string {\n return result.detail === undefined ? fallback : sanitize(result.detail);\n}\n\nasync function raceProbe(\n target: ProbeTarget,\n probe: () => Promise<AdapterHealth>,\n started: number,\n): Promise<AdapterReportFull> {\n try {\n const result = await Promise.race([probe(), timeoutError()]);\n const latencyMs = Date.now() - started;\n if (!result.ok) {\n return failedReport(target, latencyMs, detailOf(result, \"probe reported failure\"));\n }\n return okReport(\n target,\n latencyMs,\n result.detail === undefined ? undefined : sanitize(result.detail),\n );\n } catch (error) {\n return failedReport(target, Date.now() - started, messageOf(error));\n }\n}\n\nasync function probeHealth(target: ProbeTarget): Promise<AdapterReportFull> {\n const probe = target.lifecycle?.health;\n if (!probe) {\n return noLifecycleReport(target);\n }\n return await raceProbe(target, probe, Date.now());\n}\n\nasync function probeTarget(\n target: ProbeTarget,\n setupErrors: ReadonlyMap<string, string>,\n starting: boolean,\n): Promise<AdapterReportFull> {\n const setupError = setupErrors.get(`${target.metadata.category}/${target.metadata.kind}`);\n if (setupError !== undefined) {\n return setupFailedReport(target, sanitize(setupError));\n }\n if (starting) {\n return startingReport(target);\n }\n return await probeHealth(target);\n}\n\nfunction collectSetupErrors(settled: AdapterSetupResult | null): ReadonlyMap<string, string> {\n const setupErrors = new Map<string, string>();\n if (settled) {\n for (const failure of settled.failures) {\n setupErrors.set(`${failure.category}/${failure.kind}`, failure.error);\n }\n }\n return setupErrors;\n}\n\nfunction overallStatus(reports: AdapterReportFull[]): HealthReport[\"status\"] {\n if (reports.some((report) => report.state === \"failed\")) {\n return \"degraded\";\n }\n if (reports.some((report) => report.state === \"starting\")) {\n return \"starting\";\n }\n return \"ok\";\n}\n\n/** Pseudo-adapter entry reporting whether stored credentials decrypt with the configured secret. */\nfunction credentialsEntry(probe: CredentialProbe): AdapterReportFull {\n const unreadable = probe.unreadable.length;\n let detail: string | undefined;\n if (unreadable > 0) {\n detail = `${unreadable} stored credential(s) cannot be decrypted; check SECRET and SECRET_PREVIOUS`;\n } else if (probe.previous > 0) {\n detail = `${probe.previous} credential(s) still encrypted with the previous secret; re-encrypt from the System page`;\n }\n return {\n category: \"credentials\",\n kind: \"secret\",\n name: \"Credential encryption\",\n version: \"-\",\n hasLifecycle: false,\n state: unreadable > 0 ? \"failed\" : \"ok\",\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\n/**\n * Probe every wired adapter and assemble the full health report.\n *\n * @param sources - Live adapter instances to probe.\n * @param settled - Setup settlement (null while the eager run is in flight).\n * @param bootTimeMs - Process boot timestamp for uptime.\n * @param version - Server version for the report header.\n * @param credentials - Stored-credential probe (omit when no secret is configured).\n */\nexport async function collectHealthReport(\n sources: AdapterSetupSources,\n settled: AdapterSetupResult | null,\n bootTimeMs: number,\n version: string,\n credentials?: CredentialProbe | null,\n): Promise<HealthReport> {\n const setupErrors = collectSetupErrors(settled);\n const targets = targetsOf(sources);\n const adapters = await Promise.all(\n targets.map(async (target) => await probeTarget(target, setupErrors, settled === null)),\n );\n if (credentials) {\n adapters.push(credentialsEntry(credentials));\n }\n return {\n status: overallStatus(adapters),\n uptimeSecs: Math.floor((Date.now() - bootTimeMs) / 1000),\n version,\n adapters,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"health-report.mjs","names":[],"sources":["../../src/routers/health-report.ts"],"sourcesContent":["import type {\n AdapterHealth,\n AdapterLifecycle,\n AdapterMetadata,\n} from \"@storyshelf/core/adapter/metadata\";\n/**\n * Shared adapter health probing for the JSON API and the admin System page.\n *\n * `POST /api/v1/health` and `GET /admin` both consume\n * {@link collectHealthReport}; the API strips the page-only metadata fields\n * so its response shape never changes. Probes stay parallel, time-bounded,\n * and secret-free (details are truncated, never raw config).\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { CredentialProbe } from \"@storyshelf/core/models\";\nimport { sanitizeErrorText } from \"@storyshelf/core/utils\";\n\nexport type AdapterState = \"ok\" | \"starting\" | \"failed\";\n\n/** Per-adapter readiness entry with identity for the admin page. */\nexport interface AdapterReportFull {\n category: string;\n kind: string;\n name: string;\n version: string;\n description?: string;\n hasLifecycle: boolean;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Collected health report: overall status plus one entry per adapter. */\nexport interface HealthReport {\n status: \"ok\" | \"starting\" | \"degraded\";\n uptimeSecs: number;\n version: string;\n adapters: AdapterReportFull[];\n}\n\ninterface ProbeTarget {\n readonly metadata: AdapterMetadata;\n readonly lifecycle?: AdapterLifecycle;\n}\n\nconst PROBE_TIMEOUT_MS = 2000;\nconst DETAIL_MAX_LENGTH = 500;\n\nfunction pushTarget(targets: ProbeTarget[], adapter: ProbeTarget | undefined): void {\n if (adapter) {\n targets.push(adapter);\n }\n}\n\nfunction pushAll(targets: ProbeTarget[], adapters: ReadonlyArray<ProbeTarget | undefined>): void {\n for (const adapter of adapters) {\n pushTarget(targets, adapter);\n }\n}\n\nfunction targetsOf(sources: AdapterSetupSources): ProbeTarget[] {\n const targets: ProbeTarget[] = [];\n pushAll(targets, [sources.database, sources.storage, sources.captureRunner]);\n pushAll(targets, sources.design ?? []);\n pushAll(targets, sources.notifiers ?? []);\n pushTarget(targets, sources.emailSender);\n return targets;\n}\n\nfunction sanitize(detail: string): string {\n return sanitizeErrorText(detail, DETAIL_MAX_LENGTH);\n}\n\nfunction messageOf(reason: unknown): string {\n const message = reason instanceof Error ? reason.message : String(reason);\n return sanitize(message);\n}\n\nasync function timeoutError(): Promise<never> {\n await new Promise<void>((resolve) => {\n setTimeout(resolve, PROBE_TIMEOUT_MS);\n });\n throw new Error(\"health probe timed out\");\n}\n\nfunction baseReport(\n target: ProbeTarget,\n): Omit<AdapterReportFull, \"state\" | \"latencyMs\" | \"detail\"> {\n const { category, kind, name, version, description } = target.metadata;\n return {\n category,\n kind,\n name,\n version,\n ...(description === undefined ? {} : { description }),\n hasLifecycle: target.lifecycle?.health !== undefined,\n };\n}\n\nfunction okReport(\n target: ProbeTarget,\n latencyMs: number,\n detail: string | undefined,\n): AdapterReportFull {\n return {\n ...baseReport(target),\n state: \"ok\",\n latencyMs,\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\nfunction failedReport(target: ProbeTarget, latencyMs: number, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", latencyMs, detail };\n}\n\nfunction setupFailedReport(target: ProbeTarget, detail: string): AdapterReportFull {\n return { ...baseReport(target), state: \"failed\", detail };\n}\n\nfunction startingReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"starting\" };\n}\n\nfunction noLifecycleReport(target: ProbeTarget): AdapterReportFull {\n return { ...baseReport(target), state: \"ok\" };\n}\n\nfunction detailOf(result: AdapterHealth, fallback: string): string {\n return result.detail === undefined ? fallback : sanitize(result.detail);\n}\n\nasync function raceProbe(\n target: ProbeTarget,\n probe: () => Promise<AdapterHealth>,\n started: number,\n): Promise<AdapterReportFull> {\n try {\n const result = await Promise.race([probe(), timeoutError()]);\n const latencyMs = Date.now() - started;\n if (!result.ok) {\n return failedReport(target, latencyMs, detailOf(result, \"probe reported failure\"));\n }\n return okReport(\n target,\n latencyMs,\n result.detail === undefined ? undefined : sanitize(result.detail),\n );\n } catch (error) {\n return failedReport(target, Date.now() - started, messageOf(error));\n }\n}\n\nasync function probeHealth(target: ProbeTarget): Promise<AdapterReportFull> {\n const probe = target.lifecycle?.health;\n if (!probe) {\n return noLifecycleReport(target);\n }\n return await raceProbe(target, probe, Date.now());\n}\n\nasync function probeTarget(\n target: ProbeTarget,\n setupErrors: ReadonlyMap<string, string>,\n starting: boolean,\n): Promise<AdapterReportFull> {\n const setupError = setupErrors.get(`${target.metadata.category}/${target.metadata.kind}`);\n if (setupError !== undefined) {\n return setupFailedReport(target, sanitize(setupError));\n }\n if (starting) {\n return startingReport(target);\n }\n return await probeHealth(target);\n}\n\nfunction collectSetupErrors(settled: AdapterSetupResult | null): ReadonlyMap<string, string> {\n const setupErrors = new Map<string, string>();\n if (settled) {\n for (const failure of settled.failures) {\n setupErrors.set(`${failure.category}/${failure.kind}`, failure.error);\n }\n }\n return setupErrors;\n}\n\nfunction overallStatus(reports: AdapterReportFull[]): HealthReport[\"status\"] {\n if (reports.some((report) => report.state === \"failed\")) {\n return \"degraded\";\n }\n if (reports.some((report) => report.state === \"starting\")) {\n return \"starting\";\n }\n return \"ok\";\n}\n\n/** Pseudo-adapter entry reporting whether stored credentials decrypt with the configured secret. */\nfunction credentialsEntry(probe: CredentialProbe): AdapterReportFull {\n const unreadable = probe.unreadable.length;\n let detail: string | undefined;\n if (unreadable > 0) {\n detail = `${unreadable} stored credential(s) cannot be decrypted; check SECRET and SECRET_PREVIOUS`;\n } else if (probe.previous > 0) {\n detail = `${probe.previous} credential(s) still encrypted with the previous secret; re-encrypt from the System page`;\n }\n return {\n category: \"credentials\",\n kind: \"secret\",\n name: \"Credential encryption\",\n version: \"-\",\n hasLifecycle: false,\n state: unreadable > 0 ? \"failed\" : \"ok\",\n ...(detail === undefined ? {} : { detail }),\n };\n}\n\n/**\n * Probe every wired adapter and assemble the full health report.\n *\n * @param sources - Live adapter instances to probe.\n * @param settled - Setup settlement (null while the eager run is in flight).\n * @param bootTimeMs - Process boot timestamp for uptime.\n * @param version - Server version for the report header.\n * @param credentials - Stored-credential probe (omit when no secret is configured).\n */\nexport async function collectHealthReport(\n sources: AdapterSetupSources,\n settled: AdapterSetupResult | null,\n bootTimeMs: number,\n version: string,\n credentials?: CredentialProbe | null,\n): Promise<HealthReport> {\n const setupErrors = collectSetupErrors(settled);\n const targets = targetsOf(sources);\n const adapters = await Promise.all(\n targets.map(async (target) => await probeTarget(target, setupErrors, settled === null)),\n );\n if (credentials) {\n adapters.push(credentialsEntry(credentials));\n }\n return {\n status: overallStatus(adapters),\n uptimeSecs: Math.floor((Date.now() - bootTimeMs) / 1000),\n version,\n adapters,\n };\n}\n"],"mappings":"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"}
@@ -1,10 +1,10 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { getStore } from "../store.mjs";
4
+ import { getStore, testingEnabled } from "../store.mjs";
5
5
  import { loadCredentialProbe } from "../credentials.mjs";
6
- import { collectHealthReport } from "./health-report.mjs";
7
6
  import { requireSiteAdmin } from "./helpers.mjs";
7
+ import { collectHealthReport } from "./health-report.mjs";
8
8
  //#region src/routers/health.ts
9
9
  /** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */
10
10
  function registerHealth(app, deps) {
@@ -12,7 +12,8 @@ function registerHealth(app, deps) {
12
12
  const body = {
13
13
  status: "ok",
14
14
  uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1e3),
15
- version: deps.version
15
+ version: deps.version,
16
+ testing: testingEnabled()
16
17
  };
17
18
  c.header("Cache-Control", "no-store");
18
19
  return c.json(body);
@@ -1 +1 @@
1
- {"version":3,"file":"health.mjs","names":[],"sources":["../../src/routers/health.ts"],"sourcesContent":["/**\n * Two-tier health: lightweight liveness (GET, no auth) and deep readiness\n * (POST, site-admin). Neither is gated by adapter setup — liveness must\n * answer mid-migration so orchestrators don't kill the machine, and deep\n * reports the setup settlement state instead of being masked by the gate.\n *\n * Probing lives in `health-report.ts` (shared with the admin System page);\n * this module keeps the API response shape stable.\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe } from \"../credentials.ts\";\nimport { getStore } from \"../store.ts\";\nimport { collectHealthReport, type AdapterState } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\n\n/** Dependencies for the health routes (no adapter I/O on the GET path). */\nexport interface HealthDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Liveness body: process-level only, safe for unauthenticated probes. */\nexport interface LivenessBody {\n status: \"ok\";\n uptimeSecs: number;\n version: string;\n}\n\n/** Per-adapter readiness entry (error text sanitized — no secrets). */\nexport interface AdapterReport {\n category: string;\n kind: string;\n name: string;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Deep readiness body. */\nexport interface ReadinessBody {\n status: \"ok\" | \"starting\" | \"degraded\";\n adapters: AdapterReport[];\n}\n\n/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */\nexport function registerHealth(app: ShelfRouter, deps: HealthDeps): void {\n app.get(\"/api/v1/health\", (c) => {\n const body: LivenessBody = {\n status: \"ok\",\n uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1000),\n version: deps.version,\n };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body);\n });\n\n app.post(\"/api/v1/health\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n await loadCredentialProbe(db, config),\n );\n const adapters: AdapterReport[] = report.adapters.map((adapter) => {\n const entry: AdapterReport = {\n category: adapter.category,\n kind: adapter.kind,\n name: adapter.name,\n state: adapter.state,\n };\n if (adapter.latencyMs !== undefined) {\n entry.latencyMs = adapter.latencyMs;\n }\n if (adapter.detail !== undefined) {\n entry.detail = adapter.detail;\n }\n return entry;\n });\n const body: ReadinessBody = { status: report.status, adapters };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body, body.status === \"ok\" ? 200 : 503);\n });\n}\n"],"mappings":";;;;;;;;;AAgDA,SAAgB,eAAe,KAAkB,MAAwB;CACvE,IAAI,IAAI,mBAAmB,MAAM;EAC/B,MAAM,OAAqB;GACzB,QAAQ;GACR,YAAY,KAAK,OAAO,KAAK,IAAI,IAAI,KAAK,cAAc,GAAI;GAC5D,SAAS,KAAK;EAChB;EACA,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,IAAI;CACpB,CAAC;CAED,IAAI,KAAK,kBAAkB,OAAO,MAAM;EACtC,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,MAAM,oBAAoB,IAAI,MAAM,CACtC;EACA,MAAM,WAA4B,OAAO,SAAS,KAAK,YAAY;GACjE,MAAM,QAAuB;IAC3B,UAAU,QAAQ;IAClB,MAAM,QAAQ;IACd,MAAM,QAAQ;IACd,OAAO,QAAQ;GACjB;GACA,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,YAAY,QAAQ;GAE5B,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ;GAEzB,OAAO;EACT,CAAC;EACD,MAAM,OAAsB;GAAE,QAAQ,OAAO;GAAQ;EAAS;EAC9D,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,MAAM,KAAK,WAAW,OAAO,MAAM,GAAG;CACtD,CAAC;AACH"}
1
+ {"version":3,"file":"health.mjs","names":[],"sources":["../../src/routers/health.ts"],"sourcesContent":["/**\n * Two-tier health: lightweight liveness (GET, no auth) and deep readiness\n * (POST, site-admin). Neither is gated by adapter setup — liveness must\n * answer mid-migration so orchestrators don't kill the machine, and deep\n * reports the setup settlement state instead of being masked by the gate.\n *\n * Probing lives in `health-report.ts` (shared with the admin System page);\n * this module keeps the API response shape stable.\n */\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe } from \"../credentials.ts\";\nimport { getStore, testingEnabled } from \"../store.ts\";\nimport { collectHealthReport, type AdapterState } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\n\n/** Dependencies for the health routes (no adapter I/O on the GET path). */\nexport interface HealthDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Liveness body: process-level only, safe for unauthenticated probes. */\nexport interface LivenessBody {\n status: \"ok\";\n uptimeSecs: number;\n version: string;\n /** Whether this instance has the `testing` option (capture, compare, baselines; ADR 0006). */\n testing: boolean;\n}\n\n/** Per-adapter readiness entry (error text sanitized — no secrets). */\nexport interface AdapterReport {\n category: string;\n kind: string;\n name: string;\n state: AdapterState;\n latencyMs?: number;\n detail?: string;\n}\n\n/** Deep readiness body. */\nexport interface ReadinessBody {\n status: \"ok\" | \"starting\" | \"degraded\";\n adapters: AdapterReport[];\n}\n\n/** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */\nexport function registerHealth(app: ShelfRouter, deps: HealthDeps): void {\n app.get(\"/api/v1/health\", (c) => {\n const body: LivenessBody = {\n status: \"ok\",\n uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1000),\n version: deps.version,\n testing: testingEnabled(),\n };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body);\n });\n\n app.post(\"/api/v1/health\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n await loadCredentialProbe(db, config),\n );\n const adapters: AdapterReport[] = report.adapters.map((adapter) => {\n const entry: AdapterReport = {\n category: adapter.category,\n kind: adapter.kind,\n name: adapter.name,\n state: adapter.state,\n };\n if (adapter.latencyMs !== undefined) {\n entry.latencyMs = adapter.latencyMs;\n }\n if (adapter.detail !== undefined) {\n entry.detail = adapter.detail;\n }\n return entry;\n });\n const body: ReadinessBody = { status: report.status, adapters };\n c.header(\"Cache-Control\", \"no-store\");\n return c.json(body, body.status === \"ok\" ? 200 : 503);\n });\n}\n"],"mappings":";;;;;;;;;AAkDA,SAAgB,eAAe,KAAkB,MAAwB;CACvE,IAAI,IAAI,mBAAmB,MAAM;EAC/B,MAAM,OAAqB;GACzB,QAAQ;GACR,YAAY,KAAK,OAAO,KAAK,IAAI,IAAI,KAAK,cAAc,GAAI;GAC5D,SAAS,KAAK;GACd,SAAS,eAAe;EAC1B;EACA,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,IAAI;CACpB,CAAC;CAED,IAAI,KAAK,kBAAkB,OAAO,MAAM;EACtC,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,MAAM,oBAAoB,IAAI,MAAM,CACtC;EACA,MAAM,WAA4B,OAAO,SAAS,KAAK,YAAY;GACjE,MAAM,QAAuB;IAC3B,UAAU,QAAQ;IAClB,MAAM,QAAQ;IACd,MAAM,QAAQ;IACd,OAAO,QAAQ;GACjB;GACA,IAAI,QAAQ,cAAc,KAAA,GACxB,MAAM,YAAY,QAAQ;GAE5B,IAAI,QAAQ,WAAW,KAAA,GACrB,MAAM,SAAS,QAAQ;GAEzB,OAAO;EACT,CAAC;EACD,MAAM,OAAsB;GAAE,QAAQ,OAAO;GAAQ;EAAS;EAC9D,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,MAAM,KAAK,WAAW,OAAO,MAAM,GAAG;CACtD,CAAC;AACH"}
@@ -2,8 +2,8 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
- import { MemberModel, ProjectModel, TokenModel, UserModel } from "@storyshelf/core/models";
6
5
  import { sha256, timingSafeEqualString } from "@storyshelf/core/utils";
6
+ import { MemberModel, ProjectModel, TokenModel, UserModel } from "@storyshelf/core/models";
7
7
  import { HTTPException } from "hono/http-exception";
8
8
  //#region src/routers/helpers.ts
9
9
  /** Throw a 401 Unauthorized error for unauthenticated requests. */
@@ -31,7 +31,7 @@ async function findProjectBySlug(slug) {
31
31
  }
32
32
  async function resolveProjectByToken(c, slug) {
33
33
  const authHeader = c.req.header("authorization");
34
- if (authHeader?.startsWith("Bearer ")) {
34
+ if (authHeader?.startsWith("Bearer ") && !requestHasAdminToken(c)) {
35
35
  const token = authHeader.slice(7);
36
36
  const found = await new TokenModel(getStore().db).findByHash(sha256(token));
37
37
  if (!found) unauthorized();
@@ -97,6 +97,22 @@ async function resolveAuthorizedProject(c, slug, ...minRoles) {
97
97
  await assertRole(found.id, ...minRoles);
98
98
  return found;
99
99
  }
100
+ /**
101
+ * Who a verdict is attributed to (ADR 0013, decision 3): the session user, or the user who owns
102
+ * the bearer token. `null` only when the instance has no auth adapter at all (one anonymous
103
+ * reviewer). With auth on, a caller that cannot be traced to a user (the bootstrap admin token, a
104
+ * token without an owner) is refused.
105
+ */
106
+ async function resolveReviewer(c) {
107
+ const { authEnabled, user, db } = getStore();
108
+ if (!authEnabled) return null;
109
+ if (user) return user.id;
110
+ const header = c.req.header("authorization");
111
+ if (!header?.startsWith("Bearer ")) return unauthorized();
112
+ const token = requestHasAdminToken(c) ? null : await new TokenModel(db).findByHash(sha256(header.slice(7)));
113
+ if (!token?.userId || !await new UserModel(db).get(token.userId)) throw new HTTPException(403, { message: "A verdict needs a user: use a token created by the reviewer or sign in" });
114
+ return token.userId;
115
+ }
100
116
  /** Require a site-level admin (or permit when auth is disabled). */
101
117
  function requireSiteAdmin(c) {
102
118
  if (!getStore().authEnabled) return;
@@ -105,6 +121,6 @@ function requireSiteAdmin(c) {
105
121
  forbidden();
106
122
  }
107
123
  //#endregion
108
- export { assertRole, currentProjectRole, findProjectBySlug, forbidden, notFound, requestHasAdminToken, requireSiteAdmin, resolveAuthorizedProject, unauthorized };
124
+ export { assertRole, currentProjectRole, findProjectBySlug, forbidden, notFound, requestHasAdminToken, requireSiteAdmin, resolveAuthorizedProject, resolveReviewer, unauthorized };
109
125
 
110
126
  //# sourceMappingURL=helpers.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { TokenModel } from \"@storyshelf/core/models\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { sha256, timingSafeEqualString } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\nimport { getStore } from \"../store.ts\";\n/** Send a JSON response with the given status code. */\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\n/** Parse the request body as JSON and validate it against a Zod schema. */\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\n/** Throw a 401 Unauthorized error for unauthenticated requests. */\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\n/** Throw a 403 Forbidden error for requests lacking the required role. */\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\n/** Throw a 404 Not Found error with a custom message. */\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\n/** Resolve the current session user's effective role on a project. */\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Build middleware that requires one of the given project roles. */\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\n/** Look up a project by its URL slug, returning null when absent. */\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n return await new ProjectModel(db).getBySlug(slug);\n}\n\nasync function resolveProjectByToken(\n c: Context,\n slug: string,\n): Promise<{ project: Project; token: Token } | null> {\n const authHeader = c.req.header(\"authorization\");\n if (authHeader?.startsWith(\"Bearer \")) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return { project, token: found };\n }\n return null;\n}\n\n/**\n * Resolve a bearer token to an effective project role.\n * Tokens without an owner (legacy pre-binding rows) resolve as viewer;\n * tokens whose owner no longer exists resolve to null (deny).\n */\nasync function tokenProjectRole(token: Token, projectId: string): Promise<ProjectRole | null> {\n if (!token.userId) {\n return \"viewer\";\n }\n const { db } = getStore();\n const user = await new UserModel(db).get(token.userId);\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Resolve a project by slug, honoring CLI bearer-token access. */\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * True when the request bears the configured bootstrap admin token.\n * Covers site-admin routes AND project routes (as admin) so a fresh\n * instance is fully operable before any user exists. Timing-safe;\n * unset `adminToken` never matches.\n */\nexport function requestHasAdminToken(c: Context): boolean {\n const adminToken = getStore().config.adminToken;\n if (!adminToken) {\n return false;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return false;\n }\n return timingSafeEqualString(header.slice(\"Bearer \".length), adminToken);\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bootstrap admin token -> grants access as admin (see `requestHasAdminToken`).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\n/** Resolve a project by slug and enforce the caller's minimum role. */\nexport async function resolveAuthorizedProject(\n c: Context,\n slug: string,\n ...minRoles: ProjectRole[]\n): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n const role = await tokenProjectRole(viaToken.token, viaToken.project.id);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n if (requestHasAdminToken(c)) {\n return found;\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(c: Context): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role === \"admin\" || requestHasAdminToken(c)) {\n return;\n }\n forbidden();\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(\n projectId: string,\n ...minRoles: ProjectRole[]\n): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"helpers.mjs","names":[],"sources":["../../src/routers/helpers.ts"],"sourcesContent":["import { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { TokenModel } from \"@storyshelf/core/models\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { sha256, timingSafeEqualString } from \"@storyshelf/core/utils\";\nimport type { Context, Next } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { ZodType } from \"zod\";\nimport { getStore } from \"../store.ts\";\n/** Send a JSON response with the given status code. */\nexport function json(c: Context, data: unknown, status: ContentfulStatusCode = 200): Response {\n return c.json(data, status);\n}\n\n/** Parse the request body as JSON and validate it against a Zod schema. */\nexport async function validJson<T>(c: Context, schema: ZodType<T>): Promise<T> {\n const result = schema.safeParse(await c.req.json());\n if (!result.success) {\n throw new HTTPException(400, { message: result.error.message });\n }\n return result.data;\n}\n\n/** Throw a 401 Unauthorized error for unauthenticated requests. */\nexport function unauthorized(): never {\n throw new HTTPException(401, { message: \"Unauthorized\" });\n}\n\n/** Throw a 403 Forbidden error for requests lacking the required role. */\nexport function forbidden(): never {\n throw new HTTPException(403, { message: \"Forbidden\" });\n}\n\n/** Throw a 404 Not Found error with a custom message. */\nexport function notFound(message = \"Not found\"): never {\n throw new HTTPException(404, { message });\n}\n\n/** Resolve the current session user's effective role on a project. */\nexport async function currentProjectRole(projectId: string): Promise<ProjectRole | null> {\n const { db, user } = getStore();\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Build middleware that requires one of the given project roles. */\nexport function requireRole(...roles: ProjectRole[]) {\n return async (c: Context, next: Next): Promise<void> => {\n const projectId = c.req.param(\"projectId\") ?? c.req.param(\"slug\");\n if (!projectId) {\n forbidden();\n }\n const role = await currentProjectRole(projectId);\n if (!role || !roles.includes(role)) {\n forbidden();\n }\n await next();\n };\n}\n\n/** Look up a project by its URL slug, returning null when absent. */\nexport async function findProjectBySlug(slug: string): Promise<Project | null> {\n const { db } = getStore();\n return await new ProjectModel(db).getBySlug(slug);\n}\n\nasync function resolveProjectByToken(\n c: Context,\n slug: string,\n): Promise<{ project: Project; token: Token } | null> {\n const authHeader = c.req.header(\"authorization\");\n // The bootstrap admin token is not a project token: let callers grant it as admin.\n if (authHeader?.startsWith(\"Bearer \") && !requestHasAdminToken(c)) {\n const token = authHeader.slice(\"Bearer \".length);\n const found = await new TokenModel(getStore().db).findByHash(sha256(token));\n if (!found) {\n unauthorized();\n }\n const project = await new ProjectModel(getStore().db).get(found.projectId);\n if (!project || project.slug !== slug) {\n forbidden();\n }\n return { project, token: found };\n }\n return null;\n}\n\n/**\n * Resolve a bearer token to an effective project role.\n * Tokens without an owner (legacy pre-binding rows) resolve as viewer;\n * tokens whose owner no longer exists resolve to null (deny).\n */\nasync function tokenProjectRole(token: Token, projectId: string): Promise<ProjectRole | null> {\n if (!token.userId) {\n return \"viewer\";\n }\n const { db } = getStore();\n const user = await new UserModel(db).get(token.userId);\n if (!user) {\n return null;\n }\n return await new MemberModel(db).effectiveRole(user.role, projectId, user.id);\n}\n\n/** Resolve a project by slug, honoring CLI bearer-token access. */\nexport async function resolveProject(c: Context, slug: string): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n return found;\n}\n\n/**\n * True when the request bears the configured bootstrap admin token.\n * Covers site-admin routes AND project routes (as admin) so a fresh\n * instance is fully operable before any user exists. Timing-safe;\n * unset `adminToken` never matches.\n */\nexport function requestHasAdminToken(c: Context): boolean {\n const adminToken = getStore().config.adminToken;\n if (!adminToken) {\n return false;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return false;\n }\n return timingSafeEqualString(header.slice(\"Bearer \".length), adminToken);\n}\n\n/**\n * Enforce the caller's project role for a given project.\n *\n * Authorization model (ADR 0008):\n * - No auth adapter configured -> all operations permitted (development mode).\n * - Bootstrap admin token -> grants access as admin (see `requestHasAdminToken`).\n * - Bearer token (CLI) -> grants access to its own project regardless of role.\n * - Session user -> must have an effective role in `minRoles`.\n */\nexport async function assertRole(projectId: string, ...minRoles: ProjectRole[]): Promise<void> {\n if (!getStore().authEnabled) {\n return;\n }\n const role = await currentProjectRole(projectId);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n}\n\n/** Resolve a project by slug and enforce the caller's minimum role. */\nexport async function resolveAuthorizedProject(\n c: Context,\n slug: string,\n ...minRoles: ProjectRole[]\n): Promise<Project> {\n const viaToken = await resolveProjectByToken(c, slug);\n if (viaToken) {\n const role = await tokenProjectRole(viaToken.token, viaToken.project.id);\n if (!role || !minRoles.includes(role)) {\n forbidden();\n }\n return viaToken.project;\n }\n const found = await findProjectBySlug(slug);\n if (!found) {\n notFound(\"Project not found\");\n }\n if (requestHasAdminToken(c)) {\n return found;\n }\n await assertRole(found.id, ...minRoles);\n return found;\n}\n\n/**\n * Who a verdict is attributed to (ADR 0013, decision 3): the session user, or the user who owns\n * the bearer token. `null` only when the instance has no auth adapter at all (one anonymous\n * reviewer). With auth on, a caller that cannot be traced to a user (the bootstrap admin token, a\n * token without an owner) is refused.\n */\nexport async function resolveReviewer(c: Context): Promise<string | null> {\n const { authEnabled, user, db } = getStore();\n if (!authEnabled) {\n return null;\n }\n if (user) {\n return user.id;\n }\n const header = c.req.header(\"authorization\");\n if (!header?.startsWith(\"Bearer \")) {\n return unauthorized();\n }\n const token = requestHasAdminToken(c)\n ? null\n : await new TokenModel(db).findByHash(sha256(header.slice(\"Bearer \".length)));\n if (!token?.userId || !(await new UserModel(db).get(token.userId))) {\n throw new HTTPException(403, {\n message: \"A verdict needs a user: use a token created by the reviewer or sign in\",\n });\n }\n return token.userId;\n}\n\n/** Require a site-level admin (or permit when auth is disabled). */\nexport function requireSiteAdmin(c: Context): void {\n if (!getStore().authEnabled) {\n return;\n }\n const { user } = getStore();\n if (user?.role === \"admin\" || requestHasAdminToken(c)) {\n return;\n }\n forbidden();\n}\n\n/** Require the current session user to hold one of the given project roles. */\nexport async function requireProjectRole(\n projectId: string,\n ...minRoles: ProjectRole[]\n): Promise<void> {\n await assertRole(projectId, ...minRoles);\n}\n"],"mappings":"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"}
@@ -0,0 +1,64 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { notFound } from "./helpers.mjs";
5
+ import { buildForProject } from "./builds.handlers.mjs";
6
+ import { loadHealthPanel } from "../insights/health-panel.mjs";
7
+ import { loadInsightPanel } from "../insights/panel.mjs";
8
+ import { requestHealth, requestTriage } from "../insights/request.mjs";
9
+ import { InsightPanel } from "../pages/build-diff-insight.mjs";
10
+ import { HealthPanel } from "../pages/health-panel.mjs";
11
+ import { projectForRegenerate, projectForView } from "./insights.handlers.mjs";
12
+ import { jsx } from "hono/jsx/jsx-runtime";
13
+ //#region src/routers/insights-ui.tsx
14
+ /** Register the HTMX fragments for the project health panel. */
15
+ function registerHealthFragments(app) {
16
+ app.get("/projects/:slug/insights/health/panel", async (c) => {
17
+ const { project } = await projectForView(c, c.req.param("slug"));
18
+ const data = await loadHealthPanel(project);
19
+ return data ? c.html(/* @__PURE__ */ jsx(HealthPanel, {
20
+ project,
21
+ data
22
+ })) : notFound();
23
+ });
24
+ app.post("/projects/:slug/insights/health/generate", async (c) => {
25
+ const { deps, project } = await projectForRegenerate(c, c.req.param("slug"));
26
+ const form = await c.req.formData();
27
+ await requestHealth(deps, project, "30d", { force: form.get("force") === "true" });
28
+ const data = await loadHealthPanel(project);
29
+ return data ? c.html(/* @__PURE__ */ jsx(HealthPanel, {
30
+ project,
31
+ data
32
+ })) : notFound();
33
+ });
34
+ }
35
+ /** Register the HTMX fragments for the build-review AI triage panel. */
36
+ function registerInsightFragments(app) {
37
+ registerHealthFragments(app);
38
+ app.get("/projects/:slug/builds/:buildId/insights/panel", async (c) => {
39
+ const { project } = await projectForView(c, c.req.param("slug"));
40
+ const build = await buildForProject(project.id, c.req.param("buildId"));
41
+ const data = await loadInsightPanel(project, build);
42
+ return data ? c.html(/* @__PURE__ */ jsx(InsightPanel, {
43
+ project,
44
+ build,
45
+ data
46
+ })) : notFound();
47
+ });
48
+ app.post("/projects/:slug/builds/:buildId/insights/generate", async (c) => {
49
+ const { deps, project } = await projectForRegenerate(c, c.req.param("slug"));
50
+ const build = await buildForProject(project.id, c.req.param("buildId"));
51
+ const form = await c.req.formData();
52
+ await requestTriage(deps, project, build, { force: form.get("force") === "true" });
53
+ const data = await loadInsightPanel(project, build);
54
+ return data ? c.html(/* @__PURE__ */ jsx(InsightPanel, {
55
+ project,
56
+ build,
57
+ data
58
+ })) : notFound();
59
+ });
60
+ }
61
+ //#endregion
62
+ export { registerInsightFragments };
63
+
64
+ //# sourceMappingURL=insights-ui.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"insights-ui.mjs","names":[],"sources":["../../src/routers/insights-ui.tsx"],"sourcesContent":["import type { ShelfRouter } from \"../app-types.ts\";\nimport { HEALTH_UI_WINDOW, loadHealthPanel } from \"../insights/health-panel.ts\";\nimport { loadInsightPanel } from \"../insights/panel.ts\";\nimport { requestHealth, requestTriage } from \"../insights/request.ts\";\nimport { InsightPanel } from \"../pages/build-diff-insight.tsx\";\nimport { HealthPanel } from \"../pages/health-panel.tsx\";\nimport { buildForProject } from \"./builds.handlers.ts\";\nimport { notFound } from \"./helpers.ts\";\nimport { projectForRegenerate, projectForView } from \"./insights.handlers.ts\";\n\n/** Register the HTMX fragments for the project health panel. */\nfunction registerHealthFragments(app: ShelfRouter): void {\n app.get(\"/projects/:slug/insights/health/panel\", async (c) => {\n const { project } = await projectForView(c, c.req.param(\"slug\"));\n const data = await loadHealthPanel(project);\n return data ? c.html(<HealthPanel project={project} data={data} />) : notFound();\n });\n\n app.post(\"/projects/:slug/insights/health/generate\", async (c) => {\n const { deps, project } = await projectForRegenerate(c, c.req.param(\"slug\"));\n const form = await c.req.formData();\n await requestHealth(deps, project, HEALTH_UI_WINDOW, { force: form.get(\"force\") === \"true\" });\n const data = await loadHealthPanel(project);\n return data ? c.html(<HealthPanel project={project} data={data} />) : notFound();\n });\n}\n\n/** Register the HTMX fragments for the build-review AI triage panel. */\nexport function registerInsightFragments(app: ShelfRouter): void {\n registerHealthFragments(app);\n app.get(\"/projects/:slug/builds/:buildId/insights/panel\", async (c) => {\n const { project } = await projectForView(c, c.req.param(\"slug\"));\n const build = await buildForProject(project.id, c.req.param(\"buildId\"));\n const data = await loadInsightPanel(project, build);\n return data ? c.html(<InsightPanel project={project} build={build} data={data} />) : notFound();\n });\n\n app.post(\"/projects/:slug/builds/:buildId/insights/generate\", async (c) => {\n const { deps, project } = await projectForRegenerate(c, c.req.param(\"slug\"));\n const build = await buildForProject(project.id, c.req.param(\"buildId\"));\n const form = await c.req.formData();\n await requestTriage(deps, project, build, { force: form.get(\"force\") === \"true\" });\n const data = await loadInsightPanel(project, build);\n return data ? c.html(<InsightPanel project={project} build={build} data={data} />) : notFound();\n });\n}\n"],"mappings":";;;;;;;;;;;;;;AAWA,SAAS,wBAAwB,KAAwB;CACvD,IAAI,IAAI,yCAAyC,OAAO,MAAM;EAC5D,MAAM,EAAE,YAAY,MAAM,eAAe,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC/D,MAAM,OAAO,MAAM,gBAAgB,OAAO;EAC1C,OAAO,OAAO,EAAE,KAAK,oBAAC,aAAD;GAAsB;GAAe;EAAO,CAAA,CAAC,IAAI,SAAS;CACjF,CAAC;CAED,IAAI,KAAK,4CAA4C,OAAO,MAAM;EAChE,MAAM,EAAE,MAAM,YAAY,MAAM,qBAAqB,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC3E,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,cAAc,MAAM,SAAA,OAA2B,EAAE,OAAO,KAAK,IAAI,OAAO,MAAM,OAAO,CAAC;EAC5F,MAAM,OAAO,MAAM,gBAAgB,OAAO;EAC1C,OAAO,OAAO,EAAE,KAAK,oBAAC,aAAD;GAAsB;GAAe;EAAO,CAAA,CAAC,IAAI,SAAS;CACjF,CAAC;AACH;;AAGA,SAAgB,yBAAyB,KAAwB;CAC/D,wBAAwB,GAAG;CAC3B,IAAI,IAAI,kDAAkD,OAAO,MAAM;EACrE,MAAM,EAAE,YAAY,MAAM,eAAe,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC/D,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,EAAE,IAAI,MAAM,SAAS,CAAC;EACtE,MAAM,OAAO,MAAM,iBAAiB,SAAS,KAAK;EAClD,OAAO,OAAO,EAAE,KAAK,oBAAC,cAAD;GAAuB;GAAgB;GAAa;EAAO,CAAA,CAAC,IAAI,SAAS;CAChG,CAAC;CAED,IAAI,KAAK,qDAAqD,OAAO,MAAM;EACzE,MAAM,EAAE,MAAM,YAAY,MAAM,qBAAqB,GAAG,EAAE,IAAI,MAAM,MAAM,CAAC;EAC3E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,EAAE,IAAI,MAAM,SAAS,CAAC;EACtE,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,cAAc,MAAM,SAAS,OAAO,EAAE,OAAO,KAAK,IAAI,OAAO,MAAM,OAAO,CAAC;EACjF,MAAM,OAAO,MAAM,iBAAiB,SAAS,KAAK;EAClD,OAAO,OAAO,EAAE,KAAK,oBAAC,cAAD;GAAuB;GAAgB;GAAa;EAAO,CAAA,CAAC,IAAI,SAAS;CAChG,CAAC;AACH"}
@@ -0,0 +1,45 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { failWith } from "../insights/errors.mjs";
6
+ import { assertRole, requestHasAdminToken, resolveAuthorizedProject } from "./helpers.mjs";
7
+ import { insightDepsFromStore } from "../insights/deps.mjs";
8
+ import { APPROVER_ROLES, VIEW_ROLES } from "./builds.handlers.mjs";
9
+ //#region src/routers/insights.handlers.ts
10
+ /** AI deps or `501 ai-disabled` when the site has no AI configured. */
11
+ function requireAi() {
12
+ const deps = insightDepsFromStore();
13
+ if (!deps) failWith(501, "ai-disabled");
14
+ return deps;
15
+ }
16
+ /** `409 ai-disabled-for-project` unless the project selected a profile. */
17
+ function requireProjectAi(project) {
18
+ if (project.aiProfile === null || project.aiProfile === void 0) failWith(409, "ai-disabled-for-project");
19
+ }
20
+ /** Whether the caller is a site admin (or auth is off / bootstrap admin token). */
21
+ function isSiteAdmin(c) {
22
+ const store = getStore();
23
+ return !store.authEnabled || store.user?.role === "admin" || requestHasAdminToken(c);
24
+ }
25
+ /** Resolve a project for reading insights (all four roles). */
26
+ async function projectForView(c, slug) {
27
+ return {
28
+ deps: requireAi(),
29
+ project: await resolveAuthorizedProject(c, slug, ...VIEW_ROLES)
30
+ };
31
+ }
32
+ /** Resolve a project for regenerating (approver + admin) in the ADR guard order. */
33
+ async function projectForRegenerate(c, slug) {
34
+ const { deps, project } = await projectForView(c, slug);
35
+ requireProjectAi(project);
36
+ if (!requestHasAdminToken(c)) await assertRole(project.id, ...APPROVER_ROLES);
37
+ return {
38
+ deps,
39
+ project
40
+ };
41
+ }
42
+ //#endregion
43
+ export { isSiteAdmin, projectForRegenerate, projectForView, requireAi, requireProjectAi };
44
+
45
+ //# sourceMappingURL=insights.handlers.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"insights.handlers.mjs","names":[],"sources":["../../src/routers/insights.handlers.ts"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\n/** Shared guards for the insights API (ADR 0016 §6 guard order). */\nimport type { Context } from \"hono\";\nimport { insightDepsFromStore, type InsightDeps } from \"../insights/deps.ts\";\nimport { failWith } from \"../insights/errors.ts\";\nimport { getStore } from \"../store.ts\";\nimport { APPROVER_ROLES, VIEW_ROLES } from \"./builds.handlers.ts\";\nimport { assertRole, requestHasAdminToken, resolveAuthorizedProject } from \"./helpers.ts\";\n\n/** AI deps or `501 ai-disabled` when the site has no AI configured. */\nexport function requireAi(): InsightDeps {\n const deps = insightDepsFromStore();\n if (!deps) {\n failWith(501, \"ai-disabled\");\n }\n return deps;\n}\n\n/** `409 ai-disabled-for-project` unless the project selected a profile. */\nexport function requireProjectAi(project: Project): void {\n if (project.aiProfile === null || project.aiProfile === undefined) {\n failWith(409, \"ai-disabled-for-project\");\n }\n}\n\n/** Whether the caller is a site admin (or auth is off / bootstrap admin token). */\nexport function isSiteAdmin(c: Context): boolean {\n const store = getStore();\n return !store.authEnabled || store.user?.role === \"admin\" || requestHasAdminToken(c);\n}\n\n/** Resolve a project for reading insights (all four roles). */\nexport async function projectForView(\n c: Context,\n slug: string,\n): Promise<{ deps: InsightDeps; project: Project }> {\n const deps = requireAi();\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n return { deps, project };\n}\n\n/** Resolve a project for regenerating (approver + admin) in the ADR guard order. */\nexport async function projectForRegenerate(\n c: Context,\n slug: string,\n): Promise<{ deps: InsightDeps; project: Project }> {\n const { deps, project } = await projectForView(c, slug);\n requireProjectAi(project);\n if (!requestHasAdminToken(c)) {\n await assertRole(project.id, ...APPROVER_ROLES);\n }\n return { deps, project };\n}\n"],"mappings":";;;;;;;;;;AAUA,SAAgB,YAAyB;CACvC,MAAM,OAAO,qBAAqB;CAClC,IAAI,CAAC,MACH,SAAS,KAAK,aAAa;CAE7B,OAAO;AACT;;AAGA,SAAgB,iBAAiB,SAAwB;CACvD,IAAI,QAAQ,cAAc,QAAQ,QAAQ,cAAc,KAAA,GACtD,SAAS,KAAK,yBAAyB;AAE3C;;AAGA,SAAgB,YAAY,GAAqB;CAC/C,MAAM,QAAQ,SAAS;CACvB,OAAO,CAAC,MAAM,eAAe,MAAM,MAAM,SAAS,WAAW,qBAAqB,CAAC;AACrF;;AAGA,eAAsB,eACpB,GACA,MACkD;CAGlD,OAAO;EAAE,MAFI,UAED;EAAG,SAAA,MADO,yBAAyB,GAAG,MAAM,GAAG,UAAU;CAC9C;AACzB;;AAGA,eAAsB,qBACpB,GACA,MACkD;CAClD,MAAM,EAAE,MAAM,YAAY,MAAM,eAAe,GAAG,IAAI;CACtD,iBAAiB,OAAO;CACxB,IAAI,CAAC,qBAAqB,CAAC,GACzB,MAAM,WAAW,QAAQ,IAAI,GAAG,cAAc;CAEhD,OAAO;EAAE;EAAM;CAAQ;AACzB"}