@storyshelf/core 0.3.2 → 0.5.4
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/README.md +38 -31
- package/dist/adapter/capture-queue.d.mts +29 -1
- package/dist/adapter/capture-runner.d.mts +18 -2
- package/dist/adapter/capture-runner.mjs +12 -1
- package/dist/adapter/capture-runner.mjs.map +1 -0
- package/dist/adapter/database.d.mts +67 -17
- package/dist/adapter/database.mjs +22 -55
- package/dist/adapter/database.mjs.map +1 -1
- package/dist/adapter/email-sender.d.mts +27 -0
- package/dist/adapter/email-sender.mjs +4 -0
- package/dist/adapter/metadata.d.mts +21 -15
- package/dist/adapter/notifier.d.mts +7 -0
- package/dist/adapter/notifier.mjs +6 -0
- package/dist/adapter/setup.d.mts +61 -0
- package/dist/adapter/setup.mjs +29 -0
- package/dist/adapter/setup.mjs.map +1 -0
- package/dist/adapter/webhook-events.d.mts +29 -0
- package/dist/adapter/webhook-events.mjs +83 -0
- package/dist/adapter/webhook-events.mjs.map +1 -0
- package/dist/adapters/notifier/events.d.mts +26 -0
- package/dist/adapters/notifier/events.mjs +66 -0
- package/dist/adapters/notifier/events.mjs.map +1 -0
- package/dist/adapters/notifier/format.d.mts +14 -0
- package/dist/adapters/notifier/format.mjs +96 -0
- package/dist/adapters/notifier/format.mjs.map +1 -0
- package/dist/adapters/notifier/provider.d.mts +32 -0
- package/dist/adapters/notifier/types.d.mts +56 -0
- package/dist/adapters/setup-runner.d.mts +23 -0
- package/dist/adapters/setup-runner.mjs +149 -0
- package/dist/adapters/setup-runner.mjs.map +1 -0
- package/dist/adapters/validate.d.mts +30 -0
- package/dist/adapters/validate.mjs +313 -0
- package/dist/adapters/validate.mjs.map +1 -0
- package/dist/auth.d.mts +94 -0
- package/dist/auth.mjs +4 -0
- package/dist/brand-packs.d.mts +16 -0
- package/dist/brand-packs.mjs +130 -0
- package/dist/brand-packs.mjs.map +1 -0
- package/dist/capture/adapter.d.mts +51 -1
- package/dist/capture/adapter.mjs.map +1 -1
- package/dist/capture/affected.d.mts +16 -0
- package/dist/capture/affected.mjs +64 -0
- package/dist/capture/affected.mjs.map +1 -0
- package/dist/capture/diff-record.mjs +30 -0
- package/dist/capture/diff-record.mjs.map +1 -0
- package/dist/capture/dispatch.d.mts +36 -0
- package/dist/capture/dispatch.mjs +88 -31
- package/dist/capture/dispatch.mjs.map +1 -1
- package/dist/capture/guards.mjs +31 -0
- package/dist/capture/guards.mjs.map +1 -0
- package/dist/capture/orchestrator.d.mts +41 -6
- package/dist/capture/orchestrator.mjs +151 -28
- package/dist/capture/orchestrator.mjs.map +1 -1
- package/dist/capture/pipeline.d.mts +30 -16
- package/dist/capture/pipeline.mjs +143 -39
- package/dist/capture/pipeline.mjs.map +1 -1
- package/dist/capture/recovery.d.mts +21 -0
- package/dist/capture/recovery.mjs +83 -0
- package/dist/capture/recovery.mjs.map +1 -0
- package/dist/capture/rediff.d.mts +35 -0
- package/dist/capture/rediff.mjs +77 -0
- package/dist/capture/rediff.mjs.map +1 -0
- package/dist/capture/sizing.d.mts +58 -0
- package/dist/capture/sizing.mjs +119 -0
- package/dist/capture/sizing.mjs.map +1 -0
- package/dist/capture/skip-checks.mjs +3 -3
- package/dist/capture/skip-checks.mjs.map +1 -1
- package/dist/capture/statics.d.mts +38 -0
- package/dist/capture/statics.mjs +38 -4
- package/dist/capture/statics.mjs.map +1 -1
- package/dist/capture/status-fanout.mjs +7 -2
- package/dist/capture/status-fanout.mjs.map +1 -1
- package/dist/capture/storybook.d.mts +31 -2
- package/dist/capture/storybook.mjs +77 -2
- package/dist/capture/storybook.mjs.map +1 -1
- package/dist/capture.d.mts +10 -4
- package/dist/capture.mjs +7 -2
- package/dist/config.d.mts +121 -4
- package/dist/config.mjs +55 -8
- package/dist/config.mjs.map +1 -1
- package/dist/index.d.mts +5 -56
- package/dist/index.mjs +4 -284
- package/dist/logger.d.mts +26 -17
- package/dist/logger.mjs +10 -5
- package/dist/logger.mjs.map +1 -1
- package/dist/models/baseline-status.d.mts +29 -0
- package/dist/models/baseline-status.mjs +26 -0
- package/dist/models/baseline-status.mjs.map +1 -0
- package/dist/models/baseline.d.mts +33 -0
- package/dist/models/baseline.mjs +35 -13
- package/dist/models/baseline.mjs.map +1 -1
- package/dist/models/build.d.mts +87 -0
- package/dist/models/build.mjs +83 -35
- package/dist/models/build.mjs.map +1 -1
- package/dist/models/capture-attempt.d.mts +40 -0
- package/dist/models/capture-attempt.mjs +85 -0
- package/dist/models/capture-attempt.mjs.map +1 -0
- package/dist/models/capture-log.d.mts +21 -0
- package/dist/models/capture-log.mjs +44 -0
- package/dist/models/capture-log.mjs.map +1 -0
- package/dist/models/comment.d.mts +29 -0
- package/dist/models/comment.mjs +11 -8
- package/dist/models/comment.mjs.map +1 -1
- package/dist/models/content-ref.d.mts +21 -0
- package/dist/models/content-ref.mjs +49 -0
- package/dist/models/content-ref.mjs.map +1 -0
- package/dist/models/credentials.d.mts +36 -0
- package/dist/models/credentials.mjs +103 -0
- package/dist/models/credentials.mjs.map +1 -0
- package/dist/models/label.d.mts +58 -0
- package/dist/models/label.mjs +32 -27
- package/dist/models/label.mjs.map +1 -1
- package/dist/models/member.d.mts +34 -0
- package/dist/models/member.mjs +20 -11
- package/dist/models/member.mjs.map +1 -1
- package/dist/models/notification-channel.d.mts +52 -0
- package/dist/models/notification-channel.mjs +93 -0
- package/dist/models/notification-channel.mjs.map +1 -0
- package/dist/models/notification-subscription.d.mts +44 -0
- package/dist/models/notification-subscription.mjs +89 -0
- package/dist/models/notification-subscription.mjs.map +1 -0
- package/dist/models/project-group-mapping.d.mts +30 -0
- package/dist/models/project-group-mapping.mjs +46 -0
- package/dist/models/project-group-mapping.mjs.map +1 -0
- package/dist/models/project.d.mts +54 -0
- package/dist/models/project.mjs +15 -11
- package/dist/models/project.mjs.map +1 -1
- package/dist/models/snapshot.d.mts +58 -0
- package/dist/models/snapshot.mjs +13 -7
- package/dist/models/snapshot.mjs.map +1 -1
- package/dist/models/status-config.d.mts +38 -0
- package/dist/models/status-config.mjs +12 -11
- package/dist/models/status-config.mjs.map +1 -1
- package/dist/models/token.d.mts +44 -0
- package/dist/models/token.mjs +18 -15
- package/dist/models/token.mjs.map +1 -1
- package/dist/models/user.d.mts +42 -0
- package/dist/models/user.mjs +65 -0
- package/dist/models/user.mjs.map +1 -0
- package/dist/models/webhook.d.mts +50 -0
- package/dist/models/webhook.mjs +12 -10
- package/dist/models/webhook.mjs.map +1 -1
- package/dist/models.d.mts +22 -0
- package/dist/models.mjs +23 -0
- package/dist/paths.mjs +29 -0
- package/dist/paths.mjs.map +1 -1
- package/dist/retention.d.mts +45 -0
- package/dist/retention.mjs +145 -0
- package/dist/retention.mjs.map +1 -0
- package/dist/schema/baseline.d.mts +1 -0
- package/dist/schema/build.d.mts +8 -0
- package/dist/schema/capture-attempt.d.mts +38 -0
- package/dist/schema/comment.d.mts +1 -1
- package/dist/schema/content-ref.d.mts +13 -0
- package/dist/schema/member.d.mts +2 -0
- package/dist/schema/notification.d.mts +35 -0
- package/dist/schema/project-group-mapping.d.mts +15 -0
- package/dist/schema/project.d.mts +4 -0
- package/dist/schema/snapshot.d.mts +7 -0
- package/dist/schema/token.d.mts +2 -0
- package/dist/schema/user.d.mts +11 -1
- package/dist/schema.d.mts +9 -46
- package/dist/schema.mjs +1 -55
- package/dist/test-helpers/create-project.d.mts +15 -0
- package/dist/test-helpers/create-project.mjs +28 -0
- package/dist/test-helpers/create-project.mjs.map +1 -0
- package/dist/test-helpers/database-contract.d.mts +13 -0
- package/dist/test-helpers/database-contract.mjs +58 -0
- package/dist/test-helpers/database-contract.mjs.map +1 -0
- package/dist/test-helpers/fake-adapters.d.mts +14 -0
- package/dist/test-helpers/fake-adapters.mjs +20 -0
- package/dist/test-helpers/fake-adapters.mjs.map +1 -0
- package/dist/test-helpers/fake-database.d.mts +11 -0
- package/dist/test-helpers/fake-database.mjs +104 -0
- package/dist/test-helpers/fake-database.mjs.map +1 -0
- package/dist/test-helpers/fake-storage.d.mts +14 -0
- package/dist/test-helpers/fake-storage.mjs +49 -0
- package/dist/test-helpers/fake-storage.mjs.map +1 -0
- package/dist/test-helpers/fake-tables.mjs +276 -0
- package/dist/test-helpers/fake-tables.mjs.map +1 -0
- package/dist/test-helpers/git-contract.d.mts +11 -0
- package/dist/test-helpers/git-contract.mjs +34 -0
- package/dist/test-helpers/git-contract.mjs.map +1 -0
- package/dist/test-helpers/notifier-contract.d.mts +11 -0
- package/dist/test-helpers/notifier-contract.mjs +34 -0
- package/dist/test-helpers/notifier-contract.mjs.map +1 -0
- package/dist/test-helpers/queue-contract.d.mts +11 -0
- package/dist/test-helpers/queue-contract.mjs +64 -0
- package/dist/test-helpers/queue-contract.mjs.map +1 -0
- package/dist/test-helpers/runner-contract.d.mts +11 -0
- package/dist/test-helpers/runner-contract.mjs +32 -0
- package/dist/test-helpers/runner-contract.mjs.map +1 -0
- package/dist/test-helpers/sql-chunks.mjs +138 -0
- package/dist/test-helpers/sql-chunks.mjs.map +1 -0
- package/dist/test-helpers/storage-contract.d.mts +11 -0
- package/dist/test-helpers/storage-contract.mjs +87 -0
- package/dist/test-helpers/storage-contract.mjs.map +1 -0
- package/dist/test-helpers.d.mts +13 -0
- package/dist/test-helpers.mjs +14 -0
- package/dist/tracing.mjs +63 -0
- package/dist/tracing.mjs.map +1 -0
- package/dist/types.d.mts +38 -5
- package/dist/types.mjs +18 -5
- package/dist/types.mjs.map +1 -1
- package/dist/urls.d.mts +12 -1
- package/dist/urls.mjs +22 -3
- package/dist/urls.mjs.map +1 -1
- package/dist/utils/encrypt.d.mts +48 -0
- package/dist/utils/encrypt.mjs +67 -13
- package/dist/utils/encrypt.mjs.map +1 -1
- package/dist/utils/hash.d.mts +17 -0
- package/dist/utils/hash.mjs +8 -2
- package/dist/utils/hash.mjs.map +1 -1
- package/dist/utils/http.d.mts +58 -0
- package/dist/utils/http.mjs +147 -0
- package/dist/utils/http.mjs.map +1 -0
- package/dist/utils/mime.d.mts +9 -0
- package/dist/utils/mime.mjs +33 -0
- package/dist/utils/mime.mjs.map +1 -0
- package/dist/utils/path-security.d.mts +12 -0
- package/dist/utils/path-security.mjs +24 -0
- package/dist/utils/path-security.mjs.map +1 -0
- package/dist/utils/redact.d.mts +17 -0
- package/dist/utils/redact.mjs +42 -0
- package/dist/utils/redact.mjs.map +1 -0
- package/dist/utils/timing.d.mts +19 -0
- package/dist/utils/timing.mjs +46 -0
- package/dist/utils/timing.mjs.map +1 -0
- package/dist/utils/ulid.d.mts +10 -0
- package/dist/utils/ulid.mjs +8 -8
- package/dist/utils/ulid.mjs.map +1 -1
- package/dist/utils.d.mts +12 -0
- package/dist/utils.mjs +13 -0
- package/package.json +69 -28
- package/dist/adapter/auth.d.mts +0 -50
- package/dist/adapter/auth.mjs +0 -10
- package/dist/adapter/auth.mjs.map +0 -1
- package/dist/adapter/init.d.mts +0 -58
- package/dist/adapter/init.mjs +0 -93
- package/dist/adapter/init.mjs.map +0 -1
- package/dist/adapters/webhook-events.mjs +0 -55
- package/dist/adapters/webhook-events.mjs.map +0 -1
- package/dist/ddl.d.mts +0 -8
- package/dist/ddl.mjs +0 -158
- package/dist/ddl.mjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/middleware/auth-gate.mjs +0 -36
- package/dist/middleware/auth-gate.mjs.map +0 -1
- package/dist/middleware/csrf.mjs +0 -40
- package/dist/middleware/csrf.mjs.map +0 -1
- package/dist/middleware/rate-limit.mjs +0 -39
- package/dist/middleware/rate-limit.mjs.map +0 -1
- package/dist/middleware/request-log.mjs +0 -28
- package/dist/middleware/request-log.mjs.map +0 -1
- package/dist/middleware/store-scope.mjs +0 -29
- package/dist/middleware/store-scope.mjs.map +0 -1
- package/dist/pages/build-detail.mjs +0 -339
- package/dist/pages/build-detail.mjs.map +0 -1
- package/dist/pages/build-diff-comments.mjs +0 -113
- package/dist/pages/build-diff-comments.mjs.map +0 -1
- package/dist/pages/build-diff-header.mjs +0 -163
- package/dist/pages/build-diff-header.mjs.map +0 -1
- package/dist/pages/build-diff-nav.mjs +0 -66
- package/dist/pages/build-diff-nav.mjs.map +0 -1
- package/dist/pages/build-diff-viewer.mjs +0 -197
- package/dist/pages/build-diff-viewer.mjs.map +0 -1
- package/dist/pages/build-diff.mjs +0 -90
- package/dist/pages/build-diff.mjs.map +0 -1
- package/dist/pages/compute-jobs.mjs +0 -198
- package/dist/pages/compute-jobs.mjs.map +0 -1
- package/dist/pages/label-detail.mjs +0 -228
- package/dist/pages/label-detail.mjs.map +0 -1
- package/dist/pages/login.mjs +0 -78
- package/dist/pages/login.mjs.map +0 -1
- package/dist/pages/project-builds.mjs +0 -231
- package/dist/pages/project-builds.mjs.map +0 -1
- package/dist/pages/project-create.mjs +0 -169
- package/dist/pages/project-create.mjs.map +0 -1
- package/dist/pages/project-settings.mjs +0 -102
- package/dist/pages/project-settings.mjs.map +0 -1
- package/dist/pages/projects.mjs +0 -184
- package/dist/pages/projects.mjs.map +0 -1
- package/dist/pages/root.mjs +0 -94
- package/dist/pages/root.mjs.map +0 -1
- package/dist/pages/settings-general.mjs +0 -192
- package/dist/pages/settings-general.mjs.map +0 -1
- package/dist/pages/settings-labels.mjs +0 -150
- package/dist/pages/settings-labels.mjs.map +0 -1
- package/dist/pages/settings-members.mjs +0 -125
- package/dist/pages/settings-members.mjs.map +0 -1
- package/dist/pages/settings-status.mjs +0 -152
- package/dist/pages/settings-status.mjs.map +0 -1
- package/dist/pages/settings-tests.mjs +0 -149
- package/dist/pages/settings-tests.mjs.map +0 -1
- package/dist/pages/settings-tokens.mjs +0 -96
- package/dist/pages/settings-tokens.mjs.map +0 -1
- package/dist/pages/settings-webhooks.mjs +0 -150
- package/dist/pages/settings-webhooks.mjs.map +0 -1
- package/dist/pages/storybook.mjs +0 -96
- package/dist/pages/storybook.mjs.map +0 -1
- package/dist/retention/purge.mjs +0 -70
- package/dist/retention/purge.mjs.map +0 -1
- package/dist/routers/admin.mjs +0 -42
- package/dist/routers/admin.mjs.map +0 -1
- package/dist/routers/assets.mjs +0 -25
- package/dist/routers/assets.mjs.map +0 -1
- package/dist/routers/auth.mjs +0 -78
- package/dist/routers/auth.mjs.map +0 -1
- package/dist/routers/builds.handlers.mjs +0 -184
- package/dist/routers/builds.handlers.mjs.map +0 -1
- package/dist/routers/builds.mjs +0 -185
- package/dist/routers/builds.mjs.map +0 -1
- package/dist/routers/comments.mjs +0 -90
- package/dist/routers/comments.mjs.map +0 -1
- package/dist/routers/health.mjs +0 -137
- package/dist/routers/health.mjs.map +0 -1
- package/dist/routers/helpers.mjs +0 -82
- package/dist/routers/helpers.mjs.map +0 -1
- package/dist/routers/htmx.mjs +0 -20
- package/dist/routers/htmx.mjs.map +0 -1
- package/dist/routers/labels.mjs +0 -110
- package/dist/routers/labels.mjs.map +0 -1
- package/dist/routers/media.mjs +0 -55
- package/dist/routers/media.mjs.map +0 -1
- package/dist/routers/members.mjs +0 -102
- package/dist/routers/members.mjs.map +0 -1
- package/dist/routers/projects.mjs +0 -122
- package/dist/routers/projects.mjs.map +0 -1
- package/dist/routers/schemas.mjs +0 -224
- package/dist/routers/schemas.mjs.map +0 -1
- package/dist/routers/settings-general.mjs +0 -95
- package/dist/routers/settings-general.mjs.map +0 -1
- package/dist/routers/settings-labels.mjs +0 -49
- package/dist/routers/settings-labels.mjs.map +0 -1
- package/dist/routers/settings-members.mjs +0 -30
- package/dist/routers/settings-members.mjs.map +0 -1
- package/dist/routers/settings-status.mjs +0 -73
- package/dist/routers/settings-status.mjs.map +0 -1
- package/dist/routers/settings-tokens.mjs +0 -40
- package/dist/routers/settings-tokens.mjs.map +0 -1
- package/dist/routers/settings-webhooks.mjs +0 -63
- package/dist/routers/settings-webhooks.mjs.map +0 -1
- package/dist/routers/settings.handlers.mjs +0 -93
- package/dist/routers/settings.handlers.mjs.map +0 -1
- package/dist/routers/settings.mjs +0 -23
- package/dist/routers/settings.mjs.map +0 -1
- package/dist/routers/snapshots.mjs +0 -144
- package/dist/routers/snapshots.mjs.map +0 -1
- package/dist/routers/status-configs.mjs +0 -101
- package/dist/routers/status-configs.mjs.map +0 -1
- package/dist/routers/storybook.mjs +0 -120
- package/dist/routers/storybook.mjs.map +0 -1
- package/dist/routers/tokens.mjs +0 -81
- package/dist/routers/tokens.mjs.map +0 -1
- package/dist/routers/ui.mjs +0 -165
- package/dist/routers/ui.mjs.map +0 -1
- package/dist/routers/webhooks.mjs +0 -90
- package/dist/routers/webhooks.mjs.map +0 -1
- package/dist/schema/baseline.mjs +0 -22
- package/dist/schema/baseline.mjs.map +0 -1
- package/dist/schema/build.mjs +0 -29
- package/dist/schema/build.mjs.map +0 -1
- package/dist/schema/comment.mjs +0 -26
- package/dist/schema/comment.mjs.map +0 -1
- package/dist/schema/label.mjs +0 -30
- package/dist/schema/label.mjs.map +0 -1
- package/dist/schema/member.mjs +0 -19
- package/dist/schema/member.mjs.map +0 -1
- package/dist/schema/project.mjs +0 -25
- package/dist/schema/project.mjs.map +0 -1
- package/dist/schema/snapshot.mjs +0 -34
- package/dist/schema/snapshot.mjs.map +0 -1
- package/dist/schema/status-config.mjs +0 -20
- package/dist/schema/status-config.mjs.map +0 -1
- package/dist/schema/token.mjs +0 -19
- package/dist/schema/token.mjs.map +0 -1
- package/dist/schema/user.mjs +0 -19
- package/dist/schema/user.mjs.map +0 -1
- package/dist/schema/webhook.mjs +0 -20
- package/dist/schema/webhook.mjs.map +0 -1
- package/dist/schema.mjs.map +0 -1
- package/dist/store.mjs +0 -20
- package/dist/store.mjs.map +0 -1
- package/dist/ui/document.mjs +0 -348
- package/dist/ui/document.mjs.map +0 -1
- package/dist/ui/feedback.mjs +0 -24
- package/dist/ui/feedback.mjs.map +0 -1
- package/dist/ui/forms.mjs +0 -117
- package/dist/ui/forms.mjs.map +0 -1
- package/dist/ui/styles.mjs +0 -174
- package/dist/ui/styles.mjs.map +0 -1
- package/dist/ui/theme.mjs +0 -44
- package/dist/ui/theme.mjs.map +0 -1
package/dist/pages/storybook.mjs
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import "node:path";
|
|
2
|
-
import "node:url";
|
|
3
|
-
import.meta.url;
|
|
4
|
-
import { getStore } from "../store.mjs";
|
|
5
|
-
//#region src/pages/storybook.tsx
|
|
6
|
-
/**
|
|
7
|
-
* Placeholder shown while a build's statics are not yet available.
|
|
8
|
-
* Refreshes itself; once extraction lands, the route serves the live page.
|
|
9
|
-
*/
|
|
10
|
-
function renderStorybookPreparingPage(project, build, slug) {
|
|
11
|
-
const { ui } = getStore();
|
|
12
|
-
const name = ui.name ?? "StoryShelf";
|
|
13
|
-
return `<!doctype html>
|
|
14
|
-
<html lang="en" data-theme="system">
|
|
15
|
-
<head>
|
|
16
|
-
<meta charset="utf-8" />
|
|
17
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
18
|
-
<meta http-equiv="refresh" content="5" />
|
|
19
|
-
<title>${escapeHtml(project.name)} Storybook · ${name}</title>
|
|
20
|
-
<style>
|
|
21
|
-
html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }
|
|
22
|
-
header { display: flex; align-items: center; gap: .75rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); }
|
|
23
|
-
header a { color: inherit; text-decoration: none; }
|
|
24
|
-
header .crumb { opacity: .7; }
|
|
25
|
-
main { flex: 1; display: flex; align-items: center; justify-content: center; opacity: .7; }
|
|
26
|
-
</style>
|
|
27
|
-
</head>
|
|
28
|
-
<body>
|
|
29
|
-
<header>
|
|
30
|
-
<a href="/">${name}</a>
|
|
31
|
-
<span class="crumb">/</span>
|
|
32
|
-
<a href="/projects/${escapeAttr(slug)}/builds">${escapeHtml(project.name)}</a>
|
|
33
|
-
<span class="crumb">/</span>
|
|
34
|
-
<span>${escapeHtml(build.gitBranch)}</span>
|
|
35
|
-
<span class="crumb">· ${build.gitSha.slice(0, 7)}</span>
|
|
36
|
-
</header>
|
|
37
|
-
<main>
|
|
38
|
-
<p>Preparing preview — this page refreshes automatically. <a href="/projects/${escapeAttr(slug)}/builds/${escapeAttr(build.id)}">View build status</a></p>
|
|
39
|
-
</main>
|
|
40
|
-
</body>
|
|
41
|
-
</html>`;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Landing page for a published Storybook build. Presents the live Storybook
|
|
45
|
-
* (via an iframe against the same build's statics) with the project chrome, so
|
|
46
|
-
* the URL is shareable with designers/managers (ADR 0011).
|
|
47
|
-
*/
|
|
48
|
-
function renderStorybookPage(project, build, slug) {
|
|
49
|
-
const { ui } = getStore();
|
|
50
|
-
const name = ui.name ?? "StoryShelf";
|
|
51
|
-
return `<!doctype html>
|
|
52
|
-
<html lang="en" data-theme="system">
|
|
53
|
-
<head>
|
|
54
|
-
<meta charset="utf-8" />
|
|
55
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
56
|
-
<title>${escapeHtml(project.name)} Storybook · ${name}</title>
|
|
57
|
-
<style>
|
|
58
|
-
html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }
|
|
59
|
-
header { display: flex; align-items: center; gap: .75rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); }
|
|
60
|
-
header a { color: inherit; text-decoration: none; }
|
|
61
|
-
header .crumb { opacity: .7; }
|
|
62
|
-
header .branch { font-weight: 600; }
|
|
63
|
-
iframe { flex: 1; border: 0; width: 100%; }
|
|
64
|
-
</style>
|
|
65
|
-
</head>
|
|
66
|
-
<body>
|
|
67
|
-
<header>
|
|
68
|
-
<a href="/">${name}</a>
|
|
69
|
-
<span class="crumb">/</span>
|
|
70
|
-
<a href="/projects/${escapeAttr(slug)}/builds">${escapeHtml(project.name)}</a>
|
|
71
|
-
<span class="crumb">/</span>
|
|
72
|
-
<span class="branch">${escapeHtml(build.gitBranch)}</span>
|
|
73
|
-
<span class="crumb">· ${build.gitSha.slice(0, 7)}</span>
|
|
74
|
-
</header>
|
|
75
|
-
<iframe src="${escapeAttr(`./iframe.html`)}" title="${escapeAttr(project.name)} Storybook"></iframe>
|
|
76
|
-
</body>
|
|
77
|
-
</html>`;
|
|
78
|
-
}
|
|
79
|
-
function escapeHtml(value) {
|
|
80
|
-
return value.replaceAll(/[&<>"']/gu, (ch) => {
|
|
81
|
-
switch (ch) {
|
|
82
|
-
case "&": return "&";
|
|
83
|
-
case "<": return "<";
|
|
84
|
-
case ">": return ">";
|
|
85
|
-
case "\"": return """;
|
|
86
|
-
default: return "'";
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
function escapeAttr(value) {
|
|
91
|
-
return escapeHtml(value);
|
|
92
|
-
}
|
|
93
|
-
//#endregion
|
|
94
|
-
export { renderStorybookPage, renderStorybookPreparingPage };
|
|
95
|
-
|
|
96
|
-
//# sourceMappingURL=storybook.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/pages/storybook.tsx"],"sourcesContent":["import type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { getStore } from \"../store.ts\";\n\n/**\n * Placeholder shown while a build's statics are not yet available.\n * Refreshes itself; once extraction lands, the route serves the live page.\n */\nexport function renderStorybookPreparingPage(project: Project, build: Build, slug: string): string {\n const { ui } = getStore();\n const name = ui.name ?? \"StoryShelf\";\n return `<!doctype html>\n<html lang=\"en\" data-theme=\"system\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta http-equiv=\"refresh\" content=\"5\" />\n <title>${escapeHtml(project.name)} Storybook · ${name}</title>\n <style>\n html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }\n header { display: flex; align-items: center; gap: .75rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); }\n header a { color: inherit; text-decoration: none; }\n header .crumb { opacity: .7; }\n main { flex: 1; display: flex; align-items: center; justify-content: center; opacity: .7; }\n </style>\n </head>\n <body>\n <header>\n <a href=\"/\">${name}</a>\n <span class=\"crumb\">/</span>\n <a href=\"/projects/${escapeAttr(slug)}/builds\">${escapeHtml(project.name)}</a>\n <span class=\"crumb\">/</span>\n <span>${escapeHtml(build.gitBranch)}</span>\n <span class=\"crumb\">· ${build.gitSha.slice(0, 7)}</span>\n </header>\n <main>\n <p>Preparing preview — this page refreshes automatically. <a href=\"/projects/${escapeAttr(slug)}/builds/${escapeAttr(build.id)}\">View build status</a></p>\n </main>\n </body>\n</html>`;\n}\n/**\n * Landing page for a published Storybook build. Presents the live Storybook\n * (via an iframe against the same build's statics) with the project chrome, so\n * the URL is shareable with designers/managers (ADR 0011).\n */\nexport function renderStorybookPage(project: Project, build: Build, slug: string): string {\n const { ui } = getStore();\n const name = ui.name ?? \"StoryShelf\";\n const iframeSrc = `./iframe.html`;\n return `<!doctype html>\n<html lang=\"en\" data-theme=\"system\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>${escapeHtml(project.name)} Storybook · ${name}</title>\n <style>\n html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }\n header { display: flex; align-items: center; gap: .75rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); }\n header a { color: inherit; text-decoration: none; }\n header .crumb { opacity: .7; }\n header .branch { font-weight: 600; }\n iframe { flex: 1; border: 0; width: 100%; }\n </style>\n </head>\n <body>\n <header>\n <a href=\"/\">${name}</a>\n <span class=\"crumb\">/</span>\n <a href=\"/projects/${escapeAttr(slug)}/builds\">${escapeHtml(project.name)}</a>\n <span class=\"crumb\">/</span>\n <span class=\"branch\">${escapeHtml(build.gitBranch)}</span>\n <span class=\"crumb\">· ${build.gitSha.slice(0, 7)}</span>\n </header>\n <iframe src=\"${escapeAttr(iframeSrc)}\" title=\"${escapeAttr(project.name)} Storybook\"></iframe>\n </body>\n</html>`;\n}\n\nfunction escapeHtml(value: string): string {\n return value.replaceAll(/[&<>\"']/gu, (ch) => {\n switch (ch) {\n case \"&\": {\n return \"&\";\n }\n case \"<\": {\n return \"<\";\n }\n case \">\": {\n return \">\";\n }\n case '\"': {\n return \""\";\n }\n default: {\n return \"'\";\n }\n }\n });\n}\n\nfunction escapeAttr(value: string): string {\n return escapeHtml(value);\n}\n"],"mappings":";;;;;;;;;AAQA,SAAgB,6BAA6B,SAAkB,OAAc,MAAsB;CACjG,MAAM,EAAE,OAAO,SAAS;CACxB,MAAM,OAAO,GAAG,QAAQ;CACxB,OAAO;;;;;;aAMI,WAAW,QAAQ,IAAI,EAAE,eAAe,KAAK;;;;;;;;;;;oBAWtC,KAAK;;2BAEE,WAAW,IAAI,EAAE,WAAW,WAAW,QAAQ,IAAI,EAAE;;cAElE,WAAW,MAAM,SAAS,EAAE;8BACZ,MAAM,OAAO,MAAM,GAAG,CAAC,EAAE;;;qFAG8B,WAAW,IAAI,EAAE,UAAU,WAAW,MAAM,EAAE,EAAE;;;;AAIrI;;;;;;AAMA,SAAgB,oBAAoB,SAAkB,OAAc,MAAsB;CACxF,MAAM,EAAE,OAAO,SAAS;CACxB,MAAM,OAAO,GAAG,QAAQ;CAExB,OAAO;;;;;aAKI,WAAW,QAAQ,IAAI,EAAE,eAAe,KAAK;;;;;;;;;;;;oBAYtC,KAAK;;2BAEE,WAAW,IAAI,EAAE,WAAW,WAAW,QAAQ,IAAI,EAAE;;6BAEnD,WAAW,MAAM,SAAS,EAAE;8BAC3B,MAAM,OAAO,MAAM,GAAG,CAAC,EAAE;;mBAEpC,WAAW,eAAS,EAAE,WAAW,WAAW,QAAQ,IAAI,EAAE;;;AAG7E;AAEA,SAAS,WAAW,OAAuB;CACzC,OAAO,MAAM,WAAW,cAAc,OAAO;EAC3C,QAAQ,IAAR;GACE,KAAK,KACH,OAAO;GAET,KAAK,KACH,OAAO;GAET,KAAK,KACH,OAAO;GAET,KAAK,MACH,OAAO;GAET,SACE,OAAO;EAEX;CACF,CAAC;AACH;AAEA,SAAS,WAAW,OAAuB;CACzC,OAAO,WAAW,KAAK;AACzB"}
|
package/dist/retention/purge.mjs
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import "node:path";
|
|
2
|
-
import "node:url";
|
|
3
|
-
import.meta.url;
|
|
4
|
-
import { builds } from "../schema/build.mjs";
|
|
5
|
-
import { BuildModel } from "../models/build.mjs";
|
|
6
|
-
import { TERMINAL_BUILD_STATUSES } from "../types.mjs";
|
|
7
|
-
import { LabelModel } from "../models/label.mjs";
|
|
8
|
-
import { and, desc, eq, inArray, lt } from "drizzle-orm";
|
|
9
|
-
//#region src/retention/purge.ts
|
|
10
|
-
/** Removes expired transient builds while keeping baselines and persistent builds. */
|
|
11
|
-
var Retention = class {
|
|
12
|
-
db;
|
|
13
|
-
storage;
|
|
14
|
-
logger;
|
|
15
|
-
constructor(db, storage, logger) {
|
|
16
|
-
this.db = db;
|
|
17
|
-
this.storage = storage;
|
|
18
|
-
this.logger = logger;
|
|
19
|
-
}
|
|
20
|
-
async purge(project, options) {
|
|
21
|
-
const cutoff = (/* @__PURE__ */ new Date(Date.now() - options.ttlDays * 864e5)).toISOString();
|
|
22
|
-
const candidates = await this.db.list(builds, { where: and(eq(builds.projectId, project.id), inArray(builds.status, [...TERMINAL_BUILD_STATUSES]), lt(builds.updatedAt, cutoff)) });
|
|
23
|
-
const keep = options.keepLatestPerBranch ? await this.latestPerBranch(project.id) : /* @__PURE__ */ new Set();
|
|
24
|
-
const labelModel = new LabelModel(this.db);
|
|
25
|
-
const buildIds = (await Promise.all(candidates.map(async (build) => {
|
|
26
|
-
if (keep.has(build.id) || await labelModel.hasPersistent(project.id, build.id)) return null;
|
|
27
|
-
return build.id;
|
|
28
|
-
}))).filter((id) => id !== null);
|
|
29
|
-
const results = await Promise.all(buildIds.map(async (buildId) => ({
|
|
30
|
-
files: await this.deleteBuildFiles(project.id, buildId),
|
|
31
|
-
removed: await this.removeBuild(buildId)
|
|
32
|
-
})));
|
|
33
|
-
const removedBuilds = results.filter((r) => r.removed).length;
|
|
34
|
-
const removedFiles = results.reduce((sum, r) => sum + r.files, 0);
|
|
35
|
-
this.logger?.info({
|
|
36
|
-
projectId: project.id,
|
|
37
|
-
removedBuilds,
|
|
38
|
-
removedFiles
|
|
39
|
-
}, "build retention purge complete");
|
|
40
|
-
return {
|
|
41
|
-
removedBuilds,
|
|
42
|
-
removedFiles
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
async removeBuild(buildId) {
|
|
46
|
-
await new BuildModel(this.db).remove(buildId);
|
|
47
|
-
return true;
|
|
48
|
-
}
|
|
49
|
-
async latestPerBranch(projectId) {
|
|
50
|
-
const rows = await this.db.list(builds, {
|
|
51
|
-
where: eq(builds.projectId, projectId),
|
|
52
|
-
orderBy: desc(builds.updatedAt)
|
|
53
|
-
});
|
|
54
|
-
const latest = /* @__PURE__ */ new Map();
|
|
55
|
-
for (const row of rows) if (!latest.get(row.gitBranch)) latest.set(row.gitBranch, row.id);
|
|
56
|
-
return new Set(latest.values());
|
|
57
|
-
}
|
|
58
|
-
async deleteBuildFiles(projectId, buildId) {
|
|
59
|
-
const prefix = `${projectId}/builds/${buildId}/`;
|
|
60
|
-
const files = await this.storage.list(prefix);
|
|
61
|
-
await Promise.all(files.map(async (file) => {
|
|
62
|
-
await this.storage.delete(file);
|
|
63
|
-
}));
|
|
64
|
-
return files.length;
|
|
65
|
-
}
|
|
66
|
-
};
|
|
67
|
-
//#endregion
|
|
68
|
-
export { Retention };
|
|
69
|
-
|
|
70
|
-
//# sourceMappingURL=purge.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"purge.mjs","names":[],"sources":["../../src/retention/purge.ts"],"sourcesContent":["import { and, desc, eq, inArray, lt } from \"drizzle-orm\";\nimport type { Logger } from \"pino\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { LabelModel } from \"../models/label.ts\";\nimport { builds } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { TERMINAL_BUILD_STATUSES } from \"../types.ts\";\n\n/** Options controlling which builds a retention purge removes. */\nexport interface PurgeOptions {\n ttlDays: number;\n keepLatestPerBranch: boolean;\n}\n\n/** Counts of builds and files removed by a retention purge. */\nexport interface PurgeResult {\n removedBuilds: number;\n removedFiles: number;\n}\n/** Removes expired transient builds while keeping baselines and persistent builds. */\nexport class Retention {\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly storage: StorageAdapter,\n private readonly logger?: Logger,\n ) {}\n\n async purge(project: Project, options: PurgeOptions): Promise<PurgeResult> {\n const cutoff = new Date(Date.now() - options.ttlDays * 86_400_000).toISOString();\n const candidates = await this.db.list(builds, {\n where: and(\n eq(builds.projectId, project.id),\n inArray(builds.status, [...TERMINAL_BUILD_STATUSES]),\n lt(builds.updatedAt, cutoff),\n ),\n });\n\n const keep = options.keepLatestPerBranch\n ? await this.latestPerBranch(project.id)\n : new Set<string>();\n const labelModel = new LabelModel(this.db);\n const target = await Promise.all(\n candidates.map(async (build) => {\n if (keep.has(build.id) || (await labelModel.hasPersistent(project.id, build.id))) {\n return null;\n }\n return build.id;\n }),\n );\n\n const buildIds = target.filter((id): id is string => id !== null);\n const results = await Promise.all(\n buildIds.map(async (buildId) => ({\n files: await this.deleteBuildFiles(project.id, buildId),\n removed: await this.removeBuild(buildId),\n })),\n );\n const removedBuilds = results.filter((r) => r.removed).length;\n const removedFiles = results.reduce((sum, r) => sum + r.files, 0);\n this.logger?.info(\n { projectId: project.id, removedBuilds, removedFiles },\n \"build retention purge complete\",\n );\n return { removedBuilds, removedFiles };\n }\n\n private async removeBuild(buildId: string): Promise<boolean> {\n await new BuildModel(this.db).remove(buildId);\n return true;\n }\n\n private async latestPerBranch(projectId: string): Promise<Set<string>> {\n const rows = await this.db.list(builds, {\n where: eq(builds.projectId, projectId),\n orderBy: desc(builds.updatedAt),\n });\n const latest = new Map<string, string>();\n for (const row of rows) {\n const current = latest.get(row.gitBranch);\n if (!current) {\n latest.set(row.gitBranch, row.id);\n }\n }\n return new Set(latest.values());\n }\n\n private async deleteBuildFiles(projectId: string, buildId: string): Promise<number> {\n const prefix = `${projectId}/builds/${buildId}/`;\n const files = await this.storage.list(prefix);\n await Promise.all(\n files.map(async (file) => {\n await this.storage.delete(file);\n }),\n );\n return files.length;\n }\n}\n"],"mappings":";;;;;;;;;;AAsBA,IAAa,YAAb,MAAuB;CAEF;CACA;CACA;CAHnB,YACE,IACA,SACA,QACA;EAHiB,KAAA,KAAA;EACA,KAAA,UAAA;EACA,KAAA,SAAA;CAChB;CAEH,MAAM,MAAM,SAAkB,SAA6C;EACzE,MAAM,0BAAS,IAAI,KAAK,KAAK,IAAI,IAAI,QAAQ,UAAU,KAAU,EAAA,CAAE,YAAY;EAC/E,MAAM,aAAa,MAAM,KAAK,GAAG,KAAK,QAAQ,EAC5C,OAAO,IACL,GAAG,OAAO,WAAW,QAAQ,EAAE,GAC/B,QAAQ,OAAO,QAAQ,CAAC,GAAG,uBAAuB,CAAC,GACnD,GAAG,OAAO,WAAW,MAAM,CAC7B,EACF,CAAC;EAED,MAAM,OAAO,QAAQ,sBACjB,MAAM,KAAK,gBAAgB,QAAQ,EAAE,oBACrC,IAAI,IAAY;EACpB,MAAM,aAAa,IAAI,WAAW,KAAK,EAAE;EAUzC,MAAM,YAAW,MATI,QAAQ,IAC3B,WAAW,IAAI,OAAO,UAAU;GAC9B,IAAI,KAAK,IAAI,MAAM,EAAE,KAAM,MAAM,WAAW,cAAc,QAAQ,IAAI,MAAM,EAAE,GAC5E,OAAO;GAET,OAAO,MAAM;EACf,CAAC,CACH,EAAA,CAEwB,QAAQ,OAAqB,OAAO,IAAI;EAChE,MAAM,UAAU,MAAM,QAAQ,IAC5B,SAAS,IAAI,OAAO,aAAa;GAC/B,OAAO,MAAM,KAAK,iBAAiB,QAAQ,IAAI,OAAO;GACtD,SAAS,MAAM,KAAK,YAAY,OAAO;EACzC,EAAE,CACJ;EACA,MAAM,gBAAgB,QAAQ,QAAQ,MAAM,EAAE,OAAO,CAAC,CAAC;EACvD,MAAM,eAAe,QAAQ,QAAQ,KAAK,MAAM,MAAM,EAAE,OAAO,CAAC;EAChE,KAAK,QAAQ,KACX;GAAE,WAAW,QAAQ;GAAI;GAAe;EAAa,GACrD,gCACF;EACA,OAAO;GAAE;GAAe;EAAa;CACvC;CAEA,MAAc,YAAY,SAAmC;EAC3D,MAAM,IAAI,WAAW,KAAK,EAAE,CAAC,CAAC,OAAO,OAAO;EAC5C,OAAO;CACT;CAEA,MAAc,gBAAgB,WAAyC;EACrE,MAAM,OAAO,MAAM,KAAK,GAAG,KAAK,QAAQ;GACtC,OAAO,GAAG,OAAO,WAAW,SAAS;GACrC,SAAS,KAAK,OAAO,SAAS;EAChC,CAAC;EACD,MAAM,yBAAS,IAAI,IAAoB;EACvC,KAAK,MAAM,OAAO,MAEhB,IAAI,CADY,OAAO,IAAI,IAAI,SACpB,GACT,OAAO,IAAI,IAAI,WAAW,IAAI,EAAE;EAGpC,OAAO,IAAI,IAAI,OAAO,OAAO,CAAC;CAChC;CAEA,MAAc,iBAAiB,WAAmB,SAAkC;EAClF,MAAM,SAAS,GAAG,UAAU,UAAU,QAAQ;EAC9C,MAAM,QAAQ,MAAM,KAAK,QAAQ,KAAK,MAAM;EAC5C,MAAM,QAAQ,IACZ,MAAM,IAAI,OAAO,SAAS;GACxB,MAAM,KAAK,QAAQ,OAAO,IAAI;EAChC,CAAC,CACH;EACA,OAAO,MAAM;CACf;AACF"}
|
package/dist/routers/admin.mjs
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import "node:path";
|
|
2
|
-
import "node:url";
|
|
3
|
-
import.meta.url;
|
|
4
|
-
import { ProjectModel } from "../models/project.mjs";
|
|
5
|
-
import { getStore } from "../store.mjs";
|
|
6
|
-
import { Retention } from "../retention/purge.mjs";
|
|
7
|
-
import { requireSiteAdmin } from "./helpers.mjs";
|
|
8
|
-
import { forbidden, purgeInputSchema, purgeSchema } from "./schemas.mjs";
|
|
9
|
-
import { createRoute } from "@hono/zod-openapi";
|
|
10
|
-
//#region src/routers/admin.ts
|
|
11
|
-
const purgeRoute = createRoute({
|
|
12
|
-
method: "post",
|
|
13
|
-
path: "/api/v1/admin/purge",
|
|
14
|
-
request: { body: { content: { "application/json": { schema: purgeInputSchema } } } },
|
|
15
|
-
responses: {
|
|
16
|
-
200: {
|
|
17
|
-
content: { "application/json": { schema: purgeSchema } },
|
|
18
|
-
description: "Retention purge run"
|
|
19
|
-
},
|
|
20
|
-
...forbidden
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
/** Register the site-admin retention purge endpoint. */
|
|
24
|
-
function registerAdmin(app) {
|
|
25
|
-
app.openapi(purgeRoute, async (c) => {
|
|
26
|
-
requireSiteAdmin();
|
|
27
|
-
const ttlDays = c.req.valid("json").ttlDays ?? getStore().config.purgeTtlDays ?? 30;
|
|
28
|
-
const projects = await new ProjectModel(getStore().db).list();
|
|
29
|
-
const retention = new Retention(getStore().db, getStore().storage);
|
|
30
|
-
const removedBuilds = (await Promise.all(projects.map(async (project) => {
|
|
31
|
-
return await retention.purge(project, {
|
|
32
|
-
ttlDays,
|
|
33
|
-
keepLatestPerBranch: true
|
|
34
|
-
});
|
|
35
|
-
}))).reduce((sum, result) => sum + result.removedBuilds, 0);
|
|
36
|
-
return c.json({ removedBuilds });
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
//#endregion
|
|
40
|
-
export { registerAdmin };
|
|
41
|
-
|
|
42
|
-
//# sourceMappingURL=admin.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"admin.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/admin.ts"],"sourcesContent":["import { createRoute } from \"@hono/zod-openapi\";\nimport type { ShelfApp } from \"../index.tsx\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { Retention } from \"../retention/purge.ts\";\nimport { getStore } from \"../store.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { forbidden as forbiddenResponse, purgeInputSchema, purgeSchema } from \"./schemas.ts\";\n\nconst purgeRoute = createRoute({\n method: \"post\",\n path: \"/api/v1/admin/purge\",\n request: { body: { content: { \"application/json\": { schema: purgeInputSchema } } } },\n responses: {\n 200: {\n content: { \"application/json\": { schema: purgeSchema } },\n description: \"Retention purge run\",\n },\n ...forbiddenResponse,\n },\n});\n\n/** Register the site-admin retention purge endpoint. */\nexport function registerAdmin(app: ShelfApp): void {\n app.openapi(purgeRoute, async (c) => {\n requireSiteAdmin();\n const body = c.req.valid(\"json\");\n const ttlDays = body.ttlDays ?? getStore().config.purgeTtlDays ?? 30;\n const projects = await new ProjectModel(getStore().db).list();\n const retention = new Retention(getStore().db, getStore().storage);\n const results = await Promise.all(\n projects.map(async (project) => {\n return await retention.purge(project, { ttlDays, keepLatestPerBranch: true });\n }),\n );\n const removedBuilds = results.reduce((sum, result) => sum + result.removedBuilds, 0);\n return c.json({ removedBuilds });\n });\n}\n"],"mappings":";;;;;;;;;;AAQA,MAAM,aAAa,YAAY;CAC7B,QAAQ;CACR,MAAM;CACN,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,oBAAoB,EAAE,QAAQ,iBAAiB,EAAE,EAAE,EAAE;CACnF,WAAW;EACT,KAAK;GACH,SAAS,EAAE,oBAAoB,EAAE,QAAQ,YAAY,EAAE;GACvD,aAAa;EACf;EACA,GAAGA;CACL;AACF,CAAC;;AAGD,SAAgB,cAAc,KAAqB;CACjD,IAAI,QAAQ,YAAY,OAAO,MAAM;EACnC,iBAAiB;EAEjB,MAAM,UADO,EAAE,IAAI,MAAM,MACN,CAAC,CAAC,WAAW,SAAS,CAAC,CAAC,OAAO,gBAAgB;EAClE,MAAM,WAAW,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK;EAC5D,MAAM,YAAY,IAAI,UAAU,SAAS,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,OAAO;EAMjE,MAAM,iBAAgB,MALA,QAAQ,IAC5B,SAAS,IAAI,OAAO,YAAY;GAC9B,OAAO,MAAM,UAAU,MAAM,SAAS;IAAE;IAAS,qBAAqB;GAAK,CAAC;EAC9E,CAAC,CACH,EAAA,CAC8B,QAAQ,KAAK,WAAW,MAAM,OAAO,eAAe,CAAC;EACnF,OAAO,EAAE,KAAK,EAAE,cAAc,CAAC;CACjC,CAAC;AACH"}
|
package/dist/routers/assets.mjs
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import "node:path";
|
|
2
|
-
import "node:url";
|
|
3
|
-
import.meta.url;
|
|
4
|
-
import { readFile } from "node:fs/promises";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
//#region src/routers/assets.ts
|
|
7
|
-
let htmxSource = null;
|
|
8
|
-
async function htmxScript() {
|
|
9
|
-
htmxSource ??= await readFile(fileURLToPath(new URL("../assets/htmx.min.js", import.meta.url)), "utf8");
|
|
10
|
-
return htmxSource;
|
|
11
|
-
}
|
|
12
|
-
/** Register the vendored static-asset routes (HTMX bundle). */
|
|
13
|
-
function registerAssets(app) {
|
|
14
|
-
app.get("/assets/htmx.js", async (c) => {
|
|
15
|
-
const body = await htmxScript();
|
|
16
|
-
return c.body(body, 200, {
|
|
17
|
-
"content-type": "application/javascript",
|
|
18
|
-
"cache-control": "public, max-age=31536000, immutable"
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
//#endregion
|
|
23
|
-
export { registerAssets };
|
|
24
|
-
|
|
25
|
-
//# sourceMappingURL=assets.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"assets.mjs","names":[],"sources":["../../src/routers/assets.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { fileURLToPath } from \"node:url\";\nimport type { ShelfApp } from \"../index.tsx\";\n\nlet htmxSource: string | null = null;\n\nasync function htmxScript(): Promise<string> {\n htmxSource ??= await readFile(\n fileURLToPath(new URL(\"../assets/htmx.min.js\", import.meta.url)),\n \"utf8\",\n );\n return htmxSource;\n}\n\n/** Register the vendored static-asset routes (HTMX bundle). */\nexport function registerAssets(app: ShelfApp): void {\n app.get(\"/assets/htmx.js\", async (c) => {\n const body = await htmxScript();\n return c.body(body, 200, {\n \"content-type\": \"application/javascript\",\n \"cache-control\": \"public, max-age=31536000, immutable\",\n });\n });\n}\n"],"mappings":";;;;;;AAIA,IAAI,aAA4B;AAEhC,eAAe,aAA8B;CAC3C,eAAe,MAAM,SACnB,cAAc,IAAI,IAAI,yBAAyB,YAAY,GAAG,CAAC,GAC/D,MACF;CACA,OAAO;AACT;;AAGA,SAAgB,eAAe,KAAqB;CAClD,IAAI,IAAI,mBAAmB,OAAO,MAAM;EACtC,MAAM,OAAO,MAAM,WAAW;EAC9B,OAAO,EAAE,KAAK,MAAM,KAAK;GACvB,gBAAgB;GAChB,iBAAiB;EACnB,CAAC;CACH,CAAC;AACH"}
|
package/dist/routers/auth.mjs
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import "node:path";
|
|
2
|
-
import "node:url";
|
|
3
|
-
import.meta.url;
|
|
4
|
-
import { randomToken } from "../utils/hash.mjs";
|
|
5
|
-
import { SESSION_COOKIE } from "../adapter/auth.mjs";
|
|
6
|
-
import { renderLoginPage } from "../pages/login.mjs";
|
|
7
|
-
import { hxRedirect } from "./htmx.mjs";
|
|
8
|
-
import { HTTPException } from "hono/http-exception";
|
|
9
|
-
//#region src/routers/auth.ts
|
|
10
|
-
const OAUTH_STATE_COOKIE = "storyshelf_oauth_state";
|
|
11
|
-
const SESSION_TTL_SECONDS = 604800;
|
|
12
|
-
const SHARED_USER = {
|
|
13
|
-
id: "shared",
|
|
14
|
-
email: "admin@local",
|
|
15
|
-
name: "Admin",
|
|
16
|
-
role: "admin"
|
|
17
|
-
};
|
|
18
|
-
function sessionCookieHeader(token) {
|
|
19
|
-
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_SECONDS}`;
|
|
20
|
-
}
|
|
21
|
-
function clearSessionCookieHeader() {
|
|
22
|
-
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
23
|
-
}
|
|
24
|
-
function hasPasswordLogin(auth) {
|
|
25
|
-
return "login" in auth;
|
|
26
|
-
}
|
|
27
|
-
function hasSso(auth) {
|
|
28
|
-
return "loginUrl" in auth;
|
|
29
|
-
}
|
|
30
|
-
function buildSsoUrl(c, auth) {
|
|
31
|
-
const state = randomToken("shelf_").value;
|
|
32
|
-
c.header("set-cookie", `${OAUTH_STATE_COOKIE}=${state}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`);
|
|
33
|
-
return auth.loginUrl(state);
|
|
34
|
-
}
|
|
35
|
-
/** Register the password and SSO login, callback, and logout routes. */
|
|
36
|
-
function registerAuth(app, auth) {
|
|
37
|
-
app.get("/auth/login", async (c) => {
|
|
38
|
-
if (hasSso(auth) && !hasPasswordLogin(auth)) return c.redirect(buildSsoUrl(c, auth));
|
|
39
|
-
const ssoUrl = hasSso(auth) ? buildSsoUrl(c, auth) : void 0;
|
|
40
|
-
return c.html(await renderLoginPage({ ssoUrl }));
|
|
41
|
-
});
|
|
42
|
-
app.post("/auth/login", async (c) => {
|
|
43
|
-
if (!hasPasswordLogin(auth)) throw new HTTPException(404, { message: "Password login is not configured" });
|
|
44
|
-
const raw = (await c.req.formData()).get("password");
|
|
45
|
-
const password = typeof raw === "string" ? raw : "";
|
|
46
|
-
try {
|
|
47
|
-
const token = await auth.login(password, SHARED_USER);
|
|
48
|
-
c.header("set-cookie", sessionCookieHeader(token));
|
|
49
|
-
return hxRedirect(c, "/");
|
|
50
|
-
} catch {
|
|
51
|
-
return c.html(await renderLoginPage({ error: "Invalid password" }), 401);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
app.get("/auth/callback", async (c) => {
|
|
55
|
-
if (!auth.handleCallback) return c.notFound();
|
|
56
|
-
const code = c.req.query("code") ?? "";
|
|
57
|
-
const state = c.req.query("state") ?? "";
|
|
58
|
-
if (!code || !state) throw new HTTPException(400, { message: "Missing code or state" });
|
|
59
|
-
const user = await auth.handleCallback({
|
|
60
|
-
provider: "oidc",
|
|
61
|
-
code,
|
|
62
|
-
state
|
|
63
|
-
});
|
|
64
|
-
if (!user) return c.html(await renderLoginPage({ error: "SSO sign in failed" }), 401);
|
|
65
|
-
const token = await auth.createSession(user);
|
|
66
|
-
c.header("set-cookie", sessionCookieHeader(token));
|
|
67
|
-
return c.redirect("/");
|
|
68
|
-
});
|
|
69
|
-
app.post("/auth/logout", async (c) => {
|
|
70
|
-
await auth.destroySession("");
|
|
71
|
-
c.header("set-cookie", clearSessionCookieHeader());
|
|
72
|
-
return hxRedirect(c, "/auth/login");
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
//#endregion
|
|
76
|
-
export { registerAuth };
|
|
77
|
-
|
|
78
|
-
//# sourceMappingURL=auth.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
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"}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import "node:path";
|
|
2
|
-
import "node:url";
|
|
3
|
-
import.meta.url;
|
|
4
|
-
import { BuildModel } from "../models/build.mjs";
|
|
5
|
-
import { ProjectModel } from "../models/project.mjs";
|
|
6
|
-
import { emitWebhookEvent } from "../adapters/webhook-events.mjs";
|
|
7
|
-
import { BaselineModel } from "../models/baseline.mjs";
|
|
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";
|
|
14
|
-
import { z } from "@hono/zod-openapi";
|
|
15
|
-
import { HTTPException } from "hono/http-exception";
|
|
16
|
-
import { Readable, Transform } from "node:stream";
|
|
17
|
-
//#region src/routers/builds.handlers.ts
|
|
18
|
-
/** Roles permitted to view builds, snapshots, and comments. */
|
|
19
|
-
const VIEW_ROLES = [
|
|
20
|
-
"viewer",
|
|
21
|
-
"developer",
|
|
22
|
-
"approver",
|
|
23
|
-
"admin"
|
|
24
|
-
];
|
|
25
|
-
/** Roles permitted to upload builds and write comments. */
|
|
26
|
-
const DEVELOPER_ROLES = [
|
|
27
|
-
"developer",
|
|
28
|
-
"approver",
|
|
29
|
-
"admin"
|
|
30
|
-
];
|
|
31
|
-
/** Roles permitted to approve or reject snapshots and delete builds. */
|
|
32
|
-
const APPROVER_ROLES = ["approver", "admin"];
|
|
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),
|
|
37
|
-
authorEmail: z.string().optional(),
|
|
38
|
-
authorName: z.string().optional(),
|
|
39
|
-
message: z.string().optional(),
|
|
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. */
|
|
131
|
-
const buildListQuery = z.object({
|
|
132
|
-
status: z.enum(BUILD_STATUSES).optional(),
|
|
133
|
-
branch: z.string().optional(),
|
|
134
|
-
labelKey: z.string().optional(),
|
|
135
|
-
labelValue: z.string().optional()
|
|
136
|
-
});
|
|
137
|
-
/** Fetch a build scoped to its project, throwing 404 when it does not belong. */
|
|
138
|
-
async function buildForProject(projectId, buildId) {
|
|
139
|
-
const build = await new BuildModel(getStore().db).get(buildId);
|
|
140
|
-
if (!build || build.projectId !== projectId) notFound("Build not found");
|
|
141
|
-
return build;
|
|
142
|
-
}
|
|
143
|
-
/** Fetch a snapshot scoped to its build, throwing 404 when it does not belong. */
|
|
144
|
-
async function snapshotForBuild(build, snapshotId) {
|
|
145
|
-
const snapshot = await new SnapshotModel(getStore().db).get(snapshotId);
|
|
146
|
-
if (!snapshot || snapshot.buildId !== build.id) notFound("Snapshot not found");
|
|
147
|
-
return snapshot;
|
|
148
|
-
}
|
|
149
|
-
/** Recompute a build's counts and roll its status up from its snapshots. */
|
|
150
|
-
async function refreshBuild(buildId) {
|
|
151
|
-
const { db, config } = getStore();
|
|
152
|
-
await new BuildModel(db).updateCounts(buildId);
|
|
153
|
-
const snapshots = await new SnapshotModel(db).listByBuild(buildId);
|
|
154
|
-
const unresolved = snapshots.some((s) => s.status === "new" || s.status === "changed");
|
|
155
|
-
let status;
|
|
156
|
-
if (unresolved) status = "reviewing";
|
|
157
|
-
else status = snapshots.some((s) => s.status === "rejected") ? "rejected" : "approved";
|
|
158
|
-
const build = await new BuildModel(db).get(buildId);
|
|
159
|
-
if (build) {
|
|
160
|
-
await new BuildModel(db).setStatus(buildId, status);
|
|
161
|
-
await emitWebhookEvent(db, build.projectId, `build:${status}`, {
|
|
162
|
-
buildId,
|
|
163
|
-
status,
|
|
164
|
-
snapshotCount: snapshots.length
|
|
165
|
-
}, config.secret);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
/** Approve a snapshot, promote its screenshot to baseline, and refresh the build. */
|
|
169
|
-
async function approveSnapshot(snapshotId, userId) {
|
|
170
|
-
const { db, config } = getStore();
|
|
171
|
-
const snapshots = new SnapshotModel(db);
|
|
172
|
-
const snapshot = await snapshots.get(snapshotId);
|
|
173
|
-
if (!snapshot) notFound("Snapshot not found");
|
|
174
|
-
const project = await new ProjectModel(db).get(snapshot.projectId);
|
|
175
|
-
const build = await new BuildModel(db).get(snapshot.buildId);
|
|
176
|
-
if (!project || !build) notFound("Project or build not found");
|
|
177
|
-
await snapshots.review(snapshotId, "approved", userId);
|
|
178
|
-
await new BaselineModel(db, getStore().storage, config.secret).upsert(project.id, snapshot.storyId, snapshot.viewportName, build.gitBranch, snapshot.id, snapshot.screenshotPath);
|
|
179
|
-
await refreshBuild(build.id);
|
|
180
|
-
}
|
|
181
|
-
//#endregion
|
|
182
|
-
export { APPROVER_ROLES, DEVELOPER_ROLES, VIEW_ROLES, approveSnapshot, buildCreateJsonSchema, buildForProject, buildListQuery, createBuildRecord, persistInlineStatics, refreshBuild, snapshotForBuild, storeUploadStream };
|
|
183
|
-
|
|
184
|
-
//# sourceMappingURL=builds.handlers.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
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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"}
|