@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/app-types.d.mts +54 -0
- package/dist/asset-manifest.mjs +41 -0
- package/dist/asset-manifest.mjs.map +1 -0
- package/dist/asset-paths.mjs +23 -0
- package/dist/asset-paths.mjs.map +1 -0
- package/dist/assets/htmx.min.js +3304 -0
- package/dist/assets/mark.svg +1 -0
- package/dist/capture-setup.mjs +139 -0
- package/dist/capture-setup.mjs.map +1 -0
- package/dist/credential-keys.mjs +20 -0
- package/dist/credential-keys.mjs.map +1 -0
- package/dist/credentials.mjs +56 -0
- package/dist/credentials.mjs.map +1 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.mjs +310 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lifecycle.mjs +132 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/middleware/auth-gate.mjs +36 -0
- package/dist/middleware/auth-gate.mjs.map +1 -0
- package/dist/middleware/csrf.mjs +75 -0
- package/dist/middleware/csrf.mjs.map +1 -0
- package/dist/middleware/rate-limit.mjs +68 -0
- package/dist/middleware/rate-limit.mjs.map +1 -0
- package/dist/middleware/request-log.mjs +30 -0
- package/dist/middleware/request-log.mjs.map +1 -0
- package/dist/middleware/server-timing.mjs +39 -0
- package/dist/middleware/server-timing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs +37 -0
- package/dist/middleware/setup-gate.mjs.map +1 -0
- package/dist/middleware/store-scope.mjs +33 -0
- package/dist/middleware/store-scope.mjs.map +1 -0
- package/dist/notify.d.mts +17 -0
- package/dist/notify.mjs +141 -0
- package/dist/notify.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +243 -0
- package/dist/pages/admin-system.mjs.map +1 -0
- package/dist/pages/build-detail.mjs +392 -0
- package/dist/pages/build-detail.mjs.map +1 -0
- package/dist/pages/build-diff-actions.mjs +162 -0
- package/dist/pages/build-diff-actions.mjs.map +1 -0
- package/dist/pages/build-diff-comments.mjs +104 -0
- package/dist/pages/build-diff-comments.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +117 -0
- package/dist/pages/build-diff-header.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +48 -0
- package/dist/pages/build-diff-model.mjs.map +1 -0
- package/dist/pages/build-diff-nav.mjs +122 -0
- package/dist/pages/build-diff-nav.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +78 -0
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
- package/dist/pages/build-diff-stale.mjs +70 -0
- package/dist/pages/build-diff-stale.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +70 -0
- package/dist/pages/build-diff-viewer.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +110 -0
- package/dist/pages/build-diff.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +165 -0
- package/dist/pages/compute-jobs.mjs.map +1 -0
- package/dist/pages/invite.mjs +91 -0
- package/dist/pages/invite.mjs.map +1 -0
- package/dist/pages/label-detail.mjs +154 -0
- package/dist/pages/label-detail.mjs.map +1 -0
- package/dist/pages/library.mjs +343 -0
- package/dist/pages/library.mjs.map +1 -0
- package/dist/pages/login.mjs +117 -0
- package/dist/pages/login.mjs.map +1 -0
- package/dist/pages/passkey-ceremony.mjs +97 -0
- package/dist/pages/passkey-ceremony.mjs.map +1 -0
- package/dist/pages/profile.mjs +364 -0
- package/dist/pages/profile.mjs.map +1 -0
- package/dist/pages/project-builds-table.mjs +151 -0
- package/dist/pages/project-builds-table.mjs.map +1 -0
- package/dist/pages/project-builds.mjs +143 -0
- package/dist/pages/project-builds.mjs.map +1 -0
- package/dist/pages/project-create.mjs +90 -0
- package/dist/pages/project-create.mjs.map +1 -0
- package/dist/pages/project-settings.mjs +95 -0
- package/dist/pages/project-settings.mjs.map +1 -0
- package/dist/pages/projects-card.mjs +194 -0
- package/dist/pages/projects-card.mjs.map +1 -0
- package/dist/pages/projects.mjs +129 -0
- package/dist/pages/projects.mjs.map +1 -0
- package/dist/pages/root.mjs +98 -0
- package/dist/pages/root.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +138 -0
- package/dist/pages/settings-general.mjs.map +1 -0
- package/dist/pages/settings-labels.mjs +107 -0
- package/dist/pages/settings-labels.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +192 -0
- package/dist/pages/settings-members.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +240 -0
- package/dist/pages/settings-notifications.mjs.map +1 -0
- package/dist/pages/settings-status.mjs +137 -0
- package/dist/pages/settings-status.mjs.map +1 -0
- package/dist/pages/settings-tests.mjs +111 -0
- package/dist/pages/settings-tests.mjs.map +1 -0
- package/dist/pages/settings-tokens.mjs +90 -0
- package/dist/pages/settings-tokens.mjs.map +1 -0
- package/dist/pages/settings-webhooks.mjs +104 -0
- package/dist/pages/settings-webhooks.mjs.map +1 -0
- package/dist/pages/storybook.mjs +116 -0
- package/dist/pages/storybook.mjs.map +1 -0
- package/dist/retention-timer.mjs +65 -0
- package/dist/retention-timer.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +48 -0
- package/dist/routers/admin-pages.mjs.map +1 -0
- package/dist/routers/admin.mjs +113 -0
- package/dist/routers/admin.mjs.map +1 -0
- package/dist/routers/assets.mjs +64 -0
- package/dist/routers/assets.mjs.map +1 -0
- package/dist/routers/attempts.mjs +96 -0
- package/dist/routers/attempts.mjs.map +1 -0
- package/dist/routers/auth-sync.mjs +64 -0
- package/dist/routers/auth-sync.mjs.map +1 -0
- package/dist/routers/auth.mjs +236 -0
- package/dist/routers/auth.mjs.map +1 -0
- package/dist/routers/baseline-guard.mjs +83 -0
- package/dist/routers/baseline-guard.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +217 -0
- package/dist/routers/builds.handlers.mjs.map +1 -0
- package/dist/routers/builds.mjs +381 -0
- package/dist/routers/builds.mjs.map +1 -0
- package/dist/routers/comments.mjs +109 -0
- package/dist/routers/comments.mjs.map +1 -0
- package/dist/routers/flash.mjs +35 -0
- package/dist/routers/flash.mjs.map +1 -0
- package/dist/routers/health-report.mjs +159 -0
- package/dist/routers/health-report.mjs.map +1 -0
- package/dist/routers/health.mjs +46 -0
- package/dist/routers/health.mjs.map +1 -0
- package/dist/routers/helpers.mjs +110 -0
- package/dist/routers/helpers.mjs.map +1 -0
- package/dist/routers/htmx.mjs +24 -0
- package/dist/routers/htmx.mjs.map +1 -0
- package/dist/routers/labels.mjs +118 -0
- package/dist/routers/labels.mjs.map +1 -0
- package/dist/routers/media.mjs +53 -0
- package/dist/routers/media.mjs.map +1 -0
- package/dist/routers/members.mjs +177 -0
- package/dist/routers/members.mjs.map +1 -0
- package/dist/routers/notification-channels.mjs +190 -0
- package/dist/routers/notification-channels.mjs.map +1 -0
- package/dist/routers/notification-subscriptions.mjs +120 -0
- package/dist/routers/notification-subscriptions.mjs.map +1 -0
- package/dist/routers/profile.mjs +276 -0
- package/dist/routers/profile.mjs.map +1 -0
- package/dist/routers/projects.mjs +133 -0
- package/dist/routers/projects.mjs.map +1 -0
- package/dist/routers/review-next.mjs +47 -0
- package/dist/routers/review-next.mjs.map +1 -0
- package/dist/routers/schemas.mjs +337 -0
- package/dist/routers/schemas.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +104 -0
- package/dist/routers/settings-general.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +52 -0
- package/dist/routers/settings-labels.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +58 -0
- package/dist/routers/settings-members.mjs.map +1 -0
- package/dist/routers/settings-notifications.mjs +108 -0
- package/dist/routers/settings-notifications.mjs.map +1 -0
- package/dist/routers/settings-status.mjs +77 -0
- package/dist/routers/settings-status.mjs.map +1 -0
- package/dist/routers/settings-tokens.mjs +40 -0
- package/dist/routers/settings-tokens.mjs.map +1 -0
- package/dist/routers/settings-webhooks.mjs +67 -0
- package/dist/routers/settings-webhooks.mjs.map +1 -0
- package/dist/routers/settings.handlers.mjs +149 -0
- package/dist/routers/settings.handlers.mjs.map +1 -0
- package/dist/routers/settings.mjs +25 -0
- package/dist/routers/settings.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +240 -0
- package/dist/routers/snapshots.mjs.map +1 -0
- package/dist/routers/status-configs.mjs +108 -0
- package/dist/routers/status-configs.mjs.map +1 -0
- package/dist/routers/storybook.mjs +171 -0
- package/dist/routers/storybook.mjs.map +1 -0
- package/dist/routers/tokens.mjs +91 -0
- package/dist/routers/tokens.mjs.map +1 -0
- package/dist/routers/ui.mjs +166 -0
- package/dist/routers/ui.mjs.map +1 -0
- package/dist/routers/webhooks.mjs +97 -0
- package/dist/routers/webhooks.mjs.map +1 -0
- package/dist/routers/well-known.mjs +43 -0
- package/dist/routers/well-known.mjs.map +1 -0
- package/dist/runtime.mjs +52 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/store.mjs +20 -0
- package/dist/store.mjs.map +1 -0
- package/dist/ui/avatar.mjs +76 -0
- package/dist/ui/avatar.mjs.map +1 -0
- package/dist/ui/buttons.mjs +205 -0
- package/dist/ui/buttons.mjs.map +1 -0
- package/dist/ui/code-block.mjs +47 -0
- package/dist/ui/code-block.mjs.map +1 -0
- package/dist/ui/compare-stage.mjs +344 -0
- package/dist/ui/compare-stage.mjs.map +1 -0
- package/dist/ui/csrf-field.mjs +20 -0
- package/dist/ui/csrf-field.mjs.map +1 -0
- package/dist/ui/css.mjs +36 -0
- package/dist/ui/css.mjs.map +1 -0
- package/dist/ui/document.mjs +130 -0
- package/dist/ui/document.mjs.map +1 -0
- package/dist/ui/dropdown.mjs +181 -0
- package/dist/ui/dropdown.mjs.map +1 -0
- package/dist/ui/feedback.mjs +341 -0
- package/dist/ui/feedback.mjs.map +1 -0
- package/dist/ui/filter-input.mjs +58 -0
- package/dist/ui/filter-input.mjs.map +1 -0
- package/dist/ui/forms.mjs +291 -0
- package/dist/ui/forms.mjs.map +1 -0
- package/dist/ui/icons/icon.mjs +53 -0
- package/dist/ui/icons/icon.mjs.map +1 -0
- package/dist/ui/icons/paths.mjs +61 -0
- package/dist/ui/icons/paths.mjs.map +1 -0
- package/dist/ui/icons/sprite.mjs +26 -0
- package/dist/ui/icons/sprite.mjs.map +1 -0
- package/dist/ui/kbd.mjs +33 -0
- package/dist/ui/kbd.mjs.map +1 -0
- package/dist/ui/layout.mjs +173 -0
- package/dist/ui/layout.mjs.map +1 -0
- package/dist/ui/progress.mjs +44 -0
- package/dist/ui/progress.mjs.map +1 -0
- package/dist/ui/segmented.mjs +77 -0
- package/dist/ui/segmented.mjs.map +1 -0
- package/dist/ui/shell/account.mjs +161 -0
- package/dist/ui/shell/account.mjs.map +1 -0
- package/dist/ui/shell/bare.mjs +69 -0
- package/dist/ui/shell/bare.mjs.map +1 -0
- package/dist/ui/shell/boot-script.mjs +46 -0
- package/dist/ui/shell/boot-script.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +259 -0
- package/dist/ui/shell/client-script.mjs.map +1 -0
- package/dist/ui/shell/nav.mjs +111 -0
- package/dist/ui/shell/nav.mjs.map +1 -0
- package/dist/ui/shell/review-script.mjs +195 -0
- package/dist/ui/shell/review-script.mjs.map +1 -0
- package/dist/ui/shell/sidebar.mjs +323 -0
- package/dist/ui/shell/sidebar.mjs.map +1 -0
- package/dist/ui/shell/topbar.mjs +268 -0
- package/dist/ui/shell/topbar.mjs.map +1 -0
- package/dist/ui/stacks.mjs +82 -0
- package/dist/ui/stacks.mjs.map +1 -0
- package/dist/ui/styles/base.mjs +41 -0
- package/dist/ui/styles/base.mjs.map +1 -0
- package/dist/ui/styles/behavior.mjs +43 -0
- package/dist/ui/styles/behavior.mjs.map +1 -0
- package/dist/ui/styles/review-layout.mjs +347 -0
- package/dist/ui/styles/review-layout.mjs.map +1 -0
- package/dist/ui/styles/review.mjs +86 -0
- package/dist/ui/styles/review.mjs.map +1 -0
- package/dist/ui/styles/tokens.mjs +132 -0
- package/dist/ui/styles/tokens.mjs.map +1 -0
- package/dist/ui/styles.mjs +19 -0
- package/dist/ui/styles.mjs.map +1 -0
- package/dist/ui/subnav.mjs +91 -0
- package/dist/ui/subnav.mjs.map +1 -0
- package/dist/ui/table.mjs +73 -0
- package/dist/ui/table.mjs.map +1 -0
- package/dist/ui/theme.mjs +64 -0
- package/dist/ui/theme.mjs.map +1 -0
- package/dist/ui/thumbnail.mjs +51 -0
- package/dist/ui/thumbnail.mjs.map +1 -0
- package/dist/ui/time.mjs +50 -0
- package/dist/ui/time.mjs.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.mjs","names":[],"sources":["../../src/routers/auth.ts"],"sourcesContent":["import type { Auth, EngineLoginMethod } from \"@storyshelf/auth\";\nimport { MIN_PASSWORD_LENGTH } from \"@storyshelf/auth\";\nimport { UserModel } from \"@storyshelf/core/models\";\nimport { SESSION_COOKIE } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notifySystem } from \"../notify.ts\";\nimport { renderInviteInvalidPage, renderInvitePage } from \"../pages/invite.tsx\";\nimport { renderLoginPage } from \"../pages/login.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { syncLoginMemberships } from \"./auth-sync.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Provider ids are URL path segments: strict charset, bounded length. */\nfunction validProviderId(value: string): boolean {\n return /^[a-zA-Z0-9_-]{1,64}$/u.test(value);\n}\n\nconst ENGINE_PASSWORD_ACTION = \"/auth/engine/login\";\n\n/** Forward a request to the mounted engine, preserving origin and cookies. */\nexport async function forwardToEngine(\n c: Context,\n engine: Auth,\n path: string,\n body?: unknown,\n): Promise<Response> {\n return await forwardEngineRequest(c, engine, path, \"POST\", body);\n}\n\nasync function forwardEngineRequest(\n c: Context,\n engine: Auth,\n path: string,\n method: \"GET\" | \"POST\",\n body?: unknown,\n): Promise<Response> {\n const headers = new Headers();\n for (const name of [\"cookie\", \"origin\", \"referer\", \"user-agent\"]) {\n const value = c.req.header(name);\n if (value) {\n headers.set(name, value);\n }\n }\n if (method === \"POST\") {\n headers.set(\"content-type\", \"application/json\");\n }\n return await engine.handler(\n new Request(new URL(path, c.req.url), {\n method,\n headers,\n ...(method === \"POST\" ? { body: JSON.stringify(body ?? {}) } : {}),\n }),\n );\n}\n\nfunction copySetCookies(c: Context, res: Response): void {\n const cookies =\n typeof res.headers.getSetCookie === \"function\"\n ? res.headers.getSetCookie()\n : splitSetCookie(res.headers.get(\"set-cookie\"));\n for (const cookie of cookies) {\n c.header(\"set-cookie\", cookie, { append: true });\n }\n}\n\n/** Split a joined Set-Cookie header (runtimes without getSetCookie). */\nfunction splitSetCookie(header: string | null): string[] {\n if (!header) {\n return [];\n }\n // Split on commas starting a new `name=` pair; Expires dates (\"Wed, 21\")\n // never match (no `=` after the token). Fallback only — Node has getSetCookie.\n return header\n .split(/,\\s*(?=[^;\\s=]+=)/u)\n .map((part) => part.trim())\n .filter((part) => part !== \"\");\n}\n\nfunction loginLinks(\n methods: EngineLoginMethod[],\n): { kind: \"password\" | \"oauth\" | \"passkey\" | \"sso\"; id: string; label: string; url: string }[] {\n return methods.map((method) => ({\n ...method,\n // Passkey sign-in is JS-driven on the page; the url is an unused fallback.\n url:\n method.kind === \"oauth\" || method.kind === \"sso\"\n ? `/auth/engine/${method.id}`\n : ENGINE_PASSWORD_ACTION,\n }));\n}\n\nasync function renderEngineLogin(\n c: Context,\n engine: Auth,\n error?: string,\n email?: string,\n status?: 200 | 401 | 429 | 500 | 502,\n): Promise<Response> {\n return c.html(\n await renderLoginPage({\n error,\n email,\n engineMethods: loginLinks(engine.loginMethods()),\n }),\n status ?? 200,\n );\n}\n\n/** Membership sync for first-seen engine users only (never demote existing roles). */\nasync function syncEngineUser(user: { id: string; email: string; name: string }): Promise<void> {\n const existing = await new UserModel(getStore().db).get(user.id);\n if (existing) {\n return;\n }\n await syncLoginMemberships(\n getStore().db,\n {\n users: getStore().db.tables.users,\n projects: getStore().db.tables.projects,\n projectMembers: getStore().db.tables.projectMembers,\n projectGroupMappings: getStore().db.tables.projectGroupMappings,\n },\n { ...user, role: \"member\" },\n );\n}\n\nasync function readCredentials(c: Context): Promise<{ email: string; password: string }> {\n const form = await c.req.formData();\n const rawEmail = form.get(\"email\");\n const rawPassword = form.get(\"password\");\n return {\n email: typeof rawEmail === \"string\" ? rawEmail : \"\",\n password: typeof rawPassword === \"string\" ? rawPassword : \"\",\n };\n}\n\nasync function finishCredentialLogin(\n c: Context,\n engine: Auth,\n email: string,\n password: string,\n): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", { email, password });\n if (res.status !== 200) {\n // Generic message either way (no user enumeration), but propagate\n // rate-limit/upstream statuses instead of masking them as 401.\n if (res.status === 429) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 429);\n }\n if (res.status >= 500) {\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 502);\n }\n // Credential failures only (never rate-limit/upstream noise, never\n // secrets): admins opted into sys:* see a login-attempt signal. Spray\n // attempts can fan out here; the /auth/* rate-limit bucket bounds it.\n await notifySystem(\"sys:auth-failed\", { email });\n return renderEngineLogin(c, engine, \"Invalid credentials\", email, 401);\n }\n copySetCookies(c, res);\n const body = (await res.json()) as { user: { id: string; email: string; name: string } };\n await syncEngineUser(body.user);\n return hxRedirect(c, \"/\");\n}\n\nasync function handleEngineLogin(c: Context, engine: Auth): Promise<Response> {\n const { email, password } = await readCredentials(c);\n return finishCredentialLogin(c, engine, email, password);\n}\n\nfunction resolveEngineMethod(c: Context, engine: Auth): EngineLoginMethod | null {\n const id = c.req.param(\"providerId\") ?? \"\";\n if (!validProviderId(id)) {\n throw new HTTPException(400, { message: \"Unknown provider\" });\n }\n return (\n engine.loginMethods().find((entry) => entry.id === id && entry.kind !== \"password\") ?? null\n );\n}\n\nasync function finishOAuthStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/social\", {\n provider: id,\n callbackURL: \"/\",\n disableRedirect: true,\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function finishSSOStart(c: Context, engine: Auth, id: string): Promise<Response> {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/sso\", {\n providerId: id,\n callbackURL: \"/\",\n });\n if (res.status !== 200) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n const body = (await res.json()) as { url?: string };\n if (!body.url) {\n throw new HTTPException(502, { message: \"Sign in failed\" });\n }\n copySetCookies(c, res);\n return c.redirect(body.url);\n}\n\nasync function handleEngineStart(c: Context, engine: Auth): Promise<Response> {\n const method = resolveEngineMethod(c, engine);\n if (!method || method.kind === \"passkey\") {\n return c.notFound();\n }\n if (method.kind === \"sso\") {\n return await finishSSOStart(c, engine, method.id);\n }\n return await finishOAuthStart(c, engine, method.id);\n}\n\nasync function handleInvitePage(c: Context, engine: Auth): Promise<Response> {\n const inviteId = c.req.param(\"inviteId\") ?? \"\";\n const token = c.req.query(\"token\") ?? \"\";\n // The invite token renders into the page: never store.\n c.header(\"Cache-Control\", \"no-store\");\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n try {\n const { email, name } = await engine.verifyInvite({ inviteId, token });\n return c.html(await renderInvitePage({ inviteId, token, email, name }));\n } catch {\n return c.html(renderInviteInvalidPage(\"Invalid or expired invite\"), 400);\n }\n}\n\nasync function readInviteForm(c: Context): Promise<{\n inviteId: string;\n token: string;\n password: string;\n confirm: string;\n}> {\n const form = await c.req.formData();\n return {\n inviteId: c.req.param(\"inviteId\") ?? \"\",\n token: formText(form.get(\"token\")),\n password: formText(form.get(\"password\")),\n confirm: formText(form.get(\"confirm\")),\n };\n}\n\nfunction formText(value: unknown): string {\n return typeof value === \"string\" ? value : \"\";\n}\n\nasync function finishInviteAccept(\n c: Context,\n engine: Auth,\n inviteId: string,\n token: string,\n password: string,\n): Promise<Response> {\n const user = await engine.acceptInvite({ inviteId, token, password });\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-in/email\", {\n email: user.email,\n password,\n });\n if (res.status !== 200) {\n throw new Error(\"Sign in failed\");\n }\n copySetCookies(c, res);\n await syncEngineUser({ id: user.id, email: user.email, name: user.name });\n return c.redirect(\"/profile\");\n}\n\nasync function inviteAcceptError(\n c: Context,\n inviteId: string,\n token: string,\n error: unknown,\n): Promise<Response> {\n const message = error instanceof Error ? error.message : \"Invalid or expired invite\";\n if (message.includes(`at least ${MIN_PASSWORD_LENGTH}`)) {\n return c.html(await renderInvitePage({ inviteId, token, error: message }), 400);\n }\n return c.html(renderInviteInvalidPage(message), 400);\n}\n\nasync function handleInviteAccept(c: Context, engine: Auth): Promise<Response> {\n const { inviteId, token, password, confirm } = await readInviteForm(c);\n if (!inviteId || !token) {\n return c.html(renderInviteInvalidPage(\"Missing invite link parameters\"), 400);\n }\n if (password !== confirm) {\n return c.html(\n await renderInvitePage({ inviteId, token, error: \"Passwords do not match\" }),\n 400,\n );\n }\n try {\n return await finishInviteAccept(c, engine, inviteId, token, password);\n } catch (error) {\n return await inviteAcceptError(c, inviteId, token, error);\n }\n}\n\n/** Register the engine mount and descriptor-driven login routes. */\nexport function registerEngineAuth(app: ShelfRouter, engine: Auth): void {\n app.all(\"/api/auth/*\", async (c) => await engine.handler(c.req.raw));\n\n app.get(\"/auth/login\", async (c) => {\n const methods = engine.loginMethods();\n const sole = methods.length === 1 ? methods.at(0) : undefined;\n if (sole?.kind === \"oauth\" || sole?.kind === \"sso\") {\n return c.redirect(`/auth/engine/${sole.id}`);\n }\n return await renderEngineLogin(c, engine);\n });\n\n app.post(\"/auth/engine/login\", async (c) => await handleEngineLogin(c, engine));\n\n app.get(\"/auth/engine/:providerId\", async (c) => await handleEngineStart(c, engine));\n\n app.get(\"/auth/invites/:inviteId\", async (c) => await handleInvitePage(c, engine));\n\n app.post(\"/auth/invites/:inviteId\", async (c) => await handleInviteAccept(c, engine));\n\n app.post(\"/auth/logout\", async (c) => {\n const res = await forwardToEngine(c, engine, \"/api/auth/sign-out\", {});\n copySetCookies(c, res);\n // Mirror the Secure attribute or the clear-cookie misses Secure session\n // cookies over HTTPS (behind TLS-terminating proxies check forwarded proto).\n const secure =\n new URL(c.req.url).protocol === \"https:\" || c.req.header(\"x-forwarded-proto\") === \"https\";\n c.header(\n \"set-cookie\",\n `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure ? \"; Secure\" : \"\"}`,\n { append: true },\n );\n return hxRedirect(c, \"/auth/login\");\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { BaselineModel, baselineStatus, isBaselineDrifted } from "@storyshelf/core/models";
|
|
6
|
+
import { HTTPException } from "hono/http-exception";
|
|
7
|
+
//#region src/routers/baseline-guard.ts
|
|
8
|
+
/** Refuse to promote a screenshot over a baseline the reviewer has not seen. */
|
|
9
|
+
/** Whether a snapshot is awaiting a review decision. */
|
|
10
|
+
function isOpenSnapshot(snapshot) {
|
|
11
|
+
return snapshot.status === "new" || snapshot.status === "changed";
|
|
12
|
+
}
|
|
13
|
+
/** Compare a snapshot's recorded baseline with the baseline that applies to it now. */
|
|
14
|
+
async function snapshotBaselineStatus(project, build, snapshot) {
|
|
15
|
+
const current = await new BaselineModel(getStore().db).resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch);
|
|
16
|
+
return baselineStatus(snapshot, current);
|
|
17
|
+
}
|
|
18
|
+
/** Open snapshots whose diff no longer matches the current baseline. */
|
|
19
|
+
async function findDriftedSnapshots(project, build, snapshots) {
|
|
20
|
+
const open = snapshots.filter((snapshot) => isOpenSnapshot(snapshot));
|
|
21
|
+
const statuses = await Promise.all(open.map(async (snapshot) => await snapshotBaselineStatus(project, build, snapshot)));
|
|
22
|
+
const drifted = /* @__PURE__ */ new Map();
|
|
23
|
+
for (const [index, snapshot] of open.entries()) {
|
|
24
|
+
const status = statuses[index];
|
|
25
|
+
if (status && isBaselineDrifted(status)) drifted.set(snapshot.id, status);
|
|
26
|
+
}
|
|
27
|
+
return drifted;
|
|
28
|
+
}
|
|
29
|
+
/** 409 telling the client the diff is out of date; recompute it or force the approval. */
|
|
30
|
+
function baselineChanged(snapshotId, status) {
|
|
31
|
+
return new HTTPException(409, { res: new Response(JSON.stringify({
|
|
32
|
+
code: "baseline_changed",
|
|
33
|
+
message: "The baseline changed after this diff was computed. Re-diff the build, or approve with force.",
|
|
34
|
+
snapshotId,
|
|
35
|
+
baselineStatus: status
|
|
36
|
+
}), {
|
|
37
|
+
status: 409,
|
|
38
|
+
headers: { "content-type": "application/json" }
|
|
39
|
+
}) });
|
|
40
|
+
}
|
|
41
|
+
/** Throw {@link baselineChanged} when an open snapshot's baseline drifted and `force` is off. */
|
|
42
|
+
async function assertBaselineCurrent(project, build, snapshot, force) {
|
|
43
|
+
if (!isOpenSnapshot(snapshot)) return;
|
|
44
|
+
const status = await snapshotBaselineStatus(project, build, snapshot);
|
|
45
|
+
if (!isBaselineDrifted(status)) return;
|
|
46
|
+
if (force) {
|
|
47
|
+
getStore().logger.warn({
|
|
48
|
+
snapshotId: snapshot.id,
|
|
49
|
+
baselineStatus: status,
|
|
50
|
+
userId: getStore().user?.id ?? null
|
|
51
|
+
}, "approved snapshot despite changed baseline (forced)");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
throw baselineChanged(snapshot.id, status);
|
|
55
|
+
}
|
|
56
|
+
/** True when `error` is the 409 raised by {@link baselineChanged}. */
|
|
57
|
+
function isBaselineChanged(error) {
|
|
58
|
+
return error instanceof HTTPException && error.status === 409;
|
|
59
|
+
}
|
|
60
|
+
/** Resolve each snapshot's current baseline once and derive presence plus drift. */
|
|
61
|
+
async function loadBaselineView(project, build, snapshots) {
|
|
62
|
+
const baselines = new BaselineModel(getStore().db);
|
|
63
|
+
const entries = await Promise.all(snapshots.map(async (snapshot) => {
|
|
64
|
+
return {
|
|
65
|
+
snapshot,
|
|
66
|
+
current: await baselines.resolve(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, project.gitDefaultBranch)
|
|
67
|
+
};
|
|
68
|
+
}));
|
|
69
|
+
const view = {
|
|
70
|
+
hasBaseline: {},
|
|
71
|
+
drifted: {}
|
|
72
|
+
};
|
|
73
|
+
for (const { snapshot, current } of entries) {
|
|
74
|
+
view.hasBaseline[snapshot.id] = Boolean(current);
|
|
75
|
+
const status = baselineStatus(snapshot, current);
|
|
76
|
+
if (!build.isDefault && isOpenSnapshot(snapshot) && (status === "stale" || status === "removed")) view.drifted[snapshot.id] = status;
|
|
77
|
+
}
|
|
78
|
+
return view;
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { assertBaselineCurrent, baselineChanged, findDriftedSnapshots, isBaselineChanged, isOpenSnapshot, loadBaselineView, snapshotBaselineStatus };
|
|
82
|
+
|
|
83
|
+
//# sourceMappingURL=baseline-guard.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"baseline-guard.mjs","names":[],"sources":["../../src/routers/baseline-guard.ts"],"sourcesContent":["/** Refuse to promote a screenshot over a baseline the reviewer has not seen. */\nimport {\n BaselineModel,\n baselineStatus,\n isBaselineDrifted,\n type BaselineStatus,\n} from \"@storyshelf/core/models\";\nimport type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** Whether a snapshot is awaiting a review decision. */\nexport function isOpenSnapshot(snapshot: Pick<Snapshot, \"status\">): boolean {\n return snapshot.status === \"new\" || snapshot.status === \"changed\";\n}\n\n/** Compare a snapshot's recorded baseline with the baseline that applies to it now. */\nexport async function snapshotBaselineStatus(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n): Promise<BaselineStatus> {\n const baselines = new BaselineModel(getStore().db);\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n return baselineStatus(snapshot, current);\n}\n\n/** Open snapshots whose diff no longer matches the current baseline. */\nexport async function findDriftedSnapshots(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshots: Snapshot[],\n): Promise<Map<string, BaselineStatus>> {\n const open = snapshots.filter((snapshot) => isOpenSnapshot(snapshot));\n const statuses = await Promise.all(\n open.map(async (snapshot) => await snapshotBaselineStatus(project, build, snapshot)),\n );\n const drifted = new Map<string, BaselineStatus>();\n for (const [index, snapshot] of open.entries()) {\n const status = statuses[index];\n if (status && isBaselineDrifted(status)) {\n drifted.set(snapshot.id, status);\n }\n }\n return drifted;\n}\n\n/** 409 telling the client the diff is out of date; recompute it or force the approval. */\nexport function baselineChanged(snapshotId: string, status: BaselineStatus): HTTPException {\n const body = {\n code: \"baseline_changed\",\n message:\n \"The baseline changed after this diff was computed. Re-diff the build, or approve with force.\",\n snapshotId,\n baselineStatus: status,\n };\n return new HTTPException(409, {\n res: new Response(JSON.stringify(body), {\n status: 409,\n headers: { \"content-type\": \"application/json\" },\n }),\n });\n}\n\n/** Throw {@link baselineChanged} when an open snapshot's baseline drifted and `force` is off. */\nexport async function assertBaselineCurrent(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\">,\n snapshot: Snapshot,\n force: boolean,\n): Promise<void> {\n if (!isOpenSnapshot(snapshot)) {\n return;\n }\n const status = await snapshotBaselineStatus(project, build, snapshot);\n if (!isBaselineDrifted(status)) {\n return;\n }\n if (force) {\n getStore().logger.warn(\n { snapshotId: snapshot.id, baselineStatus: status, userId: getStore().user?.id ?? null },\n \"approved snapshot despite changed baseline (forced)\",\n );\n return;\n }\n throw baselineChanged(snapshot.id, status);\n}\n\n/** True when `error` is the 409 raised by {@link baselineChanged}. */\nexport function isBaselineChanged(error: unknown): boolean {\n return error instanceof HTTPException && error.status === 409;\n}\n\n/** Per-snapshot baseline facts the review pages need. */\nexport interface BaselineView {\n /** Snapshot ids that have a baseline to compare against. */\n hasBaseline: Record<string, boolean>;\n /** Open snapshots whose recorded baseline no longer matches the current one. */\n drifted: Record<string, \"stale\" | \"removed\">;\n}\n\n/** Resolve each snapshot's current baseline once and derive presence plus drift. */\nexport async function loadBaselineView(\n project: Pick<Project, \"id\" | \"gitDefaultBranch\">,\n build: Pick<Build, \"gitBranch\" | \"isDefault\">,\n snapshots: Snapshot[],\n): Promise<BaselineView> {\n const baselines = new BaselineModel(getStore().db);\n const entries = await Promise.all(\n snapshots.map(async (snapshot) => {\n const current = await baselines.resolve(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n project.gitDefaultBranch,\n );\n return { snapshot, current };\n }),\n );\n const view: BaselineView = { hasBaseline: {}, drifted: {} };\n for (const { snapshot, current } of entries) {\n view.hasBaseline[snapshot.id] = Boolean(current);\n const status = baselineStatus(snapshot, current);\n if (\n !build.isDefault &&\n isOpenSnapshot(snapshot) &&\n (status === \"stale\" || status === \"removed\")\n ) {\n view.drifted[snapshot.id] = status;\n }\n }\n return view;\n}\n"],"mappings":";;;;;;;;;AAYA,SAAgB,eAAe,UAA6C;CAC1E,OAAO,SAAS,WAAW,SAAS,SAAS,WAAW;AAC1D;;AAGA,eAAsB,uBACpB,SACA,OACA,UACyB;CAEzB,MAAM,UAAU,MAAM,IADA,cAAc,SAAS,CAAC,CAAC,EACjB,CAAC,CAAC,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;CACA,OAAO,eAAe,UAAU,OAAO;AACzC;;AAGA,eAAsB,qBACpB,SACA,OACA,WACsC;CACtC,MAAM,OAAO,UAAU,QAAQ,aAAa,eAAe,QAAQ,CAAC;CACpE,MAAM,WAAW,MAAM,QAAQ,IAC7B,KAAK,IAAI,OAAO,aAAa,MAAM,uBAAuB,SAAS,OAAO,QAAQ,CAAC,CACrF;CACA,MAAM,0BAAU,IAAI,IAA4B;CAChD,KAAK,MAAM,CAAC,OAAO,aAAa,KAAK,QAAQ,GAAG;EAC9C,MAAM,SAAS,SAAS;EACxB,IAAI,UAAU,kBAAkB,MAAM,GACpC,QAAQ,IAAI,SAAS,IAAI,MAAM;CAEnC;CACA,OAAO;AACT;;AAGA,SAAgB,gBAAgB,YAAoB,QAAuC;CAQzF,OAAO,IAAI,cAAc,KAAK,EAC5B,KAAK,IAAI,SAAS,KAAK,UAAU;EAPjC,MAAM;EACN,SACE;EACF;EACA,gBAAgB;CAGoB,CAAC,GAAG;EACtC,QAAQ;EACR,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC,EACH,CAAC;AACH;;AAGA,eAAsB,sBACpB,SACA,OACA,UACA,OACe;CACf,IAAI,CAAC,eAAe,QAAQ,GAC1B;CAEF,MAAM,SAAS,MAAM,uBAAuB,SAAS,OAAO,QAAQ;CACpE,IAAI,CAAC,kBAAkB,MAAM,GAC3B;CAEF,IAAI,OAAO;EACT,SAAS,CAAC,CAAC,OAAO,KAChB;GAAE,YAAY,SAAS;GAAI,gBAAgB;GAAQ,QAAQ,SAAS,CAAC,CAAC,MAAM,MAAM;EAAK,GACvF,qDACF;EACA;CACF;CACA,MAAM,gBAAgB,SAAS,IAAI,MAAM;AAC3C;;AAGA,SAAgB,kBAAkB,OAAyB;CACzD,OAAO,iBAAiB,iBAAiB,MAAM,WAAW;AAC5D;;AAWA,eAAsB,iBACpB,SACA,OACA,WACuB;CACvB,MAAM,YAAY,IAAI,cAAc,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,UAAU,MAAM,QAAQ,IAC5B,UAAU,IAAI,OAAO,aAAa;EAQhC,OAAO;GAAE;GAAU,SAAA,MAPG,UAAU,QAC9B,QAAQ,IACR,SAAS,SACT,SAAS,cACT,MAAM,WACN,QAAQ,gBACV;EAC2B;CAC7B,CAAC,CACH;CACA,MAAM,OAAqB;EAAE,aAAa,CAAC;EAAG,SAAS,CAAC;CAAE;CAC1D,KAAK,MAAM,EAAE,UAAU,aAAa,SAAS;EAC3C,KAAK,YAAY,SAAS,MAAM,QAAQ,OAAO;EAC/C,MAAM,SAAS,eAAe,UAAU,OAAO;EAC/C,IACE,CAAC,MAAM,aACP,eAAe,QAAQ,MACtB,WAAW,WAAW,WAAW,YAElC,KAAK,QAAQ,SAAS,MAAM;CAEhC;CACA,OAAO;AACT"}
|
|
@@ -0,0 +1,217 @@
|
|
|
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 { notifyProject } from "../notify.mjs";
|
|
7
|
+
import { notFound } from "./helpers.mjs";
|
|
8
|
+
import { assertBaselineCurrent } from "./baseline-guard.mjs";
|
|
9
|
+
import { z } from "@hono/zod-openapi";
|
|
10
|
+
import { extractStorybookToScratch, persistStorybookStatics } from "@storyshelf/core/capture";
|
|
11
|
+
import { BaselineModel, BuildModel, LabelModel, ProjectModel, SnapshotModel } from "@storyshelf/core/models";
|
|
12
|
+
import { BUILD_STATUSES } from "@storyshelf/core/types";
|
|
13
|
+
import { HTTPException } from "hono/http-exception";
|
|
14
|
+
import { emitWebhookEvent } from "@storyshelf/core/adapter/webhook-events";
|
|
15
|
+
import { Readable, Transform } from "node:stream";
|
|
16
|
+
//#region src/routers/builds.handlers.ts
|
|
17
|
+
/**
|
|
18
|
+
* Create a build record with labels and the `build:created` webhook.
|
|
19
|
+
* Shared by the build-creation route.
|
|
20
|
+
*/
|
|
21
|
+
async function createBuildRecord(project, meta) {
|
|
22
|
+
const { db, config } = getStore();
|
|
23
|
+
if (!meta.gitSha || !meta.gitBranch) throw new HTTPException(400, { message: "gitSha and gitBranch are required (revision SHA and baseline namespace)" });
|
|
24
|
+
const build = await new BuildModel(db).create(project.id, {
|
|
25
|
+
gitSha: meta.gitSha,
|
|
26
|
+
gitBranch: meta.gitBranch,
|
|
27
|
+
isDefault: meta.gitBranch === project.gitDefaultBranch,
|
|
28
|
+
authorEmail: meta.authorEmail,
|
|
29
|
+
authorName: meta.authorName,
|
|
30
|
+
message: meta.message,
|
|
31
|
+
affectedOnly: meta.affectedOnly,
|
|
32
|
+
baselineSha: await resolveBaselineSha(db, project.id, meta.gitBranch, meta.gitSha)
|
|
33
|
+
});
|
|
34
|
+
if (meta.labels && meta.labels.length > 0) await attachLabels(db, project.id, build.id, meta.labels);
|
|
35
|
+
await emitWebhookEvent(db, { webhooks: getStore().db.tables.webhooks }, project.id, "build:created", {
|
|
36
|
+
buildId: build.id,
|
|
37
|
+
gitSha: meta.gitSha,
|
|
38
|
+
gitBranch: meta.gitBranch,
|
|
39
|
+
authorEmail: meta.authorEmail,
|
|
40
|
+
authorName: meta.authorName,
|
|
41
|
+
message: meta.message
|
|
42
|
+
}, credentialKeys(config));
|
|
43
|
+
await notifyProject(project, "build:created", {
|
|
44
|
+
buildId: build.id,
|
|
45
|
+
gitSha: meta.gitSha,
|
|
46
|
+
gitBranch: meta.gitBranch,
|
|
47
|
+
authorEmail: meta.authorEmail,
|
|
48
|
+
authorName: meta.authorName,
|
|
49
|
+
message: meta.message
|
|
50
|
+
}, `/builds/${build.id}`);
|
|
51
|
+
return build;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Stream a raw request body into storage with a byte cap.
|
|
55
|
+
* Throws 400 for a missing body, 413 once `maxBytes` is exceeded.
|
|
56
|
+
*/
|
|
57
|
+
async function storeUploadStream(storage, path, body, maxBytes) {
|
|
58
|
+
if (!body) throw new HTTPException(400, { message: "Missing request body" });
|
|
59
|
+
let seen = 0;
|
|
60
|
+
const limiter = new Transform({ transform(chunk, _encoding, callback) {
|
|
61
|
+
seen += chunk.length;
|
|
62
|
+
if (seen > maxBytes) callback(/* @__PURE__ */ new Error(`Upload exceeds ${maxBytes} byte limit`));
|
|
63
|
+
else callback(null, chunk);
|
|
64
|
+
} });
|
|
65
|
+
try {
|
|
66
|
+
const nodeBody = Readable.fromWeb(body);
|
|
67
|
+
await storage.writeStream(path, nodeBody.pipe(limiter));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (seen > maxBytes) throw new HTTPException(413, { message: "Upload exceeds size limit" });
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Best-effort inline statics extraction for small uploads (see
|
|
75
|
+
* `maxInlineUnzipSize`). Never throws: failures are logged and the capture
|
|
76
|
+
* job backfills the same keys idempotently.
|
|
77
|
+
*/
|
|
78
|
+
async function persistInlineStatics(storage, scratchDir, maxInlineBytes, contentLength, projectId, buildId, logger) {
|
|
79
|
+
if (!scratchDir || !maxInlineBytes) return;
|
|
80
|
+
const size = parseContentLength(contentLength);
|
|
81
|
+
if (size === void 0 || size > maxInlineBytes) return;
|
|
82
|
+
try {
|
|
83
|
+
const extractedDir = await extractStorybookToScratch(storage, scratchDir, projectId, buildId);
|
|
84
|
+
await persistStorybookStatics(storage, extractedDir, projectId, buildId);
|
|
85
|
+
logger.info({
|
|
86
|
+
buildId,
|
|
87
|
+
size
|
|
88
|
+
}, "upload statics persisted inline");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logger.error({
|
|
91
|
+
err: error,
|
|
92
|
+
buildId
|
|
93
|
+
}, "inline statics persist failed; capture will backfill");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Query filters accepted by the build list endpoint. */
|
|
97
|
+
const buildListQuery = z.object({
|
|
98
|
+
status: z.enum(BUILD_STATUSES).optional(),
|
|
99
|
+
branch: z.string().optional(),
|
|
100
|
+
labelKey: z.string().optional(),
|
|
101
|
+
labelValue: z.string().optional()
|
|
102
|
+
});
|
|
103
|
+
/** Fetch a build scoped to its project, throwing 404 when it does not belong. */
|
|
104
|
+
async function buildForProject(projectId, buildId) {
|
|
105
|
+
const build = await new BuildModel(getStore().db).get(buildId);
|
|
106
|
+
if (!build || build.projectId !== projectId) notFound("Build not found");
|
|
107
|
+
return build;
|
|
108
|
+
}
|
|
109
|
+
/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */
|
|
110
|
+
async function snapshotForBuild(build, snapshotId) {
|
|
111
|
+
const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);
|
|
112
|
+
if (!snapshot || snapshot.buildId !== build.id) notFound("Snapshot not found");
|
|
113
|
+
return snapshot;
|
|
114
|
+
}
|
|
115
|
+
/** Recompute a build's counts and roll its status up from its snapshots. */
|
|
116
|
+
async function refreshBuild(buildId) {
|
|
117
|
+
const { db, config } = getStore();
|
|
118
|
+
await new BuildModel(db).updateCounts(buildId);
|
|
119
|
+
const snapshots = await new SnapshotModel(db).listByBuild(buildId);
|
|
120
|
+
const unresolved = snapshots.some((s) => s.status === "new" || s.status === "changed");
|
|
121
|
+
let status;
|
|
122
|
+
if (unresolved) status = "reviewing";
|
|
123
|
+
else status = snapshots.some((s) => s.status === "rejected") ? "rejected" : "approved";
|
|
124
|
+
const build = await new BuildModel(db).get(buildId);
|
|
125
|
+
if (build) {
|
|
126
|
+
await new BuildModel(db).setStatus(buildId, status);
|
|
127
|
+
await emitWebhookEvent(db, { webhooks: getStore().db.tables.webhooks }, build.projectId, `build:${status}`, {
|
|
128
|
+
buildId,
|
|
129
|
+
status,
|
|
130
|
+
snapshotCount: snapshots.length
|
|
131
|
+
}, credentialKeys(config));
|
|
132
|
+
const project = await new ProjectModel(db).get(build.projectId);
|
|
133
|
+
if (project) await notifyProject(project, `build:${status}`, {
|
|
134
|
+
buildId,
|
|
135
|
+
status,
|
|
136
|
+
snapshotCount: snapshots.length
|
|
137
|
+
}, `/builds/${buildId}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Approve a snapshot, promote its screenshot to baseline, and refresh the build.
|
|
142
|
+
* Refuses with 409 when the baseline changed since the diff, unless `force` is set.
|
|
143
|
+
*/
|
|
144
|
+
async function approveSnapshot(snapshotId, userId, options = {}) {
|
|
145
|
+
const { db, config } = getStore();
|
|
146
|
+
const snapshots = new SnapshotModel(db);
|
|
147
|
+
const snapshot = await snapshots.get(snapshotId);
|
|
148
|
+
if (!snapshot) notFound("Snapshot not found");
|
|
149
|
+
const project = await new ProjectModel(db).get(snapshot.projectId);
|
|
150
|
+
const build = await new BuildModel(db).get(snapshot.buildId);
|
|
151
|
+
if (!project || !build) notFound("Project or build not found");
|
|
152
|
+
await assertBaselineCurrent(project, build, snapshot, options.force ?? false);
|
|
153
|
+
await snapshots.review(snapshotId, "approved", userId);
|
|
154
|
+
const baselines = new BaselineModel(db, void 0, getStore().storage, credentialKeys(config));
|
|
155
|
+
const prior = await baselines.getFor(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch);
|
|
156
|
+
await baselines.upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
|
|
157
|
+
await notifyProject(project, prior ? "baseline:updated" : "baseline:created", {
|
|
158
|
+
storyId: snapshot.storyId,
|
|
159
|
+
viewport: snapshot.viewportName,
|
|
160
|
+
branch: build.gitBranch,
|
|
161
|
+
snapshotId: snapshot.id,
|
|
162
|
+
buildId: build.id
|
|
163
|
+
}, `/builds/${build.id}`);
|
|
164
|
+
await refreshBuild(build.id);
|
|
165
|
+
}
|
|
166
|
+
/** Roles permitted to view builds, snapshots, and comments. */
|
|
167
|
+
const VIEW_ROLES = [
|
|
168
|
+
"viewer",
|
|
169
|
+
"developer",
|
|
170
|
+
"approver",
|
|
171
|
+
"admin"
|
|
172
|
+
];
|
|
173
|
+
/** Roles permitted to upload builds and write comments. */
|
|
174
|
+
const DEVELOPER_ROLES = [
|
|
175
|
+
"developer",
|
|
176
|
+
"approver",
|
|
177
|
+
"admin"
|
|
178
|
+
];
|
|
179
|
+
/** Roles permitted to approve or reject snapshots and delete builds. */
|
|
180
|
+
const APPROVER_ROLES = ["approver", "admin"];
|
|
181
|
+
/** JSON input schema for creating a build before streaming its bundle. */
|
|
182
|
+
const buildCreateJsonSchema = z.object({
|
|
183
|
+
gitSha: z.string().min(1),
|
|
184
|
+
gitBranch: z.string().min(1),
|
|
185
|
+
authorEmail: z.string().optional(),
|
|
186
|
+
authorName: z.string().optional(),
|
|
187
|
+
message: z.string().optional(),
|
|
188
|
+
affectedOnly: z.boolean().optional(),
|
|
189
|
+
labels: z.array(z.object({
|
|
190
|
+
key: z.string().min(1),
|
|
191
|
+
value: z.string()
|
|
192
|
+
})).optional()
|
|
193
|
+
}).openapi("BuildCreate");
|
|
194
|
+
/** Latest prior revision on a branch with a build, or null for first builds. */
|
|
195
|
+
async function resolveBaselineSha(db, projectId, branch, sha) {
|
|
196
|
+
return (await new BuildModel(db).list(projectId, { branch })).find((build) => build.gitSha !== sha)?.gitSha ?? null;
|
|
197
|
+
}
|
|
198
|
+
async function attachLabels(db, projectId, buildId, labels) {
|
|
199
|
+
const models = new LabelModel(db);
|
|
200
|
+
for (const { key, value } of labels) {
|
|
201
|
+
if (!await models.getType(projectId, key)) await models.createType(projectId, {
|
|
202
|
+
key,
|
|
203
|
+
name: key
|
|
204
|
+
});
|
|
205
|
+
await models.attach(projectId, buildId, key, value);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Parse a Content-Length header value into bytes, or undefined when unknown. */
|
|
209
|
+
function parseContentLength(value) {
|
|
210
|
+
if (!value) return;
|
|
211
|
+
const parsed = Math.trunc(Number(value));
|
|
212
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, refreshBuild, snapshotForBuild, storeUploadStream };
|
|
216
|
+
|
|
217
|
+
//# sourceMappingURL=builds.handlers.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builds.handlers.mjs","names":[],"sources":["../../src/routers/builds.handlers.ts"],"sourcesContent":["import { z } from \"@hono/zod-openapi\";\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { emitWebhookEvent } from \"@storyshelf/core/adapter/webhook-events\";\nimport { extractStorybookToScratch, persistStorybookStatics } from \"@storyshelf/core/capture\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport { BaselineModel } from \"@storyshelf/core/models\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Build, Snapshot } from \"@storyshelf/core/schema\";\nimport { type ProjectRole, BUILD_STATUSES } from \"@storyshelf/core/types\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { Readable, Transform } from \"node:stream\";\nimport type { ReadableStream as NodeWebStream } from \"node:stream/web\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { notifyProject } from \"../notify.ts\";\nimport { getStore } from \"../store.ts\";\nimport { assertBaselineCurrent } from \"./baseline-guard.ts\";\nimport { notFound } from \"./helpers.ts\";\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<Build> {\n const { db, config } = getStore();\n if (!meta.gitSha || !meta.gitBranch) {\n throw new HTTPException(400, {\n message: \"gitSha and gitBranch are required (revision SHA and baseline namespace)\",\n });\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 affectedOnly: meta.affectedOnly,\n baselineSha: await resolveBaselineSha(db, project.id, meta.gitBranch, meta.gitSha),\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 { webhooks: getStore().db.tables.webhooks },\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 credentialKeys(config),\n );\n await notifyProject(\n project,\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 `/builds/${build.id}`,\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/**\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\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(projectId: string, buildId: string): Promise<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<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 { webhooks: getStore().db.tables.webhooks },\n build.projectId,\n `build:${status}`,\n {\n buildId,\n status,\n snapshotCount: snapshots.length,\n },\n credentialKeys(config),\n );\n const project = await new ProjectModel(db).get(build.projectId);\n if (project) {\n await notifyProject(\n project,\n `build:${status}`,\n { buildId, status, snapshotCount: snapshots.length },\n `/builds/${buildId}`,\n );\n }\n }\n}\n\n/**\n * Approve a snapshot, promote its screenshot to baseline, and refresh the build.\n * Refuses with 409 when the baseline changed since the diff, unless `force` is set.\n */\nexport async function approveSnapshot(\n snapshotId: string,\n userId: string | null,\n options: { force?: boolean } = {},\n): 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 assertBaselineCurrent(project, build, snapshot, options.force ?? false);\n await snapshots.review(snapshotId, \"approved\", userId);\n const baselines = new BaselineModel(db, undefined, getStore().storage, credentialKeys(config));\n const prior = await baselines.getFor(\n project.id,\n snapshot.storyId,\n snapshot.viewportName,\n build.gitBranch,\n );\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 notifyProject(\n project,\n prior ? \"baseline:updated\" : \"baseline:created\",\n {\n storyId: snapshot.storyId,\n viewport: snapshot.viewportName,\n branch: build.gitBranch,\n snapshotId: snapshot.id,\n buildId: build.id,\n },\n `/builds/${build.id}`,\n );\n await refreshBuild(build.id);\n}\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 affectedOnly: z.boolean().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 affectedOnly?: boolean;\n labels?: { key: string; value: string }[];\n}\n\n/** Latest prior revision on a branch with a build, or null for first builds. */\nasync function resolveBaselineSha(\n db: DatabaseAdapter,\n projectId: string,\n branch: string,\n sha: string,\n): Promise<string | null> {\n const builds = await new BuildModel(db).list(projectId, { branch });\n return builds.find((build) => build.gitSha !== sha)?.gitSha ?? null;\n}\n\n/* oxlint-disable eslint/no-await-in-loop -- label attach is intentionally sequential */\nasync function attachLabels(\n db: 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/** 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"],"mappings":"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"}
|