@storyshelf/app 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -4
- package/dist/app-types.d.mts +2 -6
- package/dist/asset-manifest.mjs +14 -7
- package/dist/asset-manifest.mjs.map +1 -1
- package/dist/assets/{htmx.min.js → htmx.mjs} +9 -1
- package/dist/assets/htmx.mjs.map +1 -0
- package/dist/assets/mark.mjs +9 -0
- package/dist/assets/mark.mjs.map +1 -0
- package/dist/boot-tasks.mjs +79 -0
- package/dist/boot-tasks.mjs.map +1 -0
- package/dist/build-state-flags.mjs +17 -0
- package/dist/build-state-flags.mjs.map +1 -0
- package/dist/build-state-setup.mjs +74 -0
- package/dist/build-state-setup.mjs.map +1 -0
- package/dist/capture-alerts.mjs +45 -0
- package/dist/capture-alerts.mjs.map +1 -0
- package/dist/capture-process.mjs +58 -0
- package/dist/capture-process.mjs.map +1 -0
- package/dist/capture-setup.mjs +96 -119
- package/dist/capture-setup.mjs.map +1 -1
- package/dist/connections.mjs +33 -0
- package/dist/connections.mjs.map +1 -0
- package/dist/consumer-setup.mjs +57 -0
- package/dist/consumer-setup.mjs.map +1 -0
- package/dist/credential-keys.mjs +1 -2
- package/dist/credentials.mjs +1 -2
- package/dist/git-status.mjs +45 -0
- package/dist/git-status.mjs.map +1 -0
- package/dist/hidden-data.mjs +23 -0
- package/dist/hidden-data.mjs.map +1 -0
- package/dist/index.d.mts +3 -5
- package/dist/index.mjs +204 -127
- package/dist/index.mjs.map +1 -1
- package/dist/insights/budget.mjs +48 -0
- package/dist/insights/budget.mjs.map +1 -0
- package/dist/insights/deps.mjs +20 -0
- package/dist/insights/deps.mjs.map +1 -0
- package/dist/insights/errors.mjs +21 -0
- package/dist/insights/errors.mjs.map +1 -0
- package/dist/insights/health-panel.mjs +39 -0
- package/dist/insights/health-panel.mjs.map +1 -0
- package/dist/insights/job.mjs +102 -0
- package/dist/insights/job.mjs.map +1 -0
- package/dist/insights/load-health.mjs +58 -0
- package/dist/insights/load-health.mjs.map +1 -0
- package/dist/insights/load-triage.mjs +97 -0
- package/dist/insights/load-triage.mjs.map +1 -0
- package/dist/insights/panel.mjs +27 -0
- package/dist/insights/panel.mjs.map +1 -0
- package/dist/insights/request.mjs +112 -0
- package/dist/insights/request.mjs.map +1 -0
- package/dist/insights/stale.mjs +20 -0
- package/dist/insights/stale.mjs.map +1 -0
- package/dist/insights/usage-report.mjs +59 -0
- package/dist/insights/usage-report.mjs.map +1 -0
- package/dist/insights/view.mjs +47 -0
- package/dist/insights/view.mjs.map +1 -0
- package/dist/job-canceller.mjs +13 -0
- package/dist/job-canceller.mjs.map +1 -0
- package/dist/job-queue.mjs +50 -0
- package/dist/job-queue.mjs.map +1 -0
- package/dist/jobs-setup.mjs +131 -0
- package/dist/jobs-setup.mjs.map +1 -0
- package/dist/lifecycle.mjs +93 -22
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs +5 -4
- package/dist/middleware/auth-gate.mjs.map +1 -1
- package/dist/middleware/csrf.mjs +1 -2
- package/dist/middleware/rate-limit.mjs +42 -35
- package/dist/middleware/rate-limit.mjs.map +1 -1
- package/dist/middleware/request-log.mjs +4 -4
- package/dist/middleware/request-log.mjs.map +1 -1
- package/dist/middleware/require-review.mjs +50 -0
- package/dist/middleware/require-review.mjs.map +1 -0
- package/dist/middleware/require-testing.mjs +40 -0
- package/dist/middleware/require-testing.mjs.map +1 -0
- package/dist/middleware/server-timing.mjs +1 -2
- package/dist/middleware/setup-gate.mjs +1 -2
- package/dist/middleware/setup-gate.mjs.map +1 -1
- package/dist/middleware/store-scope.mjs +13 -4
- package/dist/middleware/store-scope.mjs.map +1 -1
- package/dist/notifications-pause.mjs +32 -0
- package/dist/notifications-pause.mjs.map +1 -0
- package/dist/notify.d.mts +3 -4
- package/dist/notify.mjs +79 -41
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/a11y-violations.mjs +115 -0
- package/dist/pages/a11y-violations.mjs.map +1 -0
- package/dist/pages/admin-ai.mjs +77 -0
- package/dist/pages/admin-ai.mjs.map +1 -0
- package/dist/pages/admin-system-origin.mjs +132 -0
- package/dist/pages/admin-system-origin.mjs.map +1 -0
- package/dist/pages/admin-system-targets.mjs +52 -0
- package/dist/pages/admin-system-targets.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +64 -15
- package/dist/pages/admin-system.mjs.map +1 -1
- package/dist/pages/build-detail.mjs +105 -78
- package/dist/pages/build-detail.mjs.map +1 -1
- package/dist/pages/build-diff-actions.mjs +88 -35
- package/dist/pages/build-diff-actions.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +17 -16
- package/dist/pages/build-diff-comments.mjs.map +1 -1
- package/dist/pages/build-diff-default.mjs +53 -0
- package/dist/pages/build-diff-default.mjs.map +1 -0
- package/dist/pages/build-diff-environment.mjs +81 -0
- package/dist/pages/build-diff-environment.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +23 -6
- package/dist/pages/build-diff-header.mjs.map +1 -1
- package/dist/pages/build-diff-insight.mjs +112 -0
- package/dist/pages/build-diff-insight.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +74 -12
- package/dist/pages/build-diff-model.mjs.map +1 -1
- package/dist/pages/build-diff-nav.mjs +29 -14
- package/dist/pages/build-diff-nav.mjs.map +1 -1
- package/dist/pages/build-diff-reference.mjs +84 -0
- package/dist/pages/build-diff-reference.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +6 -3
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
- package/dist/pages/build-diff-stale.mjs +1 -2
- package/dist/pages/build-diff-usage.mjs +62 -0
- package/dist/pages/build-diff-usage.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +97 -14
- package/dist/pages/build-diff-viewer.mjs.map +1 -1
- package/dist/pages/build-diff.mjs +37 -22
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/build-tests.mjs +73 -0
- package/dist/pages/build-tests.mjs.map +1 -0
- package/dist/pages/component-detail.mjs +248 -0
- package/dist/pages/component-detail.mjs.map +1 -0
- package/dist/pages/component-stories.mjs +167 -0
- package/dist/pages/component-stories.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +14 -28
- package/dist/pages/compute-jobs.mjs.map +1 -1
- package/dist/pages/design-links.mjs +181 -0
- package/dist/pages/design-links.mjs.map +1 -0
- package/dist/pages/environment-change.mjs +52 -0
- package/dist/pages/environment-change.mjs.map +1 -0
- package/dist/pages/health-panel.mjs +125 -0
- package/dist/pages/health-panel.mjs.map +1 -0
- package/dist/pages/invite.mjs +1 -2
- package/dist/pages/label-detail.mjs +1 -2
- package/dist/pages/library-grid.mjs +104 -0
- package/dist/pages/library-grid.mjs.map +1 -0
- package/dist/pages/library-model.mjs +46 -0
- package/dist/pages/library-model.mjs.map +1 -0
- package/dist/pages/library-snapshots.mjs +129 -0
- package/dist/pages/library-snapshots.mjs.map +1 -0
- package/dist/pages/library.mjs +113 -220
- package/dist/pages/library.mjs.map +1 -1
- package/dist/pages/login.mjs +1 -2
- package/dist/pages/notifications-paused.mjs +21 -0
- package/dist/pages/notifications-paused.mjs.map +1 -0
- package/dist/pages/passkey-ceremony.mjs +1 -2
- package/dist/pages/profile.mjs +54 -50
- package/dist/pages/profile.mjs.map +1 -1
- package/dist/pages/project-builds-table.mjs +3 -4
- package/dist/pages/project-builds-table.mjs.map +1 -1
- package/dist/pages/project-builds.mjs +52 -23
- package/dist/pages/project-builds.mjs.map +1 -1
- package/dist/pages/project-create.mjs +1 -2
- package/dist/pages/project-settings.mjs +52 -14
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/projects-card.mjs +15 -3
- package/dist/pages/projects-card.mjs.map +1 -1
- package/dist/pages/projects.mjs +7 -4
- package/dist/pages/projects.mjs.map +1 -1
- package/dist/pages/review-changeset.mjs +115 -0
- package/dist/pages/review-changeset.mjs.map +1 -0
- package/dist/pages/review-comments.mjs +102 -0
- package/dist/pages/review-comments.mjs.map +1 -0
- package/dist/pages/review-verdict.mjs +101 -0
- package/dist/pages/review-verdict.mjs.map +1 -0
- package/dist/pages/root.mjs +1 -2
- package/dist/pages/settings-ai.mjs +68 -0
- package/dist/pages/settings-ai.mjs.map +1 -0
- package/dist/pages/settings-capture.mjs +123 -0
- package/dist/pages/settings-capture.mjs.map +1 -0
- package/dist/pages/settings-design-credential.mjs +98 -0
- package/dist/pages/settings-design-credential.mjs.map +1 -0
- package/dist/pages/settings-design-links.mjs +178 -0
- package/dist/pages/settings-design-links.mjs.map +1 -0
- package/dist/pages/settings-design.mjs +47 -0
- package/dist/pages/settings-design.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +47 -23
- package/dist/pages/settings-general.mjs.map +1 -1
- package/dist/pages/settings-labels.mjs +44 -4
- package/dist/pages/settings-labels.mjs.map +1 -1
- package/dist/pages/settings-links.mjs +111 -0
- package/dist/pages/settings-links.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +1 -2
- package/dist/pages/settings-notifications-git.mjs +127 -0
- package/dist/pages/settings-notifications-git.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +80 -44
- package/dist/pages/settings-notifications.mjs.map +1 -1
- package/dist/pages/settings-tests.mjs +34 -4
- package/dist/pages/settings-tests.mjs.map +1 -1
- package/dist/pages/settings-tokens.mjs +1 -2
- package/dist/pages/settings-webhooks.mjs +1 -2
- package/dist/pages/storybook.mjs +1 -2
- package/dist/pages/target-choices.mjs +38 -0
- package/dist/pages/target-choices.mjs.map +1 -0
- package/dist/publish-setup.mjs +88 -0
- package/dist/publish-setup.mjs.map +1 -0
- package/dist/retention-timer.mjs +12 -12
- package/dist/retention-timer.mjs.map +1 -1
- package/dist/review-events.mjs +47 -0
- package/dist/review-events.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +46 -5
- package/dist/routers/admin-pages.mjs.map +1 -1
- package/dist/routers/admin-public-origin.mjs +92 -0
- package/dist/routers/admin-public-origin.mjs.map +1 -0
- package/dist/routers/admin.mjs +8 -4
- package/dist/routers/admin.mjs.map +1 -1
- package/dist/routers/assets.mjs +10 -4
- package/dist/routers/assets.mjs.map +1 -1
- package/dist/routers/attempts.mjs +1 -2
- package/dist/routers/auth-sync.mjs +1 -2
- package/dist/routers/auth.mjs +2 -3
- package/dist/routers/baseline-guard.mjs +4 -5
- package/dist/routers/baseline-guard.mjs.map +1 -1
- package/dist/routers/baseline-reference.mjs +72 -0
- package/dist/routers/baseline-reference.mjs.map +1 -0
- package/dist/routers/baselines.handlers.mjs +63 -0
- package/dist/routers/baselines.handlers.mjs.map +1 -0
- package/dist/routers/baselines.mjs +124 -0
- package/dist/routers/baselines.mjs.map +1 -0
- package/dist/routers/build-cancel.mjs +67 -0
- package/dist/routers/build-cancel.mjs.map +1 -0
- package/dist/routers/build-changeset.mjs +69 -0
- package/dist/routers/build-changeset.mjs.map +1 -0
- package/dist/routers/build-reviews.handlers.mjs +85 -0
- package/dist/routers/build-reviews.handlers.mjs.map +1 -0
- package/dist/routers/build-reviews.mjs +73 -0
- package/dist/routers/build-reviews.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +65 -60
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +77 -19
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/capture-cache.mjs +29 -0
- package/dist/routers/capture-cache.mjs.map +1 -0
- package/dist/routers/capture-config.mjs +34 -0
- package/dist/routers/capture-config.mjs.map +1 -0
- package/dist/routers/comments.mjs +42 -17
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/comparison-image.mjs +29 -0
- package/dist/routers/comparison-image.mjs.map +1 -0
- package/dist/routers/component-catalog.handlers.mjs +70 -0
- package/dist/routers/component-catalog.handlers.mjs.map +1 -0
- package/dist/routers/component.handlers.mjs +120 -0
- package/dist/routers/component.handlers.mjs.map +1 -0
- package/dist/routers/connections.mjs +117 -0
- package/dist/routers/connections.mjs.map +1 -0
- package/dist/routers/design-connect.handlers.mjs +43 -0
- package/dist/routers/design-connect.handlers.mjs.map +1 -0
- package/dist/routers/design.handlers.mjs +113 -0
- package/dist/routers/design.handlers.mjs.map +1 -0
- package/dist/routers/flash.mjs +1 -2
- package/dist/routers/health-report.mjs +3 -5
- package/dist/routers/health-report.mjs.map +1 -1
- package/dist/routers/health.mjs +5 -5
- package/dist/routers/health.mjs.map +1 -1
- package/dist/routers/helpers.mjs +20 -5
- package/dist/routers/helpers.mjs.map +1 -1
- package/dist/routers/htmx.mjs +1 -2
- package/dist/routers/insights-ui.mjs +63 -0
- package/dist/routers/insights-ui.mjs.map +1 -0
- package/dist/routers/insights.handlers.mjs +44 -0
- package/dist/routers/insights.handlers.mjs.map +1 -0
- package/dist/routers/insights.mjs +188 -0
- package/dist/routers/insights.mjs.map +1 -0
- package/dist/routers/labels.mjs +1 -2
- package/dist/routers/links.handlers.mjs +79 -0
- package/dist/routers/links.handlers.mjs.map +1 -0
- package/dist/routers/links.mjs +138 -0
- package/dist/routers/links.mjs.map +1 -0
- package/dist/routers/media.mjs +30 -7
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs +3 -3
- package/dist/routers/members.mjs.map +1 -1
- package/dist/routers/notification-channels.mjs +18 -25
- package/dist/routers/notification-channels.mjs.map +1 -1
- package/dist/routers/notification-subscriptions.mjs +1 -2
- package/dist/routers/oauth-state.mjs +44 -0
- package/dist/routers/oauth-state.mjs.map +1 -0
- package/dist/routers/preview-base.mjs +37 -0
- package/dist/routers/preview-base.mjs.map +1 -0
- package/dist/routers/preview-token.mjs +50 -0
- package/dist/routers/preview-token.mjs.map +1 -0
- package/dist/routers/profile.mjs +4 -3
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/projects.mjs +18 -3
- package/dist/routers/projects.mjs.map +1 -1
- package/dist/routers/publish-upload.mjs +85 -0
- package/dist/routers/publish-upload.mjs.map +1 -0
- package/dist/routers/published-access.mjs +51 -0
- package/dist/routers/published-access.mjs.map +1 -0
- package/dist/routers/published-compression.mjs +109 -0
- package/dist/routers/published-compression.mjs.map +1 -0
- package/dist/routers/published-files.mjs +69 -0
- package/dist/routers/published-files.mjs.map +1 -0
- package/dist/routers/review-forms.mjs +71 -0
- package/dist/routers/review-forms.mjs.map +1 -0
- package/dist/routers/review-next.mjs +1 -2
- package/dist/routers/review-section.handlers.mjs +84 -0
- package/dist/routers/review-section.handlers.mjs.map +1 -0
- package/dist/routers/review-switch-off.mjs +28 -0
- package/dist/routers/review-switch-off.mjs.map +1 -0
- package/dist/routers/schemas.mjs +141 -24
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/scoped-models.mjs +21 -0
- package/dist/routers/scoped-models.mjs.map +1 -0
- package/dist/routers/settings-ai.mjs +34 -0
- package/dist/routers/settings-ai.mjs.map +1 -0
- package/dist/routers/settings-capture.mjs +76 -0
- package/dist/routers/settings-capture.mjs.map +1 -0
- package/dist/routers/settings-design.mjs +102 -0
- package/dist/routers/settings-design.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +56 -12
- package/dist/routers/settings-general.mjs.map +1 -1
- package/dist/routers/settings-git-channel.mjs +76 -0
- package/dist/routers/settings-git-channel.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +28 -3
- package/dist/routers/settings-labels.mjs.map +1 -1
- package/dist/routers/settings-links.mjs +62 -0
- package/dist/routers/settings-links.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +5 -9
- package/dist/routers/settings-members.mjs.map +1 -1
- package/dist/routers/settings-notifications.mjs +16 -12
- package/dist/routers/settings-notifications.mjs.map +1 -1
- package/dist/routers/settings-tokens.mjs +3 -7
- package/dist/routers/settings-tokens.mjs.map +1 -1
- package/dist/routers/settings-webhooks.mjs +8 -26
- package/dist/routers/settings-webhooks.mjs.map +1 -1
- package/dist/routers/settings.handlers.mjs +108 -28
- package/dist/routers/settings.handlers.mjs.map +1 -1
- package/dist/routers/settings.mjs +17 -6
- package/dist/routers/settings.mjs.map +1 -1
- package/dist/routers/signed-storybook.mjs +55 -0
- package/dist/routers/signed-storybook.mjs.map +1 -0
- package/dist/routers/snapshot-groups.mjs +62 -0
- package/dist/routers/snapshot-groups.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +104 -7
- package/dist/routers/snapshots.mjs.map +1 -1
- package/dist/routers/storybook.mjs +57 -104
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +3 -7
- package/dist/routers/tokens.mjs.map +1 -1
- package/dist/routers/ui.mjs +38 -7
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +6 -13
- package/dist/routers/webhooks.mjs.map +1 -1
- package/dist/routers/well-known.mjs +12 -6
- package/dist/routers/well-known.mjs.map +1 -1
- package/dist/routes/api-routes.mjs +92 -0
- package/dist/routes/api-routes.mjs.map +1 -0
- package/dist/routes/classification.mjs +37 -0
- package/dist/routes/classification.mjs.map +1 -0
- package/dist/routes/openapi-classes.mjs +21 -0
- package/dist/routes/openapi-classes.mjs.map +1 -0
- package/dist/routes/page-routes.mjs +103 -0
- package/dist/routes/page-routes.mjs.map +1 -0
- package/dist/runtime.mjs +19 -15
- package/dist/runtime.mjs.map +1 -1
- package/dist/serverless.mjs +4 -0
- package/dist/store.mjs +20 -3
- package/dist/store.mjs.map +1 -1
- package/dist/targets.mjs +45 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/ui/avatar.mjs +1 -2
- package/dist/ui/buttons.mjs +17 -5
- package/dist/ui/buttons.mjs.map +1 -1
- package/dist/ui/code-block.mjs +1 -2
- package/dist/ui/compare-stage.mjs +86 -54
- package/dist/ui/compare-stage.mjs.map +1 -1
- package/dist/ui/csrf-field.mjs +1 -2
- package/dist/ui/css.mjs +1 -2
- package/dist/ui/design-reference.mjs +96 -0
- package/dist/ui/design-reference.mjs.map +1 -0
- package/dist/ui/document.mjs +2 -4
- package/dist/ui/document.mjs.map +1 -1
- package/dist/ui/dropdown.mjs +1 -2
- package/dist/ui/feedback.mjs +1 -2
- package/dist/ui/filter-input.mjs +1 -2
- package/dist/ui/forms.mjs +7 -7
- package/dist/ui/forms.mjs.map +1 -1
- package/dist/ui/icons/icon.mjs +1 -2
- package/dist/ui/icons/paths.mjs +2 -2
- package/dist/ui/icons/paths.mjs.map +1 -1
- package/dist/ui/icons/sprite.mjs +1 -2
- package/dist/ui/kbd.mjs +1 -2
- package/dist/ui/layout.mjs +1 -2
- package/dist/ui/preview-embed.mjs +81 -0
- package/dist/ui/preview-embed.mjs.map +1 -0
- package/dist/ui/progress.mjs +1 -2
- package/dist/ui/segmented.mjs +1 -2
- package/dist/ui/shell/account.mjs +1 -2
- package/dist/ui/shell/bare.mjs +1 -2
- package/dist/ui/shell/boot-script.mjs +1 -2
- package/dist/ui/shell/client-script.mjs +86 -2
- package/dist/ui/shell/client-script.mjs.map +1 -1
- package/dist/ui/shell/nav.mjs +13 -10
- package/dist/ui/shell/nav.mjs.map +1 -1
- package/dist/ui/shell/review-script.mjs +25 -3
- package/dist/ui/shell/review-script.mjs.map +1 -1
- package/dist/ui/shell/sidebar.mjs +3 -4
- package/dist/ui/shell/sidebar.mjs.map +1 -1
- package/dist/ui/shell/topbar.mjs +1 -2
- package/dist/ui/stacks.mjs +1 -2
- package/dist/ui/styles/base.mjs +1 -2
- package/dist/ui/styles/behavior.mjs +1 -2
- package/dist/ui/styles/review-layout.mjs +1 -2
- package/dist/ui/styles/review.mjs +1 -2
- package/dist/ui/styles/tokens.mjs +1 -2
- package/dist/ui/styles.mjs +1 -2
- package/dist/ui/subnav.mjs +1 -2
- package/dist/ui/table.mjs +1 -2
- package/dist/ui/theme.mjs +1 -2
- package/dist/ui/thumbnail.mjs +1 -2
- package/dist/ui/time.mjs +1 -2
- package/package.json +14 -12
- package/dist/asset-paths.mjs +0 -23
- package/dist/asset-paths.mjs.map +0 -1
- package/dist/assets/mark.svg +0 -1
- package/dist/pages/settings-status.mjs +0 -137
- package/dist/pages/settings-status.mjs.map +0 -1
- package/dist/routers/settings-status.mjs +0 -77
- package/dist/routers/settings-status.mjs.map +0 -1
- package/dist/routers/status-configs.mjs +0 -108
- package/dist/routers/status-configs.mjs.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/core/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport { eq, getTableColumns } from \"@storyshelf/core/orm\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"profile.mjs","names":[],"sources":["../../src/routers/profile.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/core/auth\";\nimport { MIN_PASSWORD_LENGTH, unsignedToken } from \"@storyshelf/core/auth\";\nimport { NotificationSubscriptionModel, ProjectModel, UserModel } from \"@storyshelf/core/models\";\nimport { eq, getTableColumns } from \"@storyshelf/core/orm\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { SESSION_COOKIE, type AuthUser } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { notificationsPaused } from \"../notifications-pause.ts\";\nimport { renderProfilePage } from \"../pages/profile.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { forwardToEngine } from \"./auth.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** User columns needed to render the profile page. */\ninterface ProfileRow {\n email: string;\n name: string;\n displayNameOverride: string | null;\n authProvider: string;\n lastLoginAt: string | null;\n createdAt: string;\n}\n\n/** Membership entry for the profile page. */\ninterface ProfileMembership {\n projectName: string;\n projectSlug: string;\n role: string;\n source: string;\n}\n\nfunction field(form: FormData, name: string): string {\n const raw = form.get(name);\n return typeof raw === \"string\" ? raw : \"\";\n}\n\nfunction toDbUser(row: ProfileRow | null): ProfilePage[\"dbUser\"] {\n if (!row) {\n return undefined;\n }\n return {\n email: row.email,\n name: row.name,\n displayNameOverride: row.displayNameOverride,\n authProvider: row.authProvider,\n lastLoginAt: row.lastLoginAt,\n createdAt: row.createdAt,\n };\n}\n\ntype ProfilePage = Parameters<typeof renderProfilePage>[0];\n\nasync function loadProfileData(userId: string): Promise<{\n userRow: ProfileRow | null;\n memberships: ProfileMembership[];\n subscribedSlugs: string[];\n}> {\n const { db } = getStore();\n const userRow = (await new UserModel(db).get(userId)) as unknown as ProfileRow | null;\n const members = (await db.list(db.tables.projectMembers, {\n where: eq(getTableColumns(db.tables.projectMembers)[\"userId\"] as never, userId),\n })) as unknown as Array<{ projectId: string; role: string; source: string }>;\n const projects = (await db.list(db.tables.projects)) as unknown as Array<{\n id: string;\n name: string;\n slug: string;\n }>;\n const byId = new Map(projects.map((project) => [project.id, project]));\n const memberships = members.flatMap((member) => {\n const project = byId.get(member.projectId);\n return project\n ? [\n {\n projectName: project.name,\n projectSlug: project.slug,\n role: member.role,\n source: member.source,\n },\n ]\n : [];\n });\n const subs = await new NotificationSubscriptionModel(db).listForUser(userId);\n const subscribedIds = new Set(subs.filter((sub) => sub.enabled).map((sub) => sub.projectId));\n const subscribedSlugs = [...byId.values()]\n .filter((project) => subscribedIds.has(project.id))\n .map((project) => project.slug);\n return { userRow, memberships, subscribedSlugs };\n}\n\nfunction readSessionToken(c: Context): string {\n const header = c.req.header(\"cookie\") ?? \"\";\n for (const part of header.split(\";\")) {\n const eqIndex = part.indexOf(\"=\");\n if (eqIndex !== -1 && part.slice(0, eqIndex).trim() === SESSION_COOKIE) {\n // Engine cookies are signed (`token.signature`); the table holds the raw token.\n return unsignedToken(part.slice(eqIndex + 1).trim());\n }\n }\n return \"\";\n}\n\n/** Engine security overview for the profile page. */\nasync function loadEngineSecurity(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<NonNullable<ProfilePage[\"security\"]>> {\n const [sessions, passkeys, hasPassword] = await Promise.all([\n auth.listSessions(user.id),\n auth.listPasskeys(user.id),\n auth.hasPassword(user.id),\n ]);\n const current = readSessionToken(c);\n return {\n sessions: sessions.map((session) => ({\n id: session.id,\n ipAddress: session.ipAddress,\n userAgent: session.userAgent,\n createdAt: session.createdAt,\n expiresAt: session.expiresAt,\n current: session.token === current,\n })),\n passkeys,\n passkeysEnabled: auth.passkeysEnabled(),\n hasPassword,\n };\n}\n\nasync function profileView(\n c: Context,\n user: AuthUser,\n auth: Auth,\n extra?: { error?: string; success?: string; local?: boolean; status?: 200 | 400 },\n): Promise<Response> {\n const { userRow, memberships, subscribedSlugs } = await loadProfileData(user.id);\n const security = await loadEngineSecurity(c, auth, user);\n const html = await renderProfilePage({\n user,\n dbUser: toDbUser(userRow),\n memberships,\n subscribedSlugs,\n isLocal: extra?.local ?? security.hasPassword,\n security,\n error: extra?.error,\n notificationsPaused: getStore().notifiers.length > 0 && (await notificationsPaused()),\n });\n if (extra?.success) {\n flash(c, extra.success);\n }\n // Device tokens render into the page (per-device revoke forms): never store.\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(html, extra?.status ?? 200);\n}\n\n/** Register the personal profile page and its mutations. */\nexport function registerProfile(app: ShelfRouter, auth: Auth): void {\n app.get(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await profileView(c, user, auth);\n });\n\n app.post(\"/profile\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n const displayName = field(await c.req.formData(), \"displayName\").trim();\n if (!displayName) {\n return profileView(c, user, auth, { error: \"Display name is required\", status: 400 });\n }\n await new UserModel(getStore().db).setDisplayNameOverride(user.id, displayName);\n flash(c, \"Profile updated\");\n return hxRedirect(c, \"/profile\");\n });\n\n registerPasswordRoute(app, auth);\n registerSessionRoutes(app, auth);\n registerPasskeyRoutes(app, auth);\n registerNotificationsRoute(app, auth);\n}\n\nfunction registerNotificationsRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/notifications\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await saveProfileNotifications(c, user, auth);\n });\n}\n\n/** Toggle the viewer's own email subscription for one member project. */\nasync function saveProfileNotifications(c: Context, user: AuthUser, auth: Auth): Promise<Response> {\n const form = await c.req.formData();\n const project = await memberProject(field(form, \"slug\"), user.id);\n if (!project) {\n return profileView(c, user, auth, { error: \"Unknown project\", status: 400 });\n }\n await applyProfileToggle(project.id, user.id, field(form, \"enabled\") === \"1\");\n flash(c, \"Notification preference saved\");\n return hxRedirect(c, \"/profile\");\n}\n\n/** Resolve a project the viewer belongs to (null otherwise). */\nasync function memberProject(slug: string, userId: string): Promise<Project | null> {\n const project = await new ProjectModel(getStore().db).getBySlug(slug);\n if (!project) {\n return null;\n }\n const { memberships } = await loadProfileData(userId);\n const isMember = memberships.some((membership) => membership.projectSlug === slug);\n return isMember ? project : null;\n}\n\n/** Enable (opt-in email) or disable (remove) a subscription. */\nasync function applyProfileToggle(\n projectId: string,\n userId: string,\n enable: boolean,\n): Promise<void> {\n const model = new NotificationSubscriptionModel(getStore().db);\n if (enable) {\n await model.upsert(projectId, userId, { via: [\"email\"] });\n return;\n }\n await model.remove(projectId, userId);\n}\n\nfunction registerPasswordRoute(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/password\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleEnginePasswordChange(c, auth, user);\n });\n}\n\nfunction registerSessionRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/sessions/revoke\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleSessionRevoke(c, auth, user);\n });\n\n app.post(\"/profile/sessions/revoke-others\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handleRevokeOthers(c, auth, user);\n });\n}\n\nfunction registerPasskeyRoutes(app: ShelfRouter, auth: Auth): void {\n app.post(\"/profile/passkeys/:passkeyId/delete\", async (c) => {\n const { user } = getStore();\n if (!user) {\n return c.redirect(\"/auth/login\", 302);\n }\n return await handlePasskeyDelete(c, auth, user);\n });\n}\n\nasync function handleSessionRevoke(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = await readSessionId(c);\n if (!id) {\n return profileView(c, user, auth, { error: \"Missing session\", status: 400 });\n }\n const token = await resolveSessionToken(auth, user, id);\n if (!token) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-session\", { token });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not revoke that session\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Session revoked\" });\n}\n\nasync function readSessionId(c: Context): Promise<string> {\n return field(await c.req.formData(), \"sessionId\");\n}\n\nasync function resolveSessionToken(auth: Auth, user: AuthUser, id: string): Promise<string | null> {\n const sessions = await auth.listSessions(user.id);\n return sessions.find((session) => session.id === id)?.token ?? null;\n}\n\nasync function handleRevokeOthers(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const res = await forwardToEngine(c, auth, \"/api/auth/revoke-other-sessions\", {});\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not sign out other devices\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Signed out of other devices\" });\n}\n\nasync function handlePasskeyDelete(c: Context, auth: Auth, user: AuthUser): Promise<Response> {\n const id = c.req.param(\"passkeyId\") ?? \"\";\n if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) {\n return profileView(c, user, auth, { error: \"Unknown passkey\", status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/passkey/delete-passkey\", { id });\n if (res.status !== 200) {\n return profileView(c, user, auth, { error: \"Could not remove that passkey\", status: 400 });\n }\n return profileView(c, user, auth, { success: \"Passkey removed\" });\n}\n\nasync function engineErrorMessage(res: Response, fallback: string): Promise<string> {\n try {\n const body = (await res.json()) as { message?: unknown };\n return typeof body.message === \"string\" && body.message ? body.message : fallback;\n } catch {\n return fallback;\n }\n}\n\n/** Validate the password form and change it through the engine. */\nasync function handleEnginePasswordChange(\n c: Context,\n auth: Auth,\n user: AuthUser,\n): Promise<Response> {\n const { current, next, error } = readPasswordChange(await c.req.formData());\n if (error) {\n return profileView(c, user, auth, { error, local: true, status: 400 });\n }\n const res = await forwardToEngine(c, auth, \"/api/auth/change-password\", {\n currentPassword: current,\n newPassword: next,\n revokeOtherSessions: true,\n });\n if (res.status !== 200) {\n const message = await engineErrorMessage(res, \"Password change failed\");\n return profileView(c, user, auth, { error: message, local: true, status: 400 });\n }\n return profileView(c, user, auth, { success: \"Password changed\", local: true });\n}\n\nfunction readPasswordChange(form: FormData): { current: string; next: string; error?: string } {\n const next = field(form, \"newPassword\");\n if (next !== field(form, \"confirmPassword\")) {\n return { current: \"\", next: \"\", error: \"Passwords do not match\" };\n }\n if (next.length < MIN_PASSWORD_LENGTH) {\n return {\n current: \"\",\n next: \"\",\n error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters`,\n };\n }\n return { current: field(form, \"currentPassword\"), next };\n}\n"],"mappings":"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"}
|
|
@@ -3,10 +3,15 @@ import "node:url";
|
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
5
|
import { forbidden, requireSiteAdmin, resolveAuthorizedProject } from "./helpers.mjs";
|
|
6
|
+
import { assertTargetsSelectable } from "../targets.mjs";
|
|
6
7
|
import { badRequest, forbidden as forbidden$1, notFound, projectCreateSchema, projectSchema, projectUpdateSchema, unauthorized } from "./schemas.mjs";
|
|
8
|
+
import { toProjectPatch } from "./capture-config.mjs";
|
|
9
|
+
import { runSwitchOffPass } from "./review-switch-off.mjs";
|
|
7
10
|
import { createRoute, z } from "@hono/zod-openapi";
|
|
11
|
+
import { resolveBrowsers } from "@storyshelf/core/capture";
|
|
8
12
|
import { LabelModel, MemberModel, ProjectModel } from "@storyshelf/core/models";
|
|
9
|
-
|
|
13
|
+
import { HTTPException } from "hono/http-exception";
|
|
14
|
+
// src/routers/projects.ts
|
|
10
15
|
const VIEW_ROLES = [
|
|
11
16
|
"viewer",
|
|
12
17
|
"developer",
|
|
@@ -92,6 +97,13 @@ const deleteProjectRoute = createRoute({
|
|
|
92
97
|
...notFound
|
|
93
98
|
}
|
|
94
99
|
});
|
|
100
|
+
/** `aiProfile` is a site-admin data-flow decision; it must name a configured profile. */
|
|
101
|
+
function assertAiProfileChange(c, profile) {
|
|
102
|
+
if (profile === void 0) return;
|
|
103
|
+
requireSiteAdmin(c);
|
|
104
|
+
const { ai } = getStore();
|
|
105
|
+
if (profile !== null && !ai?.profileNames().includes(profile)) throw new HTTPException(400, { message: `Unknown AI profile "${profile}"` });
|
|
106
|
+
}
|
|
95
107
|
/** Register the project list, create, fetch, update, and delete endpoints. */
|
|
96
108
|
function registerProjects(app) {
|
|
97
109
|
app.openapi(listProjectsRoute, async (c) => {
|
|
@@ -116,7 +128,11 @@ function registerProjects(app) {
|
|
|
116
128
|
app.openapi(updateProjectRoute, async (c) => {
|
|
117
129
|
const { slug } = c.req.valid("param");
|
|
118
130
|
const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);
|
|
119
|
-
const
|
|
131
|
+
const body = c.req.valid("json");
|
|
132
|
+
assertAiProfileChange(c, body.aiProfile);
|
|
133
|
+
await assertTargetsSelectable(body.browsers ?? (body.browser === void 0 ? [] : [body.browser]), resolveBrowsers(project));
|
|
134
|
+
const updated = await new ProjectModel(getStore().db).update(project.id, toProjectPatch(body));
|
|
135
|
+
await runSwitchOffPass(project, updated);
|
|
120
136
|
return c.json(updated);
|
|
121
137
|
});
|
|
122
138
|
app.openapi(deleteProjectRoute, async (c) => {
|
|
@@ -127,7 +143,6 @@ function registerProjects(app) {
|
|
|
127
143
|
return c.body(null, 204);
|
|
128
144
|
});
|
|
129
145
|
}
|
|
130
|
-
//#endregion
|
|
131
146
|
export { registerProjects };
|
|
132
147
|
|
|
133
148
|
//# sourceMappingURL=projects.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"projects.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/projects.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { forbidden, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport {\n badRequest,\n forbidden as forbiddenResponse,\n notFound,\n projectCreateSchema,\n projectSchema,\n projectUpdateSchema,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nfunction requireSessionUser(): void {\n if (!getStore().authEnabled) {\n return;\n }\n if (!getStore().user) {\n forbidden();\n }\n}\n\nconst listProjectsRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"List projects\",\n path: \"/api/v1/projects\",\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema.array() } },\n description: \"List projects\",\n },\n ...unauthorized,\n },\n});\n\nconst createProjectRoute = createRoute({\n method: \"post\",\n tags: [\"Projects\"],\n summary: \"Create a project\",\n path: \"/api/v1/projects\",\n request: { body: { content: { \"application/json\": { schema: projectCreateSchema } } } },\n responses: {\n 201: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Created project\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...unauthorized,\n },\n});\n\nconst getProjectRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"Get a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Fetch a project\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst updateProjectRoute = createRoute({\n method: \"patch\",\n tags: [\"Projects\"],\n summary: \"Update a project\",\n path: \"/api/v1/projects/{slug}\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: projectUpdateSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Updated project\",\n },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\nconst deleteProjectRoute = createRoute({\n method: \"delete\",\n tags: [\"Projects\"],\n summary: \"Delete a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 204: { description: \"Deleted project\" },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\n/** Register the project list, create, fetch, update, and delete endpoints. */\nexport function registerProjects(app: ShelfRouter): void {\n app.openapi(listProjectsRoute, async (c) => {\n requireSessionUser();\n const projects = new ProjectModel(getStore().db);\n return c.json(await projects.list());\n });\n\n app.openapi(createProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const projects = new ProjectModel(getStore().db);\n const project = await projects.create(body);\n await new LabelModel(getStore().db).seedFor(project.id);\n const creator = getStore().user;\n if (creator) {\n await new MemberModel(getStore().db).set(project.id, creator.id, \"admin\");\n }\n return c.json(project, 201);\n });\n\n app.openapi(getProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n return c.json(project);\n });\n\n app.openapi(updateProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const updated = await new ProjectModel(getStore().db).update(project.id,
|
|
1
|
+
{"version":3,"file":"projects.mjs","names":["forbiddenResponse"],"sources":["../../src/routers/projects.ts"],"sourcesContent":["import { createRoute, z } from \"@hono/zod-openapi\";\nimport { resolveBrowsers } from \"@storyshelf/core/capture\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { MemberModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { ProjectRole } from \"@storyshelf/core/types\";\nimport type { Context } from \"hono\";\nimport { HTTPException } from \"hono/http-exception\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { assertTargetsSelectable } from \"../targets.ts\";\nimport { toProjectPatch } from \"./capture-config.ts\";\nimport { forbidden, requireSiteAdmin, resolveAuthorizedProject } from \"./helpers.ts\";\nimport { runSwitchOffPass } from \"./review-switch-off.ts\";\nimport {\n badRequest,\n forbidden as forbiddenResponse,\n notFound,\n projectCreateSchema,\n projectSchema,\n projectUpdateSchema,\n unauthorized,\n} from \"./schemas.ts\";\nconst VIEW_ROLES: readonly ProjectRole[] = [\"viewer\", \"developer\", \"approver\", \"admin\"];\nconst ADMIN_ROLES: readonly ProjectRole[] = [\"admin\"];\n\nfunction requireSessionUser(): void {\n if (!getStore().authEnabled) {\n return;\n }\n if (!getStore().user) {\n forbidden();\n }\n}\n\nconst listProjectsRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"List projects\",\n path: \"/api/v1/projects\",\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema.array() } },\n description: \"List projects\",\n },\n ...unauthorized,\n },\n});\n\nconst createProjectRoute = createRoute({\n method: \"post\",\n tags: [\"Projects\"],\n summary: \"Create a project\",\n path: \"/api/v1/projects\",\n request: { body: { content: { \"application/json\": { schema: projectCreateSchema } } } },\n responses: {\n 201: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Created project\",\n },\n ...badRequest,\n ...forbiddenResponse,\n ...unauthorized,\n },\n});\n\nconst getProjectRoute = createRoute({\n method: \"get\",\n tags: [\"Projects\"],\n summary: \"Get a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Fetch a project\",\n },\n ...notFound,\n ...unauthorized,\n },\n});\n\nconst updateProjectRoute = createRoute({\n method: \"patch\",\n tags: [\"Projects\"],\n summary: \"Update a project\",\n path: \"/api/v1/projects/{slug}\",\n request: {\n params: z.object({ slug: z.string() }),\n body: { content: { \"application/json\": { schema: projectUpdateSchema } } },\n },\n responses: {\n 200: {\n content: { \"application/json\": { schema: projectSchema } },\n description: \"Updated project\",\n },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\nconst deleteProjectRoute = createRoute({\n method: \"delete\",\n tags: [\"Projects\"],\n summary: \"Delete a project\",\n path: \"/api/v1/projects/{slug}\",\n request: { params: z.object({ slug: z.string() }) },\n responses: {\n 204: { description: \"Deleted project\" },\n ...forbiddenResponse,\n ...notFound,\n },\n});\n\n/** `aiProfile` is a site-admin data-flow decision; it must name a configured profile. */\nfunction assertAiProfileChange(c: Context, profile: string | null | undefined): void {\n if (profile === undefined) {\n return;\n }\n requireSiteAdmin(c);\n const { ai } = getStore();\n if (profile !== null && !ai?.profileNames().includes(profile)) {\n throw new HTTPException(400, { message: `Unknown AI profile \"${profile}\"` });\n }\n}\n\n/** Register the project list, create, fetch, update, and delete endpoints. */\nexport function registerProjects(app: ShelfRouter): void {\n app.openapi(listProjectsRoute, async (c) => {\n requireSessionUser();\n const projects = new ProjectModel(getStore().db);\n return c.json(await projects.list());\n });\n\n app.openapi(createProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const body = c.req.valid(\"json\");\n const projects = new ProjectModel(getStore().db);\n const project = await projects.create(body);\n await new LabelModel(getStore().db).seedFor(project.id);\n const creator = getStore().user;\n if (creator) {\n await new MemberModel(getStore().db).set(project.id, creator.id, \"admin\");\n }\n return c.json(project, 201);\n });\n\n app.openapi(getProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n return c.json(project);\n });\n\n app.openapi(updateProjectRoute, async (c) => {\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...ADMIN_ROLES);\n const body = c.req.valid(\"json\");\n assertAiProfileChange(c, body.aiProfile);\n await assertTargetsSelectable(\n body.browsers ?? (body.browser === undefined ? [] : [body.browser]),\n resolveBrowsers(project),\n );\n const updated = await new ProjectModel(getStore().db).update(project.id, toProjectPatch(body));\n await runSwitchOffPass(project, updated);\n return c.json(updated);\n });\n\n app.openapi(deleteProjectRoute, async (c) => {\n requireSiteAdmin(c);\n const { slug } = c.req.valid(\"param\");\n const project = await resolveAuthorizedProject(c, slug, ...VIEW_ROLES);\n await new ProjectModel(getStore().db).remove(project.id);\n return c.body(null, 204);\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { DEFAULT_SCRATCH_DIR } from "@storyshelf/core/config";
|
|
6
|
+
import { storybookZipPath } from "@storyshelf/core/utils";
|
|
7
|
+
import { FILE_BY_FILE_HINT, ZipInvalidError, ZipLimitError, extractionLimitsOf, publishRoute, publishStoredZip, resolvePublishLimits } from "@storyshelf/core/capture";
|
|
8
|
+
import { BuildModel } from "@storyshelf/core/models";
|
|
9
|
+
import { HTTPException } from "hono/http-exception";
|
|
10
|
+
// src/routers/publish-upload.ts
|
|
11
|
+
/**
|
|
12
|
+
* What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it
|
|
13
|
+
* inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a
|
|
14
|
+
* pointer to the file-by-file route on a host that runs no consumer.
|
|
15
|
+
*/
|
|
16
|
+
/** How an upload of `zipBytes` will be published, or a 413 when this host cannot publish it. */
|
|
17
|
+
function planUpload(zipBytes) {
|
|
18
|
+
const { config, publish } = getStore();
|
|
19
|
+
const route = publishRoute(zipBytes, resolvePublishLimits(config));
|
|
20
|
+
if (route === "job" && !publish?.enqueuePublish) throw new HTTPException(413, { message: FILE_BY_FILE_HINT });
|
|
21
|
+
return route;
|
|
22
|
+
}
|
|
23
|
+
/** Remove a stored zip that was refused (best effort), so no orphan is left behind. */
|
|
24
|
+
async function discardZip(projectId, buildId) {
|
|
25
|
+
try {
|
|
26
|
+
await getStore().storage.delete(storybookZipPath(projectId, buildId));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
getStore().logger.warn({
|
|
29
|
+
err: error,
|
|
30
|
+
buildId
|
|
31
|
+
}, "could not remove a refused zip");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Publish the stored zip of `build` per `route`. Inline: extract now and complete the build (a
|
|
36
|
+
* failure fails it, or, with a capture to come, is left for capture to backfill). Job: queue it.
|
|
37
|
+
* Resolves to the build as it stands afterwards.
|
|
38
|
+
*/
|
|
39
|
+
async function publishUpload(route, build, reqId) {
|
|
40
|
+
const { publish } = getStore();
|
|
41
|
+
if (route === "job") {
|
|
42
|
+
await publish?.enqueuePublish?.(build.id, reqId);
|
|
43
|
+
return build;
|
|
44
|
+
}
|
|
45
|
+
await publishInline(build);
|
|
46
|
+
return await new BuildModel(getStore().db).get(build.id) ?? build;
|
|
47
|
+
}
|
|
48
|
+
async function publishInline(build) {
|
|
49
|
+
const { storage, config, logger, publish, db } = getStore();
|
|
50
|
+
try {
|
|
51
|
+
await publishStoredZip({
|
|
52
|
+
storage,
|
|
53
|
+
scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,
|
|
54
|
+
limits: extractionLimitsOf(resolvePublishLimits(config)),
|
|
55
|
+
db,
|
|
56
|
+
logger
|
|
57
|
+
}, {
|
|
58
|
+
projectId: build.projectId,
|
|
59
|
+
buildId: build.id
|
|
60
|
+
});
|
|
61
|
+
logger.info({ buildId: build.id }, "upload statics persisted inline");
|
|
62
|
+
await publish?.completer.complete(build.id, { ok: true });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof ZipInvalidError || error instanceof ZipLimitError) {
|
|
65
|
+
await discardZip(build.projectId, build.id);
|
|
66
|
+
throw new HTTPException(422, { message: error.message });
|
|
67
|
+
}
|
|
68
|
+
await failInlinePublish(build, error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function failInlinePublish(build, error) {
|
|
72
|
+
const { logger, publish } = getStore();
|
|
73
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
74
|
+
logger.error({
|
|
75
|
+
err: error,
|
|
76
|
+
buildId: build.id
|
|
77
|
+
}, "inline publish failed");
|
|
78
|
+
await publish?.completer.complete(build.id, {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: message
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export { discardZip, planUpload, publishUpload };
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=publish-upload.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"publish-upload.mjs","names":[],"sources":["../../src/routers/publish-upload.ts"],"sourcesContent":["/**\n * What the zip upload route does once the zip is stored (ADR 0006, decision 10): extract it\n * inline when it is small, hand it to a `publish` job when it is larger, or refuse it with a\n * pointer to the file-by-file route on a host that runs no consumer.\n */\nimport {\n extractionLimitsOf,\n FILE_BY_FILE_HINT,\n publishRoute,\n publishStoredZip,\n resolvePublishLimits,\n ZipInvalidError,\n ZipLimitError,\n} from \"@storyshelf/core/capture\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport { storybookZipPath } from \"@storyshelf/core/utils\";\nimport { HTTPException } from \"hono/http-exception\";\nimport { getStore } from \"../store.ts\";\n\n/** How an upload of `zipBytes` will be published, or a 413 when this host cannot publish it. */\nexport function planUpload(zipBytes: number): \"inline\" | \"job\" {\n const { config, publish } = getStore();\n const route = publishRoute(zipBytes, resolvePublishLimits(config));\n if (route === \"job\" && !publish?.enqueuePublish) {\n throw new HTTPException(413, { message: FILE_BY_FILE_HINT });\n }\n return route;\n}\n\n/** Remove a stored zip that was refused (best effort), so no orphan is left behind. */\nexport async function discardZip(projectId: string, buildId: string): Promise<void> {\n try {\n await getStore().storage.delete(storybookZipPath(projectId, buildId));\n } catch (error) {\n getStore().logger.warn({ err: error, buildId }, \"could not remove a refused zip\");\n }\n}\n\n/**\n * Publish the stored zip of `build` per `route`. Inline: extract now and complete the build (a\n * failure fails it, or, with a capture to come, is left for capture to backfill). Job: queue it.\n * Resolves to the build as it stands afterwards.\n */\nexport async function publishUpload(\n route: \"inline\" | \"job\",\n build: Build,\n reqId: string | undefined,\n): Promise<Build> {\n const { publish } = getStore();\n if (route === \"job\") {\n await publish?.enqueuePublish?.(build.id, reqId);\n return build;\n }\n await publishInline(build);\n return (await new BuildModel(getStore().db).get(build.id)) ?? build;\n}\n\nasync function publishInline(build: Build): Promise<void> {\n const { storage, config, logger, publish, db } = getStore();\n try {\n await publishStoredZip(\n {\n storage,\n scratchDir: config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n limits: extractionLimitsOf(resolvePublishLimits(config)),\n db,\n logger,\n },\n { projectId: build.projectId, buildId: build.id },\n );\n logger.info({ buildId: build.id }, \"upload statics persisted inline\");\n await publish?.completer.complete(build.id, { ok: true });\n } catch (error) {\n if (error instanceof ZipInvalidError || error instanceof ZipLimitError) {\n await discardZip(build.projectId, build.id);\n throw new HTTPException(422, { message: error.message });\n }\n await failInlinePublish(build, error);\n }\n}\n\nasync function failInlinePublish(build: Build, error: unknown): Promise<void> {\n const { logger, publish } = getStore();\n const message = error instanceof Error ? error.message : String(error);\n logger.error({ err: error, buildId: build.id }, \"inline publish failed\");\n await publish?.completer.complete(build.id, { ok: false, error: message });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAsBA,SAAgB,WAAW,UAAoC;CAC7D,MAAM,EAAE,QAAQ,YAAY,SAAS;CACrC,MAAM,QAAQ,aAAa,UAAU,qBAAqB,MAAM,CAAC;CACjE,IAAI,UAAU,SAAS,CAAC,SAAS,gBAC/B,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,kBAAkB,CAAC;CAE7D,OAAO;AACT;;AAGA,eAAsB,WAAW,WAAmB,SAAgC;CAClF,IAAI;EACF,MAAM,SAAS,CAAC,CAAC,QAAQ,OAAO,iBAAiB,WAAW,OAAO,CAAC;CACtE,SAAS,OAAO;EACd,SAAS,CAAC,CAAC,OAAO,KAAK;GAAE,KAAK;GAAO;EAAQ,GAAG,gCAAgC;CAClF;AACF;;;;;;AAOA,eAAsB,cACpB,OACA,OACA,OACgB;CAChB,MAAM,EAAE,YAAY,SAAS;CAC7B,IAAI,UAAU,OAAO;EACnB,MAAM,SAAS,iBAAiB,MAAM,IAAI,KAAK;EAC/C,OAAO;CACT;CACA,MAAM,cAAc,KAAK;CACzB,OAAQ,MAAM,IAAI,WAAW,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,EAAE,KAAM;AAChE;AAEA,eAAe,cAAc,OAA6B;CACxD,MAAM,EAAE,SAAS,QAAQ,QAAQ,SAAS,OAAO,SAAS;CAC1D,IAAI;EACF,MAAM,iBACJ;GACE;GACA,YAAY,OAAO,cAAc;GACjC,QAAQ,mBAAmB,qBAAqB,MAAM,CAAC;GACvD;GACA;EACF,GACA;GAAE,WAAW,MAAM;GAAW,SAAS,MAAM;EAAG,CAClD;EACA,OAAO,KAAK,EAAE,SAAS,MAAM,GAAG,GAAG,iCAAiC;EACpE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI,EAAE,IAAI,KAAK,CAAC;CAC1D,SAAS,OAAO;EACd,IAAI,iBAAiB,mBAAmB,iBAAiB,eAAe;GACtE,MAAM,WAAW,MAAM,WAAW,MAAM,EAAE;GAC1C,MAAM,IAAI,cAAc,KAAK,EAAE,SAAS,MAAM,QAAQ,CAAC;EACzD;EACA,MAAM,kBAAkB,OAAO,KAAK;CACtC;AACF;AAEA,eAAe,kBAAkB,OAAc,OAA+B;CAC5E,MAAM,EAAE,QAAQ,YAAY,SAAS;CACrC,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;CACrE,OAAO,MAAM;EAAE,KAAK;EAAO,SAAS,MAAM;CAAG,GAAG,uBAAuB;CACvE,MAAM,SAAS,UAAU,SAAS,MAAM,IAAI;EAAE,IAAI;EAAO,OAAO;CAAQ,CAAC;AAC3E"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { currentProjectRole } from "./helpers.mjs";
|
|
6
|
+
import { isPublicBuild } from "@storyshelf/core/models";
|
|
7
|
+
// src/routers/published-access.ts
|
|
8
|
+
/**
|
|
9
|
+
* The one policy for reading a build's published files (ADR 0007, decision 5; ADR 0011): a signed
|
|
10
|
+
* token naming the build, or a build anyone may open (public, or an instance without sign-in), or
|
|
11
|
+
* a signed-in viewer of the project. Every route that serves published files asks
|
|
12
|
+
* {@link publishedAccess} and takes its CORS headers from {@link publishedCorsHeaders}.
|
|
13
|
+
*/
|
|
14
|
+
const VIEW_ROLES = /* @__PURE__ */ new Set([
|
|
15
|
+
"viewer",
|
|
16
|
+
"developer",
|
|
17
|
+
"approver",
|
|
18
|
+
"admin"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Whether the request may read `build`'s published files, and how. A `grant` (verified claims of a
|
|
22
|
+
* signed URL) decides alone: it opens exactly the build it names and nothing falls through to the
|
|
23
|
+
* other rules, so a token that does not fit answers like a missing one.
|
|
24
|
+
*/
|
|
25
|
+
async function publishedAccess(build, project, grant) {
|
|
26
|
+
if (grant) return grant.projectId === project.id && grant.buildId === build.id && build.projectId === project.id ? "token" : null;
|
|
27
|
+
if (isAnonymouslyViewable(build, project)) return "public";
|
|
28
|
+
const role = await currentProjectRole(project.id);
|
|
29
|
+
return role && VIEW_ROLES.has(role) ? "session" : null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether anyone who can reach the app may fetch the build's published Storybook with no
|
|
33
|
+
* credentials: the instance has no sign-in, or the build is public (ADR 0011).
|
|
34
|
+
*/
|
|
35
|
+
function isAnonymouslyViewable(build, project) {
|
|
36
|
+
return !getStore().authEnabled || isPublicBuild(project, build);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* CORS headers of a published file. A sandboxed preview frame runs on an opaque origin, so its
|
|
40
|
+
* module scripts are cross-origin requests without cookies: they load only when the file answers
|
|
41
|
+
* `Access-Control-Allow-Origin: *`. That is offered exactly where no credentials are involved, for
|
|
42
|
+
* a public build and for a signed URL (which is itself the credential, never sent as a cookie or
|
|
43
|
+
* header, and never with `Allow-Credentials`). A signed-in viewer's private build answers without
|
|
44
|
+
* it: only same-origin pages may read it.
|
|
45
|
+
*/
|
|
46
|
+
function publishedCorsHeaders(access) {
|
|
47
|
+
return access === "session" ? {} : { "access-control-allow-origin": "*" };
|
|
48
|
+
}
|
|
49
|
+
export { isAnonymouslyViewable, publishedAccess, publishedCorsHeaders };
|
|
50
|
+
|
|
51
|
+
//# sourceMappingURL=published-access.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"published-access.mjs","names":[],"sources":["../../src/routers/published-access.ts"],"sourcesContent":["/**\n * The one policy for reading a build's published files (ADR 0007, decision 5; ADR 0011): a signed\n * token naming the build, or a build anyone may open (public, or an instance without sign-in), or\n * a signed-in viewer of the project. Every route that serves published files asks\n * {@link publishedAccess} and takes its CORS headers from {@link publishedCorsHeaders}.\n */\nimport { isPublicBuild } from \"@storyshelf/core/models\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport type { StorybookTokenClaims } from \"@storyshelf/core/utils\";\nimport { getStore } from \"../store.ts\";\nimport { currentProjectRole } from \"./helpers.ts\";\n\n/** How a request may read a build: by its token, because the build is open, or by a session. */\nexport type PublishedAccess = \"token\" | \"public\" | \"session\";\n\nconst VIEW_ROLES: ReadonlySet<string> = new Set([\"viewer\", \"developer\", \"approver\", \"admin\"]);\n\n/**\n * Whether the request may read `build`'s published files, and how. A `grant` (verified claims of a\n * signed URL) decides alone: it opens exactly the build it names and nothing falls through to the\n * other rules, so a token that does not fit answers like a missing one.\n */\nexport async function publishedAccess(\n build: Pick<Build, \"id\" | \"projectId\" | \"public\" | \"gitBranch\">,\n project: Project,\n grant?: StorybookTokenClaims,\n): Promise<PublishedAccess | null> {\n if (grant) {\n return grant.projectId === project.id &&\n grant.buildId === build.id &&\n build.projectId === project.id\n ? \"token\"\n : null;\n }\n if (isAnonymouslyViewable(build, project)) {\n return \"public\";\n }\n const role = await currentProjectRole(project.id);\n return role && VIEW_ROLES.has(role) ? \"session\" : null;\n}\n\n/**\n * Whether anyone who can reach the app may fetch the build's published Storybook with no\n * credentials: the instance has no sign-in, or the build is public (ADR 0011).\n */\nexport function isAnonymouslyViewable(\n build: Pick<Build, \"public\" | \"gitBranch\">,\n project: Project,\n): boolean {\n return !getStore().authEnabled || isPublicBuild(project, build);\n}\n\n/**\n * CORS headers of a published file. A sandboxed preview frame runs on an opaque origin, so its\n * module scripts are cross-origin requests without cookies: they load only when the file answers\n * `Access-Control-Allow-Origin: *`. That is offered exactly where no credentials are involved, for\n * a public build and for a signed URL (which is itself the credential, never sent as a cookie or\n * header, and never with `Allow-Credentials`). A signed-in viewer's private build answers without\n * it: only same-origin pages may read it.\n */\nexport function publishedCorsHeaders(access: PublishedAccess): Record<string, string> {\n return access === \"session\" ? {} : { \"access-control-allow-origin\": \"*\" };\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,MAAM,6BAAkC,IAAI,IAAI;CAAC;CAAU;CAAa;CAAY;AAAO,CAAC;;;;;;AAO5F,eAAsB,gBACpB,OACA,SACA,OACiC;CACjC,IAAI,OACF,OAAO,MAAM,cAAc,QAAQ,MACjC,MAAM,YAAY,MAAM,MACxB,MAAM,cAAc,QAAQ,KAC1B,UACA;CAEN,IAAI,sBAAsB,OAAO,OAAO,GACtC,OAAO;CAET,MAAM,OAAO,MAAM,mBAAmB,QAAQ,EAAE;CAChD,OAAO,QAAQ,WAAW,IAAI,IAAI,IAAI,YAAY;AACpD;;;;;AAMA,SAAgB,sBACd,OACA,SACS;CACT,OAAO,CAAC,SAAS,CAAC,CAAC,eAAe,cAAc,SAAS,KAAK;AAChE;;;;;;;;;AAUA,SAAgB,qBAAqB,QAAiD;CACpF,OAAO,WAAW,YAAY,CAAC,IAAI,EAAE,+BAA+B,IAAI;AAC1E"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { publishedGzipPath } from "@storyshelf/core/utils";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { gunzip, gzip } from "node:zlib";
|
|
8
|
+
// src/routers/published-compression.ts
|
|
9
|
+
const compress = promisify(gzip);
|
|
10
|
+
const decompress = promisify(gunzip);
|
|
11
|
+
const MAX_FILE_BYTES = 8388608;
|
|
12
|
+
const MAX_JOBS = 2;
|
|
13
|
+
const MAX_READS = 4;
|
|
14
|
+
const READ_TIMEOUT_MS = 250;
|
|
15
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
16
|
+
/** Gzip is chosen only if explicitly accepted, or by a nonzero wildcard. */
|
|
17
|
+
function acceptsGzip(header) {
|
|
18
|
+
if (!header) return false;
|
|
19
|
+
const entries = header.split(",").map((part) => part.trim().split(";"));
|
|
20
|
+
const entry = entries.find(([name]) => name?.trim().toLowerCase() === "gzip") ?? entries.find(([name]) => name?.trim() === "*");
|
|
21
|
+
if (!entry) return false;
|
|
22
|
+
const quality = entry.slice(1).find((part) => /^\s*q\s*=/iu.test(part));
|
|
23
|
+
if (quality === void 0) return true;
|
|
24
|
+
const value = Number(quality.split("=")[1]);
|
|
25
|
+
return Number.isFinite(value) && value > 0 && value <= 1;
|
|
26
|
+
}
|
|
27
|
+
/** Return a verified existing variant, or the original while a bounded background job runs. */
|
|
28
|
+
async function publishedGzip(storage, hash, original, mime) {
|
|
29
|
+
if (typeof hash !== "string" || !eligible(hash, original, mime)) return void 0;
|
|
30
|
+
const state = stateOf(storage);
|
|
31
|
+
if (state.reads >= MAX_READS) return void 0;
|
|
32
|
+
state.reads += 1;
|
|
33
|
+
const found = await withDeadline(findVariant(storage, hash, original).finally(() => {
|
|
34
|
+
state.reads -= 1;
|
|
35
|
+
}));
|
|
36
|
+
if (found === "timeout") return void 0;
|
|
37
|
+
if (found) return found;
|
|
38
|
+
schedule(storage, state, hash, original);
|
|
39
|
+
}
|
|
40
|
+
function eligible(hash, original, mime) {
|
|
41
|
+
return typeof hash === "string" && /^[a-f0-9]{64}$/u.test(hash) && original.length > 0 && original.length <= MAX_FILE_BYTES && /^(?:text\/|application\/(?:javascript|json|xml|wasm)|image\/svg\+xml)/iu.test(mime);
|
|
42
|
+
}
|
|
43
|
+
function stateOf(storage) {
|
|
44
|
+
let state = states.get(storage);
|
|
45
|
+
if (!state) {
|
|
46
|
+
state = {
|
|
47
|
+
jobs: /* @__PURE__ */ new Map(),
|
|
48
|
+
reads: 0
|
|
49
|
+
};
|
|
50
|
+
states.set(storage, state);
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
async function findVariant(storage, hash, original) {
|
|
55
|
+
try {
|
|
56
|
+
const path = publishedGzipPath(hash);
|
|
57
|
+
if (!await storage.exists(path)) return void 0;
|
|
58
|
+
const bytes = await readBounded(storage, path, original.length);
|
|
59
|
+
if (!bytes || bytes.length === 0) return void 0;
|
|
60
|
+
return (await decompress(bytes, { maxOutputLength: original.length })).equals(original) ? bytes : void 0;
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Do not allocate an unbounded buffer when a damaged storage object is oversized. */
|
|
66
|
+
async function readBounded(storage, path, limit) {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
for await (const chunk of await storage.readStream(path)) {
|
|
70
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
71
|
+
size += bytes.length;
|
|
72
|
+
if (size >= limit) return;
|
|
73
|
+
chunks.push(bytes);
|
|
74
|
+
}
|
|
75
|
+
return Buffer.concat(chunks, size);
|
|
76
|
+
}
|
|
77
|
+
/** No queue: at capacity a future request may try again. */
|
|
78
|
+
function schedule(storage, state, hash, original) {
|
|
79
|
+
if (state.jobs.has(hash) || state.jobs.size >= MAX_JOBS) return;
|
|
80
|
+
const job = generate(storage, hash, original).catch(() => {}).finally(() => {
|
|
81
|
+
state.jobs.delete(hash);
|
|
82
|
+
});
|
|
83
|
+
state.jobs.set(hash, job);
|
|
84
|
+
}
|
|
85
|
+
async function generate(storage, hash, original) {
|
|
86
|
+
if (createHash("sha256").update(original).digest("hex") !== hash) return;
|
|
87
|
+
const source = `content/${hash}`;
|
|
88
|
+
if (!await storage.exists(source)) return;
|
|
89
|
+
const compressed = await compress(original);
|
|
90
|
+
if (compressed.length >= original.length || !await storage.exists(source)) return;
|
|
91
|
+
const path = publishedGzipPath(hash);
|
|
92
|
+
await storage.write(path, compressed);
|
|
93
|
+
if (!await storage.exists(source)) await storage.delete(path);
|
|
94
|
+
}
|
|
95
|
+
async function withDeadline(promise) {
|
|
96
|
+
let timer;
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([promise, new Promise((resolve) => {
|
|
99
|
+
timer = setTimeout(() => {
|
|
100
|
+
resolve("timeout");
|
|
101
|
+
}, READ_TIMEOUT_MS);
|
|
102
|
+
})]);
|
|
103
|
+
} finally {
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export { acceptsGzip, publishedGzip };
|
|
108
|
+
|
|
109
|
+
//# sourceMappingURL=published-compression.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"published-compression.mjs","names":[],"sources":["../../src/routers/published-compression.ts"],"sourcesContent":["/** Disposable, content-addressed gzip variants of authorized published files. */\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport { publishedGzipPath } from \"@storyshelf/core/utils\";\nimport { createHash } from \"node:crypto\";\nimport { promisify } from \"node:util\";\nimport { gunzip, gzip } from \"node:zlib\";\n\nconst compress = promisify(gzip);\nconst decompress = promisify(gunzip);\nconst MAX_FILE_BYTES = 8 * 1024 * 1024;\nconst MAX_JOBS = 2;\nconst MAX_READS = 4;\nconst READ_TIMEOUT_MS = 250;\nconst states = new WeakMap<StorageAdapter, State>();\n\ninterface State {\n jobs: Map<string, Promise<void>>;\n reads: number;\n}\n\n/** Gzip is chosen only if explicitly accepted, or by a nonzero wildcard. */\nexport function acceptsGzip(header?: string): boolean {\n if (!header) return false;\n const entries = header.split(\",\").map((part) => part.trim().split(\";\"));\n const entry =\n entries.find(([name]) => name?.trim().toLowerCase() === \"gzip\") ??\n entries.find(([name]) => name?.trim() === \"*\");\n if (!entry) return false;\n const quality = entry.slice(1).find((part) => /^\\s*q\\s*=/iu.test(part));\n if (quality === undefined) return true;\n const value = Number(quality.split(\"=\")[1]);\n return Number.isFinite(value) && value > 0 && value <= 1;\n}\n\n/** Return a verified existing variant, or the original while a bounded background job runs. */\nexport async function publishedGzip(\n storage: StorageAdapter,\n hash: string | undefined,\n original: Buffer,\n mime: string,\n): Promise<Buffer | undefined> {\n if (typeof hash !== \"string\" || !eligible(hash, original, mime)) return undefined;\n const state = stateOf(storage);\n if (state.reads >= MAX_READS) return undefined;\n state.reads += 1;\n const read = findVariant(storage, hash, original).finally(() => {\n state.reads -= 1;\n });\n const found = await withDeadline(read);\n if (found === \"timeout\") return undefined;\n if (found) return found;\n schedule(storage, state, hash, original);\n return undefined;\n}\n\nfunction eligible(hash: string | undefined, original: Buffer, mime: string): boolean {\n return (\n typeof hash === \"string\" &&\n /^[a-f0-9]{64}$/u.test(hash) &&\n original.length > 0 &&\n original.length <= MAX_FILE_BYTES &&\n /^(?:text\\/|application\\/(?:javascript|json|xml|wasm)|image\\/svg\\+xml)/iu.test(mime)\n );\n}\n\nfunction stateOf(storage: StorageAdapter): State {\n let state = states.get(storage);\n if (!state) {\n state = { jobs: new Map(), reads: 0 };\n states.set(storage, state);\n }\n return state;\n}\n\nasync function findVariant(\n storage: StorageAdapter,\n hash: string,\n original: Buffer,\n): Promise<Buffer | undefined> {\n try {\n const path = publishedGzipPath(hash);\n if (!(await storage.exists(path))) return undefined;\n const bytes = await readBounded(storage, path, original.length);\n if (!bytes || bytes.length === 0) return undefined;\n const decoded = await decompress(bytes, { maxOutputLength: original.length });\n return decoded.equals(original) ? bytes : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** Do not allocate an unbounded buffer when a damaged storage object is oversized. */\nasync function readBounded(\n storage: StorageAdapter,\n path: string,\n limit: number,\n): Promise<Buffer | undefined> {\n const chunks: Buffer[] = [];\n let size = 0;\n for await (const chunk of await storage.readStream(path)) {\n const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);\n size += bytes.length;\n if (size >= limit) {\n return undefined;\n }\n chunks.push(bytes);\n }\n return Buffer.concat(chunks, size);\n}\n\n/** No queue: at capacity a future request may try again. */\nfunction schedule(storage: StorageAdapter, state: State, hash: string, original: Buffer): void {\n if (state.jobs.has(hash) || state.jobs.size >= MAX_JOBS) return;\n const job = generate(storage, hash, original)\n .catch(() => {})\n .finally(() => {\n state.jobs.delete(hash);\n });\n state.jobs.set(hash, job);\n}\n\nasync function generate(storage: StorageAdapter, hash: string, original: Buffer): Promise<void> {\n if (createHash(\"sha256\").update(original).digest(\"hex\") !== hash) return;\n const source = `content/${hash}`;\n if (!(await storage.exists(source))) return;\n const compressed = await compress(original);\n if (compressed.length >= original.length || !(await storage.exists(source))) return;\n const path = publishedGzipPath(hash);\n await storage.write(path, compressed);\n // A purge can remove the original while compression is in flight.\n if (!(await storage.exists(source))) await storage.delete(path);\n}\n\nasync function withDeadline<T>(promise: Promise<T>): Promise<T | \"timeout\"> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n try {\n return await Promise.race([\n promise,\n new Promise<\"timeout\">((resolve) => {\n timer = setTimeout(() => {\n resolve(\"timeout\");\n }, READ_TIMEOUT_MS);\n }),\n ]);\n } finally {\n if (timer) clearTimeout(timer);\n }\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,WAAW,UAAU,IAAI;AAC/B,MAAM,aAAa,UAAU,MAAM;AACnC,MAAM,iBAAiB;AACvB,MAAM,WAAW;AACjB,MAAM,YAAY;AAClB,MAAM,kBAAkB;AACxB,MAAM,yBAAS,IAAI,QAA+B;;AAQlD,SAAgB,YAAY,QAA0B;CACpD,IAAI,CAAC,QAAQ,OAAO;CACpB,MAAM,UAAU,OAAO,MAAM,GAAG,CAAC,CAAC,KAAK,SAAS,KAAK,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC;CACtE,MAAM,QACJ,QAAQ,MAAM,CAAC,UAAU,MAAM,KAAK,CAAC,CAAC,YAAY,MAAM,MAAM,KAC9D,QAAQ,MAAM,CAAC,UAAU,MAAM,KAAK,MAAM,GAAG;CAC/C,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,UAAU,MAAM,MAAM,CAAC,CAAC,CAAC,MAAM,SAAS,cAAc,KAAK,IAAI,CAAC;CACtE,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,MAAM,QAAQ,OAAO,QAAQ,MAAM,GAAG,CAAC,CAAC,EAAE;CAC1C,OAAO,OAAO,SAAS,KAAK,KAAK,QAAQ,KAAK,SAAS;AACzD;;AAGA,eAAsB,cACpB,SACA,MACA,UACA,MAC6B;CAC7B,IAAI,OAAO,SAAS,YAAY,CAAC,SAAS,MAAM,UAAU,IAAI,GAAG,OAAO,KAAA;CACxE,MAAM,QAAQ,QAAQ,OAAO;CAC7B,IAAI,MAAM,SAAS,WAAW,OAAO,KAAA;CACrC,MAAM,SAAS;CAIf,MAAM,QAAQ,MAAM,aAHP,YAAY,SAAS,MAAM,QAAQ,CAAC,CAAC,cAAc;EAC9D,MAAM,SAAS;CACjB,CACoC,CAAC;CACrC,IAAI,UAAU,WAAW,OAAO,KAAA;CAChC,IAAI,OAAO,OAAO;CAClB,SAAS,SAAS,OAAO,MAAM,QAAQ;AAEzC;AAEA,SAAS,SAAS,MAA0B,UAAkB,MAAuB;CACnF,OACE,OAAO,SAAS,YAChB,kBAAkB,KAAK,IAAI,KAC3B,SAAS,SAAS,KAClB,SAAS,UAAU,kBACnB,0EAA0E,KAAK,IAAI;AAEvF;AAEA,SAAS,QAAQ,SAAgC;CAC/C,IAAI,QAAQ,OAAO,IAAI,OAAO;CAC9B,IAAI,CAAC,OAAO;EACV,QAAQ;GAAE,sBAAM,IAAI,IAAI;GAAG,OAAO;EAAE;EACpC,OAAO,IAAI,SAAS,KAAK;CAC3B;CACA,OAAO;AACT;AAEA,eAAe,YACb,SACA,MACA,UAC6B;CAC7B,IAAI;EACF,MAAM,OAAO,kBAAkB,IAAI;EACnC,IAAI,CAAE,MAAM,QAAQ,OAAO,IAAI,GAAI,OAAO,KAAA;EAC1C,MAAM,QAAQ,MAAM,YAAY,SAAS,MAAM,SAAS,MAAM;EAC9D,IAAI,CAAC,SAAS,MAAM,WAAW,GAAG,OAAO,KAAA;EAEzC,QAAO,MADe,WAAW,OAAO,EAAE,iBAAiB,SAAS,OAAO,CAAC,EAAA,CAC7D,OAAO,QAAQ,IAAI,QAAQ,KAAA;CAC5C,QAAQ;EACN;CACF;AACF;;AAGA,eAAe,YACb,SACA,MACA,OAC6B;CAC7B,MAAM,SAAmB,CAAC;CAC1B,IAAI,OAAO;CACX,WAAW,MAAM,SAAS,MAAM,QAAQ,WAAW,IAAI,GAAG;EACxD,MAAM,QAAQ,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAmB;EAC9E,QAAQ,MAAM;EACd,IAAI,QAAQ,OACV;EAEF,OAAO,KAAK,KAAK;CACnB;CACA,OAAO,OAAO,OAAO,QAAQ,IAAI;AACnC;;AAGA,SAAS,SAAS,SAAyB,OAAc,MAAc,UAAwB;CAC7F,IAAI,MAAM,KAAK,IAAI,IAAI,KAAK,MAAM,KAAK,QAAQ,UAAU;CACzD,MAAM,MAAM,SAAS,SAAS,MAAM,QAAQ,CAAC,CAC1C,YAAY,CAAC,CAAC,CAAC,CACf,cAAc;EACb,MAAM,KAAK,OAAO,IAAI;CACxB,CAAC;CACH,MAAM,KAAK,IAAI,MAAM,GAAG;AAC1B;AAEA,eAAe,SAAS,SAAyB,MAAc,UAAiC;CAC9F,IAAI,WAAW,QAAQ,CAAC,CAAC,OAAO,QAAQ,CAAC,CAAC,OAAO,KAAK,MAAM,MAAM;CAClE,MAAM,SAAS,WAAW;CAC1B,IAAI,CAAE,MAAM,QAAQ,OAAO,MAAM,GAAI;CACrC,MAAM,aAAa,MAAM,SAAS,QAAQ;CAC1C,IAAI,WAAW,UAAU,SAAS,UAAU,CAAE,MAAM,QAAQ,OAAO,MAAM,GAAI;CAC7E,MAAM,OAAO,kBAAkB,IAAI;CACnC,MAAM,QAAQ,MAAM,MAAM,UAAU;CAEpC,IAAI,CAAE,MAAM,QAAQ,OAAO,MAAM,GAAI,MAAM,QAAQ,OAAO,IAAI;AAChE;AAEA,eAAe,aAAgB,SAA6C;CAC1E,IAAI;CACJ,IAAI;EACF,OAAO,MAAM,QAAQ,KAAK,CACxB,SACA,IAAI,SAAoB,YAAY;GAClC,QAAQ,iBAAiB;IACvB,QAAQ,SAAS;GACnB,GAAG,eAAe;EACpB,CAAC,CACH,CAAC;CACH,UAAU;EACR,IAAI,OAAO,aAAa,KAAK;CAC/B;AACF"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { publishedCorsHeaders } from "./published-access.mjs";
|
|
6
|
+
import { SIGNED_NO_STORE, signedCacheControl } from "./capture-cache.mjs";
|
|
7
|
+
import { acceptsGzip, publishedGzip } from "./published-compression.mjs";
|
|
8
|
+
import { isSafeSegment, mimeFor } from "@storyshelf/core/utils";
|
|
9
|
+
import { readPublishedFileInfo } from "@storyshelf/core/capture";
|
|
10
|
+
// src/routers/published-files.ts
|
|
11
|
+
/**
|
|
12
|
+
* Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes
|
|
13
|
+
* and checks a request path, reads the file through the build's manifest and sets the headers.
|
|
14
|
+
* The published Storybook routes (`/projects/:slug/storybook/build/:id/*`, `/_/:id/*`) and the
|
|
15
|
+
* signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}
|
|
16
|
+
* granted.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Headers of every signed URL answer: a token URL must not be indexed or leaked by `Referer`, and
|
|
20
|
+
* is `no-store` here. Only a successful answer for a capture grant may replace `cache-control`
|
|
21
|
+
* (`signedCacheControl`); an error always sends these as they are.
|
|
22
|
+
*/
|
|
23
|
+
const SIGNED_URL_HEADERS = {
|
|
24
|
+
"cache-control": SIGNED_NO_STORE,
|
|
25
|
+
"referrer-policy": "no-referrer",
|
|
26
|
+
"x-robots-tag": "noindex, nofollow",
|
|
27
|
+
"x-content-type-options": "nosniff"
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The file path after `base`, decoded once. `""` is the root; `null` when the path is not under
|
|
31
|
+
* `base` or could leave the build: empty, `.` or `..` segments, backslashes and NUL are refused.
|
|
32
|
+
*/
|
|
33
|
+
function publishedRelativePath(path, base) {
|
|
34
|
+
if (!path.startsWith(base)) return null;
|
|
35
|
+
let rest;
|
|
36
|
+
try {
|
|
37
|
+
rest = decodeURIComponent(path.slice(base.length));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return rest === "" || rest.split("/").every((segment) => segment !== "" && segment !== "." && !segment.includes("\0") && isSafeSegment(segment)) ? rest : null;
|
|
42
|
+
}
|
|
43
|
+
/** The file as a response, or `null` when the build has no such file. */
|
|
44
|
+
async function servePublishedFile(c, request) {
|
|
45
|
+
const { projectId, buildId, rel, access } = request;
|
|
46
|
+
const { storage, config } = getStore();
|
|
47
|
+
const file = await readPublishedFileInfo(storage, projectId, buildId, rel);
|
|
48
|
+
if (!file) return null;
|
|
49
|
+
const headers = access === "token" ? {
|
|
50
|
+
...SIGNED_URL_HEADERS,
|
|
51
|
+
"cache-control": signedCacheControl({
|
|
52
|
+
cacheMaxAgeSeconds: config.captureAssets?.cacheMaxAgeSeconds,
|
|
53
|
+
grant: request.grant,
|
|
54
|
+
immutable: file.contentHash !== void 0
|
|
55
|
+
})
|
|
56
|
+
} : { "cache-control": request.cacheControl };
|
|
57
|
+
const mime = mimeFor(rel);
|
|
58
|
+
const compressed = config.publishedCompression && acceptsGzip(c.req.header("accept-encoding")) ? await publishedGzip(storage, file.contentHash, file.bytes, mime) : void 0;
|
|
59
|
+
return c.body(new Uint8Array(compressed ?? file.bytes), 200, {
|
|
60
|
+
...headers,
|
|
61
|
+
...publishedCorsHeaders(access),
|
|
62
|
+
"content-type": mime,
|
|
63
|
+
...config.publishedCompression ? { vary: "Accept-Encoding" } : {},
|
|
64
|
+
...compressed ? { "content-encoding": "gzip" } : {}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export { SIGNED_URL_HEADERS, publishedRelativePath, servePublishedFile };
|
|
68
|
+
|
|
69
|
+
//# sourceMappingURL=published-files.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"published-files.mjs","names":[],"sources":["../../src/routers/published-files.ts"],"sourcesContent":["/**\n * Serving a build's published files (ADR 0010, ADR 0007 decision 5): the single place that decodes\n * and checks a request path, reads the file through the build's manifest and sets the headers.\n * The published Storybook routes (`/projects/:slug/storybook/build/:id/*`, `/_/:id/*`) and the\n * signed URL route (`/_s/:token/*`) all end here, each bringing the access {@link publishedAccess}\n * granted.\n */\nimport { readPublishedFileInfo } from \"@storyshelf/core/capture\";\nimport { isSafeSegment, mimeFor, type StorybookTokenClaims } from \"@storyshelf/core/utils\";\nimport type { Context } from \"hono\";\nimport { getStore } from \"../store.ts\";\nimport { signedCacheControl, SIGNED_NO_STORE } from \"./capture-cache.ts\";\nimport { publishedCorsHeaders, type PublishedAccess } from \"./published-access.ts\";\nimport { acceptsGzip, publishedGzip } from \"./published-compression.ts\";\n\n/**\n * Headers of every signed URL answer: a token URL must not be indexed or leaked by `Referer`, and\n * is `no-store` here. Only a successful answer for a capture grant may replace `cache-control`\n * (`signedCacheControl`); an error always sends these as they are.\n */\nexport const SIGNED_URL_HEADERS = {\n \"cache-control\": SIGNED_NO_STORE,\n \"referrer-policy\": \"no-referrer\",\n \"x-robots-tag\": \"noindex, nofollow\",\n \"x-content-type-options\": \"nosniff\",\n} as const;\n\n/**\n * The file path after `base`, decoded once. `\"\"` is the root; `null` when the path is not under\n * `base` or could leave the build: empty, `.` or `..` segments, backslashes and NUL are refused.\n */\nexport function publishedRelativePath(path: string, base: string): string | null {\n if (!path.startsWith(base)) {\n return null;\n }\n let rest: string;\n try {\n rest = decodeURIComponent(path.slice(base.length));\n } catch {\n return null;\n }\n const safe =\n rest === \"\" ||\n rest\n .split(\"/\")\n .every(\n (segment) =>\n segment !== \"\" && segment !== \".\" && !segment.includes(\"\\0\") && isSafeSegment(segment),\n );\n return safe ? rest : null;\n}\n\n/** Where a file of a build lives and how the request got to it. */\nexport interface PublishedFileRequest {\n projectId: string;\n buildId: string;\n /** A path accepted by {@link publishedRelativePath}, not the root. */\n rel: string;\n access: PublishedAccess;\n /** `Cache-Control` of a normal answer; a signed URL answers `signedCacheControl` instead. */\n cacheControl: string;\n /** The verified claims of a signed URL's token; decides whether its answer may be fresh. */\n grant?: StorybookTokenClaims | undefined;\n}\n\n/** The file as a response, or `null` when the build has no such file. */\nexport async function servePublishedFile(\n c: Context,\n request: PublishedFileRequest,\n): Promise<Response | null> {\n const { projectId, buildId, rel, access } = request;\n const { storage, config } = getStore();\n const file = await readPublishedFileInfo(storage, projectId, buildId, rel);\n if (!file) {\n return null;\n }\n const headers =\n access === \"token\"\n ? {\n ...SIGNED_URL_HEADERS,\n \"cache-control\": signedCacheControl({\n cacheMaxAgeSeconds: config.captureAssets?.cacheMaxAgeSeconds,\n grant: request.grant,\n immutable: file.contentHash !== undefined,\n }),\n }\n : { \"cache-control\": request.cacheControl };\n const mime = mimeFor(rel);\n const compressed =\n config.publishedCompression && acceptsGzip(c.req.header(\"accept-encoding\"))\n ? await publishedGzip(storage, file.contentHash, file.bytes, mime)\n : undefined;\n return c.body(new Uint8Array(compressed ?? file.bytes), 200, {\n ...headers,\n ...publishedCorsHeaders(access),\n \"content-type\": mime,\n ...(config.publishedCompression ? { vary: \"Accept-Encoding\" } : {}),\n ...(compressed ? { \"content-encoding\": \"gzip\" } : {}),\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAoBA,MAAa,qBAAqB;CAChC,iBAAiB;CACjB,mBAAmB;CACnB,gBAAgB;CAChB,0BAA0B;AAC5B;;;;;AAMA,SAAgB,sBAAsB,MAAc,MAA6B;CAC/E,IAAI,CAAC,KAAK,WAAW,IAAI,GACvB,OAAO;CAET,IAAI;CACJ,IAAI;EACF,OAAO,mBAAmB,KAAK,MAAM,KAAK,MAAM,CAAC;CACnD,QAAQ;EACN,OAAO;CACT;CASA,OAPE,SAAS,MACT,KACG,MAAM,GAAG,CAAC,CACV,OACE,YACC,YAAY,MAAM,YAAY,OAAO,CAAC,QAAQ,SAAS,IAAI,KAAK,cAAc,OAAO,CACzF,IACU,OAAO;AACvB;;AAgBA,eAAsB,mBACpB,GACA,SAC0B;CAC1B,MAAM,EAAE,WAAW,SAAS,KAAK,WAAW;CAC5C,MAAM,EAAE,SAAS,WAAW,SAAS;CACrC,MAAM,OAAO,MAAM,sBAAsB,SAAS,WAAW,SAAS,GAAG;CACzE,IAAI,CAAC,MACH,OAAO;CAET,MAAM,UACJ,WAAW,UACP;EACE,GAAG;EACH,iBAAiB,mBAAmB;GAClC,oBAAoB,OAAO,eAAe;GAC1C,OAAO,QAAQ;GACf,WAAW,KAAK,gBAAgB,KAAA;EAClC,CAAC;CACH,IACA,EAAE,iBAAiB,QAAQ,aAAa;CAC9C,MAAM,OAAO,QAAQ,GAAG;CACxB,MAAM,aACJ,OAAO,wBAAwB,YAAY,EAAE,IAAI,OAAO,iBAAiB,CAAC,IACtE,MAAM,cAAc,SAAS,KAAK,aAAa,KAAK,OAAO,IAAI,IAC/D,KAAA;CACN,OAAO,EAAE,KAAK,IAAI,WAAW,cAAc,KAAK,KAAK,GAAG,KAAK;EAC3D,GAAG;EACH,GAAG,qBAAqB,MAAM;EAC9B,gBAAgB;EAChB,GAAI,OAAO,uBAAuB,EAAE,MAAM,kBAAkB,IAAI,CAAC;EACjE,GAAI,aAAa,EAAE,oBAAoB,OAAO,IAAI,CAAC;CACrD,CAAC;AACH"}
|