@storyshelf/app 0.5.3

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/app-types.d.mts +54 -0
  4. package/dist/asset-manifest.mjs +41 -0
  5. package/dist/asset-manifest.mjs.map +1 -0
  6. package/dist/asset-paths.mjs +23 -0
  7. package/dist/asset-paths.mjs.map +1 -0
  8. package/dist/assets/htmx.min.js +3304 -0
  9. package/dist/assets/mark.svg +1 -0
  10. package/dist/capture-setup.mjs +139 -0
  11. package/dist/capture-setup.mjs.map +1 -0
  12. package/dist/credential-keys.mjs +20 -0
  13. package/dist/credential-keys.mjs.map +1 -0
  14. package/dist/credentials.mjs +56 -0
  15. package/dist/credentials.mjs.map +1 -0
  16. package/dist/index.d.mts +18 -0
  17. package/dist/index.mjs +310 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/lifecycle.mjs +132 -0
  20. package/dist/lifecycle.mjs.map +1 -0
  21. package/dist/middleware/auth-gate.mjs +36 -0
  22. package/dist/middleware/auth-gate.mjs.map +1 -0
  23. package/dist/middleware/csrf.mjs +75 -0
  24. package/dist/middleware/csrf.mjs.map +1 -0
  25. package/dist/middleware/rate-limit.mjs +68 -0
  26. package/dist/middleware/rate-limit.mjs.map +1 -0
  27. package/dist/middleware/request-log.mjs +30 -0
  28. package/dist/middleware/request-log.mjs.map +1 -0
  29. package/dist/middleware/server-timing.mjs +39 -0
  30. package/dist/middleware/server-timing.mjs.map +1 -0
  31. package/dist/middleware/setup-gate.mjs +37 -0
  32. package/dist/middleware/setup-gate.mjs.map +1 -0
  33. package/dist/middleware/store-scope.mjs +33 -0
  34. package/dist/middleware/store-scope.mjs.map +1 -0
  35. package/dist/notify.d.mts +17 -0
  36. package/dist/notify.mjs +141 -0
  37. package/dist/notify.mjs.map +1 -0
  38. package/dist/pages/admin-system.mjs +243 -0
  39. package/dist/pages/admin-system.mjs.map +1 -0
  40. package/dist/pages/build-detail.mjs +392 -0
  41. package/dist/pages/build-detail.mjs.map +1 -0
  42. package/dist/pages/build-diff-actions.mjs +162 -0
  43. package/dist/pages/build-diff-actions.mjs.map +1 -0
  44. package/dist/pages/build-diff-comments.mjs +104 -0
  45. package/dist/pages/build-diff-comments.mjs.map +1 -0
  46. package/dist/pages/build-diff-header.mjs +117 -0
  47. package/dist/pages/build-diff-header.mjs.map +1 -0
  48. package/dist/pages/build-diff-model.mjs +48 -0
  49. package/dist/pages/build-diff-model.mjs.map +1 -0
  50. package/dist/pages/build-diff-nav.mjs +122 -0
  51. package/dist/pages/build-diff-nav.mjs.map +1 -0
  52. package/dist/pages/build-diff-shortcuts.mjs +78 -0
  53. package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
  54. package/dist/pages/build-diff-stale.mjs +70 -0
  55. package/dist/pages/build-diff-stale.mjs.map +1 -0
  56. package/dist/pages/build-diff-viewer.mjs +70 -0
  57. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  58. package/dist/pages/build-diff.mjs +110 -0
  59. package/dist/pages/build-diff.mjs.map +1 -0
  60. package/dist/pages/compute-jobs.mjs +165 -0
  61. package/dist/pages/compute-jobs.mjs.map +1 -0
  62. package/dist/pages/invite.mjs +91 -0
  63. package/dist/pages/invite.mjs.map +1 -0
  64. package/dist/pages/label-detail.mjs +154 -0
  65. package/dist/pages/label-detail.mjs.map +1 -0
  66. package/dist/pages/library.mjs +343 -0
  67. package/dist/pages/library.mjs.map +1 -0
  68. package/dist/pages/login.mjs +117 -0
  69. package/dist/pages/login.mjs.map +1 -0
  70. package/dist/pages/passkey-ceremony.mjs +97 -0
  71. package/dist/pages/passkey-ceremony.mjs.map +1 -0
  72. package/dist/pages/profile.mjs +364 -0
  73. package/dist/pages/profile.mjs.map +1 -0
  74. package/dist/pages/project-builds-table.mjs +151 -0
  75. package/dist/pages/project-builds-table.mjs.map +1 -0
  76. package/dist/pages/project-builds.mjs +143 -0
  77. package/dist/pages/project-builds.mjs.map +1 -0
  78. package/dist/pages/project-create.mjs +90 -0
  79. package/dist/pages/project-create.mjs.map +1 -0
  80. package/dist/pages/project-settings.mjs +95 -0
  81. package/dist/pages/project-settings.mjs.map +1 -0
  82. package/dist/pages/projects-card.mjs +194 -0
  83. package/dist/pages/projects-card.mjs.map +1 -0
  84. package/dist/pages/projects.mjs +129 -0
  85. package/dist/pages/projects.mjs.map +1 -0
  86. package/dist/pages/root.mjs +98 -0
  87. package/dist/pages/root.mjs.map +1 -0
  88. package/dist/pages/settings-general.mjs +138 -0
  89. package/dist/pages/settings-general.mjs.map +1 -0
  90. package/dist/pages/settings-labels.mjs +107 -0
  91. package/dist/pages/settings-labels.mjs.map +1 -0
  92. package/dist/pages/settings-members.mjs +192 -0
  93. package/dist/pages/settings-members.mjs.map +1 -0
  94. package/dist/pages/settings-notifications.mjs +240 -0
  95. package/dist/pages/settings-notifications.mjs.map +1 -0
  96. package/dist/pages/settings-status.mjs +137 -0
  97. package/dist/pages/settings-status.mjs.map +1 -0
  98. package/dist/pages/settings-tests.mjs +111 -0
  99. package/dist/pages/settings-tests.mjs.map +1 -0
  100. package/dist/pages/settings-tokens.mjs +90 -0
  101. package/dist/pages/settings-tokens.mjs.map +1 -0
  102. package/dist/pages/settings-webhooks.mjs +104 -0
  103. package/dist/pages/settings-webhooks.mjs.map +1 -0
  104. package/dist/pages/storybook.mjs +116 -0
  105. package/dist/pages/storybook.mjs.map +1 -0
  106. package/dist/retention-timer.mjs +65 -0
  107. package/dist/retention-timer.mjs.map +1 -0
  108. package/dist/routers/admin-pages.mjs +48 -0
  109. package/dist/routers/admin-pages.mjs.map +1 -0
  110. package/dist/routers/admin.mjs +113 -0
  111. package/dist/routers/admin.mjs.map +1 -0
  112. package/dist/routers/assets.mjs +64 -0
  113. package/dist/routers/assets.mjs.map +1 -0
  114. package/dist/routers/attempts.mjs +96 -0
  115. package/dist/routers/attempts.mjs.map +1 -0
  116. package/dist/routers/auth-sync.mjs +64 -0
  117. package/dist/routers/auth-sync.mjs.map +1 -0
  118. package/dist/routers/auth.mjs +236 -0
  119. package/dist/routers/auth.mjs.map +1 -0
  120. package/dist/routers/baseline-guard.mjs +83 -0
  121. package/dist/routers/baseline-guard.mjs.map +1 -0
  122. package/dist/routers/builds.handlers.mjs +217 -0
  123. package/dist/routers/builds.handlers.mjs.map +1 -0
  124. package/dist/routers/builds.mjs +381 -0
  125. package/dist/routers/builds.mjs.map +1 -0
  126. package/dist/routers/comments.mjs +109 -0
  127. package/dist/routers/comments.mjs.map +1 -0
  128. package/dist/routers/flash.mjs +35 -0
  129. package/dist/routers/flash.mjs.map +1 -0
  130. package/dist/routers/health-report.mjs +159 -0
  131. package/dist/routers/health-report.mjs.map +1 -0
  132. package/dist/routers/health.mjs +46 -0
  133. package/dist/routers/health.mjs.map +1 -0
  134. package/dist/routers/helpers.mjs +110 -0
  135. package/dist/routers/helpers.mjs.map +1 -0
  136. package/dist/routers/htmx.mjs +24 -0
  137. package/dist/routers/htmx.mjs.map +1 -0
  138. package/dist/routers/labels.mjs +118 -0
  139. package/dist/routers/labels.mjs.map +1 -0
  140. package/dist/routers/media.mjs +53 -0
  141. package/dist/routers/media.mjs.map +1 -0
  142. package/dist/routers/members.mjs +177 -0
  143. package/dist/routers/members.mjs.map +1 -0
  144. package/dist/routers/notification-channels.mjs +190 -0
  145. package/dist/routers/notification-channels.mjs.map +1 -0
  146. package/dist/routers/notification-subscriptions.mjs +120 -0
  147. package/dist/routers/notification-subscriptions.mjs.map +1 -0
  148. package/dist/routers/profile.mjs +276 -0
  149. package/dist/routers/profile.mjs.map +1 -0
  150. package/dist/routers/projects.mjs +133 -0
  151. package/dist/routers/projects.mjs.map +1 -0
  152. package/dist/routers/review-next.mjs +47 -0
  153. package/dist/routers/review-next.mjs.map +1 -0
  154. package/dist/routers/schemas.mjs +337 -0
  155. package/dist/routers/schemas.mjs.map +1 -0
  156. package/dist/routers/settings-general.mjs +104 -0
  157. package/dist/routers/settings-general.mjs.map +1 -0
  158. package/dist/routers/settings-labels.mjs +52 -0
  159. package/dist/routers/settings-labels.mjs.map +1 -0
  160. package/dist/routers/settings-members.mjs +58 -0
  161. package/dist/routers/settings-members.mjs.map +1 -0
  162. package/dist/routers/settings-notifications.mjs +108 -0
  163. package/dist/routers/settings-notifications.mjs.map +1 -0
  164. package/dist/routers/settings-status.mjs +77 -0
  165. package/dist/routers/settings-status.mjs.map +1 -0
  166. package/dist/routers/settings-tokens.mjs +40 -0
  167. package/dist/routers/settings-tokens.mjs.map +1 -0
  168. package/dist/routers/settings-webhooks.mjs +67 -0
  169. package/dist/routers/settings-webhooks.mjs.map +1 -0
  170. package/dist/routers/settings.handlers.mjs +149 -0
  171. package/dist/routers/settings.handlers.mjs.map +1 -0
  172. package/dist/routers/settings.mjs +25 -0
  173. package/dist/routers/settings.mjs.map +1 -0
  174. package/dist/routers/snapshots.mjs +240 -0
  175. package/dist/routers/snapshots.mjs.map +1 -0
  176. package/dist/routers/status-configs.mjs +108 -0
  177. package/dist/routers/status-configs.mjs.map +1 -0
  178. package/dist/routers/storybook.mjs +171 -0
  179. package/dist/routers/storybook.mjs.map +1 -0
  180. package/dist/routers/tokens.mjs +91 -0
  181. package/dist/routers/tokens.mjs.map +1 -0
  182. package/dist/routers/ui.mjs +166 -0
  183. package/dist/routers/ui.mjs.map +1 -0
  184. package/dist/routers/webhooks.mjs +97 -0
  185. package/dist/routers/webhooks.mjs.map +1 -0
  186. package/dist/routers/well-known.mjs +43 -0
  187. package/dist/routers/well-known.mjs.map +1 -0
  188. package/dist/runtime.mjs +52 -0
  189. package/dist/runtime.mjs.map +1 -0
  190. package/dist/store.mjs +20 -0
  191. package/dist/store.mjs.map +1 -0
  192. package/dist/ui/avatar.mjs +76 -0
  193. package/dist/ui/avatar.mjs.map +1 -0
  194. package/dist/ui/buttons.mjs +205 -0
  195. package/dist/ui/buttons.mjs.map +1 -0
  196. package/dist/ui/code-block.mjs +47 -0
  197. package/dist/ui/code-block.mjs.map +1 -0
  198. package/dist/ui/compare-stage.mjs +344 -0
  199. package/dist/ui/compare-stage.mjs.map +1 -0
  200. package/dist/ui/csrf-field.mjs +20 -0
  201. package/dist/ui/csrf-field.mjs.map +1 -0
  202. package/dist/ui/css.mjs +36 -0
  203. package/dist/ui/css.mjs.map +1 -0
  204. package/dist/ui/document.mjs +130 -0
  205. package/dist/ui/document.mjs.map +1 -0
  206. package/dist/ui/dropdown.mjs +181 -0
  207. package/dist/ui/dropdown.mjs.map +1 -0
  208. package/dist/ui/feedback.mjs +341 -0
  209. package/dist/ui/feedback.mjs.map +1 -0
  210. package/dist/ui/filter-input.mjs +58 -0
  211. package/dist/ui/filter-input.mjs.map +1 -0
  212. package/dist/ui/forms.mjs +291 -0
  213. package/dist/ui/forms.mjs.map +1 -0
  214. package/dist/ui/icons/icon.mjs +53 -0
  215. package/dist/ui/icons/icon.mjs.map +1 -0
  216. package/dist/ui/icons/paths.mjs +61 -0
  217. package/dist/ui/icons/paths.mjs.map +1 -0
  218. package/dist/ui/icons/sprite.mjs +26 -0
  219. package/dist/ui/icons/sprite.mjs.map +1 -0
  220. package/dist/ui/kbd.mjs +33 -0
  221. package/dist/ui/kbd.mjs.map +1 -0
  222. package/dist/ui/layout.mjs +173 -0
  223. package/dist/ui/layout.mjs.map +1 -0
  224. package/dist/ui/progress.mjs +44 -0
  225. package/dist/ui/progress.mjs.map +1 -0
  226. package/dist/ui/segmented.mjs +77 -0
  227. package/dist/ui/segmented.mjs.map +1 -0
  228. package/dist/ui/shell/account.mjs +161 -0
  229. package/dist/ui/shell/account.mjs.map +1 -0
  230. package/dist/ui/shell/bare.mjs +69 -0
  231. package/dist/ui/shell/bare.mjs.map +1 -0
  232. package/dist/ui/shell/boot-script.mjs +46 -0
  233. package/dist/ui/shell/boot-script.mjs.map +1 -0
  234. package/dist/ui/shell/client-script.mjs +259 -0
  235. package/dist/ui/shell/client-script.mjs.map +1 -0
  236. package/dist/ui/shell/nav.mjs +111 -0
  237. package/dist/ui/shell/nav.mjs.map +1 -0
  238. package/dist/ui/shell/review-script.mjs +195 -0
  239. package/dist/ui/shell/review-script.mjs.map +1 -0
  240. package/dist/ui/shell/sidebar.mjs +323 -0
  241. package/dist/ui/shell/sidebar.mjs.map +1 -0
  242. package/dist/ui/shell/topbar.mjs +268 -0
  243. package/dist/ui/shell/topbar.mjs.map +1 -0
  244. package/dist/ui/stacks.mjs +82 -0
  245. package/dist/ui/stacks.mjs.map +1 -0
  246. package/dist/ui/styles/base.mjs +41 -0
  247. package/dist/ui/styles/base.mjs.map +1 -0
  248. package/dist/ui/styles/behavior.mjs +43 -0
  249. package/dist/ui/styles/behavior.mjs.map +1 -0
  250. package/dist/ui/styles/review-layout.mjs +347 -0
  251. package/dist/ui/styles/review-layout.mjs.map +1 -0
  252. package/dist/ui/styles/review.mjs +86 -0
  253. package/dist/ui/styles/review.mjs.map +1 -0
  254. package/dist/ui/styles/tokens.mjs +132 -0
  255. package/dist/ui/styles/tokens.mjs.map +1 -0
  256. package/dist/ui/styles.mjs +19 -0
  257. package/dist/ui/styles.mjs.map +1 -0
  258. package/dist/ui/subnav.mjs +91 -0
  259. package/dist/ui/subnav.mjs.map +1 -0
  260. package/dist/ui/table.mjs +73 -0
  261. package/dist/ui/table.mjs.map +1 -0
  262. package/dist/ui/theme.mjs +64 -0
  263. package/dist/ui/theme.mjs.map +1 -0
  264. package/dist/ui/thumbnail.mjs +51 -0
  265. package/dist/ui/thumbnail.mjs.map +1 -0
  266. package/dist/ui/time.mjs +50 -0
  267. package/dist/ui/time.mjs.map +1 -0
  268. package/package.json +92 -0
@@ -0,0 +1,240 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { hxRefresh, isHxRequest } from "./htmx.mjs";
6
+ import { flashHx } from "./flash.mjs";
7
+ import { resolveAuthorizedProject } from "./helpers.mjs";
8
+ import { approveAllResultSchema, baselineChangedSchema, errorSchema, notFound, okSchema, rediffResultSchema, snapshotSchema, unauthorized } from "./schemas.mjs";
9
+ import { findDriftedSnapshots, isBaselineChanged, isOpenSnapshot } from "./baseline-guard.mjs";
10
+ import { APPROVER_ROLES, VIEW_ROLES, approveSnapshot, buildForProject, refreshBuild, snapshotForBuild } from "./builds.handlers.mjs";
11
+ import { hxAdvance } from "./review-next.mjs";
12
+ import { createRoute, z } from "@hono/zod-openapi";
13
+ import { REDIFFABLE_BUILD_STATUSES, rediffBuild } from "@storyshelf/core/capture";
14
+ import { SnapshotModel } from "@storyshelf/core/models";
15
+ import { HTTPException } from "hono/http-exception";
16
+ //#region src/routers/snapshots.ts
17
+ const listSnapshotsRoute = createRoute({
18
+ method: "get",
19
+ tags: ["Review"],
20
+ summary: "List snapshots",
21
+ path: "/api/v1/projects/{slug}/builds/{buildId}/snapshots",
22
+ request: { params: z.object({
23
+ slug: z.string(),
24
+ buildId: z.string()
25
+ }) },
26
+ responses: {
27
+ 200: {
28
+ content: { "application/json": { schema: snapshotSchema.array() } },
29
+ description: "List snapshots for a build"
30
+ },
31
+ ...notFound,
32
+ ...unauthorized
33
+ }
34
+ });
35
+ const approveSnapshotRoute = createRoute({
36
+ method: "post",
37
+ tags: ["Review"],
38
+ summary: "Approve a snapshot",
39
+ path: "/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/approve",
40
+ request: {
41
+ params: z.object({
42
+ slug: z.string(),
43
+ buildId: z.string(),
44
+ snapshotId: z.string()
45
+ }),
46
+ query: z.object({ force: z.enum(["true", "false"]).optional() })
47
+ },
48
+ responses: {
49
+ 200: {
50
+ content: { "application/json": { schema: okSchema } },
51
+ description: "Snapshot approved"
52
+ },
53
+ 409: {
54
+ content: { "application/json": { schema: baselineChangedSchema } },
55
+ description: "The baseline changed after this diff was computed; re-diff the build or pass force=true"
56
+ },
57
+ ...notFound
58
+ }
59
+ });
60
+ const rejectSnapshotRoute = createRoute({
61
+ method: "post",
62
+ tags: ["Review"],
63
+ summary: "Reject a snapshot",
64
+ path: "/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/reject",
65
+ request: { params: z.object({
66
+ slug: z.string(),
67
+ buildId: z.string(),
68
+ snapshotId: z.string()
69
+ }) },
70
+ responses: {
71
+ 200: {
72
+ content: { "application/json": { schema: okSchema } },
73
+ description: "Snapshot rejected"
74
+ },
75
+ ...notFound
76
+ }
77
+ });
78
+ const approveAllRoute = createRoute({
79
+ method: "post",
80
+ tags: ["Review"],
81
+ summary: "Approve all open snapshots",
82
+ path: "/api/v1/projects/{slug}/builds/{buildId}/approve-all",
83
+ request: {
84
+ params: z.object({
85
+ slug: z.string(),
86
+ buildId: z.string()
87
+ }),
88
+ query: z.object({ force: z.enum(["true", "false"]).optional() })
89
+ },
90
+ responses: {
91
+ 200: {
92
+ content: { "application/json": { schema: approveAllResultSchema } },
93
+ description: "Open snapshots approved; snapshots with a changed baseline are skipped"
94
+ },
95
+ ...notFound
96
+ }
97
+ });
98
+ const rediffRoute = createRoute({
99
+ method: "post",
100
+ tags: ["Review"],
101
+ summary: "Re-diff against current baselines",
102
+ path: "/api/v1/projects/{slug}/builds/{buildId}/rediff",
103
+ request: { params: z.object({
104
+ slug: z.string(),
105
+ buildId: z.string()
106
+ }) },
107
+ responses: {
108
+ 200: {
109
+ content: { "application/json": { schema: rediffResultSchema } },
110
+ description: "Undecided snapshots re-diffed against the current baselines (no re-render). Decided snapshots are untouched."
111
+ },
112
+ 400: {
113
+ content: { "application/json": { schema: errorSchema } },
114
+ description: "Default-branch builds are authoritative and are not re-diffed"
115
+ },
116
+ 409: {
117
+ content: { "application/json": { schema: errorSchema } },
118
+ description: "Build has not finished capturing or failed; retry capture instead"
119
+ },
120
+ ...notFound
121
+ }
122
+ });
123
+ const rejectAllRoute = createRoute({
124
+ method: "post",
125
+ tags: ["Review"],
126
+ summary: "Reject all open snapshots",
127
+ path: "/api/v1/projects/{slug}/builds/{buildId}/reject-all",
128
+ request: { params: z.object({
129
+ slug: z.string(),
130
+ buildId: z.string()
131
+ }) },
132
+ responses: {
133
+ 200: {
134
+ content: { "application/json": { schema: okSchema } },
135
+ description: "All snapshots rejected"
136
+ },
137
+ ...notFound
138
+ }
139
+ });
140
+ /** Register the snapshot list, approve, reject, and bulk-review endpoints. */
141
+ function registerSnapshots(app) {
142
+ app.openapi(listSnapshotsRoute, async (c) => {
143
+ const { slug, buildId } = c.req.valid("param");
144
+ const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);
145
+ const build = await buildForProject(project.id, buildId);
146
+ const snapshots = new SnapshotModel(getStore().db).listByBuild(build.id);
147
+ return c.json(await snapshots);
148
+ });
149
+ app.openapi(approveSnapshotRoute, async (c) => {
150
+ const { slug, buildId, snapshotId } = c.req.valid("param");
151
+ const { force } = c.req.valid("query");
152
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
153
+ const build = await buildForProject(project.id, buildId);
154
+ await snapshotForBuild(build, snapshotId);
155
+ const userId = getStore().user?.id ?? null;
156
+ try {
157
+ await approveSnapshot(snapshotId, userId, { force: force === "true" });
158
+ } catch (error) {
159
+ if (!isHxRequest(c) || !isBaselineChanged(error)) throw error;
160
+ c.header("HX-Redirect", `/projects/${slug}/builds/${buildId}/diff?snapshot=${snapshotId}`);
161
+ return c.json({ ok: true }, 200);
162
+ }
163
+ await hxAdvance(c, {
164
+ slug,
165
+ buildId,
166
+ snapshotId
167
+ });
168
+ return c.json({ ok: true }, 200);
169
+ });
170
+ app.openapi(rejectSnapshotRoute, async (c) => {
171
+ const { slug, buildId, snapshotId } = c.req.valid("param");
172
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
173
+ const build = await buildForProject(project.id, buildId);
174
+ const snapshot = await snapshotForBuild(build, snapshotId);
175
+ const userId = getStore().user?.id ?? null;
176
+ await new SnapshotModel(getStore().db).review(snapshot.id, "rejected", userId);
177
+ await refreshBuild(build.id);
178
+ await hxAdvance(c, {
179
+ slug,
180
+ buildId,
181
+ snapshotId
182
+ });
183
+ return c.json({ ok: true });
184
+ });
185
+ app.openapi(approveAllRoute, async (c) => {
186
+ const { slug, buildId } = c.req.valid("param");
187
+ const { force } = c.req.valid("query");
188
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
189
+ const build = await buildForProject(project.id, buildId);
190
+ const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);
191
+ const userId = getStore().user?.id ?? null;
192
+ const forced = force === "true";
193
+ const drifted = forced ? /* @__PURE__ */ new Map() : await findDriftedSnapshots(project, build, snapshots);
194
+ await Promise.all(snapshots.filter((snapshot) => isOpenSnapshot(snapshot) && !drifted.has(snapshot.id)).map(async (snapshot) => {
195
+ await approveSnapshot(snapshot.id, userId, { force: forced });
196
+ }));
197
+ hxRefresh(c);
198
+ return c.json({
199
+ ok: true,
200
+ skipped: [...drifted.keys()]
201
+ });
202
+ });
203
+ app.openapi(rediffRoute, async (c) => {
204
+ const { slug, buildId } = c.req.valid("param");
205
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
206
+ const build = await buildForProject(project.id, buildId);
207
+ if (build.isDefault) throw new HTTPException(400, { message: "Default-branch builds are not re-diffed" });
208
+ if (!REDIFFABLE_BUILD_STATUSES.has(build.status)) throw new HTTPException(409, { message: `Build is ${build.status}; wait for capture to finish or retry capture` });
209
+ const { db, storage, logger } = getStore();
210
+ const result = await rediffBuild({
211
+ db,
212
+ storage,
213
+ logger
214
+ }, project, build);
215
+ await refreshBuild(build.id);
216
+ hxRefresh(c);
217
+ flashHx(c, "Re-diffed against current baselines");
218
+ return c.json({
219
+ ok: true,
220
+ ...result
221
+ }, 200);
222
+ });
223
+ app.openapi(rejectAllRoute, async (c) => {
224
+ const { slug, buildId } = c.req.valid("param");
225
+ const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);
226
+ const build = await buildForProject(project.id, buildId);
227
+ const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);
228
+ const userId = getStore().user?.id ?? null;
229
+ await Promise.all(snapshots.filter((snapshot) => snapshot.status === "new" || snapshot.status === "changed").map(async (snapshot) => {
230
+ await new SnapshotModel(getStore().db).review(snapshot.id, "rejected", userId);
231
+ }));
232
+ await refreshBuild(build.id);
233
+ hxRefresh(c);
234
+ return c.json({ ok: true });
235
+ });
236
+ }
237
+ //#endregion
238
+ export { registerSnapshots };
239
+
240
+ //# sourceMappingURL=snapshots.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"snapshots.mjs","names":[],"sources":["../../src/routers/snapshots.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { REDIFFABLE_BUILD_STATUSES, rediffBuild } from \"@storyshelf/core/capture\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { findDriftedSnapshots, isBaselineChanged, isOpenSnapshot } from \"./baseline-guard.ts\";\nimport {\n VIEW_ROLES,\n APPROVER_ROLES,\n snapshotForBuild,\n refreshBuild,\n approveSnapshot,\n buildForProject,\n} from \"./builds.handlers.ts\";\nimport { flashHx } from \"./flash.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRefresh, isHxRequest } from \"./htmx.ts\";\nimport { hxAdvance } from \"./review-next.ts\";\nimport {\n snapshotSchema,\n okSchema,\n approveAllResultSchema,\n rediffResultSchema,\n baselineChangedSchema,\n errorSchema,\n notFound,\n unauthorized,\n} from \"./schemas.ts\";\nconst listSnapshotsRoute = createRoute({\n method: \"get\",\n tags: [\"Review\"],\n summary: \"List snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: snapshotSchema.array() } },\n description: \"List snapshots for a build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst approveSnapshotRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve a snapshot\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/approve\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string(), snapshotId: z.string() }),\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"Snapshot approved\",\n },\n 409: {\n content: { \"application/json\": { schema: baselineChangedSchema } },\n description:\n \"The baseline changed after this diff was computed; re-diff the build or pass force=true\",\n },\n ...notFound,\n },\n});\n\nconst rejectSnapshotRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject a snapshot\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/snapshots/{snapshotId}/reject\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), snapshotId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"Snapshot rejected\",\n },\n ...notFound,\n },\n});\n\nconst approveAllRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Approve all open snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/approve-all\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n query: z.object({ force: z.enum([\"true\", \"false\"]).optional() }),\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: approveAllResultSchema } },\n description: \"Open snapshots approved; snapshots with a changed baseline are skipped\",\n },\n ...notFound,\n },\n});\n\nconst rediffRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Re-diff against current baselines\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/rediff\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: rediffResultSchema } },\n description:\n \"Undecided snapshots re-diffed against the current baselines (no re-render). Decided snapshots are untouched.\",\n },\n 400: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Default-branch builds are authoritative and are not re-diffed\",\n },\n 409: {\n content: { \"application/json\": { schema: errorSchema } },\n description: \"Build has not finished capturing or failed; retry capture instead\",\n },\n ...notFound,\n },\n});\n\nconst rejectAllRoute = createRoute({\n method: \"post\",\n tags: [\"Review\"],\n summary: \"Reject all open snapshots\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/reject-all\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: okSchema } },\n description: \"All snapshots rejected\",\n },\n ...notFound,\n },\n});\n\n/** Register the snapshot list, approve, reject, and bulk-review endpoints. */\nexport function registerSnapshots(app: ShelfRouter): void {\n app.openapi(listSnapshotsRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = new SnapshotModel(getStore().db).listByBuild(build.id);\n return c.json(await snapshots);\n });\n\n app.openapi(approveSnapshotRoute, async (c) => {\n const { slug, buildId, snapshotId } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n await snapshotForBuild(build, snapshotId);\n const userId = getStore().user?.id ?? null;\n try {\n await approveSnapshot(snapshotId, userId, { force: force === \"true\" });\n } catch (error) {\n if (!isHxRequest(c) || !isBaselineChanged(error)) {\n throw error;\n }\n // The review page recomputes staleness on load and offers re-diff / approve anyway.\n c.header(\"HX-Redirect\", `/projects/${slug}/builds/${buildId}/diff?snapshot=${snapshotId}`);\n return c.json({ ok: true }, 200);\n }\n await hxAdvance(c, { slug, buildId, snapshotId });\n return c.json({ ok: true }, 200);\n });\n\n app.openapi(rejectSnapshotRoute, async (c) => {\n const { slug, buildId, snapshotId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshot = await snapshotForBuild(build, snapshotId);\n const userId = getStore().user?.id ?? null;\n await new SnapshotModel(getStore().db).review(snapshot.id, \"rejected\", userId);\n await refreshBuild(build.id);\n await hxAdvance(c, { slug, buildId, snapshotId });\n return c.json({ ok: true });\n });\n\n app.openapi(approveAllRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const { force } = c.req.valid(\"query\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const userId = getStore().user?.id ?? null;\n const forced = force === \"true\";\n const drifted = forced ? new Map() : await findDriftedSnapshots(project, build, snapshots);\n await Promise.all(\n snapshots\n .filter((snapshot) => isOpenSnapshot(snapshot) && !drifted.has(snapshot.id))\n .map(async (snapshot) => {\n await approveSnapshot(snapshot.id, userId, { force: forced });\n }),\n );\n hxRefresh(c);\n return c.json({ ok: true, skipped: [...drifted.keys()] });\n });\n\n app.openapi(rediffRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n if (build.isDefault) {\n throw new HTTPException(400, { message: \"Default-branch builds are not re-diffed\" });\n }\n if (!REDIFFABLE_BUILD_STATUSES.has(build.status)) {\n throw new HTTPException(409, {\n message: `Build is ${build.status}; wait for capture to finish or retry capture`,\n });\n }\n const { db, storage, logger } = getStore();\n const result = await rediffBuild({ db, storage, logger }, project, build);\n await refreshBuild(build.id);\n hxRefresh(c);\n flashHx(c, \"Re-diffed against current baselines\");\n return c.json({ ok: true, ...result }, 200);\n });\n\n app.openapi(rejectAllRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...APPROVER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const snapshots = await new SnapshotModel(getStore().db).listByBuild(build.id);\n const userId = getStore().user?.id ?? null;\n await Promise.all(\n snapshots\n .filter((snapshot) => snapshot.status === \"new\" || snapshot.status === \"changed\")\n .map(async (snapshot) => {\n await new SnapshotModel(getStore().db).review(snapshot.id, \"rejected\", userId);\n }),\n );\n await refreshBuild(build.id);\n hxRefresh(c);\n return c.json({ ok: true });\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,108 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "../credential-keys.mjs";
5
+ import { getStore } from "../store.mjs";
6
+ import { resolveAuthorizedProject } from "./helpers.mjs";
7
+ import { notFound, statusConfigCreateSchema, statusConfigSchema, unauthorized } from "./schemas.mjs";
8
+ import { createRoute, z } from "@hono/zod-openapi";
9
+ import { StatusConfigModel } from "@storyshelf/core/models";
10
+ //#region src/routers/status-configs.ts
11
+ const ADMIN_ROLES = ["admin"];
12
+ const listRoute = createRoute({
13
+ method: "get",
14
+ tags: ["Git status"],
15
+ summary: "List status configs",
16
+ path: "/api/v1/projects/{slug}/status-configs",
17
+ request: { params: z.object({ slug: z.string() }) },
18
+ responses: {
19
+ 200: {
20
+ content: { "application/json": { schema: statusConfigSchema.array() } },
21
+ description: "List status configs"
22
+ },
23
+ ...notFound,
24
+ ...unauthorized
25
+ }
26
+ });
27
+ const createRouteDef = createRoute({
28
+ method: "post",
29
+ tags: ["Git status"],
30
+ summary: "Create a status config",
31
+ path: "/api/v1/projects/{slug}/status-configs",
32
+ request: {
33
+ params: z.object({ slug: z.string() }),
34
+ body: { content: { "application/json": { schema: statusConfigCreateSchema } } }
35
+ },
36
+ responses: {
37
+ 201: {
38
+ content: { "application/json": { schema: statusConfigSchema } },
39
+ description: "Created status config"
40
+ },
41
+ 400: {
42
+ content: { "application/json": { schema: z.object({ message: z.string() }) } },
43
+ description: "Bad request"
44
+ },
45
+ ...notFound
46
+ }
47
+ });
48
+ const deleteRoute = createRoute({
49
+ method: "delete",
50
+ tags: ["Git status"],
51
+ summary: "Delete a status config",
52
+ path: "/api/v1/projects/{slug}/status-configs/{id}",
53
+ request: { params: z.object({
54
+ slug: z.string(),
55
+ id: z.string()
56
+ }) },
57
+ responses: {
58
+ 204: { description: "Deleted status config" },
59
+ ...notFound
60
+ }
61
+ });
62
+ function toPublic(row) {
63
+ return {
64
+ id: row.id,
65
+ projectId: row.projectId,
66
+ provider: row.provider,
67
+ config: JSON.parse(row.config),
68
+ hasToken: true,
69
+ createdAt: row.createdAt,
70
+ updatedAt: row.updatedAt
71
+ };
72
+ }
73
+ /** Register the git status-config list, create, and delete endpoints. */
74
+ function registerStatusConfigs(app) {
75
+ app.openapi(listRoute, async (c) => {
76
+ const { slug } = c.req.valid("param");
77
+ const project = await resolveAuthorizedProject(c, slug, "admin");
78
+ const rows = await new StatusConfigModel(getStore().db, void 0, credentialKeys(getStore().config)).list(project.id);
79
+ return c.json(rows.map((row) => toPublic(row)));
80
+ });
81
+ app.openapi(createRouteDef, async (c) => {
82
+ const { slug } = c.req.valid("param");
83
+ const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
84
+ const body = c.req.valid("json");
85
+ const provider = getStore().gitHosts.find((p) => p.metadata.kind === body.provider);
86
+ if (!provider) return c.json({ message: `Unknown provider: ${body.provider}` }, 400);
87
+ const parsed = provider.metadata.schema.safeParse(body.config);
88
+ if (!parsed.success) return c.json({ message: parsed.error.message }, 400);
89
+ const row = await new StatusConfigModel(getStore().db, void 0, credentialKeys(getStore().config)).create(project.id, {
90
+ provider: body.provider,
91
+ config: parsed.data,
92
+ token: body.token
93
+ });
94
+ return c.json(toPublic(row), 201);
95
+ });
96
+ app.openapi(deleteRoute, async (c) => {
97
+ const { slug, id } = c.req.valid("param");
98
+ const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
99
+ const model = new StatusConfigModel(getStore().db, void 0, credentialKeys(getStore().config));
100
+ if (!await model.get(project.id, id)) return c.json({ message: "Status config not found" }, 404);
101
+ await model.remove(project.id, id);
102
+ return c.body(null, 204);
103
+ });
104
+ }
105
+ //#endregion
106
+ export { registerStatusConfigs };
107
+
108
+ //# sourceMappingURL=status-configs.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status-configs.mjs","names":[],"sources":["../../src/routers/status-configs.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { StatusConfigModel } from \"@storyshelf/core/models\";\nimport type { ProjectStatusConfig } from \"@storyshelf/core/schema\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { notFound, statusConfigCreateSchema, statusConfigSchema, unauthorized } from \"./schemas.ts\";\nconst ADMIN_ROLES = [\"admin\"] as const;\n\nconst listRoute = createRoute({\n method: \"get\",\n tags: [\"Git status\"],\n summary: \"List status configs\",\n path: \"/api/v1/projects/{slug}/status-configs\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: statusConfigSchema.array() } },\n description: \"List status configs\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createRouteDef = createRoute({\n method: \"post\",\n tags: [\"Git status\"],\n summary: \"Create a status config\",\n path: \"/api/v1/projects/{slug}/status-configs\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: statusConfigCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: statusConfigSchema } },\n description: \"Created status config\",\n },\n 400: {\n content: { \"application/json\": { schema: z.object({ message: z.string() }) } },\n description: \"Bad request\",\n },\n ...notFound,\n },\n});\n\nconst deleteRoute = createRoute({\n method: \"delete\",\n tags: [\"Git status\"],\n summary: \"Delete a status config\",\n path: \"/api/v1/projects/{slug}/status-configs/{id}\",\n request: { params: z.object({ slug: z.string(), id: z.string() }) },\n responses: {\n 204: { description: \"Deleted status config\" },\n ...notFound,\n },\n});\n\nfunction toPublic(row: ProjectStatusConfig): {\n id: string;\n projectId: string;\n provider: string;\n config: Record<string, unknown>;\n hasToken: boolean;\n createdAt: string;\n updatedAt: string;\n} {\n return {\n id: row.id,\n projectId: row.projectId,\n provider: row.provider,\n config: JSON.parse(row.config) as Record<string, unknown>,\n hasToken: true,\n createdAt: row.createdAt,\n updatedAt: row.updatedAt,\n };\n}\n\n/** Register the git status-config list, create, and delete endpoints. */\nexport function registerStatusConfigs(app: ShelfRouter): void {\n app.openapi(listRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, \"admin\");\n const model = new StatusConfigModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const rows = await model.list(project.id);\n return c.json(rows.map((row) => toPublic(row)));\n });\n\n app.openapi(createRouteDef, 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 const providers = getStore().gitHosts;\n const provider = providers.find((p) => p.metadata.kind === body.provider);\n if (!provider) {\n return c.json({ message: `Unknown provider: ${body.provider}` }, 400);\n }\n const parsed = provider.metadata.schema.safeParse(body.config);\n if (!parsed.success) {\n return c.json({ message: parsed.error.message }, 400);\n }\n const model = new StatusConfigModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const row = await model.create(project.id, {\n provider: body.provider,\n config: parsed.data,\n token: body.token,\n });\n return c.json(toPublic(row), 201);\n });\n\n app.openapi(deleteRoute, async (c) => {\n const { slug, id } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const model = new StatusConfigModel(\n getStore().db,\n undefined,\n credentialKeys(getStore().config),\n );\n const existing = await model.get(project.id, id);\n if (!existing) {\n return c.json({ message: \"Status config not found\" }, 404);\n }\n await model.remove(project.id, id);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,171 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { currentProjectRole, notFound } from "./helpers.mjs";
6
+ import { renderStorybookPage, renderStorybookPreparingPage } from "../pages/storybook.mjs";
7
+ import { BuildModel, LabelModel, ProjectModel, isPublicBuild } from "@storyshelf/core/models";
8
+ import { isSafeSegment, mimeFor, storybookDir } from "@storyshelf/core/utils";
9
+ import { posix } from "node:path";
10
+ //#region src/routers/storybook.ts
11
+ const VIEW_ROLES = /* @__PURE__ */ new Set([
12
+ "viewer",
13
+ "developer",
14
+ "approver",
15
+ "admin"
16
+ ]);
17
+ function contentTypeFor(path) {
18
+ return mimeFor(path);
19
+ }
20
+ /**
21
+ * Decide whether the current request may view `build`'s published Storybook.
22
+ *
23
+ * Public builds (ADR 0011) are viewable without auth; every other build requires
24
+ * a logged-in session with at least `viewer` membership on the project.
25
+ */
26
+ async function canViewBuild(build, project) {
27
+ if (isPublicBuild(project, build)) return true;
28
+ const { authEnabled } = getStore();
29
+ if (!authEnabled) return true;
30
+ const role = await currentProjectRole(project.id);
31
+ return Boolean(role && VIEW_ROLES.has(role));
32
+ }
33
+ /** Check whether a build's extracted statics are available for serving. */
34
+ async function staticsReady(projectId, buildId) {
35
+ if (await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), "manifest.json"))) return true;
36
+ return await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), "iframe.html"));
37
+ }
38
+ async function readWithManifestFallback(projectId, buildId, rel) {
39
+ try {
40
+ const manifestPath = posix.join(storybookDir(projectId, buildId), "manifest.json");
41
+ if (await getStore().storage.exists(manifestPath)) {
42
+ const manifestRaw = await getStore().storage.read(manifestPath);
43
+ const hash = JSON.parse(manifestRaw.toString("utf8"))[rel];
44
+ if (hash) {
45
+ const contentPath = `content/${hash}`;
46
+ if (await getStore().storage.exists(contentPath)) return await getStore().storage.read(contentPath);
47
+ }
48
+ }
49
+ } catch {}
50
+ const legacyPath = posix.join(storybookDir(projectId, buildId), rel);
51
+ if (await getStore().storage.exists(legacyPath)) return await getStore().storage.read(legacyPath);
52
+ return null;
53
+ }
54
+ /** Register the published Storybook resolver and static-asset routes. */
55
+ function registerStorybook(app) {
56
+ app.get("/_", (c) => c.redirect("/", 302));
57
+ app.get("/_/", (c) => c.redirect("/", 302));
58
+ app.get("/projects/:slug/storybook", async (c) => {
59
+ const slug = c.req.param("slug");
60
+ const project = await new ProjectModel(getStore().db).getBySlug(slug);
61
+ if (!project) notFound("Project not found");
62
+ const build = await new BuildModel(getStore().db).latestApprovedDefault(project);
63
+ if (!build) notFound("No approved build on the default branch yet");
64
+ if (!await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
65
+ return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);
66
+ });
67
+ app.get("/projects/:slug/storybook/:key/:value", async (c) => {
68
+ const slug = c.req.param("slug");
69
+ const key = c.req.param("key");
70
+ if (key === "build") return c.notFound();
71
+ const value = c.req.param("value");
72
+ const project = await new ProjectModel(getStore().db).getBySlug(slug);
73
+ if (!project) notFound("Project not found");
74
+ const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);
75
+ if (!buildId) notFound("No build carries that label");
76
+ const build = await new BuildModel(getStore().db).get(buildId);
77
+ if (!build || !await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
78
+ return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);
79
+ });
80
+ app.get("/_/:id", async (c) => {
81
+ const id = c.req.param("id");
82
+ if (id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id)) {
83
+ if (!await new BuildModel(getStore().db).get(id)) notFound();
84
+ } else if (!await new ProjectModel(getStore().db).getBySlug(id)) notFound();
85
+ return c.redirect(`/_/${id}/`, 302);
86
+ });
87
+ app.get("/_/:id/*", async (c) => {
88
+ const id = c.req.param("id");
89
+ const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);
90
+ const base = `/_/${id}/`;
91
+ if (!c.req.path.startsWith(base)) return c.notFound();
92
+ let rest;
93
+ try {
94
+ rest = decodeURIComponent(c.req.path.slice(base.length));
95
+ } catch {
96
+ return c.notFound();
97
+ }
98
+ let project = null;
99
+ let build = null;
100
+ if (isUlid26) {
101
+ build = await new BuildModel(getStore().db).get(id);
102
+ if (!build) return c.notFound();
103
+ project = await new ProjectModel(getStore().db).get(build.projectId);
104
+ if (!project) return c.notFound();
105
+ } else {
106
+ project = await new ProjectModel(getStore().db).getBySlug(id);
107
+ if (!project) return c.notFound();
108
+ build = await new BuildModel(getStore().db).latestApprovedDefault(project);
109
+ if (!build) return c.notFound();
110
+ }
111
+ if (!await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
112
+ if (rest === "") {
113
+ if (!await staticsReady(project.id, build.id)) return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);
114
+ const buffer = await readWithManifestFallback(project.id, build.id, "index.html");
115
+ if (!buffer) return c.redirect(isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`, 302);
116
+ const cacheControl = isUlid26 ? "public, max-age=31536000, immutable" : "public, max-age=60";
117
+ return c.body(new Uint8Array(buffer), 200, {
118
+ "content-type": "text/html; charset=utf-8",
119
+ "cache-control": cacheControl
120
+ });
121
+ }
122
+ if (rest.split("/").some((segment) => !isSafeSegment(segment))) return c.notFound();
123
+ const buffer = await readWithManifestFallback(project.id, build.id, rest);
124
+ if (!buffer) {
125
+ if (rest === "index.html") return c.redirect(isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`, 302);
126
+ return c.notFound();
127
+ }
128
+ const cacheControl = isUlid26 ? "public, max-age=31536000, immutable" : "public, max-age=3600";
129
+ return c.body(new Uint8Array(buffer), 200, {
130
+ "content-type": contentTypeFor(rest),
131
+ "cache-control": cacheControl
132
+ });
133
+ });
134
+ app.get("/projects/:slug/storybook/build/:buildId/*", async (c) => {
135
+ const slug = c.req.param("slug");
136
+ const buildId = c.req.param("buildId");
137
+ const base = `/projects/${slug}/storybook/build/${buildId}/`;
138
+ if (!c.req.path.startsWith(base)) return c.notFound();
139
+ let rest;
140
+ try {
141
+ rest = decodeURIComponent(c.req.path.slice(base.length));
142
+ } catch {
143
+ return c.notFound();
144
+ }
145
+ const project = await new ProjectModel(getStore().db).getBySlug(slug);
146
+ if (!project) notFound("Project not found");
147
+ const build = await new BuildModel(getStore().db).get(buildId);
148
+ if (!build || build.projectId !== project.id) notFound("Build not found");
149
+ if (!await canViewBuild(build, project)) return c.redirect("/auth/login", 302);
150
+ if (rest === "") {
151
+ if (!await staticsReady(project.id, build.id)) return c.html(renderStorybookPreparingPage(project, build, slug), 200);
152
+ const storyId = c.req.query("storyId") ?? c.req.query("id") ?? void 0;
153
+ const viewMode = c.req.query("viewMode") === "docs" ? "docs" : void 0;
154
+ return c.html(renderStorybookPage(project, build, slug, {
155
+ storyId,
156
+ viewMode
157
+ }), 200);
158
+ }
159
+ if (rest.split("/").some((segment) => !isSafeSegment(segment))) return c.notFound();
160
+ const buffer = await readWithManifestFallback(project.id, build.id, rest);
161
+ if (!buffer) return c.notFound();
162
+ return c.body(new Uint8Array(buffer), 200, {
163
+ "content-type": contentTypeFor(rest),
164
+ "cache-control": "public, max-age=31536000, immutable"
165
+ });
166
+ });
167
+ }
168
+ //#endregion
169
+ export { registerStorybook };
170
+
171
+ //# sourceMappingURL=storybook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/routers/storybook.ts"],"sourcesContent":["import { isPublicBuild, BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { isSafeSegment, mimeFor, storybookDir } from \"@storyshelf/core/utils\";\nimport { posix } from \"node:path\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderStorybookPage, renderStorybookPreparingPage } from \"../pages/storybook.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { currentProjectRole, notFound } from \"./helpers.ts\";\nconst VIEW_ROLES: ReadonlySet<string> = new Set([\"viewer\", \"developer\", \"approver\", \"admin\"]);\n\nfunction contentTypeFor(path: string): string {\n return mimeFor(path);\n}\n\n/**\n * Decide whether the current request may view `build`'s published Storybook.\n *\n * Public builds (ADR 0011) are viewable without auth; every other build requires\n * a logged-in session with at least `viewer` membership on the project.\n */\nasync function canViewBuild(\n build: Pick<Build, \"public\" | \"gitBranch\">,\n project: Project,\n): Promise<boolean> {\n if (isPublicBuild(project, build)) {\n return true;\n }\n const { authEnabled } = getStore();\n if (!authEnabled) {\n return true;\n }\n const role = await currentProjectRole(project.id);\n return Boolean(role && VIEW_ROLES.has(role));\n}\n\n/** Check whether a build's extracted statics are available for serving. */\nasync function staticsReady(projectId: string, buildId: string): Promise<boolean> {\n // New path: manifest.json indicates content-addressed storage; fallback to legacy iframe.html\n if (\n await getStore().storage.exists(posix.join(storybookDir(projectId, buildId), \"manifest.json\"))\n ) {\n return true;\n }\n return await getStore().storage.exists(\n posix.join(storybookDir(projectId, buildId), \"iframe.html\"),\n );\n}\n\nasync function readWithManifestFallback(\n projectId: string,\n buildId: string,\n rel: string,\n): Promise<Buffer | null> {\n // Try manifest indirection first (content-addressed)\n try {\n const manifestPath = posix.join(storybookDir(projectId, buildId), \"manifest.json\");\n if (await getStore().storage.exists(manifestPath)) {\n const manifestRaw = await getStore().storage.read(manifestPath);\n const manifest = JSON.parse(manifestRaw.toString(\"utf8\")) as Record<string, string>;\n const hash = manifest[rel];\n if (hash) {\n const contentPath = `content/${hash}`;\n if (await getStore().storage.exists(contentPath)) {\n return await getStore().storage.read(contentPath);\n }\n }\n }\n } catch {\n // ignore — manifest may not exist for old builds\n }\n // Fallback to legacy per-build path\n const legacyPath = posix.join(storybookDir(projectId, buildId), rel);\n if (await getStore().storage.exists(legacyPath)) {\n return await getStore().storage.read(legacyPath);\n }\n return null;\n}\n\n/** Register the published Storybook resolver and static-asset routes. */\nexport function registerStorybook(app: ShelfRouter): void {\n app.get(\"/_\", (c) => c.redirect(\"/\", 302));\n app.get(\"/_/\", (c) => c.redirect(\"/\", 302));\n\n // Resolver: latest approved build on the default branch (never feature branches).\n app.get(\"/projects/:slug/storybook\", async (c) => {\n const slug = c.req.param(\"slug\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) {\n notFound(\"No approved build on the default branch yet\");\n }\n if (!(await canViewBuild(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${build.id}/`, 302);\n });\n\n // Resolver: latest build bearing a label (`build` is a reserved label key).\n app.get(\"/projects/:slug/storybook/:key/:value\", async (c) => {\n const slug = c.req.param(\"slug\");\n const key = c.req.param(\"key\");\n if (key === \"build\") {\n return c.notFound();\n }\n const value = c.req.param(\"value\");\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const buildId = await new LabelModel(getStore().db).latestBuildId(project.id, key, value);\n if (!buildId) {\n notFound(\"No build carries that label\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || !(await canViewBuild(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n return c.redirect(`/projects/${slug}/storybook/build/${buildId}/`, 302);\n });\n\n // Short link canonical is /_/:id/ with trailing slash (so relative ./sb-manager/... resolves).\n // 26-char ULID → that exact build, whatever its status (visibility check only).\n // Any other id → project slug → latest approved build on the default branch, else 404\n // (no fallback to feature-branch or unreviewed builds). No fall-through on ULID.\n app.get(\"/_/:id\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n if (isUlid26) {\n const build = await new BuildModel(getStore().db).get(id);\n if (!build) notFound();\n } else {\n const project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) notFound();\n }\n return c.redirect(`/_/${id}/`, 302);\n });\n\n app.get(\"/_/:id/*\", async (c) => {\n const id = c.req.param(\"id\");\n const isUlid26 = id.length === 26 && /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(id);\n const base = `/_/${id}/`;\n if (!c.req.path.startsWith(base)) return c.notFound();\n let rest: string;\n try {\n rest = decodeURIComponent(c.req.path.slice(base.length));\n } catch {\n return c.notFound();\n }\n let project: Project | null = null;\n let build: Build | null = null;\n if (isUlid26) {\n build = await new BuildModel(getStore().db).get(id);\n if (!build) return c.notFound();\n project = await new ProjectModel(getStore().db).get(build.projectId);\n if (!project) return c.notFound();\n } else {\n project = await new ProjectModel(getStore().db).getBySlug(id);\n if (!project) return c.notFound();\n build = await new BuildModel(getStore().db).latestApprovedDefault(project);\n if (!build) return c.notFound();\n }\n if (!(await canViewBuild(build, project))) return c.redirect(\"/auth/login\", 302);\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, project.slug), 200);\n }\n const buffer = await readWithManifestFallback(project.id, build.id, \"index.html\");\n if (!buffer) {\n return c.redirect(\n isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`,\n 302,\n );\n }\n const cacheControl = isUlid26 ? \"public, max-age=31536000, immutable\" : \"public, max-age=60\";\n return c.body(new Uint8Array(buffer), 200, {\n \"content-type\": \"text/html; charset=utf-8\",\n \"cache-control\": cacheControl,\n });\n }\n const segments = rest.split(\"/\");\n if (segments.some((segment) => !isSafeSegment(segment))) return c.notFound();\n const buffer = await readWithManifestFallback(project.id, build.id, rest);\n if (!buffer) {\n if (rest === \"index.html\") {\n return c.redirect(\n isUlid26 ? `/projects/${project.slug}/builds/${build.id}` : `/projects/${project.slug}`,\n 302,\n );\n }\n return c.notFound();\n }\n const cacheControl = isUlid26 ? \"public, max-age=31536000, immutable\" : \"public, max-age=3600\";\n return c.body(new Uint8Array(buffer), 200, {\n \"content-type\": contentTypeFor(rest),\n \"cache-control\": cacheControl,\n });\n });\n\n // Canonical build route: serves the static Storybook and the landing page.\n app.get(\"/projects/:slug/storybook/build/:buildId/*\", async (c) => {\n const slug = c.req.param(\"slug\");\n const buildId = c.req.param(\"buildId\");\n // Hono's bare `/*` wildcard is not exposed through `param()`, so derive the\n // remainder from the raw request path (un-encoded) and decode it ourselves.\n const base = `/projects/${slug}/storybook/build/${buildId}/`;\n if (!c.req.path.startsWith(base)) {\n return c.notFound();\n }\n let rest: string;\n try {\n rest = decodeURIComponent(c.req.path.slice(base.length));\n } catch {\n return c.notFound();\n }\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n notFound(\"Project not found\");\n }\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== project.id) {\n notFound(\"Build not found\");\n }\n if (!(await canViewBuild(build, project))) {\n return c.redirect(\"/auth/login\", 302);\n }\n\n if (rest === \"\") {\n if (!(await staticsReady(project.id, build.id))) {\n return c.html(renderStorybookPreparingPage(project, build, slug), 200);\n }\n const storyId = c.req.query(\"storyId\") ?? c.req.query(\"id\") ?? undefined;\n const viewMode = c.req.query(\"viewMode\") === \"docs\" ? \"docs\" : undefined;\n return c.html(renderStorybookPage(project, build, slug, { storyId, viewMode }), 200);\n }\n\n const segments = rest.split(\"/\");\n if (segments.some((segment) => !isSafeSegment(segment))) {\n return c.notFound();\n }\n const buffer = await readWithManifestFallback(project.id, build.id, rest);\n if (!buffer) return c.notFound();\n return c.body(new Uint8Array(buffer), 200, {\n \"content-type\": contentTypeFor(rest),\n \"cache-control\": \"public, max-age=31536000, immutable\",\n });\n });\n}\n"],"mappings":"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"}