@storyshelf/app 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/credentials.mjs +3 -2
  26. package/dist/credentials.mjs.map +1 -1
  27. package/dist/git-status.mjs +46 -0
  28. package/dist/git-status.mjs.map +1 -0
  29. package/dist/hidden-data.mjs +24 -0
  30. package/dist/hidden-data.mjs.map +1 -0
  31. package/dist/index.d.mts +3 -3
  32. package/dist/index.mjs +202 -124
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/insights/budget.mjs +49 -0
  35. package/dist/insights/budget.mjs.map +1 -0
  36. package/dist/insights/deps.mjs +21 -0
  37. package/dist/insights/deps.mjs.map +1 -0
  38. package/dist/insights/errors.mjs +22 -0
  39. package/dist/insights/errors.mjs.map +1 -0
  40. package/dist/insights/health-panel.mjs +40 -0
  41. package/dist/insights/health-panel.mjs.map +1 -0
  42. package/dist/insights/job.mjs +103 -0
  43. package/dist/insights/job.mjs.map +1 -0
  44. package/dist/insights/load-health.mjs +59 -0
  45. package/dist/insights/load-health.mjs.map +1 -0
  46. package/dist/insights/load-triage.mjs +98 -0
  47. package/dist/insights/load-triage.mjs.map +1 -0
  48. package/dist/insights/panel.mjs +28 -0
  49. package/dist/insights/panel.mjs.map +1 -0
  50. package/dist/insights/request.mjs +113 -0
  51. package/dist/insights/request.mjs.map +1 -0
  52. package/dist/insights/stale.mjs +21 -0
  53. package/dist/insights/stale.mjs.map +1 -0
  54. package/dist/insights/usage-report.mjs +60 -0
  55. package/dist/insights/usage-report.mjs.map +1 -0
  56. package/dist/insights/view.mjs +48 -0
  57. package/dist/insights/view.mjs.map +1 -0
  58. package/dist/job-canceller.mjs +14 -0
  59. package/dist/job-canceller.mjs.map +1 -0
  60. package/dist/job-queue.mjs +51 -0
  61. package/dist/job-queue.mjs.map +1 -0
  62. package/dist/jobs-setup.mjs +132 -0
  63. package/dist/jobs-setup.mjs.map +1 -0
  64. package/dist/lifecycle.mjs +109 -19
  65. package/dist/lifecycle.mjs.map +1 -1
  66. package/dist/middleware/auth-gate.mjs +4 -2
  67. package/dist/middleware/auth-gate.mjs.map +1 -1
  68. package/dist/middleware/rate-limit.mjs +41 -33
  69. package/dist/middleware/rate-limit.mjs.map +1 -1
  70. package/dist/middleware/request-log.mjs +3 -2
  71. package/dist/middleware/request-log.mjs.map +1 -1
  72. package/dist/middleware/require-review.mjs +51 -0
  73. package/dist/middleware/require-review.mjs.map +1 -0
  74. package/dist/middleware/require-testing.mjs +41 -0
  75. package/dist/middleware/require-testing.mjs.map +1 -0
  76. package/dist/middleware/setup-gate.mjs.map +1 -1
  77. package/dist/middleware/store-scope.mjs +12 -2
  78. package/dist/middleware/store-scope.mjs.map +1 -1
  79. package/dist/notifications-pause.mjs +33 -0
  80. package/dist/notifications-pause.mjs.map +1 -0
  81. package/dist/notify.d.mts +2 -1
  82. package/dist/notify.mjs +78 -39
  83. package/dist/notify.mjs.map +1 -1
  84. package/dist/pages/a11y-violations.mjs +116 -0
  85. package/dist/pages/a11y-violations.mjs.map +1 -0
  86. package/dist/pages/admin-ai.mjs +78 -0
  87. package/dist/pages/admin-ai.mjs.map +1 -0
  88. package/dist/pages/admin-system-origin.mjs +123 -0
  89. package/dist/pages/admin-system-origin.mjs.map +1 -0
  90. package/dist/pages/admin-system-targets.mjs +53 -0
  91. package/dist/pages/admin-system-targets.mjs.map +1 -0
  92. package/dist/pages/admin-system.mjs +63 -13
  93. package/dist/pages/admin-system.mjs.map +1 -1
  94. package/dist/pages/build-detail.mjs +104 -76
  95. package/dist/pages/build-detail.mjs.map +1 -1
  96. package/dist/pages/build-diff-actions.mjs +87 -33
  97. package/dist/pages/build-diff-actions.mjs.map +1 -1
  98. package/dist/pages/build-diff-comments.mjs +16 -14
  99. package/dist/pages/build-diff-comments.mjs.map +1 -1
  100. package/dist/pages/build-diff-environment.mjs +82 -0
  101. package/dist/pages/build-diff-environment.mjs.map +1 -0
  102. package/dist/pages/build-diff-header.mjs +22 -4
  103. package/dist/pages/build-diff-header.mjs.map +1 -1
  104. package/dist/pages/build-diff-insight.mjs +113 -0
  105. package/dist/pages/build-diff-insight.mjs.map +1 -0
  106. package/dist/pages/build-diff-model.mjs +55 -10
  107. package/dist/pages/build-diff-model.mjs.map +1 -1
  108. package/dist/pages/build-diff-nav.mjs +28 -12
  109. package/dist/pages/build-diff-nav.mjs.map +1 -1
  110. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  111. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  112. package/dist/pages/build-diff-usage.mjs +63 -0
  113. package/dist/pages/build-diff-usage.mjs.map +1 -0
  114. package/dist/pages/build-diff-viewer.mjs +62 -5
  115. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  116. package/dist/pages/build-diff.mjs +34 -13
  117. package/dist/pages/build-diff.mjs.map +1 -1
  118. package/dist/pages/build-tests.mjs +74 -0
  119. package/dist/pages/build-tests.mjs.map +1 -0
  120. package/dist/pages/component-detail.mjs +249 -0
  121. package/dist/pages/component-detail.mjs.map +1 -0
  122. package/dist/pages/component-stories.mjs +168 -0
  123. package/dist/pages/component-stories.mjs.map +1 -0
  124. package/dist/pages/compute-jobs.mjs +13 -26
  125. package/dist/pages/compute-jobs.mjs.map +1 -1
  126. package/dist/pages/design-links.mjs +182 -0
  127. package/dist/pages/design-links.mjs.map +1 -0
  128. package/dist/pages/environment-change.mjs +53 -0
  129. package/dist/pages/environment-change.mjs.map +1 -0
  130. package/dist/pages/health-panel.mjs +126 -0
  131. package/dist/pages/health-panel.mjs.map +1 -0
  132. package/dist/pages/library-grid.mjs +105 -0
  133. package/dist/pages/library-grid.mjs.map +1 -0
  134. package/dist/pages/library-model.mjs +47 -0
  135. package/dist/pages/library-model.mjs.map +1 -0
  136. package/dist/pages/library-snapshots.mjs +130 -0
  137. package/dist/pages/library-snapshots.mjs.map +1 -0
  138. package/dist/pages/library.mjs +112 -218
  139. package/dist/pages/library.mjs.map +1 -1
  140. package/dist/pages/notifications-paused.mjs +22 -0
  141. package/dist/pages/notifications-paused.mjs.map +1 -0
  142. package/dist/pages/profile.mjs +53 -48
  143. package/dist/pages/profile.mjs.map +1 -1
  144. package/dist/pages/project-builds-table.mjs +2 -2
  145. package/dist/pages/project-builds-table.mjs.map +1 -1
  146. package/dist/pages/project-builds.mjs +51 -21
  147. package/dist/pages/project-builds.mjs.map +1 -1
  148. package/dist/pages/project-settings.mjs +51 -12
  149. package/dist/pages/project-settings.mjs.map +1 -1
  150. package/dist/pages/projects-card.mjs +14 -1
  151. package/dist/pages/projects-card.mjs.map +1 -1
  152. package/dist/pages/projects.mjs +6 -2
  153. package/dist/pages/projects.mjs.map +1 -1
  154. package/dist/pages/review-changeset.mjs +116 -0
  155. package/dist/pages/review-changeset.mjs.map +1 -0
  156. package/dist/pages/review-comments.mjs +103 -0
  157. package/dist/pages/review-comments.mjs.map +1 -0
  158. package/dist/pages/review-verdict.mjs +102 -0
  159. package/dist/pages/review-verdict.mjs.map +1 -0
  160. package/dist/pages/settings-ai.mjs +69 -0
  161. package/dist/pages/settings-ai.mjs.map +1 -0
  162. package/dist/pages/settings-capture.mjs +124 -0
  163. package/dist/pages/settings-capture.mjs.map +1 -0
  164. package/dist/pages/settings-design-credential.mjs +99 -0
  165. package/dist/pages/settings-design-credential.mjs.map +1 -0
  166. package/dist/pages/settings-design-links.mjs +179 -0
  167. package/dist/pages/settings-design-links.mjs.map +1 -0
  168. package/dist/pages/settings-design.mjs +48 -0
  169. package/dist/pages/settings-design.mjs.map +1 -0
  170. package/dist/pages/settings-general.mjs +46 -21
  171. package/dist/pages/settings-general.mjs.map +1 -1
  172. package/dist/pages/settings-labels.mjs +43 -2
  173. package/dist/pages/settings-labels.mjs.map +1 -1
  174. package/dist/pages/settings-links.mjs +112 -0
  175. package/dist/pages/settings-links.mjs.map +1 -0
  176. package/dist/pages/settings-notifications-git.mjs +128 -0
  177. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  178. package/dist/pages/settings-notifications.mjs +79 -42
  179. package/dist/pages/settings-notifications.mjs.map +1 -1
  180. package/dist/pages/settings-tests.mjs +33 -2
  181. package/dist/pages/settings-tests.mjs.map +1 -1
  182. package/dist/pages/target-choices.mjs +39 -0
  183. package/dist/pages/target-choices.mjs.map +1 -0
  184. package/dist/publish-setup.mjs +89 -0
  185. package/dist/publish-setup.mjs.map +1 -0
  186. package/dist/retention-timer.mjs +2 -1
  187. package/dist/retention-timer.mjs.map +1 -1
  188. package/dist/review-events.mjs +48 -0
  189. package/dist/review-events.mjs.map +1 -0
  190. package/dist/routers/admin-pages.mjs +45 -3
  191. package/dist/routers/admin-pages.mjs.map +1 -1
  192. package/dist/routers/admin-public-origin.mjs +85 -0
  193. package/dist/routers/admin-public-origin.mjs.map +1 -0
  194. package/dist/routers/assets.mjs +9 -2
  195. package/dist/routers/assets.mjs.map +1 -1
  196. package/dist/routers/auth.mjs +2 -2
  197. package/dist/routers/auth.mjs.map +1 -1
  198. package/dist/routers/baseline-guard.mjs +2 -2
  199. package/dist/routers/baseline-guard.mjs.map +1 -1
  200. package/dist/routers/baselines.handlers.mjs +64 -0
  201. package/dist/routers/baselines.handlers.mjs.map +1 -0
  202. package/dist/routers/baselines.mjs +125 -0
  203. package/dist/routers/baselines.mjs.map +1 -0
  204. package/dist/routers/build-cancel.mjs +68 -0
  205. package/dist/routers/build-cancel.mjs.map +1 -0
  206. package/dist/routers/build-changeset.mjs +70 -0
  207. package/dist/routers/build-changeset.mjs.map +1 -0
  208. package/dist/routers/build-reviews.handlers.mjs +86 -0
  209. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  210. package/dist/routers/build-reviews.mjs +74 -0
  211. package/dist/routers/build-reviews.mjs.map +1 -0
  212. package/dist/routers/builds.handlers.mjs +46 -55
  213. package/dist/routers/builds.handlers.mjs.map +1 -1
  214. package/dist/routers/builds.mjs +71 -17
  215. package/dist/routers/builds.mjs.map +1 -1
  216. package/dist/routers/capture-config.mjs +35 -0
  217. package/dist/routers/capture-config.mjs.map +1 -0
  218. package/dist/routers/comments.mjs +41 -15
  219. package/dist/routers/comments.mjs.map +1 -1
  220. package/dist/routers/component-catalog.handlers.mjs +71 -0
  221. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  222. package/dist/routers/component.handlers.mjs +121 -0
  223. package/dist/routers/component.handlers.mjs.map +1 -0
  224. package/dist/routers/connections.mjs +118 -0
  225. package/dist/routers/connections.mjs.map +1 -0
  226. package/dist/routers/design-connect.handlers.mjs +44 -0
  227. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  228. package/dist/routers/design.handlers.mjs +114 -0
  229. package/dist/routers/design.handlers.mjs.map +1 -0
  230. package/dist/routers/health-report.mjs +2 -3
  231. package/dist/routers/health-report.mjs.map +1 -1
  232. package/dist/routers/health.mjs +4 -3
  233. package/dist/routers/health.mjs.map +1 -1
  234. package/dist/routers/helpers.mjs +19 -3
  235. package/dist/routers/helpers.mjs.map +1 -1
  236. package/dist/routers/insights-ui.mjs +64 -0
  237. package/dist/routers/insights-ui.mjs.map +1 -0
  238. package/dist/routers/insights.handlers.mjs +45 -0
  239. package/dist/routers/insights.handlers.mjs.map +1 -0
  240. package/dist/routers/insights.mjs +189 -0
  241. package/dist/routers/insights.mjs.map +1 -0
  242. package/dist/routers/links.handlers.mjs +80 -0
  243. package/dist/routers/links.handlers.mjs.map +1 -0
  244. package/dist/routers/links.mjs +139 -0
  245. package/dist/routers/links.mjs.map +1 -0
  246. package/dist/routers/media.mjs +18 -4
  247. package/dist/routers/media.mjs.map +1 -1
  248. package/dist/routers/members.mjs +2 -1
  249. package/dist/routers/members.mjs.map +1 -1
  250. package/dist/routers/notification-channels.mjs +17 -23
  251. package/dist/routers/notification-channels.mjs.map +1 -1
  252. package/dist/routers/oauth-state.mjs +45 -0
  253. package/dist/routers/oauth-state.mjs.map +1 -0
  254. package/dist/routers/preview-base.mjs +38 -0
  255. package/dist/routers/preview-base.mjs.map +1 -0
  256. package/dist/routers/preview-token.mjs +51 -0
  257. package/dist/routers/preview-token.mjs.map +1 -0
  258. package/dist/routers/profile.mjs +4 -2
  259. package/dist/routers/profile.mjs.map +1 -1
  260. package/dist/routers/projects.mjs +17 -1
  261. package/dist/routers/projects.mjs.map +1 -1
  262. package/dist/routers/publish-upload.mjs +78 -0
  263. package/dist/routers/publish-upload.mjs.map +1 -0
  264. package/dist/routers/published-access.mjs +52 -0
  265. package/dist/routers/published-access.mjs.map +1 -0
  266. package/dist/routers/published-files.mjs +52 -0
  267. package/dist/routers/published-files.mjs.map +1 -0
  268. package/dist/routers/review-forms.mjs +72 -0
  269. package/dist/routers/review-forms.mjs.map +1 -0
  270. package/dist/routers/review-section.handlers.mjs +85 -0
  271. package/dist/routers/review-section.handlers.mjs.map +1 -0
  272. package/dist/routers/review-switch-off.mjs +29 -0
  273. package/dist/routers/review-switch-off.mjs.map +1 -0
  274. package/dist/routers/schemas.mjs +140 -22
  275. package/dist/routers/schemas.mjs.map +1 -1
  276. package/dist/routers/scoped-models.mjs +22 -0
  277. package/dist/routers/scoped-models.mjs.map +1 -0
  278. package/dist/routers/settings-ai.mjs +35 -0
  279. package/dist/routers/settings-ai.mjs.map +1 -0
  280. package/dist/routers/settings-capture.mjs +77 -0
  281. package/dist/routers/settings-capture.mjs.map +1 -0
  282. package/dist/routers/settings-design.mjs +103 -0
  283. package/dist/routers/settings-design.mjs.map +1 -0
  284. package/dist/routers/settings-general.mjs +55 -10
  285. package/dist/routers/settings-general.mjs.map +1 -1
  286. package/dist/routers/settings-git-channel.mjs +77 -0
  287. package/dist/routers/settings-git-channel.mjs.map +1 -0
  288. package/dist/routers/settings-labels.mjs +27 -1
  289. package/dist/routers/settings-labels.mjs.map +1 -1
  290. package/dist/routers/settings-links.mjs +63 -0
  291. package/dist/routers/settings-links.mjs.map +1 -0
  292. package/dist/routers/settings-members.mjs +4 -7
  293. package/dist/routers/settings-members.mjs.map +1 -1
  294. package/dist/routers/settings-notifications.mjs +15 -10
  295. package/dist/routers/settings-notifications.mjs.map +1 -1
  296. package/dist/routers/settings-tokens.mjs +2 -5
  297. package/dist/routers/settings-tokens.mjs.map +1 -1
  298. package/dist/routers/settings-webhooks.mjs +7 -24
  299. package/dist/routers/settings-webhooks.mjs.map +1 -1
  300. package/dist/routers/settings.handlers.mjs +107 -26
  301. package/dist/routers/settings.handlers.mjs.map +1 -1
  302. package/dist/routers/settings.mjs +16 -4
  303. package/dist/routers/settings.mjs.map +1 -1
  304. package/dist/routers/signed-storybook.mjs +53 -0
  305. package/dist/routers/signed-storybook.mjs.map +1 -0
  306. package/dist/routers/snapshot-groups.mjs +63 -0
  307. package/dist/routers/snapshot-groups.mjs.map +1 -0
  308. package/dist/routers/snapshots.mjs +103 -5
  309. package/dist/routers/snapshots.mjs.map +1 -1
  310. package/dist/routers/storybook.mjs +37 -88
  311. package/dist/routers/storybook.mjs.map +1 -1
  312. package/dist/routers/tokens.mjs +2 -5
  313. package/dist/routers/tokens.mjs.map +1 -1
  314. package/dist/routers/ui.mjs +34 -5
  315. package/dist/routers/ui.mjs.map +1 -1
  316. package/dist/routers/webhooks.mjs +5 -11
  317. package/dist/routers/webhooks.mjs.map +1 -1
  318. package/dist/routers/well-known.mjs +11 -4
  319. package/dist/routers/well-known.mjs.map +1 -1
  320. package/dist/routes/api-routes.mjs +93 -0
  321. package/dist/routes/api-routes.mjs.map +1 -0
  322. package/dist/routes/classification.mjs +38 -0
  323. package/dist/routes/classification.mjs.map +1 -0
  324. package/dist/routes/openapi-classes.mjs +22 -0
  325. package/dist/routes/openapi-classes.mjs.map +1 -0
  326. package/dist/routes/page-routes.mjs +104 -0
  327. package/dist/routes/page-routes.mjs.map +1 -0
  328. package/dist/runtime.mjs +18 -13
  329. package/dist/runtime.mjs.map +1 -1
  330. package/dist/serverless.mjs +5 -0
  331. package/dist/store.mjs +19 -1
  332. package/dist/store.mjs.map +1 -1
  333. package/dist/targets.mjs +46 -0
  334. package/dist/targets.mjs.map +1 -0
  335. package/dist/ui/buttons.mjs +16 -3
  336. package/dist/ui/buttons.mjs.map +1 -1
  337. package/dist/ui/design-reference.mjs +97 -0
  338. package/dist/ui/design-reference.mjs.map +1 -0
  339. package/dist/ui/document.mjs +1 -2
  340. package/dist/ui/document.mjs.map +1 -1
  341. package/dist/ui/forms.mjs +6 -5
  342. package/dist/ui/forms.mjs.map +1 -1
  343. package/dist/ui/icons/paths.mjs +1 -0
  344. package/dist/ui/icons/paths.mjs.map +1 -1
  345. package/dist/ui/preview-embed.mjs +82 -0
  346. package/dist/ui/preview-embed.mjs.map +1 -0
  347. package/dist/ui/shell/client-script.mjs +85 -0
  348. package/dist/ui/shell/client-script.mjs.map +1 -1
  349. package/dist/ui/shell/nav.mjs +12 -8
  350. package/dist/ui/shell/nav.mjs.map +1 -1
  351. package/dist/ui/shell/review-script.mjs +8 -0
  352. package/dist/ui/shell/review-script.mjs.map +1 -1
  353. package/dist/ui/shell/sidebar.mjs +2 -2
  354. package/dist/ui/shell/sidebar.mjs.map +1 -1
  355. package/package.json +11 -6
  356. package/dist/asset-paths.mjs +0 -23
  357. package/dist/asset-paths.mjs.map +0 -1
  358. package/dist/assets/mark.svg +0 -1
  359. package/dist/pages/settings-status.mjs +0 -137
  360. package/dist/pages/settings-status.mjs.map +0 -1
  361. package/dist/routers/settings-status.mjs +0 -77
  362. package/dist/routers/settings-status.mjs.map +0 -1
  363. package/dist/routers/status-configs.mjs +0 -108
  364. package/dist/routers/status-configs.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"members.mjs","names":[],"sources":["../../src/routers/members.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { MemberModel, ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n groupMappingCreateSchema,\n groupMappingSchema,\n memberRoleSchema,\n memberSchema,\n memberSetSchema,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listMembersRoute = createRoute({\n method: \"get\",\n tags: [\"Members\"],\n summary: \"List members\",\n path: \"/api/v1/projects/{slug}/members\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: memberSchema.array() } },\n description: \"List project members\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst setMemberRoute = createRoute({\n method: \"post\",\n tags: [\"Members\"],\n summary: \"Add or update a member\",\n path: \"/api/v1/projects/{slug}/members\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: memberSetSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: memberSchema } },\n description: \"Added or updated member\",\n },\n ...notFound,\n },\n});\n\nconst updateMemberRoute = createRoute({\n method: \"patch\",\n tags: [\"Members\"],\n summary: \"Change a member's role\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: {\n params: z.object({ slug: z.string(), userId: z.string() }),\n body: { content: { \"application/json\": { schema: memberRoleSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: memberSchema } },\n description: \"Updated member role\",\n },\n ...notFound,\n },\n});\n\nconst deleteMemberRoute = createRoute({\n method: \"delete\",\n tags: [\"Members\"],\n summary: \"Remove a member\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: { params: z.object({ slug: z.string(), userId: z.string() }) },\n responses: {\n 204: { description: \"Removed member\" },\n ...notFound,\n },\n});\n\nconst listGroupMappingsRoute = createRoute({\n method: \"get\",\n tags: [\"Members\"],\n summary: \"List group mappings\",\n path: \"/api/v1/projects/{slug}/group-mappings\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: groupMappingSchema.array() } },\n description: \"List identity-provider group mappings\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createGroupMappingRoute = createRoute({\n method: \"post\",\n tags: [\"Members\"],\n summary: \"Create a group mapping\",\n path: \"/api/v1/projects/{slug}/group-mappings\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: groupMappingCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: groupMappingSchema } },\n description: \"Created group mapping (exact group-name match)\",\n },\n ...notFound,\n },\n});\n\nconst deleteGroupMappingRoute = createRoute({\n method: \"delete\",\n tags: [\"Members\"],\n summary: \"Remove a group mapping\",\n path: \"/api/v1/projects/{slug}/group-mappings/{mappingId}\",\n request: { params: z.object({ slug: z.string(), mappingId: z.string() }) },\n responses: {\n 204: { description: \"Removed group mapping\" },\n ...notFound,\n },\n});\n\n/** Register the project member list, upsert, and removal endpoints. */\nexport function registerMembers(app: ShelfRouter): void {\n app.openapi(listMembersRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new MemberModel(getStore().db).list(project.id));\n });\n\n app.openapi(setMemberRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(\n await new MemberModel(getStore().db).set(project.id, body.userId, body.role),\n 201,\n );\n });\n\n app.openapi(updateMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new MemberModel(getStore().db).set(project.id, userId, body.role));\n });\n\n app.openapi(deleteMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new MemberModel(getStore().db).remove(project.id, userId);\n return c.body(null, 204);\n });\n\n registerGroupMappingRoutes(app);\n}\n\n/** Register identity-provider group mapping endpoints. */\nfunction registerGroupMappingRoutes(app: ShelfRouter): void {\n app.openapi(listGroupMappingsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new ProjectGroupMappingModel(getStore().db).list(project.id));\n });\n\n app.openapi(createGroupMappingRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n if (body.groupName.includes(\"*\")) {\n throw new HTTPException(400, {\n message:\n \"Group names match exactly; wildcards are not expanded. A broad pattern can silently grant org-wide access.\",\n });\n }\n return c.json(\n await new ProjectGroupMappingModel(getStore().db).create(\n project.id,\n body.groupName,\n body.role,\n ),\n 201,\n );\n });\n\n app.openapi(deleteGroupMappingRoute, async (c) => {\n const { slug, mappingId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new ProjectGroupMappingModel(getStore().db).remove(project.id, mappingId);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"members.mjs","names":[],"sources":["../../src/routers/members.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { MemberModel, ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n groupMappingCreateSchema,\n groupMappingSchema,\n memberRoleSchema,\n memberSchema,\n memberSetSchema,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nconst listMembersRoute = createRoute({\n method: \"get\",\n tags: [\"Members\"],\n summary: \"List members\",\n path: \"/api/v1/projects/{slug}/members\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: memberSchema.array() } },\n description: \"List project members\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst setMemberRoute = createRoute({\n method: \"post\",\n tags: [\"Members\"],\n summary: \"Add or update a member\",\n path: \"/api/v1/projects/{slug}/members\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: memberSetSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: memberSchema } },\n description: \"Added or updated member\",\n },\n ...notFound,\n },\n});\n\nconst updateMemberRoute = createRoute({\n method: \"patch\",\n tags: [\"Members\"],\n summary: \"Change a member's role\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: {\n params: z.object({ slug: z.string(), userId: z.string() }),\n body: { content: { \"application/json\": { schema: memberRoleSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: memberSchema } },\n description: \"Updated member role\",\n },\n ...notFound,\n },\n});\n\nconst deleteMemberRoute = createRoute({\n method: \"delete\",\n tags: [\"Members\"],\n summary: \"Remove a member\",\n path: \"/api/v1/projects/{slug}/members/{userId}\",\n request: { params: z.object({ slug: z.string(), userId: z.string() }) },\n responses: {\n 204: { description: \"Removed member\" },\n ...notFound,\n },\n});\n\nconst listGroupMappingsRoute = createRoute({\n method: \"get\",\n tags: [\"Members\"],\n summary: \"List group mappings\",\n path: \"/api/v1/projects/{slug}/group-mappings\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: groupMappingSchema.array() } },\n description: \"List identity-provider group mappings\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createGroupMappingRoute = createRoute({\n method: \"post\",\n tags: [\"Members\"],\n summary: \"Create a group mapping\",\n path: \"/api/v1/projects/{slug}/group-mappings\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: groupMappingCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: groupMappingSchema } },\n description: \"Created group mapping (exact group-name match)\",\n },\n ...notFound,\n },\n});\n\nconst deleteGroupMappingRoute = createRoute({\n method: \"delete\",\n tags: [\"Members\"],\n summary: \"Remove a group mapping\",\n path: \"/api/v1/projects/{slug}/group-mappings/{mappingId}\",\n request: { params: z.object({ slug: z.string(), mappingId: z.string() }) },\n responses: {\n 204: { description: \"Removed group mapping\" },\n ...notFound,\n },\n});\n\n/** Register the project member list, upsert, and removal endpoints. */\nexport function registerMembers(app: ShelfRouter): void {\n app.openapi(listMembersRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new MemberModel(getStore().db).list(project.id));\n });\n\n app.openapi(setMemberRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(\n await new MemberModel(getStore().db).set(project.id, body.userId, body.role),\n 201,\n );\n });\n\n app.openapi(updateMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n return c.json(await new MemberModel(getStore().db).set(project.id, userId, body.role));\n });\n\n app.openapi(deleteMemberRoute, async (c) => {\n const { slug, userId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new MemberModel(getStore().db).remove(project.id, userId);\n return c.body(null, 204);\n });\n\n registerGroupMappingRoutes(app);\n}\n\n/** Register identity-provider group mapping endpoints. */\nfunction registerGroupMappingRoutes(app: ShelfRouter): void {\n app.openapi(listGroupMappingsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n return c.json(await new ProjectGroupMappingModel(getStore().db).list(project.id));\n });\n\n app.openapi(createGroupMappingRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n const inputError = ProjectGroupMappingModel.inputError(body.groupName, body.role);\n if (inputError) {\n throw new HTTPException(400, { message: inputError });\n }\n return c.json(\n await new ProjectGroupMappingModel(getStore().db).create(\n project.id,\n body.groupName,\n body.role,\n ),\n 201,\n );\n });\n\n app.openapi(deleteGroupMappingRoute, async (c) => {\n const { slug, mappingId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n await new ProjectGroupMappingModel(getStore().db).remove(project.id, mappingId);\n return c.body(null, 204);\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 { credentialKeys } from "../credential-keys.mjs";
5
4
  import { getStore } from "../store.mjs";
6
5
  import { notFound, requireSiteAdmin, resolveAuthorizedProject } from "./helpers.mjs";
7
6
  import { notFound as notFound$1, notificationChannelCreateSchema, notificationChannelSchema, unauthorized } from "./schemas.mjs";
7
+ import { notificationChannelModel } from "./scoped-models.mjs";
8
8
  import { createRoute, z } from "@hono/zod-openapi";
9
9
  import { NotificationChannelModel } from "@storyshelf/core/models";
10
10
  //#region src/routers/notification-channels.ts
@@ -19,24 +19,12 @@ function toPublic(row) {
19
19
  hasSecret: row.secretEncrypted !== null,
20
20
  events: NotificationChannelModel.eventsOf(row),
21
21
  enabled: row.enabled,
22
+ lastError: row.lastError,
23
+ lastErrorAt: row.lastErrorAt,
22
24
  createdAt: row.createdAt,
23
25
  updatedAt: row.updatedAt
24
26
  };
25
27
  }
26
- /** Validate channel input against the provider descriptor. */
27
- function validateChannelInput(provider, config) {
28
- const descriptor = getStore().notifiers.find((candidate) => candidate.metadata.kind === provider);
29
- if (!descriptor) return {
30
- ok: false,
31
- message: `Unknown provider: ${provider}`
32
- };
33
- const parsed = descriptor.metadata.schema.safeParse(config);
34
- if (!parsed.success) return {
35
- ok: false,
36
- message: parsed.error.message
37
- };
38
- return { ok: true };
39
- }
40
28
  const listChannelsRoute = createRoute({
41
29
  method: "get",
42
30
  tags: ["Notifications"],
@@ -91,15 +79,18 @@ const deleteChannelRoute = createRoute({
91
79
  function registerNotificationChannels(app) {
92
80
  app.openapi(listChannelsRoute, async (c) => {
93
81
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...ADMIN_ROLES);
94
- const rows = await new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config)).list(project.id);
82
+ const rows = await notificationChannelModel().list(project.id);
95
83
  return c.json(rows.map((row) => toPublic(row)));
96
84
  });
97
85
  app.openapi(createChannelRoute, async (c) => {
98
86
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...ADMIN_ROLES);
99
87
  const body = c.req.valid("json");
100
- const valid = validateChannelInput(body.provider, body.config);
88
+ const valid = NotificationChannelModel.validateInput(getStore().notifiers, body.provider, body.config, {
89
+ projectId: project.id,
90
+ hasSecret: body.secret !== void 0
91
+ });
101
92
  if (!valid.ok) return c.json({ message: valid.message }, 400);
102
- const row = await new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config)).create({
93
+ const row = await notificationChannelModel().create({
103
94
  projectId: project.id,
104
95
  ...body
105
96
  });
@@ -108,7 +99,7 @@ function registerNotificationChannels(app) {
108
99
  app.openapi(deleteChannelRoute, async (c) => {
109
100
  const { slug, channelId } = c.req.valid("param");
110
101
  const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
111
- const model = new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config));
102
+ const model = notificationChannelModel();
112
103
  if ((await model.get(channelId))?.projectId !== project.id) notFound("Notification channel not found");
113
104
  await model.remove(channelId);
114
105
  return c.body(null, 204);
@@ -161,15 +152,18 @@ const deleteSystemRoute = createRoute({
161
152
  function registerSystemChannels(app) {
162
153
  app.openapi(listSystemRoute, async (c) => {
163
154
  requireSiteAdmin(c);
164
- const rows = await new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config)).listSystem();
155
+ const rows = await notificationChannelModel().listSystem();
165
156
  return c.json(rows.map((row) => toPublic(row)));
166
157
  });
167
158
  app.openapi(createSystemRoute, async (c) => {
168
159
  requireSiteAdmin(c);
169
160
  const body = c.req.valid("json");
170
- const valid = validateChannelInput(body.provider, body.config);
161
+ const valid = NotificationChannelModel.validateInput(getStore().notifiers, body.provider, body.config, {
162
+ projectId: null,
163
+ hasSecret: body.secret !== void 0
164
+ });
171
165
  if (!valid.ok) return c.json({ message: valid.message }, 400);
172
- const row = await new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config)).create({
166
+ const row = await notificationChannelModel().create({
173
167
  projectId: null,
174
168
  ...body
175
169
  });
@@ -178,7 +172,7 @@ function registerSystemChannels(app) {
178
172
  app.openapi(deleteSystemRoute, async (c) => {
179
173
  requireSiteAdmin(c);
180
174
  const { channelId } = c.req.valid("param");
181
- const model = new NotificationChannelModel(getStore().db, void 0, credentialKeys(getStore().config));
175
+ const model = notificationChannelModel();
182
176
  if ((await model.get(channelId))?.projectId !== null) notFound("Notification channel not found");
183
177
  await model.remove(channelId);
184
178
  return c.body(null, 204);
@@ -1 +1 @@
1
- {"version":3,"file":"notification-channels.mjs","names":["notFoundResponse"],"sources":["../../src/routers/notification-channels.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { NotificationChannelModel } from \"@storyshelf/core/models\";\nimport type { NotificationChannelRow } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { notFound, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n notFound as notFoundResponse,\n notificationChannelCreateSchema,\n notificationChannelSchema,\n unauthorized,\n} from \"./schemas.ts\";\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\n/** Public channel view (secret redacted, config/events decoded). */\nfunction toPublic(row: NotificationChannelRow): {\n id: string;\n projectId: string | null;\n provider: string;\n config: Record<string, unknown>;\n hasSecret: boolean;\n events: string[];\n enabled: boolean;\n createdAt: string;\n updatedAt: string;\n} {\n return {\n id: row.id,\n projectId: row.projectId,\n provider: row.provider,\n config: NotificationChannelModel.configOf(row),\n hasSecret: row.secretEncrypted !== null,\n events: NotificationChannelModel.eventsOf(row),\n enabled: row.enabled,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\n/** Validate channel input against the provider descriptor. */\nfunction validateChannelInput(\n provider: string,\n config: unknown,\n): { ok: true } | { ok: false; message: string } {\n const descriptor = getStore().notifiers.find((candidate) => candidate.metadata.kind === provider);\n if (!descriptor) {\n return { ok: false, message: `Unknown provider: ${provider}` };\n }\n const parsed = descriptor.metadata.schema.safeParse(config);\n if (!parsed.success) {\n return { ok: false, message: parsed.error.message };\n }\n return { ok: true };\n}\n\nconst listChannelsRoute = createRoute({\n method: \"get\",\n tags: [\"Notifications\"],\n summary: \"List notification channels\",\n path: \"/api/v1/projects/{slug}/notification-channels\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: notificationChannelSchema.array() } },\n description: \"List notification channels\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst createChannelRoute = createRoute({\n method: \"post\",\n tags: [\"Notifications\"],\n summary: \"Create a notification channel\",\n path: \"/api/v1/projects/{slug}/notification-channels\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: notificationChannelCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: notificationChannelSchema } },\n description: \"Created notification channel\",\n },\n 400: {\n content: { \"application/json\": { schema: z.object({ message: z.string() }) } },\n description: \"Bad request\",\n },\n ...notFoundResponse,\n },\n});\n\nconst deleteChannelRoute = createRoute({\n method: \"delete\",\n tags: [\"Notifications\"],\n summary: \"Delete a notification channel\",\n path: \"/api/v1/projects/{slug}/notification-channels/{channelId}\",\n request: { params: z.object({ slug: z.string(), channelId: z.string() }) },\n responses: {\n 204: { description: \"Deleted notification channel\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the project notification-channel list, create, and delete endpoints. */\nexport function registerNotificationChannels(app: ShelfRouter): void {\n app.openapi(listChannelsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const rows = await new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n ).list(project.id);\n return c.json(rows.map((row) => toPublic(row)));\n });\n\n app.openapi(createChannelRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n const valid = validateChannelInput(body.provider, body.config);\n if (!valid.ok) {\n return c.json({ message: valid.message }, 400);\n }\n const row = await new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n ).create({ projectId: project.id, ...body });\n return c.json(toPublic(row), 201);\n });\n\n app.openapi(deleteChannelRoute, async (c) => {\n const { slug, channelId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const model = new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const existing = await model.get(channelId);\n if (existing?.projectId !== project.id) {\n notFound(\"Notification channel not found\");\n }\n await model.remove(channelId);\n return c.body(null, 204);\n });\n}\n\nconst listSystemRoute = createRoute({\n method: \"get\",\n tags: [\"Notifications\"],\n summary: \"List site-wide channels\",\n path: \"/api/v1/admin/notification-channels\",\n request: {},\n responses: {\n 200: {\n content: { \"application/json\": { schema: notificationChannelSchema.array() } },\n description: \"List site-wide notification channels\",\n },\n ...unauthorized,\n },\n});\n\nconst createSystemRoute = createRoute({\n method: \"post\",\n tags: [\"Notifications\"],\n summary: \"Create a site-wide channel\",\n path: \"/api/v1/admin/notification-channels\",\n request: {\n body: { content: { \"application/json\": { schema: notificationChannelCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: notificationChannelSchema } },\n description: \"Created site-wide notification channel\",\n },\n 400: {\n content: { \"application/json\": { schema: z.object({ message: z.string() }) } },\n description: \"Bad request\",\n },\n ...unauthorized,\n },\n});\n\nconst deleteSystemRoute = createRoute({\n method: \"delete\",\n tags: [\"Notifications\"],\n summary: \"Delete a site-wide channel\",\n path: \"/api/v1/admin/notification-channels/{channelId}\",\n request: { params: z.object({ channelId: z.string() }) },\n responses: {\n 204: { description: \"Deleted site-wide notification channel\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the site-admin (project-less, `sys:*`) channel endpoints. */\nexport function registerSystemChannels(app: ShelfRouter): void {\n app.openapi(listSystemRoute, async (c) => {\n requireSiteAdmin(c);\n const rows = await new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n ).listSystem();\n return c.json(rows.map((row) => toPublic(row)));\n });\n\n app.openapi(createSystemRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const valid = validateChannelInput(body.provider, body.config);\n if (!valid.ok) {\n return c.json({ message: valid.message }, 400);\n }\n const row = await new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n ).create({ projectId: null, ...body });\n return c.json(toPublic(row), 201);\n });\n\n app.openapi(deleteSystemRoute, async (c) => {\n requireSiteAdmin(c);\n const { channelId } = c.req.valid(\"param\");\n const model = new NotificationChannelModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const existing = await model.get(channelId);\n if (existing?.projectId !== null) {\n notFound(\"Notification channel not found\");\n }\n await model.remove(channelId);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"notification-channels.mjs","names":["notFoundResponse"],"sources":["../../src/routers/notification-channels.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { NotificationChannelModel } from \"@storyshelf/core/models\";\nimport type { NotificationChannelRow } from \"@storyshelf/core/schema\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { notFound, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n notFound as notFoundResponse,\n notificationChannelCreateSchema,\n notificationChannelSchema,\n unauthorized,\n} from \"./schemas.ts\";\nimport { notificationChannelModel } from \"./scoped-models.ts\";\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\n/** Public channel view (secret redacted, config/events decoded). */\nfunction toPublic(row: NotificationChannelRow): {\n id: string;\n projectId: string | null;\n provider: string;\n config: Record<string, unknown>;\n hasSecret: boolean;\n events: string[];\n enabled: boolean;\n lastError: string | null;\n lastErrorAt: string | null;\n createdAt: string;\n updatedAt: string;\n} {\n return {\n id: row.id,\n projectId: row.projectId,\n provider: row.provider,\n config: NotificationChannelModel.configOf(row),\n hasSecret: row.secretEncrypted !== null,\n events: NotificationChannelModel.eventsOf(row),\n enabled: row.enabled,\n lastError: row.lastError,\n lastErrorAt: row.lastErrorAt,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\nconst listChannelsRoute = createRoute({\n method: \"get\",\n tags: [\"Notifications\"],\n summary: \"List notification channels\",\n path: \"/api/v1/projects/{slug}/notification-channels\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: notificationChannelSchema.array() } },\n description: \"List notification channels\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst createChannelRoute = createRoute({\n method: \"post\",\n tags: [\"Notifications\"],\n summary: \"Create a notification channel\",\n path: \"/api/v1/projects/{slug}/notification-channels\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: notificationChannelCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: notificationChannelSchema } },\n description: \"Created notification channel\",\n },\n 400: {\n content: { \"application/json\": { schema: z.object({ message: z.string() }) } },\n description: \"Bad request\",\n },\n ...notFoundResponse,\n },\n});\n\nconst deleteChannelRoute = createRoute({\n method: \"delete\",\n tags: [\"Notifications\"],\n summary: \"Delete a notification channel\",\n path: \"/api/v1/projects/{slug}/notification-channels/{channelId}\",\n request: { params: z.object({ slug: z.string(), channelId: z.string() }) },\n responses: {\n 204: { description: \"Deleted notification channel\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the project notification-channel list, create, and delete endpoints. */\nexport function registerNotificationChannels(app: ShelfRouter): void {\n app.openapi(listChannelsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const rows = await notificationChannelModel().list(project.id);\n return c.json(rows.map((row) => toPublic(row)));\n });\n\n app.openapi(createChannelRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n const valid = NotificationChannelModel.validateInput(\n getStore().notifiers,\n body.provider,\n body.config,\n { projectId: project.id, hasSecret: body.secret !== undefined },\n );\n if (!valid.ok) {\n return c.json({ message: valid.message }, 400);\n }\n const row = await notificationChannelModel().create({ projectId: project.id, ...body });\n return c.json(toPublic(row), 201);\n });\n\n app.openapi(deleteChannelRoute, async (c) => {\n const { slug, channelId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const model = notificationChannelModel();\n const existing = await model.get(channelId);\n if (existing?.projectId !== project.id) {\n notFound(\"Notification channel not found\");\n }\n await model.remove(channelId);\n return c.body(null, 204);\n });\n}\n\nconst listSystemRoute = createRoute({\n method: \"get\",\n tags: [\"Notifications\"],\n summary: \"List site-wide channels\",\n path: \"/api/v1/admin/notification-channels\",\n request: {},\n responses: {\n 200: {\n content: { \"application/json\": { schema: notificationChannelSchema.array() } },\n description: \"List site-wide notification channels\",\n },\n ...unauthorized,\n },\n});\n\nconst createSystemRoute = createRoute({\n method: \"post\",\n tags: [\"Notifications\"],\n summary: \"Create a site-wide channel\",\n path: \"/api/v1/admin/notification-channels\",\n request: {\n body: { content: { \"application/json\": { schema: notificationChannelCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: notificationChannelSchema } },\n description: \"Created site-wide notification channel\",\n },\n 400: {\n content: { \"application/json\": { schema: z.object({ message: z.string() }) } },\n description: \"Bad request\",\n },\n ...unauthorized,\n },\n});\n\nconst deleteSystemRoute = createRoute({\n method: \"delete\",\n tags: [\"Notifications\"],\n summary: \"Delete a site-wide channel\",\n path: \"/api/v1/admin/notification-channels/{channelId}\",\n request: { params: z.object({ channelId: z.string() }) },\n responses: {\n 204: { description: \"Deleted site-wide notification channel\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the site-admin (project-less, `sys:*`) channel endpoints. */\nexport function registerSystemChannels(app: ShelfRouter): void {\n app.openapi(listSystemRoute, async (c) => {\n requireSiteAdmin(c);\n const rows = await notificationChannelModel().listSystem();\n return c.json(rows.map((row) => toPublic(row)));\n });\n\n app.openapi(createSystemRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const valid = NotificationChannelModel.validateInput(\n getStore().notifiers,\n body.provider,\n body.config,\n { projectId: null, hasSecret: body.secret !== undefined },\n );\n if (!valid.ok) {\n return c.json({ message: valid.message }, 400);\n }\n const row = await notificationChannelModel().create({ projectId: null, ...body });\n return c.json(toPublic(row), 201);\n });\n\n app.openapi(deleteSystemRoute, async (c) => {\n requireSiteAdmin(c);\n const { channelId } = c.req.valid(\"param\");\n const model = notificationChannelModel();\n const existing = await model.get(channelId);\n if (existing?.projectId !== null) {\n notFound(\"Notification channel not found\");\n }\n await model.remove(channelId);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,45 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { timingSafeEqualString } from "@storyshelf/core/utils";
5
+ import { createHmac, randomBytes } from "node:crypto";
6
+ //#region src/routers/oauth-state.ts
7
+ /** Signed, expiring OAuth `state` for "connect your account" flows (binds user, service and browser). */
8
+ /** State lifetime: long enough to sign in at the service, short enough to limit replay. */
9
+ const STATE_TTL_MS = 6e5;
10
+ /** Issue a signed state for a new flow, with a fresh nonce. */
11
+ function signState(secret, fields, now = Date.now()) {
12
+ const nonce = randomBytes(16).toString("hex");
13
+ const body = {
14
+ ...fields,
15
+ nonce,
16
+ expiresAt: now + STATE_TTL_MS
17
+ };
18
+ const payload = Buffer.from(JSON.stringify(body)).toString("base64url");
19
+ return {
20
+ state: `${payload}.${mac(secret, payload)}`,
21
+ nonce
22
+ };
23
+ }
24
+ /** Parse and verify a state; null when forged, malformed or expired. */
25
+ function verifyState(secret, state, now = Date.now()) {
26
+ const [payload, signature] = state.split(".");
27
+ if (!payload || !signature || !timingSafeEqualString(signature, mac(secret, payload))) return null;
28
+ try {
29
+ const body = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
30
+ return body.expiresAt > now ? body : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** A same-site path (never an absolute or protocol-relative URL), else the fallback. */
36
+ function safeReturnPath(value, fallback) {
37
+ return value && /^\/(?![/\\])/u.test(value) ? value : fallback;
38
+ }
39
+ function mac(secret, payload) {
40
+ return createHmac("sha256", secret).update(`oauth-connect:${payload}`).digest("base64url");
41
+ }
42
+ //#endregion
43
+ export { safeReturnPath, signState, verifyState };
44
+
45
+ //# sourceMappingURL=oauth-state.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth-state.mjs","names":[],"sources":["../../src/routers/oauth-state.ts"],"sourcesContent":["/** Signed, expiring OAuth `state` for \"connect your account\" flows (binds user, service and browser). */\nimport { timingSafeEqualString } from \"@storyshelf/core/utils\";\nimport { createHmac, randomBytes } from \"node:crypto\";\n\n/** State lifetime: long enough to sign in at the service, short enough to limit replay. */\nconst STATE_TTL_MS = 10 * 60_000;\n\n/** What a state asserts about the flow that issued it. */\nexport interface OAuthState {\n /** User who started the flow. */\n userId: string;\n /** Service `metadata.kind`. */\n service: string;\n /** Local path to return to afterwards. */\n returnTo: string;\n /** Random value also kept in a cookie, binding the state to one browser. */\n nonce: string;\n expiresAt: number;\n}\n\n/** Issue a signed state for a new flow, with a fresh nonce. */\nexport function signState(\n secret: string,\n fields: Omit<OAuthState, \"nonce\" | \"expiresAt\">,\n now = Date.now(),\n): { state: string; nonce: string } {\n const nonce = randomBytes(16).toString(\"hex\");\n const body: OAuthState = { ...fields, nonce, expiresAt: now + STATE_TTL_MS };\n const payload = Buffer.from(JSON.stringify(body)).toString(\"base64url\");\n return { state: `${payload}.${mac(secret, payload)}`, nonce };\n}\n\n/** Parse and verify a state; null when forged, malformed or expired. */\nexport function verifyState(secret: string, state: string, now = Date.now()): OAuthState | null {\n const [payload, signature] = state.split(\".\");\n if (!payload || !signature || !timingSafeEqualString(signature, mac(secret, payload))) {\n return null;\n }\n try {\n const body = JSON.parse(Buffer.from(payload, \"base64url\").toString(\"utf8\")) as OAuthState;\n return body.expiresAt > now ? body : null;\n } catch {\n return null;\n }\n}\n\n/** A same-site path (never an absolute or protocol-relative URL), else the fallback. */\nexport function safeReturnPath(value: string | undefined, fallback: string): string {\n return value && /^\\/(?![/\\\\])/u.test(value) ? value : fallback;\n}\n\nfunction mac(secret: string, payload: string): string {\n return createHmac(\"sha256\", secret).update(`oauth-connect:${payload}`).digest(\"base64url\");\n}\n"],"mappings":";;;;;;;;AAKA,MAAM,eAAe;;AAgBrB,SAAgB,UACd,QACA,QACA,MAAM,KAAK,IAAI,GACmB;CAClC,MAAM,QAAQ,YAAY,EAAE,CAAC,CAAC,SAAS,KAAK;CAC5C,MAAM,OAAmB;EAAE,GAAG;EAAQ;EAAO,WAAW,MAAM;CAAa;CAC3E,MAAM,UAAU,OAAO,KAAK,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,SAAS,WAAW;CACtE,OAAO;EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,QAAQ,OAAO;EAAK;CAAM;AAC9D;;AAGA,SAAgB,YAAY,QAAgB,OAAe,MAAM,KAAK,IAAI,GAAsB;CAC9F,MAAM,CAAC,SAAS,aAAa,MAAM,MAAM,GAAG;CAC5C,IAAI,CAAC,WAAW,CAAC,aAAa,CAAC,sBAAsB,WAAW,IAAI,QAAQ,OAAO,CAAC,GAClF,OAAO;CAET,IAAI;EACF,MAAM,OAAO,KAAK,MAAM,OAAO,KAAK,SAAS,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC;EAC1E,OAAO,KAAK,YAAY,MAAM,OAAO;CACvC,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,eAAe,OAA2B,UAA0B;CAClF,OAAO,SAAS,gBAAgB,KAAK,KAAK,IAAI,QAAQ;AACxD;AAEA,SAAS,IAAI,QAAgB,SAAyB;CACpD,OAAO,WAAW,UAAU,MAAM,CAAC,CAAC,OAAO,iBAAiB,SAAS,CAAC,CAAC,OAAO,WAAW;AAC3F"}
@@ -0,0 +1,38 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "../credential-keys.mjs";
5
+ import { getStore, testingEnabled } from "../store.mjs";
6
+ import { isAnonymouslyViewable, publishedAccess } from "./published-access.mjs";
7
+ import { STORYBOOK_TOKEN_PREFIX, signStorybookToken } from "@storyshelf/core/utils";
8
+ import { createUrlBuilder } from "@storyshelf/core/urls";
9
+ //#region src/routers/preview-base.ts
10
+ /** Lifetime of the token in a page: long enough to open the frame, short enough to be harmless. */
11
+ const PREVIEW_TOKEN_TTL = 3600;
12
+ /**
13
+ * How often a page asks for a fresh frame token (seconds): well inside {@link PREVIEW_TOKEN_TTL},
14
+ * so a frame on a page left open never holds an expired token. The token stays short-lived.
15
+ */
16
+ const PREVIEW_TOKEN_REFRESH = 2400;
17
+ /** Whether a frame root is a signed `/_s/<token>` URL, the only kind that expires. */
18
+ function isSignedPreviewBase(base) {
19
+ return base.startsWith(STORYBOOK_TOKEN_PREFIX);
20
+ }
21
+ /** The frame's root URL (no trailing slash), or null when this viewer cannot be given one. */
22
+ async function previewBaseOf(project, build) {
23
+ const { config } = getStore();
24
+ if (isAnonymouslyViewable(build, project)) return createUrlBuilder("/", config.publishedBaseDomain).storybookBuild(project.slug, build.id);
25
+ const secret = credentialKeys(config);
26
+ if (!testingEnabled() || !secret || !await publishedAccess(build, project)) return null;
27
+ const token = signStorybookToken({
28
+ secret,
29
+ projectId: project.id,
30
+ buildId: build.id,
31
+ ttlSeconds: PREVIEW_TOKEN_TTL
32
+ });
33
+ return `${STORYBOOK_TOKEN_PREFIX}${token}`;
34
+ }
35
+ //#endregion
36
+ export { PREVIEW_TOKEN_REFRESH, PREVIEW_TOKEN_TTL, isSignedPreviewBase, previewBaseOf };
37
+
38
+ //# sourceMappingURL=preview-base.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview-base.mjs","names":[],"sources":["../../src/routers/preview-base.ts"],"sourcesContent":["/**\n * The root a component page's preview frame loads a build's Storybook from (ADR 0006, decision 9).\n * A sandboxed frame has no cookies, so only a URL that needs none works in it: the build's own URL\n * when anyone who reaches the app may open it, otherwise a signed `/_s/<token>` URL minted for a\n * viewer who already has access (ADR 0007, decision 5; needs `testing` and a `secret`).\n */\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport {\n signStorybookToken,\n STORYBOOK_TOKEN_PREFIX,\n type SecretInput,\n} from \"@storyshelf/core/utils\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore, testingEnabled } from \"../store.ts\";\nimport { isAnonymouslyViewable, publishedAccess } from \"./published-access.ts\";\n\n/** Lifetime of the token in a page: long enough to open the frame, short enough to be harmless. */\nexport const PREVIEW_TOKEN_TTL = 3600;\n\n/**\n * How often a page asks for a fresh frame token (seconds): well inside {@link PREVIEW_TOKEN_TTL},\n * so a frame on a page left open never holds an expired token. The token stays short-lived.\n */\nexport const PREVIEW_TOKEN_REFRESH = 2400;\n\n/** Whether a frame root is a signed `/_s/<token>` URL, the only kind that expires. */\nexport function isSignedPreviewBase(base: string): boolean {\n return base.startsWith(STORYBOOK_TOKEN_PREFIX);\n}\n\n/** The frame's root URL (no trailing slash), or null when this viewer cannot be given one. */\nexport async function previewBaseOf(project: Project, build: Build): Promise<string | null> {\n const { config } = getStore();\n if (isAnonymouslyViewable(build, project)) {\n return createUrlBuilder(\"/\", config.publishedBaseDomain).storybookBuild(project.slug, build.id);\n }\n const secret: SecretInput = credentialKeys(config);\n if (!testingEnabled() || !secret || !(await publishedAccess(build, project))) {\n return null;\n }\n const token = signStorybookToken({\n secret,\n projectId: project.id,\n buildId: build.id,\n ttlSeconds: PREVIEW_TOKEN_TTL,\n });\n return `${STORYBOOK_TOKEN_PREFIX}${token}`;\n}\n"],"mappings":";;;;;;;;;;AAkBA,MAAa,oBAAoB;;;;;AAMjC,MAAa,wBAAwB;;AAGrC,SAAgB,oBAAoB,MAAuB;CACzD,OAAO,KAAK,WAAW,sBAAsB;AAC/C;;AAGA,eAAsB,cAAc,SAAkB,OAAsC;CAC1F,MAAM,EAAE,WAAW,SAAS;CAC5B,IAAI,sBAAsB,OAAO,OAAO,GACtC,OAAO,iBAAiB,KAAK,OAAO,mBAAmB,CAAC,CAAC,eAAe,QAAQ,MAAM,MAAM,EAAE;CAEhG,MAAM,SAAsB,eAAe,MAAM;CACjD,IAAI,CAAC,eAAe,KAAK,CAAC,UAAU,CAAE,MAAM,gBAAgB,OAAO,OAAO,GACxE,OAAO;CAET,MAAM,QAAQ,mBAAmB;EAC/B;EACA,WAAW,QAAQ;EACnB,SAAS,MAAM;EACf,YAAY;CACd,CAAC;CACD,OAAO,GAAG,yBAAyB;AACrC"}
@@ -0,0 +1,51 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { isSignedPreviewBase, previewBaseOf } from "./preview-base.mjs";
6
+ import { SIGNED_URL_HEADERS } from "./published-files.mjs";
7
+ import { createRoute, z } from "@hono/zod-openapi";
8
+ import { BuildModel, ProjectModel } from "@storyshelf/core/models";
9
+ //#region src/routers/preview-token.ts
10
+ /**
11
+ * `GET /api/v1/projects/:slug/builds/:buildId/preview-token`: a fresh root for a component page's preview frame
12
+ * (ADR 0006, decision 9). A frame holding a signed `/_s/<token>` URL (ADR 0007, decision 5) outlives
13
+ * the token's hour on a page left open, so the page's script asks for a new root before then and
14
+ * re-points the frame. Answers `{ base }` for a viewer who may open the build, 404 otherwise or when
15
+ * the build needs no token (anyone may open it). Never cached: the answer carries a token.
16
+ */
17
+ const previewTokenRoute = createRoute({
18
+ method: "get",
19
+ tags: ["Review"],
20
+ summary: "Get a fresh signed root for a build's preview frame",
21
+ path: "/api/v1/projects/{slug}/builds/{buildId}/preview-token",
22
+ request: { params: z.object({
23
+ slug: z.string(),
24
+ buildId: z.string()
25
+ }) },
26
+ responses: {
27
+ 200: {
28
+ content: { "application/json": { schema: z.object({ base: z.string() }) } },
29
+ description: "The signed root (`/_s/<token>`) to frame the build's Storybook under"
30
+ },
31
+ 404: {
32
+ content: { "text/plain": { schema: z.string() } },
33
+ description: "Not found, or the build needs no signed URL"
34
+ }
35
+ }
36
+ });
37
+ /** Register the preview token refresh route. */
38
+ function registerPreviewToken(app) {
39
+ app.openapi(previewTokenRoute, async (c) => {
40
+ const { db } = getStore();
41
+ const project = await new ProjectModel(db).getBySlug(c.req.param("slug"));
42
+ const build = await new BuildModel(db).get(c.req.param("buildId"));
43
+ const base = project && build?.projectId === project.id ? await previewBaseOf(project, build) : null;
44
+ if (base === null || !isSignedPreviewBase(base)) return c.text("Not found", 404, SIGNED_URL_HEADERS);
45
+ return c.json({ base }, 200, SIGNED_URL_HEADERS);
46
+ });
47
+ }
48
+ //#endregion
49
+ export { registerPreviewToken };
50
+
51
+ //# sourceMappingURL=preview-token.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preview-token.mjs","names":[],"sources":["../../src/routers/preview-token.ts"],"sourcesContent":["/**\n * `GET /api/v1/projects/:slug/builds/:buildId/preview-token`: a fresh root for a component page's preview frame\n * (ADR 0006, decision 9). A frame holding a signed `/_s/<token>` URL (ADR 0007, decision 5) outlives\n * the token's hour on a page left open, so the page's script asks for a new root before then and\n * re-points the frame. Answers `{ base }` for a viewer who may open the build, 404 otherwise or when\n * the build needs no token (anyone may open it). Never cached: the answer carries a token.\n */\nimport { createRoute, z } from \"@hono/zod-openapi\";\nimport { BuildModel, ProjectModel } from \"@storyshelf/core/models\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { isSignedPreviewBase, previewBaseOf } from \"./preview-base.ts\";\nimport { SIGNED_URL_HEADERS } from \"./published-files.ts\";\n\nconst previewTokenRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"Get a fresh signed root for a build's preview frame\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/preview-token\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: z.object({ base: z.string() }) } },\n description: \"The signed root (`/_s/<token>`) to frame the build's Storybook under\",\n },\n 404: {\n content: { \"text/plain\": { schema: z.string() } },\n description: \"Not found, or the build needs no signed URL\",\n },\n },\n});\n\n/** Register the preview token refresh route. */\nexport function registerPreviewToken(app: ShelfRouter): void {\n app.openapi(previewTokenRoute, async (c) => {\n const { db } = getStore();\n const project = await new ProjectModel(db).getBySlug(c.req.param(\"slug\"));\n const build = await new BuildModel(db).get(c.req.param(\"buildId\"));\n const base =\n project && build?.projectId === project.id ? await previewBaseOf(project, build) : null;\n if (base === null || !isSignedPreviewBase(base)) {\n return c.text(\"Not found\", 404, SIGNED_URL_HEADERS);\n }\n return c.json({ base }, 200, SIGNED_URL_HEADERS);\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAcA,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM,CAAC,QAAQ;CACf,SAAS;CACT,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,EAAE;GAC1E,aAAa;EACf;EACA,KAAK;GACH,SAAS,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;GAChD,aAAa;EACf;CACF;AACF,CAAC;;AAGD,SAAgB,qBAAqB,KAAwB;CAC3D,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,OAAO,SAAS;EACxB,MAAM,UAAU,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,UAAU,EAAE,IAAI,MAAM,MAAM,CAAC;EACxE,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,MAAM,SAAS,CAAC;EACjE,MAAM,OACJ,WAAW,OAAO,cAAc,QAAQ,KAAK,MAAM,cAAc,SAAS,KAAK,IAAI;EACrF,IAAI,SAAS,QAAQ,CAAC,oBAAoB,IAAI,GAC5C,OAAO,EAAE,KAAK,aAAa,KAAK,kBAAkB;EAEpD,OAAO,EAAE,KAAK,EAAE,KAAK,GAAG,KAAK,kBAAkB;CACjD,CAAC;AACH"}
@@ -2,13 +2,14 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
+ import { notificationsPaused } from "../notifications-pause.mjs";
5
6
  import { hxRedirect } from "./htmx.mjs";
6
7
  import { flash } from "./flash.mjs";
7
8
  import { forwardToEngine } from "./auth.mjs";
8
9
  import { renderProfilePage } from "../pages/profile.mjs";
9
10
  import { NotificationSubscriptionModel, ProjectModel, UserModel } from "@storyshelf/core/models";
10
11
  import { SESSION_COOKIE } from "@storyshelf/core/types";
11
- import { MIN_PASSWORD_LENGTH, unsignedToken } from "@storyshelf/auth";
12
+ import { MIN_PASSWORD_LENGTH, unsignedToken } from "@storyshelf/core/auth";
12
13
  import { eq, getTableColumns } from "@storyshelf/core/orm";
13
14
  //#region src/routers/profile.ts
14
15
  function field(form, name) {
@@ -89,7 +90,8 @@ async function profileView(c, user, auth, extra) {
89
90
  subscribedSlugs,
90
91
  isLocal: extra?.local ?? security.hasPassword,
91
92
  security,
92
- error: extra?.error
93
+ error: extra?.error,
94
+ notificationsPaused: getStore().notifiers.length > 0 && await notificationsPaused()
93
95
  });
94
96
  if (extra?.success) flash(c, extra.success);
95
97
  c.header("Cache-Control", "no-store");
@@ -1 +1 @@
1
- {"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport { eq, getTableColumns } from \"@storyshelf/core/orm\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/core/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport { eq, getTableColumns } from \"@storyshelf/core/orm\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notificationsPaused } from \"../notifications-pause.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n notificationsPaused: getStore().notifiers.length > 0 && (await notificationsPaused()),\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
@@ -3,9 +3,14 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { getStore } from "../store.mjs";
5
5
  import { forbidden, requireSiteAdmin, resolveAuthorizedProject } from "./helpers.mjs";
6
+ import { assertTargetsSelectable } from "../targets.mjs";
6
7
  import { badRequest, forbidden as forbidden$1, notFound, projectCreateSchema, projectSchema, projectUpdateSchema, unauthorized } from "./schemas.mjs";
8
+ import { toProjectPatch } from "./capture-config.mjs";
9
+ import { runSwitchOffPass } from "./review-switch-off.mjs";
7
10
  import { createRoute, z } from "@hono/zod-openapi";
11
+ import { resolveBrowsers } from "@storyshelf/core/capture";
8
12
  import { LabelModel, MemberModel, ProjectModel } from "@storyshelf/core/models";
13
+ import { HTTPException } from "hono/http-exception";
9
14
  //#region src/routers/projects.ts
10
15
  const VIEW_ROLES = [
11
16
  "viewer",
@@ -92,6 +97,13 @@ const deleteProjectRoute = createRoute({
92
97
  ...notFound
93
98
  }
94
99
  });
100
+ /** `aiProfile` is a site-admin data-flow decision; it must name a configured profile. */
101
+ function assertAiProfileChange(c, profile) {
102
+ if (profile === void 0) return;
103
+ requireSiteAdmin(c);
104
+ const { ai } = getStore();
105
+ if (profile !== null && !ai?.profileNames().includes(profile)) throw new HTTPException(400, { message: `Unknown AI profile "${profile}"` });
106
+ }
95
107
  /** Register the project list, create, fetch, update, and delete endpoints. */
96
108
  function registerProjects(app) {
97
109
  app.openapi(listProjectsRoute, async (c) => {
@@ -116,7 +128,11 @@ function registerProjects(app) {
116
128
  app.openapi(updateProjectRoute, async (c) => {
117
129
  const { slug } = c.req.valid("param");
118
130
  const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
119
- const updated = await new ProjectModel(getStore().db).update(project.id, c.req.valid("json"));
131
+ const body = c.req.valid("json");
132
+ assertAiProfileChange(c, body.aiProfile);
133
+ await assertTargetsSelectable(body.browsers ?? (body.browser === void 0 ? [] : [body.browser]), resolveBrowsers(project));
134
+ const updated = await new ProjectModel(getStore().db).update(project.id, toProjectPatch(body));
135
+ await runSwitchOffPass(project, updated);
120
136
  return c.json(updated);
121
137
  });
122
138
  app.openapi(deleteProjectRoute, async (c) => {
@@ -1 +1 @@
1
- {"version":3,"file":"projects.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/projects.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { forbidden, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n badRequest,\n forbidden as forbiddenResponse,\n notFound,\n projectCreateSchema,\n projectSchema,\n projectUpdateSchema,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nfunction requireSessionUser(): void {\n if (!getStore().authEnabled) {\n return;\n }\n if (!getStore().user) {\n forbidden();\n }\n}\n\nconst listProjectsRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"List projects\",\n path: \"/api/v1/projects\",\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema.array() } },\n description: \"List projects\",\n },\n ...unauthorized,\n },\n});\n\nconst createProjectRoute = createRoute({\n method: \"post\",\n tags: [\"Projects\"],\n summary: \"Create a project\",\n path: \"/api/v1/projects\",\n request: { body: { content: { \"application/json\": { schema: projectCreateSchema } } } },\n responses: {\n 201: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Created project\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...unauthorized,\n },\n});\n\nconst getProjectRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"Get a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Fetch a project\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst updateProjectRoute = createRoute({\n method: \"patch\",\n tags: [\"Projects\"],\n summary: \"Update a project\",\n path: \"/api/v1/projects/{slug}\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: projectUpdateSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Updated project\",\n },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\nconst deleteProjectRoute = createRoute({\n method: \"delete\",\n tags: [\"Projects\"],\n summary: \"Delete a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 204: { description: \"Deleted project\" },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\n/** Register the project list, create, fetch, update, and delete endpoints. */\nexport function registerProjects(app: ShelfRouter): void {\n app.openapi(listProjectsRoute, async (c) => {\n requireSessionUser();\n const projects = new ProjectModel(getStore().db);\n return c.json(await projects.list());\n });\n\n app.openapi(createProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const projects = new ProjectModel(getStore().db);\n const project = await projects.create(body);\n await new LabelModel(getStore().db).seedFor(project.id);\n const creator = getStore().user;\n if (creator) {\n await new MemberModel(getStore().db).set(project.id, creator.id, \"admin\");\n }\n return c.json(project, 201);\n });\n\n app.openapi(getProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n return c.json(project);\n });\n\n app.openapi(updateProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const updated = await new ProjectModel(getStore().db).update(project.id, c.req.valid(\"json\"));\n return c.json(updated);\n });\n\n app.openapi(deleteProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n await new ProjectModel(getStore().db).remove(project.id);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"projects.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/projects.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { resolveBrowsers } from \"@storyshelf/core/capture\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { assertTargetsSelectable } from \"../targets.ts\";\nimport { toProjectPatch } from \"./capture-config.ts\";\nimport { forbidden, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport { runSwitchOffPass } from \"./review-switch-off.ts\";\nimport {\n badRequest,\n forbidden as forbiddenResponse,\n notFound,\n projectCreateSchema,\n projectSchema,\n projectUpdateSchema,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nfunction requireSessionUser(): void {\n if (!getStore().authEnabled) {\n return;\n }\n if (!getStore().user) {\n forbidden();\n }\n}\n\nconst listProjectsRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"List projects\",\n path: \"/api/v1/projects\",\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema.array() } },\n description: \"List projects\",\n },\n ...unauthorized,\n },\n});\n\nconst createProjectRoute = createRoute({\n method: \"post\",\n tags: [\"Projects\"],\n summary: \"Create a project\",\n path: \"/api/v1/projects\",\n request: { body: { content: { \"application/json\": { schema: projectCreateSchema } } } },\n responses: {\n 201: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Created project\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...unauthorized,\n },\n});\n\nconst getProjectRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"Get a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Fetch a project\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst updateProjectRoute = createRoute({\n method: \"patch\",\n tags: [\"Projects\"],\n summary: \"Update a project\",\n path: \"/api/v1/projects/{slug}\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: projectUpdateSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Updated project\",\n },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\nconst deleteProjectRoute = createRoute({\n method: \"delete\",\n tags: [\"Projects\"],\n summary: \"Delete a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 204: { description: \"Deleted project\" },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\n/** `aiProfile` is a site-admin data-flow decision; it must name a configured profile. */\nfunction assertAiProfileChange(c: Context, profile: string | null | undefined): void {\n if (profile === undefined) {\n return;\n }\n requireSiteAdmin(c);\n const { ai } = getStore();\n if (profile !== null && !ai?.profileNames().includes(profile)) {\n throw new HTTPException(400, { message: `Unknown AI profile \"${profile}\"` });\n }\n}\n\n/** Register the project list, create, fetch, update, and delete endpoints. */\nexport function registerProjects(app: ShelfRouter): void {\n app.openapi(listProjectsRoute, async (c) => {\n requireSessionUser();\n const projects = new ProjectModel(getStore().db);\n return c.json(await projects.list());\n });\n\n app.openapi(createProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const projects = new ProjectModel(getStore().db);\n const project = await projects.create(body);\n await new LabelModel(getStore().db).seedFor(project.id);\n const creator = getStore().user;\n if (creator) {\n await new MemberModel(getStore().db).set(project.id, creator.id, \"admin\");\n }\n return c.json(project, 201);\n });\n\n app.openapi(getProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n return c.json(project);\n });\n\n app.openapi(updateProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n assertAiProfileChange(c, body.aiProfile);\n await assertTargetsSelectable(\n body.browsers ?? (body.browser === undefined ? [] : [body.browser]),\n resolveBrowsers(project),\n );\n const updated = await new ProjectModel(getStore().db).update(project.id, toProjectPatch(body));\n await runSwitchOffPass(project, updated);\n return c.json(updated);\n });\n\n app.openapi(deleteProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n await new ProjectModel(getStore().db).remove(project.id);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}