@storyshelf/core 0.1.2 → 0.3.1

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 (275) hide show
  1. package/README.md +50 -46
  2. package/dist/adapter/auth.d.mts +4 -4
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/{adapters → adapter}/capture-queue.d.mts +6 -4
  5. package/dist/adapter/capture-queue.mjs +4 -0
  6. package/dist/adapter/capture-runner.d.mts +7 -7
  7. package/dist/adapter/database.d.mts +20 -9
  8. package/dist/adapter/database.mjs +60 -1
  9. package/dist/adapter/database.mjs.map +1 -0
  10. package/dist/adapter/git-host/comments.d.mts +41 -0
  11. package/dist/adapter/git-host/comments.mjs +54 -0
  12. package/dist/adapter/git-host/comments.mjs.map +1 -0
  13. package/dist/adapter/git-host/helpers.d.mts +1 -0
  14. package/dist/adapter/git-host/helpers.mjs +1 -0
  15. package/dist/adapter/git-host/helpers.mjs.map +1 -1
  16. package/dist/adapter/git-host.d.mts +3 -7
  17. package/dist/adapter/init.d.mts +58 -0
  18. package/dist/adapter/init.mjs +93 -0
  19. package/dist/adapter/init.mjs.map +1 -0
  20. package/dist/adapter/metadata.d.mts +83 -0
  21. package/dist/adapter/metadata.mjs +4 -0
  22. package/dist/adapter/storage.d.mts +15 -4
  23. package/dist/adapters/webhook-events.mjs +20 -4
  24. package/dist/adapters/webhook-events.mjs.map +1 -1
  25. package/dist/capture/adapter.d.mts +7 -1
  26. package/dist/capture/adapter.mjs +3 -0
  27. package/dist/capture/adapter.mjs.map +1 -1
  28. package/dist/capture/dispatch.mjs +150 -0
  29. package/dist/capture/dispatch.mjs.map +1 -0
  30. package/dist/capture/orchestrator.d.mts +9 -0
  31. package/dist/capture/orchestrator.mjs +11 -44
  32. package/dist/capture/orchestrator.mjs.map +1 -1
  33. package/dist/capture/pipeline.d.mts +4 -1
  34. package/dist/capture/pipeline.mjs +6 -6
  35. package/dist/capture/pipeline.mjs.map +1 -1
  36. package/dist/capture/queue.d.mts +4 -1
  37. package/dist/capture/queue.mjs +3 -1
  38. package/dist/capture/queue.mjs.map +1 -1
  39. package/dist/capture/skip-checks.mjs +76 -0
  40. package/dist/capture/skip-checks.mjs.map +1 -0
  41. package/dist/capture/statics.mjs +124 -0
  42. package/dist/capture/statics.mjs.map +1 -0
  43. package/dist/capture/status-fanout.mjs +1 -0
  44. package/dist/capture/status-fanout.mjs.map +1 -1
  45. package/dist/capture/storybook.d.mts +1 -0
  46. package/dist/capture/storybook.mjs +1 -0
  47. package/dist/capture/storybook.mjs.map +1 -1
  48. package/dist/capture.d.mts +8 -0
  49. package/dist/capture.mjs +9 -0
  50. package/dist/config.d.mts +37 -22
  51. package/dist/config.mjs +28 -2
  52. package/dist/config.mjs.map +1 -1
  53. package/dist/ddl.d.mts +2 -1
  54. package/dist/ddl.mjs +2 -1
  55. package/dist/ddl.mjs.map +1 -1
  56. package/dist/diff/engine.d.mts +8 -0
  57. package/dist/diff/engine.mjs +8 -0
  58. package/dist/diff/engine.mjs.map +1 -1
  59. package/dist/diff/options.d.mts +3 -1
  60. package/dist/diff.d.mts +5 -0
  61. package/dist/diff.mjs +6 -0
  62. package/dist/index.d.mts +34 -24
  63. package/dist/index.mjs +211 -252
  64. package/dist/index.mjs.map +1 -1
  65. package/dist/logger.d.mts +3 -3
  66. package/dist/logger.mjs.map +1 -1
  67. package/dist/middleware/auth-gate.mjs +36 -0
  68. package/dist/middleware/auth-gate.mjs.map +1 -0
  69. package/dist/middleware/csrf.mjs +1 -0
  70. package/dist/middleware/csrf.mjs.map +1 -1
  71. package/dist/middleware/rate-limit.mjs +1 -0
  72. package/dist/middleware/rate-limit.mjs.map +1 -1
  73. package/dist/middleware/request-log.mjs +28 -0
  74. package/dist/middleware/request-log.mjs.map +1 -0
  75. package/dist/middleware/store-scope.mjs +29 -0
  76. package/dist/middleware/store-scope.mjs.map +1 -0
  77. package/dist/models/baseline.mjs +9 -5
  78. package/dist/models/baseline.mjs.map +1 -1
  79. package/dist/models/build.mjs +5 -1
  80. package/dist/models/build.mjs.map +1 -1
  81. package/dist/models/comment.mjs +4 -1
  82. package/dist/models/comment.mjs.map +1 -1
  83. package/dist/models/label.mjs +3 -1
  84. package/dist/models/label.mjs.map +1 -1
  85. package/dist/models/member.mjs +2 -1
  86. package/dist/models/member.mjs.map +1 -1
  87. package/dist/models/project.mjs +2 -1
  88. package/dist/models/project.mjs.map +1 -1
  89. package/dist/models/snapshot.mjs +2 -1
  90. package/dist/models/snapshot.mjs.map +1 -1
  91. package/dist/models/status-config.mjs +2 -1
  92. package/dist/models/status-config.mjs.map +1 -1
  93. package/dist/models/token.mjs +2 -1
  94. package/dist/models/token.mjs.map +1 -1
  95. package/dist/models/webhook.mjs +12 -3
  96. package/dist/models/webhook.mjs.map +1 -1
  97. package/dist/pages/build-detail.mjs +10 -4
  98. package/dist/pages/build-detail.mjs.map +1 -1
  99. package/dist/pages/build-diff-comments.mjs +113 -0
  100. package/dist/pages/build-diff-comments.mjs.map +1 -0
  101. package/dist/pages/build-diff-header.mjs +163 -0
  102. package/dist/pages/build-diff-header.mjs.map +1 -0
  103. package/dist/pages/build-diff-nav.mjs +66 -0
  104. package/dist/pages/build-diff-nav.mjs.map +1 -0
  105. package/dist/pages/build-diff-viewer.mjs +197 -0
  106. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  107. package/dist/pages/build-diff.mjs +66 -413
  108. package/dist/pages/build-diff.mjs.map +1 -1
  109. package/dist/pages/compute-jobs.mjs +3 -1
  110. package/dist/pages/compute-jobs.mjs.map +1 -1
  111. package/dist/pages/label-detail.mjs +4 -3
  112. package/dist/pages/label-detail.mjs.map +1 -1
  113. package/dist/pages/login.mjs +1 -0
  114. package/dist/pages/login.mjs.map +1 -1
  115. package/dist/pages/project-builds.mjs +6 -3
  116. package/dist/pages/project-builds.mjs.map +1 -1
  117. package/dist/pages/project-create.mjs +1 -0
  118. package/dist/pages/project-create.mjs.map +1 -1
  119. package/dist/pages/project-settings.mjs +1 -0
  120. package/dist/pages/project-settings.mjs.map +1 -1
  121. package/dist/pages/projects.mjs +12 -6
  122. package/dist/pages/projects.mjs.map +1 -1
  123. package/dist/pages/root.mjs +7 -3
  124. package/dist/pages/root.mjs.map +1 -1
  125. package/dist/pages/settings-general.mjs +1 -0
  126. package/dist/pages/settings-general.mjs.map +1 -1
  127. package/dist/pages/settings-labels.mjs +2 -1
  128. package/dist/pages/settings-labels.mjs.map +1 -1
  129. package/dist/pages/settings-members.mjs +2 -1
  130. package/dist/pages/settings-members.mjs.map +1 -1
  131. package/dist/pages/settings-status.mjs +3 -1
  132. package/dist/pages/settings-status.mjs.map +1 -1
  133. package/dist/pages/settings-tests.mjs +4 -3
  134. package/dist/pages/settings-tests.mjs.map +1 -1
  135. package/dist/pages/settings-tokens.mjs +1 -0
  136. package/dist/pages/settings-tokens.mjs.map +1 -1
  137. package/dist/pages/settings-webhooks.mjs +1 -0
  138. package/dist/pages/settings-webhooks.mjs.map +1 -1
  139. package/dist/pages/storybook.mjs +38 -1
  140. package/dist/pages/storybook.mjs.map +1 -1
  141. package/dist/{utils/paths.d.mts → paths.d.mts} +5 -0
  142. package/dist/{utils/paths.mjs → paths.mjs} +5 -0
  143. package/dist/paths.mjs.map +1 -0
  144. package/dist/retention/purge.mjs +2 -1
  145. package/dist/retention/purge.mjs.map +1 -1
  146. package/dist/routers/admin.mjs +2 -1
  147. package/dist/routers/admin.mjs.map +1 -1
  148. package/dist/routers/assets.mjs +1 -0
  149. package/dist/routers/assets.mjs.map +1 -1
  150. package/dist/routers/auth.mjs +1 -0
  151. package/dist/routers/auth.mjs.map +1 -1
  152. package/dist/routers/builds.handlers.mjs +114 -19
  153. package/dist/routers/builds.handlers.mjs.map +1 -1
  154. package/dist/routers/builds.mjs +55 -34
  155. package/dist/routers/builds.mjs.map +1 -1
  156. package/dist/routers/comments.mjs +1 -0
  157. package/dist/routers/comments.mjs.map +1 -1
  158. package/dist/routers/health.mjs +137 -0
  159. package/dist/routers/health.mjs.map +1 -0
  160. package/dist/routers/helpers.mjs +8 -2
  161. package/dist/routers/helpers.mjs.map +1 -1
  162. package/dist/routers/htmx.mjs +2 -0
  163. package/dist/routers/htmx.mjs.map +1 -1
  164. package/dist/routers/labels.mjs +2 -1
  165. package/dist/routers/labels.mjs.map +1 -1
  166. package/dist/routers/media.mjs +3 -2
  167. package/dist/routers/media.mjs.map +1 -1
  168. package/dist/routers/members.mjs +1 -0
  169. package/dist/routers/members.mjs.map +1 -1
  170. package/dist/routers/projects.mjs +2 -1
  171. package/dist/routers/projects.mjs.map +1 -1
  172. package/dist/routers/schemas.mjs +25 -0
  173. package/dist/routers/schemas.mjs.map +1 -1
  174. package/dist/routers/settings-general.mjs +95 -0
  175. package/dist/routers/settings-general.mjs.map +1 -0
  176. package/dist/routers/settings-labels.mjs +49 -0
  177. package/dist/routers/settings-labels.mjs.map +1 -0
  178. package/dist/routers/settings-members.mjs +30 -0
  179. package/dist/routers/settings-members.mjs.map +1 -0
  180. package/dist/routers/settings-status.mjs +73 -0
  181. package/dist/routers/settings-status.mjs.map +1 -0
  182. package/dist/routers/settings-tokens.mjs +40 -0
  183. package/dist/routers/settings-tokens.mjs.map +1 -0
  184. package/dist/routers/settings-webhooks.mjs +63 -0
  185. package/dist/routers/settings-webhooks.mjs.map +1 -0
  186. package/dist/routers/settings.handlers.mjs +93 -0
  187. package/dist/routers/settings.handlers.mjs.map +1 -0
  188. package/dist/routers/settings.mjs +14 -246
  189. package/dist/routers/settings.mjs.map +1 -1
  190. package/dist/routers/snapshots.mjs +2 -1
  191. package/dist/routers/snapshots.mjs.map +1 -1
  192. package/dist/routers/status-configs.mjs +2 -1
  193. package/dist/routers/status-configs.mjs.map +1 -1
  194. package/dist/routers/storybook.mjs +12 -4
  195. package/dist/routers/storybook.mjs.map +1 -1
  196. package/dist/routers/tokens.mjs +2 -1
  197. package/dist/routers/tokens.mjs.map +1 -1
  198. package/dist/routers/ui.mjs +5 -4
  199. package/dist/routers/ui.mjs.map +1 -1
  200. package/dist/routers/webhooks.mjs +7 -6
  201. package/dist/routers/webhooks.mjs.map +1 -1
  202. package/dist/schema/baseline.d.mts +18 -0
  203. package/dist/schema/baseline.mjs +22 -0
  204. package/dist/schema/baseline.mjs.map +1 -0
  205. package/dist/schema/build.d.mts +26 -0
  206. package/dist/schema/build.mjs +29 -0
  207. package/dist/schema/build.mjs.map +1 -0
  208. package/dist/schema/comment.d.mts +19 -0
  209. package/dist/schema/comment.mjs +26 -0
  210. package/dist/schema/comment.mjs.map +1 -0
  211. package/dist/schema/label.d.mts +25 -0
  212. package/dist/schema/label.mjs +30 -0
  213. package/dist/schema/label.mjs.map +1 -0
  214. package/dist/schema/member.d.mts +15 -0
  215. package/dist/schema/member.mjs +19 -0
  216. package/dist/schema/member.mjs.map +1 -0
  217. package/dist/schema/project.d.mts +34 -0
  218. package/dist/schema/project.mjs +25 -0
  219. package/dist/schema/project.mjs.map +1 -0
  220. package/dist/schema/snapshot.d.mts +30 -0
  221. package/dist/schema/snapshot.mjs +34 -0
  222. package/dist/schema/snapshot.mjs.map +1 -0
  223. package/dist/schema/status-config.d.mts +16 -0
  224. package/dist/schema/status-config.mjs +20 -0
  225. package/dist/schema/status-config.mjs.map +1 -0
  226. package/dist/schema/token.d.mts +15 -0
  227. package/dist/schema/token.mjs +19 -0
  228. package/dist/schema/token.mjs.map +1 -0
  229. package/dist/schema/user.d.mts +17 -0
  230. package/dist/schema/user.mjs +19 -0
  231. package/dist/schema/user.mjs.map +1 -0
  232. package/dist/schema/webhook.d.mts +16 -0
  233. package/dist/schema/webhook.mjs +20 -0
  234. package/dist/schema/webhook.mjs.map +1 -0
  235. package/dist/schema.d.mts +52 -4433
  236. package/dist/schema.mjs +37 -139
  237. package/dist/schema.mjs.map +1 -1
  238. package/dist/store.mjs +2 -0
  239. package/dist/store.mjs.map +1 -1
  240. package/dist/types.d.mts +13 -2
  241. package/dist/types.mjs +8 -0
  242. package/dist/types.mjs.map +1 -1
  243. package/dist/ui/document.mjs +1 -0
  244. package/dist/ui/document.mjs.map +1 -1
  245. package/dist/ui/feedback.mjs +24 -0
  246. package/dist/ui/feedback.mjs.map +1 -0
  247. package/dist/ui/{components.mjs → forms.mjs} +6 -16
  248. package/dist/ui/forms.mjs.map +1 -0
  249. package/dist/ui/styles.mjs +1 -0
  250. package/dist/ui/styles.mjs.map +1 -1
  251. package/dist/ui/theme.mjs +2 -0
  252. package/dist/ui/theme.mjs.map +1 -1
  253. package/dist/urls.d.mts +8 -0
  254. package/dist/urls.mjs +7 -0
  255. package/dist/urls.mjs.map +1 -1
  256. package/dist/utils/hash.mjs +3 -0
  257. package/dist/utils/hash.mjs.map +1 -1
  258. package/dist/utils/ulid.mjs +2 -0
  259. package/dist/utils/ulid.mjs.map +1 -1
  260. package/package.json +47 -45
  261. package/dist/adapters/metadata.d.mts +0 -24
  262. package/dist/models/baseline.d.mts +0 -20
  263. package/dist/models/build.d.mts +0 -50
  264. package/dist/models/comment.d.mts +0 -20
  265. package/dist/models/label.d.mts +0 -49
  266. package/dist/models/member.d.mts +0 -27
  267. package/dist/models/project.d.mts +0 -42
  268. package/dist/models/snapshot.d.mts +0 -46
  269. package/dist/models/token.d.mts +0 -33
  270. package/dist/models/webhook.d.mts +0 -37
  271. package/dist/retention/purge.d.mts +0 -28
  272. package/dist/ui/components.mjs.map +0 -1
  273. package/dist/ui/document.d.mts +0 -8
  274. package/dist/utils/paths.mjs.map +0 -1
  275. package/dist/utils/ulid.d.mts +0 -8
@@ -1 +1 @@
1
- {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { SESSION_COOKIE, type AuthAdapter, type AuthUser } from \"../adapters/auth.ts\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { randomToken } from \"../utils/hash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\nconst OAUTH_STATE_COOKIE = \"storyshelf_oauth_state\";\nconst SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;\n\nconst SHARED_USER: AuthUser = { id: \"shared\", email: \"admin@local\", name: \"Admin\", role: \"admin\" };\n\nfunction sessionCookieHeader(token: string): string {\n return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}`;\n}\n\nfunction clearSessionCookieHeader(): string {\n return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;\n}\n\ntype PasswordAuth = AuthAdapter & { login(password: string, user: AuthUser): Promise<string> };\ntype SsoAuth = AuthAdapter & { loginUrl(state: string): string };\n\nfunction hasPasswordLogin(auth: AuthAdapter): auth is PasswordAuth {\n return \"login\" in auth;\n}\n\nfunction hasSso(auth: AuthAdapter): auth is SsoAuth {\n return \"loginUrl\" in auth;\n}\n\nfunction buildSsoUrl(c: Context, auth: SsoAuth): string {\n const state = randomToken(\"shelf_\").value;\n c.header(\"set-cookie\", `${OAUTH_STATE_COOKIE}=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`);\n return auth.loginUrl(state);\n}\n\nexport function registerAuth(app: ShelfApp, auth: AuthAdapter): void {\n app.get(\"/auth/login\", async (c) => {\n if (hasSso(auth) && !hasPasswordLogin(auth)) {\n return c.redirect(buildSsoUrl(c, auth));\n }\n const ssoUrl = hasSso(auth) ? buildSsoUrl(c, auth) : undefined;\n return c.html(await renderLoginPage({ ssoUrl }));\n });\n\n app.post(\"/auth/login\", async (c) => {\n if (!hasPasswordLogin(auth)) {\n throw new HTTPException(404, { message: \"Password login is not configured\" });\n }\n const form = await c.req.formData();\n const raw = form.get(\"password\");\n const password = typeof raw === \"string\" ? raw : \"\";\n try {\n const token = await auth.login(password, SHARED_USER);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return hxRedirect(c, \"/\");\n } catch {\n return c.html(await renderLoginPage({ error: \"Invalid password\" }), 401);\n }\n });\n\n app.get(\"/auth/callback\", async (c) => {\n if (!auth.handleCallback) {\n return c.notFound();\n }\n const code = c.req.query(\"code\") ?? \"\";\n const state = c.req.query(\"state\") ?? \"\";\n if (!code || !state) {\n throw new HTTPException(400, { message: \"Missing code or state\" });\n }\n const user = await auth.handleCallback({ provider: \"oidc\", code, state });\n if (!user) {\n return c.html(await renderLoginPage({ error: \"SSO sign in failed\" }), 401);\n }\n const token = await auth.createSession(user);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return c.redirect(\"/\");\n });\n\n app.post(\"/auth/logout\", async (c) => {\n await auth.destroySession(\"\");\n c.header(\"set-cookie\", clearSessionCookieHeader());\n return hxRedirect(c, \"/auth/login\");\n });\n}"],"mappings":";;;;;;;;;AASA,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAE5B,MAAM,cAAwB;CAAE,IAAI;CAAU,OAAO;CAAe,MAAM;CAAS,MAAM;AAAQ;AAEjG,SAAS,oBAAoB,OAAuB;CAClD,OAAO,GAAG,eAAe,GAAG,MAAM,4CAA4C;AAChF;AAEA,SAAS,2BAAmC;CAC1C,OAAO,GAAG,eAAe;AAC3B;AAKA,SAAS,iBAAiB,MAAyC;CACjE,OAAO,WAAW;AACpB;AAEA,SAAS,OAAO,MAAoC;CAClD,OAAO,cAAc;AACvB;AAEA,SAAS,YAAY,GAAY,MAAuB;CACtD,MAAM,QAAQ,YAAY,QAAQ,CAAC,CAAC;CACpC,EAAE,OAAO,cAAc,GAAG,mBAAmB,GAAG,MAAM,8CAA8C;CACpG,OAAO,KAAK,SAAS,KAAK;AAC5B;AAEA,SAAgB,aAAa,KAAe,MAAyB;CACnE,IAAI,IAAI,eAAe,OAAO,MAAM;EAClC,IAAI,OAAO,IAAI,KAAK,CAAC,iBAAiB,IAAI,GACxC,OAAO,EAAE,SAAS,YAAY,GAAG,IAAI,CAAC;EAExC,MAAM,SAAS,OAAO,IAAI,IAAI,YAAY,GAAG,IAAI,IAAI,KAAA;EACrD,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,CAAC,CAAC;CACjD,CAAC;CAED,IAAI,KAAK,eAAe,OAAO,MAAM;EACnC,IAAI,CAAC,iBAAiB,IAAI,GACxB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,mCAAmC,CAAC;EAG9E,MAAM,OAAM,MADO,EAAE,IAAI,SAAS,EAAA,CACjB,IAAI,UAAU;EAC/B,MAAM,WAAW,OAAO,QAAQ,WAAW,MAAM;EACjD,IAAI;GACF,MAAM,QAAQ,MAAM,KAAK,MAAM,UAAU,WAAW;GACpD,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;GACjD,OAAO,WAAW,GAAG,GAAG;EAC1B,QAAQ;GACN,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,mBAAmB,CAAC,GAAG,GAAG;EACzE;CACF,CAAC;CAED,IAAI,IAAI,kBAAkB,OAAO,MAAM;EACrC,IAAI,CAAC,KAAK,gBACR,OAAO,EAAE,SAAS;EAEpB,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM,KAAK;EACpC,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO,KAAK;EACtC,IAAI,CAAC,QAAQ,CAAC,OACZ,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,wBAAwB,CAAC;EAEnE,MAAM,OAAO,MAAM,KAAK,eAAe;GAAE,UAAU;GAAQ;GAAM;EAAM,CAAC;EACxE,IAAI,CAAC,MACH,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,qBAAqB,CAAC,GAAG,GAAG;EAE3E,MAAM,QAAQ,MAAM,KAAK,cAAc,IAAI;EAC3C,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;EACjD,OAAO,EAAE,SAAS,GAAG;CACvB,CAAC;CAED,IAAI,KAAK,gBAAgB,OAAO,MAAM;EACpC,MAAM,KAAK,eAAe,EAAE;EAC5B,EAAE,OAAO,cAAc,yBAAyB,CAAC;EACjD,OAAO,WAAW,GAAG,aAAa;CACpC,CAAC;AACH"}
1
+ {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { SESSION_COOKIE, type AuthAdapter, type AuthUser } from \"../adapters/auth.ts\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { randomToken } from \"../utils/hash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\nconst OAUTH_STATE_COOKIE = \"storyshelf_oauth_state\";\nconst SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;\n\nconst SHARED_USER: AuthUser = { id: \"shared\", email: \"admin@local\", name: \"Admin\", role: \"admin\" };\n\nfunction sessionCookieHeader(token: string): string {\n return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}`;\n}\n\nfunction clearSessionCookieHeader(): string {\n return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;\n}\n\ntype PasswordAuth = AuthAdapter & { login(password: string, user: AuthUser): Promise<string> };\ntype SsoAuth = AuthAdapter & { loginUrl(state: string): string };\n\nfunction hasPasswordLogin(auth: AuthAdapter): auth is PasswordAuth {\n return \"login\" in auth;\n}\n\nfunction hasSso(auth: AuthAdapter): auth is SsoAuth {\n return \"loginUrl\" in auth;\n}\n\nfunction buildSsoUrl(c: Context, auth: SsoAuth): string {\n const state = randomToken(\"shelf_\").value;\n c.header(\n \"set-cookie\",\n `${OAUTH_STATE_COOKIE}=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`,\n );\n return auth.loginUrl(state);\n}\n\n/** Register the password and SSO login, callback, and logout routes. */\nexport function registerAuth(app: ShelfApp, auth: AuthAdapter): void {\n app.get(\"/auth/login\", async (c) => {\n if (hasSso(auth) && !hasPasswordLogin(auth)) {\n return c.redirect(buildSsoUrl(c, auth));\n }\n const ssoUrl = hasSso(auth) ? buildSsoUrl(c, auth) : undefined;\n return c.html(await renderLoginPage({ ssoUrl }));\n });\n\n app.post(\"/auth/login\", async (c) => {\n if (!hasPasswordLogin(auth)) {\n throw new HTTPException(404, { message: \"Password login is not configured\" });\n }\n const form = await c.req.formData();\n const raw = form.get(\"password\");\n const password = typeof raw === \"string\" ? raw : \"\";\n try {\n const token = await auth.login(password, SHARED_USER);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return hxRedirect(c, \"/\");\n } catch {\n return c.html(await renderLoginPage({ error: \"Invalid password\" }), 401);\n }\n });\n\n app.get(\"/auth/callback\", async (c) => {\n if (!auth.handleCallback) {\n return c.notFound();\n }\n const code = c.req.query(\"code\") ?? \"\";\n const state = c.req.query(\"state\") ?? \"\";\n if (!code || !state) {\n throw new HTTPException(400, { message: \"Missing code or state\" });\n }\n const user = await auth.handleCallback({ provider: \"oidc\", code, state });\n if (!user) {\n return c.html(await renderLoginPage({ error: \"SSO sign in failed\" }), 401);\n }\n const token = await auth.createSession(user);\n c.header(\"set-cookie\", sessionCookieHeader(token));\n return c.redirect(\"/\");\n });\n\n app.post(\"/auth/logout\", async (c) => {\n await auth.destroySession(\"\");\n c.header(\"set-cookie\", clearSessionCookieHeader());\n return hxRedirect(c, \"/auth/login\");\n });\n}\n"],"mappings":";;;;;;;;;AAQA,MAAM,qBAAqB;AAC3B,MAAM,sBAAsB;AAE5B,MAAM,cAAwB;CAAE,IAAI;CAAU,OAAO;CAAe,MAAM;CAAS,MAAM;AAAQ;AAEjG,SAAS,oBAAoB,OAAuB;CAClD,OAAO,GAAG,eAAe,GAAG,MAAM,4CAA4C;AAChF;AAEA,SAAS,2BAAmC;CAC1C,OAAO,GAAG,eAAe;AAC3B;AAKA,SAAS,iBAAiB,MAAyC;CACjE,OAAO,WAAW;AACpB;AAEA,SAAS,OAAO,MAAoC;CAClD,OAAO,cAAc;AACvB;AAEA,SAAS,YAAY,GAAY,MAAuB;CACtD,MAAM,QAAQ,YAAY,QAAQ,CAAC,CAAC;CACpC,EAAE,OACA,cACA,GAAG,mBAAmB,GAAG,MAAM,8CACjC;CACA,OAAO,KAAK,SAAS,KAAK;AAC5B;;AAGA,SAAgB,aAAa,KAAe,MAAyB;CACnE,IAAI,IAAI,eAAe,OAAO,MAAM;EAClC,IAAI,OAAO,IAAI,KAAK,CAAC,iBAAiB,IAAI,GACxC,OAAO,EAAE,SAAS,YAAY,GAAG,IAAI,CAAC;EAExC,MAAM,SAAS,OAAO,IAAI,IAAI,YAAY,GAAG,IAAI,IAAI,KAAA;EACrD,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,CAAC,CAAC;CACjD,CAAC;CAED,IAAI,KAAK,eAAe,OAAO,MAAM;EACnC,IAAI,CAAC,iBAAiB,IAAI,GACxB,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,mCAAmC,CAAC;EAG9E,MAAM,OAAM,MADO,EAAE,IAAI,SAAS,EAAA,CACjB,IAAI,UAAU;EAC/B,MAAM,WAAW,OAAO,QAAQ,WAAW,MAAM;EACjD,IAAI;GACF,MAAM,QAAQ,MAAM,KAAK,MAAM,UAAU,WAAW;GACpD,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;GACjD,OAAO,WAAW,GAAG,GAAG;EAC1B,QAAQ;GACN,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,mBAAmB,CAAC,GAAG,GAAG;EACzE;CACF,CAAC;CAED,IAAI,IAAI,kBAAkB,OAAO,MAAM;EACrC,IAAI,CAAC,KAAK,gBACR,OAAO,EAAE,SAAS;EAEpB,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM,KAAK;EACpC,MAAM,QAAQ,EAAE,IAAI,MAAM,OAAO,KAAK;EACtC,IAAI,CAAC,QAAQ,CAAC,OACZ,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,wBAAwB,CAAC;EAEnE,MAAM,OAAO,MAAM,KAAK,eAAe;GAAE,UAAU;GAAQ;GAAM;EAAM,CAAC;EACxE,IAAI,CAAC,MACH,OAAO,EAAE,KAAK,MAAM,gBAAgB,EAAE,OAAO,qBAAqB,CAAC,GAAG,GAAG;EAE3E,MAAM,QAAQ,MAAM,KAAK,cAAc,IAAI;EAC3C,EAAE,OAAO,cAAc,oBAAoB,KAAK,CAAC;EACjD,OAAO,EAAE,SAAS,GAAG;CACvB,CAAC;CAED,IAAI,KAAK,gBAAgB,OAAO,MAAM;EACpC,MAAM,KAAK,eAAe,EAAE;EAC5B,EAAE,OAAO,cAAc,yBAAyB,CAAC;EACjD,OAAO,WAAW,GAAG,aAAa;CACpC,CAAC;AACH"}
@@ -3,55 +3,152 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { BuildModel } from "../models/build.mjs";
5
5
  import { ProjectModel } from "../models/project.mjs";
6
- import { BUILD_STATUSES } from "../types.mjs";
7
- import { getStore } from "../store.mjs";
8
- import { notFound } from "./helpers.mjs";
9
6
  import { emitWebhookEvent } from "../adapters/webhook-events.mjs";
10
7
  import { BaselineModel } from "../models/baseline.mjs";
11
8
  import { SnapshotModel } from "../models/snapshot.mjs";
9
+ import { extractStorybookToScratch, persistStorybookStatics } from "../capture/statics.mjs";
10
+ import { getStore } from "../store.mjs";
11
+ import { BUILD_STATUSES } from "../types.mjs";
12
+ import { LabelModel } from "../models/label.mjs";
13
+ import { notFound } from "./helpers.mjs";
12
14
  import { z } from "@hono/zod-openapi";
15
+ import { HTTPException } from "hono/http-exception";
16
+ import { Readable, Transform } from "node:stream";
13
17
  //#region src/routers/builds.handlers.ts
18
+ /** Roles permitted to view builds, snapshots, and comments. */
14
19
  const VIEW_ROLES = [
15
20
  "viewer",
16
21
  "developer",
17
22
  "approver",
18
23
  "admin"
19
24
  ];
25
+ /** Roles permitted to upload builds and write comments. */
20
26
  const DEVELOPER_ROLES = [
21
27
  "developer",
22
28
  "approver",
23
29
  "admin"
24
30
  ];
31
+ /** Roles permitted to approve or reject snapshots and delete builds. */
25
32
  const APPROVER_ROLES = ["approver", "admin"];
26
- const buildUploadSchema = z.object({
27
- gitSha: z.string(),
28
- gitBranch: z.string(),
33
+ /** JSON input schema for creating a build before streaming its bundle. */
34
+ const buildCreateJsonSchema = z.object({
35
+ gitSha: z.string().min(1),
36
+ gitBranch: z.string().min(1),
29
37
  authorEmail: z.string().optional(),
30
38
  authorName: z.string().optional(),
31
39
  message: z.string().optional(),
32
- zip: z.instanceof(File).openapi({
33
- type: "string",
34
- format: "binary"
35
- }).optional()
36
- }).openapi("BuildUpload");
40
+ labels: z.array(z.object({
41
+ key: z.string().min(1),
42
+ value: z.string()
43
+ })).optional()
44
+ }).openapi("BuildCreate");
45
+ async function attachLabels(db, projectId, buildId, labels) {
46
+ const models = new LabelModel(db);
47
+ for (const { key, value } of labels) {
48
+ if (!await models.getType(projectId, key)) await models.createType(projectId, {
49
+ key,
50
+ name: key
51
+ });
52
+ await models.attach(projectId, buildId, key, value);
53
+ }
54
+ }
55
+ /**
56
+ * Create a build record with labels and the `build:created` webhook.
57
+ * Shared by the build-creation route.
58
+ */
59
+ async function createBuildRecord(project, meta) {
60
+ const { db, config } = getStore();
61
+ if (!meta.gitSha || !meta.gitBranch) throw new HTTPException(400, { message: "gitSha and gitBranch are required" });
62
+ const build = await new BuildModel(db).create(project.id, {
63
+ gitSha: meta.gitSha,
64
+ gitBranch: meta.gitBranch,
65
+ isDefault: meta.gitBranch === project.gitDefaultBranch,
66
+ authorEmail: meta.authorEmail,
67
+ authorName: meta.authorName,
68
+ message: meta.message
69
+ });
70
+ if (meta.labels && meta.labels.length > 0) await attachLabels(db, project.id, build.id, meta.labels);
71
+ await emitWebhookEvent(db, project.id, "build:created", {
72
+ buildId: build.id,
73
+ gitSha: meta.gitSha,
74
+ gitBranch: meta.gitBranch,
75
+ authorEmail: meta.authorEmail,
76
+ authorName: meta.authorName,
77
+ message: meta.message
78
+ }, config.secret);
79
+ return build;
80
+ }
81
+ /**
82
+ * Stream a raw request body into storage with a byte cap.
83
+ * Throws 400 for a missing body, 413 once `maxBytes` is exceeded.
84
+ */
85
+ async function storeUploadStream(storage, path, body, maxBytes) {
86
+ if (!body) throw new HTTPException(400, { message: "Missing request body" });
87
+ let seen = 0;
88
+ const limiter = new Transform({ transform(chunk, _encoding, callback) {
89
+ seen += chunk.length;
90
+ if (seen > maxBytes) callback(/* @__PURE__ */ new Error(`Upload exceeds ${maxBytes} byte limit`));
91
+ else callback(null, chunk);
92
+ } });
93
+ try {
94
+ const nodeBody = Readable.fromWeb(body);
95
+ await storage.writeStream(path, nodeBody.pipe(limiter));
96
+ } catch (error) {
97
+ if (seen > maxBytes) throw new HTTPException(413, { message: "Upload exceeds size limit" });
98
+ throw error;
99
+ }
100
+ }
101
+ /** Parse a Content-Length header value into bytes, or undefined when unknown. */
102
+ function parseContentLength(value) {
103
+ if (!value) return;
104
+ const parsed = Math.trunc(Number(value));
105
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
106
+ }
107
+ /**
108
+ * Best-effort inline statics extraction for small uploads (see
109
+ * `maxInlineUnzipSize`). Never throws: failures are logged and the capture
110
+ * job backfills the same keys idempotently.
111
+ */
112
+ async function persistInlineStatics(storage, scratchDir, maxInlineBytes, contentLength, projectId, buildId, logger) {
113
+ if (!scratchDir || !maxInlineBytes) return;
114
+ const size = parseContentLength(contentLength);
115
+ if (size === void 0 || size > maxInlineBytes) return;
116
+ try {
117
+ const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);
118
+ await persistStorybookStatics(storage, extractedDir, projectId, buildId);
119
+ logger.info({
120
+ buildId,
121
+ size
122
+ }, "upload statics persisted inline");
123
+ } catch (error) {
124
+ logger.error({
125
+ err: error,
126
+ buildId
127
+ }, "inline statics persist failed; capture will backfill");
128
+ }
129
+ }
130
+ /** Query filters accepted by the build list endpoint. */
37
131
  const buildListQuery = z.object({
38
132
  status: z.enum(BUILD_STATUSES).optional(),
39
133
  branch: z.string().optional(),
40
134
  labelKey: z.string().optional(),
41
135
  labelValue: z.string().optional()
42
136
  });
137
+ /** Fetch a build scoped to its project, throwing 404 when it does not belong. */
43
138
  async function buildForProject(projectId, buildId) {
44
139
  const build = await new BuildModel(getStore().db).get(buildId);
45
140
  if (!build || build.projectId !== projectId) notFound("Build not found");
46
141
  return build;
47
142
  }
143
+ /** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */
48
144
  async function snapshotForBuild(build, snapshotId) {
49
145
  const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);
50
146
  if (!snapshot || snapshot.buildId !== build.id) notFound("Snapshot not found");
51
147
  return snapshot;
52
148
  }
149
+ /** Recompute a build's counts and roll its status up from its snapshots. */
53
150
  async function refreshBuild(buildId) {
54
- const { db } = getStore();
151
+ const { db, config } = getStore();
55
152
  await new BuildModel(db).updateCounts(buildId);
56
153
  const snapshots = await new SnapshotModel(db).listByBuild(buildId);
57
154
  const unresolved = snapshots.some((s) => s.status === "new" || s.status === "changed");
@@ -65,11 +162,12 @@ async function refreshBuild(buildId) {
65
162
  buildId,
66
163
  status,
67
164
  snapshotCount: snapshots.length
68
- });
165
+ }, config.secret);
69
166
  }
70
167
  }
168
+ /** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */
71
169
  async function approveSnapshot(snapshotId, userId) {
72
- const { db } = getStore();
170
+ const { db, config } = getStore();
73
171
  const snapshots = new SnapshotModel(db);
74
172
  const snapshot = await snapshots.get(snapshotId);
75
173
  if (!snapshot) notFound("Snapshot not found");
@@ -77,13 +175,10 @@ async function approveSnapshot(snapshotId, userId) {
77
175
  const build = await new BuildModel(db).get(snapshot.buildId);
78
176
  if (!project || !build) notFound("Project or build not found");
79
177
  await snapshots.review(snapshotId, "approved", userId);
80
- await new BaselineModel(db, getStore().storage).upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
178
+ await new BaselineModel(db, getStore().storage, config.secret).upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
81
179
  await refreshBuild(build.id);
82
180
  }
83
- function asString(value) {
84
- return typeof value === "string" ? value : void 0;
85
- }
86
181
  //#endregion
87
- export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot, asString, buildForProject, buildListQuery, buildUploadSchema, refreshBuild, snapshotForBuild };
182
+ export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, refreshBuild, snapshotForBuild, storeUploadStream };
88
183
 
89
184
  //# sourceMappingURL=builds.handlers.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\n\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { type ProjectRole, BUILD_STATUSES } from \"../types.ts\";\nimport { notFound } from \"./helpers.ts\";\n\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\nexport const buildUploadSchema = z.object({\n gitSha: z.string(),\n gitBranch: z.string(),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n zip: z.instanceof(File).openapi({ type: \"string\", format: \"binary\" }).optional(),\n}).openapi(\"BuildUpload\");\n\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\nexport async function buildForProject(projectId: string, buildId: string): Promise<import(\"../models/build.ts\").Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\nexport async function snapshotForBuild(build: { id: string }, snapshotId: string): Promise<import(\"../models/snapshot.ts\").Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\nexport async function refreshBuild(buildId: string): Promise<void> {\n const {db} = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(db, build.projectId, `build:${status}`, {\n buildId,\n status,\n snapshotCount: snapshots.length,\n });\n }\n}\n\nexport async function approveSnapshot(snapshotId: string, userId: string): Promise<void> {\n const {db} = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, getStore().storage);\n await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);\n await refreshBuild(build.id);\n}\n\nfunction asString(value: FormDataEntryValue | null): string | undefined {\n return typeof value === \"string\" ? value : undefined;\n}\n\nexport { asString };"],"mappings":";;;;;;;;;;;;;AAWA,MAAa,aAAqC;CAAC;CAAU;CAAa;CAAY;AAAO;AAC7F,MAAa,kBAA0C;CAAC;CAAa;CAAY;AAAO;AACxF,MAAa,iBAAyC,CAAC,YAAY,OAAO;AAE1E,MAAa,oBAAoB,EAAE,OAAO;CACxC,QAAQ,EAAE,OAAO;CACjB,WAAW,EAAE,OAAO;CACpB,aAAa,EAAE,OAAO,CAAC,CAAC,SAAS;CACjC,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;CAChC,SAAS,EAAE,OAAO,CAAC,CAAC,SAAS;CAC7B,KAAK,EAAE,WAAW,IAAI,CAAC,CAAC,QAAQ;EAAE,MAAM;EAAU,QAAQ;CAAS,CAAC,CAAC,CAAC,SAAS;AACjF,CAAC,CAAC,CAAC,QAAQ,aAAa;AAExB,MAAa,iBAAiB,EAAE,OAAO;CACrC,QAAQ,EAAE,KAAK,cAAc,CAAC,CAAC,SAAS;CACxC,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;CAC5B,UAAU,EAAE,OAAO,CAAC,CAAC,SAAS;CAC9B,YAAY,EAAE,OAAO,CAAC,CAAC,SAAS;AAClC,CAAC;AAED,eAAsB,gBAAgB,WAAmB,SAA8D;CACrH,MAAM,QAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,OAAO;CAC7D,IAAI,CAAC,SAAS,MAAM,cAAc,WAChC,SAAS,iBAAiB;CAE5B,OAAO;AACT;AAEA,eAAsB,iBAAiB,OAAuB,YAAuE;CACnI,MAAM,WAAW,MAAM,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,UAAU;CACtE,IAAI,CAAC,YAAY,SAAS,YAAY,MAAM,IAC1C,SAAS,oBAAoB;CAE/B,OAAO;AACT;AAEA,eAAsB,aAAa,SAAgC;CACjE,MAAM,EAAC,OAAM,SAAS;CACtB,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,aAAa,OAAO;CAC7C,MAAM,YAAY,MAAM,IAAI,cAAc,EAAE,CAAC,CAAC,YAAY,OAAO;CACjE,MAAM,aAAa,UAAU,MAAM,MAAM,EAAE,WAAW,SAAS,EAAE,WAAW,SAAS;CACrF,IAAI;CACJ,IAAI,YACF,SAAS;MAGT,SADiB,UAAU,MAAM,MAAM,EAAE,WAAW,UACpC,IAAI,aAAa;CAEnC,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,OAAO;CAClD,IAAI,OAAO;EACT,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,UAAU,SAAS,MAAM;EAClD,MAAM,iBAAiB,IAAI,MAAM,WAAW,SAAS,UAAU;GAC7D;GACA;GACA,eAAe,UAAU;EAC3B,CAAC;CACH;AACF;AAEA,eAAsB,gBAAgB,YAAoB,QAA+B;CACvF,MAAM,EAAC,OAAM,SAAS;CACtB,MAAM,YAAY,IAAI,cAAc,EAAE;CACtC,MAAM,WAAW,MAAM,UAAU,IAAI,UAAU;CAC/C,IAAI,CAAC,UACH,SAAS,oBAAoB;CAE/B,MAAM,UAAU,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,IAAI,SAAS,SAAS;CACjE,MAAM,QAAQ,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,IAAI,SAAS,OAAO;CAC3D,IAAI,CAAC,WAAW,CAAC,OACf,SAAS,4BAA4B;CAEvC,MAAM,UAAU,OAAO,YAAY,YAAY,MAAM;CAErD,MAAM,IADgB,cAAc,IAAI,SAAS,CAAC,CAAC,OACrC,CAAC,CAAC,OAAO,QAAQ,IAAI,SAAS,SAAS,SAAS,cAAc,MAAM,WAAW,SAAS,IAAI,SAAS,cAAc;CACjI,MAAM,aAAa,MAAM,EAAE;AAC7B;AAEA,SAAS,SAAS,OAAsD;CACtE,OAAO,OAAO,UAAU,WAAW,QAAQ,KAAA;AAC7C"}
1
+ {"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { Readable, Transform } from \"node:stream\";\nimport type { ReadableStream as NodeWebStream } from \"node:stream/web\";\nimport type { Logger } from \"pino\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { extractStorybookToScratch, persistStorybookStatics } from \"../capture/statics.ts\";\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { LabelModel } from \"../models/label.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { getStore } from \"../store.ts\";\nimport { type ProjectRole, BUILD_STATUSES } from \"../types.ts\";\nimport { notFound } from \"./helpers.ts\";\n\n/** Roles permitted to view builds, snapshots, and comments. */\nexport const VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\n/** Roles permitted to upload builds and write comments. */\nexport const DEVELOPER_ROLES: readonly ProjectRole[] = [\"developer\", \"approver\", \"admin\"];\n/** Roles permitted to approve or reject snapshots and delete builds. */\nexport const APPROVER_ROLES: readonly ProjectRole[] = [\"approver\", \"admin\"];\n\n/** JSON input schema for creating a build before streaming its bundle. */\nexport const buildCreateJsonSchema = z\n .object({\n gitSha: z.string().min(1),\n gitBranch: z.string().min(1),\n authorEmail: z.string().optional(),\n authorName: z.string().optional(),\n message: z.string().optional(),\n labels: z.array(z.object({ key: z.string().min(1), value: z.string() })).optional(),\n })\n .openapi(\"BuildCreate\");\n\n/** Metadata accepted by either build-creation route. */\nexport interface BuildCreateMetadata {\n gitSha: string;\n gitBranch: string;\n authorEmail?: string;\n authorName?: string;\n message?: string;\n labels?: { key: string; value: string }[];\n}\n\n/* oxlint-disable eslint/no-await-in-loop -- label attach is intentionally sequential */\nasync function attachLabels(\n db: import(\"../adapters/database.ts\").DatabaseAdapter,\n projectId: string,\n buildId: string,\n labels: { key: string; value: string }[],\n): Promise<void> {\n const models = new LabelModel(db);\n for (const { key, value } of labels) {\n const existing = await models.getType(projectId, key);\n if (!existing) {\n await models.createType(projectId, { key, name: key });\n }\n await models.attach(projectId, buildId, key, value);\n }\n}\n/* oxlint-enable eslint/no-await-in-loop */\n\n/**\n * Create a build record with labels and the `build:created` webhook.\n * Shared by the build-creation route.\n */\nexport async function createBuildRecord(\n project: Project,\n meta: BuildCreateMetadata,\n): Promise<import(\"../schema/build.ts\").Build> {\n const { db, config } = getStore();\n if (!meta.gitSha || !meta.gitBranch) {\n throw new HTTPException(400, { message: \"gitSha and gitBranch are required\" });\n }\n const build = await new BuildModel(db).create(project.id, {\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n isDefault: meta.gitBranch === project.gitDefaultBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n });\n if (meta.labels && meta.labels.length > 0) {\n await attachLabels(db, project.id, build.id, meta.labels);\n }\n await emitWebhookEvent(\n db,\n project.id,\n \"build:created\",\n {\n buildId: build.id,\n gitSha: meta.gitSha,\n gitBranch: meta.gitBranch,\n authorEmail: meta.authorEmail,\n authorName: meta.authorName,\n message: meta.message,\n },\n config.secret,\n );\n return build;\n}\n\n/**\n * Stream a raw request body into storage with a byte cap.\n * Throws 400 for a missing body, 413 once `maxBytes` is exceeded.\n */\nexport async function storeUploadStream(\n storage: StorageAdapter,\n path: string,\n body: ReadableStream<Uint8Array> | null,\n maxBytes: number,\n): Promise<void> {\n if (!body) {\n throw new HTTPException(400, { message: \"Missing request body\" });\n }\n let seen = 0;\n const limiter = new Transform({\n transform(\n chunk: Buffer,\n _encoding: string,\n callback: (error?: Error | null, data?: Buffer) => void,\n ): void {\n seen += chunk.length;\n if (seen > maxBytes) {\n callback(new Error(`Upload exceeds ${maxBytes} byte limit`));\n } else {\n callback(null, chunk);\n }\n },\n });\n try {\n const nodeBody = Readable.fromWeb(body as unknown as NodeWebStream);\n await storage.writeStream(path, nodeBody.pipe(limiter));\n } catch (error) {\n if (seen > maxBytes) {\n throw new HTTPException(413, { message: \"Upload exceeds size limit\" });\n }\n throw error;\n }\n}\n\n/** Parse a Content-Length header value into bytes, or undefined when unknown. */\nfunction parseContentLength(value: string | undefined): number | undefined {\n if (!value) {\n return undefined;\n }\n const parsed = Math.trunc(Number(value));\n return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;\n}\n\n/**\n * Best-effort inline statics extraction for small uploads (see\n * `maxInlineUnzipSize`). Never throws: failures are logged and the capture\n * job backfills the same keys idempotently.\n */\nexport async function persistInlineStatics(\n storage: StorageAdapter,\n scratchDir: string | undefined,\n maxInlineBytes: number | undefined,\n contentLength: string | undefined,\n projectId: string,\n buildId: string,\n logger: Logger,\n): Promise<void> {\n if (!scratchDir || !maxInlineBytes) {\n return;\n }\n const size = parseContentLength(contentLength);\n if (size === undefined || size > maxInlineBytes) {\n return;\n }\n try {\n const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);\n await persistStorybookStatics(storage, extractedDir, projectId, buildId);\n logger.info({ buildId, size }, \"upload statics persisted inline\");\n } catch (error) {\n logger.error({ err: error, buildId }, \"inline statics persist failed; capture will backfill\");\n }\n}\n/** Query filters accepted by the build list endpoint. */\nexport const buildListQuery = z.object({\n status: z.enum(BUILD_STATUSES).optional(),\n branch: z.string().optional(),\n labelKey: z.string().optional(),\n labelValue: z.string().optional(),\n});\n\n/** Fetch a build scoped to its project, throwing 404 when it does not belong. */\nexport async function buildForProject(\n projectId: string,\n buildId: string,\n): Promise<import(\"../schema/build.ts\").Build> {\n const build = await new BuildModel(getStore().db).get(buildId);\n if (!build || build.projectId !== projectId) {\n notFound(\"Build not found\");\n }\n return build;\n}\n\n/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */\nexport async function snapshotForBuild(\n build: { id: string },\n snapshotId: string,\n): Promise<import(\"../schema/snapshot.ts\").Snapshot> {\n const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);\n if (!snapshot || snapshot.buildId !== build.id) {\n notFound(\"Snapshot not found\");\n }\n return snapshot;\n}\n\n/** Recompute a build's counts and roll its status up from its snapshots. */\nexport async function refreshBuild(buildId: string): Promise<void> {\n const { db, config } = getStore();\n await new BuildModel(db).updateCounts(buildId);\n const snapshots = await new SnapshotModel(db).listByBuild(buildId);\n const unresolved = snapshots.some((s) => s.status === \"new\" || s.status === \"changed\");\n let status: \"reviewing\" | \"rejected\" | \"approved\";\n if (unresolved) {\n status = \"reviewing\";\n } else {\n const rejected = snapshots.some((s) => s.status === \"rejected\");\n status = rejected ? \"rejected\" : \"approved\";\n }\n const build = await new BuildModel(db).get(buildId);\n if (build) {\n await new BuildModel(db).setStatus(buildId, status);\n await emitWebhookEvent(\n db,\n build.projectId,\n `build:${status}`,\n {\n buildId,\n status,\n snapshotCount: snapshots.length,\n },\n config.secret,\n );\n }\n}\n\n/** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */\nexport async function approveSnapshot(snapshotId: string, userId: string): Promise<void> {\n const { db, config } = getStore();\n const snapshots = new SnapshotModel(db);\n const snapshot = await snapshots.get(snapshotId);\n if (!snapshot) {\n notFound(\"Snapshot not found\");\n }\n const project = await new ProjectModel(db).get(snapshot.projectId);\n const build = await new BuildModel(db).get(snapshot.buildId);\n if (!project || !build) {\n notFound(\"Project or build not found\");\n }\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, getStore().storage, config.secret);\n await baselines.upsert(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n snapshot.id,\n snapshot.screenshotPath,\n );\n await refreshBuild(build.id);\n}\n"],"mappings":"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"}
@@ -2,14 +2,14 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  import { BuildModel } from "../models/build.mjs";
5
+ import { storybookZipPath } from "../paths.mjs";
6
+ import "../config.mjs";
5
7
  import { getStore } from "../store.mjs";
6
8
  import { resolveAuthorizedProject } from "./helpers.mjs";
7
9
  import { badRequest, buildSchema, forbidden, notFound, unauthorized } from "./schemas.mjs";
8
- import { emitWebhookEvent } from "../adapters/webhook-events.mjs";
9
- import { storybookZipPath } from "../utils/paths.mjs";
10
- import { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, asString, buildForProject, buildListQuery, buildUploadSchema } from "./builds.handlers.mjs";
11
- import { registerSnapshots } from "./snapshots.mjs";
10
+ import { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, storeUploadStream } from "./builds.handlers.mjs";
12
11
  import { registerComments } from "./comments.mjs";
12
+ import { registerSnapshots } from "./snapshots.mjs";
13
13
  import { createRoute, z } from "@hono/zod-openapi";
14
14
  import { HTTPException } from "hono/http-exception";
15
15
  //#region src/routers/builds.ts
@@ -29,17 +29,48 @@ const listBuildsRoute = createRoute({
29
29
  ...unauthorized
30
30
  }
31
31
  });
32
+ const buildCreatedSchema = z.object({
33
+ build: buildSchema,
34
+ uploadUrl: z.string()
35
+ }).openapi("BuildCreated");
32
36
  const createBuildRoute = createRoute({
33
37
  method: "post",
34
38
  path: "/api/v1/projects/{slug}/builds",
35
39
  request: {
36
40
  params: z.object({ slug: z.string() }),
37
- body: { content: { "multipart/form-data": { schema: buildUploadSchema } } }
41
+ body: { content: { "application/json": { schema: buildCreateJsonSchema } } }
42
+ },
43
+ responses: {
44
+ 202: {
45
+ content: { "application/json": { schema: buildCreatedSchema } },
46
+ description: "Build created. PUT the Storybook zip to uploadUrl to queue capture."
47
+ },
48
+ ...badRequest,
49
+ ...forbidden,
50
+ ...notFound
51
+ }
52
+ });
53
+ const uploadZipRoute = createRoute({
54
+ method: "put",
55
+ path: "/api/v1/projects/{slug}/builds/{buildId}/zip",
56
+ request: {
57
+ params: z.object({
58
+ slug: z.string(),
59
+ buildId: z.string()
60
+ }),
61
+ body: { content: { "application/zip": { schema: z.string().openapi({
62
+ type: "string",
63
+ format: "binary"
64
+ }) } } }
38
65
  },
39
66
  responses: {
40
67
  202: {
41
68
  content: { "application/json": { schema: buildSchema } },
42
- description: "Build created and capture queued"
69
+ description: "Zip stored and capture queued"
70
+ },
71
+ 413: {
72
+ content: { "application/json": { schema: z.object({ message: z.string() }).openapi("Error") } },
73
+ description: "Upload exceeds the configured size limit"
43
74
  },
44
75
  ...badRequest,
45
76
  ...forbidden,
@@ -89,6 +120,7 @@ const deleteBuildRoute = createRoute({
89
120
  ...notFound
90
121
  }
91
122
  });
123
+ /** Register the build list, upload, fetch, retry, and delete endpoints. */
92
124
  function registerBuilds(app) {
93
125
  app.openapi(listBuildsRoute, async (c) => {
94
126
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...VIEW_ROLES);
@@ -103,36 +135,25 @@ function registerBuilds(app) {
103
135
  });
104
136
  app.openapi(createBuildRoute, async (c) => {
105
137
  const project = await resolveAuthorizedProject(c, c.req.valid("param").slug, ...DEVELOPER_ROLES);
106
- const form = await c.req.formData();
107
- const gitSha = asString(form.get("gitSha")) ?? "";
108
- const gitBranch = asString(form.get("gitBranch")) ?? "";
109
- if (!gitSha || !gitBranch) throw new HTTPException(400, { message: "gitSha and gitBranch are required" });
110
- const authorEmail = asString(form.get("authorEmail"));
111
- const authorName = asString(form.get("authorName"));
112
- const message = asString(form.get("message"));
113
- const build = await new BuildModel(getStore().db).create(project.id, {
114
- gitSha,
115
- gitBranch,
116
- isDefault: gitBranch === project.gitDefaultBranch,
117
- authorEmail,
118
- authorName,
119
- message
120
- });
121
- await emitWebhookEvent(getStore().db, project.id, "build:created", {
122
- buildId: build.id,
123
- gitSha,
124
- gitBranch,
125
- authorEmail,
126
- authorName,
127
- message
128
- });
129
- const zip = form.get("zip");
130
- if (zip && typeof zip !== "string") {
131
- const buffer = Buffer.from(await zip.arrayBuffer());
132
- await getStore().storage.write(storybookZipPath(project.id, build.id), buffer);
133
- }
138
+ const parsed = buildCreateJsonSchema.safeParse(await c.req.json());
139
+ if (!parsed.success) throw new HTTPException(400, { message: parsed.error.message });
140
+ const build = await createBuildRecord(project, parsed.data);
141
+ const uploadUrl = `/api/v1/projects/${project.slug}/builds/${build.id}/zip`;
142
+ return c.json({
143
+ build,
144
+ uploadUrl
145
+ }, 202);
146
+ });
147
+ app.openapi(uploadZipRoute, async (c) => {
148
+ const { slug, buildId } = c.req.valid("param");
149
+ const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);
150
+ const build = await buildForProject(project.id, buildId);
151
+ const maxBytes = getStore().config.maxUploadBytes ?? 1073741824;
152
+ await storeUploadStream(getStore().storage, storybookZipPath(project.id, build.id), c.req.raw.body, maxBytes);
134
153
  const reqId = c.get("requestId");
135
154
  await getStore().enqueueCapture?.(build.id, reqId);
155
+ const { storage, config, logger } = getStore();
156
+ await persistInlineStatics(storage, config.scratchDir, config.maxInlineUnzipSize, c.req.header("content-length"), project.id, build.id, logger);
136
157
  return c.json(build, 202);
137
158
  });
138
159
  app.openapi(getBuildRoute, async (c) => {
@@ -1 +1 @@
1
- {"version":3,"file":"builds.mjs","names":["notFoundResponse","forbiddenResponse"],"sources":["../../src/routers/builds.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { HTTPException } from \"hono/http-exception\";\n\nimport { BuildModel } from \"../models/build.ts\";\nimport { emitWebhookEvent } from \"../adapters/webhook-events.ts\";\nimport { getStore } from \"../store.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { badRequest, buildSchema, forbidden as forbiddenResponse, notFound as notFoundResponse, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, APPROVER_ROLES, buildUploadSchema, buildListQuery, buildForProject, asString } from \"./builds.handlers.ts\";\nimport { registerSnapshots } from \"./snapshots.ts\";\nimport { registerComments } from \"./comments.ts\";\n\nconst listBuildsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: { params: z.object({ slug: z.string() }), query: buildListQuery },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema.array() } }, description: \"List builds for a project\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst createBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"multipart/form-data\": { schema: buildUploadSchema } } },\n },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build created and capture queued\" },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst getBuildRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema } }, description: \"Fetch a build\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst retryBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/retry\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 202: { content: { \"application/json\": { schema: buildSchema } }, description: \"Build reset to pending\" },\n ...notFoundResponse,\n },\n});\n\nconst deleteBuildRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 204: { description: \"Build deleted\" },\n ...notFoundResponse,\n },\n});\n\nexport function registerBuilds(app: ShelfApp): void {\n app.openapi(listBuildsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { status, branch, labelKey, labelValue } = c.req.valid(\"query\");\n const builds = new BuildModel(getStore().db).list(project.id, { status, branch: branch ?? undefined, labelKey: labelKey ?? undefined, labelValue: labelValue ?? undefined });\n return c.json(await builds);\n });\n\n app.openapi(createBuildRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...DEVELOPER_ROLES);\n const form = await c.req.formData();\n\n const gitSha = asString(form.get(\"gitSha\")) ?? \"\";\n const gitBranch = asString(form.get(\"gitBranch\")) ?? \"\";\n if (!gitSha || !gitBranch) {\n throw new HTTPException(400, { message: \"gitSha and gitBranch are required\" });\n }\n const authorEmail = asString(form.get(\"authorEmail\"));\n const authorName = asString(form.get(\"authorName\"));\n const message = asString(form.get(\"message\"));\n\n const build = await new BuildModel(getStore().db).create(project.id, {\n gitSha,\n gitBranch,\n isDefault: gitBranch === project.gitDefaultBranch,\n authorEmail,\n authorName,\n message,\n });\n\n await emitWebhookEvent(getStore().db, project.id, \"build:created\", {\n buildId: build.id,\n gitSha,\n gitBranch,\n authorEmail,\n authorName,\n message,\n });\n\n const zip = form.get(\"zip\");\n if (zip && typeof zip !== \"string\") {\n const buffer = Buffer.from(await zip.arrayBuffer());\n await getStore().storage.write(storybookZipPath(project.id, build.id), buffer);\n }\n\n const reqId = c.get(\"requestId\");\n await getStore().enqueueCapture?.(build.id, reqId);\n return c.json(build, 202);\n });\n\n app.openapi(getBuildRoute, 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 return c.json(build);\n });\n\n app.openapi(retryBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const updated = await new BuildModel(getStore().db).setStatus(build.id, \"pending\");\n return c.json(updated, 202);\n });\n\n app.openapi(deleteBuildRoute, 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 await new BuildModel(getStore().db).remove(build.id);\n return c.body(null, 204);\n });\n\n registerSnapshots(app);\n registerComments(app);\n}"],"mappings":"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"}
1
+ {"version":3,"file":"builds.mjs","names":["notFoundResponse","forbiddenResponse"],"sources":["../../src/routers/builds.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { DEFAULT_MAX_UPLOAD_BYTES } from \"../config.ts\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { getStore } from \"../store.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport {\n VIEW_ROLES,\n DEVELOPER_ROLES,\n APPROVER_ROLES,\n buildCreateJsonSchema,\n buildListQuery,\n buildForProject,\n createBuildRecord,\n persistInlineStatics,\n storeUploadStream,\n} from \"./builds.handlers.ts\";\nimport { registerComments } from \"./comments.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n badRequest,\n buildSchema,\n forbidden as forbiddenResponse,\n notFound as notFoundResponse,\n unauthorized,\n} from \"./schemas.ts\";\nimport { registerSnapshots } from \"./snapshots.ts\";\n\nconst listBuildsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: { params: z.object({ slug: z.string() }), query: buildListQuery },\n responses: {\n 200: {\n content: { \"application/json\": { schema: buildSchema.array() } },\n description: \"List builds for a project\",\n },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst buildCreatedSchema = z\n .object({ build: buildSchema, uploadUrl: z.string() })\n .openapi(\"BuildCreated\");\n\nconst createBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds\",\n request: {\n params: z.object({ slug: z.string() }),\n body: {\n content: {\n \"application/json\": { schema: buildCreateJsonSchema },\n },\n },\n },\n responses: {\n 202: {\n content: {\n \"application/json\": {\n schema: buildCreatedSchema,\n },\n },\n description: \"Build created. PUT the Storybook zip to uploadUrl to queue capture.\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst uploadZipRoute = createRoute({\n method: \"put\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/zip\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: {\n content: {\n \"application/zip\": {\n schema: z.string().openapi({ type: \"string\", format: \"binary\" }),\n },\n },\n },\n },\n responses: {\n 202: {\n content: { \"application/json\": { schema: buildSchema } },\n description: \"Zip stored and capture queued\",\n },\n 413: {\n content: {\n \"application/json\": {\n schema: z.object({ message: z.string() }).openapi(\"Error\"),\n },\n },\n description: \"Upload exceeds the configured size limit\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...notFoundResponse,\n },\n});\n\nconst getBuildRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: buildSchema } }, description: \"Fetch a build\" },\n ...notFoundResponse,\n ...unauthorized,\n },\n});\n\nconst retryBuildRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/retry\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 202: {\n content: { \"application/json\": { schema: buildSchema } },\n description: \"Build reset to pending\",\n },\n ...notFoundResponse,\n },\n});\n\nconst deleteBuildRoute = createRoute({\n method: \"delete\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 204: { description: \"Build deleted\" },\n ...notFoundResponse,\n },\n});\n\n/** Register the build list, upload, fetch, retry, and delete endpoints. */\nexport function registerBuilds(app: ShelfApp): void {\n app.openapi(listBuildsRoute, async (c) => {\n const project = await resolveAuthorizedProject(c, c.req.valid(\"param\").slug, ...VIEW_ROLES);\n const { status, branch, labelKey, labelValue } = c.req.valid(\"query\");\n const builds = new BuildModel(getStore().db).list(project.id, {\n status,\n branch: branch ?? undefined,\n labelKey: labelKey ?? undefined,\n labelValue: labelValue ?? undefined,\n });\n return c.json(await builds);\n });\n\n app.openapi(createBuildRoute, async (c) => {\n const project = await resolveAuthorizedProject(\n c,\n c.req.valid(\"param\").slug,\n ...DEVELOPER_ROLES,\n );\n const parsed = buildCreateJsonSchema.safeParse(await c.req.json());\n if (!parsed.success) {\n throw new HTTPException(400, { message: parsed.error.message });\n }\n const build = await createBuildRecord(project, parsed.data);\n const uploadUrl = `/api/v1/projects/${project.slug}/builds/${build.id}/zip`;\n return c.json({ build, uploadUrl }, 202);\n });\n\n app.openapi(uploadZipRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const maxBytes = getStore().config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;\n await storeUploadStream(\n getStore().storage,\n storybookZipPath(project.id, build.id),\n c.req.raw.body,\n maxBytes,\n );\n\n const reqId = c.get(\"requestId\");\n await getStore().enqueueCapture?.(build.id, reqId);\n const { storage, config, logger } = getStore();\n await persistInlineStatics(\n storage,\n config.scratchDir,\n config.maxInlineUnzipSize,\n c.req.header(\"content-length\"),\n project.id,\n build.id,\n logger,\n );\n return c.json(build, 202);\n });\n\n app.openapi(getBuildRoute, 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 return c.json(build);\n });\n\n app.openapi(retryBuildRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const updated = await new BuildModel(getStore().db).setStatus(build.id, \"pending\");\n return c.json(updated, 202);\n });\n\n app.openapi(deleteBuildRoute, 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 await new BuildModel(getStore().db).remove(build.id);\n return c.body(null, 204);\n });\n\n registerSnapshots(app);\n registerComments(app);\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6BA,MAAM,kBAAkB,YAAY;CAClC,QAAQ;CACR,MAAM;CACN,SAAS;EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EAAG,OAAO;CAAe;CACzE,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,MAAM,EAAE,EAAE;GAC/D,aAAa;EACf;EACA,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,qBAAqB,EACxB,OAAO;CAAE,OAAO;CAAa,WAAW,EAAE,OAAO;AAAE,CAAC,CAAC,CACrD,QAAQ,cAAc;AAEzB,MAAM,mBAAmB,YAAY;CACnC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;EACrC,MAAM,EACJ,SAAS,EACP,oBAAoB,EAAE,QAAQ,sBAAsB,EACtD,EACF;CACF;CACA,WAAW;EACT,KAAK;GACH,SAAS,EACP,oBAAoB,EAClB,QAAQ,mBACV,EACF;GACA,aAAa;EACf;EACA,GAAG;EACH,GAAGC;EACH,GAAGD;CACL;AACF,CAAC;AAED,MAAM,iBAAiB,YAAY;CACjC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO;GAAE,MAAM,EAAE,OAAO;GAAG,SAAS,EAAE,OAAO;EAAE,CAAC;EAC1D,MAAM,EACJ,SAAS,EACP,mBAAmB,EACjB,QAAQ,EAAE,OAAO,CAAC,CAAC,QAAQ;GAAE,MAAM;GAAU,QAAQ;EAAS,CAAC,EACjE,EACF,EACF;CACF;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,KAAK;GACH,SAAS,EACP,oBAAoB,EAClB,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,QAAQ,OAAO,EAC3D,EACF;GACA,aAAa;EACf;EACA,GAAG;EACH,GAAGC;EACH,GAAGD;CACL;AACF,CAAC;AAED,MAAM,gBAAgB,YAAY;CAChC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GAAG,aAAa;EAAgB;EAC9F,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,kBAAkB,YAAY;CAClC,QAAQ;CACR,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,YAAY,EAAE;GACvD,aAAa;EACf;EACA,GAAGA;CACL;AACF,CAAC;AAED,MAAM,mBAAmB,YAAY;CACnC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK,EAAE,aAAa,gBAAgB;EACpC,GAAGA;CACL;AACF,CAAC;;AAGD,SAAgB,eAAe,KAAqB;CAClD,IAAI,QAAQ,iBAAiB,OAAO,MAAM;EACxC,MAAM,UAAU,MAAM,yBAAyB,GAAG,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MAAM,GAAG,UAAU;EAC1F,MAAM,EAAE,QAAQ,QAAQ,UAAU,eAAe,EAAE,IAAI,MAAM,OAAO;EACpE,MAAM,SAAS,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,QAAQ,IAAI;GAC5D;GACA,QAAQ,UAAU,KAAA;GAClB,UAAU,YAAY,KAAA;GACtB,YAAY,cAAc,KAAA;EAC5B,CAAC;EACD,OAAO,EAAE,KAAK,MAAM,MAAM;CAC5B,CAAC;CAED,IAAI,QAAQ,kBAAkB,OAAO,MAAM;EACzC,MAAM,UAAU,MAAM,yBACpB,GACA,EAAE,IAAI,MAAM,OAAO,CAAC,CAAC,MACrB,GAAG,eACL;EACA,MAAM,SAAS,sBAAsB,UAAU,MAAM,EAAE,IAAI,KAAK,CAAC;EACjE,IAAI,CAAC,OAAO,SACV,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,OAAO,MAAM,QAAQ,CAAC;EAEhE,MAAM,QAAQ,MAAM,kBAAkB,SAAS,OAAO,IAAI;EAC1D,MAAM,YAAY,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG;EACtE,OAAO,EAAE,KAAK;GAAE;GAAO;EAAU,GAAG,GAAG;CACzC,CAAC;CAED,IAAI,QAAQ,gBAAgB,OAAO,MAAM;EACvC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,WAAW,SAAS,CAAC,CAAC,OAAO,kBAAA;EACnC,MAAM,kBACJ,SAAS,CAAC,CAAC,SACX,iBAAiB,QAAQ,IAAI,MAAM,EAAE,GACrC,EAAE,IAAI,IAAI,MACV,QACF;EAEA,MAAM,QAAQ,EAAE,IAAI,WAAW;EAC/B,MAAM,SAAS,CAAC,CAAC,iBAAiB,MAAM,IAAI,KAAK;EACjD,MAAM,EAAE,SAAS,QAAQ,WAAW,SAAS;EAC7C,MAAM,qBACJ,SACA,OAAO,YACP,OAAO,oBACP,EAAE,IAAI,OAAO,gBAAgB,GAC7B,QAAQ,IACR,MAAM,IACN,MACF;EACA,OAAO,EAAE,KAAK,OAAO,GAAG;CAC1B,CAAC;CAED,IAAI,QAAQ,eAAe,OAAO,MAAM;EACtC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,KAAK;CACrB,CAAC;CAED,IAAI,QAAQ,iBAAiB,OAAO,MAAM;EACxC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,UAAU,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,MAAM,IAAI,SAAS;EACjF,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;CAED,IAAI,QAAQ,kBAAkB,OAAO,MAAM;EACzC,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,cAAc;EACzE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,MAAM,EAAE;EACnD,OAAO,EAAE,KAAK,MAAM,GAAG;CACzB,CAAC;CAED,kBAAkB,GAAG;CACrB,iBAAiB,GAAG;AACtB"}
@@ -58,6 +58,7 @@ const resolveCommentRoute = createRoute({
58
58
  ...notFound$1
59
59
  }
60
60
  });
61
+ /** Register the build comment list, create, and resolve endpoints. */
61
62
  function registerComments(app) {
62
63
  app.openapi(listCommentsRoute, async (c) => {
63
64
  const { slug, buildId } = c.req.valid("param");
@@ -1 +1 @@
1
- {"version":3,"file":"comments.mjs","names":["notFound"],"sources":["../../src/routers/comments.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nimport { CommentModel } from \"../models/comment.ts\";\nimport { getStore } from \"../store.ts\";\nimport { resolveAuthorizedProject, notFound as throwNotFound } from \"./helpers.ts\";\nimport { commentSchema, commentCreateSchema, notFound, unauthorized } from \"./schemas.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, buildForProject } from \"./builds.handlers.ts\";\n\nconst listCommentsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: commentSchema.array() } }, description: \"List comments on a build\" },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: { content: { \"application/json\": { schema: commentCreateSchema } } },\n },\n responses: {\n 201: { content: { \"application/json\": { schema: commentSchema } }, description: \"Comment created\" },\n ...notFound,\n },\n});\n\nconst resolveCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments/{commentId}/resolve\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), commentId: z.string() }) },\n responses: {\n 200: { content: { \"application/json\": { schema: commentSchema } }, description: \"Comment resolved\" },\n ...notFound,\n },\n});\n\nexport function registerComments(app: ShelfApp): void {\n app.openapi(listCommentsRoute, 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 return c.json(await new CommentModel(getStore().db).listByBuild(build.id));\n });\n\n app.openapi(createCommentRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const body = c.req.valid(\"json\");\n const userId = getStore().user?.id ?? \"anonymous\";\n const comment = await new CommentModel(getStore().db).create(project.id, build.id, userId, body);\n return c.json(comment, 201);\n });\n\n app.openapi(resolveCommentRoute, async (c) => {\n const { slug, buildId, commentId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const comment = await new CommentModel(getStore().db).resolve(commentId);\n if (comment.buildId !== build.id) {\n throwNotFound(\"Comment not found\");\n }\n return c.json(comment);\n });\n}"],"mappings":";;;;;;;;;;AASA,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;CAAE,CAAC,EAAE;CACvE,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,MAAM,EAAE,EAAE;GAAG,aAAa;EAA2B;EACnH,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,qBAAqB,YAAY;CACrC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO;GAAE,MAAM,EAAE,OAAO;GAAG,SAAS,EAAE,OAAO;EAAE,CAAC;EAC1D,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,oBAAoB,EAAE,EAAE;CAC3E;CACA,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GAAG,aAAa;EAAkB;EAClG,GAAGA;CACL;AACF,CAAC;AAED,MAAM,sBAAsB,YAAY;CACtC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;CAAE,CAAC,EAAE;CAC9F,WAAW;EACT,KAAK;GAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GAAG,aAAa;EAAmB;EACnG,GAAGA;CACL;AACF,CAAC;AAED,SAAgB,iBAAiB,KAAqB;CACpD,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,CAAC;CAC3E,CAAC;CAED,IAAI,QAAQ,oBAAoB,OAAO,MAAM;EAC3C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,SAAS,SAAS,CAAC,CAAC,MAAM,MAAM;EACtC,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,MAAM,IAAI,QAAQ,IAAI;EAC/F,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;CAED,IAAI,QAAQ,qBAAqB,OAAO,MAAM;EAC5C,MAAM,EAAE,MAAM,SAAS,cAAc,EAAE,IAAI,MAAM,OAAO;EACxD,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,SAAS;EACvE,IAAI,QAAQ,YAAY,MAAM,IAC5B,SAAc,mBAAmB;EAEnC,OAAO,EAAE,KAAK,OAAO;CACvB,CAAC;AACH"}
1
+ {"version":3,"file":"comments.mjs","names":["notFound"],"sources":["../../src/routers/comments.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { CommentModel } from \"../models/comment.ts\";\nimport { getStore } from \"../store.ts\";\nimport { VIEW_ROLES, DEVELOPER_ROLES, buildForProject } from \"./builds.handlers.ts\";\nimport { resolveAuthorizedProject, notFound as throwNotFound } from \"./helpers.ts\";\nimport { commentSchema, commentCreateSchema, notFound, unauthorized } from \"./schemas.ts\";\n\nconst listCommentsRoute = createRoute({\n method: \"get\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: { params: z.object({ slug: z.string(), buildId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: commentSchema.array() } },\n description: \"List comments on a build\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst createCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments\",\n request: {\n params: z.object({ slug: z.string(), buildId: z.string() }),\n body: { content: { \"application/json\": { schema: commentCreateSchema } } },\n },\n responses: {\n 201: {\n content: { \"application/json\": { schema: commentSchema } },\n description: \"Comment created\",\n },\n ...notFound,\n },\n});\n\nconst resolveCommentRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/projects/{slug}/builds/{buildId}/comments/{commentId}/resolve\",\n request: { params: z.object({ slug: z.string(), buildId: z.string(), commentId: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: commentSchema } },\n description: \"Comment resolved\",\n },\n ...notFound,\n },\n});\n\n/** Register the build comment list, create, and resolve endpoints. */\nexport function registerComments(app: ShelfApp): void {\n app.openapi(listCommentsRoute, 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 return c.json(await new CommentModel(getStore().db).listByBuild(build.id));\n });\n\n app.openapi(createCommentRoute, async (c) => {\n const { slug, buildId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const body = c.req.valid(\"json\");\n const userId = getStore().user?.id ?? \"anonymous\";\n const comment = await new CommentModel(getStore().db).create(\n project.id,\n build.id,\n userId,\n body,\n );\n return c.json(comment, 201);\n });\n\n app.openapi(resolveCommentRoute, async (c) => {\n const { slug, buildId, commentId } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...DEVELOPER_ROLES);\n const build = await buildForProject(project.id, buildId);\n const comment = await new CommentModel(getStore().db).resolve(commentId);\n if (comment.buildId !== build.id) {\n throwNotFound(\"Comment not found\");\n }\n return c.json(comment);\n });\n}\n"],"mappings":";;;;;;;;;;AAQA,MAAM,oBAAoB,YAAY;CACpC,QAAQ;CACR,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,cAAc,MAAM,EAAE,EAAE;GACjE,aAAa;EACf;EACA,GAAGA;EACH,GAAG;CACL;AACF,CAAC;AAED,MAAM,qBAAqB,YAAY;CACrC,QAAQ;CACR,MAAM;CACN,SAAS;EACP,QAAQ,EAAE,OAAO;GAAE,MAAM,EAAE,OAAO;GAAG,SAAS,EAAE,OAAO;EAAE,CAAC;EAC1D,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,oBAAoB,EAAE,EAAE;CAC3E;CACA,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GACzD,aAAa;EACf;EACA,GAAGA;CACL;AACF,CAAC;AAED,MAAM,sBAAsB,YAAY;CACtC,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,QAAQ,EAAE,OAAO;EAAE,MAAM,EAAE,OAAO;EAAG,SAAS,EAAE,OAAO;EAAG,WAAW,EAAE,OAAO;CAAE,CAAC,EAAE;CAC9F,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,cAAc,EAAE;GACzD,aAAa;EACf;EACA,GAAGA;CACL;AACF,CAAC;;AAGD,SAAgB,iBAAiB,KAAqB;CACpD,IAAI,QAAQ,mBAAmB,OAAO,MAAM;EAC1C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,UAAU;EACrE,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,OAAO,EAAE,KAAK,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,CAAC;CAC3E,CAAC;CAED,IAAI,QAAQ,oBAAoB,OAAO,MAAM;EAC3C,MAAM,EAAE,MAAM,YAAY,EAAE,IAAI,MAAM,OAAO;EAC7C,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,MAAM,SAAS,SAAS,CAAC,CAAC,MAAM,MAAM;EACtC,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OACpD,QAAQ,IACR,MAAM,IACN,QACA,IACF;EACA,OAAO,EAAE,KAAK,SAAS,GAAG;CAC5B,CAAC;CAED,IAAI,QAAQ,qBAAqB,OAAO,MAAM;EAC5C,MAAM,EAAE,MAAM,SAAS,cAAc,EAAE,IAAI,MAAM,OAAO;EACxD,MAAM,UAAU,MAAM,yBAAyB,GAAG,MAAM,GAAG,eAAe;EAC1E,MAAM,QAAQ,MAAM,gBAAgB,QAAQ,IAAI,OAAO;EACvD,MAAM,UAAU,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,SAAS;EACvE,IAAI,QAAQ,YAAY,MAAM,IAC5B,SAAc,mBAAmB;EAEnC,OAAO,EAAE,KAAK,OAAO;CACvB,CAAC;AACH"}
@@ -0,0 +1,137 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { requireSiteAdmin } from "./helpers.mjs";
5
+ //#region src/routers/health.ts
6
+ const PROBE_TIMEOUT_MS = 2e3;
7
+ const DETAIL_MAX_LENGTH = 500;
8
+ function pushTarget(targets, adapter) {
9
+ if (adapter) targets.push(adapter);
10
+ }
11
+ function targetsOf(sources) {
12
+ const targets = [];
13
+ pushTarget(targets, sources.database);
14
+ pushTarget(targets, sources.storage);
15
+ pushTarget(targets, sources.captureRunner);
16
+ pushTarget(targets, sources.captureQueue);
17
+ pushTarget(targets, sources.auth);
18
+ for (const provider of sources.gitHosts ?? []) pushTarget(targets, provider);
19
+ return targets;
20
+ }
21
+ function sanitize(detail) {
22
+ return detail.length > DETAIL_MAX_LENGTH ? detail.slice(0, DETAIL_MAX_LENGTH) : detail;
23
+ }
24
+ function messageOf(reason) {
25
+ return sanitize(reason instanceof Error ? reason.message : String(reason));
26
+ }
27
+ async function timeoutError() {
28
+ await new Promise((resolve) => {
29
+ setTimeout(resolve, PROBE_TIMEOUT_MS);
30
+ });
31
+ throw new Error("health probe timed out");
32
+ }
33
+ function okReport(target, latencyMs, detail) {
34
+ const { category, kind, name } = target.metadata;
35
+ return {
36
+ category,
37
+ kind,
38
+ name,
39
+ state: "ok",
40
+ latencyMs,
41
+ ...detail === void 0 ? {} : { detail }
42
+ };
43
+ }
44
+ function failedReport(target, latencyMs, detail) {
45
+ const { category, kind, name } = target.metadata;
46
+ return {
47
+ category,
48
+ kind,
49
+ name,
50
+ state: "failed",
51
+ latencyMs,
52
+ detail
53
+ };
54
+ }
55
+ function detailOf(result, fallback) {
56
+ return result.detail === void 0 ? fallback : sanitize(result.detail);
57
+ }
58
+ async function raceProbe(target, probe, started) {
59
+ try {
60
+ const result = await Promise.race([probe(), timeoutError()]);
61
+ const latencyMs = Date.now() - started;
62
+ if (!result.ok) return failedReport(target, latencyMs, detailOf(result, "probe reported failure"));
63
+ return okReport(target, latencyMs, result.detail === void 0 ? void 0 : sanitize(result.detail));
64
+ } catch (error) {
65
+ return failedReport(target, Date.now() - started, messageOf(error));
66
+ }
67
+ }
68
+ async function probeHealth(target) {
69
+ const probe = target.lifecycle?.health;
70
+ if (!probe) {
71
+ const { category, kind, name } = target.metadata;
72
+ return {
73
+ category,
74
+ kind,
75
+ name,
76
+ state: "ok"
77
+ };
78
+ }
79
+ return await raceProbe(target, probe, Date.now());
80
+ }
81
+ async function probeTarget(target, initErrors, starting) {
82
+ const { category, kind, name } = target.metadata;
83
+ const initError = initErrors.get(`${category}/${kind}`);
84
+ if (initError !== void 0) return {
85
+ category,
86
+ kind,
87
+ name,
88
+ state: "failed",
89
+ detail: sanitize(initError)
90
+ };
91
+ if (starting) return {
92
+ category,
93
+ kind,
94
+ name,
95
+ state: "starting"
96
+ };
97
+ return await probeHealth(target);
98
+ }
99
+ function collectInitErrors(settled) {
100
+ const initErrors = /* @__PURE__ */ new Map();
101
+ if (settled) for (const failure of settled.failures) initErrors.set(`${failure.category}/${failure.kind}`, failure.error);
102
+ return initErrors;
103
+ }
104
+ function overallStatus(reports) {
105
+ if (reports.some((report) => report.state === "failed")) return "degraded";
106
+ if (reports.some((report) => report.state === "starting")) return "starting";
107
+ return "ok";
108
+ }
109
+ /** Register `GET /api/v1/health` (open liveness) and `POST` (authed deep readiness). */
110
+ function registerHealth(app, deps) {
111
+ app.get("/api/v1/health", (c) => {
112
+ const body = {
113
+ status: "ok",
114
+ uptimeSecs: Math.floor((Date.now() - deps.bootTimeMs) / 1e3),
115
+ version: deps.version
116
+ };
117
+ c.header("Cache-Control", "no-store");
118
+ return c.json(body);
119
+ });
120
+ app.post("/api/v1/health", async (c) => {
121
+ requireSiteAdmin();
122
+ const settled = deps.getSettled();
123
+ const initErrors = collectInitErrors(settled);
124
+ const targets = targetsOf(deps.sources);
125
+ const reports = await Promise.all(targets.map(async (target) => await probeTarget(target, initErrors, settled === null)));
126
+ const body = {
127
+ status: overallStatus(reports),
128
+ adapters: reports
129
+ };
130
+ c.header("Cache-Control", "no-store");
131
+ return c.json(body, body.status === "ok" ? 200 : 503);
132
+ });
133
+ }
134
+ //#endregion
135
+ export { registerHealth };
136
+
137
+ //# sourceMappingURL=health.mjs.map